@aiquants/daily-report 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to `@aiquants/daily-report` are documented here.
4
4
 
5
+ ## 0.31.0 (2026-10-06)
6
+
7
+ 0.30.0 の続き: 編集中の入力が描画の窓の外や選択の移動を越えて残ること、要求の値 (営業日・ID・操作の時刻・フォーム) の厳格な解釈と作成した日報の営業日を UTC の 0 時で格納すること、原本の `Content-Length` を渡す本文から決めること、一覧の行を ids ストリームの変化だけで導くことと SSE の知らせの合体、合体窓を単調な時計で測ること、行の集合の大きさ (`aria-setsize`) と見出しの総件数を申告した総数で決めること、ホバーの浮き上がりの定義域を 1 から 3 までの 1/4 の倍数のすべてへ広げること、要求の隔離の結線を型で決めること。どれも README の該当の節が今の契約を記す。
8
+
9
+ ### Fixed
10
+
11
+ - **編集中の入力は、行が描画の窓を外れても選択を移しても残る**: 編集の状態 (開いているか・自分で開いたか) と入力中の件名と本文は、行やペインの状態ではなく、アクションのプロバイダーが 1 つ持つ編集の置き場 (日報ごと。`src/client/contexts/daily-report-draft-store.ts`) にある。DetailList の行が仮想スクロールの描画の窓を外れて戻っても (ホイール・ドラッグ・スクロールバー・タップスクロール・別の行からのキー・ホストの選択)、List の側面ペインとモバイルのオーバーレイが別の日報を表示して戻っても、編集モードと入力中の値のまま描く。入力で描き直すのはフォームだけ (行・ペインの中身・一覧は描き直さない)。
12
+ 同じ日報の SSE の更新は入力中の値を上書きせず、保存の成功は値を残し、キャンセルは値を捨てる。公開の成功と日報の削除 (利用者の削除・SSE の `report-delete`・読み込めない日報の除去) は日報の項目を捨て、サーバーが拒んだ削除は日報と一緒に項目も残す。置き場の寿命はプロバイダーと同じで、ページを離れる・再読み込み・利用者の切り替えで空になる。
13
+ 以前は編集中かどうかを行の状態に、入力をフォームの状態に持ったので、行が窓を外れるか選択が移ると、入力中の件名と本文が消えた (下書きは保存済みの値で開き直し、公開済みの日報は表示に戻った)。
14
+ - **作成した日報の営業日は、どの時間帯のサーバーでもその日**: 作成の営業日はその日の UTC の 0 時として格納し、日報の `date` に正準のキー (`YYYY-MM-DD`) を返す。以前は送られた文字列を `new Date()` の自由な解釈へ渡したので、`2026/10/06` をサーバーのローカルの 0 時と読み、UTC より東の時間帯では前日 (`2026-10-05T15:00:00.000Z`) で格納した。`2026-02-30` は 2026-03-02、`1` は 2001-01-01 の日報になった。
15
+ - **要求の値は 1 つの厳格な解釈で読み、誤りはどのクエリよりも前に 400**: API の経路と action は、営業日を `YYYY-MM-DD` か `YYYY/MM/DD` (区切りは 2 か所とも同じ) の 0001-01-01 から 9999-12-31 までの暦にある日だけ (`parseBusinessDateKey`)、日報とコメントの ID を正の安全な整数の正準の 10 進の形だけ (`parseCanonicalPositiveId`。先頭に 0 を持たない数字で 1 から 2^53 − 1)、操作の時刻 (`operationTimestamp`) を 0 以上の安全な整数の正準の 10 進の形だけで読む。
16
+ `business-date` は暦にある日でも前後の空白・時刻付きの ISO 8601・日付の文章を 400 (`Invalid business date`) で答え、`report` は `12abc`・`1e3`・`1.9`・` 7 ` を 400 (`Invalid reportHubId`) で答える。action は送られた営業日をどの操作でも検証し (`Invalid businessDate`)、ID と時刻の誤りを `Invalid reportHubId`・`Invalid commentId`・`Invalid operationTimestamp` で答え、フォームとして読めない本文を error の行を残さない 400 (`Invalid form`) で答える。テキストを求める欄のファイルの部分は無い欄ではなく不正な値。action はフォームを丸ごと解釈してから内部ユーザー・可視集合・操作を解決する。
17
+ 以前は `Number.parseInt` と `Number()` の寛容な解釈で読んだので、`report?reportHubId=12abc` が日報 12 を返し、action の `reportHubId=1e3` は日報 1000 に、`-3` はサービスまで届き、2^53 + 1 は隣の整数に丸まって別の日報を名指しえた。営業日は自由な日付の解釈を通り (`business-date` は `Tue Oct 06 2026` も受けた)、読めない本文は error の行 2 本の 500 になった。
18
+ - **原本の `Content-Length` は渡す本文から決める**: GET の `Content-Length` は渡すバイト列の長さで、読み取りポートが申告した `size` がそれと違えばポートの契約違反として 500 (`reason=port_contract code=size_mismatch`、error) で答え、本文を送らず実体の有無も記録しない。HEAD の `Content-Length` はポートが申告した `size` で、申告が無ければ付けない (RFC 9110 §8.6)。
19
+ 以前は GET も申告した `size` を優先したので、大きさを別の呼び出しで測るポートの申告が本文と食い違うと、短い本文は読み手を待たせ続け、長い本文は切れた。申告の無い HEAD は本文のある原本に `Content-Length: 0` を名乗った。
20
+ - **行の集合の大きさは申告した総数**: 両ビューの行の `aria-setsize`、List のカードの読み上げる位置 (全 N 件中 M 件目) と見出しの総件数は、ビューの行が属する集合の大きさを読む (`DailyReportResolvedContent` が 1 か所で決める)。ids ストリームが一覧を届けている間はサーバーが最初の行で申告した総数 (見せている行の数より小さくはしない)、申告の前は分からない (`aria-setsize="-1"`、見出しは `labels.totalCountLoading`、位置は新しいキー `listRowPositionInUnknownTotal` の「M 件目」)、完走した後は見せている行の数。
21
+ 以前は届いた行の数だったので、228,222 件の一覧の走査の途中で「全 4,200 件中 3 件目」と読み、チャンクが届くたびに集合の大きさと見出しの総件数が変わった。
22
+ - **ホバーの浮き上がりは 1 から 3 までの 1/4 の倍数のどの画素密度でも整数の装置の画素**: 浮き上がり L = ⌊2·dpr⌋ / dpr の上書きを、2·dpr が整数でない 1.25・1.75 に加えて 2.25 (16/9 px、4 装置画素) と 2.75 (20/11 px、5 装置画素) にも置く。以前の 2.25 と 2.75 では 2 px が 4.5 と 5.5 装置画素になり、ホバーした面が装置の画素の半分に乗った (1 px の枠線・輪・アウトラインが隣の画素の行へにじんだ)。
23
+ - **ids ストリームの publish の合体窓は単調な時計で測る**: 合体窓の経過は `performance.now()` で測る (完走の時刻 `completedAt` は時刻なので壁時計のまま)。以前は壁時計で測ったので、時刻合わせで時計が戻ると、戻った分だけ次の publish が遅れた (60 秒戻れば 60 秒)。
24
+
25
+ ### Changed
26
+
27
+ - **一覧の行は ids ストリームの変化だけを当てはめて導く**: ストリームのアイテムの publish は、一覧と一緒にその版 (`itemsRevision`。どのセッションの間でも重ならない) と、publish ごとの正味の変化 (入った・値が変わった・離れた日報) を版の鎖で運ぶ (`itemsChanges`。最新の 32 件)。アクションのプロバイダーは行を正準の順 (営業日の降順 → id の降順、営業日の無い日報は末尾) に保ち、最後に当てはめた版からの変化だけを、1 回の描画が何回の publish を受けても 1 つに畳んで当てはめる。
28
+ 前の末尾の後ろへ入る行は 1 回の比較とネイティブの連結で済み (Δ 件を運ぶ publish が触る行は、ストリームのクライアントを含めて Δ + 1 まで)、順を外れて届いた行は二分探索で差し込み、値の変わった行はその場で置き換え (⌈log2 n⌉)、一覧全体を 1 回たどるのは削除だけ。一覧全体から導き直すのは、最初の描画・セッションの置き換わり・32 回より多い publish の遅れのときだけ。
29
+ 以前は publish ごとに一覧を作り直したので、228,222 件の走査では publish のたびに届いた行の数に比例して触る行が増えた (4,000 件のチャンクの 2 回目で 8,000 行、以後も増え続けた)。
30
+ - **SSE の作成・公開・削除の知らせもチャンクと同じ合体窓を通る**: プロバイダーが常駐のストリームへ中継する変化 (SSE の `report-create`・`report-publish`・`report-delete` と、利用者自身の作成と削除) は、チャンクと同じ 50 ms の窓で 1 回の publish にまとまる (窓の外の最初の変化はすぐ、窓の中の変化は窓の終わりに 1 回、走査の完走は保留中の変化を待たずに運ぶ)。SSE の知らせが連なって届いても一覧は 1 回で変わり、行は最初の知らせから 50 ms 以内に現れる (以前は知らせごとにすぐ)。利用者自身の作成と削除は今までどおりすぐ見える (プロバイダーがその行を自分で足し、外す)。
31
+ - **自動で開く編集は表示中ユーザーの下書きだけ、キャンセルした下書きは開き直さない**: List の側面ペインも DetailList の行と同じ規則で、表示中ユーザーの下書きだけを編集モードで開く (以前の側面ペインはどの下書きも開いた)。キャンセルした下書きは、選択を移して戻っても、行が窓を外れて戻っても表示のまま (置き場が空になるまで)。
32
+ - **開発者向け**: 要求の隔離の結線を型で決める。経路の方針 (`RequestIsolationPolicy`) は拒否の記録の名前 (`route`) も持ち、包みは方針とハンドラーだけを受け取る (`isolated(policy, handler)`)。共通の土台 `NOT_NAVIGABLE` は `route` を持たないので、名前を書かない方針は型検査で落ちる。API の経路のエンドポイントの名前と答える本体の種類は `src/server/api-endpoint.ts` の表 (`API_ENDPOINT_BODY_KINDS`) にだけ書き、メソッド (`API_ENDPOINT_METHODS`) と API の経路のデータの要求の断り (`API_LOADER_ISOLATION_POLICY`) はそこから導く。action の方針は `ACTION_ISOLATION_POLICY` (記録の名前は `action`)。答えと記録の行は変わらない。
33
+ 要求の値の解釈は `src/shared/business-date.ts` (`parseBusinessDateKey`・`isCanonicalBusinessDateKey`) と `src/server/wire-params.ts` (`parseCanonicalPositiveId`・`parseCanonicalNonNegativeInteger`・`isPositiveSafeInteger`・`formFieldText`) に 1 つずつある。ids のストリームの再開カーソルと添付のトークンの ID も同じ解釈で読む (カーソルの営業日は正準のキーだけ)。
34
+
35
+ ### Added
36
+
37
+ - client のラベルのキー `listRowPositionInUnknownTotal(position)`: 集合の大きさが分からない間の List のカードの位置 (en "Item 3"、ja 「3 件目」)。固有のキーは 85 (全体は 96) で、上書きは関数でなければならない。
38
+ - `DailyReportList` と `DailyReportDetailList` の prop `rowSetSize`: 行が属する集合の大きさ (分からない間は −1)。省けば渡した行が集合そのもの。`VirtualScroll` の行の数はいつも渡した行の数。
39
+ - `DailyReportIdsStreamClient` の `has(reportHubId)` (常駐の一覧がまだ publish していない変化を含めてその日報を持つか) と、合体窓の単調な時計を差し替える `now` オプション (既定は `performance.now()`)。`useDailyReportIdsStream` の状態の `itemsRevision` と `itemsChanges` (上の Changed)。
40
+
41
+ ### Breaking
42
+
43
+ - shared の `normalizeBusinessDateKey` は文字列を厳格に読む: `YYYY-MM-DD` か `YYYY/MM/DD` で 0001-01-01 から 9999-12-31 までの暦にある日だけをキーにし、それ以外 (前後の空白・時刻付きの ISO 8601・日付の文章・暦に無い日・0 年) は `null`。`Date` は今までどおりローカルの暦日。以前は空白を削り、形の合う `YYYY-MM-DD` を暦を見ずに受け (`2026-02-30`)、ほかの文字列は `new Date()` の自由な解釈で読んだ。
44
+ Migration: 時刻付きの文字列は `Date` にしてから渡すか、日付の部分 (`YYYY-MM-DD`) を切り出して渡す。
45
+ - client の `DailyReportActionProvider` は `initialItems` を受け取らない: 行はモジュール常駐の ids ストリームのセッションから自分で導く。
46
+ Migration: `initialItems` を外し、プロバイダーを描く前にセッションを確立する (経路の `createDailyReportClientLoader`、またはマウントのときの `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })`。`DailyReportPage` は自分で確立する)。利用者ごとにプロバイダーをマウントし直す (`key={userId}`)。
47
+ - API の経路と action は、寛容な解釈なら読めた値を 400 で答える (上の Fixed): 営業日は `YYYY-MM-DD` か `YYYY/MM/DD` の暦にある日だけ (action は作成以外の操作でも、送られた営業日を検証する)、ID は正準の 10 進の正の整数だけ、`operationTimestamp` は 0 以上の整数だけ。action が送り返す `operationTimestamp` は、送られなければ `null` (以前は 0)。
48
+ Migration: 営業日は `YYYY-MM-DD` で送る (時刻付きの値は日付の部分を切り出す)。ID と時刻は 10 進の整数の文字列で送り、時刻が無ければ欄ごと省く。
49
+ - server の `service.createDailyReport(userId, businessDate, clientTempId)` は正準のキー (`YYYY-MM-DD`) だけを受け、それ以外にはどのクエリよりも前に `RangeError` (`[daily-report] createDailyReport: businessDate must be a business-date key in the canonical form YYYY-MM-DD naming a day from 0001-01-01 to 9999-12-31; got "<値>"`) を投げる。格納するのはその日の UTC の 0 時。
50
+ Migration: 斜線の形は公開の `normalizeBusinessDateKey` (文字列は同じ厳格な解釈で読む) で正準のキーにしてから渡し、時刻付きの値は日付の部分を切り出してから渡す (action は自分で解釈してから渡す)。
51
+ - client の型 `DailyReportLabels` に必須のキー `listRowPositionInUnknownTotal(position)` が増えた (上の Added)。
52
+ Migration: `DailyReportLabels` を丸ごと組むホスト (試験の代役など) はこのキーも書く。上書き (`DailyReportLabelOverrides`) は部分のままでよい。
53
+ - 読み取りポートの `size` の契約: GET の読み取りで申告するなら `bytes.byteLength` と等しくなければならず、違えば 500 (上の Fixed)。HEAD で申告しなければ、HEAD の応答は `Content-Length` を持たない (以前は `0`)。
54
+ Migration: GET では `size` を省くか `bytes.byteLength` を返し、HEAD では実体の大きさを返す (README の Read port の例はどちらもそうしている)。
55
+ - ピアの `@aiquants/virtualscroll` の下限を 3.11.4 へ上げた (`peer-floors.json`)。両ビューが 3.11.2〜3.11.4 の変更に頼る所は無いが、この版の試験はすべて 3.11.4 の上で通したので、公開する範囲 (`^3.11.4`) と文書の下限をそれに合わせた。
56
+ Migration: `@aiquants/virtualscroll` を 3.11.4 以降へ上げる。
57
+
5
58
  ## 0.30.0 (2026-10-06)
6
59
 
7
60
  0.29.0 の続き: 本体をストリームで返す 2 つの経路のフレームワークのデータの要求をパッケージが断ること (ホストの経路は薄いマウントに戻る)、原本の本文の 1 切れずつの受け渡しと無通信の締め切りと閲覧者ごとのスロットの上限、間隔を単調な時計で測ること、読み取りの予算切れの 504、暦に無い営業日の 400、行とカードのフォーカスのアウトラインをキーボードのフォーカスの属性の下だけで描くこと、整数の装置の画素のホバーの浮き上がり、List のカードの位置の読み上げ、キーの移動の `tabindex` をフォーカスの受け渡しで書くこと、ハンドラーの設定と戻り値の型の公開。どれも README の該当の節が今の契約を記す。
package/README.md CHANGED
@@ -26,7 +26,7 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
26
26
  ```
27
27
 
28
28
  `@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
29
- The peer floor of `@aiquants/virtualscroll` is **3.11.1**: install 3.11.1 or later.
29
+ The peer floor of `@aiquants/virtualscroll` is **3.11.4**: install 3.11.4 or later.
30
30
  Both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
31
31
  see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
32
32
  `DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes `align: "nearest"` since 3.11.0, and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
@@ -167,9 +167,21 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
167
167
  but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
168
168
  React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
169
169
  - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
170
- The package's request isolation refuses them before authentication ([Request isolation](#request-isolation)): on the SSE route every one, and on the API route the ids stream's (`ids-stream.data`, with or without a cursor, and its trailing-slash form `ids-stream/_.data`), `GET` and `HEAD` alike, with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`) thrown the way a loader answers early.
170
+ The package's request isolation refuses them before authentication ([Request isolation](#request-isolation)): on the SSE route every one, and on the API route those of every endpoint whose body streams — the ids stream's (`ids-stream.data`, with or without a cursor, and its trailing-slash form `ids-stream/_.data`) —, `GET` and `HEAD` alike, with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`) thrown the way a loader answers early.
171
171
  A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`) and the action keep serving single fetch, which React Router's own fetchers use.
172
- - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`, `GET` for the JSON endpoints (their ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for the ids stream.
172
+ - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`.
173
+ The endpoint table (`API_ENDPOINT_BODY_KINDS` in `src/server/api-endpoint.ts`) states once what kind of body each endpoint answers, and the methods follow from it (`API_ENDPOINT_METHODS`): `GET` for a JSON body (`report` and `business-date`, whose ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for a streaming body (the ids stream). The API route's refusal of data requests above follows from the same table.
174
+
175
+ **Request values**: the API route and the action read every business date, id and time stamp of a request with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
176
+
177
+ - **Business dates** (`parseBusinessDateKey`): `YYYY-MM-DD`, or `YYYY/MM/DD` with the same separator twice (read as the same day), naming a day of the calendar from 0001-01-01 to 9999-12-31, the range of the database's `date` column.
178
+ Anything else — surrounding whitespace, a time stamp (`2026-10-06T00:00:00.000Z`), a date written as text, a day off the calendar (`2026-02-30`, `2026-13-40`, month 0), the year 0 — is refused: the `business-date` endpoint answers `{"error":{"message":"Invalid business date"}}` (also when `businessDate` is missing), and the action `{"error":"Invalid businessDate"}` for every intent that sends one, `create` included.
179
+ A created report's business date is stored as that day's UTC midnight and echoed as `YYYY-MM-DD` in the report's `date`, whatever the server's time zone.
180
+ - **Ids** (`parseCanonicalPositiveId`): `reportHubId` (the `report` endpoint, and every intent but `create` and `clearCache`) and `commentId` (`deleteComment`) are only the canonical decimal form of a positive safe integer: digits without a leading zero, from 1 to 2^53 − 1.
181
+ `1e3`, `12abc`, ` 7 `, `0x10`, `1.9`, `-3`, `0` and `9007199254740993` (2^53 + 1, which would round to its neighbour and could name another row) are `Invalid reportHubId` / `Invalid commentId`.
182
+ The ids stream's resume cursor follows the same rule, and its business date must be the canonical `YYYY-MM-DD` the server wrote (a malformed cursor is 400 `Invalid cursor`); an attachment token that decodes to anything but a positive safe integer is 400 `Invalid attachment token`.
183
+ - **The action's form**: the action parses the whole form before it resolves the internal user, the viewer's visibility or any intent. A body that is no form (no form media type, a broken multipart body, a body cut short) is `{"error":"Invalid form"}` without an error log line, and a file where a text field is expected is an invalid value, not a missing one.
184
+ The checks run in this order: the business date, the operation timestamp (`operationTimestamp`: the canonical decimal form of a non-negative safe integer, else `Invalid operationTimestamp`), then — for every intent but `clearCache`, which reads nothing more — `clientTempId` (`clientTempId required`), for `create` the business date's presence (`businessDate required`; `create` never reads `reportHubId`), `reportHubId`, `commentId`, and the intent's name (`Invalid intent`). A star or read toggle echoes the timestamp as it was sent, `null` when none was sent.
173
185
 
174
186
  ### DI ports
175
187
 
@@ -180,7 +192,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
180
192
  - `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
181
193
  - `draftLabelName` / `draftLabelNames` — Database label names representing draft states (single string or array of candidates like `["Draft", "Work in Progress"]`). Used for server-side cross-user visibility filtering (hiding drafts from other users) and `isDraft` evaluation.
182
194
  - `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
183
- - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the handler returns `400` before touching the service. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
195
+ - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the action answers `400` (`Invalid intent`) and never calls `service.clearCache()`. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
184
196
  - `attachmentIdCodec` / `readAttachment` / `attachmentThumbnailRenderer` / `attachmentMaxBytes` — Attachment delivery ports and size limit, owned by the service. See [Attachments](#attachments).
185
197
  - `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package as it is. Without it each area logs to the console with its own prefix and lowest level, for example `[DailyReportAttachment]` from `info` (see **Log levels** under [Attachments](#attachments)) and `[DailyReportIsolation]` from `warn` (the refusal record of [Request isolation](#request-isolation)).
186
198
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`, plus the attachment tuning keys listed under [Attachments](#attachments).
@@ -201,10 +213,12 @@ The policy's `frameworkDataRefusal` receives the loader's arguments and returns
201
213
  | --- | --- |
202
214
  | `attachment.loader` (`ATTACHMENT_ISOLATION_POLICY`) | Every `<token>.data` — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is refused with the attachment route's 404 (`Attachment not found`). React Router would read the original into memory and drop every protective header; the 404 carries no attachment content, so it is safe after React Router has replaced its headers, and a caller of the loader itself still sees the attachment security headers |
203
215
  | `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`; `dataRequestRefusal`): the body never ends, and `EventSource` requests the route itself |
204
- | `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names the ids stream (the whole NDJSON would be held); served for the JSON endpoints |
205
- | `api.action` (`NOT_NAVIGABLE`) | Served (React Router's own fetchers write through single fetch) |
216
+ | `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names an endpoint whose body streams in the endpoint table (`API_ENDPOINT_BODY_KINDS`: the ids stream, whose whole NDJSON would be held); served for the JSON endpoints, and for a name that is no endpoint (the route's own 404) |
217
+ | `api.action` (`ACTION_ISOLATION_POLICY`) | Served (React Router's own fetchers write through single fetch) |
206
218
 
207
- All four policies also decide the navigations they serve from another origin and build their own 403 (`crossSiteRejection`), so the route, its refusals and their response builders cannot be paired wrongly.
219
+ Each policy is one frozen `RequestIsolationPolicy` that carries everything the wrapper decides for its route: the name its refusals are recorded under (`route`: `api`, `action`, `sse` or `attachment`), the navigations it serves from another origin (`navigable`), its answer to a data request (`frameworkDataRefusal`) and its 403 (`crossSiteRejection`).
220
+ The wrapper receives only the policy and the handler (`isolated(policy, handler)` in `src/server/handlers.ts`), so a route's record name, its refusals and their response builders come from one object and cannot be paired wrongly. `NOT_NAVIGABLE`, the base of the API, action and SSE policies, has no `route` (`Omit<RequestIsolationPolicy, "route">`), so a policy built on it type-checks only once it names its route.
221
+ The two API policies live in `src/server/handlers.ts`, the API loader's refusal derived from the endpoint table, so an endpoint added to the table with a streaming body is refused from the start; the SSE policy lives in `src/server/sse-delivery.ts` and the attachment policy in `src/server/attachment-delivery/loader.ts`.
208
222
 
209
223
  | Request | Answer |
210
224
  | --- | --- |
@@ -302,7 +316,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
302
316
  - **Variants** form the closed, frozen list `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` (root entry). It has one variant, `tile`: the box 480 × 320 a preview fits in, twice the largest tile frame of 240 × 160 CSS px. `isDailyReportAttachmentThumbnailVariant(value)` accepts the list's own keys only, exactly (`Tile`, `" tile"` and `toString` are not variants).
303
317
  - **Parsing is strict.** `thumbnail` must be one known variant name, `download` must be exactly `1`, the two cannot be combined, and neither may repeat. Anything else — `?thumbnail=1`, `?thumbnail=true`, `?download=true`, `?download=yes`, `?thumbnail=tile&download=1` — answers **400** `{"error":{"message":"Invalid attachment request"}}` right after authentication, before any port or database query runs, and writes no log line. Other parameters are ignored, and names are case-sensitive (`?Download=1` is an unknown parameter, so the request stays inline).
304
318
  - **The original's type**: the declared type is the row's `file_type` when it is a valid media type, otherwise the type the read port reported. An inline-safe declared type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `application/pdf`, `text/plain`) is sent as itself, inline (as an attachment for a download); any other declared type, and a missing one, is sent as `application/octet-stream` with `Content-Disposition: attachment`, because the declared type comes from outside the package and an inline `text/html` or `image/svg+xml` would run script in the host's origin.
305
- - **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only and reports the same `Content-Length` as GET); any other method answers **405** with `Allow: GET, HEAD`. The thumbnail answers `GET` only (see **Thumbnail endpoint** below). Both 405s are checked right after the query, before the ports and the token.
319
+ - **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only, and its `Content-Length` is the size the read port declares, the length a GET sends; a HEAD whose port declares no size sends none); any other method answers **405** with `Allow: GET, HEAD`. The thumbnail answers `GET` only (see **Thumbnail endpoint** below). Both 405s are checked right after the query, before the ports and the token.
306
320
  - **Same-origin loads only**: a request from another origin's page — an `<img>`, a no-cors `fetch`, a `HEAD` probe, an `<iframe>` or `<frame>` from a sibling subdomain, any request for a thumbnail — is refused with 403 before authentication ([Request isolation](#request-isolation);
307
321
  on a host served from a potentially trustworthy origin such as HTTPS, where the browser sends Fetch Metadata), so it learns nothing about a token: a visible and a hidden attachment get the same 403 at the same cost, with no authentication, rate token, visibility resolution or query. The one exception is a top-level `GET` document navigation to an original (a link to an attachment in another page), which is served after authentication and authorization as usual.
308
322
  As defence in depth, every attachment response the route answers itself, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the route answers (400, 401, 403, 404, 405, 413, 429, 500, 502, 503 and 504, for GET and HEAD alike, the isolation's 403 included) —
@@ -351,7 +365,9 @@ type DailyReportReadAttachment = (
351
365
  >
352
366
  ```
353
367
 
354
- - Never throw; return a typed failure. `filePath` stays on the server. With `head: true` read metadata only and declare the real `size` (the HEAD `Content-Length` must match GET).
368
+ - Never throw; return a typed failure. `filePath` stays on the server.
369
+ - **`size` is the size of the whole object.** With `head: true` read metadata only and declare it: it is the HEAD's `Content-Length`, which must match a GET's (RFC 9110 §9.3.2), and a HEAD whose read declares no `size` answers without `Content-Length` rather than claim 0 for an object that has a body.
370
+ A GET's `Content-Length` is the length of the `bytes` it sends, so a read that declares `size` declares exactly `bytes.byteLength`: any other value — a port that measures the size in a separate call can see another version of the object — is a port contract violation, answered 500 and logged at `error` as `reason=port_contract code=size_mismatch`, with nothing sent and no missing- or present-object record.
355
371
  - **Failure reasons** form one closed union (`DailyReportAttachmentFailure`), and each has the status the handlers answer with; `code` is the raw storage code for the log line and never decides a status. A reason outside the union — which a host outside the type check can return — is a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=failure_reason`, never guessed into another status.
356
372
 
357
373
  | Reason | When | Original | Thumbnail |
@@ -696,7 +712,7 @@ The package never writes the file path itself; a `port_exception` line includes
696
712
  | --- | --- |
697
713
  | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
698
714
  | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
699
- | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
715
+ | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
700
716
 
701
717
  `error` is left to failures of the server itself, so an alert on `error` does not fire on user traffic or on upstream storage states.
702
718
 
@@ -758,12 +774,19 @@ An expired cached report is therefore drawn at once, never preceded by `null`, a
758
774
  The first load of a report that is not cached starts inside the hook's effect, with no task between the row's mount and its request, so a held arrow key's stream of keydowns cannot postpone it until the key is released; only the refetch of an expired cached report waits 120 ms, to absorb rows that only scroll past. Loads of reports of one business date share one request.
759
775
 
760
776
  Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route them into your own toast/notification system, or omit it to use the package's built-in `role="alert"` banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the banner work.
777
+ The provider takes no items: it derives its rows from the module-resident ids stream session (below), so establish the session first — `createDailyReportClientLoader` in the route, or `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })` on mount, as `DailyReportPage` does — and mount one provider per user (`key={userId}`), which also starts the new user with an empty editing store (**Editing** in [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)).
761
778
 
762
779
  The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
763
780
  The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
764
781
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
765
782
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
766
783
  `DailyReportPage` subscribes via `useDailyReportIdsStream`, rendering the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
784
+
785
+ - **Rows follow the stream by its changes.** Every items publish of the session carries, with the list, its revision (`itemsRevision`, never reused by another session) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
786
+ `DailyReportActionProvider` keeps its rows in the canonical order (business date descending, then id descending, reports without a date last) and applies only the changes since the revision it last derived, folded into one however many publishes a render receives: rows that arrive after the old last row cost one comparison and a native concatenation (a publish of Δ such rows touches at most Δ + 1 rows, the stream client included), rows that arrive out of order are merged in by binary search, a changed row is replaced in place (⌈log2 n⌉ touches), and only a removal filters the list once.
787
+ It derives from the whole list only on its first render, after the session is replaced, when it falls more than 32 publishes behind, and after the development cache clear drops the tombstones (which brings back the reports they hid). A row the provider shows before the stream holds it — the optimistic row of a report being created, a report the user created, a deletion the server refused and rolled back — stays until the stream confirms it, or until a settled scan published after it lacks it; the optimistic row goes when its creation settles.
788
+ - **Publishes coalesce.** Chunks and every relay into the session — the SSE `report-create`, `report-publish` and `report-delete`, and the user's own creations and deletions — share one 50 ms window: the first change after a quiet window publishes at once, the changes crowded into the window publish once at its end, and the completion of a scan publishes what is pending without waiting.
789
+ A burst of SSE events therefore reaches the list in one publish, at most 50 ms after the first; the user's own creation and deletion show at once all the same, because the provider adds or removes that row itself. The window is measured on a monotonic clock (`performance.now()`), so a wall clock set back does not lengthen it.
767
790
  There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
768
791
 
769
792
  ### View height (host layout)
@@ -783,7 +806,7 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
783
806
  Every change is followed, a sub-pixel one included. A root without a box (not rendered, or detached) reads 0 in Chromium, whose first delivery reports 0 for it; an engine that follows the specification's 0 × 0 starting size reports nothing for such a root, and the value stays `null` until the root has a box. A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
784
807
  - **G-symmetric frame**: the rows' gutter G is inside the view's box, so the last aligned surface — a List or DetailList row aligned to the bottom, or the List's side pane — sits G = 8 px above the page's bottom edge (where a footer that follows the page starts), the distance from the view-mode toolbar to the first surface at the top.
785
808
  `VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
786
- It adds nothing — the surface sits exactly G from the edge — when the view's edge and the row slots are whole numbers of device pixels. The slots are multiples of the 4 px lattice (**Row slots on the lattice**), a whole number of device pixels at the ratios that are multiples of 1/4 (1, 1.25, 1.5, 1.75, 2, 3): at the integer ratios a view's end on a whole CSS pixel is on a device pixel too, so the bottom-aligned surface sits exactly G there, and at 1.25, 1.5 and 1.75 it does when the view's end lies on the lattice.
809
+ It adds nothing — the surface sits exactly G from the edge — when the view's edge and the row slots are whole numbers of device pixels. The slots are multiples of the 4 px lattice (**Row slots on the lattice**), a whole number of device pixels at every ratio that is a multiple of 1/4 (k device pixels at the ratio k/4): at the integer ratios a view's end on a whole CSS pixel is on a device pixel too, so the bottom-aligned surface sits exactly G there, and at the other quarter ratios (1.25, 1.5, 1.75, 2.25, 2.5, 2.75) it does when the view's end lies on the lattice.
787
810
  Anywhere else — a view's end off the lattice at those ratios, or browser zoom such as 0.9, 1.1 or 1.33 — the snap keeps the surface at least G and less than G + 1 device pixel from the end (measured in Chromium: 8.2 px at 1.25 and 8.333 px at 1.5 for a view's end 1 and 3 px off the lattice, 8.091 px at 1.1, 8.052–8.173 px at 1.33, 8.778 px at 0.9).
788
811
  The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the lattice unit, exported as `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px, client entry; the package's own unit, not a copy), so the views' top edge lies on the lattice. It cannot size the window, so the remainder of the window height modulo the unit stays inside the view, whose end lies on the lattice only when the window height is a multiple of the unit.
789
812
  A host that computes its styles in script builds its lengths from the value (the `4px` of `height: calc-size(auto, round(up, size, 4px))` for a bar whose content decides its height); a Tailwind host, whose scanner reads class names only as they are written, writes such a class as a literal and ties it to `DAILY_REPORT_LAYOUT_LATTICE_PX` with a unit test that reads both.
@@ -814,7 +837,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
814
837
  - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
815
838
  keys from outside a row (the scroll bar, the side pane, the mobile overlay); keys from an input region — a `form` (every control of the in-row edit form and of the comment form, their buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`, `video[controls]`) —
816
839
  or from inside ARIA widgets that use these keys themselves (`combobox`, `listbox`, `menu`, `menubar`, `radiogroup`, `slider`, `spinbutton`, `textbox`, `tree`, `grid`, `scrollbar`, `tablist`, `toolbar`, `treegrid`, `separator`) between the key's origin and the view's scroll container, the exits included.
817
- So no key pressed in an open in-row editor moves the list (moving it would scroll the editing row out of the rendered window and discard what was typed). Only elements **inside** the view count: wrapping the whole view in a host `form`, `role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not handled.
840
+ So no key pressed in an open in-row editor moves the list: the keys keep their meaning in the field (the caret, the text selection, the form's buttons), and the row being edited stays where the viewer types. Only elements **inside** the view count: wrapping the whole view in a host `form`, `role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not handled.
818
841
  - **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below) while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported its visible range: the focus owner, then the selection).
819
842
  Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
820
843
  So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its form in the Tab order.
@@ -890,14 +913,17 @@ The keys are delegated to each view's **list**: the element that holds the view'
890
913
  - **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
891
914
  On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new `data-report-id` in the key's own render and carries `aria-busy="true"` until the content catches up, and the content (`data-displayed-report-id`) renders from `useDeferredValue` of the selection, so React draws it when the main thread is free and drops an unfinished catch-up for a newer key.
892
915
  While a held key repeats, the content keeps the report the repeat stream started from and does not render at all (the frame keeps following the selection, with `aria-busy="true"`); it catches up once, when the stream ends.
893
- The content is keyed by the displayed report: each change of the displayed report mounts it exactly once, so nothing of one report (its loaded detail, an open editor, its comments, its thumbnails) renders under the next, and coming back to a report mounts its attachments once (one conditional request per loaded thumbnail). The chosen tab (article or relations) belongs to the pane, and to the mobile overlay, and is kept across reports.
916
+ The content is keyed by the displayed report: each change of the displayed report mounts it exactly once, so nothing of one report (its loaded detail, an open editor, its comments, its thumbnails) renders under the next, and coming back to a report mounts its attachments once (one conditional request per loaded thumbnail) and shows an editor it left open as it was left, with what was typed (**Editing** below). The chosen tab (article or relations) belongs to the pane, and to the mobile overlay, and is kept across reports.
894
917
  Auto-read and the article tab's scroll to the top follow the displayed report. Auto-read marks an unread report read once the pane has dwelt on it for 500 ms: on the desktop layout while the pane has caught up with the selection (the displayed report is the selected one and the pane is not `aria-busy`), in the mobile overlay while the overlay is open. The clock stops when the pane stops dwelling (a newer key, a held key's stream, the overlay sliding out)
895
918
  and starts over when it dwells again, so a report that was only selected — never displayed —, passed over with a held key, or opened in the overlay and closed within 500 ms stays unread. A report whose read state the viewer toggled is not marked again while it stays displayed.
896
919
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
897
920
  The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE, from another user or another tab, never moves what the viewer is reading.
898
921
  - **Screen readers**: each list references `labels.listKeyboardHelp` (visually hidden) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`); there is no live region, announcements come from focus.
899
922
  The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en "3 of 40", ja 「全 40 件中 3 件目」; the row frame carries `aria-posinset` / `aria-setsize`, which the focused button cannot carry), then by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected.
900
- The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
923
+ The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body.
924
+ The total the position names is the size of the set the rows belong to — every row frame's `aria-setsize` and the header's total count read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
925
+ while the ids stream delivers, the total the server declared on the stream's first line (never fewer than the rows shown, and unchanged as chunks arrive); before the server has declared it, unknown — `aria-setsize="-1"`, the header shows `labels.totalCountLoading`, and the position reads `labels.listRowPositionInUnknownTotal` (en "Item 3", ja 「3 件目」); once the scan has completed, the rows shown.
926
+ A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
901
927
  The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
902
928
  - **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
903
929
  The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date alone), and `labels.author` with the author's name; each label and each value is read once, and the source badge sits beside the pair, outside it. The overlay names its dialog by the heading and the author's value together (`<date> <author>`, the name of the List card).
@@ -907,7 +933,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
907
933
  Every ISO date of a DetailList card — the header chips' business date and updated at, the metadata column's created at, updated at and business date — is a `<time datetime>` carrying that date, through one component (`DateText` in `src/client/components/detail-list/date-text.tsx`), so a row never exposes the same date with two semantics; a missing date's `-` stays text.
908
934
  Both tab lists are named by what they switch (`aria-label`): the view-mode tabs by `labels.viewTabList`, the side pane's tabs by `labels.reportTabList` (the report heading names only the business date, which reports of the same day share).
909
935
  - **Editing**: on the viewer's own reports, an Edit button (`labels.edit`, `data-detail-edit-button`, a pencil icon) stands before Delete in the DetailList header and in the side pane's action row. It is the keyboard way into the editor; a double click is the pointer shortcut. Entering the editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (when the button is not rendered, the DetailList row takes focus itself and the side pane focuses its report heading).
910
- A draft that opens in the editor by itself does not move focus. The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
936
+ A draft of the viewer's own opens in the editor by itself, in a DetailList row and in the side pane alike (another user's draft never does), without moving focus; it closes by itself only when the report turns published, and an editor the viewer opened stays open then.
937
+ The editor's state lives in one editing store per action provider, by report (`src/client/contexts/daily-report-draft-store.ts`), not in the row or the pane: whether the editor is open, whether it opened by itself, and the typed title and body.
938
+ So an open editor survives everything that unmounts what draws it — a DetailList row leaving the rendered window (a wheel, a drag, the scroll bar, a tap scroll, keys from another row, a host selection) and the side pane or the mobile overlay showing another report — and comes back with what was typed. Typing re-renders only the form, never the row, the pane's content or the list.
939
+ An SSE update of the same report never overwrites the typed values, and Save keeps them (what was saved is what is being typed). Cancel drops them and keeps a draft of the viewer's own from opening by itself again; a successful Publish and the report's deletion — by the viewer, by an SSE `report-delete`, or the removal of a report that no longer loads — drop the report's entry, while a deletion the server refuses keeps it along with the report.
940
+ The store lives as long as the provider, so leaving the page, a reload and a user switch empty it (a user never sees another user's typing).
941
+ The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
911
942
  - **Deleting a comment**: the trash button of the viewer's own comment is a disclosure (`aria-expanded`; while open, `aria-controls` names the confirmation pill it shows under itself). The confirmation has no time limit (WCAG 2.2.1): it stays open until it is confirmed or cancelled — by pressing the trash button again, by Escape (the comment list takes it before the mobile overlay while the confirmation is open; an Escape that belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
912
943
  A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
913
944
  Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — the row in the DetailList (through the view's focus request), the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
@@ -940,14 +971,15 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
940
971
 
941
972
  - **Row gutter G = 8 px** on all four sides of both row frames: G = 8 ≥ ring 2 + separation 2 + outline 2 + hover lift L, with L ≤ 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift's values), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
942
973
  a DetailList row is its measured body plus 2G = 16.
943
- The lift L is the largest whole number of device pixels within 2 CSS px at the device-pixel ratio dpr, ⌊2·dpr⌋ / dpr: 2 px at 1, 1.5, 2 and 3, 1.6 px (2 device pixels) at 1.25 and 12/7 px (3 device pixels) at 1.75.
944
- A 2 px lift would be 2.5 and 3.5 device pixels at 1.25 and 1.75, which puts the hovered surface on a half device pixel and blends its 1 px border, the ring and the outline into the next row of device pixels.
945
- The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden under the media conditions `resolution: 1.25dppx` and `resolution: 1.75dppx`), and the lift reads it (`translate-y-[calc(-1*var(--aqdr-hover-lift))]`); every class is a literal, so a host's Tailwind build and the package's standalone stylesheet compile the same rules.
974
+ The lift L is the largest whole number of device pixels within 2 CSS px at the device-pixel ratio dpr, ⌊2·dpr⌋ / dpr, over the ratios the layout lattice is built for: every multiple of 1/4 from 1 to 3. Where 2·dpr is whole (1, 1.5, 2, 2.5, 3) L is 2 px; at 1.25, 1.75, 2.25 and 2.75 it is 1.6 px (2 device pixels), 12/7 px (3), 16/9 px (4) and 20/11 px (5).
975
+ A 2 px lift would be 2.5, 3.5, 4.5 and 5.5 device pixels at those four ratios, which puts the hovered surface on a half device pixel and blends its 1 px border, the ring and the outline into the next row of device pixels.
976
+ The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden once under each of the media conditions `resolution: 1.25dppx`, `1.75dppx`, `2.25dppx` and `2.75dppx` with `calc(⌊2·dpr⌋px / dpr)`), and the lift reads it (`translate-y-[calc(-1*var(--aqdr-hover-lift))]`); every class is a literal, so a host's Tailwind build and the package's standalone stylesheet compile the same rules.
946
977
  - **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 4 · ⌈(2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R) / 4⌉ = 4 · ⌈(52 px + 6.75 rem) / 4⌉, the sum rounded up to the 4 px layout lattice (`listRowSlotHeight`, `LAYOUT_LATTICE_PX`), so every row top stays on the lattice.
947
978
  R is read from the root element's computed style and read again whenever a hidden 1 rem probe inside the List (`data-daily-report-root-font-size-probe`) changes size, and the List renders its `VirtualScroll` only once P is known (measured before the first paint).
948
979
  That one value sizes the row frames, `VirtualScroll`'s rows and the keyboard's row geometry, and when it changes the List keeps the first visible row in place by rescaling the scroll position. At the default 16 px root the sum is exactly 160, so P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits 8 px from the edge (exactly 8 when the view's height is a whole number of device pixels, see **G-symmetric frame**). Roots of 12, 20 and 24 px give 136, 188 and 216 (the sums 133, 187 and 214, rounded up), so the card's spare space grows by less than 4 px.
949
980
  P is not a host contract: it follows the host's root font size, so a host or a test locates a row by `[data-daily-report-row="<id>"]` or through the test handle (below), never by its index times a slot height.
950
- - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot, the DetailList estimate and the attachment tile's pad all read), which is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
981
+ - **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot, the DetailList estimate and the attachment tile's pad all read), which is a whole number of device pixels at every ratio that is a multiple of 1/4 (k at the ratio k/4: 4, 5, 6, 7 and 8 at 1, 1.25, 1.5, 1.75 and 2),
982
+ so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
951
983
  The List slot is P above. A DetailList row is its measured body plus 2G, and the body — the frame's direct child in every state: the card, the skeleton, the edit form and the error — rounds its content height up to the lattice (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, 4px))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) leave the body on the lattice; at a 16 px root the content is already on it. Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
952
984
  A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX` = 88 u), also on the lattice, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
953
985
  - **List card**: a wrapping row of the primary button and the action row (markers and toggles), with the preview always on the next line. The primary button takes the remaining width but never less than 96 px, enough for the business-date pill, and the action row keeps to the card's end; on a card too narrow for both (a phone with a pinned host menu), the action row wraps under the button instead of squeezing it to nothing, and the card clips the preview lines that no longer fit.
@@ -1026,7 +1058,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1026
1058
  **The 12 px tier**: every panel with a 12 px corner has one inset, 12, equal to its corner, so its content box's corner is concentric with the panel's — the reading panel, the DetailList metadata column and the DetailList skeleton's inner panel (`p-3`, no border), the placeholder panel, a posted comment and the bordered pill (1 + 11); a 24 px pill has the same 12 px padding at its round ends. The tab bars are not in that tier: their 4 px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
1027
1059
  The only other exception is the visually hidden text. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
1028
1060
  - **Origin on the 4 px lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of 4 px (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),4px))]`, part of `VIEW_COLUMN_CLASS_NAME`, so the loading and load-error screens start there too), less than 4 px left of the exact centre.
1029
- 4 CSS px is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
1061
+ 4 CSS px is a whole number of device pixels at every ratio that is a multiple of 1/4 (k at the ratio k/4: 4, 5, 6, 7 and 8 at 1, 1.25, 1.5, 1.75 and 2), so every inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
1030
1062
  Plain centring puts the column on a half pixel whenever the space beside it is odd (x = 56.5 in a 1,265 px area), and at a fractional ratio each 2 px stroke then blends into its neighbours.
1031
1063
  On the lattice, each 2 px stroke of the two-channel indicator — the ring, the separation band and the outline — paints ⌊2 × ratio⌋ full device pixels on the left and right edges (2, 3 and 3 at 1.25, 1.5 and 1.75) with no blended pixel on its inner side, and the 1 px border paints its own colour (measured in Chromium at those ratios, light and dark). An engine without CSS `round()` drops the declaration and keeps the `mx-auto` centre.
1032
1064
  The block-axis origin and the view's height are the host's: the views start and end where the host's layout puts them.
@@ -1141,7 +1173,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
1141
1173
 
1142
1174
  | Hook | Element |
1143
1175
  | --- | --- |
1144
- | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`), in every load state |
1176
+ | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
1145
1177
  | `[data-daily-report-row-surface]` | Surface of a row frame: its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
1146
1178
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
1147
1179
  | `button[data-daily-report-button="<id>"]` | List card primary button (selection, focus target, `aria-current`) |
@@ -1183,7 +1215,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1183
1215
  ```
1184
1216
 
1185
1217
  - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1186
- `@aiquants/virtualscroll` plus 84 own keys: field headings, the page title (`title`, passed to
1218
+ `@aiquants/virtualscroll` plus 85 own keys: field headings, the page title (`title`, passed to
1187
1219
  `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List card's position read to
1188
1220
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1189
1221
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
@@ -1192,17 +1224,17 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1192
1224
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1193
1225
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1194
1226
  re-render the memoized list subtree.
1195
- - **Formatter keys.** Eleven keys take arguments and are functions: `totalCount(count)`,
1227
+ - **Formatter keys.** Twelve keys take arguments and are functions: `totalCount(count)`,
1196
1228
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
1197
1229
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
1198
1230
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
1199
1231
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
1200
- `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })` and `listRowPosition(position, total)`. An override of such a key must be a function too.
1232
+ `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })`, `listRowPosition(position, total)` and `listRowPositionInUnknownTotal(position)`. An override of such a key must be a function too.
1201
1233
  - **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
1202
1234
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1203
1235
  the raw number and formats it itself.
1204
1236
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1205
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 95 keys), a string
1237
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 96 keys), a string
1206
1238
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1207
1239
  function. An `undefined` value keeps the catalog value.
1208
1240
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1239,7 +1271,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1239
1271
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1240
1272
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1241
1273
 
1242
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 95 keys, the 11 engine keys
1274
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 96 keys, the 11 engine keys
1243
1275
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1244
1276
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1245
1277
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1287,7 +1319,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1287
1319
  | `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
1288
1320
  | `viewTabList` | accessible name (`aria-label`) of the top-level tab list | Views | 表示の切り替え |
1289
1321
  | `treeComingSoon` | tree tab body | The tree view is coming soon | ツリーは現在準備中です |
1290
- | `totalCountLoading` | header annotation while loading | Total: loading... | 総件数: 読み込み中... |
1322
+ | `totalCountLoading` | header annotation while the size of the views' set is unknown (the loading screen, and the loaded screen before the ids stream declares its total) | Total: loading... | 総件数: 読み込み中... |
1291
1323
  | `createReport` | create button | New report | 日報作成 |
1292
1324
  | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
1293
1325
  | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
@@ -1331,7 +1363,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1331
1363
  | `commentDeleteConfirm` | comment delete confirmation | Delete | 削除する |
1332
1364
  | `unknownUser` | author name when none is known (legacy / relational comment without a name, optimistic data of a user without a name) | Unknown | 不明なユーザー |
1333
1365
  | `sourceInternal` | built-in `Internal` badge (only when `sourceTypeConfigs` is omitted) | Original | オリジナル |
1334
- | `totalCount` | header annotation | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
1366
+ | `totalCount` | header annotation: the size of the views' set (the declared total while the ids stream delivers, the rows once it completes) | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
1335
1367
  | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
1336
1368
  | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
1337
1369
  | `streamProgressRatio` | stream badge once the total is known | `(1234, 5000, 25)` → Loading 1,234 / 5,000 (25%) | `(1234, 5000, 25)` → 読み込み中 1,234 / 5,000 (25%) |
@@ -1341,7 +1373,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1341
1373
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
1342
1374
  | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1343
1375
  | `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1344
- | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
1376
+ | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based; `total` is the size of the views' set) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
1377
+ | `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition` | `(3)` → Item 3 | `(3)` → 3 件目 |
1345
1378
 
1346
1379
  ### External Source Badge Configuration (`sourceTypeConfigs`)
1347
1380
 
@@ -1466,12 +1499,12 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1466
1499
  - **shared** (`@aiquants/daily-report`, isomorphic):
1467
1500
  - Values: `buildDailyReportAttachmentUrl` / `parseDailyReportAttachmentQuery` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` / `isDailyReportAttachmentThumbnailVariant` (the attachment URL codec and the variant list), `DAILY_REPORT_SSE_TERMINAL_EVENTS` / `DAILY_REPORT_SSE_HEARTBEAT_MS` / `DAILY_REPORT_SSE_CURSOR_PARAM` / `isDailyReportSseStreamId` (the SSE wire constants),
1468
1501
  `dailyReportSseMessageSchema` (8 discriminated union types) with its members `connectedMessageSchema` / `reportCreateMessageSchema` / `reportUpdateMessageSchema` / `reportPublishMessageSchema` / `reportDeleteMessageSchema` / `statusUpdateMessageSchema` / `commentAddMessageSchema` / `commentDeleteMessageSchema` and the part schemas `dailyReportDetailSchema` / `dailyReportPostedCommentSchema` / `dailyReportExternalCommentSchema` / `dailyReportInterviewerSchema` / `dailyReportLabelDefSchema`,
1469
- `isIdsStreamChunkLine`, `normalizeBusinessDateKey`, `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`.
1502
+ `isIdsStreamChunkLine`, `normalizeBusinessDateKey` (a `Date`'s local calendar day, or a string read by the strict parser of **Request values**; `null` for anything else), `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`.
1470
1503
  - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
1471
1504
  - **client** (`@aiquants/daily-report/client`, React):
1472
1505
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
1473
- - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
1474
- - The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1506
+ - Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`, and `itemsRevision` / `itemsChanges`, the revision and the latest net changes of `items`).
1507
+ - The ids stream: `DailyReportIdsStreamClient` (`resync()`, `has(reportHubId)`; its `now` option is the monotonic clock of the publish window) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
1475
1508
  - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
1476
1509
  - Layout: `DAILY_REPORT_LAYOUT_LATTICE_PX`, the 4 px lattice unit a host sizes its bars on (see **G-symmetric frame**); it is the only public layout value. The row geometry is not public: it follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
1477
1510
  - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`, and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` (see [Test hooks](#test-hooks)).
package/dist/client.d.mts CHANGED
@@ -23,6 +23,7 @@ type DailyReportDetailListScrollRestore = {
23
23
  };
24
24
  type DailyReportDetailListProps = {
25
25
  dailyReportItems: DailyReportItem[];
26
+ rowSetSize?: number;
26
27
  userId?: string | null;
27
28
  selectedItemId?: number | null;
28
29
  onSelectItem?: (id: number | null) => void;
@@ -32,6 +33,7 @@ declare const DailyReportDetailList: (props: DailyReportDetailListProps) => reac
32
33
  declare const DailyReportIdsStreamStatus: () => react.JSX.Element | null;
33
34
  type DailyReportListProps = {
34
35
  dailyReportItems: DailyReportItem[];
36
+ rowSetSize?: number;
35
37
  autoMarkRead?: boolean;
36
38
  selectedReportHubId: number | null;
37
39
  onSelectItem: (reportHubId: number | null) => void;
@@ -129,6 +131,7 @@ type DailyReportLabels = VirtualScrollLabels & {
129
131
  readonly isStarred: boolean;
130
132
  }) => string;
131
133
  readonly listRowPosition: (position: number, total: number) => string;
134
+ readonly listRowPositionInUnknownTotal: (position: number) => string;
132
135
  };
133
136
  type DailyReportLabelOverrides = {
134
137
  readonly [K in keyof DailyReportLabels]?: DailyReportLabels[K];
@@ -228,7 +231,8 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
228
231
  "interviewerWithAffiliation",
229
232
  "operationFailed",
230
233
  "rowState",
231
- "listRowPosition"
234
+ "listRowPosition",
235
+ "listRowPositionInUnknownTotal"
232
236
  ];
233
237
  declare const DAILY_REPORT_LABEL_CATALOGS: Readonly<Record<DailyReportLocale, DailyReportLabels>>;
234
238
  declare const resolveDailyReportLabels: (locale: DailyReportLocale | undefined, overrides: DailyReportLabelOverrides | undefined) => DailyReportLabels;
@@ -334,10 +338,9 @@ declare global {
334
338
  var __DailyReportActionContext: Context<DailyReportActionContextType | null> | undefined;
335
339
  }
336
340
  declare const useDailyReportActionContext: () => DailyReportActionContextType;
337
- declare const DailyReportActionProvider: ({ children, user, initialItems, userId }: {
341
+ declare const DailyReportActionProvider: ({ children, user, userId }: {
338
342
  children: ReactNode;
339
343
  user?: DailyReportUser;
340
- initialItems?: DailyReportItem[];
341
344
  userId?: string | null;
342
345
  }) => react.JSX.Element;
343
346
  declare const DailyReportErrorProvider: ({ children }: {
@@ -378,10 +381,23 @@ declare const createDailyReportClientLoader: <TServerData extends Record<string,
378
381
  }): Promise<TServerData>;
379
382
  hydrate: true;
380
383
  };
384
+ type DailyReportIdsStreamItemReplacement = {
385
+ readonly previous: DailyReportItem;
386
+ readonly next: DailyReportItem;
387
+ };
388
+ type DailyReportIdsStreamItemsChange = {
389
+ readonly base: number;
390
+ readonly revision: number;
391
+ readonly appended: readonly DailyReportItem[];
392
+ readonly replaced: readonly DailyReportIdsStreamItemReplacement[];
393
+ readonly removed: readonly DailyReportItem[];
394
+ };
381
395
  type DailyReportIdsStreamPhase = "idle" | "streaming" | "retrying" | "complete" | "failed" | "auth-required";
382
396
  type DailyReportIdsStreamState = {
383
397
  items: DailyReportItem[];
384
398
  loadedCount: number;
399
+ itemsRevision: number;
400
+ itemsChanges: readonly DailyReportIdsStreamItemsChange[];
385
401
  totalCount: number | null;
386
402
  phase: DailyReportIdsStreamPhase;
387
403
  isRevalidating: boolean;
@@ -402,6 +418,7 @@ type DailyReportIdsStreamClientOptions = {
402
418
  readStallTimeoutMs?: number;
403
419
  warn?: (...args: unknown[]) => void;
404
420
  publishCoalesceMs?: number;
421
+ now?: () => number;
405
422
  };
406
423
  declare class DailyReportIdsStreamClient {
407
424
  private readonly apiBasePath;
@@ -428,7 +445,9 @@ declare class DailyReportIdsStreamClient {
428
445
  private readonly handleOnline;
429
446
  private readonly publishCoalesceMs;
430
447
  private publishCoalesceTimer;
448
+ private readonly now;
431
449
  private lastItemsPublishAt;
450
+ private pendingItemsChange;
432
451
  constructor(options?: DailyReportIdsStreamClientOptions);
433
452
  getState(): DailyReportIdsStreamState;
434
453
  subscribe(listener: () => void): () => void;
@@ -440,13 +459,17 @@ declare class DailyReportIdsStreamClient {
440
459
  resync(): void;
441
460
  applyExternalUpsert(item: DailyReportItem): void;
442
461
  applyExternalRemoval(reportHubId: number): void;
462
+ has(reportHubId: number): boolean;
443
463
  dispose(): void;
444
464
  private runStreamLoop;
445
465
  private pruneUnseenItems;
446
466
  private streamOnce;
447
467
  private readWithStallGuard;
448
468
  private absorbChunkLine;
469
+ private putItem;
470
+ private deleteItem;
449
471
  private absorbStreamAnchor;
472
+ private takePendingItems;
450
473
  private publishItems;
451
474
  private schedulePublishItems;
452
475
  private cancelScheduledPublishItems;