@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 +278 -13
- package/dist/client.d.mts +213 -45
- package/dist/client.d.ts +213 -45
- package/dist/client.js +5 -5
- package/dist/client.mjs +5 -5
- package/dist/{sse-schema-BdUGThiz.d.ts → ids-stream-BnM6L-Sk.d.ts} +173 -133
- package/dist/{sse-schema-ChSFm-4q.d.mts → ids-stream-DCuiaTit.d.mts} +173 -133
- package/dist/index.d.mts +3 -30
- package/dist/index.d.ts +3 -30
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +11 -3
- package/dist/server.d.ts +11 -3
- package/dist/server.js +4 -15
- package/dist/server.mjs +4 -15
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +7 -6
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
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
242
|
-
|
|
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`
|
|
308
|
-
4. On the client, `useDailyReportSseConnection`
|
|
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` /
|
|
314
|
-
|
|
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
|