お疲れ様です!IT業界で働くアライグマです!
個人開発しているAndroidアプリ「終業ログ」には、終業予定の時刻や、打刻を忘れたときに知らせるローカル通知があります。この通知のうち、終業予定を23時台の後半にした日の「終業の記録がありません」という通知だけが、0時を過ぎてからアプリを開くと予約から外れる作りになっていました。予約の作り直し方そのものはよくある形で、日付をまたぐ通知と組み合わさったときだけ問題になるものでした。
0時を過ぎてアプリを開くと、0:20の通知が消えていた
終業の打刻忘れ通知は、終業予定の30分後に届く
終業ログは、1本目のアプリと同じExpo(React Native)の構成を流用して作ったアプリです。公開までの経緯は「Expoアプリを4日でGoogle Playに公開した記録」に書きました。通知はサーバーから送るプッシュ通知ではなく、Expo Notificationsで端末の中に予約しておくローカル通知だけを使っています。通知は次の3種類で、どれも勤務する曜日だけに予約します。
| 通知 | 時刻 | 予約する条件 |
|---|---|---|
| 終業予定 | 終業予定の時刻 | その日の勤務を終業していない |
| 始業の打刻忘れ | 始業予定の30分後 | その日に始業した記録がない |
| 終業の打刻忘れ | 終業予定の30分後 | その日に始業し、まだ終業していない |
終業予定は始業予定より後で、同じ日の中(最大で23:59)にしか設定できませんが、終業の打刻忘れは「予定の30分後」なので、終業予定を23時半より後にすると翌日の0時台になります。この仕様自体は、通知の設計メモにも最初から書いてありました。
23:55には予約されていた通知が、0:05には0件になる
問題の手順は次のとおりです。勤務曜日は月〜金、始業予定は9:00、終業予定は23:50で、打刻忘れの通知をオンにしています。
- 2026年10月23日(金)の9:00に始業し、終業しないまま23:55になる
- この時点では、金曜の勤務に対する「終業の打刻忘れ」が翌24日(土)0:20に予約される
- 0:05にアプリを前面へ戻すと、アプリが記録を読み直して通知を予約し直す
- 予約し直したあと、0:20の通知が予約に残っていない
土曜は勤務曜日ではありませんが、この通知は金曜の勤務についてのものなので、本来は残るべきです。終業予定の通知(23:50)はすでに過ぎているので消えて構いませんが、0:20はまだ来ていない時刻です。
確かめたのは実機ではなく、予約を計算する関数の結果
この問題は、Android端末で通知が届かなかったことを見て確かめたものではありません。不具合の記録に残っている確認は、アプリと同じ勤務記録の保存処理をNodeのSQLiteで動かして金曜の勤務を作り、予約する通知を計算する関数を上の日時で呼び出したものです。結果は次のとおりでした。
| 計算した時刻 | 金曜の「終業の打刻忘れ」 |
|---|---|
| 10月23日 23:55 | 1件(24日 0:20) |
| 10月24日 0:05 | 0件 |
| 10月24日 0:05(金曜の勤務を明示的に渡した場合) | 0件 |
3行目が、後で原因を考えるときの手がかりになりました。金曜の勤務を関数に渡しても、計算結果に入ってこなかったのです。
IT女子 アラ美予約は「全部取り消してから、今日以降の分だけ登録し直す」作りだった
繰り返し通知にせず、14日分を1回ずつ予約していた
終業ログの通知は、毎週同じ時刻に鳴る繰り返し通知ではなく、今日から14日分を1回ずつ日時指定で予約しています。打刻した日だけ通知を外したいので、日ごとに予約を持つ必要があるからです。通知には「種類と勤務日」を組にしたID(例:missing-end:2026-10-23)を付けています。
予約し直すのは、端末内の記録や設定を読み直したときです。打刻、記録の修正、設定の保存に加えて、アプリの起動や前面への復帰でも読み直すので、そのたびに予約が作り直されます。
予約し直すときは、まず全部取り消す
予約を登録する処理は、渡された計画を差分で反映するのではなく、予約済みの通知をすべて取り消してから計画どおりに登録し直します。実装から要点だけを抜き出すと、次のようになります。
// 実装から要点だけを抜き出した例
export function syncReminders(reminders: readonly PlannedReminder[]) {
const run = queue.then(async () => {
await Notifications.cancelAllScheduledNotificationsAsync();
for (const reminder of reminders) {
await Notifications.scheduleNotificationAsync({
identifier: reminder.id,
content: { title: reminder.title, body: reminder.body },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: reminder.at,
},
});
}
});
queue = run.catch(() => undefined);
return run;
}
呼び出しは順番に処理するので、最後に渡した計画だけが残り、同じ通知が二重に予約されることはありません。古い予約の掃除を考えなくてよい、シンプルな作りです。
全部取り消す作りでは、計画に入らなかった通知は消える
この作りには前提があります。計画には、残すべき通知がすべて入っていることです。計画から漏れた通知は、「予約しない」では済まず、すでに予約されていたものまで取り消されます。
計画を作る側は「今日から14日分」を対象にしていました。勤務記録を読み込む範囲も、計画を作るループも、今日の日付から始まります。日中であれば「今日以降の勤務」で通知は全部カバーできますが、0:05の時点の「今日」は土曜です。金曜の勤務に属する0:20の通知は計画に入らず、全部取り消す処理によって消えていました。



取得範囲を広げるだけでは直らなかった2か所
1か所目:勤務記録を読む範囲が今日から始まっていた
予約を作り直すときは、まず端末内のデータベースから、対象期間の勤務記録を読み込みます。その期間を決める関数は、修正前は次のようになっていました。
// 修正前(実装から抜粋)
export function reminderRange(now: number): { from: string; to: string } {
const date = new Date(now);
return {
from: localWorkDay(now), // 今日
to: localWorkDay(
new Date(
date.getFullYear(),
date.getMonth(),
date.getDate() + REMINDER_HORIZON_DAYS - 1,
).getTime(),
),
};
}
from が今日なので、0:05に予約し直すと土曜からの記録しか読み込まれず、金曜の勤務は計画を作る関数に届きません。ここだけを見ると、from を前日にすれば直りそうに見えます。
2か所目:計画を作るループも今日から始まっていた
ところが、前の章の表の3行目では、金曜の勤務を明示的に渡しても結果は0件でした。計画を作る関数は、受け取った勤務記録を順に見るのではなく、今日から14日分の日付を1日ずつ回し、その日の勤務記録を探して通知を組み立てていたからです。
// 修正前(実装から要点だけを抜き出した例)
for (let offset = 0; offset < REMINDER_HORIZON_DAYS; offset += 1) {
// today + offset の日について、勤務曜日か・休みの日かを判定し、
// その日の勤務記録から「終業予定」「始業・終業の打刻忘れ」を組み立てる
}
return reminders.filter((reminder) => reminder.at > now); // 過ぎた時刻は除く
ループが offset = 0(今日)から始まるので、前日の日付は一度も処理されません。勤務記録を渡しても、それを見に行く日付が回ってこないのです。読み込む範囲とループの開始日、2か所が両方とも「今日」で揃っていたため、片方を広げても通知は出てきません。
不具合の記録の段階で、2か所とも直す必要があると分かっていた
不具合の記録には、この2か所を指したうえで「取得範囲の変更だけでは直りません」と書いてありました。表の3行目のように、勤務記録を渡した場合も試していたので、読み込みだけの問題ではないことが修正前に分かっていたことになります。範囲だけを広げて直したつもりになる、という回り道はせずに済みました。



前日から計算し、過ぎた時刻は従来どおり外す修正とテスト
読む範囲とループの開始日を、同じ修正で前日からにした
修正では、前の章の2か所を1回の変更でまとめて直しました。勤務記録を読む範囲の開始日を前日にし、計画を作るループも offset = -1(前日)から回すようにしています。
// 修正後(実装から抜粋)
from: localWorkDay(
new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1).getTime(),
),
// 終業予定が23:31以降の場合、終業打刻忘れ(予定+30分)は翌日0時台になります。
// 日付変更後にもその通知を再予約できるよう、前日(offset = -1)から含めて計算します。
for (let offset = -1; offset < REMINDER_HORIZON_DAYS; offset += 1) {
23:30ちょうどの場合、打刻忘れの時刻は0:00ちょうどです。日付が変わった時点でその時刻を迎えるので、0時を過ぎてから予約し直しても残す対象にはなりません。実際に影響を受けるのは、コメントのとおり23:31以降の設定です。
前日の分を足しても、余計な通知は増えない
前日から計算すると、前日の「終業予定」や「始業の打刻忘れ」まで計画に入りそうに見えます。これを外しているのは、修正前からあった最後の1行です。
return reminders.filter((reminder) => reminder.at > now);
前日の通知はほとんどが過去の時刻なので、ここで落ちます。残るのは、翌日0時台にずれ込んだ「終業の打刻忘れ」のように、まだ来ていないものだけです。前日の日付についても、勤務曜日か、休みの日に設定していないか、始業済みで終業していないかを、今日以降の日と同じ規則で判定します。通知のIDには勤務日が入っているので、前日の分と今日の分が同じIDでぶつかることもありません。
日付をまたぐ場面を、5つの条件でテストに固定した
追加した単体テストは、終業予定23:50・金曜の勤務が未終業という前提で、計算する時刻や条件を変えて確かめるものです。
| 条件 | 金曜の「終業の打刻忘れ」(0:20) |
|---|---|
| 金曜 23:55に計算 | 予約される |
| 土曜 0:05に計算(土曜は勤務曜日でない) | 予約される(終業予定の23:50は予約されない) |
| 土曜 0:25に計算(0:20を過ぎた後) | 予約されない |
| 金曜 23:52に終業済み | 予約されない |
| 金曜を休みの日に設定済み | 予約されない |
あわせて、読み込む範囲が前日からになったことを既存のテストにも反映し、通知の設計メモの「今日から14日分」という記述に、前日を含む理由を書き足しました。単体テストは31ファイル・339件がすべて通っています。
不具合の記録から約35分でマージし、1.0.6に含めた
不具合の記録を作ったのは10月25日の10時12分で、修正のPRは10時36分に作られ、10時46分にマージされました。この修正は、同じ日に作ったバージョン1.0.6に含め、内部テストへの提出を経て、同じ日のうちに製品版の審査へ申請しています。リリースノートにも「終業予定が深夜(23:31以降)の場合に、日付が変わった後も終業打刻忘れの通知が正しく届くよう修正しました」と載せました。



Android実機での配信は確かめていない
確かめたのは「何を予約するか」の計算まで
今回の確認は、修正の前も後も、予約する通知を計算する関数の単体テストと、勤務記録を読む範囲のテストまでです。不具合の記録にも修正のPRにも、未確認の範囲として次のことが書いてあります。
- この時刻条件で、Androidの予約一覧が実際にどう変わるか
- 0:20以降に、通知が実際に端末へ届くか
- 端末の省電力機能(Doze)などによる配信の遅れ
つまり、「0時過ぎに予約し直しても、0:20の通知が計画に残る」ことは確かめましたが、「Androidの端末で0:20に通知が表示される」ことは確かめていません。ブラウザで動かすWeb版のテストでは、Androidの通知の配信は検証できないので、Web版で確認したことを実機の確認として扱ってもいません。
既存のテストは全部通っていたが、この境界は見ていなかった
不具合の記録の時点で、既存の単体テスト338件とWeb版のE2Eテスト21件はすべて成功していました。それでもこの問題が残っていたのは、どのテストも「日付が変わる前後で予約し直す」場面を扱っていなかったからです。通知の設計メモには「終業予定が23:30より後なら、終業の打刻忘れは翌日0時台」と最初から書かれていたので、仕様として分かっていた境界が、テストにも計画の計算にも反映されていなかったことになります。
同じアプリの勤務時間の計算が1分ずれた件では、日付をまたぐ勤務を計算のテスト条件に入れていました。ただ、それは勤務時間の計算のテストで、通知の予約には同じ観点のテストがありませんでした。時刻の扱いは、仕様に書いた境界ごとにテストの条件を作っておく必要があると考えています。



まとめ
終業ログでは、終業予定を23:31以降にすると、終業の打刻忘れ通知が翌日の0時台になります。この通知が、0時を過ぎてからアプリを開くと予約から消える作りになっていました。予約を更新するたびに全部取り消してから計画どおりに登録し直す作りで、その計画が「今日以降の勤務」だけから作られていたため、前日の勤務に属する、まだ来ていない通知が漏れていたのが原因です。
勤務記録を読む範囲だけでなく、計画を作るループの開始日も今日になっていたので、両方を前日からに直しました。前日の分の過ぎた通知は、もともとあった「過ぎた時刻を外す」処理で落ちます。日付をまたぐ5つの条件を単体テストに固定しましたが、Android実機で0:20に通知が届くところまでは確かめていません。
全部取り消してから作り直す方式は単純で扱いやすい一方、計画から漏れたものは黙って消えます。この方式を使うなら、取り消す範囲(予約済みのすべて)と作り直す範囲(計画)が本当に一致しているかを、日付の境界で確かめておくべきだと今は考えています。











