# Spec: 統計の「全会話」を月の集計にする(締め日と、前の月への遡り) **ID**: 42 **Date**: 2026-08-19 **Status**: **Done(2026-08-19。起票から Done まで同日)** P0〜P4 着地後、利用者が実機で表示を確認(利用者の言葉「表示確認しました」)。 検収 7(境界の 23:59:59 / 00:00:00)は実機で踏めないので P1 の単体が留めている **Branch**: P0(契約)は rev 承認後に main 直。P1 以降は `20260819_stats_period` へ積む ## Goal **統計の「全会話」を、村の生涯累計ではなく「締め日で区切った 1 か月」で読めるようにする。** 起点は利用者(2026-08-19)—「全会話は期間を決めないと無限に増えていくね」→ 「**システム設定で月間の集計日を決めてもらって月の集計に変えましょうか。 例えば 25 日が集計日だとすると前の月の 26〜25 日の期間。ただ月末というのもあり、 その場合は 1 日〜月末まで。それで前の月とかを遡って見れたほうがいい**」。 いま「全会話」が持つ問題は 2 つで、表の行が増えることは軽いほう: - **表の行は `list_sessions()` の全件**(上限なし)。スクロールは効く(`overflow-auto`)ので 画面は壊れないが、v0.1.8 より前の会話が `—` の行として永久に並ぶ - **重いのは合計** — `totals` / `by_agent` / `≈ $` が**村の生涯累計**なので、使い込むほど 「先月いくら払ったか」が読めない数字に育つ。統計としての単位は本来「期間」 **これは拡張か収縮か**: 拡張(設定 1 つ・ワイヤに欄 1 つ・画面要素 1 つ)。 収縮側の選択肢は D0。 ## 現況(実測 2026-08-19) - `TurnRecord.ts_ms`(`session_store.rs:170`)が**ターンの開始時刻**を持っている (Spec 39 P1 で `TurnContext.started` から書く)。**期間フィルタはこの 1 欄で掛けられ、 新しい計器は 1 つも要らない** - `StatsScope` は `Session { session_id } | All` の閉じた 2 値(`stats.rs:36`)。 `aggregate(turns, sessions, scope)` は純関数で時計を読まない - `scope_meta.sessions` は `list_sessions()` の全件を並べ、期間内に払っていない会話も `turns=0` で載る(画面は `—`) - `cost`(Spec 41 の `≈ $`)は `aggregate` の**後**で `by_agent` に単価を掛けている (`sessions.rs:251`)ので、**`by_agent` を期間で切れば金額は自動で追従する** - 設定の置き場は 2 つだけ(`settings_contract`)— 村(`world.json`)と端末 (`localStorage "fuseforks.settings.v1"`)。システム設定に「コスト管理」ページが既にあり、 中身は「トークン制限」の 1 節 ## 決めること ### D0. そもそも作るか(収縮側) **作らない案**: 表を「直近 N 件」で切るだけ。**却下を推す** — 行数は問題の軽いほうで、 合計が生涯累計であることは N 件では直らない。加えて Spec 39 の契約 「`scope_meta.sessions` に居ない会話も `totals` には数える」を N 件で切ると**表と合計が 食い違う**。期間で切れば**表も合計も同じ述語**で切れる。 ### D1. 期間の定義(**利用者の言葉を式にする**) **締め日 `d` を 1 つ持ち、期間は「前の締め日の翌日 00:00 から、締め日の翌日 00:00 まで」の 半開区間 `[since, until)`**。日付はローカル時刻。 ```text 締め日 25: 7/26 00:00 ≤ ts < 8/26 00:00 → 「8 月分(7/26〜8/25)」 月末: 8/01 00:00 ≤ ts < 9/01 00:00 → 「8 月分(8/1〜8/31)」 ``` - **「今の期間」は今日を含む期間**。今日が締め日以前ならその月が締め月、 締め日より後なら翌月が締め月(8/19 で締め日 25 → 8 月分。8/26 なら 9 月分) - **選べる締め日は 1〜28 と「月末」で確定**(rev2。査読 1)。型は `1..=28 | "eom"`。 29〜31 を選ばせない — 2 月に存在しない日を選ばせると「その月はどうなるか」の 規則がもう 1 つ要る。**「月末」は独立した値**で、月ごとの末日に落ちる (1 日〜末日 = 利用者の 2 例目)。 **却下した代案は 1〜31 + `min(d, その月の末日)` への丸め** — 期間に穴も重なりも 出ないが、2 月にだけ「30 日締めが 28 日締めになる」を黙って起こし、 設定した本人にその規則が見えない - **締め日 1 は月末と同義ではない**(rev2。査読 6 を訂正して採用)。1 日締めは `7/2 00:00 ≤ ts < 8/2 00:00` = 「8 月分(7/2〜8/1)」で、月末締めの 8/1〜8/31 とは 別の期間。**選択肢から 1 を外さず、同義とも書かない**。暦の月を期待して 1 を選ぶ 取り違えは**構造で防ぐ** — 設定ページの選択肢の直下に**その締め日での「今の期間」を ライブで出す**(`今の期間: 7/2〜8/1`)。選んだ瞬間に境界が見えるので、 暦の月が欲しい人は自分で「月末」へ動く。文言の注記だけでは守れない(#84) - **表示は「締め月」で呼び、範囲を併記する**(`8 月分(7/26〜8/25)`)。 締め月だけだと「7/26 が 8 月分」に見えないことがあり、範囲だけだと月の並びが 読みにくい。両方出す。**範囲の右端は `until − 1 ms` をローカル時刻で日付にしたもの** (`until` は排他なので `9/1` を `8/31` として出す。`since` と同じタイムゾーンで 日付化する。査読 7) - **年をまたぐ**(12 月分 = 11/26〜12/25、1 月分 = 12/26〜1/25)は式のまま成立する - **境界は半開**。締め日 23:59:59.999 のターンは今期、翌 00:00.000 のターンは来期。 `ts_ms` は開始時刻なので、日付をまたいで走ったターンは**始まった側**に落ちる (払いの単位はターン 1 本で割らない) ### D2. 締め日の置き場は端末(`localStorage "fuseforks.settings.v1"` の `statsClosingDay`) - **村ではなく端末** — 締め日は**請求を読む人の属性**で村の状態ではない。会話ログにも プロンプトにも焼き付かない(`settings_contract` の境界「会話ログへ焼き付くか」で 端末側に落ちる)。村を配った先の人には**その人の締め日**がある - **入口はシステム設定 > コスト管理**(「トークン制限」の下)。保存先で分類しない (#52)— 「コスト管理」は主題で、`tokenBudget` が村・締め日が端末でも同じページに並ぶ。 ページ末尾の注記で「端末に保存」と言う(Spec 13 の作法) - **既定は「月末」**。暦の月がいちばん多くの人の既定で、利用者の例(25 日)は 「例えば」の側 - **選んだ瞬間に反映**(テーマと同じ。保存ボタン無し)。次に統計を開いたとき、 あるいは開いたままなら再取得 - **選択肢の直下に、その締め日での「今の期間」をライブで出す**(D1 の 1 日締めの 取り違え防止。純関数 `statsPeriod` を設定ページも呼ぶ — 統計画面と**同じ 1 実装**で 境界を出すので、設定で見た範囲と統計で見る範囲が食い違わない) - **締め日を変えると過去の月の境界も全部いまの締め日で引き直される**(Notes 4)。 「25 日締めで見ていた 7 月分」は月末へ変えた瞬間に 7/1〜7/31 になる。 締め日は**保存された属性であって記録の属性ではない**ので、これは意図どおり ### D3. ワイヤ — 期間は `All` にだけ載せる(構造で「この会話」には期間が無い) ```text Scope = { kind: "session", sessionId } | { kind: "all", period?: { sinceMs, untilMs } } # 無ければ全期間 ``` - **`Session` には期間を持たせない。** 1 つの会話は会話そのものが境界で、 そこへ月を掛けると「この会話に今月の記録がありません」という **D6 と紛らわしい空**が生まれる。**送れる形が無いほうが強い**(Spec 34 の `temperature` / Spec 37 の `tool_choice` と同じ判断) - **`period` 省略 = 全期間 = 現行の挙動**。画面に「全期間」の切り替えを残す (過去の合計が要る場面はある。凍結の覆しではなく既定の変更) - **境界の計算はフロント**(ローカル時刻 → epoch ms)。**コアは時計もタイムゾーンも 読まない**(`aggregate` は純関数のまま。`budget.rs` と同じ規律)。 コアに渡るのは `[sinceMs, untilMs)` の 2 数だけ - **フィルタは `aggregate` の引数**(`aggregate(turns, sessions, scope)` の `scope` から 読む)。I/O 側で先に落とさない — 純関数の中に置けば**境界のテストが時計なしで書ける** ### D4. `scope_meta.sessions` は期間内に払った会話だけ(`period` があるとき) - `period` あり: `turns > 0` の会話だけ載せる。**`—` の行(この版より前の会話・ 期間外の会話)は出さない** — 「今月」の表に生涯の全会話が `—` で並ぶのは、 行が増える問題をそのまま残す形 - `period` なし: 現行どおり全件(`—` を含む)。**「この会話にはターンの記録がありません (記録はこの版から)」の D6 は `session` スコープの規則で、`all` の表には 元から関係ない** - **`totals` / `by_agent` / `by_stop` は `sessions` に居るかに関係なく期間内の全ターン** (Spec 39 の規則そのまま)。**ただし `period` ありでは表と合計が一致する** — `all` の `sessions` は `list_sessions()` の全件なので「`turns` に居て `sessions` に 居ない会話」は存在せず、`turns > 0` だけを載せた表の合計 = `totals`。 規則は残すが、期間ありでこの但し書きが空振りになることを契約に書く(査読 5) - **`recorded_since` の意味は変えない**(そのスコープで最初の turn = **フィルタ後**)。 遡りの下限として **`scope_meta.oldestMs`(`Option`)を 1 欄足す** — 無いと「◀ をどこまで押せるか」を画面が知る手段が無く、押しても空が続くだけの ボタンになる。**定義を 3 点で固定する**(rev2。査読 2 / 4): - **`All` のときだけ計算する**。`Session` では `null`(`◀` は `All` にしか出ないので 計算する理由が無い。**「使う場所が無い値を計算しない」を型ではなく契約で留める** — 型を分けると `StatsScopeMeta` が 2 つに割れる) - **フィルタ前の全 turn から取る**。計算順は `oldest_ms = min(全 turn の ts_ms)` → `since ≤ ts < until` で落とす → `recorded_since = min(残った turn の ts_ms)`。 **順序を逆にすると `oldestMs` が期間内の最古になり `◀` が 1 つ前で止まる** - **turn が 1 件も無い村は `null`**。`◀` は無効 ### D5. 画面 — 「全会話」を選んだときだけ期間の操作が出る ```text [この会話] [全会話] ◀ 2026 年 8 月分(7/26〜8/25) ▶ [ 全期間 ] ``` - **`▶` は今の期間で止まる**(未来の期間は開けない — 記録が存在しないので空を見せない) - **`◀` は `oldestMs` を含む期間で止まる**。`oldestMs` が `null`(記録が 1 件も無い村)なら `◀` は無効 - **「全期間」はトグル**。ON で `period` を外して現行の表へ。OFF に戻すと今の期間へ。 **ON の間は `◀ ▶` とラベルを出さない**(期間が無いのに月のラベルを出さない。 無効化ではなく非表示 — 灰色の月名は「今その月を見ている」と読める。査読 2) - **ナビの状態は「締め月」`{ year, month }` で持つ**(rev2。査読 3)。境界の `[sinceMs, untilMs)` はそこから `closingDay` を掛けて都度導く。`◀ ▶` は締め月を ±1 するだけで、**`period` から `closingDay` を逆算しない** — `since = 1 日` は 「月末締め」とも「1 日締め(2 日始まり)」とも読めず、逆算は不定 - 開いたときの既定は**今の期間**(利用者の要望の主語がここ) - 期間内に 1 ターンも無い月(遡った先)は「この期間にターンの記録はありません」の 1 行 (D6 と語を分ける — あちらは「この会話」、こちらは「この期間」) - **`TurnRecorded` の再取得は現行のまま**。過去の月を見ている最中に新しいターンが 着地しても数字は動かない(期間の外)— これは正しい ### D6. 集計は月だけ(週・日は作らない) 利用者の要望は月。週・日・任意区間は**要る利用がまだ無い**(Spec 39 の 「中央値・分位は出さない — 出す根拠になる利用がまだ無い」と同じ線)。 `period` は `[sinceMs, untilMs)` の 2 数なので、**要るときにフロントの純関数へ 選択肢を足すだけでワイヤは動かない**。 ## 凍結(P0 で `data_contract` へ) - `stats_contract` の Scope を D3 の形へ改訂(`All` に `period` 任意) - `settings_contract` の localStorage 鍵の列挙へ `statsClosingDay` を追記 (**総数を書かず列挙**。#67) - **境界の計算はフロント、コアは 2 数だけ受ける**(時計・タイムゾーンをコアへ入れない) - **期間があるとき `scope_meta.sessions` は `turns > 0` だけ**(D4。このとき表の合計 = `totals`) - **`Session` スコープに期間は無い**(D3) - **`oldestMs` は `All` のときだけ・フィルタ前の全 turn から・無ければ `null`**(D4) - **締め日の型は `1..=28 | "eom"`。既定 `"eom"`**(D1 / D2) ## Tasks - [x] **P0 契約**(2026-08-19。`data_contract` の `stats_contract` Scope / `oldestMs` / D4 の計算順と但し書き / 画面のナビ、`settings_contract` に `statsClosingDay`): `stats_contract` Scope 改訂 + `oldestMs` + D4 の規則 / `settings_contract` に `statsClosingDay` - [x] **P1 コア**(2026-08-19。単体 4 本・ミューテーション 2 回): `StatsScope::All { period: Option }`(`#[serde(default)]`)/ `aggregate` の順序は **`oldest_ms`(全 turn の最小)→ `since ≤ ts < until` で落とす → `recorded_since`(残りの最小)** / `oldest_ms` は `All` のときだけ(`Session` は `None`)/ 期間ありの `sessions_meta` は `turns > 0` のみ。単体: 境界の両側 (`since` 含む・`until` 含まない)/ 期間なし = 現行と同一 / 期間ありで `—` の会話が 消える / **`oldest_ms` は期間の外の turn を指す**(期間内の最古ではない)/ `Session` で `oldest_ms` が `None`。 **既存の `aggregate` テストは 1 本も変えない**(`All` の既定が `period: None`) - [x] **P2 フロント(純関数)**(2026-08-19。単体 17 本・ミューテーション 2 回): `lib/statsPeriod.ts` — 状態は締め月 `{ year, month }`。 `closingMonthOf(closingDay, now) → { year, month }`(今日を含む期間の締め月)/ `boundsOf(closingDay, { year, month }) → { sinceMs, untilMs }` / `shift({ year, month }, ±1)` / `label(closingDay, { year, month }, locale)` (右端は `untilMs − 1` を日付化)。単体: 締め日 25 で 8/19 → 締め月 8 月・ 7/26 00:00〜8/26 00:00 / 締め日 25 で 8/26 → 9 月分 / 締め日 1 で 8/19 → 8 月分(7/2〜8/1)/ 月末で 2 月(28 日・うるう 29 日)/ 年またぎ(12 月分・1 月分)/ `shift(+1)` と `shift(−1)` が往復で元に戻る / `boundsOf` の隣り合う 2 期間で `until == 次の since`(穴も重なりも無い)。**`Date.now()` を関数の中で読まない** (`formatClock` と同じ規律 — 引数で受ける) - [x] **P2' 設定**(2026-08-19): `useUiSettings` に `statsClosingDay`(既定 `"eom"`。型は `1..=28 | "eom"`)+ 検証(範囲外・型違いは既定へ)/ システム設定「コスト管理」 ページに「集計の締め日」の選択 + **選択肢の直下に `statsPeriod` で出す「今の期間」の ライブ表示** + 端末保存の注記 / `useUiSettings.test.ts` の保存形を更新 (**緩めずに期待値を直す**)/ 辞書 ja/en - [x] **P3 画面**(2026-08-19。vue-tsc / vitest 412 / build 緑): `StatsView` に期間ナビ(D5。状態は締め月)/ `oldestMs` で `◀` を止める (`null` なら無効)/ 「全期間」トグル(ON の間はナビ非表示)/ 空の期間の 1 行 / 辞書 ja/en。**チャートも表も増やさない** - [x] **P4 台帳**(2026-08-19。README 日英 / DETAIL 日英の統計と設定表 / CLAUDE.md 現在地): README 日英の統計の節(「全会話」= 締め日で切った月・全期間は選択)/ DETAIL 日英 / CLAUDE.md の現在地 / `data_contract` の回収(P0 の見直し) - [x] **P5 実機**(2026-08-19。利用者が表示を確認) ## 検収(実機) 1. 締め日 25 の状態で 8/19 に「全会話」を開くと **8 月分(7/26〜8/25)** が既定で出る 2. 締め日を「月末」に変えると**その場で** 8 月分(8/1〜8/31)に変わる(保存ボタン無し) 3. `◀` で 7 月分へ遡ると **合計と個体別と `≈ $` が変わり**、`▶` で戻ると元の数字に戻る 4. 「全期間」ON で**現行と同じ数字**(`totals` が改訂前の値と一致。全期間 = `period` 無し) 5. 期間内に払っていない会話(この版より前の会話・先月しか動いていない会話)が **表から消え、全期間 ON で `—` として戻る** 6. `▶` は今の期間で止まり、`◀` は最初の記録を含む月で止まる 7. (機械)締め日 23:59:59 と翌 00:00:00 のターンが別の期間に落ちる — 実機では踏めないので P1 の単体で留める 8. 設定ページで締め日を 1 → 25 → 月末と動かすと、直下の「今の期間」が `7/2〜8/1` → `7/26〜8/25` → `8/1〜8/31` と**その場で**変わる(D1 / D2 のライブ表示) ## Notes 1. **なぜ「上位 N 件」ではなく期間か** — 表と合計が同じ述語で切れること(D0)。 もう 1 つ、期間は**質問に対応している**(「先月いくら」)が N 件は対応していない (「直近 20 会話でいくら」を訊く人は居ない) 2. **`period` を `Session` へ広げたくなったとき** — 会話が月をまたぐ運用が実測で出たら そのとき起票する。今の実機の会話は Spec 12 の「新規チャット」で切られており、 1 会話が数か月続く形は観測していない 3. **タイムゾーン** — 境界はフロントの `Date` でローカル時刻から作る。 村を別のタイムゾーンの端末で開けば境界がずれるが、それは**その人の月**として正しい (締め日を端末に置いた D2 と同じ理由)。夏時間のある地域では 23 時間 / 25 時間の日が あるが、`[since, until)` を epoch ms で渡すのでコアは知らなくてよい 4. **締め日の変更は過去の月の境界も引き直す**(査読 8)。締め日は端末の属性で、 記録には「どの締め日で払ったか」が無い(`ts_ms` だけ)。だから 25 日締めで見ていた 「7 月分(6/26〜7/25)」は月末へ変えた瞬間に「7 月分(7/1〜7/31)」になり、 数字も変わる。**変更を記録に焼き付ける(締め日ごとに月次を保存する)機構は作らない** — 利用者が驚く可能性はあるが、設定ページのライブ表示(D2)が「境界は締め日から 導かれる」ことをその場で見せる。統計は原本ではなく `Record::Turn` からの導出値で、 導出値を保存すると 2 実装目になる(Spec 39 の「原本は turn 1 つ」) 5. **査読の記録(rev2)** — 8 点のうち採用 7・訂正して採用 1。訂正したのは 6 (「1 日 = 月末と同義と説明する」案)— 同義ではない(1 日締めは 7/2〜8/1)ので 同義とは書かず、取り違えはライブ表示という構造で防いだ。指摘の趣旨 (1 を残すなら注記が要る)は採った。**実装が止まる矛盾は 0 だが、P1 / P2 で解釈が 割れる点が 5 つあった**(`oldestMs` の定義域と計算順 / `previous` のシグネチャ / `totals` の但し書き / 全期間 ON のナビ / ラベルの右端)— どれも rev1 では 「書いていないこと」で、実装者が埋める形になっていた