@aiquants/daily-report 0.20.1 → 0.22.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/README.md CHANGED
@@ -25,7 +25,7 @@ External projects:
25
25
  pnpm add @aiquants/daily-report react react-dom react-router drizzle-orm zod
26
26
  ```
27
27
 
28
- `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest).
28
+ `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. `@aiquants/sse` (the SSE wire contract, server response helpers and the reopening client) is a regular **dependency**: it arrives transitively, so consumers do not declare it. Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest).
29
29
 
30
30
  ### Tailwind CSS
31
31
 
@@ -156,7 +156,7 @@ createDailyReportServer({
156
156
  - **How it reaches the service**: the resolved set is wrapped in a `DailyReportViewerScope` and passed as the **first** argument of every gated service method.
157
157
  The class is nominal (it holds a private field), so `{ visible: null }`, `{ ...scope, visible: null }` and `new DailyReportViewerScope(...)` are all type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` only, so it cannot be handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`.
158
158
  Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in a codebase, each carrying a written reason.
159
- - **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast — cache grants per session/user with a short TTL (≤ 60s). A live SSE connection re-resolves on its existing keep-alive tick (~60s) to bound how long a revoked grant keeps streaming; that call passes `reason: "refresh"` and reuses the connection's original `Request`, so a cache keyed on request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set.
159
+ - **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast — cache grants per session/user with a short TTL (≤ 60s). A live SSE connection re-resolves on a dedicated 60 s timer (independent of the heartbeat) to bound how long a revoked grant keeps streaming; that call passes `reason: "refresh"` and reuses the connection's original `Request`, so a cache keyed on request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set.
160
160
  - **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within ~60s. Events dropped before a widening are not replayed; the next read (which re-resolves per request) restores them.
161
161
 
162
162
  ### Comment visibility
@@ -211,11 +211,13 @@ export default function Route() {
211
211
  user={user}
212
212
  userId={userId}
213
213
  config={{
214
+ onSessionExpired: () => navigate(0), // Required: the host decides the navigation on session expiry
214
215
  renderHeader: ({ title, annotation }) => <YourHeader title={title} annotation={annotation} />,
215
216
  showDevControls: import.meta.env.DEV, // Toggle SSE subscriptions, etc.
216
- draftLabelName: "Draft", // Label name for draft states (matches target DB)
217
- fieldLabels: { // Card/detail header labels (customizable per key)
218
- businessDate: "Date", content: "Body", comments: "Comments", /* ... override to match app locale */
217
+ draftLabelName: "Draft", // Label name for draft states (matches target DB; not tied to locale)
218
+ locale: "en", // UI chrome language: "en" (default) | "ja"
219
+ labels: { // Optional per-key overrides over the catalog of `locale`
220
+ title: "Field Reports", content: "Body",
219
221
  },
220
222
  onError: (info) => yourToast(info.message), // Optional: route mutation failures to your own toast
221
223
  // apiBasePath: "/daily_report/api", ssePath: "/sse/daily_report/updates"
@@ -226,6 +228,8 @@ export default function Route() {
226
228
  }
227
229
  ```
228
230
 
231
+ `config` is **required** and so is its `onSessionExpired` port (omitting it is a type error, TS2741; a non-function throws a `TypeError` at render). The package never navigates by itself: when the session is gone — the SSE stream ends with `auth-required`, or the ids stream answers 401 / 403 — it calls `onSessionExpired()` exactly once per occurrence and stops. A typical host reloads the route (`navigate(0)`) so its own loader redirects to the login page. `DailyReportConfigProvider` takes the same required `config`; outside a provider, the default configuration's `onSessionExpired` throws.
232
+
229
233
  `DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
230
234
 
231
235
  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.
@@ -236,10 +240,230 @@ If your app overrides `config.apiBasePath`, pass the same value to `createDailyR
236
240
  `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).
237
241
  There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
238
242
 
239
- ### Field Labels (`fieldLabels`)
243
+ ### Localization (`locale` / `labels`)
244
+
245
+ Every string the package renders on its own comes from one built-in catalog per **UI chrome
246
+ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language with
247
+ `config.locale` and override single keys with `config.labels`, which are laid over the catalog of
248
+ `locale`:
249
+
250
+ ```tsx
251
+ <DailyReportPage
252
+ config={{
253
+ locale: "ja",
254
+ labels: { content: "商談内容", comments: "コメント一覧", tabArticle: "記事" },
255
+ }}
256
+ />
257
+ ```
240
258
 
241
- Card and detail headings (`id` / `businessDate` / `author` / `visitTime` / `createdAt` / `updatedAt` / `updatedBy` / `category` / `categoryInfo` / `creationCategory` / `customer` / `subject` / `content` / `interviewers` / `comments` / `attachments` / `attachmentMissing` / `attachmentDownload` / `tabArticle` / `tabRelations` / `relationsEmpty` — all 21 keys of `DailyReportFieldLabels`) are fully dependency-injected.
242
- **The package includes neutral English defaults** (`Business date` / `Customer` / `Content`, etc.), and consuming applications override headings via `fieldLabels`. This ensures the package itself carries no domain-specific wording. Partial overrides merge deeply over defaults (`DailyReportConfigProvider` performs a 1-level deep merge).
259
+ - **One surface for all wording.** The catalog covers the 7 engine chrome keys of
260
+ `@aiquants/virtualscroll` plus 81 own keys: field headings, the page title (`title`, passed to
261
+ `renderHeader` and used as the accessible name of both lists), tabs, buttons, placeholders,
262
+ `confirm()` / `alert()` texts, stream badges, the load-error screen and the mutation-failure
263
+ message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
264
+ value, so the scroll arrows and the "No items" text always speak the same language as the rest.
265
+ - **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
266
+ and the 7 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
267
+ its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
268
+ re-render the memoized list subtree.
269
+ - **Formatter keys.** Nine keys take arguments and are functions: `totalCount(count)`,
270
+ `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
271
+ `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
272
+ `interviewerWithAffiliation(name, affiliation)` and `operationFailed(operation)` (`operation` is a
273
+ `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
274
+ `toggleStar` / `toggleRead`). An override of such a key must be a function too.
275
+ - **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
276
+ the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
277
+ the raw number and formats it itself.
278
+ - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
279
+ `RangeError` at render. So do an unknown `labels` key (including the removed `fieldLabels`
280
+ container), a string key whose value is not a string with non-whitespace content, and a formatter
281
+ key whose value is not a function. An `undefined` value keeps the catalog value.
282
+ - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
283
+ `sourceTypeConfigs`, `renderHeader`, `showDevControls`, `onError`. The removed keys throw a
284
+ `RangeError` that names the replacement — `config.fieldLabels was removed in 0.21.0; use
285
+ config.labels (the same 21 keys)` and `config.title was removed in 0.21.0; use config.labels.title`
286
+ — even when their value is `undefined`. Any other unknown key throws a `RangeError` listing the
287
+ accepted keys. This runs at render because a config built outside a typed object literal (a
288
+ `useMemo` result, a variable) skips TypeScript's excess-property check.
289
+ - **`defaultDailyReportClientConfig` is spreadable input.** Its type `DailyReportClientConfigDefaults`
290
+ holds only the locale-independent defaults (`apiBasePath`, `ssePath`, `draftLabelName`,
291
+ `renderHeader`, `showDevControls`) and it is frozen. It carries no `locale`, `labels` or
292
+ `sourceTypeConfigs`, so `{ ...defaultDailyReportClientConfig, locale: "ja" }` renders Japanese. The
293
+ resolved configuration from `useDailyReportConfig()` is output only: spreading it into `config`
294
+ would pin its labels as overrides.
295
+ - **Unknown authors**: a comment whose author name is unknown — a legacy comment without a name, a
296
+ relational comment with an empty `userName`, the pending comment of a viewing user without any name
297
+ or email — shows `labels.unknownUser`. The server never sends a fixed fallback name (an author
298
+ without a display name is sent as `""`).
299
+ - **`labels` must be a plain object** (prototype `Object.prototype` or `null`); arrays, class
300
+ instances and objects created with `Object.create(proto)` throw a `RangeError`. Only own properties
301
+ are read, so a label key inherited from a polluted `Object.prototype` is never applied. Error
302
+ messages describe the rejected value without throwing (a string as JSON, `null`, otherwise only its
303
+ `typeof` tag).
304
+ - **No language negotiation**: the package never reads `navigator.language`; map it to a supported
305
+ value yourself. No `lang` attribute is stamped either — the document language belongs to the host.
306
+ - **Not tied to `locale`**: `draftLabelName` (a label name stored in the database) and
307
+ `sourceTypeConfigs` (host data). Only the built-in default badge map used when `sourceTypeConfigs`
308
+ is omitted takes its `Internal` label from `labels.sourceInternal`.
309
+ - **Host notifications**: `config.onError` receives `DailyReportErrorInfo` with the stable
310
+ `operation` key and `message` = `labels.operationFailed(operation)` in the chosen locale.
311
+ - **`locale` is the only language axis.** This package has no BCP 47 `formatLocale` option: digit
312
+ grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
313
+ that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
314
+
315
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 88 keys, engine keys
316
+ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
317
+ (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
318
+ `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
319
+ `DailyReportLabels`, `DailyReportLabelOverrides` and `DailyReportOperation`.
320
+
321
+ | Key | Where it appears | `en` | `ja` |
322
+ | --- | --- | --- | --- |
323
+ | `scrollUp` | engine: vertical ScrollBar up arrow (`aria-label`) | Scroll up | 上へスクロール |
324
+ | `scrollDown` | engine: vertical ScrollBar down arrow (`aria-label`) | Scroll down | 下へスクロール |
325
+ | `scrollLeft` | engine: horizontal arrow (not rendered by this package) | Scroll left | 左へスクロール |
326
+ | `scrollRight` | engine: horizontal arrow (not rendered by this package) | Scroll right | 右へスクロール |
327
+ | `scrollToTop` | engine: top pill (not enabled by this package) | Top | 先頭へ |
328
+ | `scrollToBottom` | engine: bottom pill (not enabled by this package) | Bottom | 末尾へ |
329
+ | `noItems` | engine: empty list text | No items | 項目がありません |
330
+ | `id` | DetailList card: id chip | ID | ID |
331
+ | `businessDate` | business date heading | Business date | 営業日 |
332
+ | `author` | author heading | Author | 作成者 |
333
+ | `visitTime` | visit time heading | Visit time | 訪問時間 |
334
+ | `createdAt` | created-at heading | Created at | 作成日時 |
335
+ | `updatedAt` | updated-at heading | Updated at | 更新日時 |
336
+ | `updatedBy` | updated-by heading | Updated by | 更新者 |
337
+ | `category` | category chip / `category: value` prefix | Category | 区分 |
338
+ | `categoryInfo` | metadata panel: category block heading | Category | 区分情報 |
339
+ | `creationCategory` | `creationCategory: value` prefix | Creation category | 作成区分 |
340
+ | `customer` | customer heading | Customer | 顧客 |
341
+ | `subject` | subject heading | Subject | 件名 |
342
+ | `content` | content heading | Content | 内容 |
343
+ | `interviewers` | interviewer list heading | Attendees | 面談者 |
344
+ | `comments` | comment section heading | Comments | コメント |
345
+ | `attachments` | attachment section heading, attachment marker name | Attachments | 添付ファイル |
346
+ | `attachmentMissing` | note on a missing attachment | File is missing | 実体が見つかりません |
347
+ | `attachmentDownload` | attachment download link | Download | ダウンロード |
348
+ | `tabArticle` | side pane tab | Overview | 概要 |
349
+ | `tabRelations` | side pane tab | Related | 関連 |
350
+ | `relationsEmpty` | side pane related tab body | No related view configured yet. | 関連ビューはまだ構成されていません。 |
351
+ | `title` | `renderHeader({ title })`, both lists' `aria-label` | Daily Reports | 日報 |
352
+ | `tabList` | top-level tab | 📄 List | 📄 一覧 |
353
+ | `tabTree` | top-level tab | 🌳 Tree | 🌳 ツリー |
354
+ | `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
355
+ | `treeComingSoon` | tree tab body | The tree view is coming soon | ツリーは現在準備中です |
356
+ | `totalCountLoading` | header annotation while loading | Total: loading... | 総件数: 読み込み中... |
357
+ | `createReport` | create button | New report | 日報作成 |
358
+ | `createReportTitle` | create button tooltip | Create a daily report | 日報を作成 |
359
+ | `devControls` | dev toolbox tooltip (`showDevControls`) | Development-only controls | 開発環境限定コントロール |
360
+ | `devSseToggle` | dev toolbox: SSE switch tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
361
+ | `devReload` | dev toolbox: reload tooltip | Reload data | データ再取得 (リロード) |
362
+ | `devClearCache` | dev toolbox: clear-cache tooltip | Clear the cache and reload | キャッシュ破棄 & 再取得 |
363
+ | `autoRead` | auto-read switch label | Auto-read | 自動既読 |
364
+ | `autoReadTitle` | auto-read switch tooltip | Mark reports as read automatically while viewing them | 日報閲覧時の自動既読処理切替 |
365
+ | `loadErrorBadge` | header annotation on load failure | Error | エラー |
366
+ | `loadError` | load-failure screen | Failed to load the daily reports. | 日報データの読み込みに失敗しました。 |
367
+ | `reload` | load-failure screen button | Reload | 再読み込み |
368
+ | `loadingIds` | loading screen | Loading report IDs... | 日報 ID を読み込み中です... |
369
+ | `streamFailed` | stream badge (failed) | Failed to load | 読み込みに失敗しました |
370
+ | `streamRetry` | stream badge retry button | Retry | 再試行 |
371
+ | `streamRevalidating` | stream badge (revalidating) | Refreshing | 最新化中 |
372
+ | `listEmpty` | package empty state under an empty list | No data | データがありません |
373
+ | `selectPrompt` | side pane without a selection | Select a report from the list on the left. | 左側の一覧から日報を選択してください。 |
374
+ | `loading` | side pane loading (edit mode) | Loading... | 読み込み中... |
375
+ | `reportNotFound` | list card of a vanished report | Report not found | 日報が見つかりません |
376
+ | `contentEmpty` | side pane: report without content | No content has been entered. | 内容は登録されていません。 |
377
+ | `contentEmptyPreview` | list card preview: report without content | No content has been entered | 内容は登録されていません |
378
+ | `confirmDelete` | `confirm()` before deleting a report | Are you sure you want to delete this report? | 本当に削除しますか? |
379
+ | `deleteFailed` | `alert()` after a failed delete | Failed to delete the report | 削除に失敗しました |
380
+ | `starAdd` | star toggle (unstarred) | Add star | スターを付ける |
381
+ | `starRemove` | star toggle (starred) | Remove star | スターを外す |
382
+ | `markRead` | read toggle (unread) | Mark as read | 既読にする |
383
+ | `markUnread` | read toggle (read) | Mark as unread | 未読にする |
384
+ | `deleteReport` | report delete button | Delete | 削除する |
385
+ | `resizeHandle` | list / detail resize handle `aria-label` | Resize the border between the report list and the detail pane | 日報一覧と詳細ペインの境界のサイズ変更ハンドル |
386
+ | `overlayBackdrop` | mobile overlay backdrop `aria-label` | Tap the background to close | 背景をタップして閉じる |
387
+ | `overlayDialog` | mobile detail dialog `aria-label` | Report details | 日報詳細 |
388
+ | `close` | mobile overlay close button, error banner close `aria-label` | Close | 閉じる |
389
+ | `formTitle` | edit form | Title | タイトル |
390
+ | `formTitlePlaceholder` | edit form | Enter the report title | 日報のタイトルを入力 |
391
+ | `formContent` | edit form | Content | 内容 |
392
+ | `formContentPlaceholder` | edit form | Enter the report content | 日報の内容を入力 |
393
+ | `formCancel` | edit form | Cancel | キャンセル |
394
+ | `formSave` | edit form | Save | 保存 |
395
+ | `formPublish` | edit form | Publish | 公開 |
396
+ | `commentPlaceholder` | comment form | Add a comment... | コメントを追加... |
397
+ | `commentSend` | comment form | Send | 送信 |
398
+ | `commentDelete` | comment trash button (`aria-label` / tooltip) | Delete comment | 削除 |
399
+ | `commentDeleteConfirm` | comment delete confirmation | Delete | 削除する |
400
+ | `unknownUser` | author name when none is known (legacy / relational comment without a name, optimistic data of a user without a name) | Unknown | 不明なユーザー |
401
+ | `sourceInternal` | built-in `Internal` badge (only when `sourceTypeConfigs` is omitted) | Original | オリジナル |
402
+ | `totalCount` | header annotation | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
403
+ | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
404
+ | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
405
+ | `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%) |
406
+ | `streamRetrying` | stream badge (reconnecting) | `(1234)` → Reconnecting... (1,234 received) | `(1234)` → 再接続中... (1,234 件受信済) |
407
+ | `reportLoadFailed` | detail load failure (side pane, DetailList card) | `(12345)` → Failed to load report 12,345. | `(12345)` → 日報 12,345 の読み込みに失敗しました。 |
408
+ | `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
409
+ | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "三和")` → 佐藤 (三和) |
410
+ | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
411
+
412
+ #### Migrating from 0.20.x (breaking changes in 0.21.0)
413
+
414
+ 0.21.0 is a clean cut-over: there are no aliases for the removed names. It needs the peer
415
+ `@aiquants/virtualscroll` 3.7.0 or later (the engine label catalog it reuses).
416
+
417
+ 1. **The default UI language is English.** The Japanese wording that used to be hard-coded in the
418
+ components (buttons, placeholders, confirmation dialogs, status badges, loading / error screens,
419
+ the failure banner) now renders in English when `locale` is omitted. A host that relies on the
420
+ Japanese chrome must opt in with `config.locale: "ja"`. The ja catalog keeps the former Japanese
421
+ wording except four values aligned with the shared `@aiquants` vocabulary: `treeComingSoon`
422
+ (`ツリーは現在準備中です`), `resizeHandle` (`日報一覧と詳細ペインの境界のサイズ変更ハンドル`),
423
+ `unknownUser` (`不明なユーザー`, formerly `"Unknown"` / `不明`) and `interviewerWithAffiliation`
424
+ (half-width parentheses: `佐藤 (三和)`).
425
+ 2. **`config.fieldLabels` → `config.labels`** (the same 21 keys). The type `DailyReportFieldLabels`
426
+ and the value `defaultDailyReportFieldLabels` are removed; use `DailyReportLabels` /
427
+ `DAILY_REPORT_LABEL_CATALOGS[locale]`. A leftover `fieldLabels` key throws a `RangeError` at
428
+ render, even inside a `useMemo` config that TypeScript does not flag.
429
+ 3. **`config.title` → `config.labels.title`** (en `"Daily Reports"`, ja `"日報"`). It is still what
430
+ `renderHeader({ title })` receives and the accessible name of both lists. A leftover `title` key
431
+ throws a `RangeError` at render.
432
+ 4. **Adding only `locale: "ja"` replaces your vocabulary with the catalog's.** The ja catalog carries
433
+ neutral headings (`顧客`, `内容`, `コメント`, `概要`, `関連`, ...). Move the domain wording you used to
434
+ pass in `fieldLabels` into `labels` in the same change, e.g.
435
+ `{ locale: "ja", labels: { customer: "顧客・面談先", content: "商談内容", comments: "コメント一覧", tabArticle: "記事", tabRelations: "相関" } }`.
436
+ 5. **`locale: "ja"` also translates what used to stay English**: the top-level tabs (`📄 一覧` /
437
+ `🌳 ツリー` / `📋 詳細一覧`), the page title (`日報`), the load-error badge (`エラー`), the side-pane
438
+ tabs (`概要` / `関連`), the scroll arrows (`上へスクロール` / `下へスクロール`) and the empty state
439
+ (`項目がありません`). Keep the English form through `labels`, e.g.
440
+ `labels: { tabList: "📄 List", tabDetailList: "📋 DetailList" }`.
441
+ 6. **Resolved config shape**: `DailyReportClientConfig` (the value of `useDailyReportConfig()`) loses
442
+ `title` / `fieldLabels`, gains the resolved `locale` / `labels`, and `sourceTypeConfigs` is always
443
+ present (the built-in default when omitted).
444
+ 7. **`defaultDailyReportClientConfig`** is now the frozen, locale-independent input
445
+ `DailyReportClientConfigDefaults` (`apiBasePath`, `ssePath`, `draftLabelName`, `renderHeader`,
446
+ `showDevControls`). It no longer has `title`, `fieldLabels` or `sourceTypeConfigs`; read resolved
447
+ values through `useDailyReportConfig()`.
448
+ 8. **Built-in `Internal` badge**: when `sourceTypeConfigs` is omitted, its label is
449
+ `labels.sourceInternal` — en `"Original"` (formerly always `オリジナル`), ja `"オリジナル"`.
450
+ 9. **`DailyReportErrorInfo`**: `operation` is narrowed from `string` to `DailyReportOperation`, and
451
+ `message` is `labels.operationFailed(operation)` in the chosen locale (English by default).
452
+ 10. **`mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`** (root
453
+ export): the fourth argument is new and required, `currentUserId` is no longer optional (pass
454
+ `undefined` to keep the server's `isMine`), and a blank or non-string `unknownAuthorName` throws a
455
+ `RangeError`. A comment without an author name (legacy `name` or relational `userName` that is
456
+ empty, `null` or missing) gets `unknownAuthorName`; the client passes `labels.unknownUser`.
457
+ 11. **Server `addComment`**: the SSE `comment-add` payload sends `userName: ""` (formerly `"Unknown"`)
458
+ for an author without a display name, like the read paths; the client renders
459
+ `labels.unknownUser`. Comments with an empty author name show `labels.unknownUser` instead of `-`.
460
+ 12. **List side-pane load error** renders `labels.reportLoadFailed(reportHubId)` instead of the
461
+ exception message (the same text as the DetailList).
462
+ 13. **Explicit `undefined`** for `apiBasePath` / `ssePath` / `draftLabelName` / `showDevControls` /
463
+ `renderHeader` now resolves to the default (it used to override the default with `undefined`).
464
+
465
+ Unchanged: `draftLabelName` (label data stored in the database) does not follow `locale`, and dates
466
+ keep their ISO form.
243
467
 
244
468
  ### External Source Badge Configuration (`sourceTypeConfigs`)
245
469
 
@@ -304,13 +528,54 @@ export default defineConfig({
304
528
 
305
529
  1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
306
530
  2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections).
307
- 3. `sse.loader` implements catch-up (`xRange`) using `Last-Event-ID` / `lastEventId` and 5-second keep-alive pings. `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks.
308
- 4. On the client, `useDailyReportSseConnection` handles exponential backoff reconnects, and the action context correlates optimistic updates with SSE echoes using `clientTempId`.
531
+ 3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry only `SSE_RESPONSE_HEADERS` plus the forwarded `Set-Cookie` (no `Connection` or other hop-by-hop header, which HTTP/2 forbids). `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type visibility, comment redaction).
532
+ 4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 30 s of healthy open, one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
533
+ The action context correlates optimistic updates with SSE echoes using `clientTempId`, and exposes the connection status as `sseStatus` (`DailyReportSseConnectionStatus`: the reopening status, or `{ kind: "resyncing" }` while a `resync-required` waits for a fresh ids anchor).
534
+
535
+ ### SSE wire contract
536
+
537
+ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000` before one named terminal event, so a client that does not listen for the event (an older bundle) reconnects at most once a day instead of in a loop. A non-200 response is reserved for a misconfigured endpoint.
538
+
539
+ | Server-side situation | Response | Client (`useDailyReportSseConnection`) |
540
+ | --- | --- | --- |
541
+ | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
542
+ | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
543
+ | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
544
+ | Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
545
+ | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
546
+ | Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
547
+ | The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
548
+ | Empty stream, or a cursor inside the retained window or in the future | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
549
+ | Redis failure during the catch-up | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
550
+
551
+ - **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter (`DAILY_REPORT_SSE_CURSOR_PARAM`). The browser's own reconnect reuses the URL it opened with (a stale query) but sends a fresh header, so the header is always the newer value. A cursor must be a Redis stream id of the form `<ms>-<seq>` with 1–15 digits per part (`isDailyReportSseStreamId`); anything else is answered with `resync-required`. The client URL-encodes the cursor.
552
+ - **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when there is no cursor). On the cursor path it is written before any Redis round trip.
553
+ The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
554
+ Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
555
+ - **Catch-up**: entries after the cursor are read in pages of 1,000 (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
556
+ - **Ids on the wire only increase**: live entries that arrive during the catch-up are queued, not written. The catch-up stops in front of the first queued entry (the fan-out reader delivers every entry after it in order), then the queue is written in id order and later live entries are written as they arrive. Writing a live entry first would let a disconnect move the client's resume position past catch-up entries it never received, and an older `report-update` would overwrite a newer one on the client.
557
+ - **Heartbeat**: `startSseHeartbeat` writes a jittered `retry:` (2,000–10,000 ms), an immediate named `event: heartbeat` (`data: {"serverTime":<ms>}`, no `id:`), then one every 15 s (`DAILY_REPORT_SSE_HEARTBEAT_MS`). Named events are ignored by `onmessage`, so old bundles are unaffected.
558
+ - **Stream anchor (`streamAnchor`)**: the ids stream carries the newest SSE entry id on its first authoritative line
559
+ (the line with `total`, or the short-circuit line when the whole data set fits in the fast first page; never on a
560
+ provisional line). The service reads it inside the cached fetcher **before** the SQL, so every cache path (bucket hit,
561
+ joining an in-flight fetch, refresh-ahead) returns an array that already reflects every update up to the anchor. The
562
+ SSE hook does not connect until an anchor arrives and then uses it as the first cursor, which closes the gap between
563
+ loading the ids and opening the stream. `null` means "position unknown" (empty stream, no redis, read failure): the
564
+ hook then opens without a cursor and the server starts from the newest entry at connect time.
565
+ - **Delivery is idempotent**: an anchor can be up to one ids-cache TTL old; replaying from it is safe because `report-*` messages upsert and `comment-add` is matched by comment id.
566
+ - **A resync that gives up is retried**: after `resync-required` the hook asks the ids session for a rescan (`resyncDailyReportIdsStream()`) and does not reconnect until an anchor newer than the one it had arrives. When that rescan gives up (the ids phase stays `complete` with an `error`, which happens after repeated failures during an outage), the hook asks again after a full-jitter backoff from 2 s to 30 s while SSE is enabled, until an anchor arrives. An initial ids scan that ends in `failed` is left to the page's manual retry.
309
567
 
310
568
  ## API Surface (Summary)
311
569
 
312
- - **server**: `createDailyReportServer` / `createDailyReportService` / `createDailyReportHandlers` / `defineDailyReportSchema` / `DailyReportSseReader` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag` / `isStreamIdLte`
313
- - **client**: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportActionProvider` / `useDailyReportActionContext` / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` / `useDailyReportIdsStream` / `DailyReportIdsStreamStatus` / `DailyReportIdsStreamClient` / `DailyReportConfigProvider` / `createDailyReportClientLoader` / `dailyReportShouldRevalidate`
314
- - **shared**: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `dailyReportSseMessageSchema` (8 discriminated union types) / `normalizeBusinessDateKey` / `mergeComments`
570
+ - **server**: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`) / `createDailyReportHandlers` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `defineDailyReportSchema` / `DailyReportSseReader` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag` / `isStreamIdLte`
571
+ - **client**: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` /
572
+ `DailyReportActionProvider` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` /
573
+ `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns
574
+ `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`) /
575
+ `resyncDailyReportIdsStream` / `DailyReportIdsStreamStatus` / `DailyReportIdsStreamClient` (`resync()`) /
576
+ `DailyReportConfigProvider` / `useDailyReportConfig` / `defaultDailyReportClientConfig` / `resolveDailyReportLabels` /
577
+ `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `createDailyReportClientLoader` /
578
+ `dailyReportShouldRevalidate`
579
+ - **shared**: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `dailyReportSseMessageSchema` (8 discriminated union types) / `DAILY_REPORT_SSE_TERMINAL_EVENTS` / `DAILY_REPORT_SSE_HEARTBEAT_MS` / `DAILY_REPORT_SSE_CURSOR_PARAM` / `isDailyReportSseStreamId` / `DailyReportSseStreamAnchor` / `normalizeBusinessDateKey` / `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`
315
580
 
316
581
  MIT