@aiquants/daily-report 0.35.0 → 0.37.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 +141 -0
- package/README.md +177 -66
- package/dist/client.d.mts +26 -15
- package/dist/client.d.ts +26 -15
- package/dist/client.js +6 -6
- package/dist/client.mjs +6 -6
- package/dist/{comment-adapter-DqBqtqfA.d.ts → comment-adapter-DOUwSlKf.d.ts} +1 -0
- package/dist/{comment-adapter-ChA4KOE1.d.mts → comment-adapter-_h3W1duG.d.mts} +1 -0
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/search-marks-wire-CZRU4q3h.d.mts +35 -0
- package/dist/search-marks-wire-CZRU4q3h.d.ts +35 -0
- package/dist/server.d.mts +25 -12
- package/dist/server.d.ts +25 -12
- package/dist/server.js +10 -10
- package/dist/server.mjs +10 -10
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +1 -1
- package/dist/logger-BaxnzF-0.d.mts +0 -17
- package/dist/logger-BaxnzF-0.d.ts +0 -17
package/README.md
CHANGED
|
@@ -46,11 +46,15 @@ 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
|
|
50
|
-
|
|
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 or directory — a path, or the `file:` URL `import.meta.url` —, never the working directory,
|
|
50
|
+
and returns, as a physical path (symbolic links resolved, like `cd -P`), the nearest directory at or above it and below the filesystem root that holds `pnpm-workspace.yaml` as a file or a `.git` (a file inside a git worktree), or `PROJECT_ROOT` when that is set to a non-empty value that holds one of the two; `importRepositoryModule(startPath, path)` loads a module by its path from that root.
|
|
51
|
+
A blank start path, a `file:` URL that names no local path, a start whose directory cannot be reached, a start without such a directory above it and a `PROJECT_ROOT` without an anchor fail at once with an error that names the start or the value. No script or spec walks up a fixed number of levels (`../../../.config`), which would silently name another directory once the file moves.
|
|
52
|
+
The repository's one table of the resolver's contract cases (`.config/scripts/lib/repository-root-contract.mjs`: symbolic links at the start and in `PROJECT_ROOT`, an empty override, a directory start, `.git` as a file, a directory named `pnpm-workspace.yaml`, no anchor up to the filesystem root, `file:` URLs, a blank start) runs against this copy in `src/repository-root.spec.ts`, as it runs against `@aiquants/virtualscroll`'s copy and the host app's test copy in their own specs, so the copies answer every layout alike; when the contract changes, the table and every copy change together.
|
|
51
53
|
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.
|
|
54
|
+
The build writes no source map, as the published files have none; a local build for a profiling run writes `dist/*.map` with `DAILY_REPORT_PROFILE_SOURCE_MAPS=1` (relative sources, no embedded source text), so a host's profiler can name the package's frames instead of minified positions, and any other value of the variable stops the build (`src/tsup-profile-source-maps.spec.ts`). A publish builds through `pnpm run verify` and ships `dist`, so it runs with the variable unset (with it, the tarball would carry the maps).
|
|
52
55
|
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.
|
|
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
|
|
56
|
+
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, an entry without a file
|
|
57
|
+
and an entry whose floor sits below the measured percentage rounded down on either metric (`<file>: <metric> floor <f> below measured <m>; run pnpm run coverage:ratchet`, so coverage that a spec adds is locked in at once and the floors rise without anyone remembering to write them), 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
58
|
Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
|
|
55
59
|
A new file enters the ratchet only at 100 % of both metrics: the script records no entry below that, so a file that cannot reach 100 % needs a committed entry with a non-empty `exemption` (the reason, reviewed with the floors), which the script keeps while it raises the floors.
|
|
56
60
|
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.
|
|
@@ -72,7 +76,7 @@ Every TypeScript or JavaScript code block of this README names its source in its
|
|
|
72
76
|
| 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
77
|
| 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) |
|
|
74
78
|
| `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 |
|
|
75
|
-
| `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short
|
|
79
|
+
| `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short labels that can wrap (`WRAPPING_LABEL_CLASS_NAME`: the side pane's selection prompt, an attachment tile's unavailable label, the search box's status line) 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 |
|
|
76
80
|
| `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 |
|
|
77
81
|
| `overflow-wrap: anywhere` | Chrome 80, Firefox 65, Safari 15.4 | The same labels break inside a phrase or a word that is wider than their box instead of crossing their frame, also as flex items, whose automatic minimum width would otherwise hold them at their longest phrase | An engine without it drops the declaration: a phrase wider than the label's box crosses its frame |
|
|
78
82
|
| `<dialog>` with `showModal()` | Chrome 37, Firefox 98, Safari 15.4 | The mobile detail overlay (a modal dialog in the top layer) | Opening the overlay throws a `TypeError` from a layout effect, which React hands to the nearest error boundary |
|
|
@@ -184,19 +188,36 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
184
188
|
but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
|
|
185
189
|
React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
|
|
186
190
|
- **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
|
|
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
|
|
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.
|
|
191
|
+
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` and the routes' security headers, **Security headers** below) thrown the way a loader answers early.
|
|
192
|
+
A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`, and `search`, `reports` and `search-marks` with the `search` block) and the action keep serving single fetch, which React Router's own fetchers use.
|
|
189
193
|
- **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`.
|
|
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 `
|
|
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), `
|
|
192
|
-
`
|
|
194
|
+
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`, `reports` and `search-marks` 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.
|
|
195
|
+
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, in at most `cacheMaxBuckets` buckets), `miss-gate` (a read that misses the cache takes one of the viewer's cache-miss slots before its query starts, **Cache misses per viewer** below),
|
|
196
|
+
`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)).
|
|
197
|
+
`cache` alone shares only identical reads: every distinct key — any business date from 0001 to 9999, any id — misses it, so the distinct reads are bounded by `miss-gate`.
|
|
198
|
+
`business-date` and `report` declare `refresh-bucket`, `cache` and `miss-gate`, the ids stream `refresh-bucket`, `stream-slots`, `cache` and `miss-gate`, and `search`, `reports` and `search-marks` `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.
|
|
193
199
|
- **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.
|
|
194
200
|
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.
|
|
195
201
|
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
202
|
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.
|
|
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).
|
|
203
|
+
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 dataset query).
|
|
198
204
|
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.
|
|
199
205
|
The slots are per worker process, so the effective bound across workers is the worker count times 32.
|
|
206
|
+
- **The SQL result cache** (`SqlResultCache`; `createDailyReportServer` builds one per process, which the service and the SSE reader share): `cacheDefaultTtlMs` (an integer of at least 1, 60,000 ms by default) is the TTL of a read that names none and the interval of the expired-bucket sweep, and `cacheMaxBuckets` (an integer of at least 1, 4,096 by default) bounds the buckets by construction.
|
|
207
|
+
A write puts its bucket in as the newest-written and evicts the oldest-written while the cache holds more than the maximum, and the expired buckets are swept at most once per default TTL on the monotonic clock, so a write costs the same whatever the number of buckets (100,000 distinct keys keep the size at the maximum).
|
|
208
|
+
4,096 is about 400 views' live keys: a view keeps about 10 (its ids key, its rows' business date and the DetailList's 6 prefetched dates). An evicted key's next read is a miss and passes the miss gate below.
|
|
209
|
+
- **Cache misses per viewer** (`createCacheMissGate` in `src/server/cache-miss-gate.ts`; the endpoints whose row declares `miss-gate`): a read that misses the cache — a forced read included — takes one of its viewer's slots exactly where its fetch starts and gives it back when the fetch settles, either way; a hit and a read that joins the fetch in flight for its key take none.
|
|
210
|
+
One gate per handler factory, keyed by the internal user id, holds `CACHE_MISS_SLOTS_PER_VIEWER` = 8 fetches at once per viewer — what one view of the package's client starts when it paints: the ids stream's full read, the shown rows' business date and the DetailList's `PREFETCH_MAX_DATES` = 6 prefetched dates — and `CACHE_MISS_SLOTS_PER_WORKER` = 64 per worker process. A freed slot goes to the waiting viewer that holds the fewest slots among the viewers under their own limit (that viewer's oldest waiter;
|
|
211
|
+
the oldest one between viewers holding as many), so a viewer without a slot gets the next freed one even while busy viewers keep every slot of the worker and their queued reads ahead of it (the max-min dispatch of `createConcurrencyGate`, which the thumbnail generation queue shares).
|
|
212
|
+
One viewer's 8 stay below a host driver's default pool of 10 connections, while the worker's 64 do not, so across viewers a miss that holds a slot can still wait in the driver's own first-come queue for a connection: the slots bound the fetches, not the connections. In a simulation of the shipped sizes in front of a first-come pool of 10 (50 ms per miss, every busy viewer keeping 32 misses outstanding), a newcomer's median first read took 84 / 183 / 334 ms beside 2 / 4 / 8 busy viewers, against 84 / 184 / 1,293 ms when the oldest waiter of any viewer under its limit took the slot;
|
|
213
|
+
the rest of that wait is the driver's queue.
|
|
214
|
+
A miss without a free slot waits in that queue, at most `CACHE_MISS_QUEUE_PER_VIEWER` = 256 per viewer (everything two browsers of the viewer can have in flight on the edge's 128 HTTP/2 streams) and `CACHE_MISS_QUEUE_PER_WORKER` = 1,024 per worker, for at most `CACHE_MISS_WAIT_MS` = 60 s, so the package's own client is queued, never refused, unless the database does not answer for a minute.
|
|
215
|
+
A read past the queue or the wait starts no query and is answered 503 `{"error":{"message":"Too many reads"}}` with `Retry-After: 5`; on the ids stream, a resumed `GET`, which waits for its full read before it answers, gets that 503, and a fresh one, which has already answered 200, ends its body with the stream's error line, from which the client resumes on its own backoff.
|
|
216
|
+
A request whose signal is already aborted takes no slot and starts no fetch, an abort while it waits leaves the queue, and a viewer without an internal user is charged nothing: its reads run no dataset query, only the lookup of its internal id, which runs before the admission and again inside the read and is not cached while it finds no internal user.
|
|
217
|
+
The refusals are recorded per viewer in windows of 60 s at `warn` on the `[DailyReportReads]` channel: the window's first as `503 <business-date|ids-stream|report> viewer=<internal id> reason=cache_miss_admission`, the rest counted into one `cache_miss_admission_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<internal id> endpoints=business-date:<n>,ids-stream:<n>,report:<n>` line written by the window's own timer at its end; the refusal of an aborted request writes nothing.
|
|
218
|
+
The slots and the queues are per worker process, so the effective bounds across workers are the worker count times these.
|
|
219
|
+
- **Security headers**: every response of the API, action and SSE routes, whatever its status — the JSON answers and refusals, the action's answers, the request isolation's refusals, the SSE stream, its terminal frames and its errors, the ids NDJSON — carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and `Content-Security-Policy: default-src 'none'` (`RESOURCE_RESPONSE_SECURITY_HEADERS` in `src/server/response.ts`, set last by every builder, so no header of its own overrides them).
|
|
220
|
+
Every answer can echo text a user wrote (a report, a comment, a search query), so a failure is no exception: none of these responses is sniffed into another type or drawn in a frame of another page. `src/server/resource-response-headers.spec.ts` walks every route and every status it answers. The attachment route carries its own set (**Same-origin loads only** in [Attachments](#attachments)).
|
|
200
221
|
|
|
201
222
|
**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:
|
|
202
223
|
|
|
@@ -207,7 +228,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
207
228
|
`1e3`, `12abc`, ` 7 `, `0x10`, `1.9`, `-3`, `0` and `9007199254740993` (2^53 + 1, which would round to its neighbour and could name another row) are `Invalid reportHubId` / `Invalid commentId`.
|
|
208
229
|
The ids stream's resume cursor follows the same rule, and its business date must be the canonical `YYYY-MM-DD` the server wrote (a malformed cursor is 400 `Invalid cursor`); an attachment token that decodes to anything but a positive safe integer is 400 `Invalid attachment token`.
|
|
209
230
|
- **`forceRefresh`** (`parseForceRefreshParam`; the `business-date`, `report` and `ids-stream` endpoints): absent reads as `false`, and only `true` and `false` are accepted. Anything else — `yes`, `TRUE`, `1`, the empty value — is `{"error":{"message":"Invalid forceRefresh"}}`; the ids stream answers it before its `HEAD` short-circuit.
|
|
210
|
-
A forced read (`true`) bypasses the SQL result cache, so two bounds keep one viewer from running uncached queries without limit:
|
|
231
|
+
A forced read (`true`) bypasses the SQL result cache, so besides the cache-miss slot its fetch takes like any other (**Cache misses per viewer** above), two bounds keep one viewer from running uncached queries without limit:
|
|
211
232
|
- **Coalesced per key** (`SqlResultCache.getOrFetch`): at most one forced fetch of a key runs and one waits. A forced call that arrives while one runs joins the waiting fetch, which starts when the running one settles (either way), so any number of simultaneous forced reads of one key cost at most two fetches, and every forced caller receives a fetch that started after it arrived.
|
|
212
233
|
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.
|
|
213
234
|
- **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.
|
|
@@ -259,7 +280,8 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
259
280
|
- `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.
|
|
260
281
|
- `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).
|
|
261
282
|
- `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).
|
|
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
|
|
283
|
+
- `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(hostLogger, defaultLevel, channel)` in `src/shared/logger.ts` builds every channel's logger;
|
|
284
|
+
a channel is one name of the closed list `DAILY_REPORT_LOG_CHANNELS`, and `[DailyReport<channel>]` is spelled in that module alone, so a misspelt channel is a type error, never a new channel that no list knows):
|
|
263
285
|
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
286
|
|
|
265
287
|
| Channel | Console from | Lines |
|
|
@@ -272,21 +294,25 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
272
294
|
| `[DailyReportAction]` | `warn` | the record of the action's 413 and 429 (**Request values** in [Server wiring](#server-wiring-di)) |
|
|
273
295
|
| `[DailyReportRefresh]` | `warn` | the refresh bucket's 429 (**`forceRefresh`** there) |
|
|
274
296
|
| `[DailyReportStreams]` | `warn` | the 503 of **Streams per viewer** there |
|
|
275
|
-
| `[
|
|
297
|
+
| `[DailyReportReads]` | `warn` | the 503 of **Cache misses per viewer** there |
|
|
298
|
+
| `[DailyReportSearch]` | `warn` | the 429 of the `search`, `reports` and `search-marks` admissions (**Requests** in [Text search](#text-search)) |
|
|
276
299
|
| `[DailyReportServer]` | `warn` | `createDailyReportServer` without a `redis` provider |
|
|
277
300
|
| `[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
301
|
| `[DailyReportSseReader]` | `info` | the shared SSE reader's Redis errors |
|
|
279
302
|
| `[DailyReportExternalSource]` | `info` | `transformJsonArray`'s malformed JSON columns of an external source |
|
|
280
303
|
|
|
304
|
+
The client has no host logger port: its four channels of the same list write to the console from `info` — `[DailyReportActionProvider]` (the action provider: a failed action, the queue of SSE messages about uncached reports, a cache reload), `[DailyReportDetailLoader]` (the detail loading: a stale update of a report's cache, a failed prefetch of a business date), `[DailyReportIdsStream]` (the ids stream client: a revalidation that gave up, a skipped malformed line or line of an unknown kind, a malformed stream anchor) and `[DailyReportSseConnection]` (the SSE connection hook).
|
|
305
|
+
Every line, the server's and the client's, carries exactly one channel prefix: no message spells a `[DailyReport…]` prefix of its own (`src/shared/logger.call-sites.spec.ts` rejects that form in any string literal outside the logger module). The ids stream client's `warn` option (`DailyReportIdsStreamClientOptions.warn`) is a test seam: a sink injected there takes the channel's place and receives the message and its arguments without a prefix.
|
|
306
|
+
|
|
281
307
|
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.
|
|
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`
|
|
283
|
-
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
|
|
308
|
+
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). The JSON builder itself (`jsonResponseWithETag` in `src/server/response.ts`) 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.
|
|
309
|
+
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `cacheDefaultTtlMs` (60s) / `cacheMaxBuckets` (4,096; **The SQL result cache** in [Server wiring](#server-wiring-di)) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
|
|
284
310
|
|
|
285
311
|
**Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
|
|
286
312
|
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`.
|
|
287
313
|
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`.
|
|
288
314
|
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
|
|
315
|
+
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, max?)`: a safe integer (`Number.isSafeInteger`, so 2^53 and above are refused too) at or above the minimum and, when a maximum is given, at or below it, else `[daily-report] <path> must be an integer >= <min>; got <value>` (`must be an integer from <min> to <max>` with a maximum).
|
|
290
316
|
`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.
|
|
291
317
|
|
|
292
318
|
### Request isolation
|
|
@@ -299,7 +325,7 @@ The policy's `frameworkDataRefusal` receives the loader's arguments and returns
|
|
|
299
325
|
| Route (policy) | Framework data requests |
|
|
300
326
|
| --- | --- |
|
|
301
327
|
| `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 |
|
|
302
|
-
| `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store
|
|
328
|
+
| `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store` and the routes' security headers, **Security headers** in [Server wiring](#server-wiring-di); `dataRequestRefusal`): the body never ends, and `EventSource` requests the route itself |
|
|
303
329
|
| `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) |
|
|
304
330
|
| `api.action` (`ACTION_ISOLATION_POLICY`) | Served (React Router's own fetchers write through single fetch) |
|
|
305
331
|
|
|
@@ -316,7 +342,7 @@ The two API policies live in `src/server/handlers.ts`, the API loader's refusal
|
|
|
316
342
|
- **Requests without Fetch Metadata**: a browser sends no `Sec-Fetch-*` to an origin that is not potentially trustworthy (plain HTTP other than `localhost`), so such a request comes either from a client that is not a browser or from a page of such an origin. A browser always sends `Origin` with an unsafe method (`null` for an opaque origin), so a sibling page's form `POST` to a plain-HTTP host is refused, while a client that is not a browser (no `Origin`) and the host's own pages are served (the rule of Go's `net/http` `CrossOriginProtection`).
|
|
317
343
|
Safe methods change no state, so a no-cors `GET`, which carries no `Origin`, is served.
|
|
318
344
|
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.
|
|
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`
|
|
345
|
+
- **The refusal**: on the API and SSE routes, JSON `{"error":{"message":"Cross-site request rejected"}}` with `Cache-Control: no-store` and the routes' security headers (`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy: default-src 'none'`; `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.
|
|
320
346
|
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.
|
|
321
347
|
- **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.
|
|
322
348
|
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.
|
|
@@ -762,7 +788,8 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
762
788
|
A generation's outcome carries this identity only when what it read matched the row's size (**verified**; see **Read port** above); an **unverified** outcome gets no validator and is never cached.
|
|
763
789
|
- **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
|
|
764
790
|
An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
|
|
765
|
-
- **Generation**: a process-local gate with `attachments.thumbnails.concurrency` slots and a
|
|
791
|
+
- **Generation**: a process-local gate with `attachments.thumbnails.concurrency` slots and a wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`) that hands a freed slot to the waiting user holding the fewest generations (arrival order within a user and between users holding as many), so one user's burst of tiles does not keep another user's first preview waiting behind it. A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation;
|
|
792
|
+
the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
|
|
766
793
|
**One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
|
|
767
794
|
The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
|
|
768
795
|
The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue, and a port that honours the signal settles early and hands the slot to the next queued generation. A read that settles after the abort is discarded before its result is looked at (see **Read port** above) and nothing is rendered; a render that is running at the abort is kept as above.
|
|
@@ -830,7 +857,7 @@ A viewer therefore writes at most two lines per bucket and window, whatever its
|
|
|
830
857
|
|
|
831
858
|
### Text search
|
|
832
859
|
|
|
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 `
|
|
860
|
+
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`, `reports` and `search-marks` endpoints answer 404 `Unknown endpoint`).
|
|
834
861
|
|
|
835
862
|
```ts examples/search-wiring.ts
|
|
836
863
|
/**
|
|
@@ -840,12 +867,12 @@ The screen can search the reports by text. The server side is one optional block
|
|
|
840
867
|
* The search is one optional block, `search`: the two index tables (here from `defineDailyReportSearchSchema`; the host's own models of
|
|
841
868
|
* the same shape also work), the readers of the searchable text the detail view does not show (per external source type), and the
|
|
842
869
|
* 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 `
|
|
844
|
-
* before passing the block in production: while the tables are missing, every save fails.
|
|
870
|
+
* and the `search`, `reports` and `search-marks` endpoints answer; without it they answer 404. Create the tables and run the initial
|
|
871
|
+
* reconciliation before passing the block in production: while the tables are missing, every save fails.
|
|
845
872
|
* 検索は任意の 1 つのブロック `search` にまとまる。索引の 2 表 (ここでは `defineDailyReportSearchSchema` から。同じ形のホストのモデルでもよい)、
|
|
846
873
|
* 詳細画面に出ない検索できる文字を読む口 (外部ソースの区分ごと)、要求の上限。ブロックを渡すと、保存の 6 つの経路がそのトランザクションの中で索引を書き直し
|
|
847
|
-
* (書けなければ保存も失敗する)、`search
|
|
848
|
-
*
|
|
874
|
+
* (書けなければ保存も失敗する)、`search`・`reports`・`search-marks` のエンドポイントが答える。渡さなければどれも 404。本番では、表を作って最初の
|
|
875
|
+
* 突き合わせを流してからブロックを渡す (表が無い間は、どの保存も失敗する)。
|
|
849
876
|
*/
|
|
850
877
|
import {
|
|
851
878
|
createDailyReportServer,
|
|
@@ -877,7 +904,7 @@ export type SearchWiring = {
|
|
|
877
904
|
* 文字の検索を有効にした日報サーバーを作る処理。
|
|
878
905
|
*
|
|
879
906
|
* @param wiring Base configuration, the hidden-text reader and the limits. 基本設定・隠れた文字を読む口・上限。
|
|
880
|
-
* @returns The server (`DailyReportServer`); its API route answers `search` and `
|
|
907
|
+
* @returns The server (`DailyReportServer`); its API route answers `search`, `reports` and `search-marks`. サーバー (`DailyReportServer`。API の経路が `search`・`reports`・`search-marks` に答える)。
|
|
881
908
|
* @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
909
|
*/
|
|
883
910
|
export const createDailyReportServerWithSearch = ({ base, readNiDetailTexts, limits }: SearchWiring): DailyReportServer =>
|
|
@@ -910,7 +937,15 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
910
937
|
|
|
911
938
|
- **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
939
|
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
|
-
|
|
940
|
+
A word typed with a Latin letter also searches its kana readings (romaji, read by fuzzy-search's `queryNeedles` with `convertRomaji` and normalized like a word): Hepburn and IME spellings alike (`shinbun` and `sinbun` both search シンブン, `chokin` and `tyokin` チョキン).
|
|
941
|
+
Both the word as typed and its normalization must hold a Latin letter: full-width letters count (`DENPUN` searches デンプン), while Roman numerals and `№`, which the normalization turns into Latin letters, are no letters as typed (`Ⅱです`, `№12` and `第Ⅲ期` have no reading), and the two Latin modifier letters the normalization turns into other marks (U+10781 into ː, U+10782 into ˑ) leave no Latin letter after it (`納期ヲ確認` followed by one has no reading).
|
|
942
|
+
A reading is kept only when the whole word is read (`report` adds nothing), it differs from the word and it has at least 3 letter clusters (`SEARCH_READING_MIN_CLUSTERS`: grapheme clusters, not counting digits, symbols and a leading dash, and counting a run of prolonged sound marks once, so `ha100` → ハ100, `no12` → ノ12, `-site` → ーシテ and `ha--` → ハーー are not kept, while `site-` → シテー is), at most 2 per word in fuzzy-search's order (`DAILY_REPORT_SEARCH_READINGS_PER_WORD`: `numadu` searches ヌマズ and ヌマヅ, `konnyaku` コンニャク and コンヤク), and the Latin spelling itself stays a candidate.
|
|
943
|
+
A reading of 1 or 2 letter clusters is not added: the index folds hiragana into katakana, so such a reading matches common kana phrasing (on 228,794 reports, `site` → シテ matched the して of 99,636 of them, 43.55 %, in 1,244 ms; `kome` → コメ the コメント of 4,131; `n` → ン 163,571, in 1,302 ms), and digits, a dash or a run of prolonged sound marks around it do not make it rarer (ハ100 matches 単価は100円, ーシテ matches コピーして, ハーー matches それは――, which the index holds as ソレハーー). Two-character words such as `memo`, `imo` and `kome` are therefore typed in kana.
|
|
944
|
+
A 3-cluster reading of common phrasing can still match many reports (`shitei` → シテイ, the してい of している, matched 60,168, 26.3 %), while ordinary readings match 0.01–1.24 %.
|
|
945
|
+
A reading never reaches kanji (`nouki` searches ノウキ, not 納期; `tanaka` タナカ, not 田中) and folds no long vowel (`kouhi-` searches コウヒー, not コーヒー; `ko-hi-` does). fuzzy-search's homophone spellings work one way: `numazu` searches ヌマズ and not ぬまづ, `otagei` オタゲイ and not をたげい, so type `numadu`, `wotagei` or the kana.
|
|
946
|
+
A word in kana never searches romaji text (デンプン does not find `denpun`), and a word without a Latin letter, as typed or after normalization, compiles to the plan it had before readings existed.
|
|
947
|
+
Words and keywords are de-duplicated by their whole set of alternatives, readings included, so two words that normalize alike but differ in their readings (`Ⅱです` reads nothing, `iiです` reads イイデス) are both kept: the plan does not depend on the order the words are typed in, and adding a word to an OR query never takes an alternative away.
|
|
948
|
+
- **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 and the attachment file names), the label names (searched although the views do not draw them), 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
949
|
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
950
|
- **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
951
|
- **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`).
|
|
@@ -918,14 +953,64 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
918
953
|
- **Reconciliation.** Writes outside the package (a sync, an import) leave the index stale until `service.reconcileSearchIndex({ batchSize, hubIds?, signal?, onProgress? })` runs.
|
|
919
954
|
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
955
|
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`.
|
|
956
|
+
- **Requests.** `GET {apiBasePath}/search?q=<text>&combine=intersection|union` answers column-wise, `{ ids, businessDates, sourceTypes, words }` 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`.
|
|
957
|
+
`words` is the plan's words, keyword by keyword: each word's alternatives `{ text, tier }` (`DailyReportSearchAlternative`), tier 0 the typed word's forms and tier 1 its romaji readings. It depends on `q` and `combine` alone, never on the viewer, and the client reads it strictly with the rest of the answer (at most 16 keywords, 16 words a keyword, 32 words, 6 alternatives a word of 1 to 100 units, the first alternative of tier 0, tiers that never fall and no text twice in a word): a malformed answer is refused whole. It decides which words the highlights mark.
|
|
922
958
|
`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) ×
|
|
959
|
+
`reports` has an admission of its own from the same factory, never the search's: `REPORTS_CONCURRENCY_PER_SEARCH_CONCURRENCY` (2) × the larger of `concurrency` and `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2, the batch reads the client keeps in flight) reads at once per process, of which one viewer holds at most half, so a viewer always passes the client's two concurrent reads (a `concurrency` of 1 admits 4 per process),
|
|
960
|
+
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
961
|
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
|
-
-
|
|
962
|
+
`GET {apiBasePath}/search-marks?q=<text>&combine=intersection|union&ids=<id,id,...>` (the shown answer's query and at most 100 distinct canonical ids, the rows a view draws) answers `{ wordsDigest, reports }` (`DailyReportSearchMarksAnswer`): for each requested report the viewer may read, once, its id, the mask of the keywords it matches (`matchedSets`: bit i for keyword i, for an OR of two or more keywords;
|
|
963
|
+
`null` otherwise) and the marks of every non-empty field value the views draw (`fields`, by key: `subject`, `content`, `author`, `employeeName`, `updatedBy`, `category`, `creationCategory`, `customerName`, `interviewer:<i>:name|affiliation`, `attachment:<i>` and `comment:<id>:author|content`, the comment id as `mergeComments` gives it).
|
|
964
|
+
A field's marks are the fingerprint of the raw value they were computed on (FNV-1a 32 over its UTF-16 units) and flat `[start, end, tier]` triples in UTF-16 offsets of that value; `wordsDigest` is the digest of the answer's `words`.
|
|
965
|
+
A field's triples are its maximal runs of one tier, overlapping and adjacent occurrences merged into one, so they never overlap and a field carries at most one triple per UTF-16 unit of its value: an answer grows with the length of the values drawn, like the details answer of the same reports, and has no cap (a one-letter word over 100 reports of 21,200 units each answers 1.6–2.9 MB, against 2.2 MB of their details).
|
|
966
|
+
The server merges a field's occurrences per tier in time proportional to their number plus the value's length, whatever length the occurrences cover (`markSearchText` in `src/server/search/search-marks.ts`).
|
|
967
|
+
The server finds the ranges with the index's own processing — the same invisible-character stripping, line splitting and 4,000-unit pieces, mapped back to the raw value through fuzzy-search's position-mapped forms, and on a line longer than 4,000 units, whose pieces may split a grapheme cluster, widened to the raw value's cluster boundaries (`Intl.Segmenter`), so a mark never cuts a character such as 👍🏽 in two — and the keyword mask with the search's own scope conditions in one SQL, so a mark never lands where the search does not match,
|
|
968
|
+
and the browser normalizes nothing (`@aiquants/fuzzy-search` stays out of the client bundle).
|
|
969
|
+
Its admission is a third instance from the same factory, for the same reason as `reports`: `SEARCH_MARKS_CONCURRENCY_PER_SEARCH_CONCURRENCY` (2) × the larger of `concurrency` and `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` reads at once per process (one viewer holds half, never fewer than the client's requests in flight) and `SEARCH_MARKS_RATE_PER_SEARCH_RATE` (10) × `rateLimitPerMinute` per viewer and minute, in a bucket of its own; its refusal lines name `route=search-marks`.
|
|
970
|
+
Unlike the search and `reports`, which refuse at once, a search-marks request that finds the gate full or its viewer at its share waits for a slot (`SEARCH_ADMISSION_WAITS`): `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` waiters per viewer and as many as the admission's concurrency in all, for at most `SEARCH_MARKS_WAIT_MS` = 2,000 ms (about twice the slowest search-marks request measured under load, 196–927 ms), and a freed slot goes to the viewer holding the fewest.
|
|
971
|
+
A new answer's first batches therefore wait for the abandoned batches of the previous answer, which hold their slots until their reads end, instead of spending their one retry; a request past the queue or the wait bound gets 429 with `Retry-After: 1`.
|
|
972
|
+
Its order is the search's and the details': 404 without the `search` block, 400 for a missing `q` or a bad `combine`, 400 with the plan's `reason`, 400 for the ids, then a viewer without an internal user answered `{ wordsDigest, reports: [] }` with no admission and no dataset query, then the admission with the request's signal, the marks (one report at a time, yielding to the event loop between reports), the slot's release whatever the outcome, and the JSON. It is not cached on the server either.
|
|
973
|
+
The service takes the request's signal too (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })`) and checks it before each read and before each report's marks, stopping there once the client has left; drizzle cannot cancel a running query, so the slot is held until the read in progress ends. A request whose client left — while it waited, at one of the service's checks or after the service answered — is answered 503 without a body, with `Retry-After` and `Cache-Control: no-store`, and writes no error line;
|
|
974
|
+
the refusal of an aborted request is never recorded as busy, on any of the three search routes.
|
|
975
|
+
Every answer the route builds — the marks, its 400, 404 and 429, and that 503 — is JSON without an ETag and with `Cache-Control: no-store` (`noStoreJsonResponse` in `src/server/response.ts`, serialized once, with the shared security headers): the batches are cut from the rows on screen, so a URL rarely repeats, and the package's client sends no `If-None-Match`; an `If-None-Match` is not read.
|
|
976
|
+
- **The search box** (`config.search: {}` searches on Enter only; `{ debounceMs }`, an integer from 300 to 5000, also after typing stops) is a second row in the toolbar's band, under the view-mode toolbar: the AND / OR toggle, the field and a status line. Enter, Esc and the debounce wait while an IME composes.
|
|
977
|
+
The row is one 32 px lattice block whose 24 px controls stand at its bottom (`SEARCH_ROW_CLASS_NAME`), so they sit G = 8 under the toolbar's bar and their bottom edge is G above the view's first surface; the band carries the toolbar's start inset G and the active view's end inset, so the field ends where the cards end in both views, and it stacks above the view, so a control's focus outline paints over the view's G strip (**Origin on the lattice** in [Selection and focus appearance](#selection-and-focus-appearance)).
|
|
978
|
+
The toggle names the current mode (`AND` / `OR`) and keeps focus when pressed, so a second Space flips the mode again and the new name is read where focus stays. The clear button sits inside the field's box at its end, in room the field always keeps free, so typing never changes the field's width; it shows while the field holds text or a search is committed and returns focus to the field.
|
|
979
|
+
The status line (searching, the hit count, no hits, a failure) wraps instead of truncating, at most half the row wide, and takes no room while it is empty. The field's and the OR face's boundary and placeholder come from the text-field token (**Text fields** in [Selection and focus appearance](#selection-and-focus-appearance)).
|
|
980
|
+
The field's description (`labels.searchSyntaxHelp`, also its tooltip) gives the syntax, says that a word in Latin letters also finds its kana reading and that a match found through a reading is underlined with dots.
|
|
926
981
|
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
982
|
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
983
|
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`).
|
|
984
|
+
- **Highlights.** While an answer shows, the List cards, the side pane and the DetailList rows mark what the server matched with `<mark data-daily-report-search-mark>`: `word` where a typed word's form matched, the normalization included (`デンプン` marks でんぷん), and `reading` where only a romaji reading matched.
|
|
985
|
+
- **Where**: a List card marks its preview lines and its creator; the side pane its author, subject, customer, body, attachment file names and comments (author and body, each on its own); a DetailList row its subject, customer and body, the header's author and categories, the metadata's author, updater and categories, the interviewers' names and affiliations, its attachment file names and its comments.
|
|
986
|
+
A file name is marked in both of its forms: in the stem and extension spans of the CSS layout, each with its own part of the name, and in the fitted text, where the synthetic `…` and the elided middle carry no mark (**File name** in [Attachment display](#attachment-display)).
|
|
987
|
+
- **Which words**: every word of an AND answer and of a one-keyword answer; for an OR of two or more keywords, only the words of the keywords the report matched (`matchedSets`; a word that several keywords share is marked when any of them matched), so a quoted keyword whose words appear only in part marks nothing.
|
|
988
|
+
- **Never marked**: label names (searched, never drawn: a report matched only through its labels shows no mark, and the indicator below shows `Hidden match`); the texts a host's readers supply for an external source (`externalSourceTexts`), which the views do not display: a report matched only through them shows no mark either, and the indicator shows `Hidden match`; the markup the List and the side pane strip (a word inside a tag), while the DetailList draws its tags as text and marks them;
|
|
989
|
+
the List preview's lines after the third (not drawn) and the elided middle of a fitted file name, whose synthetic `…` stands outside every mark; the edit form and the viewer's own draft open in it; placeholders (`-`), dates, visit times, badges, the visually hidden heading and the unknown-author substitute; and a comment still being posted.
|
|
990
|
+
Text that the browser hides keeps its marks: a List preview line past its one-line clamp, the creator name of a List card and a file-name part of the CSS layout past their ellipsis. In Chromium a clamp or ellipsis that cuts a mark, or falls at its start, is drawn inside the mark, so the mark's fill continues under the `…` while its underline ends at the last character shown (a cut at a mark's start shows a filled `…` alone).
|
|
991
|
+
An `interviewerWithAffiliation` template that does not insert the name and the affiliation verbatim (it rewrites them, uses one twice, or words itself by their values) draws the pill plain. `LIKE` counts an occurrence inside a grapheme cluster, so such a match marks the whole cluster.
|
|
992
|
+
- **Look**: one token (`SEARCH_MARK_CLASS_NAME`): yellow-200 under slate-900 (dark yellow-800 under yellow-50; 15.33:1 and 6.61:1 on any surface, since the mark sets its own text colour), a solid underline for a word and a dotted one for a reading (a cue that does not depend on colour), and the system `Mark` / `MarkText` in forced colours.
|
|
993
|
+
It writes `margin`, `border` and `padding` 0 and inherits the font, so a host's `mark` rule cannot change it and a mark changes no width, no line box and no row height. Accessible names and descriptions flatten the marks, and find-in-page and copying read the same text as without them.
|
|
994
|
+
A reading mark also carries an accessible description (`aria-description`: `labels.searchReadingMarkDescription`, en `Matched by its romaji reading`, ja `ローマ字の読みで一致`), so the weaker match does not rest on the underline's style alone; a word mark carries none, so the common case adds no speech. The attribute's value and the description come from one table keyed by the tier (`SEARCH_MARK_LOOKS` in `src/client/search/search-marked-text.tsx`); a tier outside it stops the render with an error naming the package.
|
|
995
|
+
- **Arrival**: the screen holds one marks store per shown answer (`search-marks-store.ts`, inside the client bundle), only while a view draws an answer: without `config.search`, before the first answer is drawn, after the release and for a view rendered on its own there is none, and the DOM is the plain one.
|
|
996
|
+
A row wraps its subtree in the marks scope (`DailyReportSearchMarksScope`), which subscribes to the report's marks while the row is mounted and hands them to its leaves. The row body reads no marks, so an arrival re-renders only the scope, the marked leaves (`SearchMarkedText`) and the match indicator — never the row's body, header, attachment list or comment section, and never another row; a newly mounted row draws its text plain first and its marks in a deferred render.
|
|
997
|
+
The requests are decided at send time (`createIdBatchScheduler` in `src/client/search/id-batch-scheduler.ts`): a subscribed report waits in one queue, and each free request slot — two in flight at most — takes the newest waiting reports that a row still subscribes to, at most 100 a request, never cached. A report whose rows left while it waited is dropped, so a scroll or a held key sends nothing for the rows it passed, and the window it stops on goes out in the first free slot.
|
|
998
|
+
A request whose rows have all left is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its marks were sent, or whose request was aborted, asks again when it returns.
|
|
999
|
+
The store keeps one record per report (its marks, the send order that brought them, the epoch it was asked in, its 429 retry and its revalidations) and forgets the oldest records of reports no row subscribes to beyond `SEARCH_MARKS_RECORD_BOUND` = 400 (2 × the two requests in flight × 100: the answers still in flight and as many rows just left), so its memory follows the drawn window, not the distance scrolled; a forgotten report asks again when a row subscribes to it.
|
|
1000
|
+
Answers apply in send order: a late answer to an earlier request (of a stale epoch, or sent before an edit and overtaken by the edit's revalidation) never overwrites the marks a later request brought.
|
|
1001
|
+
When a new search replaces the shown answer, a row that stays mounted draws its text plain until the new answer's marks arrive, never the previous answer's marks or matched-keyword count.
|
|
1002
|
+
A field is painted only when its marks were computed on the value drawn (the fingerprint): after a save or an SSE update the old ranges are not painted and the field asks once for fresh marks. An answer whose `wordsDigest` differs from the shown answer's words is dropped; a quiet re-query of the same search makes the marks stale and the mounted rows ask again;
|
|
1003
|
+
401 and 403 call `onSessionExpired`; a 429 is asked again once, after its `Retry-After`, for the reports a row still subscribes to then (one whose rows left meanwhile asks again when a row returns); any other failure leaves those rows plain for as long as the shown answer stays the same.
|
|
1004
|
+
Disposing the store — a new search, or React hiding the screen inside an `<Activity>` — aborts its requests, cancels its retry timers and gives every report they held back, so a store React mounts again asks for them again.
|
|
1005
|
+
- **Match indicator.** There is no score: an answer can tell only how many of its keywords a report matched, and only an OR of two or more keywords varies it. While the marks store exists, the side pane's author line (after the source badge) and the end of a DetailList row's header hold a slot in the contact map's score colours (`SEARCH_INDICATOR_CLASS_NAME`: the page's text face at the package's 12 px floor with tabular figures, on a 16 px line;
|
|
1006
|
+
slate-600 / dark slate-300, 7.56:1 on white and 12.02:1 on slate-900), as wide as the longest text the answer can show (an invisible placeholder), so nothing reflows when the marks arrive.
|
|
1007
|
+
The side pane's author line wraps with a 4 px gap, the label kept whole on one line (`SIDE_PANE_AUTHOR_LINE_CLASS_NAME`), so in a pane too narrow for the author and the slot the slot drops to a 16 px line of its own instead of crushing the author. List cards show none.
|
|
1008
|
+
- `labels.searchMatchedKeywords(k, n)` (en `Match 2/3`, ja `一致 2/3`; `data-daily-report-search-matched-keywords="k/n"`) shows for an OR of two or more keywords once the report's marks arrive, k being the set bits of `matchedSets`.
|
|
1009
|
+
- `labels.searchMatchOutsideView` (en `Hidden match`, ja `表示外で一致`; `data-daily-report-search-outside-view`) shows when the report matched (k ≥ 1, or no mask), the view is not editing it, and the view's leaves paint none of its marks: a report matched only through its labels, its external texts or fields the view does not draw says why it is listed.
|
|
1010
|
+
What the view draws is read from the leaves themselves: inside the row's marks scope every leaf (`SearchMarkedText`) registers, while it is drawn, whether its field's marks are current for the value drawn and how many marks it paints, in the row's tally (`drawn-search-marks-tally.ts`), and the token shows only when every registration is current for the marks the scope settled and their sum is 0.
|
|
1011
|
+
So a field the view does not draw at that moment never counts: the side pane on its Related tab (`tabRelations`; the article is not mounted then), a comment the viewer is deleting, an interviewer pill a host template draws plain and the elided middle of a fitted file name all show the token when the report matched only there. A file-name part that a CSS ellipsis hides counts as drawn, because its marks are painted and the browser decides what it hides.
|
|
1012
|
+
- Both tokens are `aria-hidden` with a `title`, and one visually hidden element holds their sentences (`labels.searchMatchedKeywordsDescription(k, n)`: en `Matches 2 of 3 keywords`; `labels.searchMatchOutsideViewDescription`: en `Matched in fields this view does not show, such as labels or details`).
|
|
1013
|
+
A DetailList row's description (`aria-describedby`) ends with that element while the screen holds a marks store, so moving through the rows with the arrow keys reads the sentences each row shows.
|
|
929
1014
|
|
|
930
1015
|
## Client wiring
|
|
931
1016
|
|
|
@@ -964,7 +1049,8 @@ export default function Route() {
|
|
|
964
1049
|
```
|
|
965
1050
|
|
|
966
1051
|
`config` is **required** and so is its `onSessionExpired` port (omitting it is a type error, TS2741; at render a missing port throws a `TypeError`, `[daily-report] config.onSessionExpired must be a function …`, and a value that is not a function a `RangeError` ending with `; got <value>`; every client configuration error follows the convention under **Configuration errors** in [DI ports](#di-ports)).
|
|
967
|
-
The package never navigates by itself: when the session is gone — the SSE stream ends with `auth-required`, or the ids stream answers 401 / 403 — it calls `onSessionExpired()` exactly once per occurrence and stops. A typical host reloads the route (`navigate(0)`) so its own loader redirects to the login page.
|
|
1052
|
+
The package never navigates by itself: when the session is gone — the SSE stream ends with `auth-required`, or the ids stream answers 401 / 403 — it calls `onSessionExpired()` exactly once per occurrence and stops. A typical host reloads the route (`navigate(0)`) so its own loader redirects to the login page.
|
|
1053
|
+
`DailyReportConfigProvider` takes the same required `config`, and every component and hook of the package that reads the configuration needs it above: outside it `useDailyReportConfig` throws at render (`[daily-report] useDailyReportConfig must be used within a DailyReportConfigProvider`); no default configuration renders silently.
|
|
968
1054
|
|
|
969
1055
|
`config.headingLevel` is the level of each report's heading: an integer from 2 to 5 (default 3), so that the section headings inside a report, one level lower, stay within `h6`. Pick the level that continues your page's outline (see [Document structure](#keyboard-focus-and-selection-list--detaillist)); any other value (`1`, `6`, `3.5`, `"3"`, `null`) throws a `RangeError` at render (`[daily-report] config.headingLevel must be an integer from 2 to 5 …; got 6`).
|
|
970
1056
|
|
|
@@ -975,9 +1061,10 @@ The first load of a report that is not cached starts inside the hook's effect, w
|
|
|
975
1061
|
|
|
976
1062
|
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
1063
|
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
|
|
979
|
-
`
|
|
980
|
-
Outside
|
|
1064
|
+
`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. Each failure is reported through one channel only, the error seam above: the side pane's and the DetailList's delete buttons answer the rejection by returning, without a notice of their own and without handing focus on (the package opens no native `alert()`); a continuation that resolves after a user switch rolls nothing back and resolves.
|
|
1065
|
+
`DailyReportConfigProvider` is required above every package component, and `DailyReportErrorProvider` above `DailyReportActionProvider`: `DailyReportPage` composes all three, and a host that composes the views, the resolved content or the action provider itself wraps them in the providers they need (all are exported from `@aiquants/daily-report/client`).
|
|
1066
|
+
Outside the error provider the action provider and the List's mobile overlay throw at render (`[daily-report] useDailyReportErrorSurface must be used within a DailyReportErrorProvider`), as `useDailyReportConfig` throws outside `DailyReportConfigProvider` and the action context's hooks — the editing store's included — outside `DailyReportActionProvider`.
|
|
1067
|
+
Every context behind an exported provider defaults to `null` and is read only through one reader of a provided value (`useProvidedContext` in `src/client/contexts/provided-context.ts`, which `src/client/contexts/provided-context.spec.ts` enforces over the sources): no surface of the package does nothing silently outside its provider.
|
|
981
1068
|
|
|
982
1069
|
- **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
1070
|
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.
|
|
@@ -997,7 +1084,9 @@ Outside it the action provider and the List's mobile overlay throw at render (`[
|
|
|
997
1084
|
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"`.
|
|
998
1085
|
|
|
999
1086
|
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)).
|
|
1000
|
-
The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes only with the viewing user — and hands out its state apart: the rows (`items
|
|
1087
|
+
The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes only with the viewing user — and hands out its state apart: the rows (`items`, a persistent list read by its length, the report at a position (`at`) and iteration, not an array: **Rows follow the stream by its changes** below), the version that announces a change of the ledgers, and the SSE state (`isSseEnabled`, `sseStatus`).
|
|
1088
|
+
Each function calls the implementation of the last committed render, so a function kept from an earlier render does what the current one does.
|
|
1089
|
+
The ledgers are read-only views (`DailyReportLedgerView`: a frozen `{ current }` of a read-only map) whose contents only the provider's one writer changes, and the writer advances `version` in the same call, so no ledger changes without a re-render of what reads it through `version`: a comment whose post fails disappears from the list and a comment whose deletion fails comes back, while the notice reports the failure.
|
|
1001
1090
|
The package's rows, List cards, DetailList rows and report cards, the side pane's content and the edit form read only the actions, so an ids stream publish, an SSE message or a change of the connection status re-renders none of them; of a report's parts, a version bump re-renders only its comment section (`ReportComments`). `useDailyReportActionContext()` returns the actions and the state as one value, the same value on a render where the state did not change; its caller re-renders whenever the state changes.
|
|
1002
1091
|
|
|
1003
1092
|
The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
|
|
@@ -1015,10 +1104,16 @@ If your app overrides `config.apiBasePath`, pass the same value to `createDailyR
|
|
|
1015
1104
|
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).
|
|
1016
1105
|
`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
1106
|
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
|
|
1019
|
-
|
|
1107
|
+
The rows are a persistent list (`ReportRowList` in `src/client/contexts/report-row-list.ts`): immutable chunks of `REPORT_ROW_CHUNK_SIZE` = 1,024 rows behind one spine, every chunk full but the last, and a derivation's edits make a new version in one pass that shares every chunk they leave alone (`editReportRowList`), once per publish however often React runs the render again.
|
|
1108
|
+
A publish that appends rows — what the stream mostly delivers — writes the spine (⌈n / 1,024⌉ references), the last chunk's rows and the new rows, never the n references of the list (4,128 references for 4,000 rows appended to 80,000, where a copy of the list wrote 84,000); edits that only replace rows copy the spine and those rows' chunks; a removal or an insertion inside the list re-packs the rows from the first chunk it changes, once.
|
|
1109
|
+
The rows are written by position with no `concat`, `filter`, `flat`, slice or spread of the list: `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.
|
|
1110
|
+
The views, the keyboard, the action state (`items`) and the search's list derivation read rows only by their length, the report at a position (`at`, two array reads) and iteration (`ReportRows` in `src/client/utils/report-index.ts`), so a plain array (a search answer) and the provider's list read alike; a search for one id walks the chunks by position (`findReportIndex`), without a call per row.
|
|
1111
|
+
While an answer shows, the search's list derivation (`deriveSearchListItems` in `src/client/search/search-list-items.ts`) examines only the chunks a publish wrote (for an append, the last chunk and the new rows' chunks), since the chunks a version shares were examined before, and returns the previous list itself when the publish changed none of the answer's reports, so such a publish re-renders neither the views nor their rows.
|
|
1112
|
+
React runs a render-phase update again whenever it restarts the render — a transition that carries a publish is thrown away and restarted from the root by every key a held key repeats —, so one base and one set of inputs (the source's revision and change chain, the settledness and the deletion marks) are derived once and reused, the same rows object, by every rerun (`deriveReportRowsOnce`).
|
|
1113
|
+
The deletion marks are a read-only set that the provider replaces on every change, so that set's identity is an exact part of the key. Without it, one publish under a held key was derived and copied once per restart (21 to 25 copies in a concurrent root, about 320 KB each at 80,000 rows).
|
|
1020
1114
|
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).
|
|
1021
1115
|
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.
|
|
1116
|
+
A report the stream holds while a deletion mark hides it is remembered as hidden, so when the server refuses the deletion and the mark is withdrawn, the stream's row comes back at once, at the stream's value and in its canonical place (`withdrawRemovalMark`), as a derivation from the whole list would show it.
|
|
1022
1117
|
- **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.
|
|
1023
1118
|
A burst of SSE events therefore reaches the list in one publish, at most 50 ms after the first; the user's own creation and deletion show at once all the same, because the provider adds or removes that row itself. The window is measured on a monotonic clock (`performance.now()`), so a wall clock set back does not lengthen it.
|
|
1024
1119
|
There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
|
|
@@ -1084,11 +1179,14 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1084
1179
|
- **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below) while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported its visible range: the focus owner, then the selection).
|
|
1085
1180
|
Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
|
|
1086
1181
|
So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its form in the Tab order.
|
|
1182
|
+
The view-mode tab panel that holds a view is no Tab stop: a panel is a stop when its first meaningful content is not focusable, or when it is a scroll container that Tab must reach so the keys can scroll it (WAI-ARIA APG Tabs; `TabsContent` takes a required `tabStop`), and a view's content begins with its own stop, the cursor row.
|
|
1183
|
+
The panel carries no `tabindex` at all (not −1, which would let a pointer press on blank view space focus the panel, an ancestor of the list where the view's keys never arrive), so one Tab from the last control before the view reaches the cursor row, and `Ctrl+Home` from a row returns to that control.
|
|
1184
|
+
The side pane's article and relations panels are stops of their own: each is the pane's scroll box, which Tab must reach so the keys scroll it, although the article holds buttons, the comment field and attachment links further down.
|
|
1087
1185
|
The scroll bar is pointer-only in both views, like a native scroll bar, because the keys above already scroll the list: both views pass `enableArrowButtonTabStops: false`, `@aiquants/virtualscroll`'s one signal that the host scrolls by keyboard itself (3.10.0, its README "Scrollbar accessibility and the Tab order").
|
|
1088
1186
|
The bar carries `aria-hidden="true"`, so assistive technology sees no `scrollbar` and no `slider` in either view, named or not; its arrow buttons have `tabIndex=-1` and the bar and its thumb no `tabindex`, so the bar adds no Tab stop; and a press anywhere on the bar has its default prevented, so it moves no focus, neither onto a part of the bar nor away from the row that holds it. Every pointer interaction still scrolls: the arrows on a press and while held, the track, the thumb and the tap-scroll circle.
|
|
1089
1187
|
The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not expose.
|
|
1090
1188
|
- **Leaving the list** (`Ctrl+Home` / `Ctrl+End`, as in the WAI-ARIA feed pattern): focus moves to the last Tab stop before the list or the first one after it — where real `Shift+Tab` from the list's first stop and real `Tab` from its last stop go — and the browser scrolls it into view as it does for Tab.
|
|
1091
|
-
In the List view the exits are measured from the list like in the DetailList, so on the desktop layout `Ctrl+End` moves to the resize handle (then Tab goes on to the side pane), and `Ctrl+Home` to the last stop before the list (in `DailyReportPage`, the last control
|
|
1189
|
+
In the List view the exits are measured from the list like in the DetailList, so on the desktop layout `Ctrl+End` moves to the resize handle (then Tab goes on to the side pane), and `Ctrl+Home` to the last stop before the list (in `DailyReportPage`, the search row's last control when the search is configured — its clear button while it shows, otherwise the field — and the view-mode toolbar's last control when it is not).
|
|
1092
1190
|
The stops come from the package's one model of sequential focus navigation (`readFocusCapability`, `listTabbables` and `partitionTabbables` in `src/client/keyboard/dom-node.ts`), which the mobile overlay's Tab wrap uses as well. The order is the flat tree's: open shadow roots at their hosts, slotted elements at their slots (an empty slot shows its own children), and a focusable ancestor before its descendants (the package writes no positive `tabindex`). The list's descendants do not count; a focusable ancestor of the list (a host tab panel around the view, for example)
|
|
1093
1191
|
counts before it, as real `Shift+Tab` returns there.
|
|
1094
1192
|
An element with a `tabindex` that parses as an integer is a stop when the value is 0 or more (a negative value makes it focusable but not a stop; an unparsable value counts as absent). Without one, its kind decides:
|
|
@@ -1169,7 +1267,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1169
1267
|
The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body.
|
|
1170
1268
|
The total the position names is the size of the set the rows belong to — every row frame's `aria-setsize` and the header's total count read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
|
|
1171
1269
|
while the ids stream delivers, the total the server declared on the stream's first line (never fewer than the rows shown, and unchanged as chunks arrive); before the server has declared it, unknown — `aria-setsize="-1"`, the header shows `labels.totalCountLoading`, and the position reads `labels.listRowPositionInUnknownTotal` (en "Item 3", ja 「3 件目」); once the scan has completed, the rows shown.
|
|
1172
|
-
A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
|
|
1270
|
+
A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints, and, while the screen holds a search marks store, by the match indicator's hidden sentences last (**Match indicator** in [Text search](#text-search)).
|
|
1173
1271
|
The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
|
|
1174
1272
|
- **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
|
|
1175
1273
|
The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date alone), and `labels.author` with the author's name; each label and each value is read once, and the source badge sits beside the pair, outside it. The overlay names its dialog by the heading and the author's value together (`<date> <author>`, the name of the List card).
|
|
@@ -1287,6 +1385,10 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1287
1385
|
| Error text / overlay panel (the side pane's content on a phone) | 6.42 | 6.17 | 4.5 |
|
|
1288
1386
|
| Error text / the load-error panel (the card surface over the page frame) | 6.42 | 6.45 | 4.5 |
|
|
1289
1387
|
|
|
1388
|
+
- **Text fields**: the comment input, the edit form's `Input` and `Textarea` and the search field, and the outline of the search's OR toggle, take their boundary and placeholder from one token (`INPUT_FIELD_CLASS_NAME`): a 1 px slate-500 border in both schemes and a slate-500 placeholder (dark slate-400). Each field declares its own `border border-solid` beside its inset and its own package fill (white, the comment input slate-50 turning white on focus; dark slate-900, the comment input slate-800 turning slate-900); none reads the host's `--input`, `--muted-foreground`, `--primary` or `--destructive`.
|
|
1389
|
+
The boundary reaches the 3:1 of WCAG 1.4.11 against every surface a field sits on and against the field's own fill, its focus fill included (light: 4.76 on white and 4.55 on slate-50; dark: 3.74 on slate-900, 3.07 on slate-800 and 4.23 on slate-950), and the placeholder reaches 4.5:1 on each field's fill. The AND face of the toggle is a blue-700 fill (dark blue-600), whose edge is its boundary.
|
|
1390
|
+
`src/client/ui/ui-state-contrast.spec.ts` computes these pairs for every site from the site's own classes (so a site that drops the token fails), and its host-token guard covers the token, the primitives and the sites.
|
|
1391
|
+
|
|
1290
1392
|
- **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.
|
|
1291
1393
|
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`.
|
|
1292
1394
|
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;
|
|
@@ -1315,10 +1417,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1315
1417
|
The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / q⌉ · q − h (0 to q − 1 px, q the block quantum of **Row slots on the lattice**) as a bottom margin, so every tile is ⌈h / q⌉ · q + 48 px tall, every tile row top and every grid's height are multiples of q, and what follows a grid stays on whole device pixels (**Tile** in [Attachment display](#attachment-display)).
|
|
1316
1418
|
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.
|
|
1317
1419
|
**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).
|
|
1318
|
-
The only other
|
|
1420
|
+
The only other exceptions are the visually hidden text (Tailwind's `sr-only`, and inside virtualized rows and for every attachment file name the transparent 1 px box of `VISUALLY_HIDDEN_CLASS_NAME`, which clips nothing) and the 24 px the search field keeps free at its end for the clear button inside its box. `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.
|
|
1319
1421
|
- **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
1422
|
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
|
|
1423
|
+
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: one more 32 px block, `SEARCH_ROW_CLASS_NAME`), so the views start q + the band below the column's top — 4 + 32 = 36 px at the multiples of 1/4 and 8 + 32 = 40 px at the odd eighths, or 4 + 64 = 68 px and 8 + 64 = 72 px with the search row — on the lattice at every ratio.
|
|
1424
|
+
The search row's 24 px controls stand at its bottom, so the gap above them is 32 − 24 = 8 = G from the toolbar's bar and their bottom edge is G above the view's first surface. The band stacks above the views (`relative z-[1]`), so the 4 px focus outline of a search control that ends at the view's top paints over the view's G strip.
|
|
1322
1425
|
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
1426
|
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.
|
|
1324
1427
|
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.
|
|
@@ -1336,17 +1439,18 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1336
1439
|
The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
|
|
1337
1440
|
- **Tab bars are one segmented control**: the side pane's tabs and the view-mode toolbar share one geometry. The bar has a 12 px corner (`rounded-xl`) and a total inset of 4 px that counts its border (4 px padding without a border, `SEGMENTED_LIST_CLASS_NAME`; 1 px border + 3 px padding with one, `SEGMENTED_BORDERED_LIST_CLASS_NAME`), the same at the top and at the sides.
|
|
1338
1441
|
The tabs are 24 px tall with 12 px labels and an 8 px corner (`rounded-lg`), so both bars are 4 + 24 + 4 = 32 px tall and every tab is concentric with its bar: 12 − 4 = 8. Because xl − lg = 4 equals the inset both in Tailwind's scale and in a host `--radius` scale (lg = `--radius`, xl = `--radius` + 4 px), the pair stays concentric for any host radius; the toolbar's children at its end corners (the create button, the development box) are `rounded-lg` too.
|
|
1339
|
-
- **View-mode toolbar** (`DailyReportResolvedContent`, above every view): its start edge is inset by G and lines up with the cards
|
|
1442
|
+
- **View-mode toolbar** (`DailyReportResolvedContent`, above every view): its band carries the insets, so the toolbar and the search row share them: the start edge is inset by G and lines up with the cards (`VIEW_TOOLBAR_BAND_INSET_CLASS_NAME`), and the end edge lines up with the active view's content: G in the desktop List, where the side pane ends the row, and G + the scroll-bar width (8 + 8 = 16) in the DetailList and in the single-column List, where the scroll bar ends it.
|
|
1443
|
+
Both insets are written in px (`ml-[8px]`, `mr-[8px]` / `mr-[16px]`), like G and the scroll bar, so they line up at every root font size, and the search field's end edge is the cards' end edge in both views.
|
|
1340
1444
|
It never scrolls sideways and hides nothing: when it is narrower than its one-row width its controls wrap onto a second row, every label kept. It clips nothing, so the tabs' outlines, which reach 4 px outside them into the bar's 4 px inset, are never cut (the **Focus reach** rule above). Its tab list is named by `labels.viewTabList`.
|
|
1341
1445
|
- **Scroll bars**: both views theme VirtualScroll's scroll bar through its root class (`VIEW_SCROLL_BAR_THEME_CLASS_NAME`): the track is slate-100 / dark slate-900 and the thumb slate-500 (slate-600 / 400 on hover, slate-700 / 300 while dragged), at least 3:1 against the track in both themes (lowest 4.35 / 3.74); the arrow glyphs reach 6.90 / 6.14 in light and 6.78 / 5.56 in dark on their resting and hover backgrounds.
|
|
1342
1446
|
In forced colours the browser replaces every background that is not a system colour with `Canvas`, which would leave the thumb invisible on its track, so the token restates the thumb in system colours with the same selector as each state — `CanvasText` at rest, `Highlight` hovered and dragged, `GrayText` disabled — after the light and dark rules, and gives the track a 1 px inset `CanvasText` outline (an outline: the layout does not change).
|
|
1343
1447
|
These rules win over virtualscroll's own forced-colours rules (`@aiquants/virtualscroll` 3.10.0, its README "Forced colours") the same way the token wins over its default colours, so the views' thumb is drawn by this token in every mode.
|
|
1344
|
-
- **Line breaks of wrapping labels**: a short
|
|
1448
|
+
- **Line breaks of wrapping labels**: a short label that can wrap — the side pane's selection prompt, whose panel narrows to its minimum (**Side pane** above), an attachment tile's unavailable label, as wide as its track, and the search box's status line, at most half its row — breaks its lines through one token, `WRAPPING_LABEL_CLASS_NAME` (`text-balance wrap-anywhere [word-break:auto-phrase]`), because a plain break leaves a narrow centred label with a stray glyph or two on its last line, or breaks a Japanese word in the middle:
|
|
1345
1449
|
`text-wrap: balance` keeps the lines about equally long, so no one-glyph last line is left under the others (engines balance only blocks of a few lines, so the token is for labels, not body text);
|
|
1346
1450
|
`word-break: auto-phrase` breaks Japanese at phrase boundaries where the host document's `lang` is `ja` (ja 「プレビューを / 表示できません」 instead of 「プレビューを表示できませ / ん」 in a 171 px track) and behaves as `normal` in other languages;
|
|
1347
1451
|
and `overflow-wrap: anywhere` breaks inside a phrase or a word only when it does not fit on a line by itself, so a label never crosses its frame at any width, root font size or font. It also counts those breaks in the label's min-content width, so a flex item's automatic minimum width no longer holds the label at its longest phrase (the prompt is a flex item, and the tile label's text is the anonymous flex item of its box: the property is inherited, which is how it reaches that item, where a `min-width` on the label's box would not). Where a phrase fits, the lines are the same as without it.
|
|
1348
|
-
The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's three declarations, the side pane's spec the prompt's classes and the panel minimum derived from the compiled insets and the prompt's last phrase,
|
|
1349
|
-
- **Type and contrast**: no text is smaller than 12 px (`text-xs`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
|
|
1452
|
+
The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's three declarations, the side pane's spec the prompt's classes and the panel minimum derived from the compiled insets and the prompt's last phrase, the tile's spec compiles the label's classes and checks that its line-breaking declarations are exactly the token's, and the search box's spec renders every status message in both locales and checks that the line carries the token and no truncating class.
|
|
1453
|
+
- **Type and contrast**: no text is smaller than 12 px (`text-xs`; a scan of every module under `src/client` rejects a smaller arbitrary size, `src/client/components/report-views.spec.tsx`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
|
|
1350
1454
|
- **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on every side), so the icons sit on the 4 px grid without depending on a font. In the List card the markers and toggles stand 4 px apart; the header pills and the source badge are 24 px tall like the targets.
|
|
1351
1455
|
The star is a regular five-pointed star, outlined when off and filled gold when on (with a darker gold edge in the light theme); read is a check, unread an 8 px dot, edit a pencil, delete a trash can. Every icon state reaches at least 3.59:1 against each background it sits on, the button's hover background included, in both themes; in forced colours the star is drawn in `CanvasText` (off) and `Highlight` (on). The side pane shows them in the order star, read, edit, delete, centred on the first line of the subject.
|
|
1352
1456
|
Unread is shown by the dot on the read toggle and by the row state in the description; no surface changes its border or its layout for it (a card's border is the same 1 px in every state).
|
|
@@ -1384,6 +1488,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1384
1488
|
— after dropping that font's measure, since the glyph widths change while the font string stays the same; other font loads re-render nothing.
|
|
1385
1489
|
Outside a grid (the rows of non-image attachments) and until the grid's width is known, the name truncates with CSS: the stem ellipsizes and the extension, kept up to half the width, follows it.
|
|
1386
1490
|
The visible name is hidden from assistive technology either way, and a visually hidden copy of the whole name gives each link one unbroken name (no space before the extension); `title` holds the whole name too.
|
|
1491
|
+
Every hidden copy of a file name, the download link's included, is `HiddenFileName`: `VISUALLY_HIDDEN_CLASS_NAME` with `contain: layout`. It clips nothing, so it never makes a DetailList row repaint on a scroll step that shifts the window, and its layout containment keeps the long one-line name from widening a scroll box outside the virtualized rows (the side pane, the mobile overlay).
|
|
1492
|
+
The attachment section still holds content that clips — the visible name's ellipsis, a fitted name's `overflow: hidden`, the size note's ellipsis and the thumbnail frame's rounded corners — so a DetailList row with attachments is still repainted on such a step.
|
|
1493
|
+
While a search answer shows, the visible name carries the search marks of its `attachment:<i>` key (i, the attachment's index in the report, taken before the split into tiles and rows; **Highlights** in [Text search](#text-search)): in the CSS layout the stem span and the extension span each draw their own part of the raw name, so a match across the dot marks both;
|
|
1494
|
+
a fitted name maps back to the raw name — unit for unit when the whole name fits, otherwise by the NFC runs it was cut from (`fitFileName` returns a `DisplayText`), so a mark on a decomposed name covers the whole composed character — and its synthetic `…` and the elided middle carry no mark.
|
|
1495
|
+
The visually hidden copy, `title` and `data-attachment-file-name` stay plain.
|
|
1387
1496
|
- **Tab order inside list rows**: the attachment links follow the row rule of [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist): inside a DetailList row they are Tab stops only in the view's Tab-stop row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
|
|
1388
1497
|
- **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without padding or radius (its first ink starts at the content edge) holding a 16 px paperclip icon and, from 2 attachments on, the count in 12 px type on a 16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
|
|
1389
1498
|
Its required `id` prop lands on that `role="img"` element, so a row's description can reference the marker (pass an id unique in the document, for example from `useId()`); with no attachment nothing is rendered, and the description leaves it out.
|
|
@@ -1486,27 +1595,28 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1486
1595
|
```
|
|
1487
1596
|
|
|
1488
1597
|
- **One surface for all wording.** The catalog covers the 11 engine chrome keys of
|
|
1489
|
-
`@aiquants/virtualscroll` plus
|
|
1598
|
+
`@aiquants/virtualscroll` plus 102 own keys: field headings, the page title (`title`, passed to
|
|
1490
1599
|
`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
|
|
1491
|
-
screen readers, tabs, buttons, placeholders, `confirm()`
|
|
1600
|
+
screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box and the search's match indicator, the status screen and the page's status region
|
|
1492
1601
|
and the mutation-failure message with its repeat count. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
1493
1602
|
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.
|
|
1494
1603
|
- **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
|
|
1495
1604
|
and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
|
|
1496
1605
|
its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
|
|
1497
1606
|
re-render the memoized list subtree.
|
|
1498
|
-
- **Formatter keys.**
|
|
1607
|
+
- **Formatter keys.** Seventeen keys take arguments and are functions: `totalCount(count)`,
|
|
1499
1608
|
`debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
|
|
1500
1609
|
`streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
|
|
1501
1610
|
`interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
|
|
1502
1611
|
`DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
|
|
1503
1612
|
`toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`, `listRowPositionInUnknownTotal(position)`,
|
|
1504
|
-
`searchHitCount(count)
|
|
1613
|
+
`searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`), `searchMatchedKeywords(matched, total)` and `searchMatchedKeywordsDescription(matched, total)`. An override of such a key must be a function too.
|
|
1505
1614
|
- **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
|
|
1506
|
-
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`)
|
|
1615
|
+
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`), through one `Intl.NumberFormat` per
|
|
1616
|
+
locale built once (the row positions and counts re-render on every stream publish); a host override receives
|
|
1507
1617
|
the raw number and formats it itself.
|
|
1508
1618
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
1509
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
1619
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 113 keys), a string
|
|
1510
1620
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
1511
1621
|
function. An `undefined` value keeps the catalog value.
|
|
1512
1622
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
|
|
@@ -1524,7 +1634,8 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1524
1634
|
- **Unknown authors**: a comment whose author name is unknown — a legacy comment without a name, a
|
|
1525
1635
|
relational comment with an empty `userName`, the pending comment of a viewing user without any name
|
|
1526
1636
|
or email — shows `labels.unknownUser`. The server never sends a fixed fallback name (an author
|
|
1527
|
-
without a display name is sent as `""`).
|
|
1637
|
+
without a display name is sent as `""`). `mergeComments` flags such a comment (`DailyReportUIComment.isAuthorUnknown`),
|
|
1638
|
+
and the search never marks the substitute.
|
|
1528
1639
|
- **`labels` must be a plain object** (prototype `Object.prototype` or `null`); arrays, class
|
|
1529
1640
|
instances and objects created with `Object.create(proto)` throw a `RangeError`. Only own properties
|
|
1530
1641
|
are read, so a label key inherited from a polluted `Object.prototype` is never applied. Every error
|
|
@@ -1543,7 +1654,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1543
1654
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
1544
1655
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
1545
1656
|
|
|
1546
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1657
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 113 keys, the 11 engine keys
|
|
1547
1658
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
1548
1659
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
1549
1660
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -1598,8 +1709,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1598
1709
|
| `devSseToggle` | dev toolbox: the SSE switch's accessible name (`aria-label` on the `role="switch"` button) and tooltip | Toggle SSE real-time sync | SSE リアルタイム同期切替 |
|
|
1599
1710
|
| `devReload` | dev toolbox: reload tooltip | Reload data | データ再取得 (リロード) |
|
|
1600
1711
|
| `devClearCache` | dev toolbox: clear-cache tooltip | Clear the cache and reload | キャッシュ破棄 & 再取得 |
|
|
1601
|
-
| `autoRead` | auto-read switch label | Auto-read | 自動既読 |
|
|
1602
|
-
| `autoReadTitle` | auto-read switch tooltip | Mark reports as read automatically while viewing them | 日報閲覧時の自動既読処理切替 |
|
|
1712
|
+
| `autoRead` | auto-read switch label, tied to its switch by an id of its own (`useId`, so two mounted screens never share one) | Auto-read | 自動既読 |
|
|
1713
|
+
| `autoReadTitle` | auto-read switch: its description (`aria-describedby`) and tooltip | Mark reports as read automatically while viewing them | 日報閲覧時の自動既読処理切替 |
|
|
1603
1714
|
| `loadErrorBadge` | header annotation on load failure | Error | エラー |
|
|
1604
1715
|
| `loadError` | status panel on load failure; the page's status region announces it as an alert | Failed to load the daily reports. | 日報データの読み込みに失敗しました。 |
|
|
1605
1716
|
| `reload` | status panel's retry button on load failure | Reload | 再読み込み |
|
|
@@ -1613,7 +1724,6 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1613
1724
|
| `contentEmpty` | side pane: report without content | No content has been entered. | 内容は登録されていません。 |
|
|
1614
1725
|
| `contentEmptyPreview` | list card preview: report without content | No content has been entered | 内容は登録されていません |
|
|
1615
1726
|
| `confirmDelete` | `confirm()` before deleting a report | Are you sure you want to delete this report? | 本当に削除しますか? |
|
|
1616
|
-
| `deleteFailed` | `alert()` after a failed delete | Failed to delete the report | 削除に失敗しました |
|
|
1617
1727
|
| `starAdd` | star toggle (unstarred) | Add star | スターを付ける |
|
|
1618
1728
|
| `starRemove` | star toggle (starred) | Remove star | スターを外す |
|
|
1619
1729
|
| `markRead` | read toggle (unread) | Mark as read | 既読にする |
|
|
@@ -1725,7 +1835,7 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
|
|
|
1725
1835
|
The reader reads each live entry once per process and hands every subscriber the same frozen value (`StreamEntry`: `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `dailyReportSseMessageSchema` — the validated message (`parsed`), the JSON value as published (`published`, with the server-only fields) and its text (`text`) — or `null` when the field is absent, is not JSON or fails the schema (the last two logged once at `error`; such an entry is written to no connection — fail-closed — while the reader's own read position moves past it).
|
|
1726
1836
|
Each connection only filters that value (recipient, source-type visibility, comment redaction) and never changes it, and the frame texts of the variants that differ from the published text — without `recipientRawUserId`, and with the embedded report's comments emptied — are built at most once per message and shared by every connection that writes them, so a live entry costs one parse in proportion to its size plus a filter per connection, not one parse per connection.
|
|
1727
1837
|
A host's own reader keeps the same contract: it reads an entry once — `readDailyReportSseStreamEntry(raw, logger)` (server entry) reads it as the package's reader does — and hands the same value to every subscriber.
|
|
1728
|
-
3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry
|
|
1838
|
+
3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its responses carry `SSE_RESPONSE_HEADERS`, the routes' security headers (**Security headers** in [Server wiring](#server-wiring-di)) and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId` is filtered server-side and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type visibility, comment redaction).
|
|
1729
1839
|
4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries the cursor in the `Last-Event-ID` header), and a stale watch of 45 s (three heartbeat periods).
|
|
1730
1840
|
The action context correlates optimistic updates with SSE echoes using `clientTempId`, and exposes the connection status as `sseStatus` (`DailyReportSseConnectionStatus`: the reopening status, or `{ kind: "resyncing" }` while a `resync-required` waits for a fresh ids anchor).
|
|
1731
1841
|
The action context ignores the SSE echo of its own action for 30 s after sending it, holds a message about a report that is not cached yet for up to 30 s (when the report's cache is written within that time the held messages are handled once, by the last committed render's handler; otherwise they are dropped),
|
|
@@ -1742,8 +1852,8 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1742
1852
|
| --- | --- | --- |
|
|
1743
1853
|
| Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
|
|
1744
1854
|
| Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
|
|
1745
|
-
| A request from another origin's page that the isolation refuses ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`), counted in the bounded refusal record | never met: the package's client connects from the page's own origin |
|
|
1746
|
-
| A React Router single-fetch data request (`<path>.data`, `GET` or `HEAD`) | 404 JSON before authentication (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`,
|
|
1855
|
+
| A request from another origin's page that the isolation refuses ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, the routes' security headers, no `Set-Cookie`), counted in the bounded refusal record | never met: the package's client connects from the page's own origin |
|
|
1856
|
+
| A React Router single-fetch data request (`<path>.data`, `GET` or `HEAD`) | 404 JSON before authentication (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, the routes' security headers, no `Set-Cookie`), with no subscription or timer and no line in the refusal record ([Request isolation](#request-isolation)) | never sent by the client: `EventSource` requests the route itself |
|
|
1747
1857
|
| Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
|
|
1748
1858
|
| A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer (**Streaming routes** in [Server wiring](#server-wiring-di)) | never sent by the client |
|
|
1749
1859
|
| Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
|
|
@@ -1795,13 +1905,13 @@ A type the closure brings in is published under a name that carries `DailyReport
|
|
|
1795
1905
|
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
1906
|
- 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
1907
|
`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`)
|
|
1908
|
+
- The text search's wire: `DAILY_REPORT_SEARCH_COMBINES` with its type `DailyReportSearchCombine` (the `combine` values, `intersection` and `union`), `DailyReportSearchHit` (one hit as the server holds it before the column-wise answer), and the answer of `search-marks`: `DailyReportSearchMarksAnswer` (`{ wordsDigest, reports }`) of `DailyReportSearchReportMarks` (a report's id, `matchedSets` and `fields`) and `DailyReportSearchFieldMarks` (a field's `fingerprint` and `ranges`).
|
|
1799
1909
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
1800
1910
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider: [Client wiring](#client-wiring)),
|
|
1801
1911
|
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` /
|
|
1912
|
+
- 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` (the rows as a persistent list, read by `length`, `at` and iteration), `version`, `isSseEnabled`, `sseStatus`, and for the search `searchInvalidation`, `isReportRemoved`, `removedVersion`; `updateReport` takes both `title` and `content`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` /
|
|
1803
1913
|
`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),
|
|
1914
|
+
Their types: the action context's `DailyReportActionContextType` (what `useDailyReportActionContext` returns) / `DailyReportActions` (the functions, the ledgers and the viewing user) / `DailyReportActionFunctions` / `DailyReportActionLedgers` (with `DailyReportLedgerView`, a ledger's read-only `{ current }`) / `DailyReportActionState` / `DailyReportActionBusinessDate` (the business date an action sends), `DailyReportDetailResource` (the detail source the screen provides while it shows a search answer),
|
|
1805
1915
|
`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).
|
|
1806
1916
|
- 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`.
|
|
1807
1917
|
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
|
|
@@ -1809,12 +1919,12 @@ A type the closure brings in is published under a name that carries `DailyReport
|
|
|
1809
1919
|
- 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
1920
|
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)).
|
|
1811
1921
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1812
|
-
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
|
|
1813
|
-
|
|
1922
|
+
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
|
|
1923
|
+
and for the search `searchLimits`, `null` without the `search` block, `searchDailyReports`, `getDailyReportDetailsByIdsByExternalId`, `markDailyReportSearchHits` (it takes the request's `{ signal }` and throws its reason at its next check after an abort), `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1924
|
+
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` with `DailyReportSqlResultCacheMissAdmission` (the admission a read passes for the fetch its miss starts).
|
|
1814
1925
|
- 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`.
|
|
1815
1926
|
- 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) /
|
|
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`
|
|
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).
|
|
1927
|
+
`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` (its `defaultTtlMs` and `maxBuckets`; **The SQL result cache** in [Server wiring](#server-wiring-di)) / `transformJsonArray`.
|
|
1818
1928
|
- 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)).
|
|
1819
1929
|
- 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`,
|
|
1820
1930
|
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),
|
|
@@ -1822,6 +1932,7 @@ A type the closure brings in is published under a name that carries `DailyReport
|
|
|
1822
1932
|
and the structural drizzle surface `DailyReportDb` is built from: `DailyReportDbRows` / `DailyReportDbSelectChain` / `DailyReportDbInsertChain` / `DailyReportDbUpdateChain` / `DailyReportDbDeleteChain`,
|
|
1823
1933
|
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
1934
|
`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`
|
|
1935
|
+
and for the text search `DailyReportSearchConfig` (the `search` block) / `DailyReportSearchSourceTextReader` / `DailyReportSearchTables` with its tables `DailyReportSearchDocumentTable` / `DailyReportSearchCommentTable` / `DailyReportSearchSqlExecutor` (drizzle's `execute`) / `DailyReportSearchReconcileOptions` / `DailyReportSearchReconcileProgress` / `DailyReportSearchReconcileSummary` / `DailyReportSearchPlan`
|
|
1936
|
+
(a compiled query: `DailyReportSearchSet` keywords of `DailyReportSearchWord` words, each with its `DailyReportSearchAlternative` alternatives `{ text, tier }` and their `DailyReportSearchAlternativeTier`, 0 for the typed word's forms and 1 for a romaji reading).
|
|
1826
1937
|
|
|
1827
1938
|
MIT
|