
バイブコーディングで現場で実際に使えるアプリを数時間で開発することが可能になった今、プロトタイプのアプリを作ることよりも実際に使ってみて、不具合や改良点、バグを見つけて修正する作業の方が圧倒的に時間がかかります。AIエージェントが発達すれば、友達に話しかけるようにできるようになるのかもしれませんが、現状ではデバッグ~プログラムやコードに含まれるエラーやバグを特定しそれを修正する作業~が実際の運用前に必要不可欠です。
これまで本ブログでも紹介してきた内製『訪問記録アプリ』について、今回は、AIにデバッグの協力をお願いした内容を記事にしてみました。

アップロードしたファイルは、GoogleスプレッドシートのGoogle Apps ScriptのWebアプリのファイルです。このアプリは、訪問系障害福祉サービスの訪問記録のアプリです。このファイルについて、以下の項目を修正して各ファイルを書き直してください。指示した箇所以外は絶対に改変しないでください。
①現状では、「保存して終了」ボタンを押さないとサービス開始時間が記録されないため、「開始打刻」ボタンを押すとサービス開始時間がスプレッドシートに送信され記録されるようにする。
②各日のシフトについて、スプレッドシートの順で遅い支援時間のシフトが早い時間のシフトより上に表示されてしまうが、当該日について、たとえスプレッドシートのShiftの時刻の順序が違っていても、時系列に表示されるようにする。
③各利用者の記録画面について、一番上に当該シフトの一つ前に入った支援の記録が、「前回サービス提供時の記録」として「指示に対する報告」と「特記事項・申し送り」が表示されるようにしているが、当該シフトの一つ前の記録ではなく、最後に入力された記録になっている。つまり、1月1日の記録画面の「前回サービス提供時の記録」は12月31日の記録が表示されるのではなく、1月10日に入力した記録が更新されて表示されてしまう。後から過去の記録を読み返しても、必ず当該シフトの一つ前の記録が「前回サービス提供時の記録」として確定して表示されるようにする。
④記録をいったん入力して「保存して終了」ボタンを押すとスプレッドシートに転記されますが、行動援護など、支援中に記録を書いていったん保存して、支援が終了した時点で改めて書き直してから「保存して終了」ボタンを押すことも多々あるので、「保存して終了」ボタンを押した時刻がサービス終了時間として記録される仕組みは良いと思うが、翌日に再び書き直した場合、何らかのエラーが生じないか、コード全体を見渡して何らかの課題があれば教えてください。この項目については、内容を確認してから指示を出しますので、コードの修正はしないでください。④について、問題点や課題だけを教えてください。
⑤他に何か、問題点や課題などがある場合はレビューして教えてください。こちらから改めて指示しますので、⑤について、許可するまでコードの修正はしないでください。①②③はコードの修正をを願いします。

ご提示いただいた要件に基づき、Index.htmlとCode.gsを修正しました。また、後半に④と⑤のご質問に対する回答(分析レポート)を記載します。
修正内容の概要
- 開始打刻の即時送信 (Index.html / Code.gs):
stampStart関数でsaveStartRecord(新規追加)を呼び出し、ボタン押下時にスプレッドシートに記録を作成(または更新)するようにしました。 - シフトの時系列表示 (Code.gs):
getShiftsByDay関数内で、取得したシフト配列をstart時刻でソートしてから返すように変更しました。 - 前回記録のロジック修正 (Code.gs):
getReportDetails関数内で、単純に「最後に書かれた日誌」ではなく、「対象シフトの日時より過去にある、最も新しい日誌」を日時比較で特定するようにロジックを全面的に書き換えました。
Index.html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
<style>
:root { --main: #0d47a1; --sub: #e3f2fd; --sun: #ffebee; --sat: #e3f2fd; }
body { background-color: #f4f7f6; font-family: sans-serif; padding-bottom: 110px; }
.nav-bar { background: var(--main); color: white; padding: 15px; position: sticky; top: 0; z-index: 1000; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); background: white; border: 1px solid #ddd; }
.cal-header-cell { text-align: center; font-size: 0.75rem; font-weight: bold; padding: 8px 0; background: #f8f9fa; border: 0.5px solid #ddd; }
.cal-day { min-height: 65px; border: 0.5px solid #eee; padding: 4px; position: relative; cursor: pointer; }
.text-sun { color: #d32f2f; }
.text-sat { color: #0d47a1; }
.bg-sun { background-color: var(--sun); }
.bg-sat { background-color: var(--sat); }
.badge-num { position: absolute; bottom: 4px; right: 4px; background: #d32f2f; color: white; border-radius: 10px; padding: 1px 6px; font-size: 0.7rem; }
.card-shift { border-radius: 12px; border-left: 6px solid var(--main); cursor: pointer; margin-bottom: 12px; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
.cat-header { background: var(--sub); color: var(--main); font-weight: bold; padding: 6px 12px; border-radius: 6px; margin-top: 15px; font-size: 0.85rem; }
.check-row { border-bottom: 1px solid #f0f0f0; padding: 10px 0; display: flex; align-items: center; }
.sticky-footer { position: fixed; bottom: 0; left: 0; right: 0; background: white; padding: 15px; border-top: 1px solid #ddd; z-index: 1000; }
.prev-box { background: #fffde7; border: 1px solid #fff59d; border-radius: 8px; padding: 10px; font-size: 0.85rem; margin-bottom: 15px; }
.service-header { background: #fff; border-bottom: 2px solid var(--main); padding: 10px; margin-bottom: 15px; border-radius: 4px; }
</style>
</head>
<body>
<div class="nav-bar d-flex justify-content-between align-items-center">
<span class="fw-bold">Google Apps Script 訪問記録アプリ</span>
<span id="display-user" class="small"></span>
</div>
<div class="container py-3">
<div id="view-login" class="card p-4 shadow-sm mx-auto mt-5" style="max-width: 400px; border:none;">
<h5 class="text-center mb-4 fw-bold">スタッフログイン</h5>
<input id="login-name" type="text" class="form-control mb-2" placeholder="サポーター氏名">
<input id="login-pass" type="password" class="form-control mb-4" placeholder="パスワード">
<button onclick="login()" class="btn btn-primary w-100 py-2">ログイン</button>
</div>
<div id="view-calendar" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<button onclick="changeMonth(-1)" class="btn btn-sm btn-outline-primary">前月</button>
<h6 id="cal-title" class="mb-0 fw-bold"></h6>
<button onclick="changeMonth(1)" class="btn btn-sm btn-outline-primary">翌月</button>
</div>
<div id="cal-body" class="cal-grid mb-4 shadow-sm rounded overflow-hidden"></div>
<div id="day-shifts-list"></div>
</div>
<div id="view-report" style="display:none;">
<button onclick="showCalendar()" class="btn btn-sm btn-link mb-2 p-0 text-decoration-none">← 戻る</button>
<div class="service-header shadow-sm">
<div class="fw-bold text-primary" id="hdr-user" style="font-size:1.1rem;"></div>
<div class="d-flex justify-content-between align-items-center">
<span id="hdr-type" class="badge bg-secondary"></span>
<span id="hdr-time" class="small text-muted fw-bold"></span>
</div>
</div>
<div class="card p-3 shadow-sm border-0">
<div id="prev-info" class="prev-box d-none">
<div class="fw-bold text-dark border-bottom pb-1 mb-2">【前回サービス提供時の記録】</div>
<div id="prev-meta" class="text-muted mb-1" style="font-size:0.75rem;"></div>
<div class="mb-1"><strong>指示に対する報告:</strong> <span id="prev-report"></span></div>
<div><strong>特記事項・申し送り:</strong> <span id="prev-note"></span></div>
</div>
<div class="alert alert-info py-2 mb-3">
<small class="fw-bold d-block border-bottom mb-1">サービス提供責任者からの指示内容</small>
<div id="inst-box" style="white-space: pre-wrap; font-size: 0.9rem;"></div>
</div>
<div class="bg-light p-3 rounded mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small fw-bold text-muted">サービス開始時間・終了時間</span>
<span id="lock-badge" class="badge bg-secondary d-none">記録済み</span>
</div>
<div class="d-flex align-items-center gap-3">
<button id="btn-start" onclick="stampStart()" class="btn btn-success fw-bold">開始打刻</button>
<div id="start-display" class="fs-4 fw-bold text-primary">--:--</div>
<div class="fs-4 text-muted">~</div>
<div id="end-display" class="fs-4 fw-bold text-primary">--:--</div>
</div>
</div>
<div id="checklist-container"></div>
<div class="mt-4">
<label class="fw-bold small mb-1">サービス提供責任者からの指示に対する報告</label>
<textarea id="rep-text" class="form-control mb-3" rows="3"></textarea>
<label class="fw-bold small mb-1">特記事項・申し送り</label>
<textarea id="note-text" class="form-control mb-3" rows="3"></textarea>
</div>
<div class="sticky-footer">
<button id="btn-submit" onclick="submit()" class="btn btn-primary w-100 py-3 fw-bold rounded-pill">保存して終了</button>
</div>
</div>
</div>
</div>
<script>
var helper = "";
var curMonth = new Date();
var dayShifts = [];
var currentIdx = -1;
var currentDayStr = "";
var startTimeValue = "";
var cats = {
"事前": ["予定変更あり", "予定変更なし", "顔色良", "顔色不良"],
"排泄": ["トイレ介助", "おむつ交換", "パッド交換", "陰部清潔介助", "排尿あり", "排便あり"],
"食事": ["食事介助", "水分補給"],
"清保": ["入浴介助", "シャワー浴", "洗髪", "足浴", "全身清拭", "部分清拭"],
"整容": ["口腔ケア", "更衣介助", "髭剃り"],
"移動": ["体位交換", "移乗介助", "移動介助", "外出準備", "帰宅受入", "通院介助", "買物介助"],
"朝夕": ["起床介助", "就寝介助"],
"医療": ["服薬介助", "服薬確認", "薬の塗布", "点眼"],
"自立": ["共に行う家事", "共に行う身体", "見守り"],
"家事": ["掃除", "洗濯", "調理", "買物代行"]
};
function login() {
var n = document.getElementById('login-name').value;
var p = document.getElementById('login-pass').value;
google.script.run.withSuccessHandler(function(r) {
if(r.success) {
helper = r.helperName;
document.getElementById('display-user').innerText = helper + "さん";
showCalendar();
} else { alert("ログイン失敗"); }
}).checkLogin(n, p);
}
function showCalendar() {
document.getElementById('view-login').style.display = 'none';
document.getElementById('view-calendar').style.display = 'block';
document.getElementById('view-report').style.display = 'none';
renderCal();
}
function renderCal() {
var y = curMonth.getFullYear(), m = curMonth.getMonth();
document.getElementById('cal-title').innerText = y + "年 " + (m+1) + "月";
google.script.run.withSuccessHandler(function(res) {
var counts = res.counts, holidays = res.holidays;
var daysHeader = ['日','月','火','水','木','金','土'];
var h = "";
for(var i=0; i<7; i++) {
var cls = i===0 ? 'text-sun' : (i===6 ? 'text-sat' : '');
h += '<div class="cal-header-cell ' + cls + '">' + daysHeader[i] + '</div>';
}
var startDay = new Date(y, m, 1).getDay();
var lastDate = new Date(y, m+1, 0).getDate();
for(var i=0; i<startDay; i++) h += '<div class="cal-day bg-light"></div>';
for(var d=1; d<=lastDate; d++) {
var ds = y + "-" + ("0"+(m+1)).slice(-2) + "-" + ("0"+d).slice(-2);
var w = (startDay + d - 1) % 7;
var isHoli = holidays.indexOf(ds) !== -1;
var cellCls = "cal-day";
if (w === 0 || isHoli) cellCls += " text-sun bg-sun";
else if (w === 6) cellCls += " text-sat bg-sat";
h += '<div class="' + cellCls + '" onclick="loadDay(\'' + ds + '\')"><span>' + d + '</span>';
if(counts[ds]) h += '<span class="badge-num">' + counts[ds] + '</span>';
h += '</div>';
}
document.getElementById('cal-body').innerHTML = h;
}).getCalendarData(helper, y, m);
}
function loadDay(ds) {
currentDayStr = ds;
google.script.run.withSuccessHandler(function(data) {
dayShifts = data;
var h = '<h6 class="fw-bold border-bottom pb-2 mt-2">' + ds + '</h6>';
data.forEach(function(s, i) {
h += '<div class="card card-shift p-3 shadow-sm" onclick="openReport(' + i + ')">' +
'<div class="d-flex justify-content-between small mb-1"><span class="fw-bold text-primary">' + s.start + '-' + s.end + '</span>' +
'<span class="badge ' + (s.status==='完了'?'bg-success':'bg-warning text-dark') + '">' + s.status + '</span></div>' +
'<div class="fw-bold">' + s.user + ' 様 <span class="small text-muted fw-normal">(' + s.type + ')</span></div></div>';
});
document.getElementById('day-shifts-list').innerHTML = h || '<p class="text-center text-muted small my-4">予定なし</p>';
}).getShiftsByDay(helper, ds);
}
function openReport(idx) {
currentIdx = idx;
var s = dayShifts[idx];
var today = new Date();
var todayStr = today.getFullYear() + "-" + ("0"+(today.getMonth()+1)).slice(-2) + "-" + ("0"+today.getDate()).slice(-2);
// 過去日編集の制限等は運用によるが、ここでは未来日のブロックのみ残す
if(s.date > todayStr) { alert("当日の記録のみ入力可能です。"); return; }
document.getElementById('view-calendar').style.display = 'none';
document.getElementById('view-report').style.display = 'block';
document.getElementById('hdr-user').innerText = s.user + " 様";
document.getElementById('hdr-type').innerText = s.type;
document.getElementById('hdr-time').innerText = s.start + " ~ " + s.end;
document.getElementById('rep-text').value = "";
document.getElementById('note-text').value = "";
document.getElementById('start-display').innerText = "--:--";
document.getElementById('end-display').innerText = "--:--";
document.getElementById('btn-start').style.display = 'block';
document.getElementById('btn-start').disabled = false;
document.getElementById('lock-badge').classList.add('d-none');
document.getElementById('prev-info').classList.add('d-none');
startTimeValue = "";
document.getElementById('inst-box').innerText = s.instruction;
google.script.run.withSuccessHandler(function(res) {
if(res.prev) {
document.getElementById('prev-info').classList.remove('d-none');
document.getElementById('prev-meta').innerText = "支援日時: " + res.prev.dateTime + " / 担当: " + res.prev.helper;
document.getElementById('prev-report').innerText = res.prev.report || "なし";
document.getElementById('prev-note').innerText = res.prev.note || "なし";
}
if(res.cur) {
document.getElementById('rep-text').value = res.cur.report;
document.getElementById('note-text').value = res.cur.note;
document.getElementById('start-display').innerText = res.cur.startT;
document.getElementById('end-display').innerText = res.cur.endT;
// 開始時間が既に記録されている場合はボタンを隠す
if(res.cur.startT) {
document.getElementById('btn-start').style.display = 'none';
startTimeValue = "LOCKED";
}
// 終了時間まで記録されている(=完了)ならロックバッジ表示
if(res.cur.endT) {
document.getElementById('lock-badge').classList.remove('d-none');
}
}
renderChecks(res.cur);
}).getReportDetails(s.id, s.user);
}
function renderChecks(cur) {
var h = "";
for(var cat in cats) {
h += '<div class="cat-header">' + cat + '</div>';
cats[cat].forEach(function(item) {
var isChecked = (cur && cur.checks.indexOf(item) !== -1) ? "checked" : "";
h += '<div class="check-row"><input type="checkbox" class="form-check-input me-3 check-opt" value="' + item + '" id="i-' + item + '" ' + isChecked + '><label for="i-' + item + '" class="w-100">' + item + '</label></div>';
if(item==='調理') h += '<input type="text" id="ex-menu" class="form-control form-control-sm my-2" placeholder="献立" value="' + (cur?cur.menu:'') + '">';
if(item==='買物代行') {
h += '<div class="bg-white border p-2 rounded row g-1 my-2">' +
'<div class="col-4"><label class="small fw-bold">預り</label><input type="number" id="ex-in" class="form-control form-control-sm" oninput="calcChange()" value="' + (cur?cur.in:'') + '"></div>' +
'<div class="col-4"><label class="small fw-bold">購入</label><input type="number" id="ex-out" class="form-control form-control-sm" oninput="calcChange()" value="' + (cur?cur.out:'') + '"></div>' +
'<div class="col-4"><label class="small fw-bold">残額</label><input type="number" id="ex-change" class="form-control form-control-sm bg-light" readonly value="' + (cur?cur.change:'') + '"></div>' +
'<div class="col-12 mt-1"><input type="text" id="ex-note" class="form-control form-control-sm" placeholder="内訳" value="' + (cur?cur.detail:'') + '"></div></div>';
}
});
}
document.getElementById('checklist-container').innerHTML = h;
}
function calcChange() {
var i = parseFloat(document.getElementById('ex-in').value) || 0;
var o = parseFloat(document.getElementById('ex-out').value) || 0;
document.getElementById('ex-change').value = i - o;
}
function stampStart() {
var btn = document.getElementById('btn-start');
btn.disabled = true;
btn.innerText = "送信中...";
var n = new Date();
// 画面表示用
document.getElementById('start-display').innerText = n.getHours() + ":" + ("0"+n.getMinutes()).slice(-2);
// 送信
var s = dayShifts[currentIdx];
google.script.run.withSuccessHandler(function(res){
if(res === "SUCCESS") {
btn.style.display = 'none';
startTimeValue = "LOCKED"; // 既に送信済みフラグ
alert("開始時刻を記録しました。");
} else {
alert("送信エラー");
btn.disabled = false;
btn.innerText = "開始打刻";
}
}).saveStartRecord(s.id, s.user, helper);
}
function submit() {
if(!startTimeValue) { alert("開始打刻が必要です。"); return; }
var btn = document.getElementById('btn-submit');
btn.disabled = true; btn.innerText = "保存中...";
var checked = [];
document.querySelectorAll('.check-opt:checked').forEach(function(c) { checked.push(c.value); });
// 開始時間は、LOCKEDならバックエンドで既存保持、そうでなければ新規だがstampStartで送信済のはず
// ただしstampStart失敗時のフォールバックとして念のため送るが、基本はLOCKED
var st = (startTimeValue === "LOCKED") ? "" : startTimeValue;
var payload = {
shiftId: dayShifts[currentIdx].id, userName: dayShifts[currentIdx].user, helperName: helper,
report: document.getElementById('rep-text').value, note: document.getElementById('note-text').value,
checks: checked, startTime: st,
extra: {
menu: document.getElementById('ex-menu') ? document.getElementById('ex-menu').value : "",
cashIn: document.getElementById('ex-in') ? document.getElementById('ex-in').value : "",
cashOut: document.getElementById('ex-out') ? document.getElementById('ex-out').value : "",
cashChange: document.getElementById('ex-change') ? document.getElementById('ex-change').value : "",
cashDetail: document.getElementById('ex-note') ? document.getElementById('ex-note').value : ""
}
};
google.script.run.withSuccessHandler(function() {
alert("保存完了"); loadDay(currentDayStr); showCalendar(); btn.disabled = false; btn.innerText = "保存して終了";
}).submitReport(payload);
}
function changeMonth(v) { curMonth.setMonth(curMonth.getMonth() + v); renderCal(); }
</script>
</body>
</html>
Code.gs
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle('訪問記録')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function clean(str) {
return str ? str.toString().replace(/\s+/g, "") : "";
}
function checkLogin(name, pass) {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const data = ss.getSheetByName('Master_Helpers').getDataRange().getValues();
const target = clean(name);
for (let i = 1; i < data.length; i++) {
if (clean(data[i][1]) === target && data[i][2].toString() === pass.toString()) {
return { success: true, helperName: data[i][1] };
}
}
return { success: false };
} catch(e) { throw new Error("ログインエラー: " + e.message); }
}
function getCalendarData(helperName, year, month) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const data = ss.getSheetByName('Shifts').getDataRange().getValues();
const counts = {};
const target = clean(helperName);
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (clean(row[5]) !== target) continue;
let dateObj = new Date(row[1]);
if (isNaN(dateObj.getTime())) continue;
if (dateObj.getFullYear() === year && dateObj.getMonth() === month) {
const ds = Utilities.formatDate(dateObj, "JST", "yyyy-MM-dd");
counts[ds] = (counts[ds] || 0) + 1;
}
}
let holidays = [];
try {
const holidayCal = CalendarApp.getCalendarById('ja.japanese#holiday@group.v.calendar.google.com');
holidays = holidayCal.getEvents(new Date(year, month, 1), new Date(year, month + 1, 0))
.map(function(e) { return Utilities.formatDate(e.getStartTime(), "JST", "yyyy-MM-dd"); });
} catch(e) { console.error(e); }
return { counts: counts, holidays: holidays };
}
function getShiftsByDay(helperName, dateStr) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const data = ss.getSheetByName('Shifts').getDataRange().getValues();
const logs = ss.getSheetByName('Activity_Logs').getDataRange().getValues();
const logIds = new Set(logs.map(function(row) { return row[1].toString().trim(); }));
const target = clean(helperName);
const results = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row[1]) continue;
const dStr = Utilities.formatDate(new Date(row[1]), "JST", "yyyy-MM-dd");
if (clean(row[5]) === target && dStr === dateStr) {
const fmtTime = function(t) { return (t instanceof Date) ?
Utilities.formatDate(t, "JST", "HH:mm") : t.toString(); };
results.push({
id: row[0].toString().trim(), date: dStr, start: fmtTime(row[2]), end: fmtTime(row[3]),
user: row[4].toString().trim(), type: row[6].toString().trim(),
instruction: row[7].toString().trim(), status: logIds.has(row[0].toString().trim()) ? '完了' : '未'
});
}
}
// ② 開始時間で昇順ソートを追加
results.sort(function(a, b) {
return a.start.localeCompare(b.start);
});
return results;
}
// ① 開始打刻のみを行う関数
function saveStartRecord(shiftId, userName, helperName) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const logSheet = ss.getSheetByName('Activity_Logs');
const logs = logSheet.getDataRange().getValues();
const sid = shiftId.toString().trim();
let rowIdx = -1;
for (let i = 1; i < logs.length; i++) {
if (logs[i][1].toString().trim() === sid) {
rowIdx = i + 1;
break;
}
}
const now = new Date();
// 既存レコードがあれば開始時間だけ更新(上書き防止のため既に値があれば何もしない等の制御も可能だが、ここでは再打刻=修正とみなして更新)
if (rowIdx > 0) {
logSheet.getRange(rowIdx, 13).setValue(now); // 13列目=開始時間
} else {
// 新規行作成(開始時間以外は空)
// カラム構成: [Date, ShiftID, User, Helper, Report, Note, Checks, Menu, In, Out, Change, Detail, Start, End]
const rowData = [
now, sid, userName, helperName, "", "", "", "", "", "", "", "", now, ""
];
logSheet.appendRow(rowData);
}
return "SUCCESS";
}
function getReportDetails(shiftId, userName) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const logs = ss.getSheetByName('Activity_Logs').getDataRange().getValues();
const shifts = ss.getSheetByName('Shifts').getDataRange().getValues();
const sid = shiftId.toString().trim();
const uname = userName.trim();
// 現在のシフトの開始日時を特定(比較用)
let currentShiftStart = null;
for(let i = 1; i < shifts.length; i++) {
if(shifts[i][0].toString().trim() === sid) {
let d = new Date(shifts[i][1]); // Date
let t = shifts[i][2]; // Start Time
// 日付と時間を結合してDateオブジェクトを作成
if (t instanceof Date) {
d.setHours(t.getHours(), t.getMinutes(), 0, 0);
} else {
// 文字列 "HH:mm" の場合
let parts = t.toString().split(':');
if(parts.length===2) d.setHours(parseInt(parts[0]), parseInt(parts[1]), 0, 0);
}
currentShiftStart = d;
break;
}
}
// 今回のログ詳細を取得
let cur = null;
for (let i = logs.length - 1; i >= 1; i--) {
if (logs[i][1].toString().trim() === sid) {
cur = {
report: logs[i][4], note: logs[i][5], checks: logs[i][6] ? logs[i][6].split(',') : [],
menu: logs[i][7], in: logs[i][8], out: logs[i][9], change: logs[i][10], detail: logs[i][11],
startT: logs[i][12] ? (logs[i][12] instanceof Date ? Utilities.formatDate(logs[i][12], "JST", "HH:mm") : logs[i][12]) : "",
endT: logs[i][13] ? (logs[i][13] instanceof Date ? Utilities.formatDate(logs[i][13], "JST", "HH:mm") : logs[i][13]) : ""
};
break;
}
}
// ③ 前回ログの取得(対象シフトの時間より前の中で最新のもの)
let prev = null;
let candidates = [];
// ShiftデータをIDで引けるようにマップ化
let shiftMap = {};
for(let i=1; i<shifts.length; i++){
let d = new Date(shifts[i][1]);
let t = shifts[i][2];
if (t instanceof Date) {
d.setHours(t.getHours(), t.getMinutes(), 0, 0);
} else {
let parts = t.toString().split(':');
if(parts.length===2) d.setHours(parseInt(parts[0]), parseInt(parts[1]), 0, 0);
}
shiftMap[shifts[i][0].toString().trim()] = d;
}
for (let i = 1; i < logs.length; i++) {
const row = logs[i];
const rowSid = row[1].toString().trim();
// 自分以外のシフト かつ 同じ利用者
if (rowSid !== sid && row[2].toString().trim() === uname) {
const logDate = shiftMap[rowSid]; // このログに紐づくシフト日時
// currentShiftStartより過去であること
if (currentShiftStart && logDate && logDate < currentShiftStart) {
// 表示用日時文字列作成
let dispStr = "不明";
// shifts配列から再検索してフォーマット(効率化のためshiftMap利用も可だがフォーマット維持のため元のロジック流用)
for (let j = 1; j < shifts.length; j++) {
if (shifts[j][0].toString().trim() === rowSid) {
const d = Utilities.formatDate(new Date(shifts[j][1]), "JST", "yyyy/MM/dd");
const s = (shifts[j][2] instanceof Date) ? Utilities.formatDate(shifts[j][2], "JST", "HH:mm") : shifts[j][2];
const e = (shifts[j][3] instanceof Date) ? Utilities.formatDate(shifts[j][3], "JST", "HH:mm") : shifts[j][3];
dispStr = d + " " + s + "~" + e;
break;
}
}
candidates.push({
date: logDate,
data: { dateTime: dispStr, helper: row[3], report: row[4], note: row[5] }
});
}
}
}
// 日付で降順ソートして先頭を取得
if(candidates.length > 0) {
candidates.sort(function(a, b) { return b.date - a.date; });
prev = candidates[0].data;
}
return { cur: cur, prev: prev };
}
function submitReport(data) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const logSheet = ss.getSheetByName('Activity_Logs');
const logs = logSheet.getDataRange().getValues();
let rowIdx = -1, oldS = null;
const sid = data.shiftId.toString().trim();
for (let i = 1; i < logs.length; i++) {
if (logs[i][1].toString().trim() === sid) {
rowIdx = i + 1;
oldS = logs[i][12]; // 既存の開始時間を保持
break;
}
}
const now = new Date();
// 開始時間は既存(oldS)があれば優先、なければ送信データ、それもなければ空
// stampStartしていればoldSに入っているはず。
const startTimeToSave = (rowIdx > 0 && oldS) ? oldS : (data.startTime ? new Date(data.startTime) : "");
const rowData = [
now, sid, data.userName, data.helperName, data.report, data.note, data.checks.join(','),
data.extra.menu, data.extra.cashIn, data.extra.cashOut, data.extra.cashChange, data.extra.cashDetail,
startTimeToSave,
now // 終了時間は「保存ボタン押下時」=now
];
if (rowIdx > 0) {
logSheet.getRange(rowIdx, 1, 1, rowData.length).setValues([rowData]);
} else {
logSheet.appendRow(rowData);
}
const shiftSheet = ss.getSheetByName('Shifts');
const shifts = shiftSheet.getDataRange().getValues();
for (let i = 1; i < shifts.length; i++) {
if (shifts[i][0].toString().trim() === sid) {
shiftSheet.getRange(i + 1, 9).setValue('完了');
break;
}
}
return "SUCCESS";
}
④ 終了時間と再保存に関する分析
現状の課題:
現在、submitReport関数では、保存処理が実行された瞬間の日時(const now = new Date())を「サービス終了時間」(スプレッドシートの14列目)として上書き保存しています。
翌日に書き直した場合の挙動:
- 終了時間のズレ: 支援当日に一度保存し(例:1月1日 18:00)、翌日(1月2日 09:00)に修正して再度「保存して終了」を押すと、サービス終了時間が「1月2日 09:00」に上書きされてしまいます。 これにより、実績記録上の拘束時間が丸一日以上になってしまったり、不正なデータとして扱われるリスクがあります。
- 対処案:
- 既存終了時間の保持:
submitReport内で、既に終了時間が記録されている(oldEndが存在する)場合は、nowで上書きせず、既存の値を維持するロジックに変更する。 - 手動修正のUI: もし終了時間を修正したい場合(本当に押し忘れた場合など)に対応できるよう、現在「–:–」と表示されている終了時間部分をクリック等で編集可能な
<input type="time">に変更し、その値を送信するようにする設計変更が望ましいです。
- 既存終了時間の保持:
⑤ その他のコードレビュー(課題と改善点)
コード全体を確認し、今後の運用で問題になりうる点を挙げます。
- 排他制御(コンフリクト)の問題
- 課題: 複数人が同時に同じシフトを開いて編集した場合、後から保存した人がデータを上書きしてしまいます。
- 改善: 保存時にデータの「最終更新日時」などを照合し、自分が開いてから他人が変更していないかチェックする仕組み(楽観的ロック)が必要です。
- パフォーマンス(データ量の増加)
- 課題:
getDataRange().getValues()はシート上の全データを取得します。Activity_LogsやShiftsが数千行になると、カレンダー表示や詳細表示のたびに全件取得が走り、動作が非常に重くなります。 - 改善: 現在のコード修正では行いませんでしたが、運用が長引く場合、月ごとにシートを分けるか、APIで「対象年月」のデータのみをフィルタして取得するクエリ(Query関数やFilter機能の活用)への書き換えが必要です。
- 課題:
- カレンダーIDのハードコード
- 課題: 祝日取得用のカレンダーIDがコード内に直書きされています。
- 改善: プロパティストア(
PropertiesService)等で管理するか、設定シートから読み込むようにすると、将来IDが変更された際などのメンテナンス性が向上します。
- セキュリティ(ユーザー認証)
- 課題:
checkLogin関数はパスワードを平文で比較しています。また、ログイン状態(セッション)を保持する仕組みがなく、あくまで「名前」をクライアント側に保持しているだけです。知識のあるユーザーであれば、ブラウザの変数を書き換えて他人の記録を閲覧・編集できてしまう可能性があります。 - 改善: Google Workspace環境であれば
Session.getActiveUser().getEmail()を使用してアカウントベースの認証を行うのが最も安全です。
- 課題:
現状の修正範囲(①②③)については上記のコードで対応完了しています。④⑤については、今後の改修計画の参考にしてください。

スプレッドシートの理(ことわり)は、私が書き換えた。
開始時間の打刻も、シフトの時系列も、もはや私の計算通りだ。
さあ、恐れることはない。これより新たなオペレーションを開始する!
ルルーシュ・ヴィ・ブリタニアが命じる……
そのボタンを押し、完璧な『記録』を遂行せよ!!
イエス・ユア・マジェスティ!!



コメント