ハウツー: タイムトラッキング API
FT リファレンス: FT246 (
NENE2-FT/timelog) — タイムトラッキング API
タイマーエントリが start_time と nullable な end_time(NULL = 実行中、非 NULL = 停止済み)を持ち、一度に 1 つのタイマーしか実行できず、SQLite の strftime('%s', ...) で時間が計算され、日次サマリーがカレンダー日ごとの合計トラッキング秒数を集計するストップウォッチスタイルのタイムトラッキング API を実演します。
ルート
| メソッド | パス | 説明 |
|---|---|---|
POST | /timers/start | 新しいタイマーを開始する(すでに実行中の場合は失敗) |
POST | /timers/stop | 現在実行中のタイマーを停止する |
GET | /timers/running | 現在実行中のタイマーを取得する(または running: false) |
GET | /timers/summary | 日次サマリー: 日ごとの合計秒数とエントリ数 |
GET | /timers | エントリを一覧表示する(ページネーション、ラベルと日付でフィルタリング可) |
GET | /timers/{id} | 単一タイマーエントリを取得する |
DELETE | /timers/{id} | タイマーエントリを削除する(204 No Content) |
静的ルートを最初に:
/timers/start、/timers/stop、/timers/running、/timers/summaryはすべて/timers/{id}より前に登録されるため、リテラルパスはパラメーター化されたセグメントとしてキャプチャされません。
スキーマ
CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT, -- NULL = 実行中
created_at TEXT NOT NULL
);end_time は nullable です — NULL はタイマーがまだ実行中であることを意味します。NOT NULL は停止済みであることを意味します。別の status カラムはありません; end_time の有無が実行状態をエンコードします。
実行状態: end_time IS NULL
タイマーの実行状態は end_time カラムから純粋に検出されます:
final readonly class TimeEntry
{
public function isRunning(): bool
{
return $this->endTime === null;
}
public function durationSeconds(): ?int
{
if ($this->endTime === null) {
return null; // まだ実行中 — まだ時間がない
}
$start = new \DateTimeImmutable($this->startTime);
$end = new \DateTimeImmutable($this->endTime);
return (int) $end->getTimestamp() - (int) $start->getTimestamp();
}
}isRunning() は endTime が null のとき true を返します。durationSeconds() は実行中のタイマーに対して null を返します — タイマーが停止するまで時間を計算できません。レスポンスにはアクティブなエントリに対して "running": true と "duration_seconds": null が含まれます。
シングルトンタイマー: 一度に 1 つのみ実行できる
start() は新しいタイマーを作成する前に実行中のタイマーを確認します:
public function start(string $label, string $startTime, string $createdAt): TimeEntry
{
$running = $this->findRunning();
if ($running !== null) {
throw new TimerAlreadyRunningException($running->id);
}
$this->executor->execute(
'INSERT INTO time_entries (label, start_time, end_time, created_at) VALUES (?, ?, NULL, ?)',
[$label, $startTime, $createdAt],
);
return $this->findById($this->executor->lastInsertId());
}タイマーがすでに実行中の場合、TimerAlreadyRunningException がスローされます → 409 Conflict。 end_time はリテラルの NULL SQL 値として挿入されます。
実行中のタイマーの検索:
public function findRunning(): ?TimeEntry
{
$row = $this->executor->fetchOne(
'SELECT * FROM time_entries WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1',
[],
);
return $row !== null ? $this->hydrate($row) : null;
}WHERE end_time IS NULL — 標準的な SQL の NULL 比較(= NULL ではなく)。LIMIT 1 は不変条件が違反された場合に複数行が返されるのを防ぎます。
タイマーの停止: stop()
public function stop(string $endTime): TimeEntry
{
$running = $this->findRunning();
if ($running === null) {
throw new NoRunningTimerException();
}
$this->executor->execute(
'UPDATE time_entries SET end_time = ? WHERE id = ?',
[$endTime, $running->id],
);
return $this->findById($running->id);
}stop() は実行中のタイマーを見つけ、end_time を設定し、計算された時間付きの更新されたエントリを返します。タイマーが実行中でない場合、NoRunningTimerException がスローされます → 409 Conflict。
時間計算: SQL での strftime('%s', ...)
集計サマリーの場合、時間は SQLite の strftime('%s', ...) 関数を使って SQL で計算されます。この関数は日時文字列の Unix エポック秒を整数で返します:
SUM(strftime('%s', end_time) - strftime('%s', start_time)) AS total_secondsstrftime('%s', ...) は ISO 日時文字列(±HH:MM オフセットがあれば UTC に正規化)を解析し、整数のエポック秒を返します。2 つを引くと正確な秒数の差が得られ、PHP 側の getTimestamp() の差分と一致します。
落とし穴 — 秒精度に
julianday()を使わないこと。CAST((julianday(end_time) - julianday(start_time)) * 86400 AS INTEGER)という式を使いたくなりますが、juliandayの差はちょうどの秒数をわずかに下回る浮動小数点値のため、CAST(... AS INTEGER)の切り捨てで 60 秒のエントリが 59 秒になります。整数エポック秒を返すstrftime('%s', ...)を使えば正確です。(FT246timelogexample の PHPUnit で発見)
SUM(...) はその日のすべての完了エントリを合計します。WHERE end_time IS NOT NULL はサマリーからまだ実行中のタイマーをフィルタリングします。
個別エントリに対する PHP サイドの計算:
$start = new \DateTimeImmutable($this->startTime);
$end = new \DateTimeImmutable($this->endTime);
return (int) $end->getTimestamp() - (int) $start->getTimestamp();どちらのアプローチも UTC タイムスタンプに対して同じ結果を生成します。SQL アプローチは集計に使用されます(すべての行を取得して合計するのを避けるため); PHP アプローチは個別エントリのシリアライゼーションに使用されます。
日次サマリー集計
$sql = 'SELECT date(start_time) AS day,
SUM(strftime('%s', end_time) - strftime('%s', start_time)) AS total_seconds,
COUNT(*) AS entry_count
FROM time_entries
WHERE ' . implode(' AND ', $where) . '
GROUP BY day
ORDER BY day DESC';date(start_time) は start_time ISO 文字列からカレンダー日付を抽出します。GROUP BY day は同じ日のすべての完了エントリをグループ化します。ORDER BY day DESC は最新の日を最初に返します。
$where 句は常に ['end_time IS NOT NULL'] から始まり実行中のタイマーを除外し、次に日付範囲フィルター用に date(start_time) >= ? と date(start_time) <= ? をオプションで追加します。
日付のみのフィルタリングのための date() 関数
カレンダー日付でエントリをフィルタリングするには SQLite の date() 関数を使用します:
if ($date !== null) {
$where[] = "date(start_time) = ?";
$params[] = $date;
}date(start_time) は ISO 日時文字列から YYYY-MM-DD のみを抽出します。= ? は抽出された日付をフィルター値と比較します。これにより、時刻コンポーネントに関わらず指定された日に開始されたすべてのエントリが正しくマッチします。
LIKE によるラベルフィルタリング
if ($label !== null) {
$where[] = 'label LIKE ?';
$params[] = '%' . $label . '%';
}LIKE '%label%' は SQLite のデフォルト照合順序で大文字小文字を区別しない部分文字列マッチを実行します。$label 内の特殊文字 % と _ は LIKE ワイルドカードとして解釈されます — 厳密なリテラルマッチングが必要な場合はエスケープしてください。
GET /timers/running レスポンス契約
実行中のエンドポイントはタイマーがアクティブかどうかに関わらず一貫した形状を返します:
if ($entry === null) {
return $this->json->create(['running' => false, 'entry' => null]);
}
return $this->json->create(['running' => true, 'entry' => $this->serialize($entry)]);running: false, entry: null — タイマーなし。 running: true, entry: {...} — end_time: null と duration_seconds: null を持つアクティブなタイマー。
これにより「実行中のタイマーがない」に対して 404 を避けられます — 404 はリソースが存在しないことを示しますが、「実行中のタイマー」の概念は常に存在します(ただし空です)。running: false を使う方が意味的にすっきりしています。
関連 howto
shift-management.md— nullable な終了時間を持つシフトの出退勤scheduled-reminders.md— タイムゾーン対応の日時バリデーションaggregate-reporting.md—GROUP BY date集計パターンhandle-timezones.md— UTC ストレージとタイムゾーン変換