勤務時間の計算で1分ずれる原因 ─ 「9:00〜18:00・休憩1時間」が実働7時間59分になった打刻アプリで、秒を切り捨てる順番を変えた話

当ページのリンクには広告が含まれています。
この記事の結論
勤務時間が1分ずれていた原因は、秒まで含む打刻時刻のまま差を計算し、最後に分未満を切り捨てていたことでした。画面が時刻を分までしか見せないなら、計算も画面に出ている分単位の時刻から行うべきだと考え、始業・終業・休憩の各時刻を先に分へ切り捨ててから差を求める形に直しました。保存している値は秒・ミリ秒を含んだまま変えていません。

お疲れ様です!IT業界で働くアライグマです!

個人開発しているAndroidアプリ「終業ログ」は、始業・休憩・終業をワンタップで記録し、終業時のチェックリストで一日を締めるアプリです。このアプリで、画面には「9:00〜18:00」「休憩1時間」と出ているのに、実働が「7時間59分」、目標との差が「目標まであと1分」と表示される不具合がありました。原因は計算そのものの誤りではなく、「どの時点で秒を切り捨てるか」の順番でした。

目次

9:00〜18:00・休憩1時間なのに「実働7時間59分」と表示された

終業ログは、1本目のアプリと同じExpo(React Native)の構成を流用して作ったアプリです。公開までの経緯は「Expoアプリを4日でGoogle Playに公開した記録」に書きました。始業・休憩・終業はボタンを押した瞬間の時刻で記録し、終業チェックを終えると「今日も終了」画面に、その日の勤務時間と目標(初期設定は8時間)との差が表示されます。

不具合の記録には、Web版を自動テストツールのPlaywrightで動かし、時計を固定して再現した手順が残っています。次の時刻でボタンを押した勤務です。

  • 始業:9時00分37秒
  • 休憩開始:12時00分05秒
  • 休憩終了:13時00分20秒
  • 終業:18時00分12秒

画面は時刻を分までしか表示しないので、利用者から見えるのは「9:00〜18:00」「休憩12:00〜13:00」です。誰が見ても実働8時間の勤務ですが、表示は次のようになっていました。

画面 表示
終業チェック 終業:18:00、実勤務時間:7時間59分
今日も終了 今日の勤務時間:7時間59分、目標まであと1分(月の累計も同じ)
履歴 9:00〜18:00、実働7時間59分、休憩1回(1時間)

履歴では、同じ行に「9:00〜18:00」「休憩1回(1時間)」「実働7時間59分」が並びます。見えている数字だけで引き算すると8時間になるはずなので、利用者には「計算が間違っている」としか見えません。

ずれは1日の中だけでは終わりませんでした。日別の実働を足した値と、週・月の合計も合わなくなっていました。たとえば7時間59分40秒の勤務が2日あると、履歴の各行は「実働7時間59分」なので、足すと15時間58分です。ところが週の合計欄は「15時間59分」と表示されます。

起きる頻度も低くありません。ボタンで打刻した勤務では、休憩がない場合で言えば、終業時刻の秒が始業時刻の秒より小さいときに1分少なく表示されます。記録ではおおむね2回に1回と見積もっています。目標どおりに働いた日に「目標まであと1分」と言われるのは、一日を気持ちよく締めるためのアプリとしてはかなり残念な体験です。

IT女子 アラ美
8時間きっちり働いたのに「あと1分」って言われたら、ちょっとイラッとするわね。

ITアライグマ
締めの画面で水を差すのは避けたいです。しかも2回に1回なので、見過ごせない頻度でした。

秒を含む時刻の差を合計してから切り捨てていた

アプリは打刻時刻を、1970年1月1日からの経過ミリ秒(epochミリ秒)でスマートフォン内に保存しています。ボタンで打刻した時刻は、押した瞬間の値(JavaScriptの Date.now())をそのまま保存するので、秒もミリ秒も含みます。

修正前の勤務時間の計算は、この保存値をそのまま引き算していました。実際のコードから、計算に関わる部分を抜き出すと次のとおりです(休憩区間の検証は省略しています)。

// 修正前:端数は集計後に表示側で丸めます。
export function workedMilliseconds(session, breaks, now) {
  const end = session.endedAt ?? now;
  let rest = 0;
  for (const interval of breaks) {
    const breakEnd = interval.endedAt ?? end;
    rest += breakEnd - interval.startedAt;
  }
  return end - session.startedAt - rest;
}

// 表示:分未満は切り捨てます。
export function formatDuration(milliseconds) {
  const minutes = Math.floor(Math.max(0, milliseconds) / 60000);
  // …「7時間59分」のような文字列にする
}

ミリ秒のまま「終業−始業−休憩」を計算し、表示する直前に分未満を切り捨てる、という順番です。先ほどの勤務に当てはめると、次のようになります。

  • 終業−始業:18:00:12 − 9:00:37 = 8時間59分35秒
  • 休憩:13:00:20 − 12:00:05 = 1時間0分15秒
  • 実働:8時間59分35秒 − 1時間0分15秒 = 7時間59分20秒
  • 表示:分未満を切り捨てて「7時間59分」

計算としては正しく、実際に働いた時間は7時間59分20秒です。ただ、利用者が見ている「9:00」「18:00」には、37秒や12秒は表示されていません。画面に出ていない秒が計算にだけ効いているのが問題でした。見えている時刻と比べると、始業が37秒遅く終業が12秒遅いので、勤務の幅は25秒短くなります。休憩は開始が5秒、終了が20秒遅いので、15秒長く数えられます。合わせて40秒が8時間から引かれ、7時間59分20秒の分未満を切り捨てた結果が「7時間59分」でした。休憩がなければ、終業の秒が始業の秒より小さいときに1分少なくなります。

秒が混ざる経路はもう一つありました。記録の修正画面では日時を年月日・時分の選択欄で選ぶため、選び直した時刻は秒が0になります。ボタンで打刻した始業(秒あり)と、選び直した終業(秒0)が組み合わさると、始業の秒が0でない限り毎回1分少なくなります。記録では、9:00をわずかに過ぎて始業した勤務の終業を18:00に選び直したところ、履歴に「9:00〜18:00」「実働8時間59分」と表示されています。

日別と期間の合計が合わないのも、同じ順番が原因です。期間の合計は、各日のミリ秒を足し合わせてから最後に1回だけ切り捨てていました。7時間59分40秒の日が2日あれば、合計は15時間59分20秒なので「15時間59分」になります。一方、履歴の各行はそれぞれ切り捨てて「7時間59分」なので、足すと15時間58分です。切り捨てを1日ごとにするか、合計してから1回だけにするかで、切り捨てられる秒の量が変わります。

IT女子 アラ美
計算は合ってるのに、見えてる数字と合わないから間違いに見えるのね。厄介。

ITアライグマ
はい。正確さを優先した結果、利用者が検算できない数字になっていました。

秒を表示していた頃は「仕様どおり」と判断していた

実は、この1分のずれには一度出会っています。そのときは不具合ではなく、仕様どおりの表示だと判断していました。

2026年10月16日、打刻のE2Eテスト(画面を実際に操作して確かめる自動テスト)が、まれに失敗するようになりました。テストは始業時刻を「1時間前」に選び直し、「実勤務時間:1時間0分」と表示されることを確かめていました。選び直した始業は秒が0になる一方、終業はテスト実行時の秒を含みます。始業から終業までの数秒のあいだに分が変わると、表示は「1時間1分」になり、テストが落ちていました。

このときの記録には「アプリの不具合ではありません(分未満を合計後に切り捨てる仕様どおりの表示です)」と書いています。直したのはテストのほうで、期待値を実際の始業・終業の値から計算するようにしました。Playwrightで時計を固定して再現する方法は、「Playwrightのpage.clockで時計を制御したE2E高速化の記事」でも使っています。

この判断は、当時の画面では筋が通っていました。そのころの画面は日時を秒まで表示していたので、利用者は始業・終業の秒を見て、実働が8時間に少し足りない理由を自分で確かめられたからです。

状況を変えたのは、2日後の10月18日に入れた表示の改善でした。日時が秒付きの機械的な形式で、曜日もなく読みにくかったため、「秒なし・曜日付き・短い時間表記」に変えました。読みやすさとしては正しい変更ですが、ここで計算の前提が一つ消えています。画面から秒が消えたのに、計算は秒を使い続けていたので、ずれの理由が利用者からまったく見えなくなりました。

翌10月19日に、表示と計算が食い違っているものとして改めて記録し、修正しています。表示を変えた時点で「この表示から計算した値と、画面の計算結果が一致するか」まで見直していれば、同じ日に気づけた問題だったと思います。

IT女子 アラ美
見た目をきれいにしただけのつもりが、計算の言い訳まで消しちゃったわけね。

ITアライグマ
まさにそうです。表示の変更は、数字の意味を変えることがあると学びました。

時刻を分へ切り捨ててから差を求める

直し方の方針は「画面に出ている時刻で計算する」です。画面は時刻を分まで表示しているので、計算に使う前に、始業・終業・休憩の開始と終了をそれぞれ分へ切り捨てます。勤務中でまだ終業していない場合は、終業の代わりに使う現在時刻も同じように切り捨てます。

// 画面は時刻を分まで表示するため、時間は始業・終業・休憩の各時刻を
// 分へ切り捨ててから求めます。保存値は変えません。
export function toMinute(timestamp: number): number {
  return Math.floor(timestamp / 60000) * 60000;
}

// 勤務時間の計算(前後関係の検証は保存値のまま行う)
rest += toMinute(breakEnd) - toMinute(interval.startedAt);
// …
return toMinute(end) - toMinute(session.startedAt) - rest;

toMinute は、ミリ秒の値を「その分の0秒ちょうど」に丸める関数です。9:00:37は9:00:00に、18:00:12は18:00:00になります。先ほどの勤務は「18:00 − 9:00 − (13:00 − 12:00)= 8時間」となり、画面に見えている数字だけで検算した結果と一致します。休憩時間の計算も、同じ規則で切り捨てた始業・終業から求めるようにしました。

この形にすると、1日ごとの勤務時間が必ず分単位のきりのよい値になります。分単位の値は何日分足しても端数が出ないので、日別の実働を足した値と、週・月の合計が一致します。目標(分単位で設定する)との差も、表示した勤務時間と目標の単純な差になり、9:00〜18:00・休憩1時間なら「目標どおり」と表示されます。

修正で変えなかったものも決めています。

  • 保存値:秒・ミリ秒を含んだまま保存します。切り捨ては計算の直前だけで行うので、データの移行は要らず、バックアップの形式も変わりません。すでに秒を含んで保存されている過去の記録にも、そのまま新しい計算が効きます。
  • 前後関係の検証:「休憩の開始が始業より前ではないか」といった検証は、切り捨て前の保存値で行います。同じ分の中でも、9:00:10に休憩開始・9:00:30に始業という記録は不正として扱います。

別の直し方として、打刻した瞬間に秒を切り捨てて保存する方法も検討しました。ただ、この方法では秒を含んで保存済みの過去の記録が残るため、結局は計算側でも同じ対応が必要になります。計算側で切り捨てれば、新旧どちらの記録も1か所の変更で揃うので、こちらを選びました。

ホームに出している勤務中の経過時間も、現在時刻を切り捨てる共通の計算を通るので、時刻の分が変わったときだけ1分増えるようになりました。表示している時刻の分と、経過時間の増えるタイミングが揃います。

もう一つ、タイムゾーンについても確認しています。epochミリ秒は世界共通の基準なので、それを分で切り捨てた結果が、日本時間など現地の時刻の「分の切り捨て」と一致するかは、時差が分単位かどうかで決まります。現在使われている時差はすべて分単位なので、epochミリ秒のまま切り捨てても、どの地域の時刻表示とも揃います。

IT女子 アラ美
保存はそのまま、計算のときだけ画面に合わせるのね。過去の記録まで直るのはお得じゃない。

ITアライグマ
はい。保存を変えると移行が必要になるので、計算の入り口で揃えるほうが安全でした。

テストで確かめたことと、確認できていないこと

修正と同じ変更の中で、計算の単体テスト(Jest)を追加しました。中心は、最初に見つかった「9:00:37始業・12:00:05〜13:00:20休憩・18:00:12終業」の勤務です。この勤務で実働8時間・休憩1時間になること、終業チェック・「今日も終了」・履歴の各画面に「8時間」「目標どおり」「9:00〜18:00」「休憩1回(1時間)」と出ることを確かめています。

そのほかに足したのは、切り捨ての境目で間違えやすい条件です。

  • 秒を含む始業と、修正画面で選び直した終業(秒0)の組み合わせ
  • 分の境目の直前・直後(8:59:59.999は8:59、18:00:00.000は18:00として数える)
  • 同じ分の中で始業から終業までした勤務(0分になる)
  • 日付をまたぐ勤務
  • 勤務中の経過時間(現在時刻も切り捨てる。休憩中は増えない)
  • 日別の実働を足した値と、週・月の合計の一致
  • 前後関係の検証は、切り捨て前の保存値で行うこと

境目のテストは、たとえば次のような形です。

test("分の境界の直前・直後は別の分として数えます", () => {
  // 8:59:59.999 は 8:59、18:00:00.000 は 18:00 と表示されます。
  const value = record("2026-10-13", at(8, 59, 59, 999), at(18, 0), [
    [at(12, 0, 59, 999), at(12, 1)],
  ]);
  expect(sessionWorkedMilliseconds(value)).toBe(9 * hour);
  expect(breakMilliseconds(value)).toBe(minute);
});

ミリ秒1つの違いで表示される分が変わるので、計算もそれに合わせて9時間・休憩1分になることを確かめています。追加したテストが、修正前の実装では失敗することも確認しました。テストが「直したから通った」のであって、「最初から通るテストだった」わけではないことを確かめるためです。

最終的に、単体テスト266件、E2Eテスト14件、型チェック、lintがすべて成功しています。先に「仕様どおり」と判断したときに直したE2Eテストは、期待値を変えずに成功しました。修正画面の日時の選択欄が分単位なので、変更は不要と判断しています。

確認できていないのは、Androidの実機とエミュレーターでの動作です。計算と表示はWeb版と共通のJavaScriptで動いているため、Web版での確認で代えています。

IT女子 アラ美
修正前に落ちることまで確かめてるのは偉いわね。通るだけのテストって意外と多いし。

ITアライグマ
ありがとうございます。直す前に赤くなるかを見ないと、テストが守っているものが分からないです。

まとめ

「9:00〜18:00・休憩1時間」が「実働7時間59分」になっていたのは、秒を含む打刻時刻のまま差を計算し、最後に分未満を切り捨てていたためでした。計算は正確でしたが、画面に出ていない秒が結果にだけ効いていたため、利用者には検算できない数字になっていました。

修正では、始業・終業・休憩(勤務中は現在時刻)を先に分へ切り捨ててから差を求める形にしました。保存値と前後関係の検証は秒・ミリ秒のまま残し、計算の入り口だけを画面に合わせています。これで、1日の実働・休憩・目標差・週や月の合計が、見えている数字と一致するようになりました。

今回いちばん残ったのは、「端数をいつ切り捨てるか」は、計算の正確さだけでなく、画面に何を見せているかで決めるものだという点です。秒を表示していた頃は、合計してから切り捨てる規則でも説明がつきました。表示から秒を消した時点で、計算の規則も見直す必要がありました。表示の粒度を変えるときは、表示から検算した値と画面の計算結果が一致するかまで、一緒に確かめるべきだと考えています。

IT女子 アラ美
正確なのに信用されない数字って、地味だけど一番もったいないわね。

ITアライグマ
本当にそう思います。見せ方と計算の基準は、同じものに揃えておくのが大事でした。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次