@aiquants/daily-report 0.38.0 → 0.40.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 +156 -0
- package/README.md +219 -99
- package/dist/client.d.mts +31 -17
- package/dist/client.d.ts +31 -17
- package/dist/client.js +7 -6
- package/dist/client.mjs +7 -6
- package/dist/{comment-adapter-CldlCy1v.d.ts → comment-adapter-BbGh4nc0.d.mts} +2 -22
- package/dist/{comment-adapter-BsbOiHmX.d.mts → comment-adapter-CNti3-Dv.d.ts} +2 -22
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{search-marks-wire-CZRU4q3h.d.mts → search-marks-wire-DLgJTytk.d.mts} +6 -1
- package/dist/{search-marks-wire-CZRU4q3h.d.ts → search-marks-wire-DLgJTytk.d.ts} +6 -1
- package/dist/server.d.mts +13 -5
- package/dist/server.d.ts +13 -5
- package/dist/server.js +10 -10
- package/dist/server.mjs +10 -10
- package/dist/{sse-schema-byewDmZK.d.mts → sse-schema-S7gXWA2N.d.mts} +100 -39
- package/dist/{sse-schema-byewDmZK.d.ts → sse-schema-S7gXWA2N.d.ts} +100 -39
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -38,9 +38,12 @@ The server entry needs **Node.js 20.3 or later** (`engines.node` `>=20.3.0`): th
|
|
|
38
38
|
Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnpm run typecheck:examples` (the host examples under `examples/`, which are not shipped).
|
|
39
39
|
`pnpm run check:bundle` checks the gzip size at level 9 of every shipped ESM entry and the standalone stylesheet — `dist/client.mjs`, `dist/index.mjs`, `dist/server.mjs`, `dist/styles/daily-report.standalone.css` — against a budget that is derived, never written: `bundle-baseline.json` at the package root records each file's gzip-9 size and one `headroomPercent` (3), and each budget is ⌈baseline × (100 + headroomPercent) ÷ 100⌉ in integer arithmetic.
|
|
40
40
|
An `.mjs` or `.css` target of `exports` without an entry, an entry for a file that is no longer a target and a hand-written `bundleBudget` in `package.json` fail the check.
|
|
41
|
-
`pnpm run bundle:ratchet` (`--write`) records the sizes of a fresh build: an entry goes down freely and a stale one is removed, while a larger size or a new target is recorded only with `--write --accept` (a reviewed growth; without `--accept` the entry keeps its size and the growth is judged against the old budget). `headroomPercent` is policy and is never written by the script.
|
|
41
|
+
`pnpm run bundle:ratchet` (`--write`) records the sizes of a fresh build: an entry goes down freely and a stale one is removed, while a larger size or a new target is recorded only with `--write --accept --reason '<text>'` (a reviewed growth; `--accept` without a non-blank reason, or with a second `--reason`, exits 2; without `--accept` the entry keeps its size and the growth is judged against the old budget). `headroomPercent` is policy and is never written by the script.
|
|
42
|
+
Every accepted growth is recorded with its reason in `bundle-growth-ledger.json` at the package root (`{ "entries": [{ "version", "file", "from", "to", "deltaPercent", "reason" }] }`: `version` is `package.json`'s version when the growth was recorded, `from` the size it grew from, `to` the size it grew to and `deltaPercent` the growth in percent of `from`, rounded to two decimals), which `--write --accept --reason` appends to before it writes the baseline and never rewrites.
|
|
43
|
+
Every check fails when a baseline entry is above the last `to` the ledger records for its file, or has no ledger entry at all: the baseline grows only through `--accept`, so such an entry is a growth without a reason (a hand edit, or a merge that took another branch's baseline). Shrinking stays free and is not recorded, and the ledger is read strictly (each `deltaPercent` equals the value derived from its `from` and `to`).
|
|
44
|
+
Every `--write` also prints the bytes each input module contributes to each `.mjs` target, read from the esbuild metafile of the same build (`dist/metafile-esm.json`, which the build writes with `tsup --metafile`; a metafile whose outputs differ from the measured files is refused, and `files` leaves the metafiles out of the tarball), so a growth can be traced to its code.
|
|
42
45
|
Every `publish:*` script measures the build that `pnpm run verify` ends with (verify's last steps are the build and the bundle check) and does not build again: right after verify it runs `node scripts/check-bundle-size.mjs --write`, then `node scripts/check-bundle-size.mjs --exact`, before the leak check (which reads the same build) and the version bump.
|
|
43
|
-
`--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build after review.
|
|
46
|
+
`--write` lowers every entry the build shrank, so a shrink is recorded into the release by construction (no failed publish, no separate baseline commit); a growth is left unrecorded, and `--exact` — besides the budgets, it fails when any measured size differs from its baseline entry in either direction — stops it and prints the `node scripts/check-bundle-size.mjs --write --accept --reason '<text>'` command that records that build, with the reviewer's reason, after review.
|
|
44
47
|
So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
|
|
45
48
|
`pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the changed-lines coverage gate, the examples, the build and, last, the bundle check.
|
|
46
49
|
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):
|
|
@@ -90,15 +93,17 @@ A host control carries its own floor (a control built on `@aiquants/select-box`,
|
|
|
90
93
|
|
|
91
94
|
This package has **no hand-written component CSS**, so it ships **no components-only artifact** — only a standalone build for non-Tailwind hosts. Components carry Tailwind utility classes in JSX. Pick one consumption mode:
|
|
92
95
|
|
|
93
|
-
**Tailwind v4 host (required if your app builds Tailwind itself)** — add the package to your app's scan targets (the published package ships its classes in `dist`;
|
|
96
|
+
**Tailwind v4 host (required if your app builds Tailwind itself)** — add the package to your app's scan targets (the published package ships its components' classes in `dist/client.mjs`; in the monorepo, scan `src` without the specs, test helpers and server code, as the package's standalone build does) so all utilities are generated in one canonical build, define the `dark:` variant, and map the shadcn tokens the components reference (`bg-background`, `text-muted-foreground`, ... do not exist in Tailwind's default theme, so those utilities are not generated unless the tokens are mapped):
|
|
94
97
|
|
|
95
98
|
```css
|
|
96
99
|
/* app tailwind.css (Tailwind v4) */
|
|
97
|
-
@source "../node_modules/@aiquants/daily-report/dist
|
|
98
|
-
/* monorepo: scan the sources, without the specs
|
|
100
|
+
@source "../node_modules/@aiquants/daily-report/dist/client.mjs";
|
|
101
|
+
/* monorepo: scan the sources, without the specs, the test helpers and the server code (their words style no element):
|
|
99
102
|
@source "../../../../packages/daily-report/src/**/*.{ts,tsx}";
|
|
100
103
|
@source not "../../../../packages/daily-report/src/**/*.spec.{ts,tsx}";
|
|
101
|
-
@source not "../../../../packages/daily-report/src/**/test-helpers/**";
|
|
104
|
+
@source not "../../../../packages/daily-report/src/**/test-helpers/**";
|
|
105
|
+
@source not "../../../../packages/daily-report/src/server/**";
|
|
106
|
+
@source not "../../../../packages/daily-report/src/server.ts"; */
|
|
102
107
|
@plugin "tailwindcss-animate";
|
|
103
108
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
104
109
|
@theme inline {
|
|
@@ -157,7 +162,7 @@ export const dailyReportServer = createDailyReportServer({
|
|
|
157
162
|
externalSources: [ // Optional: ingest legacy daily report tables from external systems
|
|
158
163
|
{ sourceType: "legacy", table: LegacyReport, idColumn: LegacyReport.reportId, mapRow: mapLegacyRow },
|
|
159
164
|
],
|
|
160
|
-
draftLabelNames: ["Draft", "Work in Progress"], //
|
|
165
|
+
draftLabelNames: ["Draft", "Work in Progress"], // Required: the label names that mark a draft, the canonical one first (see DI ports)
|
|
161
166
|
enableDevCacheClear: import.meta.env.DEV, // Optional: allow the dev-only `intent=clearCache` (default false → 400)
|
|
162
167
|
attachments: { // Optional: attachment delivery (see Attachments); omit it for a deployment without attachments
|
|
163
168
|
idCodec: attachmentIdCodec, // Attachment id obfuscation (an instance separate from the user-id codec)
|
|
@@ -184,17 +189,24 @@ export const loader = (args) => dailyReportServer.sse.loader(args)
|
|
|
184
189
|
export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
185
190
|
```
|
|
186
191
|
|
|
187
|
-
**Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-
|
|
192
|
+
**Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-columns`, the name `DAILY_REPORT_API_ENDPOINTS.idsStream` holds) answer with a body that streams until the client leaves. The package keeps that body from starting, or from being held, for a request nobody reads as a stream, so the two routes need no guard of their own:
|
|
188
193
|
|
|
189
194
|
- **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
|
|
190
195
|
Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame), the ids stream's `forceRefresh` (400 for a value other than `true` / `false`, then the viewer's refresh bucket for a forced read: 429, **Request values** below), the viewer's stream slots (503, **Streams per viewer** below: a `HEAD` is checked and takes no slot) and the viewer's visibility —
|
|
191
196
|
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).
|
|
192
197
|
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.
|
|
193
198
|
- **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.
|
|
194
|
-
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-
|
|
199
|
+
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-columns.data`, with or without a cursor, and its trailing-slash form `ids-columns/_.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.
|
|
195
200
|
A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`, and `search`, `reports`, `search-marks` and `search-facets` with the `search` block) and the action keep serving single fetch, which React Router's own fetchers use.
|
|
196
201
|
- **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`.
|
|
197
|
-
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`):
|
|
202
|
+
The endpoint table (`API_ENDPOINT_TABLE` in `src/server/api-endpoint.ts`) states once, per endpoint, what kind of body it answers, how a JSON body's answers may be cached and what bounds its database work per viewer, and the methods follow from the body (`API_ENDPOINT_METHODS`):
|
|
203
|
+
`GET` for a JSON body (`report` and `business-date`, and `search`, `reports`, `search-marks` and `search-facets` with the `search` block), for the reason of its cache policy —
|
|
204
|
+
an `etag` body's ETag is computed from the body, so a `HEAD` would build the body only to discard it, and a `no-store` body has no validator to learn, so a `HEAD` would spend the endpoint's admission and its query to learn nothing a `GET` would not — and `GET, HEAD` for a streaming body (the ids stream).
|
|
205
|
+
Every JSON answer of the API route is built by one responder per row that decides by status first (`jsonResponderByStatus` in `src/server/response.ts`), and the router hands it to the endpoint's handler (`ApiJsonResponder`), which builds every JSON answer of the endpoint with it — its 200, its refusals, and the router's 405 and 500 for it.
|
|
206
|
+
A JSON row's `cache` applies to its 200 alone: `etag` (`jsonResponseWithETag`: an ETag computed from the body, `Cache-Control: private, max-age=0, must-revalidate`, and 304 without the body for a `GET` whose `If-None-Match` lists that ETag) for `report`, `business-date`, `search`, `reports` and `search-facets`,
|
|
207
|
+
and `no-store` (`noStoreJsonResponse`: no ETag, `Cache-Control: no-store`, `If-None-Match` not read) for `search-marks`, whose batches are cut from the rows on screen, so its URLs rarely repeat.
|
|
208
|
+
Every answer that is not a 200 — a 400, 401, 404, 405, 429, 500 or 503, of any row and of the router before it knows the endpoint — carries no ETag and `Cache-Control: no-store`, and one without a payload (the bodiless 503 of an abandoned search request) has no body: no such answer is ever answered 304, so a validator would only give the same 400 two cache semantics.
|
|
209
|
+
Every 429 of the API route is built by one refusal builder through the responder it is handed (`answerRateRefusal`).
|
|
198
210
|
The API route's refusal of data requests above follows from the same table.
|
|
199
211
|
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),
|
|
200
212
|
`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)).
|
|
@@ -207,9 +219,12 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
207
219
|
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).
|
|
208
220
|
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.
|
|
209
221
|
The slots are per worker process, so the effective bound across workers is the worker count times 32.
|
|
210
|
-
- **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,
|
|
211
|
-
A write puts its bucket in as the newest-written and evicts the oldest-written while the cache holds more than
|
|
212
|
-
|
|
222
|
+
- **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, `cacheMaxBuckets` (an integer of at least 1, 4,096 by default) bounds the buckets and `cacheMaxBytes` (an integer of at least 1, 512 MiB by default) bounds their estimated bytes, both by construction.
|
|
223
|
+
A write puts its bucket in as the newest-written and evicts the oldest-written while the cache holds more buckets than `cacheMaxBuckets` or more estimated bytes than `cacheMaxBytes`, 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).
|
|
224
|
+
The bound on buckets alone does not bound what they hold: a report's body and comments are as long as their author writes them (the action's form is their only bound: **Size** under **Request values** below), so one business date's bucket can grow to hundreds of MB.
|
|
225
|
+
A bucket's bytes are estimated once, when it is written (`estimateRecordsBytes` in `src/server/cache.ts`): 8 bytes for every value a record, array or object holds, 2 bytes per UTF-16 unit of a string (more than the engine keeps for a one-byte string, so a text an author writes is never under-estimated) and the byte length of a binary view, stopping as soon as the estimate passes the bound, so its cost follows the number of values and the walk ends on a cyclic value.
|
|
226
|
+
A bucket estimated above `cacheMaxBytes` on its own is not kept — the read still answers its records, and the key's older bucket is dropped all the same — so every kept bucket fits.
|
|
227
|
+
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). By the estimate a 110,000-row ids bucket is about 7 MB and a business date of 100 reports about 250 KB, so 512 MiB keeps about 70 ids buckets beside their dates (one viewer has one ids key). An evicted key's next read is a miss and passes the miss gate below.
|
|
213
228
|
- **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.
|
|
214
229
|
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;
|
|
215
230
|
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).
|
|
@@ -218,7 +233,7 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
218
233
|
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.
|
|
219
234
|
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.
|
|
220
235
|
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.
|
|
221
|
-
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-
|
|
236
|
+
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-columns|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-columns:<n>,report:<n>` line written by the window's own timer at its end; the refusal of an aborted request writes nothing.
|
|
222
237
|
The slots and the queues are per worker process, so the effective bounds across workers are the worker count times these.
|
|
223
238
|
- **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).
|
|
224
239
|
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)).
|
|
@@ -231,13 +246,13 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
231
246
|
- **Ids** (`parseCanonicalPositiveId`; the text readers of the query and the form are `src/shared/wire-text.ts`, and the id domain is `src/shared/ids.ts`): `reportHubId` (the `report` endpoint, and every intent but `create` and `clearCache`) and `commentId` (`deleteComment`) are only the canonical decimal form of a positive safe integer: digits without a leading zero, from 1 to 2^53 − 1.
|
|
232
247
|
`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`.
|
|
233
248
|
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`.
|
|
234
|
-
- **`forceRefresh`** (`parseForceRefreshParam`; the `business-date`, `report` and `ids-
|
|
249
|
+
- **`forceRefresh`** (`parseForceRefreshParam`; the `business-date`, `report` and `ids-columns` 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.
|
|
235
250
|
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:
|
|
236
251
|
- **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.
|
|
237
252
|
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.
|
|
238
253
|
- **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.
|
|
239
254
|
The bound is the package client's own forced reads with the action's headroom (**Rate** below): `DWELLING_PANES_HEADROOM` = 5 views × `FORCED_RESCANS_PER_VIEW` = 2 forced rescans per view and minute (the SSE `resync-required` and the development cache clear start one; a rescan that keeps failing spans more than half a window before it gives up) × `FORCED_RESCAN_ATTEMPTS` = 6 requests per rescan (the ids stream client's first attempt and its five retries, which keep `forceRefresh`); the business-date list's absence check (one forced read per date every 10 s at most) stays inside it.
|
|
240
|
-
The refusals are recorded per viewer in windows of one minute at `warn` on the `[DailyReportRefresh]` channel: the window's first as `429 endpoint=<business-date|report|ids-
|
|
255
|
+
The refusals are recorded per viewer in windows of one minute at `warn` on the `[DailyReportRefresh]` channel: the window's first as `429 endpoint=<business-date|report|ids-columns> reason=refresh_rate_limit`, the rest counted into one `refresh_rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> route=api` line at the window's end. No line names the user.
|
|
241
256
|
- **The echo id** (`clientTempId`; every intent but `clearCache`): the client makes one id per operation, sends it with the action and recognizes its own operation by it in the answer and in the SSE event. The action accepts exactly the two forms the package's client makes (`parseClientTempId`, server entry, which returns the branded type `DailyReportClientTempId`):
|
|
242
257
|
a lowercase UUID (`crypto.randomUUID()`: 8-4-4-4-12 lowercase hexadecimal digits) and a negative decimal integer without a leading zero of at most 16 digits (the optimistic temporary id of a report or a comment: one page-wide sequence draws each id strictly below the last one drawn and at most minus the wall clock's milliseconds, so two operations in the same millisecond never share one). A missing or empty id is `clientTempId required`;
|
|
243
258
|
anything else — an uppercase UUID, `-0`, `tmp-1`, surrounding whitespace, 37 characters, a text of 1 MiB — is `Invalid clientTempId`, before any service call.
|
|
@@ -280,7 +295,14 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
280
295
|
It must be deterministic — the same id always gives the same text — because the client decides that a comment is the viewer's own by comparing the viewer's id (`userId` of `DailyReportPage`, which the host's loader builds with this port) with the comment's author id (which the service writes with it), for comments that arrive by SSE too; an implementation that returns another text on every call (a fresh salt, for example) makes the viewer's own comments look like someone else's.
|
|
281
296
|
- `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5 client. If omitted, SSE publishing is skipped (with a warning) and epoch is disabled.
|
|
282
297
|
- `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
|
|
283
|
-
- `
|
|
298
|
+
- `draftLabelNames` — **Required**: the database label names that mark a draft, the canonical one first (such as `["Draft", "Work in Progress"]`, since sources may call their drafts by different names). `createDailyReportService` copies and freezes the list, and throws `[daily-report] draftLabelNames must be one or more non-blank label names; got <value>` for anything but one or more non-blank strings without an exact duplicate, an omitted key and `[]` included: the names decide who may see a report, so there is no default name to fall back on.
|
|
299
|
+
A report is a draft while it carries a label of one of these names, and every decision about a draft reads the names inside SQL at the moment it is made (`src/server/draft-visibility.ts`; the label table's collation ignores case, width and trailing spaces, so a name is never compared in JavaScript, and the process keeps no label id, which would go stale when a label is re-created):
|
|
300
|
+
every read shows a draft to its author only (one correlated `NOT EXISTS` over the report-label relation joined to the labels by name, so a report with several draft labels still reads as one row), and the audience of a draft's SSE events — its content on `report-create`, `report-update` and `report-publish`, and its comments' `comment-add` and `comment-delete` — is its author alone while it is a draft (`isDraftReport`, one indexed one-row read with the same test).
|
|
301
|
+
`createDailyReport` looks the label named `draftLabelNames[0]` up by name inside the insert's transaction and attaches it; without such a label it fails, writes nothing and names the label (a report without it would be visible to every viewer). `publishDailyReport` removes every draft-named label of the report, by name and inside its transaction, and addresses its `report-publish` event to the author alone when the report is still a draft after the transaction. `getDraftLabelId()` reads the canonical label's id by its name on every call (`null` when no label has that name).
|
|
302
|
+
A report whose creation category (`creationCategory`) is one of the names shows the canonical label in its labels, with that label's own id, read by name in the same read and only when a report of the read needs it (none when no label has the name); the read rule reads labels alone, so such a report without a draft label is not hidden.
|
|
303
|
+
The rule's shape is measured on a staging list of 228,222 reports through the package's driver (the time to the last row, one viewer for both shapes, the median of 2 runs): the full id list takes 542–661 ms alone and 1,896–2,114 ms per query with 4 viewers at once, and its first 200 rows 29–35 ms; moving the author test inside the `NOT EXISTS` answers the same rows in 579–689 ms and 2,042–2,328 ms, so the rule keeps the plan that streams its rows and uses about half the CPU.
|
|
304
|
+
Every report detail the server sends — the `report`, `business-date` and `reports` answers, the action's answers that carry a report and the SSE events' reports — carries a required `isDraft` (`DailyReportDetail.isDraft`), decided by the same definition in the read that builds the detail (a `CASE WHEN` over the shared draft-label test, `buildDraftLabelExists`, beside the hub row) or true when its creation category is one of the names; a row whose flag reads as anything but 1 or 0 throws.
|
|
305
|
+
The client compares no name: the views open the viewer's own draft in the editor by `isDraft` alone, so a draft named by any of the names, or by a variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v3`), so an entry written before `isDraft` existed is never read.
|
|
284
306
|
- `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.
|
|
285
307
|
- `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).
|
|
286
308
|
- `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).
|
|
@@ -305,12 +327,14 @@ export const loader = (args) => dailyReportServer.attachment.loader(args)
|
|
|
305
327
|
| `[DailyReportSseReader]` | `info` | the shared SSE reader's Redis errors |
|
|
306
328
|
| `[DailyReportExternalSource]` | `info` | `transformJsonArray`'s malformed JSON columns of an external source |
|
|
307
329
|
|
|
308
|
-
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),
|
|
309
|
-
|
|
330
|
+
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),
|
|
331
|
+
`[DailyReportIdsStream]` (the ids stream client: a revalidation that gave up, a rejected malformed line, which ends the attempt, a skipped line of an unknown kind, a malformed stream anchor) and `[DailyReportSseConnection]` (the SSE connection hook).
|
|
332
|
+
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). A sink injected through the ids stream client's `warn` option (`DailyReportIdsStreamClientOptions.warn`) is wrapped as that channel's logger (`createChannelLogger`), so it receives every line with the `[DailyReportIdsStream]` prefix too, the line's further arguments as they were given.
|
|
310
333
|
|
|
311
334
|
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.
|
|
312
|
-
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
|
|
313
|
-
|
|
335
|
+
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>` for an answer that carries an ETag — the 200 and 304 of an `etag` endpoint —, `<status> endpoint=<name>` for one that has no ETag to match — every answer of `search-marks` and every answer that is not a 200 —; the default `[DailyReportAPI]` console logger starts at `error`, so without a host logger the line is not written).
|
|
336
|
+
The JSON builders themselves (`jsonResponseWithETag` and `noStoreJsonResponse` in `src/server/response.ts`) write 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.
|
|
337
|
+
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `cacheDefaultTtlMs` (60s) / `cacheMaxBuckets` (4,096) / `cacheMaxBytes` (512 MiB; both **The SQL result cache** in [Server wiring](#server-wiring-di)) / `streamKey` / `streamMaxLen` / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
|
|
314
338
|
|
|
315
339
|
**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.
|
|
316
340
|
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`.
|
|
@@ -870,13 +894,15 @@ The screen can search the reports by text and filter the search by author, sourc
|
|
|
870
894
|
*
|
|
871
895
|
* The search is one optional block, `search`: the two index tables (here from `defineDailyReportSearchSchema`; the host's own models of
|
|
872
896
|
* the same shape also work), the readers of the searchable text the detail view does not show (per external source type), and the
|
|
873
|
-
* request limits. With the block, the
|
|
897
|
+
* request limits. With the block, the five save paths that change what the index holds (every save but the logical delete) rewrite the
|
|
898
|
+
* index inside their transaction (a failed index write fails the save)
|
|
874
899
|
* and the `search`, `reports`, `search-marks` and `search-facets` endpoints answer; without it they answer 404. Create the tables and run the initial
|
|
875
|
-
* reconciliation before passing the block in production: while the tables are missing, every save fails.
|
|
900
|
+
* reconciliation before passing the block in production: while the tables are missing, every save that rewrites the index fails.
|
|
876
901
|
* 検索は任意の 1 つのブロック `search` にまとまる。索引の 2 表 (ここでは `defineDailyReportSearchSchema` から。同じ形のホストのモデルでもよい)、
|
|
877
|
-
* 詳細画面に出ない検索できる文字を読む口 (外部ソースの区分ごと)
|
|
902
|
+
* 詳細画面に出ない検索できる文字を読む口 (外部ソースの区分ごと)、要求の上限。ブロックを渡すと、索引の中身を変える保存の 5 つの経路 (論理的な削除のほかの保存) が
|
|
903
|
+
* そのトランザクションの中で索引を書き直し
|
|
878
904
|
* (書けなければ保存も失敗する)、`search`・`reports`・`search-marks`・`search-facets` のエンドポイントが答える。渡さなければどれも 404。本番では、表を作って最初の
|
|
879
|
-
* 突き合わせを流してからブロックを渡す (
|
|
905
|
+
* 突き合わせを流してからブロックを渡す (表が無い間は、索引を書き直す保存がどれも失敗する)。
|
|
880
906
|
*/
|
|
881
907
|
import {
|
|
882
908
|
createDailyReportServer,
|
|
@@ -962,16 +988,20 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
962
988
|
Values the list shows alike are therefore one candidate, and every candidate is a value a filter can send unchanged.
|
|
963
989
|
**Host contract**: the author and customer display values (`employeeName` and `customerName` of each `externalSources` adapter's `mapRow`, and the `userTable`'s `displayName` of a report's creator, the author when no `employeeName` is given) must be at most 200 UTF-16 units after trimming.
|
|
964
990
|
A longer one throws `[daily-report] search index: report <id>: the author|customer display value has <n> UTF-16 units after trimming; the host must keep the author and customer display values at most 200 UTF-16 units`, which fails that report's save, and stops the reconciliation at that report's batch (rolled back; the batches before it stay written) on every run until the host shortens the value; the report id in the message names the row to fix.
|
|
965
|
-
The package writes both columns in every save and every reconciliation, so a document table without them fails
|
|
966
|
-
The
|
|
991
|
+
The package writes both columns in every save that rewrites the index (below) and every reconciliation, so a document table without them fails those saves: create them with the table, and add them (with their indexes) to an existing table before deploying a build that writes them. They are nullable, so adding them disturbs no process that does not write them yet.
|
|
992
|
+
The five save paths (create, update, publish, add and delete a comment) rewrite the report's rows inside their own transaction, after taking an update lock on the report row; a failed index write fails the save.
|
|
993
|
+
The logical delete rewrites nothing: every read (the search, the marks, the details and the candidates) leaves a deleted report out, so its document is never searched, and restoring a report is a write outside the package, which the reconciliation follows like any other. Publishing, deleting and the comment paths run in a transaction even without the `search` block, which a deadlock retries as a whole.
|
|
967
994
|
- **Reconciliation.** Writes outside the package (a sync, an import) leave the index stale until `service.reconcileSearchIndex({ batchSize, hubIds?, signal?, onProgress? })` runs.
|
|
968
995
|
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.
|
|
969
|
-
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
|
|
996
|
+
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).
|
|
997
|
+
A process computes the version once, in about a second: a service with the `search` block starts the computation when it is created, and the computation yields to the event loop before each of its steps, so it never blocks other requests for the whole of it. A save or a reconciliation that comes before it finishes waits for it (the rest of the computation) before opening its transaction; a failed computation fails the saves waiting for it, and the next save computes it again.
|
|
970
998
|
The digest covers the fields, then the comments, then the two filter values in the form the document stores (its `P` section), all as UTF-16 code units (a lone surrogate and U+FFFD differ), so a change of a filter value alone rewrites the document too, while a source value that differs only in leading or trailing whitespace does not. A document written by a build whose digest has no `P` section differs from every digest this build computes:
|
|
971
999
|
after deploying this build onto an existing index (its columns added first, under **The index**), run one reconciliation once every server that writes the database runs this build, which rewrites every document and fills the filter columns. Until then a document that no save has rewritten keeps both filter values `NULL`, so the author and customer filters never pass it and its values are no candidates (the source-type filter reads the report row and passes it).
|
|
972
1000
|
A server still on a previous build keeps writing documents without the filter values (a new document gets `NULL`, a rewritten one keeps its old values), so a document it writes after the reconciliation keeps empty or stale filter values until the next reconciliation; run it again after the last such server is gone, and a second run that reports 0 rewrites confirms that every document carries this build's values.
|
|
973
1001
|
The author and customer candidates and the author and customer filters read these stored values, not the source rows: the package's saves and the reconciliation refresh them, so a host that changes source rows outside the package (an external source's sync, an import) should run the reconciliation for those reports (`hubIds`), or the candidates and matches keep the old value until then.
|
|
974
|
-
- **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`
|
|
1002
|
+
- **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` gets 429 with one window's `Retry-After`.
|
|
1003
|
+
A search that finds the process at `concurrency` or its viewer at its share (half of it, at least 1) waits for a slot, for at most `SEARCH_WAIT_MS` = 3,200 ms (about twice the slowest search measured on staging, 1.6 s), since a released search or another view's search holds the viewer's slot until its SQL ends; past the queue or the wait bound it gets 429 with `Retry-After: 1` (the queue is described under `search-marks` below).
|
|
1004
|
+
Its order: 404 without the `search` block, the 400s below, then a viewer without an internal user answered with the plan's words and no hits, with no admission and no dataset query, then the admission with the request's signal, the search, the slot's release whatever the outcome, and the ETag'd JSON; a search whose client left gets the bodyless 503 every search route answers (under `search-marks` below).
|
|
975
1005
|
`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.
|
|
976
1006
|
A filters-only search answers `words: []`, and the client accepts an answer without keywords only for a request whose text is blank (and an answer with keywords only for one whose text is not).
|
|
977
1007
|
The filters travel as `author`, `sourceType` and `customer` (`DAILY_REPORT_API_QUERY_PARAMS.searchAuthor`, `searchSourceType` and `searchCustomer`), each parameter repeated once per value and never comma-joined, since a value may hold a comma; the client sends them in canonical order (each filter's values deduplicated and sorted by UTF-16 code units).
|
|
@@ -983,43 +1013,69 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
983
1013
|
`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.
|
|
984
1014
|
`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),
|
|
985
1015
|
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.
|
|
986
|
-
Its order is the search's: the ids (400), then a viewer without an internal user answered `{ reports: [] }` with no admission and no dataset query, then the admission with the request's signal (an aborted request takes no slot
|
|
987
|
-
`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;
|
|
1016
|
+
Its order is the search's: the ids (400), then a viewer without an internal user answered `{ reports: [] }` with no admission and no dataset query, then the admission with the request's signal (an aborted request takes no slot, returns its token and gets the bodyless 503; a read that finds the gate full or its viewer at its share waits for a slot like a search-marks batch, for at most `BATCHED_READ_WAIT_MS` = 2,000 ms, then gets 429 with `Retry-After: 1`; an empty bucket is 429 with one window's `Retry-After`), the query, and the slot's release whatever the outcome. Its refusal lines name `route=reports`.
|
|
1017
|
+
`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, deferred }` (`DailyReportSearchMarksAnswer`): in `reports`, for each requested report the viewer may read that the answer's budget holds (below), once, its id, the mask of the keywords it matches (`matchedSets`: bit i for keyword i, for an OR of two or more keywords;
|
|
988
1018
|
`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).
|
|
989
|
-
|
|
990
|
-
A field's
|
|
991
|
-
|
|
1019
|
+
In `deferred`, the ids of the readable requested reports past the budget, in the answer's order (empty when it held them all), so a requested id is in `reports`, in `deferred`, or — not readable — in neither, and never twice. `wordsDigest` is the digest of the answer's `words`.
|
|
1020
|
+
A field's marks (`DailyReportSearchFieldMarks`) are the fingerprint of the raw value they were computed on (FNV-1a 32 over its UTF-16 units) and either `ranges`, flat `[start, end, tier]` triples in UTF-16 offsets of that value,
|
|
1021
|
+
or `dense: true` without `ranges`, for a field with more ranges than `maxFieldRanges` (10,000: more marks than a reader can tell apart, whose drawing alone would add tens of ms to the client's render; a one-letter word over a report of 21,200 units marks about 2,000). The client's reader refuses a field with both, a `dense` that is not `true`, and a `deferred` id that was not requested, is listed twice or also has marks.
|
|
1022
|
+
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.
|
|
1023
|
+
An answer has a budget (`DAILY_REPORT_SEARCH_MARKS_BUDGET` in `src/server/search/search-marks.ts`): at most 500,000 ranges (`maxRanges`) and 4,000,000 raw UTF-16 units of the fields it marks (`maxUnits`).
|
|
1024
|
+
The reports are marked in the answer's order, and the first is always answered, so every request makes progress; from the first later report that would take the answer past either bound (its units are counted before a field is marked, its ranges after), every report is deferred: its fields are not marked and its id goes to `deferred`, for the client to ask again (**Arrival** under **Highlights** below).
|
|
1025
|
+
The budget holds whole a one-letter word over 100 reports of 21,200 units each (about 200,000 ranges and 2,120,000 units; 1.6–2.9 MB, against 2.2 MB of their details), while a field of `ab` repeated 500,000 times searched for `a` (500,000 ranges, 7.9 MB of JSON) is answered dense, and a 100-report request of such fields answers 4 reports and defers 96 in 718 B.
|
|
1026
|
+
The server never lists a field's occurrences: for each alternative it walks the field's runs — an alternative's overlapping occurrences merged into one run in the normalized text, a periodic word stepping over its repeated blocks, then mapped back to the raw value and widened once — and adds each run to the per-tier coverage that it reads as the triples, allocating nothing per occurrence, so a field costs its length times the alternatives however many occurrences they have
|
|
1027
|
+
(the resumable walk `createSearchMarkWalk` in `src/server/search/search-marks.ts`; `forEachSearchMarkRun` and `markSearchText` run the same walk whole). It builds a line's index text piece by piece and maps with positions only the pieces a run's boundary falls in, since the position-mapped form costs several times the plain one.
|
|
1028
|
+
A field of 1,000,000 units of one letter searched with 30 words of 1 to 30 of that letter (about 30 million overlapping occurrences) visits 1,000,029 runs, and its marks equal those of the per-occurrence definition byte for byte.
|
|
1029
|
+
**Slices**: the request's fields share one meter of synchronous work (`createSearchMarkSliceMeter`). Every step of the walk — the strip and line split of a value, a line's plan, a piece's index text, a needle's scan, a run, a mapped piece, a cut line's clusters, a block of the coverage sweep and a value's fingerprint — pays an estimated cost (`SEARCH_MARK_STEP_COST_NS`, about 1.3 to 2 times what `search-marks.bench.ts` measures on the content that makes each step slowest),
|
|
1030
|
+
and a slice starts no further step once its estimate passes 8 ms or 5 ms have passed on the process's monotonic clock (`sliceNs`, `sliceMs`; the clock is read once per 0.5 ms of estimated work, and the first step of a slice always runs, so every slice makes progress); the service then yields to the event loop (`setImmediate`) and checks the request's signal before the next slice, inside a field too.
|
|
1031
|
+
So a slice lasts about 5 ms and one step at most: on a field of 1,000,000 units the longest slice measured 6–14 ms on the thread's CPU clock (host load 9–10), against the 20 ms limit. Two steps stay whole, because the index's text pipeline runs each in one call: the strip and line split of one value, and the plan of one line (`planSearchLine`) —
|
|
1032
|
+
on 1,000,000 units of the content that makes them slowest, up to about 45–83 ms and 49–150 ms (a fullwidth `AB` line with no cut between its pieces, a line dense with whitespace), past the 20 ms limit.
|
|
992
1033
|
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,
|
|
993
1034
|
and the browser normalizes nothing (`@aiquants/fuzzy-search` stays out of the client bundle).
|
|
994
1035
|
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`.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
the
|
|
1000
|
-
|
|
1036
|
+
Like the search and `reports`, a search-marks request that finds the gate full or its viewer at its share waits for a slot (`SEARCH_ADMISSION_WAITS`; only `search-facets` refuses at once): `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` waiters per viewer and as many as the admission's concurrency in all, and a freed slot goes to the viewer holding the fewest.
|
|
1037
|
+
The two batched reads wait for at most `BATCHED_READ_WAIT_MS` = 2,000 ms (about twice the slowest search-marks request measured under load, 196–927 ms, whose read is the details' read plus the marks), the search for at most `SEARCH_WAIT_MS`.
|
|
1038
|
+
A new answer's first batches, details and marks alike, 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`.
|
|
1039
|
+
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: [], deferred: [] }` with no admission and no dataset query, then the admission with the request's signal, the marks (in slices, yielding to the event loop between slices, inside a field too), the slot's release whatever the outcome, and the JSON. It is not cached on the server either.
|
|
1040
|
+
The service takes the request's signal too (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })`) and checks it before each read and after each yield, stopping at the next slice once the client has left, between two slices of one field too.
|
|
1041
|
+
Options that are not an object carrying an `AbortSignal` stop the call before any query with a host-facing error: an omitted `options` or `signal` is a `TypeError` (`[daily-report] markDailyReportSearchHits: options.signal must be the request's AbortSignal (new AbortController().signal for a call that is never aborted)`), any other value a `RangeError` ending with `; got <value>`.
|
|
1042
|
+
The route always passes the request's signal; 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, when its slot was granted, 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 no ETag, and writes no error line;
|
|
1043
|
+
the four search routes share this answer and its checks (the search, `reports` and `search-facets` pass no signal to their services and check it when the slot is granted and when the read ends; a read that fails with an error of its own is still the 500), and the refusal of an aborted request is never recorded as busy, on any of them (`search-facets` included).
|
|
1044
|
+
Its 200 carries no ETag and `Cache-Control: no-store` (the row's `no-store` policy, `noStoreJsonResponse` in `src/server/response.ts`, serialized once, with the shared security headers), and so does every other answer of the endpoint — its 400, 404 and 429, the router's 405 and 500 for it, and that 503 —, as every answer of the API route that is not a 200 does (**The API route's methods** in [Server wiring](#server-wiring-di)): 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.
|
|
1001
1045
|
`GET {apiBasePath}/search-facets` (it reads no parameter) answers the filters' candidates for the viewer, `{ sourceTypes, authors, customers }` (`DailyReportSearchFacets`).
|
|
1002
1046
|
`sourceTypes` lists the source types a filter may name (`service.searchFilterSourceTypes`: `Internal`, then each `externalSources` adapter's, in the configured order, distinct case-insensitively: a later one that differs from an earlier one only in case is dropped, since the filter compares source types case-insensitively and would refuse both as a duplicate) that the viewer's scope allows, without a query.
|
|
1003
1047
|
`authors` and `customers` are the distinct non-`NULL` filter values of the documents of the reports the viewer may open, read with the search's own scope conditions (another user's draft, a deleted report and a hidden source type lend no value): one `GROUP BY` query per column, the two run one after the other, in the column's order (BIN2: UTF-16 code units) and with no row limit.
|
|
1004
|
-
Its order is the search's: 404 without the `search` block, then a viewer without an internal user answered `{ sourceTypes: [], authors: [], customers: [] }` with no admission and no dataset query, then the admission with the request's signal, the candidates (none, and no query, for a scope that denies every source type), the slot's release whatever the outcome, and the ETag'd JSON (304 for a matching `If-None-Match`).
|
|
1048
|
+
Its order is the search's: 404 without the `search` block, then a viewer without an internal user answered `{ sourceTypes: [], authors: [], customers: [] }` with no admission and no dataset query, then the admission with the request's signal (a full gate refuses at once with 429 and `Retry-After: 1`: the client sends one request at a time), the candidates (none, and no query, for a scope that denies every source type), the slot's release whatever the outcome, and the ETag'd JSON (304 for a matching `If-None-Match`); a request whose client left gets the bodyless 503.
|
|
1005
1049
|
It is not cached on the server, since a cache would keep offering the value of a report that has since become a draft or been deleted.
|
|
1006
1050
|
Its admission is a fourth instance from the same factory, with the search's own `concurrency` and `rateLimitPerMinute` and a bucket of its own, so the candidates still load while the viewer's searches hold their slots (a viewer holds at most half of a gate) or after they spent their bucket; its refusal lines name `route=search-facets`.
|
|
1007
|
-
- **The search box** (`config.search: {}` searches on Enter only; `{ debounceMs }`, an integer from 300 to 5000, also after typing stops; `{ renderFilterSelect }` adds the filters, **Filter row** below) is
|
|
1008
|
-
The row is
|
|
1051
|
+
- **The search box** (`config.search: {}` searches on Enter only; `{ debounceMs }`, an integer from 300 to 5000, also after typing stops; `{ renderFilterSelect }` adds the filters, **Filter row** below) is one search landmark (`<search data-testid="daily-report-search">`, `SEARCH_BOX_CLASS_NAME`) in the toolbar's band, under the view-mode toolbar: a row with the AND / OR toggle, the field, the filter toggle (with `renderFilterSelect` only) and a status line, followed by the filter row while it is open. Enter, Esc and the debounce wait while an IME composes.
|
|
1052
|
+
The filter row is inside the landmark, so its cells belong to the search for assistive technology and Tab goes from the field to the filter toggle and on into the cells; the landmark has no name (the screen has one search landmark), and the filter row's own name comes from the filter toggle.
|
|
1053
|
+
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, while the filter row is closed, 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)).
|
|
1009
1054
|
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 there is text to clear (the field holds text, or the committed search's text is not blank) and returns focus to the field.
|
|
1010
1055
|
The clear button and Esc clear the text: without a selected filter they release the search, and with filters the search goes on as a filters-only search (with an empty field it then shows no clear button, and Esc does nothing).
|
|
1011
|
-
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.
|
|
1056
|
+
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.
|
|
1057
|
+
The AND / OR toggle and the filter toggle share one look (`SEARCH_TOGGLE_FACE_CLASS_NAMES`): a 24 px pill with 12 px semibold text, in the field face (OR; the closed filter row), whose boundary comes from the text-field token as the field's does, or the filled face (AND; the open filter row), blue-700 under white text (dark blue-600) and `Highlight` under `HighlightText` in forced colours (**Text fields** in [Selection and focus appearance](#selection-and-focus-appearance)).
|
|
1058
|
+
The faces differ only in colour, so a toggle that changes its face moves nothing.
|
|
1012
1059
|
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.
|
|
1013
|
-
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.
|
|
1060
|
+
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, and an answer goes into the per-report cache only, never into a business date's cached list.
|
|
1061
|
+
The batches are decided when they are sent, by the scheduler the search marks use: from the rows still waiting, newest first, up to 100 ids a request and `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2) requests at once per API base path (an aborted request keeps its slot until its fetch settles), each sent `cache: "no-cache"` with a signal of its own.
|
|
1062
|
+
A row that unmounts leaves its load, so an id no row waits for is not sent, a request none of whose rows waits any more is aborted, and a row that comes back asks again. A report a 2xx answer lacks reads as `null` (not visible, or deleted), and another status, a body that does not match the schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status).
|
|
1063
|
+
A 429 whose `Retry-After` is at most 5 s (`ID_BATCH_MAX_RETRY_AFTER_MS`: it covers a full admission gate's `Retry-After: 1` and stays below the rate bucket's 60 s window) keeps the rows loading, with no error and no `null`, and asks once more after that delay for the rows still waiting; a second 429, or one without such a `Retry-After`, is the error.
|
|
1064
|
+
A cache clear (a user switch, or the development cache clear) aborts the requests in flight, whose answers never enter the cleared cache, cancels the retry waits and asks again for the rows still waiting.
|
|
1014
1065
|
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.
|
|
1015
1066
|
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`).
|
|
1016
1067
|
- **Filter row** (`config.search.renderFilterSelect`, a function the host provides, `DailyReportSearchFilterSelectRenderer`; a value that is not a function is a `RangeError` at render, `[daily-report] config.search.renderFilterSelect must be a function (it renders the control of each filter cell); got <value>`). Without it there is no filter toggle, no filter row and no `search-facets` request.
|
|
1017
1068
|
The filter toggle (`data-testid="daily-report-search-filters-toggle"`) is a 24 px button G after the field, sharing its top and bottom edges, named `labels.searchFiltersToggle(n)` with the number of selected values (`Filters`, `Filters (3)`), with `aria-expanded` and, while the row shows, `aria-controls`.
|
|
1018
|
-
|
|
1069
|
+
Its name does not change when the row opens, so it shows the state in the AND / OR toggle's look as well: the field face while the row is closed and the filled face while it is open (`SEARCH_FILTERS_TOGGLE_CLASS_NAMES`; `Highlight` in forced colours, where every other colour is replaced).
|
|
1070
|
+
It opens the filter row under the search row, inside the search landmark: a `<fieldset>` the toggle names (`data-testid="daily-report-search-filters"`) with one cell per filter in the order of `DAILY_REPORT_SEARCH_FILTER_FACETS` — author (`labels.author`), source type (`labels.searchFilterSourceType`) and customer (`labels.customer`); `data-testid` `daily-report-search-filter-author`, `-source-type` and `-customer` —,
|
|
1019
1071
|
one cell per line below `sm` and the three in one line at widths 2 : 1 : 3 from `sm`, and a status line (`daily-report-search-filters-status`, `aria-live="polite"`) that shows `labels.searchFiltersLoading` while the candidates load the first time, `labels.searchFiltersFailed(failure)` when loading them failed (also after a failed reload, under the previous candidates) and `labels.searchFiltersTooMany` after a refused selection, for as long as the row shows the selection that was refused (a release from outside, such as a creation during a search, or any new selection clears it).
|
|
1020
1072
|
Each cell shows its label as a visible caption above the host's control (`data-testid` `daily-report-search-filter-<facet>-caption`, a 16 px line that wraps rather than being cut, 4 px above the control) and is a `<fieldset>` whose `<legend>` is that caption, so the caption names the cell; the control receives the same label as its accessible name.
|
|
1021
|
-
The open row adds to the toolbar's band G, the cells (caption, 4 px and the host's control; below `sm` one per line, G apart) and, while it has something to say, the status line (G + 24 px or more), and the band rounds its whole height up to the block quantum as it does closed: the host's controls decide the open band's height, and the closed band is unchanged.
|
|
1073
|
+
The open row adds to the toolbar's band G, the cells (caption, 4 px and the host's control; below `sm` one per line, G apart) and, while it has something to say, the status line (G + 24 px or more), and the band rounds its whole height up to the block quantum q as it does closed: the host's controls decide the open band's height, and the closed band is unchanged.
|
|
1074
|
+
G is a multiple of q at every ratio (8 = 2 × 4 = 1 × 8), and so are the 16 px lines and the status line's 24 px at the default root, so the rounding's remainder (0 to q − 1 px) comes from the 4 px under each caption and from the host's control height. It goes between the search row and the filter row: the band is a column whose free space the landmark takes and spreads between its two rows (`VIEW_TOOLBAR_BAND_CLASS_NAME`, `SEARCH_BOX_CLASS_NAME`).
|
|
1075
|
+
So the search row does not move when the filter row opens, the search controls stand at least G and less than G + q above the captions, every gap inside the filter row stays as written, and the row's last content (the cells, or the status line while it speaks) ends at the band's bottom edge, exactly G above the view's first surface, as the closed row's controls do.
|
|
1076
|
+
The remainder does not go between a caption and its control: at q = 8 that gap would grow to as much as 11 px, past the G between stacked cells below `sm`, and a caption would read closer to the control above it.
|
|
1022
1077
|
- **Candidates**: every opening loads `search-facets` again (one request at a time: an opening while one is in flight sends none; stopped on unmount and when `apiBasePath` changes), so authors and customers that appear while the screen stays mounted become selectable and values that left the viewer's reports drop out. The previous candidates stay while the new ones load, and a reload that answers the same values in the same order keeps the very same option arrays.
|
|
1078
|
+
Each request is sent `cache: "no-cache"`: the browser revalidates its previous answer with the ETag (`If-None-Match`) and, on a 304, hands over its cached 200, so an unchanged answer's body is not sent again.
|
|
1023
1079
|
A failed first load shows only the status line and is retried at the next opening; a failed reload keeps the previous candidates and shows the failure in the status line. 401 and 403 call `onSessionExpired`; 404 shows `unavailable`, 429 `busy`, and anything else, a malformed answer included, `failed`.
|
|
1024
1080
|
The cells render once the candidates are loaded. The author and customer options are sorted with `Intl.Collator(config.locale)` (values the collation ties are ordered by UTF-16 code units) and labelled with their value; the source-type options keep the server's order and are labelled with their badge label from `sourceTypeConfigs` (the source type itself without one).
|
|
1025
1081
|
- **The host's control** receives `DailyReportSearchFilterSelectProps` — `facet`, `label`, `options` (`DailyReportSearchFilterOption`, `{ value, label }`: one array per answer, rebuilt only when the answer, `locale` or `sourceTypeConfigs` change, for controls that validate once per array), `value` (the selected values), `onChange(values)` and `locale` — and is controlled: it shows `value` and reports a new selection through `onChange`.
|
|
@@ -1035,31 +1091,45 @@ export const reconcileEveryReport = (server: DailyReportServer, signal: AbortSig
|
|
|
1035
1091
|
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)).
|
|
1036
1092
|
- **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.
|
|
1037
1093
|
- **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 filter values (a report is marked for its words alone); 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;
|
|
1038
|
-
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;
|
|
1094
|
+
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; a comment still being posted;
|
|
1095
|
+
and a field the server answered dense (**Requests** above: more ranges than 10,000), which draws plain — its row's sentence below still names its field, as matched by a typed word (a dense field's tier is not known, so it never claims a reading alone), and it never counts as a hidden match.
|
|
1039
1096
|
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).
|
|
1040
1097
|
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.
|
|
1041
1098
|
- **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.
|
|
1042
1099
|
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.
|
|
1043
|
-
|
|
1100
|
+
The weaker match does not rest on the underline's style alone, on either path a screen reader takes. Browsing the text, each reading mark carries an accessible description (`aria-description`: `labels.searchReadingMarkDescription`, en `Matched by its romaji reading`, ja `ローマ字の読みで一致`); 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.
|
|
1101
|
+
Moving through the rows with the keys, a row is announced by its focus target, whose name and description flatten the marks (a List card's author marks sit inside its primary button, whose children are presentational, and its preview reaches the button only as a description), so the row says in words what matched:
|
|
1102
|
+
one sentence per field heading the row's leaves draw a match under, in the order the view draws them — `labels.searchFieldMatched(heading)` (en `Author matched`, ja `作成者で一致`) when a typed word's mark or a dense field is among the heading's leaves, `labels.searchFieldMatchedByReading(heading)` (en `Author matched by its romaji reading`, ja `作成者がローマ字の読みで一致`) when only readings matched.
|
|
1103
|
+
The sentences are built from the leaves' registrations in the row's tally (`describeDrawnSearchMarks` in `src/client/search/drawn-search-marks-tally.ts`), each heading being the field's own label (`SEARCH_MARK_FIELD_HEADINGS`: the header's and the metadata's author are one `author`, a customer name is `customer`, every interviewer, attachment and comment key its section's heading), so they name only what the view draws; **Match indicator** below says where each view puts them.
|
|
1044
1104
|
- **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 that has words: without `config.search`, before the first answer is drawn, for a filters-only answer (it has no words, so nothing is marked and no match indicator shows), after the release and for a view rendered on its own there is none, and the DOM is the plain one.
|
|
1045
|
-
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
|
|
1105
|
+
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 through one row context (`DailyReportSearchRowContext`: the marks, their revalidation, the row's tally and the indicator's settings), which only the scope provides:
|
|
1106
|
+
a leaf, a match indicator or a List card's match description drawn outside a scope while the screen holds a store stops with `[daily-report] A search marks part is drawn outside a row's marks scope (DailyReportSearchMarksScope) while the screen holds a marks store`, so no leaf draws marks that no tally counts.
|
|
1107
|
+
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.
|
|
1108
|
+
A leaf registers in its row's tally only once the row's marks have arrived (the indicator and the description say nothing about a row without marks), so a row that mounts during a held key adds no tally work before its marks, and the tally builds its summary when it is read and tells its readers once, at the end of the task that changed it, however many leaves registered in it.
|
|
1109
|
+
A report's first subscription reaches the requests through a batching window (`SEARCH_MARKS_BATCH_WINDOW_MS` = 240 ms): outside a window it is handed on at once, together with the subscriptions of the same task, and opens a window; the subscriptions made inside the window are handed on together at its end, which opens the next window while rows keep mounting, and a row that leaves inside the window asks for nothing.
|
|
1110
|
+
A held key mounts one row per key at the overscan's edge, `VIRTUAL_SCROLL_OVERSCAN_COUNT` (15) rows and about 0.5 s of key repeats before the row shows, so a request at the window's end is answered and drawn before its rows are visible, while a hold sends about one request per window (4–5 in each 1.2 s hold of the perf harness). The epoch's renewal, a field's revalidation, a 429's retry and the deferred ids do not wait for the window.
|
|
1046
1111
|
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.
|
|
1047
1112
|
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.
|
|
1113
|
+
An aborted request keeps its slot until its fetch settles, so the requests in flight, aborted ones included, never exceed the two the server's per-viewer wait queue is sized for while it still winds the aborted ones down.
|
|
1048
1114
|
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.
|
|
1115
|
+
The ids an answer defers (`deferred`, past the server's budget) are asked again through the same scheduler, once the answer arrived in the epoch it was sent and carried at least one report: an id a row still subscribes to is queued again, any other is given back, so a row that returns asks again.
|
|
1116
|
+
The server always answers a request's first report, so every re-ask makes progress and the re-asks end with the ids asked in the epoch; a deferral without any report breaks the contract and is not asked again (those rows stay plain for the epoch), and a previous epoch's deferral is not queued (the epoch's renewal asked again for every subscribed report).
|
|
1049
1117
|
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.
|
|
1050
1118
|
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.
|
|
1051
1119
|
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;
|
|
1052
|
-
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.
|
|
1120
|
+
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 of the request — a network failure, an abort, another status, a body that is not JSON — leaves those rows plain for as long as the shown answer stays the same and reports nothing.
|
|
1121
|
+
Only the request's own failures are dropped: an error thrown by the host's `onSessionExpired`, by holding a 429's retry or by applying an answer and notifying the rows rejects the batch, and the scheduler reports it with `reportError` (the window's `error` event, where a host's error monitoring sees it) after it has freed the request's slot; a batch that rejects with its own abort's reason reports nothing.
|
|
1053
1122
|
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.
|
|
1054
1123
|
- **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;
|
|
1055
1124
|
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.
|
|
1056
|
-
The side pane's author line wraps
|
|
1125
|
+
The side pane's author line wraps, its parts 8 px apart along a line and its wrapped lines 4 px apart (`SIDE_PANE_AUTHOR_LINE_CLASS_NAME`, `gap-x-2 gap-y-1`), the label kept whole on one line; only the gap spaces the parts (the source badge has no margin of its own), so a part that wraps starts at the line's start, and 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 no slot.
|
|
1057
1126
|
- `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`.
|
|
1058
|
-
- `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
|
|
1059
|
-
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
|
|
1127
|
+
- `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 draw none of its matches — no painted mark and no dense field: a report matched only through its labels, its external texts or fields the view does not draw says why it is listed.
|
|
1128
|
+
What the view draws is read from the leaves themselves: inside the row's marks scope every leaf (`SearchMarkedText`) registers, while it is drawn and its row's marks have arrived, whether its field's marks are current for the value drawn, how many marks it paints and whether it draws a dense field, 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 no leaf paints a mark or draws a dense field.
|
|
1060
1129
|
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.
|
|
1061
|
-
- Both tokens are `aria-hidden` with a `title`, and one visually hidden element holds
|
|
1130
|
+
- Both tokens are `aria-hidden` with a `title`, and one visually hidden element holds the row's sentences, in this order: the matched keywords (`labels.searchMatchedKeywordsDescription(k, n)`: en `Matches 2 of 3 keywords`), the fields the row's leaves marked (**Highlights** above: `Subject matched by its romaji reading`), and the hidden match (`labels.searchMatchOutsideViewDescription`: en `Matched in fields this view does not show, such as labels or details`), which shows only when no field is marked or dense.
|
|
1062
1131
|
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.
|
|
1132
|
+
- A List card shows no slot. While the screen holds a marks store, its primary button's description ends, after the preview, with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the marked fields' sentence alone (empty while the card draws no match; the List does not announce a hidden match), so a card's focus announcement says what matched and whether only a reading did. Without a store the card's description is unchanged.
|
|
1063
1133
|
|
|
1064
1134
|
## Client wiring
|
|
1065
1135
|
|
|
@@ -1081,7 +1151,6 @@ export default function Route() {
|
|
|
1081
1151
|
onSessionExpired: () => navigate(0), // Required: the host decides the navigation on session expiry
|
|
1082
1152
|
renderHeader: ({ title, annotation }) => <YourHeader title={title} annotation={annotation} />,
|
|
1083
1153
|
showDevControls: import.meta.env.DEV, // Toggle SSE subscriptions, etc.
|
|
1084
|
-
draftLabelName: "Draft", // Label name for draft states (matches target DB; not tied to locale)
|
|
1085
1154
|
locale: "en", // UI chrome language: "en" (default) | "ja"
|
|
1086
1155
|
labels: { // Optional per-key overrides over the catalog of `locale`
|
|
1087
1156
|
title: "Field Reports", content: "Body",
|
|
@@ -1098,6 +1167,8 @@ export default function Route() {
|
|
|
1098
1167
|
```
|
|
1099
1168
|
|
|
1100
1169
|
`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)).
|
|
1170
|
+
The client compares no label name: the views open the viewer's own draft in the editor by the report's `isDraft`, which the server decides with its one definition of a draft (`draftLabelNames` in [DI ports](#di-ports)), so `config` carries no draft label name and `draftLabelName` is refused as an unknown key.
|
|
1171
|
+
An optimistic report the viewer creates is a draft with no labels until the server's answer brings its labels (the draft label among them); an optimistic publish clears `isDraft` at once and keeps the labels until the answer, and a failed publish restores `isDraft`.
|
|
1101
1172
|
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.
|
|
1102
1173
|
`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.
|
|
1103
1174
|
|
|
@@ -1138,8 +1209,18 @@ Each function calls the implementation of the last committed render, so a functi
|
|
|
1138
1209
|
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.
|
|
1139
1210
|
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.
|
|
1140
1211
|
|
|
1141
|
-
The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-
|
|
1142
|
-
|
|
1212
|
+
The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-columns`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
|
|
1213
|
+
Each chunk line carries its rows as columns (`DailyReportIdsStreamChunkLine`): the ids in stream order (`ids`), the business dates as runs of equal dates (`dates`, with each run's row count in `counts`) and the source types as a dictionary (`sourceTypes`, `null` for a row without one) with one index per row (`sources`).
|
|
1214
|
+
A 4,000-row line is about 37 KB, where one object per row spelled every key and date 4,000 times (about 300 KB; the 228,222-row staging list is 2,113,845 B against 17,046,814 B), and parsing it builds no object per row.
|
|
1215
|
+
The client reads every line through the one strict reader of the shared entry (`isIdsStreamChunkLine`): the line's `kind` is `"chunk"`, the column lengths agree (the ids, the sum of the counts and the indexes, and the dates with the counts), every count is at least 1, every index lies inside the dictionary and every entry is a string or `null`,
|
|
1216
|
+
every date is a canonical `YYYY-MM-DD` or `null` and no two adjacent runs share a date (the runs are canonical, as the encoder writes them), every id is a positive safe integer that appears once in the line, the rows strictly follow the stream's order (no two equal keys), `isLast` is a boolean and `nextCursor` is `null` or cursor-shaped.
|
|
1217
|
+
It adopts a line's resume fields only as the server's resume parser reads them back, through the parser's own predicates (`src/shared/ids-stream-query.ts`, internal): the cursor when it is resumable (`isResumableIdsStreamCursor`: a positive safe integer id with a canonical business date or `null`), the snapshot when it is a snapshot token (`isIdsStreamSnapshotToken`) and the declared total when it is a non-negative safe integer.
|
|
1218
|
+
A value that fails is not adopted — the line's rows are still taken, and the next resume sends the previous cursor and snapshot —, since a cursor the server refuses would answer every resume with 400 until the scan failed.
|
|
1219
|
+
It then walks the runs (`forEachIdsStreamRow`) and writes each row's values as codes into its resident rows (`ResidentReportRows` in `src/client/streaming/resident-report-rows.ts`: the ids, business-date codes and source-type codes in typed arrays, one off-heap open-addressing table from id to position, and one flag byte per position that marks the rows the scan in progress has seen), with no object, map entry or set entry per row;
|
|
1220
|
+
a row that arrives with the values it holds writes and records nothing, so a revalidation of unchanged rows allocates nothing per row.
|
|
1221
|
+
A line the reader refuses, or one that is not JSON, ends the attempt: the client warns once (`rejected a malformed line`), aborts the connection and, after its backoff, resumes from the last accepted line's cursor and snapshot; the rows of the lines accepted before it stay, and their progress resets the failure count.
|
|
1222
|
+
A line refused on every attempt ends a first scan in `failed` (a revalidation gives up with its error and keeps its data), never in `complete`, so the completion's pruning never runs after a refused line. A line whose `kind` is not `"chunk"` or `"error"`, a line without `kind` included, is skipped with a warning as a line of an unknown kind, so the wire can grow by new kinds.
|
|
1223
|
+
The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a long line does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it ends the attempt like any refused line, and the next attempt resumes from the last accepted line's cursor.
|
|
1143
1224
|
`createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
|
|
1144
1225
|
If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
|
|
1145
1226
|
`DailyReportPage` renders the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
|
|
@@ -1150,13 +1231,17 @@ If your app overrides `config.apiBasePath`, pass the same value to `createDailyR
|
|
|
1150
1231
|
The public `useDailyReportIdsStream` keeps its contract — the whole state on every publish — so a host component that calls it re-renders on every publish.
|
|
1151
1232
|
|
|
1152
1233
|
- **Rows follow the stream by its changes.** Every items publish of the session carries its revision (`itemsRevision`, never reused by another session), the number of reports it holds (`loadedCount`) and the net changes of the latest publishes, one per publish — the reports added, changed and removed — chained by revision (`itemsChanges`, the latest 32).
|
|
1153
|
-
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
|
|
1154
|
-
|
|
1234
|
+
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, with the changes still waiting in the coalescing window taken back: `DailyReportIdsStreamClient.readRows()` copies the list as of the last publish into one row batch (`DailyReportRowBatch`: the ids, business-date codes and source-type codes as typed arrays, read by `length`, `ids` and `itemAt(i)`), and `readItems()` makes it row objects.
|
|
1235
|
+
Each change carries its rows the same way, as batches (`appended`; `replaced`, the `previous` and `next` values of the changed rows, `DailyReportIdsStreamItemReplacements`; `removed`), so a publish of thousands of rows allocates a few buffers and no object per row.
|
|
1236
|
+
`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: one change as it is, two or more folded into one by the builder the client uses (`foldItemsChanges`), however many publishes a render receives.
|
|
1155
1237
|
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.
|
|
1156
|
-
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.
|
|
1157
|
-
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
|
|
1238
|
+
The rows are a persistent list (`ReportRowList` in `src/client/contexts/report-row-list.ts`): immutable columnar chunks of `REPORT_ROW_CHUNK_SIZE` = 1,024 rows (row batches) 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.
|
|
1239
|
+
A publish that appends rows — what the stream mostly delivers — writes the spine (⌈n / 1,024⌉ references) and, into one new set of columns, the last chunk's rows and the new rows, never the list (4,128 rows' values for 4,000 rows appended to 80,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, one columnar copy per run.
|
|
1240
|
+
The list holds no row objects: reading a row by position makes it through one identity cache (`REPORT_ROW_IDENTITY_CAPACITY` = 1,024 rows by id, the most recently read first), which answers the same object for the same id and values, so a mounted row keeps its identity across publishes (the views' memoized rows do not re-render) and a row whose values changed gets a new object; a walk over the whole list neither fills nor reorders the cache.
|
|
1241
|
+
Streaming 228,000 rows through the client and deriving the provider's rows from every publish grows the JavaScript heap by less than 8 MB (`src/client/streaming/resident-rows-heap.spec.ts`), with about 5 buffers allocated per 4,000-row publish: one object, map entry and set entry per row would keep about 50–60 B per row on the heap (11–14 MB), all of which the garbage collector walks and copies during a held key.
|
|
1158
1242
|
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.
|
|
1159
|
-
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 (`
|
|
1243
|
+
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 (`DailyReportRows` in `src/client/utils/report-index.ts`, exported from the client entry and spelled out nowhere else), 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.
|
|
1244
|
+
Every read of a row by position inside the package goes through `rowAt`, which answers `undefined` for a position that is no row of the list — negative, fractional, past the end — where `at`, like `Array.prototype.at`, would count a negative position from the end (a scroll anchor at −1 then restores nothing and a window compared at −1 counts as changed); `src/client/row-reads.spec.ts` reads the shipped client's types and rejects any other `.at(` on the rows, the search's selection rules included.
|
|
1160
1245
|
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.
|
|
1161
1246
|
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`).
|
|
1162
1247
|
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).
|
|
@@ -1189,7 +1274,9 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
1189
1274
|
`VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
|
|
1190
1275
|
It adds nothing — the surface sits exactly G from the edge — when the view's edge and the row slots are whole numbers of device pixels.
|
|
1191
1276
|
The slots are whole device pixels at every ratio of the device-pixel lattice, every multiple of 1/8 from 1 to 3 (the List slot where it is a multiple of 8, as at the default 16 px root; **Row slots on the lattice**): at the integer ratios a view's end on a whole CSS pixel is on a device pixel too, so the bottom-aligned surface sits exactly G there, and at the other quarter ratios (1.25, 1.5, 1.75, 2.25, 2.5, 2.75) it does when the view's end lies on the 4 px lattice.
|
|
1192
|
-
Anywhere else — a view's end off the lattice at those ratios, or browser zoom such as 0.9, 1.1 or 1.33 — the snap keeps the surface at least G and less than G + 1 device pixel from the end (measured in Chromium
|
|
1277
|
+
Anywhere else — a view's end off the lattice at those ratios, or browser zoom such as 0.9, 1.1 or 1.33 — the snap keeps the surface at least G and less than G + 1 device pixel from the end (measured in Chromium 148 under a 32 px header and a 20 px footer, List / DetailList, each sample with the window's CSS height and the view's height V: at 1.25, 801 px and V = 681, 1 px off the lattice, 8.2 / 8.2 px; at 1.5, 803 and 683, 3 px off, 8.333 / 8.333;
|
|
1278
|
+
at 1.75, 801 and 681, 8.428 / 8.429; at 2.25, 802 and 682, 8.222 / 8.222; at 2.75, 799 and 679, 8.091 / 8.091;
|
|
1279
|
+
under zoom, 8.818 / 8.455 at 1.1 (799 and 679), 8.383 / 8.594 at 1.33 (767 and 647) and 9.000 / 8.333 at 0.9 (801 and 681), each below G + 1 device pixel of its ratio; exactly 8 at 1, 2 and 3).
|
|
1193
1280
|
The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the block quantum q of the device-pixel lattice (**Row slots on the lattice**): 4 px wherever 4 CSS px is a whole number of device pixels, 8 px at the odd multiples of 1/8, where 4 px is half a device pixel.
|
|
1194
1281
|
The client entry exports the hook that carries q, `DAILY_REPORT_LATTICE_BLOCK_QUANTUM_CLASS_NAME` (the package's own class, not a copy): put on the document root, it defines the custom property `--aqdr-lattice-block` for every element, and a bar whose content decides its height rounds it up to that value (`height: calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`).
|
|
1195
1282
|
The column's top inset is q and the toolbar's band rounds up to q (**Origin on the lattice**), so with the host's bars on q the views' top edge lies on the lattice at every ratio of the domain. `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px) is the unit q is built from, and a bar rounded to it alone ends half a device pixel off at the odd eighths.
|
|
@@ -1305,6 +1392,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1305
1392
|
the anchor yields only to a pending selection reveal (a DetailList selection waiting for its report; a key move needs no precedence, since its scroll, its destination's render and the focus all end inside its own commit); and when the anchor report itself is removed, the first remaining report that followed it on screen is shown at its offset.
|
|
1306
1393
|
- **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
|
|
1307
1394
|
On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new `data-report-id` in the key's own render and carries `aria-busy="true"` until the content catches up, and the content (`data-displayed-report-id`) renders from `useDeferredValue` of the selection, so React draws it when the main thread is free and drops an unfinished catch-up for a newer key.
|
|
1395
|
+
The pane reads the selection from the view's selection store (`keyboard/list-selection-store.ts`), which the key, activation and click paths write in the commit that asks the host to select and which a layout effect realigns to the host's committed selection after every such write, so when the host refuses a selection — the same one twice in a row included — the pane shows the report the List selects, never the refused one.
|
|
1308
1396
|
While a held key repeats, the content keeps the report the repeat stream started from and does not render at all (the frame keeps following the selection, with `aria-busy="true"`); it catches up once, when the stream ends.
|
|
1309
1397
|
The content is keyed by the displayed report: each change of the displayed report mounts it exactly once, so nothing of one report (its loaded detail, an open editor, its comments, its thumbnails) renders under the next, and coming back to a report mounts its attachments once (one conditional request per loaded thumbnail) and shows an editor it left open as it was left, with what was typed (**Editing** below). The chosen tab (article or relations) belongs to the pane, and to the mobile overlay, and is kept across reports.
|
|
1310
1398
|
Auto-read and the article tab's scroll to the top follow the displayed report. Auto-read marks an unread report read once the pane has dwelt on it for 500 ms: on the desktop layout while the pane has caught up with the selection (the displayed report is the selected one and the pane is not `aria-busy`), in the mobile overlay while the overlay is open. The clock stops when the pane stops dwelling (a newer key, a held key's stream, the overlay sliding out)
|
|
@@ -1321,7 +1409,8 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
1321
1409
|
- **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.
|
|
1322
1410
|
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).
|
|
1323
1411
|
The sections inside a report — customer, subject, content, attendees and comments in the DetailList card; content and comments in the side pane; attachments in both — are headings one level lower.
|
|
1324
|
-
Every label and its value is a pair of its own (`dt` / `dd` inside a `dl
|
|
1412
|
+
Every label and its value is a pair of its own (`dt` / `dd` inside a `dl`), and no value repeats its label as text (no `区分: 日報`): the DetailList metadata column (author, created at, updated by, updated at, business date, and the visit time, the category and the creation category, each when present) and the side pane's subject, customer and visit time; both attachment lists are named by their section heading.
|
|
1413
|
+
The header chips and the side pane draw each pair with one `MetadataField` primitive (a `div` around the `dt` and the `dd`); the metadata column, a dozen pairs on every mounted DetailList row, puts its `dt` and `dd` straight into its `dl` with no wrapper, spaced by their own margins (8 px between pairs, 4 px from a label to its value), so a row mounts fewer boxes.
|
|
1325
1414
|
The DetailList header's chips are one `dl` too: ID, business date, author, visit time, updated at, and the category and the creation category when present, each label once; the header's markers and buttons sit outside the list.
|
|
1326
1415
|
Every ISO date of a DetailList card — the header chips' business date and updated at, the metadata column's created at, updated at and business date — is a `<time datetime>` carrying that date, through one component (`DateText` in `src/client/components/detail-list/date-text.tsx`), so a row never exposes the same date with two semantics; a missing date's `-` stays text.
|
|
1327
1416
|
The same component decides the visible form, one rule for every caller: a date-time shows as `YYYY-MM-DD HH:mm` — the date, one space, the hours and minutes, the seconds cut rather than rounded (`2026-12-31T23:59:59` shows `2026-12-31 23:59`) — while its `datetime` keeps the full ISO value, and a date alone shows as it is.
|
|
@@ -1396,7 +1485,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1396
1485
|
Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
|
|
1397
1486
|
The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the block quantum q (**Row slots on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list changes**); measuring the frame would read back the box it fills. A held body is not measured, and a body that is replaced (another load state, a released hold) is observed in its place.
|
|
1398
1487
|
A frame with layout and size containment that is neither a flex nor a grid item is a relayout boundary in Chromium. Neither frame contains paint, because the hover `shadow-lg` of an unselected surface (22 px below, 12 px to the sides) reaches beyond the 8 px gutter G that paint containment would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
|
|
1399
|
-
Scrolling repaints rows only where `VirtualScroll` shifts its rendering window. A scroll step that mounts no row writes only the items wrapper's `transform` and repaints no row; a step that mounts one makes Chromium re-centre the area it paints the wrapper's layer in (`will-change: transform`), and every kept row whose content clips its own overflow repaints
|
|
1488
|
+
Scrolling repaints rows only where `VirtualScroll` shifts its rendering window. A scroll step that mounts no row writes only the items wrapper's `transform` and repaints no row; a step that mounts one makes Chromium re-centre the area it paints the wrapper's layer in (`will-change: transform`), and every kept row whose content clips its own overflow repaints:
|
|
1489
|
+
a List card does (its preview's line clamp clips), while a DetailList row clips only inside boxes with a paint layer of their own (the attachment section's size notes and thumbnail skins; its fitted file names clip nothing, **File name** in [Attachment display](#attachment-display)), so a window-shifting step repaints the 2 rows around the row it mounts, with attachments or without (Chromium 148, cards of 952 and 496 px).
|
|
1490
|
+
`@aiquants/virtualscroll`'s README ("What a scroll step paints") gives the cost of a one-row shift in Chromium 148: 36 layers with 160 px rows and 26 with 448 px rows, Paint 0.99 and 0.66 ms.
|
|
1400
1491
|
Containing paint would not avoid it (an overflow clip inside a row still marks the row as clipped by that area) and would clip the hover shadow; a `perspective` on the wrapper would avoid it, but it composites the rows under a non-2D transform that Chromium resamples at some device-pixel ratios (at 1.25 the band between the ring and the outline blends). Neither is used, so the strokes below stay whole device pixels.
|
|
1401
1492
|
Both side-pane tab panels (article and relations) are one scroll box, `contain: strict`: its size comes from the pane's column and, as a scroll container, it already clips, so its content never restyles or repaints the pane around it and cannot change the panel's size.
|
|
1402
1493
|
The panel is not a relayout boundary in Chromium (it is a flex item), so a layout inside it still starts at the document root and walks the dirty chain of its ancestors; the tile frames inside the panel and the rows are boundaries of their own whose own style never changes, so an image's size arriving on load and the waiting pulse starting or stopping lay out inside the frame alone (**Frame** in [Attachment display](#attachment-display)).
|
|
@@ -1434,8 +1525,10 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1434
1525
|
| Error text / overlay panel (the side pane's content on a phone) | 6.42 | 6.17 | 4.5 |
|
|
1435
1526
|
| Error text / the load-error panel (the card surface over the page frame) | 6.42 | 6.45 | 4.5 |
|
|
1436
1527
|
|
|
1437
|
-
- **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).
|
|
1438
|
-
|
|
1528
|
+
- **Text fields**: the comment input, the edit form's `Input` and `Textarea` and the search field, and the outline of the search band's toggles in their field face (the OR toggle and the closed filter 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).
|
|
1529
|
+
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`.
|
|
1530
|
+
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.
|
|
1531
|
+
The toggles' filled face (AND, and the open filter toggle) is a blue-700 fill (dark blue-600), whose edge is its boundary (6.52:1 on the page, dark 3.84:1), under white text (6.82:1, dark 5.25:1); it stands 6.82:1 (dark 3.40:1) from the field face's fill, so open and closed differ in more than hue, and forced colours draw it as `Highlight` under `HighlightText` while the field face keeps the forced button colours.
|
|
1439
1532
|
`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.
|
|
1440
1533
|
|
|
1441
1534
|
- **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.
|
|
@@ -1445,6 +1538,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1445
1538
|
in the side pane it reserves three reading lines while its report loads, `READING_PANEL_RESERVE_CLASS_NAME` = `min-h-24`, the inset twice and three line boxes of its own `p-3` and `leading-6`: 12 + 3 × 24 + 12 = 96 px at the 16 px root, and its skeleton is three 24 px line boxes, `READING_SKELETON_LINE_CLASS_NAME`, each centring a 16 px bar on the text line it stands for, so a report of up to three lines keeps the panel's height when it loads, at every root font size).
|
|
1446
1539
|
The scroll bar's business-day bubble is the bordered pill (`BORDERED_PILL_CLASS_NAME`: 24 px tall, its text 12 px in = 1 px border + 11 px padding, the radius of its round ends). `src/client/ui/border-style.spec.ts` checks these rules, a source scan included.
|
|
1447
1540
|
- **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on a thumbnail frame's skin, only while the frame may show its load activity, see [Attachment display](#attachment-display)), the switch's thumb, the entrance of the comment delete confirmation and of the error notice, the mobile overlay's slide and its backdrop.
|
|
1541
|
+
An animation that never ends keeps the compositor drawing about 60 frames a second with no input, and a held key's frame waits behind those draws (with a pulse on the ids stream's progress pill, held keys waited 8.6–16.2 ms at p95 before their draw in every condition measured while the stream delivered, and about 0.2 ms without it).
|
|
1542
|
+
So only four run until their state ends, each only while something loads or reconnects and none behind a measured key hold: the spinning icon of the stream's retrying and revalidating badges, the List row's loading skeleton, the side pane's loading skeleton (its author and article lines) and the waiting pulse of a visible thumbnail frame's skin.
|
|
1543
|
+
The progress pill's dot stands still: the count and the percentage beside it change on every publish. `src/client/ui/motion-policy.spec.ts` allows exactly those places, each with its reason and its count of class positions, in the sources and in the shipped CSS (an `--animate-*` value with `infinite`, or `animation-iteration-count: infinite`), so a new animation that never ends fails until it is listed with a reason.
|
|
1448
1544
|
The one exception is the mobile overlay layer: its right edge follows the host's pinned-chrome offset (`--aqdr-overlay-right`) over 0.5 s, also under `motion-safe`, in step with the host's own transitions.
|
|
1449
1545
|
State surfaces never animate their opacity: a row's surface (`data-daily-report-row-surface`, which paints the selection ring and the focus outline) and its ancestors up to the row frame stay opaque and still: the List's loading skeleton pulses an element inside its surface (the DetailList's skeleton does not pulse), so a selected or focused loading row keeps both indicators at 3:1 or more at the pulse's trough.
|
|
1450
1546
|
Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
|
|
@@ -1466,7 +1562,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1466
1562
|
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)).
|
|
1467
1563
|
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.
|
|
1468
1564
|
**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).
|
|
1469
|
-
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.
|
|
1565
|
+
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 contains its own layout) 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.
|
|
1470
1566
|
- **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.
|
|
1471
1567
|
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.
|
|
1472
1568
|
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.
|
|
@@ -1483,6 +1579,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1483
1579
|
The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
|
|
1484
1580
|
Two pointer targets there fall short of WCAG 2.5.8 (a target of at least 24 × 24 px, or one whose 24 px circle meets no other target and no other such circle): the resize handle's 10 px hit area, centred in the 16 px channel 3 px from the scroll bar and 3 px from the pane, and the view's 8 px scroll bar.
|
|
1485
1581
|
The centres of their 24 px circles are 12 px apart (the handle's on the panel boundary, the bar's 12 px before it), so the circles intersect and the spacing exception does not apply either. Both keep the specified frame — the 2G channel and the 8 px scroll bar of both views — and no wider construction has been decided yet.
|
|
1582
|
+
The splitter also falls short of WCAG 2.5.7 (Dragging Movements, AA): the handle moves the boundary by dragging or by the arrow keys, and a keyboard path does not satisfy 2.5.7, which asks that a single pointer can do it without dragging (a click or a tap). `@aiquants/resize-panels` 2.1.0 has no such path on the handle: a press and release without movement does nothing.
|
|
1583
|
+
The step belongs in the handle's own gesture model, which every consumer of the handle shares, so it is handed over to resize-panels as an issue (an optional `activationSizes` prop that a click under the drag threshold steps through, to which the List will pass its panel's minimum, default and maximum widths, 160, 360 and 520 px); until it ships, a pointer user who cannot drag keeps the List panel's width.
|
|
1486
1584
|
The side-pane panel is at least 200 px wide (`SIDE_PANE_PANEL_MIN_WIDTH_PX`): the frame around its centred selection prompt — 2G = 16 (the pane sits G inside its panel), the card surface's insets 2 × 16 and the placeholder panel's insets 2 × 12 (`PLACEHOLDER_PANEL_INSET_PX`), 72 in all — plus the prompt's longest phrase at the default 16 px root, 「選択してください。」 (9 full-width glyphs of `text-sm`, 9 × 0.875 rem = 126 px), is 198, rounded up to the 4 px lattice.
|
|
1487
1585
|
At the default root the ja prompt therefore breaks only between phrases and leaves no one-glyph line; at a larger root its phrases widen and the prompt breaks inside one without crossing its frame (**Line breaks of wrapping labels** below). The List panel keeps its own minimum of 160 px, and the two minimums and the 10 px handle (370 px) fit in the List's two-column minimum of 460 px.
|
|
1488
1586
|
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.
|
|
@@ -1512,13 +1610,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1512
1610
|
- **Grid**: the grid sits alone in its own size container (`data-testid="daily-report-attachment-grid-container"`, an inline-size container that is also the grid's containing block), so one width W, the container's content width, decides the number of columns, the grid's width and the track width the names are fitted to (**File name**, below).
|
|
1513
1611
|
The number of columns is n(W) = 1 + ⌊(W + 16) / 256⌋ up to 5 (breakpoints at 240, 496, 752 and 1,008 px; 256 = the largest frame width 240 + the 16 px gap).
|
|
1514
1612
|
**Every track is a whole pixel**: at n columns the grid is the widest n · t + 16 (n − 1) ≤ W whose track t is a whole pixel, written beside each breakpoint's column count (`calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))`, `round(down, 100%, 1px)` for one column; the tracks stay `1fr`).
|
|
1515
|
-
So every track is t(W) = ⌊(min(W, 1,264) − 16 (n − 1)) / n⌋, every gap is exactly 16 px, and the remainder W − (n · t + 16 (n − 1)), 0 to n − 1 px for a whole-pixel W (below n px for a fractional one), is left at the inline end, outside the grid. A fractional track (
|
|
1613
|
+
So every track is t(W) = ⌊(min(W, 1,264) − 16 (n − 1)) / n⌋, every gap is exactly 16 px, and the remainder W − (n · t + 16 (n − 1)), 0 to n − 1 px for a whole-pixel W (below n px for a fractional one), is left at the inline end, outside the grid. A fractional track (234⅔ px at W = 736, the side pane's content with overlay scroll bars below) would put tile edges between pixels and paint the gaps 16 and 17 px wide at a device pixel ratio of 1.
|
|
1516
1614
|
Every tile edge then sits a whole number of CSS pixels from the grid's start, so it lands on a device pixel at an integer ratio (1, 2, 3) whenever the start does; at a fractional ratio (1.25, 1.5, 1.75) an edge does when its offset is a multiple of 4 px (5, 6 and 7 device pixels there), so all of them do when t is a multiple of 4 (196 below), but not in general (231 at 1.25) — the same bound as the frame's whole-pixel height (**Frame**, below).
|
|
1517
1615
|
Chrome 121–124, inside the floor, drop the width declarations with `round()` and keep the fluid tracks (W − 16 (n − 1)) / n with the same column counts (see the browser floor table).
|
|
1518
1616
|
The grid itself is at most 1,264 px wide, the width at which 5 tracks are exactly 240 px (1,264 − 4 × 16 = 5 × 240, so the cap leaves no remainder), so a track t is never wider than 240 px (t ≤ 240, equal only at the cap) inside any container, however wide, and at a device pixel ratio up to 2 a thumbnail is never upscaled.
|
|
1519
1617
|
For example, at 1280 × 800 and at 1920 × 1080 the DetailList card's content column is W = 1,136 (the rows, beside the 8 px scroll bar) − 2 × 8 (G) − 2 × (1 + 15) (the card's border and padding) − 16 − 240 (the gap and the metadata column) = 832 px, which holds 4 tracks of 196 px with nothing left over.
|
|
1520
|
-
The side pane's grid sits in the pane's scroll box, whose stable scroll-bar gutter g is taken whatever the content's height (the
|
|
1521
|
-
n = 3 at any scroll-bar width (W stays between 496 and 752), with t = ⌊(736 − 32) / 3⌋ = 234 for overlay scroll bars (2 px left over) and ⌊(
|
|
1618
|
+
The side pane's grid sits in the pane's scroll box, whose stable scroll-bar gutter g is taken whatever the content's height (the platform's thin classic scroll-bar width, `scrollbar-width: thin`: 11 px in Chromium 148 on Linux; 0 for overlay scroll bars), and whose focus reach (`-mx-[4px] px-[4px]`) leaves the content's width unchanged. In a 1,144 px content column with the List panel at its default 360 px the pane's content is 1,144 − 360 − 2 × 8 (the detail panel's G) − 2 × (1 + 15) (the pane's border and padding) = 736 px, so W = 736 − g:
|
|
1619
|
+
n = 3 at any scroll-bar width (W stays between 496 and 752), with t = ⌊(736 − 32) / 3⌋ = 234 for overlay scroll bars (2 px left over) and W = 736 − 11 = 725, t = ⌊(725 − 32) / 3⌋ = 231 with nothing left over beside Chromium's 11 px gutter (measured: a 725 px grid of three 231 px tracks). The 390 px phone overlay's pane is 358 px wide, W = 358 − g: 2 tracks of 171 px (overlay scroll bars, as measured in a mobile viewport) or of ⌊(347 − 16) / 2⌋ = 165 px beside an 11 px gutter.
|
|
1522
1620
|
Every native scroll container that holds a grid reserves its scroll-bar gutter (the side pane's scroll box; the views' rows sit beside `VirtualScroll`'s own scroll bar of fixed width), so a grid's track width never depends on whether its container overflows.
|
|
1523
1621
|
Every length of the grid — the breakpoints, the cap, the gap and the gaps inside the width expressions — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
|
|
1524
1622
|
- **Tile**: one link to the original wraps the thumbnail frame and the file name; below it one line holds the size and the download link. The frame is decorative (`aria-hidden="true"`, no link of its own), so the tile link is named by the file name and a tile has two Tab stops (the preview, then the download). The download link has a 24 px hit area, and its accessible name is the download label followed by the file name.
|
|
@@ -1527,18 +1625,31 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1527
1625
|
Every tile is then ⌈h / q⌉ · q + 48 px tall, a whole number of device pixels at every ratio of the domain, so every tile row top and the grid's height stay on whole device pixels, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks above, t = 196 gives h = 131 and δ = 1 at q = 4 (5 at q = 8), 171 gives 114 and 2 (6), 231 gives 154 and 2 (6), and 234 gives 156 and 0 (4).
|
|
1528
1626
|
The quantum is the view root's inherited custom property, so a tile renders inside a view (the List's side pane and the DetailList card do); measured at 412 × 915 and 2.625, the mobile overlay's tile rows step by 192 CSS px = 504 device pixels (a 4 px pad stepped by 188 = 493.5).
|
|
1529
1627
|
A `var()` declaration is resolved at computed-value time, so where the property is not defined, and on Chrome 121–124, which lack `round()`, the margin is its initial 0 (the frame height is dropped as well there), and a tile stays its frame's height + 48 px tall.
|
|
1530
|
-
- **File name**: a long name is shortened in its stem and keeps its extension visible, on one line.
|
|
1628
|
+
- **File name**: a long name is shortened in its stem and keeps its extension visible, on one line.
|
|
1629
|
+
In a tile grid and in an attachment row the name is fitted exactly to its name track: the longest start of the stem that still fits, an ellipsis that touches the extension, then the extension, drawn as one run that clips nothing (so the ellipsis never floats a glyph's width away from the extension).
|
|
1630
|
+
The fitted text, with the letter and word spacing present at the fit, is never wider than the track, at any width (at a track too narrow even for the ellipsis and the extension, that form is cut to what fits); a glyph's ink may reach less than 1 px past the box and is drawn.
|
|
1531
1631
|
The extension is kept whole up to half the track; a longer one keeps the start that fits in half the track plus an ellipsis. The names are cut between code points of the NFC-normalized name, not between graphemes, so a combining sequence or a joined emoji at the cut can be split (`Intl.Segmenter` is above the browser floor).
|
|
1532
|
-
One `ResizeObserver` per document watches every grid's size container in it (created from the document's own window, disconnected when its last
|
|
1632
|
+
One `ResizeObserver` per document watches every grid's size container and every attachment row's name track in it (`observeNameTrackWidth`; created from the document's own window, disconnected when its last track stops) and supplies the track width:
|
|
1633
|
+
a grid's computed from the container's content width W with the track formula above, not by measuring tiles, and a row's the content width of its name track, the box that stretches over the row's width left beside the download link (`daily-report-attachment-name-track`), not the narrower column of the name and the size note inside it.
|
|
1634
|
+
It reads the container, not the grid: the grid's own width leaves the remainder out and does not tell its column count (a 3-column grid at W = 496 is 494 px wide, as wide as a 2-column grid at W = 494).
|
|
1533
1635
|
A notification only reads its records: it keeps each grid's track width and asks for one animation frame when a track width differs from the one last handed out. It never writes the DOM, never commits and never reads layout, so nothing done during a delivery can resize an element that any observer watches — the grid's container, or a virtual-scroll row around it — and the browser never reports a ResizeObserver loop.
|
|
1534
|
-
In that frame, before its style and layout, every grid whose track width changed receives it and re-fits its names, all in one `flushSync` however many
|
|
1535
|
-
The frames never wait for this: CSS sizes them in the layout that sizes the tracks (**Frame**, below). Only the names do, so a grid's first frame draws its long names with the CSS truncation described below, and the next frame draws them fitted.
|
|
1536
|
-
The text is measured with one canvas `measureText` per document in the names' computed font
|
|
1636
|
+
In that frame, before its style and layout, every grid and row whose track width changed receives it and re-fits its names, all in one `flushSync` however many tracks changed. A notification with an unchanged track width (a height-only change, or a new width with the same track, such as 150 px in one column and 316 px in two) or a width of 0 (an ancestor with `display: none`) hands out nothing, a width that changes back before the frame cancels its own hand-out, and a grid hidden and shown again at the same width does not re-render.
|
|
1637
|
+
The frames never wait for this: CSS sizes them in the layout that sizes the tracks (**Frame**, below). Only the names do, so a grid's or a row's first frame draws its long names with the CSS truncation described below, and the next frame draws them fitted.
|
|
1638
|
+
The text is measured with one canvas `measureText` per document in the names' computed font, and the names' computed letter and word spacing is added by arithmetic (no forced layout; canvas `letterSpacing` and `wordSpacing` are above the Safari 17.4 floor): the letter spacing once per code point, the word spacing once per word-separator character (the CSS Text word separators, plus tab, line feed, form feed and carriage return), and a negative spacing counts as 0.
|
|
1639
|
+
The browser adds letter spacing per grapheme and word spacing per separator it draws (Chromium 148: U+0020 and U+00A0, none for a collapsed space), so neither count falls below what is drawn and the measure is an upper bound of the drawn width: exact for Latin, CJK and emoji, wider only where that is safe (decomposed kana, collapsed spaces).
|
|
1640
|
+
A user's text-spacing override (WCAG 1.4.12: letter spacing 0.12 em and word spacing 0.16 em, px lengths in the computed values) that is present when the names are fitted is therefore measured, and its names stay inside their tracks.
|
|
1641
|
+
The spacing is counted only when its computed value is `normal` or a px length; any other form (a percentage, or a `calc()` or `max()` that holds one, which Chromium 148 keeps in the computed value) cannot be measured, so that track's names are drawn with the CSS truncation below.
|
|
1642
|
+
There is one measuring function per font string and counted spacing, so a fit whose width and measure are unchanged keeps its identity and no name re-renders. One `loadingdone` listener per document (on `document.fonts`, added after the first fit) re-fits only the grids whose names a loaded face can draw — the face's family is one of the names' families and its `unicode-range` covers a character of a name, of its NFC form or the ellipsis (an unreadable range counts as covering)
|
|
1537
1643
|
— after dropping that font's measure, since the glyph widths change while the font string stays the same; other font loads re-render nothing.
|
|
1538
|
-
|
|
1644
|
+
Until its track's width is known (its first frame, and the server's render), and on a track whose spacing cannot be counted, a name truncates with CSS: the stem ellipsizes and the extension, kept up to half the width, follows it.
|
|
1539
1645
|
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.
|
|
1540
|
-
Every hidden copy of a file name, the download link's included, is `HiddenFileName`: `VISUALLY_HIDDEN_CLASS_NAME
|
|
1541
|
-
|
|
1646
|
+
Every hidden copy of a file name, the download link's included, is `HiddenFileName`: `VISUALLY_HIDDEN_CLASS_NAME`, which carries `contain: layout` itself. 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).
|
|
1647
|
+
Once the names are fitted, every box of the section that clips sits in a paint layer of its own: the size note (`SIZE_NOTE_CLASS_NAME`, `relative truncate`: a positioned box, which moves nothing) and the thumbnail skin's rounded clip (an absolutely positioned box).
|
|
1648
|
+
Chromium repaints a virtualized row on a window-shifting step for a clipping box without a layer of its own, not for a clip inside one whose content fits, so a DetailList row with attachments repaints on such a step like a plain row (Chromium 148: 2 rows per step on cards of 952 and 496 px; one clipping box without a layer made it 9).
|
|
1649
|
+
**Limit**: a size note that does not fit its line keeps its clipped ellipsis, and its row then repaints on a window-shifting step (Chromium 148: 10 rows per step on a 260 px card, whose two-column tiles leave 122 px tracks, where `512.0 KB` overflows): the note is set in tabular figures, which canvas `measureText` cannot measure, so it cannot be fitted like a name.
|
|
1650
|
+
**Limit**: a spacing override applied after the names were fitted changes no track width, so nothing fits them again until the next re-fit (a track width change, a web font load, or the row entering the rendered window again); until then a fitted name is drawn wider than it was measured and can reach into its neighbour (in the Chromium 148 probe, 2 of 7 names, by up to 64.2 px).
|
|
1651
|
+
The next re-fit measures the spacing again: the host app's visual contract applies the WCAG 1.4.12 override after the fit, resizes the window once and finds every tile and row name of a DetailList row and of the side pane fitted again inside its own track, a shortened name keeping the ellipsis against its whole extension.
|
|
1652
|
+
No clip bounds it: a clip around the fitted run, positioned or not, makes Chromium repaint every row with attachments on a window-shifting step (8 rows per step instead of 2), and `overflow-clip-margin`, which Safari 17.4 lacks, keeps the 2 rows for upright names only.
|
|
1542
1653
|
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;
|
|
1543
1654
|
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.
|
|
1544
1655
|
The visually hidden copy, `title` and `data-attachment-file-name` stay plain.
|
|
@@ -1547,7 +1658,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1547
1658
|
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.
|
|
1548
1659
|
- **Frame**: as wide as its track and h(t) = round(2t / 3) whole CSS px tall — the ratio of the variant box (480 × 320 for `tile`), rounded to the nearest pixel. CSS computes it, in the same layout that sizes the track: each frame sits in its tile, whose root is the inline-size container exactly as wide as its track (so `100cqi` is t; the frame's parent is a plain block wrapper, not a container), and has the style `aspect-ratio: 480 / 320; height: round(nearest, 100cqi * 320 / 480, 1px); contain: strict`, the sizes built from the variant box (`ATTACHMENT_TILE_FRAME_STYLE`).
|
|
1549
1660
|
No script measures or writes a frame, so its height is right from the first frame, a width change resizes the frames in the layout that changes the width, and every row of a grid draws its frames with the same number of device pixels at an integer device pixel ratio (a fractional height such as 130.67 px, 2t / 3 at t = 196, would give rows of 130 and 131; at a fractional ratio such as 1.25 or 1.5 the product can still be fractional).
|
|
1550
|
-
The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234 → 156, 231 → 154, 171 → 114 and
|
|
1661
|
+
The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234 → 156, 231 → 154, 171 → 114 and 165 → 110 (each tile adds the lattice pad under its last line, **Tile** above). On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
|
|
1551
1662
|
The height depends only on the track and stays the same before, during and after loading and after a failure (virtual-scroll row heights do not shift).
|
|
1552
1663
|
The frame paints nothing itself: its only child, the **skin** (`data-testid="daily-report-attachment-thumbnail-skin"`), is an absolutely positioned box that fills it and paints every state — the background, the waiting pulse, the rounded clip, the inner rim — and holds the image or the failure message.
|
|
1553
1664
|
The image keeps its aspect ratio inside the skin and is not enlarged; it has no corner radius of its own, and the skin's rounded clip (`overflow: hidden`) alone makes the corners, so an image narrower or shorter than the frame shows no notches of the background where it meets the straight edges (the frame does not clip the corners as well: a second clip at the same edge anti-aliases the corner pixels twice and lightens them).
|
|
@@ -1582,6 +1693,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
1582
1693
|
| `daily-report-attachment-grid` | `<ul>` of tiles (only when at least one attachment has a thumbnail) |
|
|
1583
1694
|
| `daily-report-attachment-rows` | `<ul>` of rows (only when at least one attachment has no thumbnail) |
|
|
1584
1695
|
| `daily-report-attachment-item` | One attachment (tile or row), with `data-attachment-state` = `present` / `absent` / `unknown` |
|
|
1696
|
+
| `daily-report-attachment-name-track` | An attachment row's name track: the box that stretches over the row's width left beside the download link; its content width is the width the row's name is fitted to, and the width observer watches it |
|
|
1585
1697
|
| `daily-report-attachment-link` | Link to the original: the tile link (wrapping the frame and the name) or a row's name link |
|
|
1586
1698
|
| `daily-report-attachment-name` | Struck-through name of a missing attachment (no link) |
|
|
1587
1699
|
| `daily-report-attachment-download` | Download link |
|
|
@@ -1644,7 +1756,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1644
1756
|
```
|
|
1645
1757
|
|
|
1646
1758
|
- **One surface for all wording.** The catalog covers the 11 engine chrome keys of
|
|
1647
|
-
`@aiquants/virtualscroll` plus
|
|
1759
|
+
`@aiquants/virtualscroll` plus 110 own keys: field headings, the page title (`title`, passed to
|
|
1648
1760
|
`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
|
|
1649
1761
|
screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the search's match indicator, the status screen and the page's status region
|
|
1650
1762
|
and the mutation-failure message with its repeat count. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
@@ -1653,31 +1765,32 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1653
1765
|
and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
|
|
1654
1766
|
its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
|
|
1655
1767
|
re-render the memoized list subtree.
|
|
1656
|
-
- **Formatter keys.**
|
|
1768
|
+
- **Formatter keys.** Twenty-one keys take arguments and are functions: `totalCount(count)`,
|
|
1657
1769
|
`debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
|
|
1658
1770
|
`streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
|
|
1659
1771
|
`interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
|
|
1660
1772
|
`DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
|
|
1661
1773
|
`toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`, `listRowPositionInUnknownTotal(position)`,
|
|
1662
|
-
`searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`), `searchMatchedKeywords(matched, total)`, `searchMatchedKeywordsDescription(matched, total)`, `searchFiltersToggle(count)` and `searchFiltersFailed(failure)` (`failure` is `busy` / `unavailable` / `failed`). An override of such a key must be a function too.
|
|
1774
|
+
`searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`), `searchMatchedKeywords(matched, total)`, `searchMatchedKeywordsDescription(matched, total)`, `searchFieldMatched(field)` and `searchFieldMatchedByReading(field)` (`field` is a field heading as the resolved labels word it), `searchFiltersToggle(count)` and `searchFiltersFailed(failure)` (`failure` is `busy` / `unavailable` / `failed`). An override of such a key must be a function too.
|
|
1663
1775
|
- **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
|
|
1664
1776
|
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`), through one `Intl.NumberFormat` per
|
|
1665
1777
|
locale built once (the row positions and counts re-render on every stream publish); a host override receives
|
|
1666
1778
|
the raw number and formats it itself.
|
|
1667
1779
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
1668
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
1780
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 121 keys), a string
|
|
1669
1781
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
1670
1782
|
function. An `undefined` value keeps the catalog value.
|
|
1671
|
-
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `
|
|
1783
|
+
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `locale`, `labels`,
|
|
1672
1784
|
`sourceTypeConfigs`, `renderHeader`, `showDevControls`, `headingLevel`, `onError`, `onSessionExpired`, `search`. Any other
|
|
1673
1785
|
own key — also one whose value is `undefined` — throws the key error of **Configuration errors**
|
|
1674
1786
|
(`[daily-report] config key must be one of "apiBasePath", …, "search"; got "<key>"`). This runs at
|
|
1675
1787
|
render because a config built outside a typed object literal (a `useMemo` result, a variable) skips
|
|
1676
1788
|
TypeScript's excess-property check.
|
|
1677
1789
|
- **`defaultDailyReportClientConfig` is spreadable input.** Its type `DailyReportClientConfigDefaults`
|
|
1678
|
-
holds only the locale-independent defaults (`apiBasePath`, `ssePath`,
|
|
1790
|
+
holds only the locale-independent defaults (`apiBasePath`, `ssePath`,
|
|
1679
1791
|
`renderHeader`, `showDevControls`, `headingLevel`) and it is frozen. It carries no `locale`, `labels` or
|
|
1680
|
-
`sourceTypeConfigs`, so `{ ...defaultDailyReportClientConfig, locale: "ja" }` renders Japanese.
|
|
1792
|
+
`sourceTypeConfigs`, so `{ ...defaultDailyReportClientConfig, locale: "ja" }` with the required setting renders Japanese. It carries
|
|
1793
|
+
no `onSessionExpired`, the required setting: the host supplies it. The
|
|
1681
1794
|
resolved configuration from `useDailyReportConfig()` is output only: spreading it into `config`
|
|
1682
1795
|
would pin its labels as overrides.
|
|
1683
1796
|
- **Unknown authors**: a comment whose author name is unknown — a legacy comment without a name, a
|
|
@@ -1694,8 +1807,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1694
1807
|
`[daily-report] labels key must be one of "scrollUp", …; got "<key>"`, and describing the value never throws.
|
|
1695
1808
|
- **No language negotiation**: the package never reads `navigator.language`; map it to a supported
|
|
1696
1809
|
value yourself. No `lang` attribute is stamped either — the document language belongs to the host.
|
|
1697
|
-
- **Not tied to `locale`**: `
|
|
1698
|
-
`sourceTypeConfigs` (host data). Only the built-in default badge map used when `sourceTypeConfigs`
|
|
1810
|
+
- **Not tied to `locale`**: `sourceTypeConfigs` (host data). Only the built-in default badge map used when `sourceTypeConfigs`
|
|
1699
1811
|
is omitted takes its `Internal` label from `labels.sourceInternal`.
|
|
1700
1812
|
- **Host notifications**: `config.onError` receives `DailyReportErrorInfo` with the stable
|
|
1701
1813
|
`operation` key and `message` = `labels.operationFailed(operation)` in the chosen locale.
|
|
@@ -1703,7 +1815,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1703
1815
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
1704
1816
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
1705
1817
|
|
|
1706
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1818
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 121 keys, the 11 engine keys
|
|
1707
1819
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
1708
1820
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
1709
1821
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -1811,6 +1923,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1811
1923
|
| `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
|
|
1812
1924
|
| `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based; `total` is the size of the views' set) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
|
|
1813
1925
|
| `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition` | `(3)` → Item 3 | `(3)` → 3 件目 |
|
|
1926
|
+
| `searchFieldMatched` | match sentences of a row (the DetailList row's and the side pane's hidden indicator sentences, the List card's hidden match description): a field heading the row's leaves marked with a typed word's mark (`field` is the heading's resolved label) | `("Author")` → Author matched | `("作成者")` → 作成者で一致 |
|
|
1927
|
+
| `searchFieldMatchedByReading` | the same, for a heading whose marks matched only through a romaji reading | `("Author")` → Author matched by its romaji reading | `("作成者")` → 作成者がローマ字の読みで一致 |
|
|
1814
1928
|
| `searchFiltersToggle` | search box: the filter toggle, with the number of selected filter values (no number at 0; digit grouping) | `(0)` → Filters; `(3)` → Filters (3) | `(0)` → 絞り込み; `(3)` → 絞り込み (3) |
|
|
1815
1929
|
|
|
1816
1930
|
### External Source Badge Configuration (`sourceTypeConfigs`)
|
|
@@ -1884,7 +1998,7 @@ Every breaking change and its migration is listed per version in `CHANGELOG.md`,
|
|
|
1884
1998
|
|
|
1885
1999
|
## Realtime Architecture
|
|
1886
2000
|
|
|
1887
|
-
1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
|
|
2001
|
+
1. Mutation services publish to Redis Stream (`<streamKey>:v3`, `daily-report:sse-stream:v3` by default: the key carries the wire version of the embedded report, so a deployment that changes a required key of it reads no entry an older version wrote, and the stream of the previous version stops growing and can be deleted by hand) in sequence: `invalidate -> epoch increment -> SSE publish`.
|
|
1888
2002
|
2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
|
|
1889
2003
|
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).
|
|
1890
2004
|
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.
|
|
@@ -1952,35 +2066,41 @@ A type the closure brings in is published under a name that carries `DailyReport
|
|
|
1952
2066
|
- **shared** (`@aiquants/daily-report`, isomorphic):
|
|
1953
2067
|
- Values: `buildDailyReportAttachmentUrl` / `parseDailyReportAttachmentQuery` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` / `isDailyReportAttachmentThumbnailVariant` (the attachment URL codec and the variant list), `DAILY_REPORT_SSE_TERMINAL_EVENTS` / `DAILY_REPORT_SSE_HEARTBEAT_MS` / `DAILY_REPORT_SSE_CURSOR_PARAM` / `isDailyReportSseStreamId` (the SSE wire constants),
|
|
1954
2068
|
`dailyReportSseMessageSchema` (8 discriminated union types) with its members `connectedMessageSchema` / `reportCreateMessageSchema` / `reportUpdateMessageSchema` / `reportPublishMessageSchema` / `reportDeleteMessageSchema` / `statusUpdateMessageSchema` / `commentAddMessageSchema` / `commentDeleteMessageSchema` and the part schemas `dailyReportDetailSchema` / `dailyReportPostedCommentSchema` / `dailyReportExternalCommentSchema` / `dailyReportInterviewerSchema` / `dailyReportLabelDefSchema`,
|
|
1955
|
-
`isIdsStreamChunkLine
|
|
2069
|
+
`isIdsStreamChunkLine` (the strict reader of an ids stream line: a chunk line whose row columns are well formed, canonical and strictly in stream order, with no id twice) and `forEachIdsStreamRow(line, visit)` (walks the rows of a line the reader accepted, calling `visit(reportHubId, businessDate, sourceType)` per row in stream order, without an object per row; `sourceType` is `undefined` for a row without one, and a line the reader did not accept must not be walked)
|
|
2070
|
+
with the type of the columns it walks, `DailyReportIdsStreamRowColumns`, `normalizeBusinessDateKey` (a `Date`'s local calendar day, or a string read by the strict parser of **Request values**; `null` for anything else), `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`,
|
|
1956
2071
|
and the wire contract of the action and the JSON endpoints (**One wire contract** in [Server wiring](#server-wiring-di)), for a host that calls the routes itself: `DAILY_REPORT_ACTION_INTENTS` / `DAILY_REPORT_ACTION_FIELDS` (the intents and the form's field names),
|
|
1957
2072
|
`encodeDailyReportActionCommand(command)` (a command as its `FormData`), `parseDailyReportActionResult(intent, json)` (the strict reading of a 200 answer: `{ result }` or `{ mismatch }`), `DAILY_REPORT_API_ENDPOINTS` / `DAILY_REPORT_API_QUERY_PARAMS` (the endpoint and GET query-parameter names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms).
|
|
1958
2073
|
- Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`,
|
|
1959
2074
|
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 }`).
|
|
1960
2075
|
- 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),
|
|
1961
2076
|
`DailyReportSseTerminalEvent` (one name of `DAILY_REPORT_SSE_TERMINAL_EVENTS`) and `DailyReportLogger` (the console-compatible logger the server's `logger` takes).
|
|
1962
|
-
- 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),
|
|
2077
|
+
- 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),
|
|
2078
|
+
`DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2, the batched reads the package's client keeps in flight per API base path, from which the server sizes a viewer's share of the `reports` and `search-marks` admissions), and the answer of `search-marks`: `DailyReportSearchMarksAnswer` (`{ wordsDigest, reports, deferred }`) of `DailyReportSearchReportMarks` (a report's id, `matchedSets` and `fields`) and `DailyReportSearchFieldMarks` (a field's `fingerprint`, with its `ranges` or `dense: true`).
|
|
1963
2079
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
1964
2080
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider: [Client wiring](#client-wiring)),
|
|
1965
2081
|
and their props types `DailyReportPageProps` / `DailyReportResolvedContentProps` / `DailyReportListProps` / `DailyReportDetailListProps` (with `DailyReportDetailListScrollRestore`, the scroll anchor and row heights it keeps across unmounts) / `DailyReportAttachmentIndicatorProps`.
|
|
1966
|
-
- 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` /
|
|
2082
|
+
- 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` (`DailyReportRows`: 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` /
|
|
1967
2083
|
`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).
|
|
1968
2084
|
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),
|
|
1969
|
-
`UseDailyReportSseConnectionOptions`, the ids stream's `DailyReportIdsStreamState` / `DailyReportIdsStreamPhase` / `DailyReportIdsStreamClientOptions` / `DailyReportIdsStreamItemsChange` (one net change) / `
|
|
1970
|
-
|
|
2085
|
+
`UseDailyReportSseConnectionOptions`, the ids stream's `DailyReportIdsStreamState` / `DailyReportIdsStreamPhase` / `DailyReportIdsStreamClientOptions` / `DailyReportIdsStreamItemsChange` (one net change, its rows as batches) / `DailyReportIdsStreamItemReplacements` (its changed rows: the `previous` and the `next` values, row `i` of both the same report)
|
|
2086
|
+
/ `DailyReportRowBatch` (an immutable batch of rows in columns: `length`, `ids`, the business-date and source-type codes, `itemAt(i)` and `items()`, each making new row objects), and `DailyReportSearchFailure` (why a search failed, as the search box tells it).
|
|
2087
|
+
- The ids stream: `DailyReportIdsStreamClient` (`resync()`, `has(reportHubId)`, `readRows()` — the list as of the last publish as one row batch, copied when it is read — and `readItems()`, the same list as row objects; its `now` option is the monotonic clock of the publish window) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
|
|
1971
2088
|
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
|
|
1972
2089
|
- The search's filter controls: `DAILY_REPORT_SEARCH_FILTER_FACETS` with its type `DailyReportSearchFilterFacet` (the filters in the filter row's order, `author`, `sourceType` and `customer`), and the contract of the control a host renders in each filter cell: `DailyReportSearchFilterSelectRenderer` (what `config.search.renderFilterSelect` is) /
|
|
1973
2090
|
`DailyReportSearchFilterSelectProps` (what it receives) / `DailyReportSearchFilterOption` (one candidate, `{ value, label }`).
|
|
1974
2091
|
- Layout: `DAILY_REPORT_LATTICE_BLOCK_QUANTUM_CLASS_NAME`, the hook that carries the block quantum q (`--aqdr-lattice-block`, 4 px or 8 px by ratio) on the document root, which a host's bars round up to, and `DAILY_REPORT_LAYOUT_LATTICE_PX`, the 4 px unit u that q is built from (see **G-symmetric frame**); they are the only public layout values. The row geometry is not public: it follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
|
|
2092
|
+
- The rows' shape: `DailyReportRows` (`Pick<ReadonlyArray<DailyReportItem>, "length" | "at"> & Iterable<DailyReportItem>`: what `DailyReportActionState.items` is and both views' `dailyReportItems` take; a plain array is one too).
|
|
1975
2093
|
- 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`,
|
|
1976
2094
|
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)).
|
|
1977
2095
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1978
2096
|
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`; `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
|
|
1979
|
-
and for the search `searchLimits`, `null` without the `search` block, `searchFilterSourceTypes` (the source types a filter may name), `searchDailyReports` (it takes `DailyReportSearchCriteria`), `getDailyReportDetailsByIdsByExternalId`,
|
|
2097
|
+
and for the search `searchLimits`, `null` without the `search` block, `searchFilterSourceTypes` (the source types a filter may name), `searchDailyReports` (it takes `DailyReportSearchCriteria`), `getDailyReportDetailsByIdsByExternalId`,
|
|
2098
|
+
`markDailyReportSearchHits` (it takes the request's `{ signal }`, validated before any query, and throws its reason at its next check after an abort, before a read or after a yield between two slices of the marks; its answer holds the reports within the marks budget and defers the rest), `listDailyReportSearchFacets`, `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1980
2099
|
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).
|
|
1981
2100
|
- 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`.
|
|
1982
2101
|
- 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) /
|
|
1983
|
-
`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 `
|
|
2102
|
+
`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`, `maxBuckets` and `maxBytes`, all required; **The SQL result cache** in [Server wiring](#server-wiring-di)) / `transformJsonArray`;
|
|
2103
|
+
and `encodeIdsStreamRows(items, start, end)` (the row columns of an ids stream line, `DailyReportIdsStreamRowColumns`, from a range of rows in stream order, merging equal adjacent business dates into one run and the source types into a dictionary: the encoder the server's lines use, for a host that writes lines of its own, as a test does; it is on the server entry alone, so client bundles do not carry it).
|
|
1984
2104
|
- 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)).
|
|
1985
2105
|
- 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`,
|
|
1986
2106
|
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),
|