@aiquants/daily-report 0.34.0 → 0.35.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 +69 -0
- package/README.md +225 -50
- package/dist/client.d.mts +45 -16
- package/dist/client.d.ts +45 -16
- package/dist/client.js +4 -4
- package/dist/client.mjs +4 -4
- package/dist/{comment-adapter-BLfjD6P0.d.ts → comment-adapter-ChA4KOE1.d.mts} +71 -26
- package/dist/{comment-adapter-7TduiLiu.d.mts → comment-adapter-DqBqtqfA.d.ts} +71 -26
- package/dist/index.d.mts +9 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/logger-BaxnzF-0.d.mts +17 -0
- package/dist/logger-BaxnzF-0.d.ts +17 -0
- package/dist/server.d.mts +252 -9
- package/dist/server.d.ts +252 -9
- package/dist/server.js +11 -4
- package/dist/server.mjs +11 -4
- package/dist/{sse-schema-DVIUaiAG.d.mts → sse-schema-getLS62T.d.mts} +1 -1
- package/dist/{sse-schema-DVIUaiAG.d.ts → sse-schema-getLS62T.d.ts} +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ So a release always ships the baseline of its own build: an entry left high afte
|
|
|
46
46
|
The changed-lines coverage gate (`pnpm run check:changed-lines-coverage`) reads the coverage run and the lines added to `src` since the package's latest release tag (`<package name>@<version>`, resolved from `package.json`; untracked files count as added):
|
|
47
47
|
an added line inside a statement that no spec executed fails unless `scripts/changed-lines-coverage-exemptions.json` names it with its text and the reason, and an exemption that covers no such line is stale and fails (exit 0 / 1, and 2 when nothing is proven), so new code cannot hide behind a file's floor.
|
|
48
48
|
The gate is the repository's one shared module (`.config/scripts/lib/changed-lines-coverage.mjs`, which `@aiquants/virtualscroll` runs too): `scripts/check-changed-lines-coverage.mjs` is a thin entry that passes it this package's root and its exemptions file.
|
|
49
|
+
Every gate script and spec that reads a shared module of the repository (`.config/scripts/lib`) finds the monorepo root through one resolver, `scripts/lib/repository-root.mjs`: `getProjectRoot(startPath)` takes the caller's own file (`import.meta.url`), never the working directory, and returns the nearest directory at or above it that holds `pnpm-workspace.yaml` as a file or a `.git` (a file inside a git worktree), or `PROJECT_ROOT` when that is set and holds one of the two; `importRepositoryModule(startPath, path)` loads a module by its path from that root.
|
|
50
|
+
A start path without such a directory above it, an empty start path and a `PROJECT_ROOT` without an anchor fail at once with an error that names the path. No script or spec walks up a fixed number of levels (`../../../.config`), which would silently name another directory once the file moves.
|
|
49
51
|
The build fails on any esbuild warning (`scripts/lib/build-warnings.mjs`), and the CommonJS bundles replace `import.meta.hot` with `undefined`, so they carry no `import.meta` while the ESM bundles keep the hot-module clean-up for the host's Vite.
|
|
50
52
|
The unit tests include the repository's publish leak guard over the markdown the package ships (`src/shipped-markdown-leaks.spec.ts`: every markdown file `package.json` `files` ships — `README.md`, `CHANGELOG.md` and any shipped docs — packed with `package.json` into a tarball and checked by the guard itself, with its own rules), so an internal name in them fails verify instead of stopping a publish; the publish paths still run the guard on the packed tarball, `dist` included.
|
|
51
53
|
Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
|
|
@@ -54,6 +56,8 @@ A new file enters the ratchet only at 100 % of both metrics: the script records
|
|
|
54
56
|
Two source guards keep per-file counts that only go down, through one engine (`scripts/lib/count-ratchet.mjs`): each file must match its entry exactly and a file without an entry counts 0, so a count below the entry fails too until the baseline is lowered (a fixed violation cannot come back unnoticed); an entry for a file the scan does not cover is stale; the exit code is 0 (pass), 1 (findings, each with its fix) or 2 (a configuration error, nothing proven); and `--write` lowers the entries to the measured counts and drops those at 0 and the stale ones, never raising or adding one.
|
|
55
57
|
The guards are the violations of the workspace's docstring and comment language rules, in `docstring-baseline.json` (`scripts/ratchet-docstrings.mjs`: `pnpm run check:docstrings` verifies, `pnpm run docstrings:ratchet` lowers), and the implicit value substitutions (`??` / `||` fallbacks) of the production sources, in `no-fallback-baseline.json` at the package root (`scripts/check-no-fallback.mjs`: `pnpm run check:no-fallback` verifies, `pnpm run no-fallback:ratchet` lowers).
|
|
56
58
|
Production code takes no docstring violation at all: a production source (no spec, test, test helper or declaration; the repository's one definition, `isProductionSourcePath`) fails on any violation and may not have an entry in `docstring-baseline.json`, even one that equals its count (`--write` removes such an entry), so only specs and test helpers keep counted entries.
|
|
59
|
+
The docstring rule covers every function the workspace rule names (methods, inner closures and the functions a function returns included) and the JSDoc of every contract declaration that has one (a property signature or declaration, a method signature, an enum and its members, a type alias, an interface, a class, and an exported constant that is not a function):
|
|
60
|
+
each carries English text and Japanese text. English text is a line without Japanese that has an English word, or an English sentence (two words or more, ending with `.`, `:`, `;`, `!` or `?`) ahead of the Japanese on one line, the one-line form `English. 日本語。` that declarations use; tag lines, code spans, `{@link …}` and URLs count as neither language.
|
|
57
61
|
The no-fallback rule is the repository's one shared detector (`.config/scripts/lib/no-fallback.mjs`, which the host app's wiring spec runs too, so both flag the same expressions): a value-position `??` / `||` whose right operand is a literal other than `null` / `undefined`, or anywhere reads a property or an element, calls a function (`a ?? f()` and `a || f()` included) or constructs a value, and a `??` chained on another `??`.
|
|
58
62
|
A substitution that the specification or a port contract defines is a reviewed exception instead (`scripts/no-fallback-exceptions.json`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a missing or malformed exceptions file is exit 2.
|
|
59
63
|
Every TypeScript or JavaScript code block of this README names its source in its info string: an example file, or a `#region` of one, which `src/docs-examples.spec.ts` compares byte for byte (`ts examples/<file>.ts[#<region>]`), or `illustrative` for a fragment that is not type-checked.
|
|
@@ -62,11 +66,11 @@ Every TypeScript or JavaScript code block of this README names its source in its
|
|
|
62
66
|
|
|
63
67
|
| Feature | Supported from | Used for | Below the floor |
|
|
64
68
|
| --- | --- | --- | --- |
|
|
65
|
-
| `Element.checkVisibility({ visibilityProperty: true })` | Chrome 121, Firefox 122, Safari 17.4 (the method alone: Chrome 105, Firefox 106) | Finding Tab stops for the `Ctrl+Home` / `Ctrl+End` exits and the mobile overlay's Tab wrap | Without the method (Safari before 17.4) those key handlers throw a `TypeError`: the exit keys keep their browser meaning, and at the overlay's ends Tab moves to the browser's own interface instead of wrapping (the page behind the dialog stays inert). With the method but not the
|
|
69
|
+
| `Element.checkVisibility({ visibilityProperty: true })` and its `opacityProperty` option | Chrome 121, Firefox 122, Safari 17.4 (the method alone: Chrome 105, Firefox 106) | Finding Tab stops for the `Ctrl+Home` / `Ctrl+End` exits and the mobile overlay's Tab wrap; the focus successors (with `opacityProperty: true` too; **Focus goes only to what shows it** in [Client wiring](#client-wiring)) | Without the method (Safari before 17.4) those key handlers throw a `TypeError`: the exit keys keep their browser meaning, and at the overlay's ends Tab moves to the browser's own interface instead of wrapping (the page behind the dialog stays inert); a hand-over of focus throws the same error before it moves focus (a retry button's press then does not retry, the notice's close does not close, and a hand-over in a layout effect reaches the nearest error boundary). With the method but not the options (Chrome 105–120, Firefox 106–121) an element hidden by `visibility: hidden` counts as a stop, so an exit key whose nearest stop is such an element is consumed while focus stays where it was, and an element at opacity 0 counts as a visible successor |
|
|
66
70
|
| `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The List card's selection ring and keyboard focus outline, and the forced-colours selection outline of a List row with a card (all read from the card's primary button) | List cards show neither the selection nor keyboard focus (the DetailList is unaffected) |
|
|
67
71
|
| Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count and its whole-pixel width (both read from the grid's own size container); the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
|
|
68
|
-
| CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, var(--aqdr-lattice-block)) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the
|
|
69
|
-
| CSS `calc-size()` | Chrome 129 | The DetailList row bodies'
|
|
72
|
+
| CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, var(--aqdr-lattice-block)) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the lattice (see **Origin on the lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
|
|
73
|
+
| CSS `calc-size()` | Chrome 129 | The DetailList row bodies', the error notice's and the view-mode toolbar band's height, their content height rounded up to the block quantum of the device-pixel lattice (`calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`: 4 px, 8 px at the odd multiples of 1/8; see **Row slots on the lattice** and **Origin on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the 4 px lattice at a 16 px root and can leave it at other roots and at the odd multiples of 1/8, so the DetailList rows below can start between device pixels; the toolbar's band keeps its content height, so at the odd multiples of 1/8 the views start half a device pixel off whenever that height is not a multiple of 8 px (the toolbar alone is 32) |
|
|
70
74
|
| `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
|
|
71
75
|
| `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short centred labels that can wrap (`WRAPPING_LABEL_CLASS_NAME`: the side pane's selection prompt, an attachment tile's unavailable label) keep their lines about equally long | Safari 17.4, inside the floor, drops the value and wraps them greedily, so a wrapped label can end with a short last line |
|
|
72
76
|
| `word-break: auto-phrase` | Chrome 119 (not in Firefox or Safari) | The same labels break Japanese at phrase boundaries where the host document's `lang` is `ja` | Firefox and Safari drop the declaration and break Japanese between any two characters (the default), so a narrow label can break inside a word |
|
|
@@ -127,6 +131,8 @@ const tables = defineDailyReportSchema("dbo_app", { userTable: Users })
|
|
|
127
131
|
|
|
128
132
|
`DailyReportHub.source_id_num` is a computed column (`TRY_CAST(source_id AS BIGINT)`), used to join external legacy sources.
|
|
129
133
|
|
|
134
|
+
The text search adds two optional tables in a schema of their own (see [Text search](#text-search)); `defineDailyReportSearchSchema(schemaName)` defines them, and a host may inject its own models of the same shape. The service also needs `execute` on `DailyReportDb` (drizzle's `db.execute`; the handle a host already passes has it).
|
|
135
|
+
|
|
130
136
|
## Server wiring (DI)
|
|
131
137
|
|
|
132
138
|
```ts illustrative
|
|
@@ -181,12 +187,15 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
181
187
|
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.
|
|
182
188
|
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.
|
|
183
189
|
- **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`.
|
|
184
|
-
The endpoint table (`
|
|
190
|
+
The endpoint table (`API_ENDPOINT_TABLE` in `src/server/api-endpoint.ts`) states once, per endpoint, what kind of body it answers and what bounds its database work per viewer, and the methods follow from the body (`API_ENDPOINT_METHODS`): `GET` for a JSON body (`report` and `business-date`, and `search` and `reports` with the `search` block, 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.
|
|
191
|
+
Each row's `admission` lists at least one bound (the row's type requires it, so an endpoint cannot enter the table without one): `cache` (reads through the SQL result cache, which shares one fetch among identical concurrent reads and keeps the result for its TTL), `refresh-bucket` (a read that bypasses the cache spends the viewer's refresh bucket, **`forceRefresh`** under **Request values** below), `stream-slots` (the body holds one of the viewer's stream slots, **Streams per viewer** below) and `search-limiter` (the endpoint's own search admission, **Requests** in [Text search](#text-search)).
|
|
192
|
+
`business-date` and `report` declare `cache` and `refresh-bucket`, the ids stream `cache`, `stream-slots` and `refresh-bucket`, and `search` and `reports` `search-limiter` each. Every handler admits its bounds after its 400s, and one spec walks the table and proves each declared bound on its handler (`src/server/handlers.json-endpoints.spec.ts`), so a bound that is declared and not enforced fails.
|
|
185
193
|
- **Streams per viewer**: one viewer holds at most `DAILY_REPORT_STREAMS_PER_VIEWER` = 32 streaming responses at once in one worker process, SSE streams and ids streams together (`createStreamSlots` in `src/server/stream-body.ts`: one set per handler factory, keyed by the internal user id, no wait queue). The bound is the package client's own need with headroom: a view holds 2 streams (its SSE connection for its whole life, and its ids stream while it loads or rescans), and a viewer may keep 16 views open across tabs, windows and devices.
|
|
186
194
|
It is not derived from HTTP/1.1's 6 connections per origin, since one HTTP/2 connection carries many streams. A held stream costs about 10 KB of heap and one visibility re-resolution every 60 s, so 32 streams cost about 310 KB and 0.53 re-resolutions a second.
|
|
187
|
-
A stream takes its slot after the checks that decide its status — on the SSE route after the resume cursor and before the visibility is resolved or anything subscribed, on the ids stream after its 400s and the refresh bucket — and gives it back
|
|
195
|
+
A stream takes its slot after the checks that decide its status — on the SSE route after the resume cursor and before the visibility is resolved or anything subscribed, on the ids stream after its 400s and the refresh bucket — and gives it back exactly once, at whichever comes first: the request's abort, at any time after the slot was taken, or the settle path that ends its body (the SSE connection's close; the ids stream's end, cancel or error, and a resumed `GET` that fails before its body starts).
|
|
196
|
+
The admission itself ties the slot to the body's signal (`createStreamSlots().admit`), so a request aborted while its route still awaits — the SSE route resolving the visibility, a resumed ids `GET` waiting for its full query — returns its slot at that moment, also on an adapter that never pulls an aborted body again.
|
|
188
197
|
The request after the last slot starts no body work and is answered 503 with `Retry-After: 5`: the SSE route with the text `Too many streams`, the ids stream with `{"error":{"message":"Too many streams"}}`; both clients come back on their own backoff. A `HEAD`, or a `GET` whose signal is already aborted, takes no slot: it is only checked, and gets the status a `GET` would get. A viewer without an internal user takes none (the SSE route answers it `forbidden`, and its ids stream is one empty line without a query).
|
|
189
|
-
The refusals are recorded per viewer in windows of 60 s at `warn`
|
|
198
|
+
The refusals are recorded per viewer in windows of 60 s at `warn` on the `[DailyReportStreams]` channel: the window's first as `503 <sse|ids-stream> viewer=<internal id> reason=streams_per_viewer`, the rest counted into one `streams_per_viewer_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<internal id> routes=sse:<n>,ids-stream:<n>` line written by the window's own timer at its end.
|
|
190
199
|
The slots are per worker process, so the effective bound across workers is the worker count times 32.
|
|
191
200
|
|
|
192
201
|
**Request values**: the API route and the action read every value of a request — business dates, ids, time stamps, `forceRefresh`, the action's echo id and the rest of its payload — with one strict parser each, after authentication and before any query or service call, and answer a value they do not accept with 400:
|
|
@@ -203,7 +212,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
203
212
|
The running forced fetch is the key's in-flight fetch, so plain readers join it, and its records enter the cache unless an invalidation dropped it meanwhile.
|
|
204
213
|
- **A refresh bucket per viewer**: every forced read spends one token after its 400 checks and before any service call, `REFRESH_RATE_LIMIT_PER_MINUTE` = 60 per viewer and per handler factory (so per worker process), keyed by the external user id and refilled over one minute (`REFRESH_RATE_WINDOW_MS`). An empty bucket answers 429 `{"error":{"message":"Too many requests"}}` with `Retry-After: 60` and calls no service, the ids stream's `HEAD` included.
|
|
205
214
|
The bound is the package client's own forced reads with the action's headroom (**Rate** below): `DWELLING_PANES_HEADROOM` = 5 views × `FORCED_RESCANS_PER_VIEW` = 2 forced rescans per view and minute (the SSE `resync-required` and the development cache clear start one; a rescan that keeps failing spans more than half a window before it gives up) × `FORCED_RESCAN_ATTEMPTS` = 6 requests per rescan (the ids stream client's first attempt and its five retries, which keep `forceRefresh`); the business-date list's absence check (one forced read per date every 10 s at most) stays inside it.
|
|
206
|
-
The refusals are recorded per viewer in windows of one minute at `warn`
|
|
215
|
+
The refusals are recorded per viewer in windows of one minute at `warn` on the `[DailyReportRefresh]` channel: the window's first as `429 endpoint=<business-date|report|ids-stream> reason=refresh_rate_limit`, the rest counted into one `refresh_rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> route=api` line at the window's end. No line names the user.
|
|
207
216
|
- **The echo id** (`clientTempId`; every intent but `clearCache`): the client makes one id per operation, sends it with the action and recognizes its own operation by it in the answer and in the SSE event. The action accepts exactly the two forms the package's client makes (`parseClientTempId`, server entry, which returns the branded type `DailyReportClientTempId`):
|
|
208
217
|
a lowercase UUID (`crypto.randomUUID()`: 8-4-4-4-12 lowercase hexadecimal digits) and a negative decimal integer without a leading zero of at most 16 digits (the optimistic temporary id of a report or a comment: one page-wide sequence draws each id strictly below the last one drawn and at most minus the wall clock's milliseconds, so two operations in the same millisecond never share one). A missing or empty id is `clientTempId required`;
|
|
209
218
|
anything else — an uppercase UUID, `-0`, `tmp-1`, surrounding whitespace, 37 characters, a text of 1 MiB — is `Invalid clientTempId`, before any service call.
|
|
@@ -250,8 +259,26 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
250
259
|
- `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.
|
|
251
260
|
- `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).
|
|
252
261
|
- `attachments` — Optional attachment delivery block (`DailyReportAttachmentsConfig`), owned by the service: the id codec and the read port it always carries, the size limit and the original route's tuning, and the optional `thumbnails` with the renderer port and its tuning. See [Attachments](#attachments).
|
|
253
|
-
- `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package
|
|
254
|
-
|
|
262
|
+
- `logger` — Optional console-compatible logger (`debug` / `info` / `warn` / `error`) that receives every server log line of the package. Every line names its channel — the part of the server that wrote it — by a prefix, with a host logger and without one alike, so a host's logger can tell the package's lines and their parts apart (`createChannelLogger` in `src/shared/logger.ts` builds every channel's logger):
|
|
263
|
+
a string message arrives as `<prefix> <message>`, any other value with the prefix as its own first argument, and the further arguments as they were given. A host logger receives every level of every channel and filters the levels itself; without one, each channel writes to the console from its own lowest level:
|
|
264
|
+
|
|
265
|
+
| Channel | Console from | Lines |
|
|
266
|
+
| --- | --- | --- |
|
|
267
|
+
| `[DailyReportAPI]` | `error` | the 500 of a JSON endpoint and of the action, the ids stream's emission failures, and the JSON answers' `debug` trace (below) |
|
|
268
|
+
| `[DailyReportVisibility]` | `error` | a `resolveVisibleSourceTypes` that threw (the request fails closed) |
|
|
269
|
+
| `[DailyReportSSE]` | `info` | the SSE route: a malformed resume cursor, its producers' and the shared reader's errors, its 500 |
|
|
270
|
+
| `[DailyReportAttachment]` | `info` | both attachment deliveries (**Log levels** under [Attachments](#attachments)) |
|
|
271
|
+
| `[DailyReportIsolation]` | `warn` | the refusal record of [Request isolation](#request-isolation) |
|
|
272
|
+
| `[DailyReportAction]` | `warn` | the record of the action's 413 and 429 (**Request values** in [Server wiring](#server-wiring-di)) |
|
|
273
|
+
| `[DailyReportRefresh]` | `warn` | the refresh bucket's 429 (**`forceRefresh`** there) |
|
|
274
|
+
| `[DailyReportStreams]` | `warn` | the 503 of **Streams per viewer** there |
|
|
275
|
+
| `[DailyReportSearch]` | `warn` | the 429 of the `search` and `reports` admissions (**Requests** in [Text search](#text-search)) |
|
|
276
|
+
| `[DailyReportServer]` | `warn` | `createDailyReportServer` without a `redis` provider |
|
|
277
|
+
| `[DailyReportService]` | `info` | the service: SSE publishing (an event over the size bound, a slow or failed publish), a failed read of the stream anchor, the development cache clear |
|
|
278
|
+
| `[DailyReportSseReader]` | `info` | the shared SSE reader's Redis errors |
|
|
279
|
+
| `[DailyReportExternalSource]` | `info` | `transformJsonArray`'s malformed JSON columns of an external source |
|
|
280
|
+
|
|
281
|
+
A 500 is one line: its message (`500 endpoint=<name> elapsed=<ms>ms name=<error name> code=<code or N/A> message=<message>`, `500 action elapsed=<ms>ms name=<error name> message=<message>`) and the error object go in one call, so the error's stack never becomes a line of its own that concurrent requests interleave with.
|
|
255
282
|
A JSON endpoint's answer is traced by the API route alone, as one `debug` line on this logger after the endpoint answered (`<status> endpoint=<name> etag_match=<true|false>`; the default `[DailyReportAPI]` console logger starts at `error`, so without a host logger the line is not written). `jsonResponseWithETag` itself writes nothing, and no line carries a request header's value or traces an answer given before the endpoint (the 401, 404 and 405), so a client decides neither the number nor the size of the lines.
|
|
256
283
|
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
|
|
257
284
|
|
|
@@ -259,6 +286,8 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
259
286
|
A rejected value — a wrong type included, also a port or a function given with the wrong type — is a `RangeError` whose message ends with `; got <value>` (a string as JSON; a number, a boolean, `null` or `undefined` as written; an array as `array`; anything else only its `typeof`). An unknown key is a `RangeError` that lists the known keys as JSON strings: `<path> must be one of "<key>", "<key>", …; got "<unknown key>"`. A missing (`undefined`) function or port is a `TypeError`, without `; got`.
|
|
260
287
|
For example `[daily-report] attachments.concurrency must be an integer >= 1; got "2"`, `[daily-report] config key must be one of "apiBasePath", "ssePath", …; got "title"` and `[daily-report] attachments.read must be injected when attachments is given`.
|
|
261
288
|
The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configKeyError`, `configPortError`) are the only code that constructs a `RangeError` or a `TypeError`, so every such message has the prefix and the form: `src/host-facing-errors.spec.ts` reads the sources' syntax tree (specs and test helpers aside) and fails on a construction anywhere else.
|
|
289
|
+
Every integer setting and every internal integer bound — the host's settings, and the arguments of the package's own gates, buckets, caches and client limiters — is read through one validator in the same module, `requireIntegerSetting(path, value, min)`: a safe integer (`Number.isSafeInteger`, so 2^53 and above are refused too) at or above the minimum, else `[daily-report] <path> must be an integer >= <min>; got <value>`.
|
|
290
|
+
`src/integer-bounds.spec.ts` reads the production sources' syntax tree and fails on an integer check written by hand anywhere else — a `Number.isInteger` / `Number.isSafeInteger` test inside a function that throws a configuration error, or a `configValueError` whose expectation starts with `an integer` — with no list of exceptions.
|
|
262
291
|
|
|
263
292
|
### Request isolation
|
|
264
293
|
|
|
@@ -271,7 +300,7 @@ The policy's `frameworkDataRefusal` receives the loader's arguments and returns
|
|
|
271
300
|
| --- | --- |
|
|
272
301
|
| `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 |
|
|
273
302
|
| `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 |
|
|
274
|
-
| `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names an endpoint whose body streams in the endpoint table (`
|
|
303
|
+
| `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_TABLE`: 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) |
|
|
275
304
|
| `api.action` (`ACTION_ISOLATION_POLICY`) | Served (React Router's own fetchers write through single fetch) |
|
|
276
305
|
|
|
277
306
|
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`).
|
|
@@ -289,7 +318,7 @@ The two API policies live in `src/server/handlers.ts`, the API loader's refusal
|
|
|
289
318
|
Full isolation therefore needs a potentially trustworthy origin (HTTPS): only there does the browser send the Fetch Metadata that also refuses a sibling page's `<img>` and no-cors probes before authentication.
|
|
290
319
|
- **The refusal**: on the API and SSE routes, JSON `{"error":{"message":"Cross-site request rejected"}}` with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff` (`crossSiteRequestRejection`); on the attachment route, the same body through the attachment failure builder, so it carries every attachment security header (**Same-origin loads only** in [Attachments](#attachments)). Nothing has authenticated, so no `Set-Cookie` is forwarded.
|
|
291
320
|
On the attachment route the refusal comes before the query's 400 and the method's 405: a malformed query, a `HEAD` navigation (browsers never navigate with `HEAD`) or a form `POST` from another origin answers 403.
|
|
292
|
-
- **The refusal record**: each handler factory keeps one bounded record of its refusals (`createCrossSiteRejectionRecorder`; state per factory, not per module), written to the configured `logger
|
|
321
|
+
- **The refusal record**: each handler factory keeps one bounded record of its refusals (`createCrossSiteRejectionRecorder`; state per factory, not per module), written on the `[DailyReportIsolation]` channel: to the configured `logger` with that prefix, or by default to the console from the `warn` level.
|
|
293
322
|
A refusal with no open window writes one warn line at once, `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32 characters, so a sender cannot forge the line's fields), and opens a window of `CROSS_SITE_REJECTION_LOG_WINDOW_MS` (60 s) that every route of the factory shares; later refusals in the window are only counted, per route.
|
|
294
323
|
The window's own timer ends it (one unref'd timer per window, which never keeps the process alive): when it counted refusals, it writes `cross_site_rejected_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> routes=api:<n>,action:<n>,sse:<n>,attachment:<n>` (N and the per-route counts are the refusals after the first one), and the next refusal opens a new window with its own line.
|
|
295
324
|
No clock comparison decides a window — the wall clock only stamps `since=` — and served requests neither open nor close one. So the record writes at most two lines per window, two a minute per factory however fast the refusals come: an active probe or a CSRF attempt shows in the log with its first refusal at once, and how many followed shows at the window's end, at most 60 s later, also when the refusals stop.
|
|
@@ -799,6 +828,105 @@ A viewer therefore writes at most two lines per bucket and window, whatever its
|
|
|
799
828
|
- **閉包は DB の外へ副作用を持たせない。** 再実行は毎回はじめから走る。キャッシュ無効化・epoch 更新・SSE 配信はいずれもトランザクションの**後**に置いてある。
|
|
800
829
|
- `createDailyReport` の閉包内には `logger.info` が在るため、やり直した回だけ行が重複する。これは「実際に 2 度開いた」という事実の記録であり、抑えない。
|
|
801
830
|
|
|
831
|
+
### Text search
|
|
832
|
+
|
|
833
|
+
The screen can search the reports by text. The server side is one optional block of the service configuration, `search`, and the client side is `config.search`; pass the client part only when the server has the block (without it, the `search` and `reports` endpoints answer 404 `Unknown endpoint`).
|
|
834
|
+
|
|
835
|
+
```ts examples/search-wiring.ts
|
|
836
|
+
/**
|
|
837
|
+
* Example wiring of the text search into `createDailyReportServer`, and the reconciliation a host runs from its command-line tool (not shipped; type-checked by `pnpm run typecheck:examples`).
|
|
838
|
+
* 文字の検索を `createDailyReportServer` へ結線する例と、ホストがコマンドラインの道具から流す突き合わせ (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
839
|
+
*
|
|
840
|
+
* The search is one optional block, `search`: the two index tables (here from `defineDailyReportSearchSchema`; the host's own models of
|
|
841
|
+
* the same shape also work), the readers of the searchable text the detail view does not show (per external source type), and the
|
|
842
|
+
* request limits. With the block, the six save paths rewrite the index inside their transaction (a failed index write fails the save)
|
|
843
|
+
* and the `search` and `reports` endpoints answer; without it they answer 404. Create the tables and run the initial reconciliation
|
|
844
|
+
* before passing the block in production: while the tables are missing, every save fails.
|
|
845
|
+
* 検索は任意の 1 つのブロック `search` にまとまる。索引の 2 表 (ここでは `defineDailyReportSearchSchema` から。同じ形のホストのモデルでもよい)、
|
|
846
|
+
* 詳細画面に出ない検索できる文字を読む口 (外部ソースの区分ごと)、要求の上限。ブロックを渡すと、保存の 6 つの経路がそのトランザクションの中で索引を書き直し
|
|
847
|
+
* (書けなければ保存も失敗する)、`search` と `reports` のエンドポイントが答える。渡さなければどちらも 404。本番では、表を作って最初の突き合わせを
|
|
848
|
+
* 流してからブロックを渡す (表が無い間は、どの保存も失敗する)。
|
|
849
|
+
*/
|
|
850
|
+
import {
|
|
851
|
+
createDailyReportServer,
|
|
852
|
+
type DailyReportSearchReconcileSummary,
|
|
853
|
+
type DailyReportSearchSourceTextReader,
|
|
854
|
+
type DailyReportServer,
|
|
855
|
+
type DailyReportServerConfig,
|
|
856
|
+
defineDailyReportSearchSchema,
|
|
857
|
+
} from "@aiquants/daily-report/server"
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* What the host supplies: its server configuration without the search block, the reader of the hidden text of its external source `NI`, and the per-process limits.
|
|
861
|
+
* ホストが渡すもの: 検索のブロックを除いたサーバー設定と、外部ソース `NI` の隠れた文字を読む口と、プロセスごとの上限。
|
|
862
|
+
*/
|
|
863
|
+
export type SearchWiring = {
|
|
864
|
+
/** The rest of the server configuration (its `externalSources` include the `NI` source). 検索のブロックを除いたサーバー設定の残り (`externalSources` は `NI` のソースを含む)。 */
|
|
865
|
+
base: Omit<DailyReportServerConfig, "search">
|
|
866
|
+
/**
|
|
867
|
+
* Reads the text of the `NI` rows' detail lines (one query per call, awaited in turn: the call may run inside a save transaction, which has one connection).
|
|
868
|
+
* `NI` の行の明細の文字を読む口 (呼び出し 1 回に 1 つの問い合わせを順に await する。保存のトランザクション (接続 1 本) の中で呼ばれうる)。
|
|
869
|
+
*/
|
|
870
|
+
readNiDetailTexts: DailyReportSearchSourceTextReader
|
|
871
|
+
/** Concurrent searches per process and searches per user per minute per process. プロセスごとの同時検索数と、プロセスごと利用者 1 人あたり 1 分間の検索回数。 */
|
|
872
|
+
limits: { concurrency: number; rateLimitPerMinute: number }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Creates the daily-report server with the text search enabled.
|
|
877
|
+
* 文字の検索を有効にした日報サーバーを作る処理。
|
|
878
|
+
*
|
|
879
|
+
* @param wiring Base configuration, the hidden-text reader and the limits. 基本設定・隠れた文字を読む口・上限。
|
|
880
|
+
* @returns The server (`DailyReportServer`); its API route answers `search` and `reports`. サーバー (`DailyReportServer`。API の経路が `search` と `reports` に答える)。
|
|
881
|
+
* @throws {RangeError} When the block is invalid (an unknown key, a missing column, a limit that is not an integer of at least 1, or a reader for a source type `externalSources` does not have). ブロックが不正なとき (知らないキー・列の欠け・1 以上の整数でない上限・`externalSources` に無い区分の読み取りの口)。
|
|
882
|
+
*/
|
|
883
|
+
export const createDailyReportServerWithSearch = ({ base, readNiDetailTexts, limits }: SearchWiring): DailyReportServer =>
|
|
884
|
+
createDailyReportServer({
|
|
885
|
+
...base,
|
|
886
|
+
search: {
|
|
887
|
+
tables: defineDailyReportSearchSchema("dbo_daily_report_search"),
|
|
888
|
+
externalSourceTexts: { NI: readNiDetailTexts },
|
|
889
|
+
concurrency: limits.concurrency,
|
|
890
|
+
rateLimitPerMinute: limits.rateLimitPerMinute,
|
|
891
|
+
},
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Reconciles the index with every report, in batches of 500 (the initial build, and the run after an import outside the package or after a deployment that changed the normalizer version); call it from a command-line process, never from a request.
|
|
896
|
+
* 索引をすべての日報と 500 件ずつ突き合わせる処理 (最初の構築と、パッケージの外の取り込みの後と、正規化の版が変わる配備の後)。要求の中ではなく、コマンドラインのプロセスから呼ぶ。
|
|
897
|
+
*
|
|
898
|
+
* @param server The server. サーバー。
|
|
899
|
+
* @param signal Stops between batches when aborted (for example on SIGINT). 中止されたら区切りの間で止まる (例えば SIGINT)。
|
|
900
|
+
* @param report Receives the counters after every batch. 区切りごとに数を受け取る処理。
|
|
901
|
+
* @returns The counters and the elapsed time. 数と、かかった時間。
|
|
902
|
+
*/
|
|
903
|
+
export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSignal, report: (line: string) => void): Promise<DailyReportSearchReconcileSummary> =>
|
|
904
|
+
server.service.reconcileSearchIndex({
|
|
905
|
+
batchSize: 500,
|
|
906
|
+
signal,
|
|
907
|
+
onProgress: ({ scanned, written, skipped, lastHubId }) => report(`scanned=${scanned} written=${written} skipped=${skipped} last=${lastHubId}`),
|
|
908
|
+
})
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
- **What matches.** A query is space-separated keywords; words in double quotes form one keyword that needs every one of its words. The AND / OR toggle combines the keywords (every keyword, or any). A word matches as a substring of the normalized text of one field or of one comment the viewer may read (a word never spans two fields).
|
|
912
|
+
The normalization is fuzzy-search's default (`DEFAULT_NORMALIZE_OPTIONS`) after removing zero-width characters, so full-width and half-width forms, kana and case fold together. Two kinds of substrings do not match: a word that starts with an iteration mark (`々木` does not match `佐々木`) and a word cut inside a grapheme (`デンフ`, half-width kana before its voiced mark, does not match `デンプン`).
|
|
913
|
+
- **What is searched.** The fields the detail view shows, resolved the same way (the title, the body, the people masked like the detail, the categories, the customer, the interviewers, the label names and the attachment file names), the text the host's readers return for an external source (`externalSourceTexts`: lines the detail view does not show, never the audit actors or raw ids), and the comments.
|
|
914
|
+
A posted comment is searched for a viewer the detail view would show it to (the comment scope allows the report's source type, or the viewer wrote it); an external comment follows the comment scope alone.
|
|
915
|
+
- **Authorization.** The search SQL applies the detail's conditions itself: no deleted report, no other user's draft (by the draft label names), only the viewer's visible source types.
|
|
916
|
+
- **The index.** Two derived tables per database, `DailyReportSearchDocument` (one row per report: the normalized text, the digest of the raw source, the normalizer version, `row_version`) and `DailyReportSearchComment` (one row per comment, with its author id), both `nvarchar(max)` in `Latin1_General_100_BIN2` so that `LIKE` compares the normalized text as it is, and both deleted with their report (`ON DELETE CASCADE`).
|
|
917
|
+
The six save paths (create, update, publish, delete, add and delete a comment) rewrite the report's rows inside their own transaction, after taking an update lock on the report row; a failed index write fails the save. Publishing, deleting and the comment paths therefore run in a transaction, which a deadlock retries as a whole.
|
|
918
|
+
- **Reconciliation.** Writes outside the package (a sync, an import) leave the index stale until `service.reconcileSearchIndex({ batchSize, hubIds?, signal?, onProgress? })` runs.
|
|
919
|
+
It walks every report (or the given ones) in batches of 1 to 500, one transaction per batch at the low deadlock priority, takes the same locks as a save, and rewrites only the reports whose digest or normalizer version differ.
|
|
920
|
+
Run it from a command-line process after such writes, for the initial build, and after deploying a build whose normalizer version changed (the version fingerprints the normalization of the whole Basic Multilingual Plane, the Unicode and the ICU versions; the first save or reconciliation of a process computes it, about a second).
|
|
921
|
+
- **Requests.** `GET {apiBasePath}/search?q=<text>&combine=intersection|union` answers column-wise, `{ ids, businessDates, sourceTypes }` in the order of the ID list, with an ETag; a missing `q` or `combine` and a query the plan rejects are 400 (the body's `reason` is `empty`, `too-long`, `too-many-sets`, `too-many-words-in-set`, `too-many-words`, `word-too-long` or `blank-word`), and a viewer over `rateLimitPerMinute` or a process at `concurrency` gets 429 with `Retry-After`.
|
|
922
|
+
`GET {apiBasePath}/reports?ids=<id,id,...>` (distinct canonical ids, at most 100) answers the visible details among them, `{ reports }`. Neither is cached on the server.
|
|
923
|
+
`reports` has an admission of its own from the same factory, never the search's: `REPORTS_CONCURRENCY_PER_SEARCH_CONCURRENCY` (2) × `concurrency` reads at once per process, of which one viewer holds at most half, the search's `concurrency` (the client sends at most two batch reads at once), and `REPORTS_RATE_PER_SEARCH_RATE` (10) × `rateLimitPerMinute` reads per viewer and minute, in a bucket apart from the search's, so paging through a large answer neither runs out of reads nor spends the searches.
|
|
924
|
+
Its order is the search's: the ids (400), then a viewer without an internal user answered `{ reports: [] }` with no admission and no query, then the admission with the request's signal (an aborted request takes no slot and returns its token; a full gate is 429 with `Retry-After: 1`, an empty bucket 429 with one window's `Retry-After`), the query, and the slot's release whatever the outcome. Its refusal lines name `route=reports`.
|
|
925
|
+
- **The search box** (`config.search: {}` searches on Enter only; `{ debounceMs }`, an integer from 300 to 5000, also after typing stops) sits in a second row under the toolbar. Enter, Esc and the debounce wait while an IME composes.
|
|
926
|
+
While a search request runs or after it failed, the views keep the list they showed; an answer replaces the list in its order (a report the provider marked removed is left out), and the header shows the hit count. While an answer shows, the rows read their details by id in batches (`reports?ids=`) instead of by business date.
|
|
927
|
+
A new search scrolls the views to the top, releasing the search returns them to the report that was at the top before it, and the selection stays on a report the answer keeps, else moves to the nearest remaining one (on release, back to the selection before the search). A creation during a search releases it first and selects the new report.
|
|
928
|
+
Saves, SSE report and comment events and changes of the settled ID list re-query the search quietly after about a second (`If-None-Match`).
|
|
929
|
+
|
|
802
930
|
## Client wiring
|
|
803
931
|
|
|
804
932
|
```tsx illustrative
|
|
@@ -826,6 +954,7 @@ export default function Route() {
|
|
|
826
954
|
},
|
|
827
955
|
onError: (info) => yourToast(info.message), // Optional: route mutation failures to your own toast
|
|
828
956
|
headingLevel: 2, // Optional: level of each report's heading, 2–5 (default 3)
|
|
957
|
+
search: {}, // Optional: the search box (only when the server has the `search` block; see Text search)
|
|
829
958
|
// apiBasePath: "/daily_report/api", ssePath: "/sse/daily_report/updates"
|
|
830
959
|
}}
|
|
831
960
|
/>
|
|
@@ -844,15 +973,27 @@ Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `repor
|
|
|
844
973
|
An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it; an answer that arrives for an earlier id, or after the component unmounted, is dropped. An id of 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error).
|
|
845
974
|
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.
|
|
846
975
|
|
|
847
|
-
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 notice. Failures on a continuation that resolves after a no-reload user switch are suppressed
|
|
848
|
-
|
|
976
|
+
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 notice. Failures on a continuation that resolves after a no-reload user switch are suppressed.
|
|
977
|
+
Deleting a report or a comment that the server no longer has (404) counts as done: the row or the comment stays removed and nothing is shown. Only the deleting intents, `delete` and `deleteComment`, read a 404 so (their callbacks must say what a 404 means, `onGone`, and no other intent's callbacks may, by type: `ActionCallbacks`); the 404 of any other intent rolls back and is reported.
|
|
978
|
+
`deleteReport` rejects with the failure after it has put the report back into the list (the notice reports it as well), so a caller never hands focus on as if the report were gone (the side pane's delete button answers the rejection with the `labels.deleteFailed` alert, the DetailList's with nothing more); a continuation that resolves after a user switch rolls nothing back and resolves.
|
|
979
|
+
`DailyReportErrorProvider` is required above `DailyReportActionProvider`: `DailyReportPage` composes both, and a host that composes the action provider itself wraps it in the error provider (both are exported from `@aiquants/daily-report/client`).
|
|
980
|
+
Outside it the action provider and the List's mobile overlay throw at render (`[daily-report] useDailyReportErrorSurface must be used within a DailyReportErrorProvider`), as the action context's hooks throw outside `DailyReportActionProvider` (one reader of a provided value, `useProvidedContext`): no surface of the package does nothing silently outside its provider.
|
|
849
981
|
|
|
850
|
-
- **The built-in notice** is one
|
|
982
|
+
- **The built-in notice** is one band (`data-testid="daily-report-error-banner"`), a `role="group"` named by its message, with a close button named by `labels.close` and described by the message. It never leaves on a timer (WCAG 2.2.3): only the user removes it, with the close button or with `Escape` while focus is inside it, which removes the notice alone (it sits on top of the `Escape` stack the mobile overlay uses, so the overlay stays open). A newer failure replaces the message; there is one notice.
|
|
983
|
+
When failures with the same message follow one another, the band also shows how many came in a row (`labels.noticeRepeated(count)`, from the second: en "(2 times)", ja 「(2 回目)」), which becomes part of its name; a failure with another message counts from one again.
|
|
984
|
+
- **Every failure is announced, once** (WCAG 4.1.3): the announcements come from persistent, visually hidden `role="alert"` regions (`[data-daily-report-notice-alert]`) that exist, empty, before any failure — the provider's own after its children, and one inside each registered place of the notice for as long as it is registered (the mobile overlay's dialog).
|
|
985
|
+
A failure writes its message, as a new text keyed by its occurrence, into the region of the place in front at that moment, so the same message failing again is a new insertion and is announced again. The band is no live region: moving between the places (the overlay opening or closing) announces nothing.
|
|
851
986
|
- **It covers nothing**: the notice is rendered in the flow, where it takes its own room. While the List's mobile overlay is open it renders inside the overlay's modal `<dialog>`, between the report and the close button (in the top layer, so it is neither inert nor hidden from assistive technology); otherwise it renders in the provider's own slot after the page, centred with the row gutter G around it (`data-daily-report-notice-slot`). Its block size is rounded up to the lattice like a DetailList body.
|
|
852
|
-
- **Focus is handed back, never dropped**: when the notice leaves while it holds focus, focus goes to where it came from (when that element is still in the document),
|
|
987
|
+
- **Focus is handed back, never dropped**: when the notice leaves while it holds focus, focus goes to the first visible successor (below) of: where it came from (when that element is still in the document), the view's cursor row (its one Tab stop), the dialog that hosts the notice, the selected view tab (a list without rows has no cursor row) and the status panel (no view is mounted yet).
|
|
988
|
+
- **Focus goes only to what shows it**: every control that removes itself, or empties, while it holds focus — the two retry buttons, the notice's close, the header badge's container, the status panel that the content replaces — hands focus on through one rule (`focusVisibleSuccessor` in `src/client/keyboard/focus-successor.ts`).
|
|
989
|
+
A candidate takes focus only when it is in the document, focusable, has a box with an area and passes `checkVisibility({ visibilityProperty: true, opacityProperty: true })`, and the rule confirms where focus landed before it stops, so a visually hidden region, a 0 × 0 box and an element that is not rendered never receive it. When no candidate qualifies, the rule moves nothing.
|
|
853
990
|
- **The page's stream status**: until the first ids chunk arrives `DailyReportPage` shows one status panel (`data-testid="daily-report-status-panel"`, `data-phase` `loading` or `failed`) whose content switches between loading and the load error with its retry button, so the panel persists through a retry, and the header's progress badge is one persistent container whose content switches by phase.
|
|
854
|
-
A retry button that its own press removes first hands focus to its container
|
|
855
|
-
|
|
991
|
+
A retry button that its own press removes first hands focus to its container (the status panel, or the badge's container), which draws the control focus outline while it holds keyboard focus.
|
|
992
|
+
When the status panel leaves while it holds focus, the page keeps a pending hand-over (`FocusHandOverContext`): the content's view takes it in the first commit that mounts its Tab-stop row and focuses that row (the selected view tab when the list completed empty), and a status screen that a user switch rebuilds takes it as its new panel mounts and focuses that panel.
|
|
993
|
+
Either passes focus only while focus is still nowhere, so focus the user has put elsewhere in the meantime stays. Between the panel leaving and the view's first rows focus is on `body` for one commit: a view draws no rows before its first height arrives (**One measurement, in one unit** in [View height](#view-height-host-layout)).
|
|
994
|
+
The badge's container, emptied by the stream's completion while it holds focus (an empty container is a 0 × 0 box), hands focus to the page's successors: the view's Tab-stop row, else the selected view tab.
|
|
995
|
+
One persistent, visually hidden region of the page (`data-testid="daily-report-stream-announcer"`, outside the screens the phase swaps, never focusable) announces the changes of the stream's lifecycle that happen after it mounts: loading after a retry (`labels.loadingIds`) and reconnecting (`labels.streamRetrying`) as a `role="status"`, a failure as a `role="alert"` (`labels.loadError` before any report arrived, `labels.streamFailed` after).
|
|
996
|
+
It mounts empty, since the state at mount is the one the visible status panel already shows (writing it would only make reading mode read the panel twice), and from the first change on it holds the current state's text.
|
|
856
997
|
The badge's own retrying and failed contents are live regions only where the page does not announce them: `DailyReportIdsStreamStatus` placed alone keeps its `role="status"`.
|
|
857
998
|
|
|
858
999
|
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)).
|
|
@@ -872,7 +1013,10 @@ If your app overrides `config.apiBasePath`, pass the same value to `createDailyR
|
|
|
872
1013
|
|
|
873
1014
|
- **Rows follow the stream by its changes.** Every items publish of the session carries its revision (`itemsRevision`, never reused by another session), the number of reports it holds (`loadedCount`) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
|
|
874
1015
|
The list itself is not part of a publish, so a publish costs the size of its change, never the list's length; a reader that needs the whole list builds it when it reads (`DailyReportIdsStreamClient.readItems()`: the list as of the last publish, with the changes still waiting in the coalescing window taken back).
|
|
875
|
-
`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
|
|
1016
|
+
`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.
|
|
1017
|
+
A change is located by reading a few rows, never by walking the list: rows that arrive after the old last row are placed by one comparison with it (a publish of Δ such rows reads at most Δ + 1 rows, the stream client included), and a row that arrives out of order, a changed row, a removed row and a held row that a settled stream lets go are each found by binary search (⌈log2 n⌉ reads); the user's own holds, releases and removals read the held rows' table and one binary search the same way.
|
|
1018
|
+
The edits of a derivation are then applied in one copy of the n row references into a pre-sized array (`applyReportRowsEdits` in `src/client/contexts/daily-report-rows-derivation.ts`), with no `concat`, `filter`, `slice` or spread: `Array.prototype.concat` is fast only while the page-wide `Symbol.isConcatSpreadable` protector holds, which one assignment by any library breaks for the page's lifetime (apache-arrow assigns it on its prototypes, for one), and the slow path then visits every row on every publish.
|
|
1019
|
+
Measured in Chromium 148 at 228,222 rows with the protector broken: `concat` took 51.6 ms (median; 99.5 ms at most) where the copy takes 0.65 ms (1.6 ms at most). The copy stays proportional to the list's length, because the rows are one immutable `DailyReportItem[]` per revision, which the views, the action state and the search's list derivation read as an array.
|
|
876
1020
|
It reads the whole list (once) and derives from it 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).
|
|
877
1021
|
A report the user deleted carries a deletion mark until a settled scan lacks it, and a marked report is never shown, also when the stream's net change for it is a value change (a deletion and a relay inside one coalescing window, or a change derived after the mark). 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.
|
|
878
1022
|
- **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.
|
|
@@ -885,8 +1029,9 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
885
1029
|
|
|
886
1030
|
- **Host contract**: render `DailyReportPage` (or `DailyReportResolvedContent`) as the content of a column flex container (`display: flex; flex-direction: column`) whose height is bounded — a definite height, or the growing item of a column with a minimum height, such as a `min-height: 100dvh` shell whose footer follows the content. The header that `renderHeader` returns and the page box are items of that container, and the page box takes the rest of it. Nothing is bound and no host variable is read.
|
|
887
1031
|
Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
|
|
888
|
-
- **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is
|
|
889
|
-
|
|
1032
|
+
- **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is the block quantum q of the device-pixel lattice at the top and the sides (4 px, 8 px at the odd multiples of 1/8; **Row slots on the lattice**) and none at the bottom, so the view's box ends exactly at the page's bottom edge.
|
|
1033
|
+
Between the wrapper's top and the active tab panel sits the band of the view-mode toolbar (the search row too, when configured), which does not fill: its height is its content rounded up to q (`VIEW_TOOLBAR_BAND_CLASS_NAME`), so the views start on the lattice (**Origin on the lattice**).
|
|
1034
|
+
- **One page frame and one column for every screen**: the status screen (loading or the load error, one panel) and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, q from the frame's top and side edges, starting on the lattice; the column carries q itself). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
|
|
890
1035
|
- **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`, its children in block flow): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
|
|
891
1036
|
The root's layout and paint are not contained: either would make the root the containing block of the mobile overlay, a fixed-position descendant, and as a flex item of its column the root could not be a relayout boundary anyway (Chromium makes no flex or grid item one, contained or not).
|
|
892
1037
|
- **The view's body is the relayout boundary** (`VIEW_BODY_CLASS_NAME`: a column flex container at `height: 100%` of the root, with `contain: size layout style`): it fills the root exactly, and since the root lays it out in block flow it is not a flex item, so with its size and layout contained it is a relayout boundary. Everything the view lays out — the list column, the panel group and the side pane, the scroll bar — sits inside it, so a change there (an ids stream publish that changes the row count or the scroll bar's thumb) lays out from the body, never from the document.
|
|
@@ -901,8 +1046,12 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
901
1046
|
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.
|
|
902
1047
|
The slots are whole device pixels at every ratio of the device-pixel lattice, every multiple of 1/8 from 1 to 3 (the List slot where it is a multiple of 8, as at the default 16 px root; **Row slots on the lattice**): 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 4 px lattice.
|
|
903
1048
|
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).
|
|
904
|
-
The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the
|
|
905
|
-
|
|
1049
|
+
The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the block quantum q of the device-pixel lattice (**Row slots on the lattice**): 4 px wherever 4 CSS px is a whole number of device pixels, 8 px at the odd multiples of 1/8, where 4 px is half a device pixel.
|
|
1050
|
+
The client entry exports the hook that carries q, `DAILY_REPORT_LATTICE_BLOCK_QUANTUM_CLASS_NAME` (the package's own class, not a copy): put on the document root, it defines the custom property `--aqdr-lattice-block` for every element, and a bar whose content decides its height rounds it up to that value (`height: calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`).
|
|
1051
|
+
The column's top inset is q and the toolbar's band rounds up to q (**Origin on the lattice**), so with the host's bars on q the views' top edge lies on the lattice at every ratio of the domain. `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px) is the unit q is built from, and a bar rounded to it alone ends half a device pixel off at the odd eighths.
|
|
1052
|
+
The host cannot size the window, so the remainder of the window height modulo q stays inside the view, whose end lies on the lattice only when the window height is a multiple of q.
|
|
1053
|
+
The class is literal Tailwind arbitrary properties, so a Tailwind host that scans the package (**Tailwind v4 host** in [Tailwind CSS](#tailwind-css)) and a host of the standalone stylesheet both have its rules; a bar's class reads the property (`h-[calc-size(auto,round(up,size,var(--aqdr-lattice-block)))]`) and restates neither 4 nor 8, and a host that computes its styles in script reads `var(--aqdr-lattice-block)` the same way.
|
|
1054
|
+
Importing the constant puts the client entry into the chunk that imports it: `dist/client.mjs` is one bundle, and a bundler keeps the top-level statements it cannot prove free of effects (about 106 KB in a host's root chunk, measured with Rollup 4.61). A host whose document root renders on every page may therefore write the class literally there and tie it to the export with a unit test that reads both, as Tailwind needs the literal text anyway.
|
|
906
1055
|
- **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja 「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no message of their own; they only theme the engine's (`VIEW_EMPTY_TEXT_THEME_CLASS_NAME` on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark: 4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
|
|
907
1056
|
|
|
908
1057
|
### Keyboard, focus and selection (List / DetailList)
|
|
@@ -1028,6 +1177,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1028
1177
|
Every label and its value is a pair of its own (`dt` / `dd` inside a `dl`, one `MetadataField` primitive), and no value repeats its label as text (no `区分: 日報`): the DetailList metadata column (author, created at, updated by, updated at, business date, and the visit time, the category and the creation category, each when present) and the side pane's subject, customer and visit time; both attachment lists are named by their section heading.
|
|
1029
1178
|
The DetailList header's chips are one `dl` too: ID, business date, author, visit time, updated at, and the category and the creation category when present, each label once; the header's markers and buttons sit outside the list.
|
|
1030
1179
|
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.
|
|
1180
|
+
The same component decides the visible form, one rule for every caller: a date-time shows as `YYYY-MM-DD HH:mm` — the date, one space, the hours and minutes, the seconds cut rather than rounded (`2026-12-31T23:59:59` shows `2026-12-31 23:59`) — while its `datetime` keeps the full ISO value, and a date alone shows as it is.
|
|
1031
1181
|
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).
|
|
1032
1182
|
- **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).
|
|
1033
1183
|
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.
|
|
@@ -1077,7 +1227,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1077
1227
|
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.
|
|
1078
1228
|
- **Row slots on the lattice**: every row slot of both views is built on the 4 px layout lattice u (one constant, `LAYOUT_LATTICE_PX` in `src/shared/layout-lattice.ts`, which the List slot and the DetailList estimate 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).
|
|
1079
1229
|
The device-pixel lattice covers every multiple of 1/8 from 1 to 3, the default ratios of phones included (2.625 for many 412 px Android phones): at the odd multiples of 1/8, 4 CSS px ends half a device pixel off (10.5 at 2.625) while 8 CSS px is whole, so the boxes whose content decides their height round up to the block quantum q of the ratio — 4 px at the multiples of 1/4 and 8 px at the odd multiples of 1/8 (`LATTICE_BLOCK_QUANTUM_CLASS_NAME`: the custom property `--aqdr-lattice-block`, `4px` overridden to `8px` under the media condition `resolution: <dpr>dppx` of each of the eight odd eighths).
|
|
1080
|
-
The view roots carry the property (an attachment tile inherits it from its view), and so does each box sized by it (`LATTICE_BLOCK_SIZE_CLASS_NAME`, which therefore also works outside a view).
|
|
1230
|
+
The content column and the view roots carry the property (an attachment tile inherits it from its view), and so does each box sized by it (`LATTICE_BLOCK_SIZE_CLASS_NAME`, which therefore also works outside a view); a host binds it on its document root through `DAILY_REPORT_LATTICE_BLOCK_QUANTUM_CLASS_NAME` for its own bars (**G-symmetric frame** in [View height](#view-height-host-layout)).
|
|
1081
1231
|
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).
|
|
1082
1232
|
The List slot is P above, a multiple of 8 at the default 16 px root (160) and so whole at every ratio of the domain; at a root where P is an odd multiple of 4 (188 at a 20 px root) the List's rows step by half a device pixel at the odd eighths.
|
|
1083
1233
|
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 q (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) and a body of an odd multiple of 4 px at an odd eighth leave the next row on whole device pixels; at a 16 px root and a quarter ratio the content is already on the lattice.
|
|
@@ -1097,7 +1247,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1097
1247
|
In the views the box removes every document-rooted layout of a key's own rendering: in the app's keyboard harness with 3.10.0 no key's rendering lays out from `#document` in any condition, and a key's layout CPU p50 is about 0.2–0.4 ms at 1× CPU on an uncontended host and, at 4× CPU, about 1.1 ms in the List and 6.8–6.9 ms in the DetailList, all of it inside the list (3.99 and 8.12 ms from the document root before). What still lays out from the document root is the List side pane's deferred catch-up to the selection: about once per spaced key, and once per hold when the key is held.
|
|
1098
1248
|
Inside the box the rows' overflow is ink overflow, so a scroll the browser makes on its own to reveal an overscan row (a find-in-page match there) cannot move the list and moves the nearest outer scroller instead when the row's box lies outside its view; the views' own reveals do not depend on it (one Tab stop per view, rows focused with `preventScroll`, keyboard focus revealed by the scroller).
|
|
1099
1249
|
Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
|
|
1100
|
-
The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the
|
|
1250
|
+
The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the block quantum q (**Row slots on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list changes**); measuring the frame would read back the box it fills. A held body is not measured, and a body that is replaced (another load state, a released hold) is observed in its place.
|
|
1101
1251
|
A frame with layout and size containment that is neither a flex nor a grid item is a relayout boundary in Chromium. Neither frame contains paint, because the hover `shadow-lg` of an unselected surface (22 px below, 12 px to the sides) reaches beyond the 8 px gutter G that paint containment would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
|
|
1102
1252
|
Scrolling repaints rows only where `VirtualScroll` shifts its rendering window. A scroll step that mounts no row writes only the items wrapper's `transform` and repaints no row; a step that mounts one makes Chromium re-centre the area it paints the wrapper's layer in (`will-change: transform`), and every kept row whose content clips its own overflow repaints, which the rows of both views do. `@aiquants/virtualscroll`'s README ("What a scroll step paints") gives the cost of a one-row shift in Chromium 148: 36 layers with 160 px rows and 26 with 448 px rows, Paint 0.99 and 0.66 ms.
|
|
1103
1253
|
Containing paint would not avoid it (an overflow clip inside a row still marks the row as clipped by that area) and would clip the hover shadow; a `perspective` on the wrapper would avoid it, but it composites the rows under a non-2D transform that Chromium resamples at some device-pixel ratios (at 1.25 the band between the ring and the outline blends). Neither is used, so the strokes below stay whole device pixels.
|
|
@@ -1140,7 +1290,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1140
1290
|
- **Borders declare their style**: every border the package draws sets `border-solid` itself, so a host whose base layer resets the border style of every element (for example `* { border: none }` in Tailwind v4, which turns `--tw-border-style` into `none`) cannot erase it; the hover border and the switch track depend on it.
|
|
1141
1291
|
The card-like surfaces — the List card and its skeleton, the desktop side pane, the DetailList card and its skeleton — share one token (`CARD_SURFACE_CLASS_NAME`): a 16 px corner, a 1 px slate-200 / dark slate-700 border, white / dark slate-900 at 70 %, a 16 px inset that counts the border (1 px border + 15 px padding, so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
|
|
1142
1292
|
Two more surfaces have one token each, and no other place writes their classes: the placeholder panel (`PLACEHOLDER_PANEL_CLASS_NAME`: the side pane's selection prompt and its loading and empty tabs; a dashed 1 px border, the 12 px corner of an inner panel and the 12 px inset of every such panel (1 + 11), slate-50 / dark slate-900 at 40 %, slate-600 / dark slate-300 text) and the reading panel (`READING_PANEL_CLASS_NAME`: a report's content in the side pane and in the DetailList card;
|
|
1143
|
-
a 12 px corner and a concentric 12 px inset, slate-50 / dark slate-800 at 60 %, slate-700 / dark slate-200 text on 24 px lines
|
|
1293
|
+
a 12 px corner and a concentric 12 px inset, slate-50 / dark slate-800 at 60 %, slate-700 / dark slate-200 text on 24 px lines;
|
|
1294
|
+
in the side pane it reserves three reading lines while its report loads, `READING_PANEL_RESERVE_CLASS_NAME` = `min-h-24`, the inset twice and three line boxes of its own `p-3` and `leading-6`: 12 + 3 × 24 + 12 = 96 px at the 16 px root, and its skeleton is three 24 px line boxes, `READING_SKELETON_LINE_CLASS_NAME`, each centring a 16 px bar on the text line it stands for, so a report of up to three lines keeps the panel's height when it loads, at every root font size).
|
|
1295
|
+
The scroll bar's business-day bubble is the bordered pill (`BORDERED_PILL_CLASS_NAME`: 24 px tall, its text 12 px in = 1 px border + 11 px padding, the radius of its round ends). `src/client/ui/border-style.spec.ts` checks these rules, a source scan included.
|
|
1144
1296
|
- **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on a thumbnail frame's skin, only while the frame may show its load activity, see [Attachment display](#attachment-display)), the switch's thumb, the entrance of the comment delete confirmation and of the error notice, the mobile overlay's slide and its backdrop.
|
|
1145
1297
|
The one exception is the mobile overlay layer: its right edge follows the host's pinned-chrome offset (`--aqdr-overlay-right`) over 0.5 s, also under `motion-safe`, in step with the host's own transitions.
|
|
1146
1298
|
State surfaces never animate their opacity: a row's surface (`data-daily-report-row-surface`, which paints the selection ring and the focus outline) and its ancestors up to the row frame stay opaque and still: the List's loading skeleton pulses an element inside its surface (the DetailList's skeleton does not pulse), so a selected or focused loading row keeps both indicators at 3:1 or more at the pulse's trough.
|
|
@@ -1164,14 +1316,16 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1164
1316
|
A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error notice and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error notice (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
|
|
1165
1317
|
**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).
|
|
1166
1318
|
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.
|
|
1167
|
-
- **Origin on the
|
|
1168
|
-
|
|
1169
|
-
|
|
1319
|
+
- **Origin on the 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 the block quantum q (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),var(--aqdr-lattice-block)))]`, part of `VIEW_COLUMN_CLASS_NAME`, which carries q itself, so the status screen starts there too), less than q left of the exact centre, and keeps q from the frame's top and side edges.
|
|
1320
|
+
q CSS px is a whole number of device pixels at every ratio of the lattice's domain (4 px at the multiples of 1/4, k device pixels at the ratio k/4; 8 px at the odd multiples of 1/8), so every inline edge built on the origin — G, the ladder steps of 8 and 16, the card, the row frame — starts on a whole device pixel: on a 412 px phone at 2.625 a DetailList surface's left edge is 16 px in, at device pixel 42.
|
|
1321
|
+
Below the column's top inset the view-mode toolbar sits in its band, as tall as its content rounded up to q (`VIEW_TOOLBAR_BAND_CLASS_NAME`; the search row, when configured, is inside the band), so the views start q + the band below the column's top — 4 + 32 = 36 px at the multiples of 1/4, 8 + 32 = 40 px at the odd eighths — on the lattice at every ratio.
|
|
1322
|
+
Measured in Chromium at 1280 × 800 and 1.125 under a 32 px host header: the toolbar, the view and the first row surface start at device pixels 45, 81 and 90 (with a 4 px inset and no band they started at 40.5, 76.5 and 85.5, half a device pixel off, and every row edge, ring and outline below inherited the half pixel).
|
|
1323
|
+
Still off the lattice at the odd eighths (measured, open): an inline edge at an odd multiple of 4 px inside a surface (the 12 px tier's insets, the 4 px insets of the tab bars and of the toolbar inside its band); the focus outline's 4 px offset, 10.5 device pixels at 2.625, which no CSS length moves, since Chromium 148 rounds `outline-offset` down to whole CSS px (`calc(10px / 2.625)` gives 3 px); and the positions the browser chooses itself, such as a `scrollIntoView` target or a window bottom that is no multiple of q.
|
|
1170
1324
|
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.
|
|
1171
1325
|
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.
|
|
1172
1326
|
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.
|
|
1173
|
-
A host that wants the same whole-pixel strokes on the top and bottom edges
|
|
1174
|
-
The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of
|
|
1327
|
+
A host that wants the same whole-pixel strokes on the top and bottom edges puts the edges on the lattice too — a header and a footer whose heights are their content rounded up to q (`height: calc-size(auto, round(up, size, var(--aqdr-lattice-block)))` with `DAILY_REPORT_LATTICE_BLOCK_QUANTUM_CLASS_NAME` on the document root; **G-symmetric frame** in [View height](#view-height-host-layout)), since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px).
|
|
1328
|
+
The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of q; the bottom-aligned surface then sits exactly G from the view's end.
|
|
1175
1329
|
In any other window, and at other ratios (browser zoom such as 0.9, 1.1 or 1.33), it sits at least G and less than G + 1 device pixel from the end (**G-symmetric frame** in [View height](#view-height-host-layout)).
|
|
1176
1330
|
- **Side pane** (List, desktop layout): the panel group fills the view's body (which fills the view's root), and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
|
|
1177
1331
|
The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
|
|
@@ -1303,8 +1457,11 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
|
|
|
1303
1457
|
| `[data-daily-report-root-font-size-probe]` | Hidden 1 rem probe inside the List's root whose size change re-reads the root font size (`aria-hidden`, fixed, out of the flow) |
|
|
1304
1458
|
| `[data-daily-report-attachment-preview]` | Tile link that wraps a thumbnail frame (the skin's hover and press rim read it) |
|
|
1305
1459
|
| `[data-daily-report-notice-slot]` | The place the error notice renders in: the provider's own slot after the page, or the mobile overlay's slot inside its dialog |
|
|
1460
|
+
| `[data-daily-report-notice-alert]` | A persistent `role="alert"` region of the error notice (visually hidden): the provider's own after the page, and one inside each registered place of the notice; empty until a failure is announced in its place, then the message of that failure |
|
|
1306
1461
|
| `[data-daily-report-stream-status]` | The header's ids-stream badge container (persistent; empty while there is nothing to show) |
|
|
1307
|
-
| `data-
|
|
1462
|
+
| `[data-daily-report-status-panel]` | The status screen's panel (also `data-testid="daily-report-status-panel"`), the last of the page's focus successors |
|
|
1463
|
+
| `[data-daily-report-view-tabs]` | The view-mode tab list (its selected tab is the focus successor of a list without rows) |
|
|
1464
|
+
| `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, `daily-report-error-banner` (the error notice), `daily-report-status-panel` (the status screen's panel, `data-phase` `loading` / `failed`), `daily-report-load-error-retry`, `daily-report-stream-announcer` (the page's status region, never focusable), `daily-report-ids-stream-status` and `daily-report-ids-stream-retry` (the badge's content and its retry button), and the attachment ids above |
|
|
1308
1465
|
|
|
1309
1466
|
Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-scrolling`. Hosts may select on them — in tests, or in CSS to adapt their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during a key's render the frame already names the new selection while the content still shows the previous report.
|
|
1310
1467
|
|
|
@@ -1329,32 +1486,33 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1329
1486
|
```
|
|
1330
1487
|
|
|
1331
1488
|
- **One surface for all wording.** The catalog covers the 11 engine chrome keys of
|
|
1332
|
-
`@aiquants/virtualscroll` plus
|
|
1489
|
+
`@aiquants/virtualscroll` plus 98 own keys: field headings, the page title (`title`, passed to
|
|
1333
1490
|
`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
|
|
1334
|
-
screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the status screen and the page's status region
|
|
1335
|
-
and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
1491
|
+
screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the search box, the status screen and the page's status region
|
|
1492
|
+
and the mutation-failure message with its repeat count. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
1336
1493
|
value, so the scroll arrows, the names of the scroll bar and its thumb, and the "No items" text always speak the same language as the rest.
|
|
1337
1494
|
- **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
|
|
1338
1495
|
and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
|
|
1339
1496
|
its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
|
|
1340
1497
|
re-render the memoized list subtree.
|
|
1341
|
-
- **Formatter keys.**
|
|
1498
|
+
- **Formatter keys.** Fifteen keys take arguments and are functions: `totalCount(count)`,
|
|
1342
1499
|
`debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
|
|
1343
1500
|
`streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
|
|
1344
1501
|
`interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
|
|
1345
1502
|
`DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
|
|
1346
|
-
`toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })`, `listRowPosition(position, total)
|
|
1503
|
+
`toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`, `listRowPositionInUnknownTotal(position)`,
|
|
1504
|
+
`searchHitCount(count)` and `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`). An override of such a key must be a function too.
|
|
1347
1505
|
- **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
|
|
1348
1506
|
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
|
|
1349
1507
|
the raw number and formats it itself.
|
|
1350
1508
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
1351
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
1509
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 109 keys), a string
|
|
1352
1510
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
1353
1511
|
function. An `undefined` value keeps the catalog value.
|
|
1354
1512
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
|
|
1355
|
-
`sourceTypeConfigs`, `renderHeader`, `showDevControls`, `headingLevel`, `onError`, `onSessionExpired`. Any other
|
|
1513
|
+
`sourceTypeConfigs`, `renderHeader`, `showDevControls`, `headingLevel`, `onError`, `onSessionExpired`, `search`. Any other
|
|
1356
1514
|
own key — also one whose value is `undefined` — throws the key error of **Configuration errors**
|
|
1357
|
-
(`[daily-report] config key must be one of "apiBasePath", …, "
|
|
1515
|
+
(`[daily-report] config key must be one of "apiBasePath", …, "search"; got "<key>"`). This runs at
|
|
1358
1516
|
render because a config built outside a typed object literal (a `useMemo` result, a variable) skips
|
|
1359
1517
|
TypeScript's excess-property check.
|
|
1360
1518
|
- **`defaultDailyReportClientConfig` is spreadable input.** Its type `DailyReportClientConfigDefaults`
|
|
@@ -1385,7 +1543,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1385
1543
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
1386
1544
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
1387
1545
|
|
|
1388
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1546
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 109 keys, the 11 engine keys
|
|
1389
1547
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
1390
1548
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
1391
1549
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -1486,6 +1644,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1486
1644
|
| `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
|
|
1487
1645
|
| `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
|
|
1488
1646
|
| `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
|
|
1647
|
+
| `noticeRepeated` | the error notice, beside its message when failures with the same message follow one another (`count` is how many came in a row, from 2; part of the notice's name) | `(2)` → (2 times) | `(2)` → (2 回目) |
|
|
1489
1648
|
| `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 })` → 未読、スター付き |
|
|
1490
1649
|
| `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 件目 |
|
|
1491
1650
|
| `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 件目 |
|
|
@@ -1616,13 +1775,15 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1616
1775
|
hook then opens without a cursor and the server starts from the newest entry at connect time.
|
|
1617
1776
|
- **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.
|
|
1618
1777
|
- **Event size**: the service writes an event to the stream only when its JSON is at most `SSE_EVENT_MAX_BYTES` (6,356,992 bytes, `src/server/payload-limits.ts`): the action's form cap (1 MiB, **Request values**) at the largest growth `JSON.stringify` can give a text of the form (6 times: a control character, one raw byte in a multipart body, becomes the escape `\u00XX`), plus 64 KiB for the rest of the event.
|
|
1619
|
-
So no text an accepted action carries can push its event over the bound. A larger event — only a `report-create`, `report-update` or `report-publish` of a report whose accumulated detail (its body and all its comments) exceeds about 6 MiB — is not written, and the service logs `[SSE] Event too large (<operation>): <bytes> bytes > 6356992; not published` at `warn
|
|
1778
|
+
So no text an accepted action carries can push its event over the bound. A larger event — only a `report-create`, `report-update` or `report-publish` of a report whose accumulated detail (its body and all its comments) exceeds about 6 MiB — is not written, and the service logs `[DailyReportService] [SSE] Event too large (<operation>): <bytes> bytes > 6356992; not published` at `warn` (its channel's prefix first, **`logger`** in [DI ports](#di-ports)); the caches were invalidated before, so viewers read the change on their next load.
|
|
1620
1779
|
The persistent stream itself is bounded by a count of entries, not by bytes: at most `streamMaxLen` (10,000 by default, trimmed with `MAXLEN ~`) events of at most `SSE_EVENT_MAX_BYTES` each, about 59.2 GiB in the worst case, while a status update is under 200 bytes and a report event carries one report's detail. One read of it is bounded by the byte budget above; bounding the stream's own bytes needs thin events (the type, the ids and a version, with the detail read again through the ETag'd endpoints), which is a change of the client's protocol.
|
|
1621
1780
|
- **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.
|
|
1622
1781
|
|
|
1623
1782
|
## API Surface (Summary)
|
|
1624
1783
|
|
|
1625
|
-
Every entry exports by name (no `export *`), so a module-internal helper never becomes public by accident. A name is added to an entry only when a consumer — a host app or the examples — imports it
|
|
1784
|
+
Every entry exports by name (no `export *`), so a module-internal helper never becomes public by accident. A name is added to an entry only when a consumer — a host app or the examples — imports it, or when a declaration that an entry exports references it (the closure, API Extractor's `ae-forgotten-export` rule), so a host can name every type it supplies or receives — a port, a props type, a hook's value — instead of restating its shape.
|
|
1785
|
+
A type the closure brings in is published under a name that carries `DailyReport` (a short module name is renamed with `as`). Documenting a name here is no reason to export it, and the values that tune the package itself (its row geometry, its scroll settings) stay inside it.
|
|
1786
|
+
`src/public-surface.spec.ts` pins the runtime and the type names of the three entries below, fails when a public name is missing from this section, and checks the closure: it emits the three entries' declarations in memory, walks every type reference from each exported declaration (`Foo`, `Foo<T>`, `extends Foo`, `typeof foo`, `import("./module").Foo`), through unexported declarations of the package too, and fails on a declaration of the package that it reaches and no entry exports (a dependency's declarations are not followed).
|
|
1626
1787
|
|
|
1627
1788
|
- **shared** (`@aiquants/daily-report`, isomorphic):
|
|
1628
1789
|
- 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),
|
|
@@ -1631,22 +1792,36 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1631
1792
|
and the wire contract of the action and the JSON endpoints (**One wire contract** in [Server wiring](#server-wiring-di)), for a host that calls the routes itself: `DAILY_REPORT_ACTION_INTENTS` / `DAILY_REPORT_ACTION_FIELDS` (the intents and the form's field names),
|
|
1632
1793
|
`encodeDailyReportActionCommand(command)` (a command as its `FormData`), `parseDailyReportActionResult(intent, json)` (the strict reading of a 200 answer: `{ result }` or `{ mismatch }`), `DAILY_REPORT_API_ENDPOINTS` / `DAILY_REPORT_API_QUERY_PARAMS` (the endpoint and GET query-parameter names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms).
|
|
1633
1794
|
- Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`,
|
|
1634
|
-
and the action's `DailyReportActionIntent` / `DailyReportActionCommand` (one command per intent) / `DailyReportActionResult` (the 200 answer of every intent, discriminated by `intent`) / `DailyReportActionResultOf<Intent>` (one intent's answer) / `DailyReportActionFailure` (a refusal, `{ error }`).
|
|
1795
|
+
and the action's `DailyReportActionIntent` / `DailyReportActionCommand` (one command per intent) / `DailyReportActionResult` (the 200 answer of every intent, discriminated by `intent`) / `DailyReportActionResultOf<Intent>` (one intent's answer) / `DailyReportActionFailure` (a refusal, `{ error }`) / `DailyReportActionResultReading` (what `parseDailyReportActionResult` returns: `{ result }` or `{ mismatch }`).
|
|
1796
|
+
- Types reached by the closure: `DailyReportAttachmentState` (the verification state of an attachment's object), `DailyReportBusinessDateInput` (what `normalizeBusinessDateKey` takes), `DailyReportUIComment` (an element of what `mergeComments` returns), `DailyReportIdsStreamCursor` (the ids stream's resume cursor),
|
|
1797
|
+
`DailyReportSseTerminalEvent` (one name of `DAILY_REPORT_SSE_TERMINAL_EVENTS`) and `DailyReportLogger` (the console-compatible logger the server's `logger` takes).
|
|
1798
|
+
- The text search's wire: `DAILY_REPORT_SEARCH_COMBINES` with its type `DailyReportSearchCombine` (the `combine` values, `intersection` and `union`) and `DailyReportSearchHit` (one hit as the server holds it before the column-wise answer).
|
|
1635
1799
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
1636
|
-
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider
|
|
1637
|
-
|
|
1800
|
+
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider: [Client wiring](#client-wiring)),
|
|
1801
|
+
and their props types `DailyReportPageProps` / `DailyReportResolvedContentProps` / `DailyReportListProps` / `DailyReportDetailListProps` (with `DailyReportDetailListScrollRestore`, the scroll anchor and row heights it keeps across unmounts) / `DailyReportAttachmentIndicatorProps`.
|
|
1802
|
+
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (the provider's actions — functions that keep their identity while the provider is mounted, the ledgers and `user` — and its state: `items`, `version`, `isSseEnabled`, `sseStatus`, and for the search `searchInvalidation`, `isReportRemoved`, `removedVersion`; `updateReport` takes both `title` and `content`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` /
|
|
1638
1803
|
`useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `loadedCount`, `streamAnchor`, and `itemsRevision` / `itemsChanges`, the revision of the session's list and its latest net changes; the list itself is not part of the state).
|
|
1804
|
+
Their types: the action context's `DailyReportActionContextType` (what `useDailyReportActionContext` returns) / `DailyReportActions` (the functions, the ledgers and the viewing user) / `DailyReportActionFunctions` / `DailyReportActionLedgers` / `DailyReportActionState` / `DailyReportActionBusinessDate` (the business date an action sends), `DailyReportDetailResource` (the detail source the screen provides while it shows a search answer),
|
|
1805
|
+
`UseDailyReportSseConnectionOptions`, the ids stream's `DailyReportIdsStreamState` / `DailyReportIdsStreamPhase` / `DailyReportIdsStreamClientOptions` / `DailyReportIdsStreamItemsChange` (one net change) / `DailyReportIdsStreamItemReplacement` (one value change inside it), and `DailyReportSearchFailure` (why a search failed, as the search box tells it).
|
|
1639
1806
|
- The ids stream: `DailyReportIdsStreamClient` (`resync()`, `has(reportHubId)`, `readItems()` — the list as of the last publish, built when it is read; its `now` option is the monotonic clock of the publish window) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
|
|
1640
1807
|
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
|
|
1641
|
-
- Layout: `
|
|
1642
|
-
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `
|
|
1808
|
+
- Layout: `DAILY_REPORT_LATTICE_BLOCK_QUANTUM_CLASS_NAME`, the hook that carries the block quantum q (`--aqdr-lattice-block`, 4 px or 8 px by ratio) on the document root, which a host's bars round up to, and `DAILY_REPORT_LAYOUT_LATTICE_PX`, the 4 px unit u that q is built from (see **G-symmetric frame**); they are the only public layout values. 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.
|
|
1809
|
+
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `DailyReportClientConfig` (the resolved configuration `useDailyReportConfig` returns) / `DailyReportHeaderProps` (what `renderHeader` receives) / `DailyReportSearchSettingInput` / `DailyReportSearchSetting` (`config.search` as passed and as resolved) / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`,
|
|
1810
|
+
and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` / `DailyReportListNavigationHandle` (the read-only getters of the views' `VirtualScroll` handle that `DailyReportViewTestReadHandle` builds on) (see [Test hooks](#test-hooks)).
|
|
1643
1811
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1644
|
-
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1812
|
+
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`; and for the search `searchLimits`, `null` without the `search` block, `searchDailyReports`, `getDailyReportDetailsByIdsByExternalId`, `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1813
|
+
Their types: `DailyReportService` (what `createDailyReportService` returns and `DailyReportHandlersConfig.service` takes), `DailyReportEpochStore`, `DailyReportVisibleSourceTypeSet` (a viewer scope's normalized set), `DailyReportResourceRouteArgs` (the arguments of a resource route's loader or action that the handlers read), `DailyReportSseReaderConfig`, and `DailyReportSqlResultCacheQueryOptions`.
|
|
1645
1814
|
- Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
1646
1815
|
- SSE and caching: `DailyReportSseReader` / `readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`, the frozen `StreamEntry` the shared reader hands every subscriber and the entry's parsed JSON value; for a host whose own reader keeps the same contract) /
|
|
1647
|
-
`DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` (the entries of one catch-up page, derived from the read byte budget) / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` /
|
|
1816
|
+
`DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` (the entries of one catch-up page, derived from the read byte budget) / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` /
|
|
1817
|
+
`jsonResponseWithETag(request, cookie, payload, status = 200)` (a pure builder of the JSON response with the shared security headers: it serializes the payload once and takes the ETag from that text, answers 304 without a body to a `GET` of a 200 whose `If-None-Match` lists the ETag by the weak comparison — a `W/` tag matches, as a compressing proxy weakens the strong tag it forwards, and `*` never does —, and writes no log line) / `generateETag` (the strong ETag of a JSON value).
|
|
1648
1818
|
- Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the only id the service's write methods take; **The echo id** in [Server wiring](#server-wiring-di)).
|
|
1649
1819
|
- Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` (an entry as the reader hands it to every subscriber: its id and its message read once, or `null`) / `ExternalReportFields`,
|
|
1650
|
-
|
|
1820
|
+
the ports' own types `DailyReportResolveUserId` / `DailyReportEncodeUserId` / `DailyReportResolveVisibleSourceTypes` (with `DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityResolveContext` and `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` / `DailyReportSseEntryMessage` (an entry's message read once) / `DailyReportExternalSource` (an `externalSources` adapter),
|
|
1821
|
+
the injected tables `DailyReportHubTable` / `DailyReportInternalTable` / `DailyReportCommentTable` / `DailyReportLabelTable` / `DailyReportHubLabelTable` / `DailyReportUserStatusTable` / `DailyReportUserTable` / `DailyReportAttachmentTable` (the members of `DailyReportTables`), the service's rows `DailyReportHubRow` / `DailyReportInternalRow` / `DailyReportUserStatusRow` / `DailyReportIdsSource`,
|
|
1822
|
+
and the structural drizzle surface `DailyReportDb` is built from: `DailyReportDbRows` / `DailyReportDbSelectChain` / `DailyReportDbInsertChain` / `DailyReportDbUpdateChain` / `DailyReportDbDeleteChain`,
|
|
1823
|
+
for attachments `DailyReportAttachmentsConfig` / `DailyReportAttachmentThumbnailsConfig` (the `attachments` block and its `thumbnails`) / `DailyReportAttachmentDelivery` / `DailyReportAttachmentThumbnailDelivery` (the delivery's resolved thumbnails) / `DailyReportReadAttachment` / `DailyReportAttachmentBytes` (a successful read) / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` /
|
|
1824
|
+
`DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailError` / `DailyReportAttachmentThumbnailFailure` (the renderer's failure and its kinds) / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike` / `DailyReportSharpPipelineLike` / `DailyReportSharpHeaderLike` (the parts of sharp the renderer reads),
|
|
1825
|
+
and for the text search `DailyReportSearchConfig` (the `search` block) / `DailyReportSearchSourceTextReader` / `DailyReportSearchTables` with its tables `DailyReportSearchDocumentTable` / `DailyReportSearchCommentTable` / `DailyReportSearchSqlExecutor` (drizzle's `execute`) / `DailyReportSearchReconcileOptions` / `DailyReportSearchReconcileProgress` / `DailyReportSearchReconcileSummary` / `DailyReportSearchPlan` (a compiled query: `DailyReportSearchSet` keywords of `DailyReportSearchWord` words).
|
|
1651
1826
|
|
|
1652
1827
|
MIT
|