勤怠の 22:00→02:00 を、日ごとの保存ではなく複数日一括置換で直した
勤怠アプリの「記録を足す」で 22:00→02:00 を入れられなかった。 応急処置では日末までで記録を切っていたが、恒久対応は入力欄を直すだけでは済まない。 打刻を勤務日の境界で分割し、隣の日にある記録を守りながら、複数日の範囲を原子的に置き換える必要があった。
23:59 までで切る応急処置が残った
アプリには、時計上の終了時刻が開始時刻より早い入力を拒否する処理があった。日の切り替え時刻の既定を変更すると、夜から翌朝までの記録がこの条件に入る。そこで先に入れたのが、確認を挟んで日末までだけを保存し、残りは翌日に足してもらう応急処置だった。
これは入力不能を避けるには効く。しかし利用者が入れた区間と、保存された区間が違う。さらに勤務日の切り替え時刻を設定できるアプリでは、「日末」が時計の深夜と一致するとも限らない。入力を短くする処理を積み重ねるより、日をまたぐ区間をデータモデルで扱う必要があった。
終了時刻を翌日の相対分へ直す
フォームでは時計の時刻を、選択した勤務日の開始からの相対分へ変換している。終了が開始より早いときは、翌日の終了時刻として一日分を加える。同時刻は曖昧なので拒否し、今日の記録なら未来に出る区間も拒否する。
const startMin = clockToDayMinutes(startClock)
let endMin = clockToDayMinutes(endClock)
if (endMin === startMin) {
setError('はじめと おわりを、ちがう時刻にしてください。')
return false
}
// 時計のおわりが早ければ翌日の時刻。コアが勤務日の切り替えで分けて一括保存する。
if (endMin < startMin) endMin += 24 * 60ただし、ここで得られるのは勤務日の範囲を越えた区間でしかない。保存の前に絶対時刻へ戻し、splitByDay へ渡す。分割位置を時計の深夜に固定せず、設定された勤務日の境界から求めることで、同じ入力を設定に応じた日へ配分できる。
テストでは、この入力が境界の両側に clock_in とclock_out の組として展開され、合計4打刻になることを固定した。境界設定を変えたケースも、同じ分割処理を通している。
日ごとの保存を繰り返す案は壊れる
最初に考えやすいのは、分割した前日側と翌日側を、既存のreplaceDaySegments へ順番に渡す案だ。採用しなかった。日ごとの保存は、その勤務日に属する打刻を丸ごと削除してから入れ直す操作だからだ。
前日側の保存が成功して翌日側だけ失敗すれば、日をまたぐ区間は途中までしか残らない。反対に翌日側を置き換えると、そこに先に入っていた重ならない記録まで削除対象になる。実際、監査では前日側を編集すると翌日側の区間が消え、稼働中なら進行中状態まで閉じる経路が見つかった。
範囲置換で守るべきものは、入力された新しい区間だけではない。削除範囲に入る「変更しない記録」も、置換後の集合へ明示的に戻す必要がある。
保護対象を再構成してから一括置換する
恒久対応では、まず置換範囲の打刻を広めに読み、既存のセグメントと進行中状態を導出する。新しい区間を勤務日ごとに分割したあと、翌日の既存区間との重複を保存前に検査する。重ならない既存区間は捨てず、画面に出ていないアーカイブ済みカテゴリの区間と一緒に、保存する打刻へ戻す。
const replacement = [
...split,
...hiddenOnFirstDay,
...existingAfterFirstDay,
].sort(
(a, b) => a.workDate.localeCompare(b.workDate) || a.startMin - b.startMin,
)
const drafts = draftsFromDaySegments(replacement)
if (lastWorkDate === today && state.status === 'work' && state.runningSinceMs !== null) {
drafts.push({
kind: 'clock_in',
categoryId: state.activeCategoryId,
occurredAt: new Date(state.runningSinceMs),
workDate: today,
})
} else if (lastWorkDate === today && state.status === 'break' && state.breakSinceMs !== null) {
drafts.push({
kind: 'break_start',
categoryId: null,
occurredAt: new Date(state.breakSinceMs),
workDate: today,
})
}
await repos.punches.replaceBetween(
workDate,
addDays(lastWorkDate, 1),
drafts,
)ここには見落としやすい落とし穴がある。replaceBetween は複数日の範囲を削除するため、新しいsplit だけを挿入すると、重複していない翌日の既存記録も消える。重複検査を通しただけでは保護したことにならない。削除される既存区間をexistingAfterFirstDay として再構成し、置換集合へ含めて初めて残せる。
今日まで範囲が届く場合は、進行中の稼働または休憩開始も打刻へ戻す。入力がその境界へ食い込む場合は丸めずに拒否する。黙って短くすると、保存できたように見えて進行中の記録を閉じるためだ。
保存先では、範囲削除と再構成した打刻の挿入を同じ SQLite トランザクションで実行する。途中で失敗すれば全体が戻るので、片側だけが残る状態を作らない。
await getDb().transaction(async (tx) => {
await tx.execute('DELETE FROM punches WHERE occurred_at >= ? AND occurred_at < ?', [fromIso, toIso])
for (const draft of drafts) {
await tx.execute('INSERT INTO punches (kind, category_id, occurred_at, work_date) VALUES (?, ?, ?, ?)', [
draft.kind,
draft.categoryId,
draft.occurredAt.toISOString(),
draft.workDate,
])
}
})画面側も同じ考え方にそろえた。保存処理は成功可否を返し、呼び出し側は完了を待ってから編集画面を閉じる。入力フォームが開いたまま外側の保存ボタンを押した場合も、先にフォームを下書きへ反映する。データ層を原子的にしても、画面が先に入力を捨てれば利用者には同じ「消えた」に見えるからだ。
まとめ
日をまたぐ時間入力は、終了時刻に一日分を足せば終わる問題ではなかった。保存単位が日なら、分割後の各日を別々に更新したくなる。しかし丸ごと置換する API では、それが部分成功と隣接記録の削除を生む。
今回の解決策は、削除範囲に入る既存状態を先に読み、新しい区間と保護対象を合わせて再構成し、複数日の範囲を一括で置き換えることだった。置換処理の安全性は「何を消すか」だけでなく、「消す範囲から何を戻すか」で決まる。