@aiquants/daily-report 0.42.0 → 0.44.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 +313 -0
- package/README.md +378 -161
- package/dist/client.d.mts +15 -16
- package/dist/client.d.ts +15 -16
- package/dist/client.js +5 -5
- package/dist/client.mjs +5 -5
- package/dist/index.d.mts +5 -6
- package/dist/index.d.ts +5 -6
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/instrumentation-CobsTeyw.d.mts +70 -0
- package/dist/instrumentation-Dz7Uh7dT.d.ts +70 -0
- package/dist/{search-marks-wire-BSS2d1t6.d.ts → search-marks-wire-BiImBos8.d.mts} +16 -1
- package/dist/{search-marks-wire-BSS2d1t6.d.mts → search-marks-wire-C_vjmxwf.d.ts} +16 -1
- package/dist/server.d.mts +11 -8
- package/dist/server.d.ts +11 -8
- package/dist/server.js +10 -10
- package/dist/server.mjs +10 -10
- package/dist/{comment-adapter-BTXFkMSz.d.ts → source-types-Cg7T2dm6.d.ts} +4 -2
- package/dist/{comment-adapter-wejvIziS.d.mts → source-types-Hy_znWJl.d.mts} +4 -2
- package/dist/{sse-schema-CUiQKk8K.d.mts → sse-schema-BcjRPJjN.d.ts} +166 -84
- package/dist/{sse-schema-CUiQKk8K.d.ts → sse-schema-sKBorzg9.d.mts} +166 -84
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/testing.d.mts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +1 -0
- package/dist/testing.mjs +1 -0
- package/dist/types-BP1kIGMo.d.mts +75 -0
- package/dist/types-BP1kIGMo.d.ts +75 -0
- package/package.json +12 -6
package/README.md
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
Reusable **daily-report** feature package: a virtual-scrolling report list and detail UI with optimistic updates and SSE real-time sync (React Router
|
|
4
4
|
v7 / v8), and a drizzle (**mssql**) server layer. It follows the `@aiquants/authz-*` DI layering: every app-specific concern (authentication, user
|
|
5
5
|
resolution, id obfuscation, Redis, legacy tables, the page chrome) is injected through ports, so the package carries no application- or
|
|
6
|
-
domain-specific knowledge. It is one module with
|
|
6
|
+
domain-specific knowledge. It is one module with four entries:
|
|
7
7
|
|
|
8
8
|
| Entry | Contents | Environment |
|
|
9
9
|
| :--- | :--- | :--- |
|
|
10
|
-
| `@aiquants/daily-report` | Types; the strict wire readers (`dailyReportDetailSchema` and `dailyReportSseMessageSchema`, built on the package's in-place reader; `readIdsStreamChunkLine` with `forEachIdsStreamRow`; `readDailyReportSearchMarksAnswer`); the action wire contract and the endpoint tables; the attachment URL codec and the thumbnail variants; the business-date normalizer and the echo-id pattern; the logger contract; the search wire types | isomorphic |
|
|
10
|
+
| `@aiquants/daily-report` | Types; the strict wire readers (`dailyReportDetailSchema` and `dailyReportSseMessageSchema`, built on the package's in-place reader; `readIdsStreamChunkLine` with `forEachIdsStreamRow`; `readDailyReportSearchMarksAnswer`); the action wire contract and the endpoint tables; the attachment URL codec and the thumbnail variants; the business-date normalizer and the echo-id pattern; the logger contract; the search wire types (the search, the search-marks and the per-id `reports` answers) | isomorphic |
|
|
11
11
|
| `@aiquants/daily-report/client` | `DailyReportPage`, list/detail components, hooks, action context, route helpers | browser (React) |
|
|
12
12
|
| `@aiquants/daily-report/server` | `createDailyReportServer` (service + loaders/actions), `defineDailyReportSchema`, SSE fan-out reader, SQL result cache, the sharp thumbnail renderer | node |
|
|
13
|
+
| `@aiquants/daily-report/testing` | `createDailyReportTestInstrumentation` (the views' test handle, the row pass's counters and the re-estimate counters, provided through the client entry's `DailyReportInstrumentationContext` in a host's test build only) and the types of the row pass's events | browser (tests) |
|
|
13
14
|
|
|
14
15
|
## Contents
|
|
15
16
|
|
|
@@ -87,7 +88,7 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
|
|
|
87
88
|
names with ref callbacks that return a cleanup.
|
|
88
89
|
`zod` may be 3.25.76 or later, zod 4 included (`>=3.25.76 <5.0.0`): the package reads zod only through its `zod/v3` entry, which every version in that
|
|
89
90
|
range ships, so the shipped declarations compile and a mismatch names the same issue codes whichever version the host installs.
|
|
90
|
-
The peer floor of `@aiquants/virtualscroll` is **3.
|
|
91
|
+
The peer floor of `@aiquants/virtualscroll` is **3.18.0**: install 3.18.0 or later.
|
|
91
92
|
Both views name their active row through its `activeItemIndex`, which renders `aria-activedescendant` in the same render as the row and keeps that
|
|
92
93
|
row mounted, count that row as shown only beyond its `activeItemVisibleInset`, describe an empty composite by its empty text, and skip the rendering
|
|
93
94
|
of far rows through its `itemZones` and `useVirtualScrollItem`; they bind its focus-ring colour variable `--aqvs-focus-ring-color` to the row outline
|
|
@@ -545,9 +546,12 @@ its payload — with one strict parser each, after authentication and before any
|
|
|
545
546
|
The mismatch names the failing paths and the issue codes only, never a value (`<path>: <code>` items joined by a semicolon and a space, the root as
|
|
546
547
|
`(root)`), so a host debugging a version skew or a rewriting proxy learns which fields failed and nothing it received reaches a log.
|
|
547
548
|
The package's client fails the action with `[daily-report] <intent> answered a body that is not its result (<mismatch>)`, and reads the `report` and
|
|
548
|
-
`business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`)
|
|
549
|
+
`business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`) and the `reports` endpoint with the
|
|
550
|
+
strict schema of its per-id outcomes (`{ outcomes }`: exactly one outcome per requested id, in request order, so a missing, duplicate, unrequested
|
|
551
|
+
or reordered id fails the whole answer): a body that does not match rejects with
|
|
549
552
|
`[daily-report] <endpoint> answered a body that does not match its schema (<mismatch>)`, a status other than 2xx with
|
|
550
|
-
`[daily-report] <endpoint> answered HTTP <status
|
|
553
|
+
`[daily-report] <endpoint> answered HTTP <status>`, and a `failed` outcome rejects only that report's readers with
|
|
554
|
+
`[daily-report] reports answered the outcome failed for report <id>` (the batch's other reports settle; [Requests](#requests) under the text search).
|
|
551
555
|
Every detail on the wire — an SSE message's report or posted comment, the action's answer and the read endpoints' answers — is read by one strict
|
|
552
556
|
reader of the shape, on which `dailyReportDetailSchema` is built: it checks the parsed JSON value where it lies and outputs that same value when it
|
|
553
557
|
conforms, so a conforming answer is neither copied nor reshaped (a zod object schema would copy every report's whole tree).
|
|
@@ -569,8 +573,9 @@ its payload — with one strict parser each, after authentication and before any
|
|
|
569
573
|
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
|
|
570
574
|
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
|
|
571
575
|
viewer's own comments look like someone else's.
|
|
572
|
-
- `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5
|
|
573
|
-
|
|
576
|
+
- `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange/xInfoStream) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5
|
|
577
|
+
client. The SSE catch-up from the stream origin reads XINFO STREAM's `length` and `entries-added`, which Redis 7.0 and later report (**Catch-up**
|
|
578
|
+
under the SSE route). If omitted, SSE publishing is skipped (with a warning) and epoch is disabled.
|
|
574
579
|
- `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
|
|
575
580
|
- `draftLabelNames` — **Required**: the database label names that mark a draft, the canonical one first (such as `["Draft", "Work in Progress"]`,
|
|
576
581
|
since sources may call their drafts by different names). `createDailyReportService` copies and freezes the list, and throws
|
|
@@ -595,7 +600,7 @@ its payload — with one strict parser each, after authentication and before any
|
|
|
595
600
|
shared draft-label test, 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
|
|
596
601
|
throws.
|
|
597
602
|
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
|
|
598
|
-
variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:
|
|
603
|
+
variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v4`; [Realtime architecture](#realtime-architecture)),
|
|
599
604
|
so an entry written before `isDraft` existed is never read.
|
|
600
605
|
- `resolveVisibleSourceTypes(request, { reason })` — **Required row-level authorization port.** Returns the `Hub.source_type` values this request may
|
|
601
606
|
view, or the explicit opt-out `{ unrestricted: "<reason>" }` of a deployment that does not restrict by source type. Applies uniformly to every server
|
|
@@ -639,7 +644,7 @@ its payload — with one strict parser each, after authentication and before any
|
|
|
639
644
|
|
|
640
645
|
The client has no host logger port: its four channels of the same list write to the console from `info` — `[DailyReportActionProvider]` (the action
|
|
641
646
|
provider: a failed action, the queue of SSE messages about uncached reports, a cache reload), `[DailyReportDetailLoader]` (the detail loading: a
|
|
642
|
-
stale update of a report's cache, a failed prefetch of a business date),
|
|
647
|
+
stale update of a report's cache, a failed prefetch of a business date, a failed refetch that left the shown report in place),
|
|
643
648
|
`[DailyReportIdsStream]` (the ids stream client: a revalidation that gave up, a rejected malformed line, which ends the attempt, a skipped line of
|
|
644
649
|
an unknown kind, a malformed stream anchor) and `[DailyReportSseConnection]` (the SSE connection hook).
|
|
645
650
|
Every line, the server's and the client's, carries exactly one channel prefix: no message spells a `[DailyReport…]` prefix of its own.
|
|
@@ -698,12 +703,15 @@ writes no line in the refusal record below (it is not a cross-site refusal). Eac
|
|
|
698
703
|
Each route's decisions — the name its refusals are recorded under (`api`, `action`, `sse` or `attachment`), the navigations it serves from another
|
|
699
704
|
origin, its answer to a data request and its 403 — come from one policy of that route, so they cannot be paired wrongly, and an endpoint added with a
|
|
700
705
|
streaming body is refused from the start.
|
|
706
|
+
The cross-origin rule itself is `crossOriginRefusal` of `@aiquants/request-isolation` (a dependency of this package, so every package that adopts it
|
|
707
|
+
refuses the same requests): a route's policy extends that package's `CrossOriginPolicy` (which navigations from another origin it serves), and the
|
|
708
|
+
rule answers why it refuses (`cross-origin-fetch` or `foreign-origin`, below), which the refusal record writes.
|
|
701
709
|
|
|
702
710
|
| Request | Answer |
|
|
703
711
|
| --- | --- |
|
|
704
712
|
| `Sec-Fetch-Site: same-origin` or `none` (a load the user started: the address bar, a bookmark) | Served: authentication and the route's own checks follow |
|
|
705
|
-
| Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
|
|
706
|
-
| No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 |
|
|
713
|
+
| Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 (`cross-origin-fetch`) before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
|
|
714
|
+
| No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 (`foreign-origin`) |
|
|
707
715
|
|
|
708
716
|
- **Requests without Fetch Metadata**: a browser sends no `Sec-Fetch-*` to an origin that is not potentially trustworthy (plain HTTP other than
|
|
709
717
|
`localhost`), so such a request comes either from a client that is not a browser or from a page of such an origin. A browser always sends `Origin`
|
|
@@ -721,7 +729,9 @@ streaming body is refused from the start.
|
|
|
721
729
|
- **The refusal record**: each handler factory keeps one bounded record of its refusals (state per factory), written on the `[DailyReportIsolation]`
|
|
722
730
|
channel: to the configured `logger` with that prefix, or by default to the console from the `warn` level.
|
|
723
731
|
A refusal with no open window writes one warn line at once,
|
|
724
|
-
`cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>`
|
|
732
|
+
`cross_site_rejected route=<api|action|sse|attachment> reason=<cross-origin-fetch|foreign-origin> site=<value> mode=<value> dest=<value> method=<method>`
|
|
733
|
+
(the reason says which half of the rule refused: `cross-origin-fetch` when the Fetch Metadata names another origin, `foreign-origin` when a request
|
|
734
|
+
without Fetch Metadata uses an unsafe method and an `Origin` that does not name the request's host; it carries nothing the request sent; `-` for an absent header; a value
|
|
725
735
|
outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32
|
|
726
736
|
characters, so a sender cannot forge the line's fields), and opens a window of 60 s that every route of the factory shares; later refusals in the
|
|
727
737
|
window are only counted, per route.
|
|
@@ -784,8 +794,9 @@ reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every
|
|
|
784
794
|
`"Internal"`, the package's own source type for the reports it creates (`DAILY_REPORT_INTERNAL_SOURCE_TYPE` on the shared entry). They are *not* your
|
|
785
795
|
authorization resource keys; mapping a resource key (e.g. `report_legacy`) onto a source type (e.g. `legacy`) is the consuming app's job. Read the
|
|
786
796
|
canonical set at boot from `dailyReportServer.knownSourceTypes` (frozen: `Internal` first, then each adapter's in order) and assert your mapping
|
|
787
|
-
against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (shared entry
|
|
788
|
-
|
|
797
|
+
against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (the shared entry, and the client entry for route modules that ship
|
|
798
|
+
to the browser: the shared entry is not split, so importing the fold from it carries a second copy of shared modules into the route's chunk), the
|
|
799
|
+
fold the server applies to the tokens it reads, instead of a copy of it.
|
|
789
800
|
Two adapters whose source types are equal ignoring case (an exact duplicate included), or an adapter whose source type is `Internal` in any case,
|
|
790
801
|
stop `createDailyReportService` and `createDailyReportServer` with a `RangeError`
|
|
791
802
|
(`[daily-report] externalSources[1].sourceType must be a source type that differs, ignoring case, from externalSources[0].sourceType "legacy"; got "Legacy"`):
|
|
@@ -1617,8 +1628,8 @@ and without the retry the victim would answer 500 and lose what it wrote.
|
|
|
1617
1628
|
|
|
1618
1629
|
### Realtime architecture
|
|
1619
1630
|
|
|
1620
|
-
1. Mutation services publish to Redis Stream (`<streamKey>:
|
|
1621
|
-
embedded report, so a deployment that changes a required key of
|
|
1631
|
+
1. Mutation services publish to Redis Stream (`<streamKey>:v4`, `daily-report:sse-stream:v4` by default: the key carries the wire version of the
|
|
1632
|
+
stream's messages and their embedded report, so a deployment that changes a required key of them reads no entry an older version wrote, and the stream of the previous version
|
|
1622
1633
|
stops growing and can be deleted by hand; a host that reads or writes the stream itself, such as an integration test, builds the key with the
|
|
1623
1634
|
server entry's `buildDailyReportSseStreamKey(streamKey)` instead of restating the version) in sequence:
|
|
1624
1635
|
`invalidate -> epoch increment -> SSE publish`.
|
|
@@ -1627,20 +1638,27 @@ and without the retry the victim would answer 500 and lose what it wrote.
|
|
|
1627
1638
|
`DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a
|
|
1628
1639
|
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.
|
|
1629
1640
|
The reader reads each live entry once per process and hands every subscriber the same frozen value (`DailyReportSseStreamEntry`:
|
|
1630
|
-
`{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1641
|
+
`{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by the stream form `dailyReportSseStreamMessageSchema` (the
|
|
1642
|
+
only form that declares the server-only fields: the six types that may be addressed — status-update, comment-add, comment-delete, report-create,
|
|
1643
|
+
report-update and report-publish — carry the recipient's internal id `recipientRawUserId`, and the three that embed a report also the writer's,
|
|
1644
|
+
`actorRawUserId`) — the validated message (`parsed`) and the JSON value as published (`published`, which only the recipient filter reads) — or
|
|
1645
|
+
`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 —
|
|
1646
|
+
fail-closed — while the reader's own read position moves past it).
|
|
1647
|
+
Each connection only filters that value (recipient, source-type visibility, comment redaction, the actor) and never changes it. Every frame is
|
|
1648
|
+
built from the parsed message, never from the published value or text: the message without its server-only fields, typed as the delivered form,
|
|
1649
|
+
so a server-only field left in a frame fails the type check, and a key the stream schema does not know never reaches a viewer. A message that
|
|
1650
|
+
embeds no report has one frame for every viewer; one that embeds a report has at most four — with the viewer's `viewerIsActor`, its comments
|
|
1651
|
+
emptied for a viewer who may not read them — each built at most once per message and shared by every connection that writes it, so a live entry
|
|
1652
|
+
costs one parse in proportion to its size plus a filter per connection, not one parse per connection, and every frame is one line of JSON.
|
|
1638
1653
|
A host's own reader keeps the same contract: it reads an entry once — `readDailyReportSseStreamEntry(raw, logger)` (server entry) reads it as the
|
|
1639
1654
|
package's reader does — and hands the same value to every subscriber.
|
|
1640
1655
|
3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its
|
|
1641
1656
|
responses carry the SSE response headers of `@aiquants/sse`, the routes' security headers (**Security headers** in [The API route](#the-api-route))
|
|
1642
|
-
and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId`
|
|
1643
|
-
and
|
|
1657
|
+
and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId` and `actorRawUserId` are read
|
|
1658
|
+
server-side and never written to a viewer, to prevent internal ID leaks; each connection writes `viewerIsActor` instead (whether its viewer made
|
|
1659
|
+
the write), so the delivered form (`dailyReportSseMessageSchema`, the root entry's `DailyReportSseMessage`) declares `viewerIsActor` and no internal id
|
|
1660
|
+
on any type. The delivered schema does not refuse a key it does not know (it drops it), so a long-lived tab keeps accepting a newer server's
|
|
1661
|
+
messages that carry additive fields; what a viewer receives is decided by the frame builder above. Catch-up and live entries pass through the same filters (recipient, source-type
|
|
1644
1662
|
visibility, comment redaction).
|
|
1645
1663
|
4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the
|
|
1646
1664
|
failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries
|
|
@@ -1658,6 +1676,9 @@ and without the retry the victim would answer 500 and lose what it wrote.
|
|
|
1658
1676
|
A comment that arrives by SSE is the viewer's own exactly when its author id equals the viewer's (`userId`, from the deterministic `encodeUserId`
|
|
1659
1677
|
port), whichever tab or device posted it; a comment of another user with the same text as one being posted is never taken for it, and a posted
|
|
1660
1678
|
comment's temporary id becomes its real id only through its own action's answer.
|
|
1679
|
+
A report message (create, update, publish) is the viewer's own write exactly when it carries `viewerIsActor: true`: the client then caches the
|
|
1680
|
+
payload's read and star state as its own write's answer, so the writer's other tabs and devices see their own create as read; otherwise it merges the
|
|
1681
|
+
shared fields and keeps the viewer's own read and star state (false for a report it has not cached), so no write leaks its writer's state.
|
|
1661
1682
|
Each action holds its own lock on the report it changes (its release frees only that lock and only once), so an overlapping action, an SSE change
|
|
1662
1683
|
(which records the change time without locking) and the 1 s lock after a creation (which a later hold only extends) never end one another's locks.
|
|
1663
1684
|
|
|
@@ -1682,15 +1703,17 @@ unexpected error, a request that is not the package's client (another origin's p
|
|
|
1682
1703
|
| Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
|
|
1683
1704
|
| Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1684
1705
|
| The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1685
|
-
|
|
|
1686
|
-
|
|
|
1706
|
+
| The stream origin `0-0` as the cursor (the anchor of an ids read on an empty stream) while the stream has removed an entry (XINFO STREAM's `entries-added` exceeds its `length`: trimmed or deleted) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1707
|
+
| Empty stream (any cursor: no page is read, and entries appended later arrive live), a cursor inside the retained window or in the future, or the origin `0-0` while the stream has removed no entry (the catch-up starts at the stream's first entry) | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
|
|
1708
|
+
| Redis failure during the catch-up, an XINFO STREAM reply without the counts (`length` and `entries-added` must be safe integers with `entries-added` ≥ `length` ≥ 0; a Redis older than 7.0 has no `entries-added`), or the stream tail cannot be read while the shared reader fixes its read position (`ready()` rejects; on the path without a cursor before `connected` is written) | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
|
|
1687
1709
|
|
|
1688
1710
|
- **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter
|
|
1689
1711
|
(`DAILY_REPORT_SSE_CURSOR_PARAM`). The browser's own reconnect reuses the URL it opened with (a stale query) but sends a fresh header, so the header
|
|
1690
1712
|
is always the newer value. A cursor must be a Redis stream id of the form `<ms>-<seq>` with 1–15 digits per part; anything else is answered with
|
|
1691
1713
|
`resync-required`. The client URL-encodes the cursor.
|
|
1692
1714
|
- **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when
|
|
1693
|
-
there is no cursor)
|
|
1715
|
+
there is no cursor), and without `id:` while that position names no entry (the origin cursor `0-0`, or an empty stream without a cursor). On the
|
|
1716
|
+
cursor path it is written before any Redis round trip.
|
|
1694
1717
|
Without a cursor it is written only after the shared reader has fixed its read position (`DailyReportSseReader.ready()`), so every entry appended
|
|
1695
1718
|
after a client sees `connected` reaches that client; on the cursor path the catch-up is read after that point instead, so nothing appended between
|
|
1696
1719
|
the catch-up and the position fix is lost.
|
|
@@ -1711,9 +1734,15 @@ unexpected error, a request that is not the package's client (another origin's p
|
|
|
1711
1734
|
most 64 MiB into the process, also when every entry is as large as an event can be. A catch-up reads its pages per connection; a catch-up of 1,000
|
|
1712
1735
|
small events takes 113 round trips (one check of the oldest entry and 112 pages, each new page reading 9 entries after its start).
|
|
1713
1736
|
- **Catch-up**: entries after the cursor are read in pages of `DAILY_REPORT_SSE_CATCH_UP_BATCH` (`xRange` is inclusive, so each page skips its start
|
|
1714
|
-
id) until the stream end; every entry is delivered.
|
|
1715
|
-
|
|
1716
|
-
cursor that is
|
|
1737
|
+
id) until the stream end; every entry is delivered. The catch-up first reads the oldest retained entry. An empty stream reads no page, for any
|
|
1738
|
+
cursor: the shared reader, subscribed before the catch-up, delivers every entry appended later live (a page read would take an entry appended
|
|
1739
|
+
meanwhile for a trimmed range). A cursor that names an entry is witnessed by the oldest retained entry: when that is newer than the cursor, the
|
|
1740
|
+
range after the cursor may have been trimmed, so the connection ends with `resync-required`. The stream origin `0-0` names no entry, so its witness
|
|
1741
|
+
is that the stream has never removed an entry (XINFO STREAM's `entries-added` equals its `length`): the oldest retained entry is then the first the
|
|
1742
|
+
stream ever got, and the first page starts there and delivers it too; otherwise the connection ends with `resync-required`. When a page does not
|
|
1743
|
+
start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the
|
|
1744
|
+
connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window
|
|
1745
|
+
continues).
|
|
1717
1746
|
- **Ids on the wire only increase**: live entries that arrive during the catch-up are queued, not written. The catch-up stops in front of the first
|
|
1718
1747
|
queued entry (the fan-out reader delivers every entry after it in order), then the queue is written in id order and later live entries are written
|
|
1719
1748
|
as they arrive. Writing a live entry first would let a disconnect move the client's resume position past catch-up entries it never received, and an
|
|
@@ -1725,8 +1754,11 @@ unexpected error, a request that is not the package's client (another origin's p
|
|
|
1725
1754
|
provisional line). The service reads it inside the cached fetcher **before** the SQL, so every cache path (bucket hit,
|
|
1726
1755
|
joining an in-flight fetch, refresh-ahead) returns an array that already reflects every update up to the anchor. The
|
|
1727
1756
|
SSE hook does not connect until an anchor arrives and then uses it as the first cursor, which closes the gap between
|
|
1728
|
-
loading the ids and opening the stream.
|
|
1729
|
-
|
|
1757
|
+
loading the ids and opening the stream. An empty stream anchors at the stream origin `"0-0"`, which the hook sends as
|
|
1758
|
+
its cursor like any other anchor, so a report published between the ids read and the connect arrives through the
|
|
1759
|
+
catch-up (**Catch-up** above). `null` means only "position unknown" (no redis, a read failure, or a viewer without an
|
|
1760
|
+
internal user): the hook then opens without a cursor and the server starts from the newest entry at connect time,
|
|
1761
|
+
so an entry appended between the ids read and the connect is not delivered (the gap that an unknown position leaves).
|
|
1730
1762
|
- **Delivery is idempotent**: an anchor can be up to one ids-cache TTL old; replaying from it is safe because `report-*` messages upsert and
|
|
1731
1763
|
`comment-add` is matched by comment id.
|
|
1732
1764
|
- **Event size**: the service writes an event to the stream only when its JSON is at most 6,356,992 bytes: the action's form cap (1 MiB,
|
|
@@ -1978,37 +2010,51 @@ The 400s come in order, all before any service call: `q` and `combine` (`Invalid
|
|
|
1978
2010
|
(`Invalid search query` with the plan's `reason`). `q` stays required: a blank `q` (`q=` included) with a filter is the filters-only search, and
|
|
1979
2011
|
without one it is still the 400 `empty`.
|
|
1980
2012
|
A request without filter parameters adds no condition to the SQL: its statement and parameters are those of the text search alone.
|
|
1981
|
-
`GET {apiBasePath}/reports?ids=<id,id,...>` (distinct canonical ids, at most 100) answers
|
|
1982
|
-
|
|
2013
|
+
`GET {apiBasePath}/reports?ids=<id,id,...>` (distinct canonical ids, at most 100) answers one outcome per requested id, in request order, `{ outcomes }`
|
|
2014
|
+
(`DailyReportSearchDetailsAnswer` of `DailyReportSearchDetailOutcome`): `ok` with the report (its delivered JSON copy, read strictly), `not_found`
|
|
2015
|
+
for an id the viewer cannot read, a missing id and a deleted report alike (never told apart, so the answer is no existence oracle), or `failed` for a
|
|
2016
|
+
report the viewer may read whose own assembly failed (its display fields, its mapping, its JSON copy or the strict read of that copy); the server logs a
|
|
2017
|
+
`failed` report's id and cause on its logger, and the answer carries no cause, so the batch's other ids are answered. The cause is logged once per
|
|
2018
|
+
read and report in a window of 60 s (`[DailyReportService] <read>: report <id> passed the visibility predicate but its own assembly failed` at
|
|
2019
|
+
`error`, the read being the service method, `getDailyReportDetailsByIdsByExternalId` here and `markDailyReportSearchHits` for the marks); later
|
|
2020
|
+
failures of that report in that read are only counted, and the window's end writes them as one `warn` line,
|
|
2021
|
+
`[DailyReportService] failed_report_suppressed count=<n> since=<the window's start, ISO> read=<read> report=<id>`, so a broken report that every
|
|
2022
|
+
batch asks for writes a bounded number of lines. The reports are read in slices under the request's signal, like the marks (**Slices** below).
|
|
2023
|
+
A failure before or across the
|
|
2024
|
+
visibility predicate (the viewer's resolution, the query, the batched label, comment and attachment reads, the canonical draft label) fails the whole
|
|
2025
|
+
request with 500, since a per-id `failed` for an id that never passed the predicate would tell it apart from `not_found`. Neither is cached on the
|
|
2026
|
+
server.
|
|
1983
2027
|
`reports` has an admission of its own from the same factory, never the search's: 2 × the larger of `concurrency` and
|
|
1984
2028
|
`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
|
|
1985
2029
|
half, so a viewer always passes the client's two concurrent reads (a `concurrency` of 1 admits 4 per process),
|
|
1986
2030
|
and 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
|
|
1987
2031
|
reads nor spends the searches.
|
|
1988
|
-
Its order is the search's: the ids (400), then a viewer without an internal user answered
|
|
2032
|
+
Its order is the search's: the ids (400), then a viewer without an internal user answered every id `not_found` with no admission and no dataset query,
|
|
1989
2033
|
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
|
|
1990
2034
|
gate full or its viewer at its share waits for a slot like a search-marks batch, for at most 2 s, then gets 429 with `Retry-After: 1`; an empty bucket
|
|
1991
|
-
is 429 with one window's `Retry-After`), the
|
|
2035
|
+
is 429 with one window's `Retry-After`), the read with the request's signal (in slices, yielding to the event loop between them), and the slot's
|
|
2036
|
+
release whatever the outcome. Its refusal lines name `route=reports`.
|
|
1992
2037
|
`GET {apiBasePath}/search-marks?q=<text>&combine=intersection|union&ids=<id,id,...>` (the shown answer's query and at most 100 distinct canonical ids,
|
|
1993
2038
|
the rows a view draws) answers `{ wordsDigest, reports, deferred }` (`DailyReportSearchMarksAnswer`): in `reports`, for each requested report the
|
|
1994
|
-
viewer may read
|
|
2039
|
+
viewer may read, whose delivered copy the server assembles (as `reports` does: a report that fails to assemble is logged and left out like an unreadable
|
|
2040
|
+
one, so one broken report never removes the marks of its neighbours) and 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
|
|
1995
2041
|
OR of two or more keywords;
|
|
1996
2042
|
`null` otherwise) and the marks of every non-empty field value the views draw (`fields`, by key: `subject`, `content`, `author`, `employeeName`,
|
|
1997
2043
|
`updatedBy`, `category`, `creationCategory`, `customerName`, `interviewer:<i>:name|affiliation`, `attachment:<i>` and `comment:<id>:author|content`,
|
|
1998
2044
|
the comment's id in the list `useDailyReportComments` returns).
|
|
1999
2045
|
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
|
|
2000
|
-
`reports`, in `deferred`, or — not readable — in neither, and never twice. `wordsDigest` is the digest of the answer's `words`.
|
|
2046
|
+
`reports`, in `deferred`, or — not readable, or failed to assemble — in neither, and never twice. `wordsDigest` is the digest of the answer's `words`.
|
|
2001
2047
|
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
|
|
2002
2048
|
either `ranges`, flat `[start, end, tier]` triples in UTF-16 offsets of that value,
|
|
2003
2049
|
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
|
|
2004
2050
|
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
|
|
2005
2051
|
field with both, a `dense` that is not `true`, a `deferred` id that was not requested, is listed twice or also has marks, and an answer that defers
|
|
2006
|
-
ids without carrying a report (the server always answers the first report, so such an answer breaks the contract, and asking again would
|
|
2007
|
-
progress).
|
|
2052
|
+
ids without carrying a report (the server always answers the first assembled report, so such an answer breaks the contract, and asking again would
|
|
2053
|
+
make no progress).
|
|
2008
2054
|
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
|
|
2009
2055
|
most one triple per UTF-16 unit of its value.
|
|
2010
2056
|
An answer has a budget: at most 500,000 ranges and 4,000,000 raw UTF-16 units of the fields it marks.
|
|
2011
|
-
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
|
|
2057
|
+
The reports are marked in the answer's order, and the first assembled report is always answered, so every request makes progress; from the first later report that
|
|
2012
2058
|
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
|
|
2013
2059
|
not marked and its id goes to `deferred`, for the client to ask again (**Arrival** under **Highlights** below).
|
|
2014
2060
|
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
|
|
@@ -2026,7 +2072,9 @@ runs, and its marks equal those of the per-occurrence definition byte for byte.
|
|
|
2026
2072
|
work passes 8 ms or 5 ms have passed on the
|
|
2027
2073
|
process's monotonic clock, and the first step of a slice always runs, so every slice makes progress), and the service yields to the event loop and
|
|
2028
2074
|
checks the request's signal between two slices,
|
|
2029
|
-
inside a field too.
|
|
2075
|
+
inside a field too. The delivered read slices the same way, with the same slicing, before the marks and as the whole of `reports` (a report's JSON
|
|
2076
|
+
copy and strict read are one step, so the read yields before the next report once a slice has run 5 ms, and the longest synchronous block of a
|
|
2077
|
+
`reports` answer is its one serialization); before the marks the service yields once more after it, so the marks start a fresh slice. No step of the work is longer than a block of 0.5 ms of estimated work, except the index text of one piece of a line (at most
|
|
2030
2078
|
4,000 units) and one grapheme cluster longer than a
|
|
2031
2079
|
block of the segmentation: on values of 1,000,000 units — only line breaks, one letter per line, invisible characters with line breaks, a fullwidth
|
|
2032
2080
|
`AB` line with no cut between its pieces,
|
|
@@ -2049,17 +2097,19 @@ Its order is the search's and the details': 404 without the `search` block, 400
|
|
|
2049
2097
|
400 for the ids, then a viewer without an internal user answered `{ wordsDigest, reports: [], deferred: [] }` with no admission and no dataset query,
|
|
2050
2098
|
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
|
|
2051
2099
|
whatever the outcome, and the JSON. It is not cached on the server either.
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2100
|
+
Both per-id reads of the service take the request's signal (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })` and
|
|
2101
|
+
`getDailyReportDetailsByIdsByExternalId(scope, ids, externalId, { signal })`) and check it before each query and after each yield, stopping at the
|
|
2102
|
+
next slice once the client has left, between two slices of one field too.
|
|
2103
|
+
Options that are not an object carrying an `AbortSignal` stop the call before any query with a host-facing error naming the method: an omitted
|
|
2104
|
+
`options` or `signal` is a `TypeError`
|
|
2105
|
+
(`[daily-report] markDailyReportSearchHits: options.signal must be the request's AbortSignal (new AbortController().signal for a call that is never aborted)`,
|
|
2106
|
+
and the same with `getDailyReportDetailsByIdsByExternalId`), any other value a `RangeError` ending with `; got <value>`.
|
|
2107
|
+
The two routes always pass the request's signal; drizzle cannot cancel a running query, so the slot is held until the read in progress ends. A request
|
|
2059
2108
|
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
|
|
2060
2109
|
a body, with `Retry-After` and `Cache-Control: no-store` and no ETag, and writes no error line;
|
|
2061
|
-
the four search routes share this answer and its checks (the search
|
|
2062
|
-
|
|
2110
|
+
the four search routes share this answer and its checks (the search and `search-facets` pass no signal to their services and check it when the slot
|
|
2111
|
+
is granted and when the read ends, `reports` and `search-marks` pass it to theirs; a read that fails with an error of its own is still the 500), and
|
|
2112
|
+
the refusal of an aborted request is
|
|
2063
2113
|
never recorded as busy, on any of them (`search-facets` included).
|
|
2064
2114
|
Its 200 carries no ETag and `Cache-Control: no-store` (serialized once, with the shared security headers), and so does every other answer of the
|
|
2065
2115
|
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
|
|
@@ -2118,8 +2168,8 @@ line wraps instead of truncating;
|
|
|
2118
2168
|
it comes first in the field's description (before the syntax help), and the field is `aria-invalid` only for an invalid query (busy, unavailable and
|
|
2119
2169
|
other failures are no input errors).
|
|
2120
2170
|
The AND / OR toggle and the filter toggle share one look: a 24 px pill with 12 px semibold text, in the field face (OR; the closed filter row), whose
|
|
2121
|
-
boundary comes from the text-field token as the field's does, or the filled face (AND; the open filter row), blue-
|
|
2122
|
-
and `Highlight` under `HighlightText` in forced colours (**Text fields** in [Selection and focus appearance](#selection-and-focus-appearance)).
|
|
2171
|
+
boundary comes from the text-field token as the field's does, or the filled face (AND; the open filter row), blue-800 under white text (dark: a light
|
|
2172
|
+
blue-300 fill under slate-950 text) and `Highlight` under `HighlightText` in forced colours (**Text fields** in [Selection and focus appearance](#selection-and-focus-appearance)).
|
|
2123
2173
|
The faces differ only in colour, so a toggle that changes its face moves nothing. Each toggle stacks its current label and an invisible reserved label
|
|
2124
2174
|
in one grid cell — the AND / OR toggle the other mode's label, the filter toggle its label with the largest number of selected values (every filter at
|
|
2125
2175
|
its limit) —,
|
|
@@ -2131,14 +2181,17 @@ and that a match found through a reading is underlined with dots.
|
|
|
2131
2181
|
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
|
|
2132
2182
|
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=`)
|
|
2133
2183
|
instead of by business date, and an answer goes into the per-report cache only, never into a business date's cached list.
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2184
|
+
Each view leads its rows' inputs: it keeps its rendered rows and 24–40 rows beyond each rendered edge subscribed to the details and the marks, in
|
|
2185
|
+
chunks of 8 rows (32 on each side after a jump; **Highlights** below), so the rows a held key mounts find their details already loaded.
|
|
2186
|
+
An id joins the waiting ids at the first animation frame after rows stop entering and leaving, so a fast scroll requests nothing for the rows it
|
|
2187
|
+
passes and the lead let go, and a window that stops waits at most one frame.
|
|
2188
|
+
The batches are decided when they are sent, by the scheduler the search marks use: from the ids still waiting, newest first, up to 100 ids a request
|
|
2137
2189
|
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
|
|
2138
2190
|
sent `cache: "no-cache"` with a signal of its own.
|
|
2139
|
-
A row that unmounts leaves its load,
|
|
2140
|
-
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
|
|
2141
|
-
schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status)
|
|
2191
|
+
A row that unmounts leaves its load, and the details of a report are loaded once for all of its readers (the rows, the lead, the side pane), so an
|
|
2192
|
+
id nothing waits for is not sent, a request none of whose ids is still waited for 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
|
|
2193
|
+
schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status), except over a report a row already
|
|
2194
|
+
shows: a failed refetch of an expired report leaves it in place (the shared detail record under `useDailyReportDetail` below).
|
|
2142
2195
|
A 429 whose `Retry-After` is at most 5 s (it covers a full admission gate's `Retry-After: 1` and stays below the rate bucket's 60 s window) keeps the
|
|
2143
2196
|
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
|
|
2144
2197
|
`Retry-After`, is the error.
|
|
@@ -2244,7 +2297,7 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
|
|
|
2244
2297
|
fill continues under the `…` while its underline ends at the last character shown (a cut at a mark's start shows a filled `…` alone).
|
|
2245
2298
|
An `interviewerWithAffiliation` template that does not insert the name and the affiliation verbatim (it rewrites them, uses one twice, or words
|
|
2246
2299
|
itself by their values) draws the pill plain. `LIKE` counts an occurrence inside a grapheme cluster, so such a match marks the whole cluster.
|
|
2247
|
-
- **Look**: one token: yellow-200 under slate-900 (dark yellow-
|
|
2300
|
+
- **Look**: one token: yellow-200 under slate-900 (dark yellow-900 under yellow-50; 15.33:1 and 8.38:1 on any surface, since the mark sets its own
|
|
2248
2301
|
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`
|
|
2249
2302
|
in forced colours.
|
|
2250
2303
|
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
|
|
@@ -2265,34 +2318,46 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
|
|
|
2265
2318
|
- **Arrival**: the screen holds one marks store per shown answer (inside the client bundle), only while a view draws an answer that has words: without
|
|
2266
2319
|
`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),
|
|
2267
2320
|
after the release and for a view rendered on its own there is none, and the DOM is the plain one.
|
|
2268
|
-
A row
|
|
2269
|
-
|
|
2321
|
+
A row hands its marks scope the marks of the unit that drew its card: the row reads the report's details and marks through its input source from
|
|
2322
|
+
the moment it mounts as a shell (the first subscriber of a report asks for its marks) and draws them with its units (**Rows mount in two passes**
|
|
2323
|
+
under [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)), so a card whose marks are known is drawn with them in its first
|
|
2324
|
+
commit, never plain first, and marks that arrive later are drawn by one unit of the row, which keeps the row's box (the row stays skippable).
|
|
2325
|
+
The desktop side pane reads the marks with a deferred reader instead: its first render after a change of report draws the text plain, and the marks
|
|
2326
|
+
follow in a deferred render.
|
|
2327
|
+
The scope subscribes to nothing: it hands the marks to its leaves through one row context (the marks, their revalidation, the row's tally and the
|
|
2328
|
+
indicator's settings) that only the scope provides, and settles the row's tally in its own layout effect, after its leaves registered;
|
|
2270
2329
|
a leaf, a match indicator or a List card's match description drawn outside a scope while the screen holds a store stops with an error naming the
|
|
2271
2330
|
package, so no leaf draws marks that no tally counts.
|
|
2272
|
-
The
|
|
2273
|
-
|
|
2331
|
+
The card body reads no marks, so an arrival re-renders only the row's container and frame once (one unit) or the side pane's marks scope, then the
|
|
2332
|
+
scope, the marked leaves and the match indicator — never the row's body, header, attachment list or comment section, and never another row.
|
|
2274
2333
|
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
|
|
2275
|
-
marks),
|
|
2276
|
-
|
|
2334
|
+
marks), in one layout effect that revalidates, registers and returns its unregistration (an opaque token that removes exactly that registration),
|
|
2335
|
+
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
|
|
2336
|
+
once, at the end of the task that changed it, however many leaves registered in it: a card's match description is complete by the end of the task
|
|
2337
|
+
that committed the card.
|
|
2338
|
+
While an answer is shown, each view also keeps the rows beyond its rendered range subscribed (the input lead: 24–40 rows beyond each rendered edge,
|
|
2339
|
+
8 more on the side it travels towards whenever fewer than 24 remain, 32 on each side after a jump), so a row that mounts during a held key finds its
|
|
2340
|
+
marks and details in the stores.
|
|
2277
2341
|
A report's first subscription reaches the requests through a batching window of 240 ms: outside a window it is handed on at once, together with the
|
|
2278
2342
|
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
|
|
2279
|
-
window while
|
|
2280
|
-
|
|
2281
|
-
|
|
2343
|
+
window while first subscriptions keep coming, and a report whose subscribers all left inside the window asks for nothing.
|
|
2344
|
+
During a held key the windows are opened by the lead's chunks of 8 rows, one chunk every 8 keys (about 240 ms at a 30 ms repeat), so a chunk never
|
|
2345
|
+
waits for the window the previous chunk opened, and a 40-key hold sends 4–5 requests per store; a row is the first subscriber of its report only
|
|
2346
|
+
when the lead does not hold it yet (the view's first render, after a jump, or a scroll faster than the lead).
|
|
2282
2347
|
The epoch's renewal, a field's revalidation, a 429's retry and the deferred ids do not wait for the window.
|
|
2283
2348
|
The requests are decided at send time: a subscribed report waits in one queue, and each free request slot — two in flight at most — takes the newest
|
|
2284
|
-
waiting reports that a row
|
|
2285
|
-
|
|
2286
|
-
A request whose
|
|
2349
|
+
waiting reports that are still subscribed (by a row or the lead), at most 100 a request, never cached. A report whose subscribers left while it waited
|
|
2350
|
+
is dropped, so a scroll sends nothing for the rows it passed and the lead let go, and the window it stops on goes out in the first free slot.
|
|
2351
|
+
A request none of whose reports is still subscribed is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its
|
|
2287
2352
|
marks were sent, or whose request was aborted, asks again when it returns.
|
|
2288
2353
|
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
|
|
2289
2354
|
per-viewer wait queue is sized for while it still winds the aborted ones down.
|
|
2290
2355
|
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)
|
|
2291
|
-
and forgets the oldest records of reports
|
|
2292
|
-
many
|
|
2356
|
+
and forgets the oldest records of reports nothing subscribes to beyond 400 (2 × the two requests in flight × 100: the answers still in flight and as
|
|
2357
|
+
many reports just let go), so its memory follows the drawn rows and their lead, not the distance scrolled; a forgotten report asks again when it is
|
|
2358
|
+
subscribed again.
|
|
2293
2359
|
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
|
|
2294
|
-
was sent and carried at least one report: an id
|
|
2295
|
-
again.
|
|
2360
|
+
was sent and carried at least one report: an id still subscribed is queued again, any other is given back, so a row that returns asks again.
|
|
2296
2361
|
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
|
|
2297
2362
|
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
|
|
2298
2363
|
(the epoch's renewal asked again for every subscribed report).
|
|
@@ -2304,7 +2369,7 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
|
|
|
2304
2369
|
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
|
|
2305
2370
|
the same search makes the marks stale and the mounted rows ask again;
|
|
2306
2371
|
401 and 403 call `onSessionExpired`; a 429 whose `Retry-After` is at most 60 s (one window of the server's rate bucket) is asked again once, after
|
|
2307
|
-
that delay, for the reports
|
|
2372
|
+
that delay, for the reports still subscribed then (one whose subscribers left meanwhile asks again when it is subscribed again), and a 429 without a
|
|
2308
2373
|
`Retry-After`, or with a longer one, sets no timer and leaves its reports plain for the answer's epoch (a delay of 2^31 ms or more would overflow
|
|
2309
2374
|
the timer and ask again at once);
|
|
2310
2375
|
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
|
|
@@ -2345,9 +2410,13 @@ narrow for the author and the slot the slot drops to a 16 px line of its own ins
|
|
|
2345
2410
|
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
|
|
2346
2411
|
arrow keys reads the sentences each row shows.
|
|
2347
2412
|
- A List card shows no slot. While the screen holds a marks store, the description of its row and of its primary button ends, after the preview,
|
|
2348
|
-
with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the
|
|
2349
|
-
|
|
2350
|
-
|
|
2413
|
+
with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the same
|
|
2414
|
+
sentences the DetailList row's indicator holds for the same report under the same answer, built by one rule: the matched keywords, the marked fields
|
|
2415
|
+
and the hidden match (a card whose leaves draw none of the report's matches — a report matched only in the body beyond the preview, for example —
|
|
2416
|
+
says the hidden match in the List, while the DetailList, which draws the whole body, names the content field). The element has no box, so the
|
|
2417
|
+
sentences never change the row's height, and each description reads them once; they are empty until the marks arrive. So the active row's
|
|
2418
|
+
announcement says how many keywords matched, what matched, whether only a reading did and whether the match lies outside the card. Without a store
|
|
2419
|
+
the card's description is unchanged.
|
|
2351
2420
|
|
|
2352
2421
|
## Client wiring
|
|
2353
2422
|
|
|
@@ -2407,14 +2476,29 @@ render (`[daily-report] config.headingLevel must be an integer from 2 to 5 …;
|
|
|
2407
2476
|
`DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained
|
|
2408
2477
|
usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
|
|
2409
2478
|
Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `report`, `error`, `isLoading` and `isRefetching` — belongs to the id it is
|
|
2410
|
-
called with
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2479
|
+
called with. The details of a report live in one record per report id that every reader of the screen shares — the views' rows, the views' input
|
|
2480
|
+
lead while a search answer is shown, the List's side pane and the hook — so one report has one record and one load whoever reads it: its first
|
|
2481
|
+
reader's subscription starts the load and its last reader's leaving stops it (an answer that arrives after that is dropped), and a cache write
|
|
2482
|
+
reaches the readers of its own report only, once per answer. The hook copies the record into state at default priority. On the first render after
|
|
2483
|
+
the id changes it already returns that id's starting state: its cached report (one definition, also used by the load: the report cache, expired
|
|
2484
|
+
entries included, then the business date's cached list), `isLoading` only when nothing is cached, no error and no refetch yet — never the previous
|
|
2485
|
+
id's report, error or loading flags. An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it. An id of
|
|
2486
|
+
0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error). A row and the side pane that show one report share its
|
|
2487
|
+
record, so they never run two loads of it at once, and readers waiting on one load of a report not shown yet receive its failure once each. A
|
|
2488
|
+
settled report is shared with every later reader while its cache entry is fresh. Once the entry has expired, a reader that subscribes to the record
|
|
2489
|
+
— the side pane, which is rebuilt for every report it shows, a row that mounts, an id that enters the input lead's band — re-validates it after
|
|
2490
|
+
120 ms (the same wait as the refetch below), even while a row keeps the record open: the record keeps the expired report meanwhile (never a loading
|
|
2491
|
+
state), every reader that subscribes before the answer shares the one request, and the answer reaches every reader once; a report selected again
|
|
2492
|
+
inside its TTL is not fetched. A refetch of a shown report that fails (the re-validation, or the first reader's refetch of an expired cached report)
|
|
2493
|
+
leaves the report in place with no error and only logs a warning on `[DailyReportDetailLoader]`, so the next new reader re-validates again. A settled
|
|
2494
|
+
load error is not shared either: a reader that subscribes to a record holding an error — a row that mounts, an id that enters the input lead's band,
|
|
2495
|
+
the side pane — asks again, and the answer replaces the error. Only a new subscription asks again or re-validates (a re-render or a second
|
|
2496
|
+
subscription of the same listener does not, so a row that keeps showing a failure or an expired report never loops), and a refusal of the viewer's
|
|
2497
|
+
session (401 or 403, which cannot succeed before `onSessionExpired` has run) is shared like an answer: it becomes the record's error even over a
|
|
2498
|
+
shown report, and no new reader asks again or re-validates.
|
|
2499
|
+
The first load of a report that is not cached starts inside its first reader's subscription, with no task between the row's mount and its request,
|
|
2500
|
+
so a held arrow key's stream of keydowns cannot postpone it until the key is released; only the refetch of an expired cached report waits 120 ms, to
|
|
2501
|
+
absorb rows that only scroll past. Loads of reports of one business date share one request.
|
|
2418
2502
|
|
|
2419
2503
|
Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route
|
|
2420
2504
|
them into your own toast/notification system, or omit it to use the package's built-in notice. Failures on a continuation that resolves after a
|
|
@@ -2700,8 +2784,8 @@ view, and no script computes or writes a height.
|
|
|
2700
2784
|
text anyway.
|
|
2701
2785
|
- **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja
|
|
2702
2786
|
「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no
|
|
2703
|
-
message of their own; they only theme the engine's (on `VirtualScroll`'s root: 16 px above and below,
|
|
2704
|
-
|
|
2787
|
+
message of their own; they only theme the engine's (on `VirtualScroll`'s root: 16 px above and below, in the quiet text colour: 7.39:1 and 9.75:1
|
|
2788
|
+
against the page in light and dark, where the engine's default grey reaches only 4.17:1 in dark).
|
|
2705
2789
|
Because the views pass an active row (`activeItemIndex`, `null` while the list is empty), `VirtualScroll` draws the message outside the composite —
|
|
2706
2790
|
in a layer over the composite's box, with the same pixels and the same `.aqvs-no-items-container` / `.aqvs-no-items-text` hooks — and names it first
|
|
2707
2791
|
in the composite's `aria-describedby`, before the key help: the `treegrid` owns nothing but rows, and a screen reader entering an empty view (a
|
|
@@ -2761,7 +2845,8 @@ the resize handle and the side pane lie outside the list.
|
|
|
2761
2845
|
leaving the view and unmounting end it without applying it.
|
|
2762
2846
|
On a slow device the keys therefore never queue up, and the selection stops when the key is released. `Ctrl+Home` / `Ctrl+End` are never
|
|
2763
2847
|
accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below), and rows
|
|
2764
|
-
far
|
|
2848
|
+
get their bodies only in the frames without a key, far rows behind the travel direction after the key is released (see **Rows mount in two
|
|
2849
|
+
passes** below).
|
|
2765
2850
|
- **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep
|
|
2766
2851
|
their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
|
|
2767
2852
|
keys whose target is neither the view's composite nor a control inside one of its rows (the scroll bar, the side pane, the mobile overlay); the
|
|
@@ -2852,35 +2937,86 @@ the resize handle and the side pane lie outside the list.
|
|
|
2852
2937
|
renderer do not render, and no context value changes per key.
|
|
2853
2938
|
The app's keyboard harness counts the style and layout work that starts inside a keydown dispatch or inside a frame callback the key caused: 0 in
|
|
2854
2939
|
every run of its nine conditions.
|
|
2855
|
-
- **Rows mount in two passes.** A row that a commit mounts outside the visible range first renders as a **shell**: its row frame, with its role,
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2940
|
+
- **Rows mount in two passes.** A row that a commit mounts outside the visible range first renders as a **shell**: its row frame, with its role, id,
|
|
2941
|
+
position, set size, level and selection, and a surface (`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing
|
|
2942
|
+
moves when the body arrives. The surface is the card's surface without content (the card surface token: its radius, border, colours and shadow),
|
|
2943
|
+
so the selection ring and the focus outline around a shell are concentric with it as around a card, and the resting surface does not change when
|
|
2944
|
+
the card lands. While the report's details are still loading, the shell is named by the report's ISO business date and is
|
|
2945
|
+
`aria-busy="true"`. Once the details are in the store, the shell is a **named shell**: it holds, hidden, the card's own name and the parts of the
|
|
2946
|
+
card's description that do not depend on drawing (the row state, the attachment marker with its count, the source badge's text, the List preview and
|
|
2947
|
+
the matched-keywords sentence), made by the same functions as the card's, so the active descendant announces the row as it will read as a card. Only
|
|
2948
|
+
the matched-fields and outside-view sentences, which the drawn leaves report, arrive with the card. The settled DOM never contains a shell.
|
|
2949
|
+
One scheduler per view then advances the rows one at a time — a shell to its body, a body that still draws older inputs to the latest ones (a report
|
|
2950
|
+
that arrived by a load's answer or an SSE update, or the report's search marks) — each unit applied with `flushSync` as one commit of its own.
|
|
2951
|
+
A row reads its report's details and search marks through one input source from the moment it mounts, and a unit draws what the source holds when it
|
|
2952
|
+
is applied, so an arriving answer never re-renders a row by itself: a row renders only through its units and its own state (such as its edit form).
|
|
2953
|
+
- **Units commit only in key-free frames.** The scheduler applies units only inside its own frame callback, and only when nothing of a key happened
|
|
2954
|
+
since its previous callback: no `keydown` or `keyup` was dispatched in the view's window (two passive capture listeners that only count, so they
|
|
2955
|
+
read nothing), the held key's stream applied no step in a frame callback, and the stream holds no accumulated step. A unit the scheduler applies
|
|
2956
|
+
in its own frame callback therefore never renders in the frame that renders a key, a single press included; a unit commits elsewhere only in the
|
|
2957
|
+
two cases named below (a row that becomes visible, and a newer input of a visible row outside a key hold). The first callback after the scheduler
|
|
2958
|
+
wakes only calibrates. The scheduler arms itself
|
|
2959
|
+
with a `MessageChannel` message and calls `requestAnimationFrame` only from its own message and callbacks — never from a keydown, a commit or the
|
|
2960
|
+
held key's frame — with one exception: when a key hold starts (in its first repeat keydown, before the hold's first step) in a view that has a
|
|
2961
|
+
measured row (each mounted row declares how its box is sized, and the DetailList's rows are measured), and the scheduler has no frame requested and
|
|
2962
|
+
has not classified the hold yet, it requests one bare listening frame there (it applies no unit, writes no DOM and reads no clock in the keydown),
|
|
2963
|
+
and its `hold-start` event says so (`listening`), so a harness that counts frames requested in a keydown as key work can tell the scheduler's frame
|
|
2964
|
+
apart. A view whose rows all have fixed boxes (the List) never listens, since the key frames' evidence below classifies measured rows only: during
|
|
2965
|
+
a hold its scheduler sleeps whenever no unit can run.
|
|
2966
|
+
- **A frame's share.** A key-free frame applies its first unit, then more units only while their measured costs (the p90 of the latest 32 per kind
|
|
2967
|
+
of unit, known after 8; kinds are a box sizing and a change: a body over a shell, a change that can change the box, the marks alone) fit half the
|
|
2968
|
+
frame interval and the time left in the frame. At today's costs a DetailList card takes one frame, and the List packs 1–3 rows. A frame whose
|
|
2969
|
+
callbacks started late (a GC or a long task ran before them) skips its first unit when that unit no longer fits (a 2 ms margin); a unit of a
|
|
2970
|
+
visible or reach row is skipped by at most one late frame in a row.
|
|
2971
|
+
- **Order.** By zone — visible, then the active row's reach, then near, then far (**Far rows skip rendering** below) — and within a zone by the
|
|
2972
|
+
distance from the active row, the side the keys travel towards first; equal ranks keep the order the units were added in. The travel direction is
|
|
2973
|
+
the active row's move, read at every frame callback and, during a hold, whenever units are ranked, so a hold takes its direction from its first
|
|
2974
|
+
key step (a hold upwards draws the far rows above from its first key) and never from a jump before it. While a key is held, far rows ahead in the
|
|
2975
|
+
travel direction are drawn after the near ones, so the rows a hold brings into the reach already have their bodies; far rows behind wait for the
|
|
2976
|
+
key's release. While the active row is pinned outside the rendered range or absent (a wheel or a fling), the rows are ranked by the pane instead:
|
|
2977
|
+
those ahead of the pane's travel first, by their distance from the visible range, and while the pane moves, the units of one viewport ahead of it
|
|
2978
|
+
are never skipped as late, so the rows a fling passes are drawn before they become visible.
|
|
2979
|
+
- **Visible rows are never stale.** A row that becomes visible with a pending unit renders its body inside the commit that made it visible, so a
|
|
2980
|
+
visible row is never a shell and never shows outdated data or marks (counted), with one exception on a device where a card exceeds a frame (below). Outside a key hold, a newer input of a row that is already visible
|
|
2981
|
+
is drawn at once: in the commit that brings it, or, when it arrives as a store notification, in one commit at the end of the notifying task.
|
|
2982
|
+
- **Search answers lead the rows' inputs.** While a search answer is shown, each view keeps its rendered rows and 24–40 rows beyond each rendered
|
|
2983
|
+
edge subscribed to the details and the marks (8 more rows on the side it travels towards whenever fewer than 24 remain, 32 on each side after a
|
|
2984
|
+
jump), from its range report, which `VirtualScroll` delivers in a frame callback, never inside a keydown. A row that mounts during a held key finds
|
|
2985
|
+
its details and marks already loaded, so every unit of a hold draws one complete card with its marks, and the requests leave in chunks (4–5 per
|
|
2986
|
+
store in a 40-key hold; after a jump one request per store carries the rendered window and 2 × 32 rows, at most 96 ids). The lead holds its
|
|
2987
|
+
subscriptions per store and releases only the swapped store's: a new answer (a new marks store) re-leases the marks and keeps the details and their
|
|
2988
|
+
request in flight, so it never asks for the same ids twice; a new details store also forgets the edges and leases as after a jump. The by-date
|
|
2989
|
+
views keep the by-date prefetch.
|
|
2990
|
+
- **Heights settle in the frame that lays the body out.** The DetailList's row-height delivery — one `ResizeObserver` per view, made from the
|
|
2991
|
+
view's own window where its scroll container attaches, beside the scheduler, and disconnected when the container detaches — commits each delivery
|
|
2992
|
+
in one synchronous commit:
|
|
2993
|
+
the row's height, `VirtualScroll`'s positions (with the compensation above the visible start) and the row's settled state land in the frame that
|
|
2994
|
+
laid the body out. A row that this commit reveals, or a body whose box it changes, is measured before the delivery ends and observed at the
|
|
2995
|
+
view's next frame callback, so no observation starts inside a delivery and the browser never reports "ResizeObserver loop completed with
|
|
2996
|
+
undelivered notifications".
|
|
2997
|
+
With a 30 ms key repeat at 60 Hz, a hold has about 0.8 key-free frames per key and brings about one row per key into the reach, so a 40-key hold
|
|
2998
|
+
keeps several bodied rows ahead of the active row; holds longer than about 60 keys draw the bodies they reveal inside the key's own commit (one
|
|
2999
|
+
complete card each, counted).
|
|
3000
|
+
On a device where a DetailList card exceeds a frame, a held key draws no card in its frames. The scheduler classifies the device from what it
|
|
3001
|
+
measures, with no device constant: a kind of unit exceeds the frame when its measured p50 exceeds the class limit, the display's frame interval
|
|
3002
|
+
floored at the 60 Hz interval, less 2 ms. That is 14.67 ms on a 60 Hz display and on a faster one alike: an interval more than 1 ms below 16.67 ms
|
|
3003
|
+
(a display above about 64 Hz) is judged as 16.67 ms, because the frame targets are written for 60 Hz frames, so the class follows the device's CPU
|
|
3004
|
+
and not the display's rate (a 120 Hz display's own interval less 2 ms, 6.33 ms, would classify every DetailList hold at 1×). The interval is the
|
|
3005
|
+
display's, read from the frames that were not pushed back, so a slow device does not stretch it. During a hold and for the DetailList's measured
|
|
3006
|
+
cards, a kind also exceeds the frame when the median of the hold's key frames does (from 3 samples: the key frames' own weight, measured even
|
|
3007
|
+
while the scheduler draws nothing, from their busy time, or from their cadence when a frame's work outlasted the class limit and missed the vsync
|
|
3008
|
+
after it, which a gap of 1.5 display intervals past the vsync before the one nearest the limit's end shows: 1.5 intervals at 60 Hz, 2.5 at 120 Hz
|
|
3009
|
+
and 144 Hz). The end-of-frame message of the frame after which the scheduler slept adds no sample: it waits behind input and rendering, so its time
|
|
3010
|
+
measures that wait, not the frame. On such a device the rows that enter
|
|
3011
|
+
the visible range during the hold stay named shells, the scheduler sleeps, and after the key's release it draws the active row's card first and then
|
|
3012
|
+
the visible shells in their visible order. At 1× no kind ever exceeds the frame on a 60, 120 or 144 Hz display (a DetailList card's p50 is
|
|
3013
|
+
7.3–8.4 ms and the key frames' median at most about 13.6 ms), so every entering row is complete at its entry. With the
|
|
3014
|
+
CPU slowed 4× before a 40-key hold, the hold classifies itself by its 6th or 7th key, 23–26 rows enter as named shells, and the next frame's p95 is
|
|
3015
|
+
27.4–45.1 ms (median 33.4) against the 33 ms target. The List's rows fit a frame even then, so the List keeps every row complete at its entry.
|
|
3016
|
+
Decisions never wait for the drawing: the card and the error surface are both drawn from the unit's snapshot, and only the safety valve that
|
|
3017
|
+
removes a row whose report no longer exists, the busy state of a row whose card or error waits for its unit (a load error that arrived but is not
|
|
3018
|
+
drawn yet keeps the row's skeleton, never a "not found" surface), and a DetailList body's draft and ownership, its opening of the viewer's own draft
|
|
3019
|
+
in the editor and its edit button's action read the row's current details.
|
|
2884
3020
|
- **Far rows skip rendering.** Every mounted row lies in one zone, computed in the render that positions the rows from that render's own scroll
|
|
2885
3021
|
position (half-open intervals in content pixels, V the viewport's size, s the scroll position): **visible** when it intersects [s, s + V);
|
|
2886
3022
|
**reach** when it intersects the active row's reach, from V above the row before the active one to V below the row after it — every row that a
|
|
@@ -2999,7 +3135,14 @@ the resize handle and the side pane lie outside the list.
|
|
|
2999
3135
|
(re-recording there would read the clamped position against the previous list's rows, name another report and leave the view displaced by up to
|
|
3000
3136
|
the last row's height).
|
|
3001
3137
|
- **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range,
|
|
3002
|
-
once per frame.
|
|
3138
|
+
once per frame. During a fling far beyond the rendered window the rows are ranked by the pane (those ahead of its travel first) and the rows of
|
|
3139
|
+
one viewport ahead of a moving pane are drawn before they become visible (**Rows mount in two passes**). Over rows of one height (the List)
|
|
3140
|
+
wheeling back by the same distance lands where the fling started. Over measured rows (the DetailList) a fling is not yet symmetric: the rows it
|
|
3141
|
+
passes before they are drawn are laid out at the measured estimate (below) and change to their own heights when they are drawn above the pane, and
|
|
3142
|
+
`VirtualScroll`'s compensation keeps the visible rows in place, so a plain 20,000 px fling and back lands 156–548 px away from where it started
|
|
3143
|
+
(measured in Chromium with React's development build, where a card takes 10–26 ms to draw, so the rows a fling passes are measured late); the
|
|
3144
|
+
visible rows never jump, and content that was drawn and measured never moves: wheeling there and back in steps that each wait until the rows they
|
|
3145
|
+
mount are drawn lands on the start report exactly. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and
|
|
3003
3146
|
restores from there: it neither undoes the user's scroll nor shifts the content by a row.
|
|
3004
3147
|
Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the
|
|
3005
3148
|
previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's
|
|
@@ -3058,7 +3201,12 @@ the resize handle and the side pane lie outside the list.
|
|
|
3058
3201
|
rows are the whole set; the views' `VirtualScroll` always counts the rows given.
|
|
3059
3202
|
The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one,
|
|
3060
3203
|
otherwise its label); a description references only the markers that are painted. A shell, and a row without content (loading, failed, missing,
|
|
3061
|
-
editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come
|
|
3204
|
+
editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come, and is described by the state
|
|
3205
|
+
it shows, so the active descendant's announcement never reduces a visible state to a date: a failed row by its error message
|
|
3206
|
+
(`labels.reportSummaryLoadFailed` in the List, `labels.reportLoadFailed` in the DetailList), a List row the server did not find by
|
|
3207
|
+
`labels.reportNotFound`, and a DetailList row that shows its edit form — the viewer's own draft opens there by itself — by a visually hidden
|
|
3208
|
+
`labels.rowEditingState({ isDraft })` (en "Editing draft" / "Editing", ja 「下書きを編集中」 / 「編集中」). A shell and a loading skeleton have no
|
|
3209
|
+
description. ★, 既読, 編集 and 削除 carry
|
|
3062
3210
|
`aria-label` equal to their `title` and no `aria-pressed`; no element of a view carries `aria-current`.
|
|
3063
3211
|
The active row is never skipped, so its name and description are always complete; a far row that skips rendering is absent from the accessibility
|
|
3064
3212
|
tree (**Far rows skip rendering** above). The package's tests compute the active row's name and description in both views; how real screen readers
|
|
@@ -3119,6 +3267,9 @@ the resize handle and the side pane lie outside the list.
|
|
|
3119
3267
|
the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
|
|
3120
3268
|
The comment field is named by that heading (`aria-labelledby`; `labels.comments`), which renders whenever the form does, so the field has a name of
|
|
3121
3269
|
its own in the DetailList row, the side pane and the mobile overlay; its placeholder (`labels.commentPlaceholder`) stays a hint, not its name.
|
|
3270
|
+
While a post is sent, the field keeps the draft, read-only, with the submit button disabled, and empties only when the post is accepted; a refused
|
|
3271
|
+
post (401, 403, a 5xx) leaves the draft, its text and its caret untouched, so a viewer whose session ended sends it again after signing in
|
|
3272
|
+
(the package's part of WCAG 2.2.5; keeping a draft across a navigation or a reload is the host's).
|
|
3122
3273
|
A report of a source that takes no comments and has none shows no empty section, and deleting the last comment of such a report removes the section,
|
|
3123
3274
|
so focus goes to the report's anchor above.
|
|
3124
3275
|
- **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit
|
|
@@ -3176,6 +3327,7 @@ and their versions, and per step either a pass or the text announced.
|
|
|
3176
3327
|
| Empty and no-category views | A search that matches nothing, and a viewer who may see no category: Tab into the view | The composite is announced by its title, described by the empty text (「項目がありません」) and the keyboard help, with no row; for the viewer without a category, the page's notice (「閲覧できる日報の区分がありません。…」) is reached in browse mode before the view |
|
|
3177
3328
|
| Overlay close | Phone layout, List: open a report's overlay with Enter on the composite and close it with Escape; open it with a tap and close it with the close button | After the keyboard close, focus is on the composite and the same row is announced as active; after the tap close, focus is on the card's primary button, announced with its position first |
|
|
3178
3329
|
| Touch hand-back in the DetailList | Touch reader, DetailList: in the active row of your own report from a source that takes no comments, delete its last comment (the trash button, then the confirmation) | The comment section leaves and focus lands on the row's name heading, announced as the report's date, author and subject (not on the list as a whole) |
|
|
3330
|
+
| Held key on a slow device | Desktop, DetailList with a search answer shown, Chrome's CPU throttling at 4× (DevTools, Performance): hold ArrowDown for about 40 keys, then release | While the key is held, each active row is announced with its name and its description (state, attachments, source, the matched-keywords sentence), even when its card has not been drawn; after the release, the active row's card arrives first and its description gains the matched-fields sentence; nothing is announced twice |
|
|
3179
3331
|
| Far rows | Browse mode (NVDA, JAWS, Narrator) or swiping (VoiceOver on iOS, TalkBack) past the visible rows | The rows within one viewport of the visible range are reached; rows farther away are reached only once the list has scrolled near them (**Far rows skip rendering**); the active row is always reached |
|
|
3180
3332
|
| Switch Access | Android, Switch Access with Chrome, List on the phone layout: scan to a card and select it, then close the overlay with its close button | The card is reachable by scanning and opens the overlay; after the close, focus (which Switch Access follows) is on that card's primary button, or on the composite when the page recorded no pointer input for the action (a hand-back after no recorded input lands on the composite): record which, and that scanning continues from there |
|
|
3181
3333
|
| Forced colours | Windows contrast theme (forced colours) with the keyboard: move the active row with the arrows, wheel it out of view, then press a key | The selected row shows the system's `Highlight` outline, the active row's keyboard outline shows in the system colour while it is visible, and the composite's 2 px pane ring shows while the active row is out of view |
|
|
@@ -3252,8 +3404,9 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
|
|
|
3252
3404
|
Measured in Chromium 148 at 412 × 915 and 2.625: with bodies rounded to 4 px, a DetailList row of 980 px ended at 6,478.5 device pixels and the
|
|
3253
3405
|
surface edge of the next row blended into two rows of device pixels; rounded to q, the revealed row's surface top sits at device pixel 273 in every
|
|
3254
3406
|
reveal.
|
|
3255
|
-
A row that has not been measured yet is laid out at
|
|
3256
|
-
|
|
3407
|
+
A row that has not been measured yet is laid out at the view's measured estimate — the median of the heights the view has measured, rounded up to
|
|
3408
|
+
8 px (2 u), and 440 px (110 u) until it has measured one; the view applies a changed estimate only at rest, with the first visible report kept in
|
|
3409
|
+
place — also on the lattice at every ratio of the domain, so the unmeasured rows above the window move the rows below by whole device pixels. An image attachment tile is padded to q as well (**Tile** in
|
|
3257
3410
|
[Attachment display](#attachment-display)), so the rows below an image grid stay on whole device pixels.
|
|
3258
3411
|
- **List card**: a wrapping row of the primary button and the action row (markers and toggles), with the preview always on the next line. The primary
|
|
3259
3412
|
button takes the remaining width but never less than 96 px, enough for the business-date pill, and the action row keeps to the card's end; on a card
|
|
@@ -3399,16 +3552,19 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
|
|
|
3399
3552
|
|
|
3400
3553
|
- **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
|
|
3401
3554
|
their field face (the OR toggle and the closed filter toggle), take their boundary and placeholder from one token: a 1 px slate-500 border in both
|
|
3402
|
-
schemes and a
|
|
3555
|
+
schemes and a placeholder in the quiet text colour (below).
|
|
3403
3556
|
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
|
|
3404
3557
|
focus; dark slate-900, the comment input slate-800 turning slate-900); none reads the host's `--input`, `--muted-foreground`, `--primary` or
|
|
3405
3558
|
`--destructive`.
|
|
3406
3559
|
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:
|
|
3407
|
-
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
|
|
3408
|
-
field's fill.
|
|
3409
|
-
The toggles' filled face (AND, and the open filter toggle) is a blue-
|
|
3410
|
-
|
|
3411
|
-
|
|
3560
|
+
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 7:1 on each
|
|
3561
|
+
field's fill (light: 7.73 on white, 7.39 on the comment input's slate-50; dark: 8.62 on slate-900, 7.07 on the comment input's slate-800).
|
|
3562
|
+
The toggles' filled face (AND, and the open filter toggle) is a blue-800 fill under white text in light and a light blue-300 fill under slate-950
|
|
3563
|
+
text in dark, whose edge is its boundary (8.43:1 on the page, dark 11.12:1), with its text at 8.82:1 (dark 11.12:1); it stands 8.82:1 (dark 9.83:1)
|
|
3564
|
+
from the field face's fill, so open and closed differ in more than hue, and the blue-400 focus outline, drawn 2 px outside the face, stands 1.45:1
|
|
3565
|
+
from the dark fill. No dark fill under white text can meet both the 7:1 of the text and the 3:1 of the fill against the page, since the two ratios
|
|
3566
|
+
multiply to white against the slate-950 page, 20.16 < 7 × 3. Forced colours draw it as `Highlight` under `HighlightText` while the field face keeps
|
|
3567
|
+
the forced button colours.
|
|
3412
3568
|
`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),
|
|
3413
3569
|
and its host-token guard covers the token, the primitives and the sites.
|
|
3414
3570
|
|
|
@@ -3622,8 +3778,25 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
|
|
|
3622
3778
|
are exactly the token's, and the search box's spec renders every status message in both locales and checks that the line carries the token and no
|
|
3623
3779
|
truncating class.
|
|
3624
3780
|
- **Type and contrast**: no text is smaller than 12 px (`text-xs`; a scan of every module under `src/client` rejects a smaller arbitrary size,
|
|
3625
|
-
`src/client/components/report-views.spec.tsx`); text reaches
|
|
3626
|
-
|
|
3781
|
+
`src/client/components/report-views.spec.tsx`); every text colour reaches 7:1 (WCAG 1.4.6, AAA) against every surface it can sit on, in both themes
|
|
3782
|
+
and every state (hover, active, `aria-*`, `data-*`, translucent surfaces composited over theirs), and the indicators 3:1:
|
|
3783
|
+
`src/client/ui/aaa-text-contrast.spec.ts` scans every class literal of the shipped client (and the comment authors' colours) that sets a text colour,
|
|
3784
|
+
refuses one without a registered surface, and measures each against its surfaces. The quiet text (section labels, metadata, placeholders, the empty
|
|
3785
|
+
view, inactive tabs) is one token, `--aqdr-text-quiet` (light `oklch(44% 0.046 257.417)`, dark `oklch(77% 0.040 256.788)`: slate-500's and
|
|
3786
|
+
slate-400's hue and chroma), whose lightness `src/client/ui/aaa-quiet-text.spec.ts` solves from the surfaces it sits on (light 7.06–7.73, dark
|
|
3787
|
+
7.07–9.75). A source badge's colours are the host's (**External Source Badge Configuration**). Targets stay at WCAG 2.5.8's 24 px, except the
|
|
3788
|
+
List's resize handle with its 10 px hit area and the 8 px scroll bar beside it (**Side pane** above); 2.5.5's 44 px is not claimed.
|
|
3789
|
+
- **WCAG 2.2 AAA beyond contrast**: met are 1.4.9 (no images of text), 2.1.3 (every function by the keyboard, the List splitter's arrow keys
|
|
3790
|
+
included), 2.2.3 (nothing on a timer; **The built-in notice**), 2.3.2 (no flashes), 2.3.3 (motion follows `prefers-reduced-motion`), 2.4.8 (the page
|
|
3791
|
+
and the report headings tell where the viewer is), 2.4.9 (links name their purpose), 2.4.10 (sections carry headings), 2.4.12 and 2.4.13 (the focus
|
|
3792
|
+
is never obscured, and its outline is 2 px at 3:1), 3.1.3, 3.1.4 (abbreviated source badges, **External Source Badge Configuration**), 3.2.5 and
|
|
3793
|
+
3.3.6. Not met, by decision: 2.2.4 (Interruptions): live updates (SSE) apply as they arrive, with no pause, and
|
|
3794
|
+
the scroll anchoring keeps the rows on screen in place. Not met at AA: 2.5.8 for the List's resize handle and the scroll bar beside it, and 2.5.7 for
|
|
3795
|
+
the List's splitter (**Side pane** above): a path without dragging belongs to `@aiquants/resize-panels`, and larger hit areas for the handle and the
|
|
3796
|
+
bar wait for the same layout pass as 2.5.5. Not claimed yet: 2.5.5 (44 px targets), 1.4.8 (a line measure and the List preview's
|
|
3797
|
+
leading) and 3.3.5 (a help control for the search syntax), which need a layout pass, and 3.1.6 (readings of names), which needs readings from the
|
|
3798
|
+
host's directory. 2.2.5 is met by the package for a refused comment post (**One comment section rule** above); keeping work across a
|
|
3799
|
+
re-authentication elsewhere is the host's, as is 2.2.6 (telling users how long a session lasts). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in
|
|
3627
3800
|
`VirtualScroll`'s tap-scroll circle options).
|
|
3628
3801
|
- **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side
|
|
3629
3802
|
pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on
|
|
@@ -3905,7 +4078,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
|
|
|
3905
4078
|
| `[data-daily-report-row="<id>"]` | Row frame of both views (`role="row"` with `id`, `aria-rowindex`, `aria-posinset`, `aria-setsize`, `aria-level` and an explicit `aria-selected`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
|
|
3906
4079
|
| `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
|
|
3907
4080
|
| `[data-daily-report-row-surface]` | Surface of a row frame (`role="gridcell"`): its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
|
|
3908
|
-
| `[data-daily-report-row-shell]` | The
|
|
4081
|
+
| `[data-daily-report-row-shell]` | The surface of a shell, the card's surface without content: a row mounted outside the visible range whose body is still to come (**Rows mount in two passes**) |
|
|
3909
4082
|
| `[data-daily-report-card]` | List card surface (present while the card is shown) |
|
|
3910
4083
|
| `button[data-daily-report-button="<id>"]` | List card primary button (the click target that selects, and the pointer hand-back target; never a Tab stop, `tabIndex=-1`) |
|
|
3911
4084
|
| `[data-daily-report-row-focus]` | A row's own focus target, where a pointer or touch hand-back lands: the List card's primary button, or the DetailList card's visually hidden name heading (`tabIndex=-1`; never a Tab stop) |
|
|
@@ -3932,17 +4105,30 @@ Only the package writes `data-daily-report-keyboard-focus`, `data-daily-report-s
|
|
|
3932
4105
|
their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during
|
|
3933
4106
|
a key's render the frame already names the new selection while the content still shows the previous report.
|
|
3934
4107
|
|
|
4108
|
+
A host's test build instruments the views by providing `createDailyReportTestInstrumentation()` (testing entry) through
|
|
4109
|
+
`DailyReportInstrumentationContext` (client entry) around the page; production provides none, so no view carries a test handle or reports a row-pass
|
|
4110
|
+
event there, and none of the testing entry's code is in the client bundle. A view reads the context once, when it mounts. Instrumented,
|
|
3935
4111
|
`[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the
|
|
3936
4112
|
same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`,
|
|
3937
4113
|
`getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the
|
|
3938
|
-
view's committed list,
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
`
|
|
4114
|
+
view's committed list, one way to move the position, `revealIndex(index, options)`, and one read-only view of the view's row pass:
|
|
4115
|
+
`rowPass.counters()`, the counts of its events since the view mounted, read when it is called — `syncReleases` (units drawn synchronously, in a
|
|
4116
|
+
commit or a notification's microtask commit), `deferredVisible` (units of a frame-exceeding kind kept pending when their row became visible during a
|
|
4117
|
+
hold), `oversizeFrames` (key-free frames whose first unit alone exceeded the frame's share), `lateFrames` (late key-free frames the pass skipped),
|
|
4118
|
+
`lateForcedFrames` (late key-free frames it applied a unit in regardless) and `unitFrames` (frames that applied units) — and
|
|
4119
|
+
`rowPass.unitKinds()`, the latest `p50`, `p90` and `budget` (the class limit: the frame interval, floored at the 60 Hz interval on a faster display,
|
|
4120
|
+
less the late-frame margin) of each unit kind its unit frames
|
|
4121
|
+
reported — and one read-only view of the view's re-estimates: `estimate.counters()` — `reEstimates` (position changes `VirtualScroll` made for a
|
|
4122
|
+
new row-height estimate, the cause `re-estimate`) and `reEstimatesInMotion` (those made while the view moved: during a key hold or while it
|
|
4123
|
+
scrolled; 0 by design, since the view applies an estimate at rest only). A performance probe reads them before and after a key hold and reports the
|
|
4124
|
+
differences, so it copies none of the pass's constants or rules (**Rows mount in two passes**).
|
|
4125
|
+
The testing entry exports the contract as types, so a host's tests import it instead of restating it (type-only):
|
|
4126
|
+
`DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` (client entry) the
|
|
4127
|
+
options of `revealIndex`, which are the second argument of `VirtualScroll`'s `scrollToIndex` (its alignment and offset, as the peer defines them).
|
|
3942
4128
|
`revealIndex` goes through the view's scroller (the `VirtualScroll` alignment of `scrollToIndex`, then the anchor's record, like a key move; see
|
|
3943
4129
|
**Host selections and list changes**), so a list change right after it keeps the revealed report in place. The accessor has none of the handle's
|
|
3944
|
-
functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`) and no
|
|
3945
|
-
the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
|
|
4130
|
+
functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`, `setItemHeightEstimate`) and no
|
|
4131
|
+
selection backdoor: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
|
|
3946
4132
|
|
|
3947
4133
|
### Localization (`locale` / `labels`)
|
|
3948
4134
|
|
|
@@ -3960,9 +4146,9 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
3960
4146
|
/>
|
|
3961
4147
|
```
|
|
3962
4148
|
|
|
3963
|
-
- **One surface for all wording.** The catalog covers the 11 engine chrome keys of `@aiquants/virtualscroll` plus
|
|
3964
|
-
page title (`title`, passed to `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row
|
|
3965
|
-
card's position read to screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the
|
|
4149
|
+
- **One surface for all wording.** The catalog covers the 11 engine chrome keys of `@aiquants/virtualscroll` plus 111 own keys: field headings, the
|
|
4150
|
+
page title (`title`, passed to `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row states (read and
|
|
4151
|
+
starred, editing) and the List card's position read to screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the
|
|
3966
4152
|
search's match indicator, the status screen and the page's status region and the mutation-failure message with its repeat count. The engine part of
|
|
3967
4153
|
each catalog is `@aiquants/virtualscroll`'s own catalog of the same locale, reused by value, so the scroll arrows, the names of the scroll bar and
|
|
3968
4154
|
its thumb, and the "No items" text always speak the same language as the rest.
|
|
@@ -3970,12 +4156,12 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
3970
4156
|
and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
|
|
3971
4157
|
its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
|
|
3972
4158
|
re-render the memoized list subtree.
|
|
3973
|
-
- **Formatter keys.** Twenty-
|
|
4159
|
+
- **Formatter keys.** Twenty-two keys take arguments and are functions: `totalCount(count)`,
|
|
3974
4160
|
`debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
|
|
3975
4161
|
`streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
|
|
3976
4162
|
`interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
|
|
3977
4163
|
`DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
|
|
3978
|
-
`toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`,
|
|
4164
|
+
`toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `rowEditingState({ isDraft })`, `listRowPosition(position, total)`,
|
|
3979
4165
|
`listRowPositionInUnknownTotal(position)`,
|
|
3980
4166
|
`searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`),
|
|
3981
4167
|
`searchMatchedKeywords(matched, total)`, `searchMatchedKeywordsDescription(matched, total)`, `searchFieldMatched(field)` and
|
|
@@ -3986,7 +4172,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
3986
4172
|
locale built once (the row positions and counts re-render on every stream publish); a host override receives
|
|
3987
4173
|
the raw number and formats it itself.
|
|
3988
4174
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
3989
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
4175
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 122 keys), a string
|
|
3990
4176
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
3991
4177
|
function. An `undefined` value keeps the catalog value.
|
|
3992
4178
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `locale`, `labels`,
|
|
@@ -4024,7 +4210,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
4024
4210
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
4025
4211
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
4026
4212
|
|
|
4027
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
4213
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 122 keys, the 11 engine keys
|
|
4028
4214
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
4029
4215
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
4030
4216
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -4130,6 +4316,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
4130
4316
|
| `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
|
|
4131
4317
|
| `noticeRepeated` | the error notice, beside its message when failures with the same message follow one another (`count` is how many came in a row, from 2; part of the notice's name) | `(2)` → (2 times) | `(2)` → (2 回目) |
|
|
4132
4318
|
| `rowState` | visually hidden row state, the first description of a card row in both views and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
|
|
4319
|
+
| `rowEditingState` | visually hidden state of a DetailList row that shows its edit form in place of the card (the viewer's own draft opens there by itself), the row's description | `({ isDraft: true })` → Editing draft; `({ isDraft: false })` → Editing | `({ isDraft: true })` → 下書きを編集中; `({ isDraft: false })` → 編集中 |
|
|
4133
4320
|
| `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 件目 |
|
|
4134
4321
|
| `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`; it also ends the description of a card row in both views while the size is unknown | `(3)` → Item 3 | `(3)` → 3 件目 |
|
|
4135
4322
|
| `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 | `("作成者")` → 作成者で一致 |
|
|
@@ -4145,23 +4332,29 @@ prevents the package from hardcoding specific third-party service names or desig
|
|
|
4145
4332
|
sourceTypeConfigs: {
|
|
4146
4333
|
Internal: {
|
|
4147
4334
|
label: "Native",
|
|
4148
|
-
badgeClassName: "bg-
|
|
4335
|
+
badgeClassName: "bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-400",
|
|
4149
4336
|
},
|
|
4150
4337
|
ExternalCRM: {
|
|
4151
4338
|
label: "External CRM",
|
|
4152
|
-
badgeClassName: "bg-red-100 text-red-
|
|
4339
|
+
badgeClassName: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-300",
|
|
4153
4340
|
},
|
|
4154
4341
|
LegacyPortal: {
|
|
4155
4342
|
label: "LP",
|
|
4156
4343
|
name: "Legacy Portal",
|
|
4157
|
-
badgeClassName: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-
|
|
4344
|
+
badgeClassName: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
|
4158
4345
|
},
|
|
4159
4346
|
}
|
|
4160
4347
|
```
|
|
4161
4348
|
|
|
4162
|
-
-
|
|
4163
|
-
|
|
4164
|
-
`
|
|
4349
|
+
- The badge's colours are the host's: the package's 7:1 guard reads only the package's own classes, so a `badgeClassName` must keep its text at 7:1
|
|
4350
|
+
(WCAG 1.4.6) itself, against its fill composited over the card and over the overlay in both schemes (light: white under both; dark: `slate-900/70`
|
|
4351
|
+
over `slate-950` under the card, `slate-900` under the overlay). The pairs above do, by the package's contrast math at the lower of the two
|
|
4352
|
+
surfaces: 8.47:1 light and 7.93:1 dark for emerald (the built-in `Internal` badge's own pair), 8.22:1 and 8.54:1 for red, 7.23:1 and 8.63:1 for
|
|
4353
|
+
blue. A `-800` text on a green or red `-100` fill falls short in light (6.45:1 and 6.86:1), and a red or blue `-400` text on a `-900/30` fill falls
|
|
4354
|
+
short in dark (5.68:1 and 5.94:1 over the overlay).
|
|
4355
|
+
- `label` is the text on the badge. When it is an abbreviation, give the full name in `name` (WCAG 3.1.4): a badge whose label differs from its `name`
|
|
4356
|
+
renders the label as `<abbr title="<name>">` (pointer users see the expansion), hides the abbreviation from assistive technology and reads the name
|
|
4357
|
+
once instead. Without `name`, or with a `name` equal to the label (which abbreviates nothing), the badge shows its label and nothing is substituted.
|
|
4165
4358
|
A `name` that is given must be a non-blank string; anything else (`""`, `" "`, `null`, a number) throws a `RangeError` at render that names the
|
|
4166
4359
|
source type.
|
|
4167
4360
|
- The badge is part of the row description (see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)): the List card's
|
|
@@ -4278,7 +4471,9 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4278
4471
|
`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
|
|
4279
4472
|
a viewer's share of the `reports` and `search-marks` admissions), and the answer of `search-marks`: `DailyReportSearchMarksAnswer`
|
|
4280
4473
|
(`{ wordsDigest, reports, deferred }`) of `DailyReportSearchReportMarks` (a report's id, `matchedSets` and `fields`) and
|
|
4281
|
-
`DailyReportSearchFieldMarks` (a field's `fingerprint`, with its `ranges` or `dense: true`)
|
|
4474
|
+
`DailyReportSearchFieldMarks` (a field's `fingerprint`, with its `ranges` or `dense: true`), and the answer of `reports`:
|
|
4475
|
+
`DailyReportSearchDetailsAnswer` (`{ outcomes }`, one `DailyReportSearchDetailOutcome` per requested id in request order: `ok` with its `report`,
|
|
4476
|
+
`not_found` or `failed`).
|
|
4282
4477
|
- **client** (`@aiquants/daily-report/client`, React):
|
|
4283
4478
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the
|
|
4284
4479
|
providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider:
|
|
@@ -4307,6 +4502,8 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4307
4502
|
session's client is not public. The route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
|
|
4308
4503
|
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` /
|
|
4309
4504
|
`DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
|
|
4505
|
+
- The source-type fold `foldDailyReportSourceTypeToken`, the same function as the shared entry's, for the host's route modules that ship to the
|
|
4506
|
+
browser (the shared entry is not split, so a route module that imports the fold from it carries a second copy of shared modules into its chunk).
|
|
4310
4507
|
- The search's filter controls: `DAILY_REPORT_SEARCH_FILTER_FACETS` with its type `DailyReportSearchFilterFacet` (the filters in the filter row's
|
|
4311
4508
|
order, `author`, `sourceType` and `customer`), and the contract of the control a host renders in each filter cell:
|
|
4312
4509
|
`DailyReportSearchFilterSelectRenderer` (what `config.search.renderFilterSelect` is) /
|
|
@@ -4317,20 +4514,30 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4317
4514
|
slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
|
|
4318
4515
|
- The rows' shape: `DailyReportRows` (`Pick<ReadonlyArray<DailyReportItem>, "length" | "at"> & Iterable<DailyReportItem>`: what
|
|
4319
4516
|
`DailyReportActionState.items` is and both views' `dailyReportItems` take; a plain array is one too).
|
|
4517
|
+
- The selection's phase: `DailyReportSelectionPhase` (`"held"` for each key of a held-key repeat stream, `"settled"` for every other selection and
|
|
4518
|
+
once at a stream's end: the second argument of both views' `onSelectItem`). Every way a stream ends (the key's release, another key, a pointer
|
|
4519
|
+
press, a wheel, focus leaving the view, the view unmounting) passes the stream's last selection once as `"settled"`, unless the host settled
|
|
4520
|
+
another selection during the stream or no key of the stream selected. A host may record `"held"` selections without rendering them; rendering
|
|
4521
|
+
them is also correct, at the cost of a render per key.
|
|
4320
4522
|
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `DailyReportClientConfig` (the resolved configuration
|
|
4321
4523
|
`useDailyReportConfig` returns) / `DailyReportHeaderProps` (what `renderHeader` receives) / `DailyReportSearchSettingInput` /
|
|
4322
4524
|
`DailyReportSearchSetting` (`config.search` as passed and as resolved) / `DailyReportSourceTypeConfig` (one entry of `sourceTypeConfigs`) /
|
|
4323
4525
|
`DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` /
|
|
4324
4526
|
`DailyReportSseConnectionStatus`,
|
|
4325
|
-
|
|
4326
|
-
`
|
|
4327
|
-
|
|
4527
|
+
the options of the test handle's reveal `DailyReportRevealOptions` and `DailyReportListNavigationHandle` (the read-only getters of the views'
|
|
4528
|
+
`VirtualScroll` handle that the testing entry's `DailyReportViewTestReadHandle` builds on) (see [Test hooks](#test-hooks)).
|
|
4529
|
+
- Test instrumentation: `DailyReportInstrumentationContext` (null by default; a host's test build provides the testing entry's instrumentation
|
|
4530
|
+
through it, and production provides none) with its value's contract `DailyReportInstrumentation` / `DailyReportInstrumentedView` /
|
|
4531
|
+
`DailyReportViewInstrumentation` (see [Test hooks](#test-hooks)).
|
|
4328
4532
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
4329
4533
|
- Factories: `createDailyReportServer` (whose `knownSourceTypes` lists the package's own source type, `Internal`, then each `externalSources`
|
|
4330
4534
|
adapter's, frozen) / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`;
|
|
4331
4535
|
`readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
|
|
4332
4536
|
and for the search `searchLimits`, `null` without the `search` block, `searchFilterSourceTypes` (the source types a filter may name: frozen, the
|
|
4333
|
-
very array of `knownSourceTypes`), `searchDailyReports` (it takes `DailyReportSearchCriteria`), `getDailyReportDetailsByIdsByExternalId
|
|
4537
|
+
very array of `knownSourceTypes`), `searchDailyReports` (it takes `DailyReportSearchCriteria`), `getDailyReportDetailsByIdsByExternalId` (it
|
|
4538
|
+
answers one `DailyReportSearchDetailOutcome` per requested id, in request order, and takes the request's `{ signal }` like
|
|
4539
|
+
`markDailyReportSearchHits`: validated before any query, its reason thrown at the next check after an abort, before a query or after a yield
|
|
4540
|
+
between two reports' copies),
|
|
4334
4541
|
`markDailyReportSearchHits` (it takes the request's `{ signal }`, validated before any query, and throws its reason at its next check after an
|
|
4335
4542
|
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),
|
|
4336
4543
|
`listDailyReportSearchFacets`, `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema`
|
|
@@ -4344,7 +4551,7 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4344
4551
|
decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` /
|
|
4345
4552
|
`DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
4346
4553
|
- SSE and caching: `buildDailyReportSseStreamKey(streamKey)` (the Redis stream key the service writes and reads the SSE entries of, the base key
|
|
4347
|
-
with the wire version appended, `<streamKey>:
|
|
4554
|
+
with the wire version appended, `<streamKey>:v4`, for a host that reads or writes the stream itself) / `DailyReportSseReader` /
|
|
4348
4555
|
`readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`,
|
|
4349
4556
|
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
|
|
4350
4557
|
contract) /
|
|
@@ -4369,7 +4576,10 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4369
4576
|
the ports' own types `DailyReportResolveUserId` / `DailyReportEncodeUserId` / `DailyReportResolveVisibleSourceTypes` (with
|
|
4370
4577
|
`DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityOptOut` (the explicit opt-out
|
|
4371
4578
|
`{ unrestricted: <reason> }`), `DailyReportVisibilityResolveContext` and `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
|
|
4372
|
-
`DailyReportSseEntryMessage` (an entry's message read once
|
|
4579
|
+
`DailyReportSseEntryMessage` (an entry's message read once: `parsed`, a `DailyReportSseStreamMessage` — the message as the service writes it to
|
|
4580
|
+
the stream, read by `dailyReportSseStreamMessageSchema`: the types that may be addressed carry the server-only `recipientRawUserId`, and the types
|
|
4581
|
+
that embed a report also the server-only `actorRawUserId`, which the delivery replaces with each viewer's `viewerIsActor` — and `published`, the JSON
|
|
4582
|
+
value as written, which only the recipient filter reads; every frame is built from `parsed`) / `DailyReportExternalSource` (an `externalSources` adapter),
|
|
4373
4583
|
the injected tables `DailyReportHubTable` / `DailyReportInternalTable` / `DailyReportCommentTable` / `DailyReportLabelTable` /
|
|
4374
4584
|
`DailyReportHubLabelTable` / `DailyReportUserStatusTable` / `DailyReportUserTable` / `DailyReportAttachmentTable` (the members of
|
|
4375
4585
|
`DailyReportTables`), the service's rows `DailyReportUserStatusRow` / `DailyReportIdsSource`,
|
|
@@ -4390,6 +4600,13 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4390
4600
|
`DailyReportSearchCriteria` (what `searchDailyReports` takes: `{ plan, filters }`, the plan `null` for a filters-only search) /
|
|
4391
4601
|
`DailyReportSearchFilters` (the parsed filters, each `null` when unset: the source types as a lower-case set, the authors and customers as
|
|
4392
4602
|
`DailyReportSearchNameFilter`, a non-empty tuple) / `DailyReportSearchFacets` (the answer of `search-facets`).
|
|
4603
|
+
- **testing** (`@aiquants/daily-report/testing`, a host's tests):
|
|
4604
|
+
- `createDailyReportTestInstrumentation()`, the value a host's test build provides through `DailyReportInstrumentationContext` (see
|
|
4605
|
+
[Test hooks](#test-hooks)).
|
|
4606
|
+
- Types: `DailyReportViewTestHandle` (the views' test handle) / `DailyReportViewTestReadHandle` (its read-only part) /
|
|
4607
|
+
`DailyReportRowPassCounters` / `DailyReportRowPassKindReading` (what its `rowPass` reads) and the row pass's events it counts,
|
|
4608
|
+
`DailyReportRowPassEvent` / `DailyReportRowPassUnitFrameEvent` / `DailyReportRowPassUnitKind` / `DailyReportRowBoxSizing` /
|
|
4609
|
+
`DailyReportRowUnitChange`, and `DailyReportReEstimateCounters` (what its `estimate` reads: the re-estimates and those made in motion).
|
|
4393
4610
|
|
|
4394
4611
|
## License
|
|
4395
4612
|
|