feat(web,analytics): /analytics route shell — date-range toolbar, trust header - #2115
Conversation
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech-lead review — ❌ Request changes (documentation obligations, not code defects)
Scope assessed: diffed against origin/1986-analytics-page-shell-plan — this PR's own base, not main. That isolates 32 files / ~1.7k lines: the /analytics route, the features/analytics slice, 101 lines of index.css, and plan docs. The 1985-… migration rename and order-record spec churn visible in the raw stat come from the base stack and are not assessed here.
A genuinely well-built route shell. Date range is URL state, the trust read is consumed honestly, tests are real, architectural hygiene is clean. What blocks is documentation-of-record.
BLOCKING
1. The cited mockup isn't reachable from this branch. analytics-date-range-toolbar.tsx:118 and the index.css trust-header block both cite docs/plans/mockups/analytics-ledger-2003.html as normative ("matches the mockup's own .gap-mark treatment verbatim", "frame 01"). That file is on neither main nor pr2115 — it's added by #2018, which is still open.
So this isn't a fabricated reference, it's a merge-order dependency — but as it stands the conformance claims are unverifiable by any reviewer, and I couldn't check the implementation against the Ledger package as I intended to. Either sequence #2018 first and rebase, or re-point the comments at what actually lands.
Worth knowing: I have open findings on #2018 that this PR is about to bake in. The sharpest is that DataTable's footer has no card-view answer, so on mobile the Total row and per-currency subtotals disappear entirely. If that's still unresolved when this builds, it ships that way.
2. docs/frontend-ui-style-guide.md isn't updated. § Density & Row Heights is explicit: "Never introduce a row height that isn't on this list without updating the guide first." index.css adds .trust-header__row — an auto-height grid row at var(--space-3) var(--space-4) padding, a new list-row surface with no entry (nearest neighbours are DataTable 36 px and Status banner ~64 px).
Two further additions are un-namespaced, generically-named and reusable, which makes them de-facto primitives with no catalogue entry: .info-popover-trigger (a 20×20 icon button with its own hover/focus treatment) and .gap-mark (the † marker). Either namespace them .analytics-* or document them.
IMPORTANT
3. analytics-page.tsx:33-40 — empty-dependency useEffect closing over from/to. The effect reads from, to, searchParams, setSearchParams but declares []. There's a prose comment ("Runs once on mount only") but no eslint suppression — and CLAUDE.md forbids a bare disable, so the fix is a disable with its reason inline. I couldn't confirm whether react-hooks/exhaustive-deps is error-level here because no CI has reported; worth running pnpm lint locally.
4. analytics-trust-header.tsx:47 — inline style={{ display: 'flex', … }}. The style guide's posture is that every surface is styled via index.css; this one-off escapes both the stylesheet and any future theme audit. Give it a class alongside the other new rules.
Suggestions
date-range.lib.ts:48—toUtcRangeInstantsis exported with zero call sites (only the plan docs mention it). Dead code shipped ahead of #1990; drop it or leave it unexported until a consumer exists.AnalyticsTrustSnapshot.worstStatusis typed but never read. The per-connection banner covers the same ground, but a page-level one-line verdict is the cheapest possible use of a field the backend already computes.analytics-trust-header.tsx:97— a trailing<span />purely to fill the 4th grid column; a 3-column template says the same without a phantom element.
Worth calling out
- Date range is genuinely URL state (
useSearchParams,:23), including writing resolved defaults back with{ replace: true }so the resting state is a shareable link. Exactly right, and the thing most likely to have been done withuseState. - Honest states, no green default.
STATUS_TONEmapsdisconnected → error,unknown → neutral,never-ingested → neutral— nothing falls back tosuccess. Absent-data paths render distinct copy ("First orders are still arriving… Nothing is missing; it is not here yet"), and the #2083 earliest-order-date gap is disclosed in the header's own JSDoc rather than papered over withconnectionCreatedAt— the popover copy even states connected-since "is not a claim about how far back its order history goes". That's the same conflation #2037 was blocked on, and it's pre-empted here. - Colour never the only signal (
StatusBadge withDot+ text on every row); tokens throughout with zero raw hex in 101 new CSS lines;0.8125rem/0.6875remare the guide's canonical body and eyebrow steps; mobile ≤767 collapses.trust-header__rowto one column and wraps the toolbar. - Dependency direction clean, feature barrel present, no raw
fetch, noplatformTypedispatch, noany, lazy route split, six page-level tests plus per-component and per-lib tests.
CI: nothing has reported on a3e785e9 (total_count: 0). Nothing red — nothing run. Given finding 3, get lint + apps/web tests green on Node 22 before merge (Node 25 breaks ~63 of them via localStorage/happy-dom, and the suite flakes under full-suite parallelism — neither would be this PR's fault).
Merge readiness: ❌ Blocked on the style-guide entries and the mockup reference — both same-PR documentation obligations rather than code defects. Expect a small follow-up commit, not a rework. Targets 1986-analytics-page-shell-plan, so the stack lands in order.
…ckup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
piotrswierzy
left a comment
There was a problem hiding this comment.
Tech Lead re-review (head 83fce35d) — ❌ Request changes
Both of my prior blockers are genuinely resolved, and the way the first one was resolved is better than I asked for. But the branch has picked up new blockers, so I can't approve yet.
Blocker 1 (fabricated placeholder UI) — resolved, confirmed. Every hardcoded metric, mock series and fake delta is gone. The page now renders only the date-range toolbar plus states derived from one real read, GET /analytics/trust (shipped in #1982). I grepped the whole web delta for percentages, currency amounts, mock/sample/placeholder/dummy/TODO — every remaining hit is inside a *.test.tsx. No chart, no invented number. The empty states make no claims about the operator's data: "Connect a sales channel to see figures here" and "First orders are still arriving / Nothing is missing; it is not here yet." That second one is exactly the right register.
Blocker 2 (route/nav conventions) — resolved, confirmed. analytics.route.tsx follows the convention with lazy() + RouteCrumbHandle, is registered in coreChildren under AuthenticatedAppLayout like its siblings, and EXPECTED_LAZY_ROUTE_COUNT was updated 50 → 51 with the comment amended. The nav item is defensible now that the page shows a real read.
Blocker 3 (tests) — not resolved. The tests were written — 6 page tests covering loading / empty / still-arriving / healthy / degraded / error-with-retry, plus component, hook and lib suites — but two fail, and CI has never run on this PR at all (total_count: 0).
BLOCKING
1. The branch is red — 2 failing tests. Reproduced locally on Node 22.22.1, both failing in isolation, so this is neither the Node-25 happy-dom issue nor the full-suite parallelism flake:
features/analytics/lib/date-range.lib.test.ts:16— expectscomputePresetRange('90d', 2026-08-14)→from: '2026-05-16', gets'2026-05-17'. The implementation is right and the test is wrong:2026-05-17 … 2026-08-14inclusive is exactly 90 days;2026-05-16is 91. Consistent with the passing 7d/30d cases. Fix the expectation, notdate-range.lib.ts.analytics-date-range-toolbar.test.tsx:72—getByText('Order date †')fails because the chip splits that string across a text node and a nested<span>†</span>(analytics-date-range-toolbar.tsx:121-123). Needs a normalizer function matcher or a scopedtextContentassertion.
(Four further suites errored on collection in my sandbox on an unresolvable posthog-js. That's an artefact of how the throwaway worktree symlinked node_modules, not a defect here — but it does leave those four unverified, which CI would settle.)
2. CI has produced zero check runs. The PR body's checklist leaves pnpm test unticked "for CI", and CI didn't pick it up — so nothing has ever executed this code. Please land a green run; the two failures above are exactly what it exists to catch.
IMPORTANT
3. analytics-date-range-toolbar.tsx:33 leaks schema jargon into operator-facing copy. ORDER_DATE_CAVEAT = 'placedAt is not a column and cannot be filtered today' reaches end users twice — as the † glyph's title, and inside the chip's aria-label, so a screen-reader user hears "Order date. placedAt is not a column and cannot be filtered today" as the control's name. An operator doesn't know what placedAt is. This is developer rationale; it belongs in the file header (where the same fact is already recorded), or rephrased for an operator if it must be visible.
4. "Current to" and "has not ingested since" are labelled off lastPollAt, which the backend explicitly says does not mean that. analytics-trust-header.tsx:72 renders lastPollAt under "Current to", and analytics-degradation-banner.tsx:38 renders "{name} has not ingested since {lastPollAt}". The DTO being mirrored is unambiguous (analytics-trust-response.dto.ts:50-53): lastPollAt is "a liveness signal for the ingestion pipe itself, not proof that any order data has arrived — see lastOrderIngestedAt for that." The honest field is fetched and typed (analytics-trust.types.ts:26) and never rendered. As written the header asserts data currency from a pipe-liveness value — the same class of false claim about the operator's data as the original placeholder-metrics blocker, just smaller. Either relabel to "Last polled" or render lastOrderIngestedAt on the recency row.
SUGGESTION
:122— the caveat sits on a baretitle, not keyboard- or touch-reachable.shared/uiexports aTooltip.analytics-page.tsx:24/ toolbar:40—useRef(new Date()).currentfreezes "today" at mount, so a dashboard left open across midnight lights the wrong preset.date-range.lib.ts:5— the header advertises "UTC-widening math"; the file has none (all formatting is local-time viaformatDate). Stale from an earlier draft.- The date range is written to the URL but consumed by nothing, so the presets and Apply look functional while changing no output. Acceptable for a shell, and the
†gestures at it — but a plain-language line in the page description beats schema jargon in a tooltip.
Epic-shape check against #2018
I couldn't read #2018 (404 via the API), but on substance there's no premature commitment: analytics-trust.types.ts mirrors the already-shipped AnalyticsTrustResponseDto field-for-field (all 10 match, including connectionCreatedAt, expectedIntervalMs, staleAfterMs) and invents no metrics shape. No duplicate query layer either — analyticsTrust is one more namespace on the existing CoreApiClient, read through TanStack Query with a keyed factory. Whatever #2018 specifies for revenue/order series stays unconstrained.
Conventions
Clean throughout. Server state → TanStack Query; URL state → useSearchParams with resolved defaults written back via replace: true so the resting state is a shareable link; no ad-hoc global store; no raw fetch(); loading, error-with-Retry and two distinct empty states present; tokens only, zero raw hex; no any.
Merge readiness
❌ Not ready, and two of the reasons are outside the code. The base branch is 1986-analytics-page-shell-plan — not main, and not the 1985-order-analytics-read-model the body claims — so this cannot reach main until its base does, and the body is stale. The body also still reads "Draft PR — not closing #1986 yet, waiting for #1985 update" while the PR is open, non-draft, and carries no Closes #1986.
Fix the two test defects, land a green CI run, address findings 3 and 4, and reconcile the base/closing statement. Once those are done this is a genuinely good shell — the fabricated-UI problem is fully and thoughtfully resolved, which was the hard part.
…t ingestion copy
- Fix two failing tests: the 90d preset test asserted an off-by-one date
(implementation was correct), and the disclaimer-chip test failed to
match the tooltip-split text node. Also fixes a third, previously
undetected failure in the degradation-banner "renders nothing" test,
which asserted an empty DOM even though renderWithProviders always
mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
operator-facing aria-label/tooltip copy; move the rationale into a
code comment and replace the bare `title` with a keyboard-reachable
Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
"has not ingested since" copy, both of which asserted data currency
from `lastPollAt` — a pipe-liveness signal, not proof any order data
arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
date-range.lib.ts (the file only does local-time formatting).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
piotrswierzy
left a comment
There was a problem hiding this comment.
Delta re-review (83fce35d → c9673175) — ❌ Request changes
Every code-level finding from the last round is genuinely fixed in the source. What's left is entirely process: CI has still never run, and the base/body state is unmergeable in any reading.
| # | Status | Evidence |
|---|---|---|
| B1(a) 90d expectation | ✅ and they fixed the correct side | date-range.lib.test.ts:16 changed '2026-05-16' → '2026-05-17'; date-range.lib.ts arithmetic untouched. Both previously-failing files run clean locally, 13/13 |
| B1(b) split-text-node chip query | ✅ | Now getByText((_c, el) => el?.textContent === 'Order date †') |
| B2 zero CI check runs | ❌ Not resolved | get_check_runs on c9673175 → total_count: 0; combined status pending, total_count: 0 |
| I1 schema jargon in the a11y name | ✅ | ORDER_DATE_CAVEAT is now "This range doesn't filter results yet — coming soon"; the placedAt fact moved to a code comment tracked to #1990 |
I2 lastPollAt mislabelled |
✅ | Current to → Last polled; banner → has not been polled since / has never been polled; the popover now states outright it "is not proof that new order data has arrived". lastOrderIngestedAt stays fetched-and-unrendered, which the relabel makes honest rather than misleading |
S bare title |
✅ | Replaced with the shared/ui Tooltip, tabIndex={0}, :focus-visible ring via var(--shadow-focus) |
| S stale "UTC-widening math" header | ✅ | Rewritten to "local-time, inclusive day ranges" |
| S frozen "today" | ⬜ Not resolved | Still in two places now |
| Base branch | ❌ Not resolved | Still 1986-analytics-page-shell-plan @ fd8a5ad2 |
| PR body | ❌ Not resolved | Still ends "Draft PR — not closing #1986 yet, waiting for #1985 update" |
Spot-checked the two blockers resolved last round: both held. No fabricated UI reappeared — every rendered fact traces to ConnectionIngestionTrust. Route/nav conventions intact. Sweep for any, raw fetch(, raw hex across features/analytics and pages/analytics: zero hits. State ownership correct.
BLOCKING
1. CI has still never run on this PR, on any head. Two rounds of "left for CI (per project convention)" against a PR where CI produces nothing means nothing has ever been verified by the pipeline. I confirmed the two specific specs locally, but I ran on Node 25 — precisely the configuration known to break ~63 apps/web tests via localStorage shadowing — so my local run is not a substitute for a green suite. This needs a real signal before merge.
2. Base and body are unreconciled, and it's worse than a stale note. The diff against fd8a5ad2 carries commit 12e843d2 — "fix(orders): resolve migration timestamp collision and merge-broken tests (#1985)" — a migration rename plus two backend spec files. So this PR isn't only based on unlanded work, it's carrying a fix to it. The current state (non-draft, open, targeting a plan branch, body saying "Draft") can't merge under any reading. Pick one: retarget to main once #1985 lands, or return this to draft while it's stacked.
SUGGESTION
analytics-date-range-toolbar.tsx:13deep-imports the tooltip parts from../../../shared/ui/tooltipbecauseshared/ui/index.ts:58re-exports onlyTooltip. Widen the barrel and import like the neighbouringButton, SegmentedControlon the line above.useRef(new Date()).currentis now duplicated inpages/analytics/analytics-page.tsx:24andfeatures/analytics/components/analytics-date-range-toolbar.tsx:45. Beyond freezing "today" at mount, the two mounts can now disagree with each other. Hoist to one source if it stays.- The tooltip trigger is a
<span tabIndex={0}>with no role, inside a chip that already carries the fullaria-label— keyboard-focusable now, but a screen-reader user lands on an unlabelled stop. Either<button type="button">styled flat, oraria-hiddenon the glyph since the parent label already carries the caveat. - The delta strips trailing commas from multi-line call arguments across four files. That matches
.prettierrc("trailingComma": "es5") but not how the rest of the repo's.tsxis committed — andformat:checkonly globs{ts,js,json,md}, so.tsxis unchecked either way. Harmless and contained, but worth knowing before anyone runs Prettier more widely: this repo reformats ~1000 files on a broad glob.
CI: none — total_count: 0 on c9673175.
Merge readiness: ❌ three gates, none of them code: green CI, base retargeted (or back to draft), body rewritten to drop the stale "Draft PR" note and state the closing intent for #1986. The code itself is in good shape.
fd8a5ad to
0d3965d
Compare
…st header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
…ckup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
…t ingestion copy
- Fix two failing tests: the 90d preset test asserted an off-by-one date
(implementation was correct), and the disclaimer-chip test failed to
match the tooltip-split text node. Also fixes a third, previously
undetected failure in the degradation-banner "renders nothing" test,
which asserted an empty DOM even though renderWithProviders always
mounts a toast region.
- Stop leaking schema jargon ("placedAt is not a column") into
operator-facing aria-label/tooltip copy; move the rationale into a
code comment and replace the bare `title` with a keyboard-reachable
Tooltip.
- Relabel the trust-header "Current to" row and the degradation banner's
"has not ingested since" copy, both of which asserted data currency
from `lastPollAt` — a pipe-liveness signal, not proof any order data
arrived. Now "Last polled" / "has not been polled since".
- Drop the stale "UTC-widening math" file-header claim in
date-range.lib.ts (the file only does local-time formatting).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
c967317 to
5abbac1
Compare
…shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com>
piotrswierzy
left a comment
There was a problem hiding this comment.
Delta re-review (c9673175 → 63ac38d9) — ❌ Request changes
Every code finding from the prior round is fixed, and the delta is a real improvement. I'm blocking on one non-code reason only, and it's the same one as last time.
| Prior gate | Status | Evidence |
|---|---|---|
| G1 CI has produced zero check runs | ❌ Not resolved | total_count: 0 on 63ac38d9; status pending, zero contexts. Third consecutive head with nothing ever verified |
G2a base not main, body claimed the wrong branch |
✅ documented | Base is still 1986-analytics-page-shell-plan, and the body now says so explicitly and correctly |
| G2b diff carried a fix to its own unlanded base | ✅ | 12e843d2 is an ancestor of neither head nor base; the equivalent fix now lives in the base branch (183f1de4). The PR's diff is web + docs only — zero migrations, zero backend specs. Correctly relocated |
| G3 stale "Draft PR" note | ✅ | Now reads "Open (not draft) — stacked on #2098, which is stacked on #1985. Not closing #1986 yet…" — accurate and self-explaining |
| Original: fabricated placeholder metrics | ✅ stays fixed | Grepped the full web diff for mock|sample|placeholder|dummy, percentages, currency amounts — every hit is test scaffolding or prose. No metric rendered anywhere |
| Original: route/nav conventions | ✅ stays fixed | lazy(), RouteCrumbHandle, registered in root.route.tsx + nav-registry.ts, covered by route-lazy.test.ts |
The delta itself is right. ConnectionIngestionTrust.earliestOrderDate replaces the connectionCreatedAt approximation in the "Data from" row, the popover copy was rewritten to describe the real fact rather than the old disclaimer, and the null case renders 'No orders yet' instead of a fabricated date — with a test pinning that branch. That's the same instinct as the lastPollAt relabel last round: state an ingested fact, not a proxy for one.
Consistency with the now-landed #2018 design package: checked, consistent. The mockup is on main and the shell's primitives line up — trust-header* class names, the "Data from" coverage row, the "About these dates" popover, the preset/Custom toolbar. Nothing needs reshaping.
BLOCKING
1. Nothing has ever run in CI, across three heads. The body's own test plan still shows pnpm --filter @openlinker/web test unchecked and "left for CI (per project convention)" — so the ~9 web test files this PR adds, including the new earliestOrderDate branch test, have not been executed by anyone, by your own account.
The likely cause is workflow branch/path filtering: every sibling PR that produces runs is based on main, and this one is based on a topic branch. That's a CI-configuration problem rather than a code problem — but it means this PR's correctness is unverified, which is exactly what I blocked on last round. Either get the workflow to trigger on this base, or retarget once the stack lands and let main's pipeline run it (which finding 2 requires anyway).
IMPORTANT
2. The merge-order dependency is now load-bearing on a type, not just on the plan. earliestOrderDate is a required, non-optional field on the FE ConnectionIngestionTrust (analytics-trust.types.ts:28), and the backend supplying it exists only in this PR's base chain — git grep earliestOrderDate origin/main -- apps/api libs/core returns nothing, while the same grep against the base hits the DTO, controller, core service and domain type.
So merging this shell ahead of the #1985 → #2098 → #2083 stack would render a "Data from" row backed by an absent field. That's a correct consequence of stacking rather than a defect, but it now has to be enforced at merge time — worth stating in the body beside the existing stacking note, since a reader of the body currently learns the stack order but not that it is a hard requirement.
SUGGESTION (all four prior items still open, all non-blocking)
analytics-page.tsx:24andanalytics-date-range-toolbar.tsx:45both holduseRef(new Date()).current— two independently frozen "today" values that can disagree across midnight or if the toolbar remounts while the page doesn't. One owner passingtodaydown removes the class of bug.analytics-date-range-toolbar.tsx:13deep-imports the tooltip parts becauseshared/ui/index.ts:58re-exports onlyTooltip. One-line barrel widening keeps the boundary honest.:131— the tooltip trigger is a<span tabIndex={0}>with norole, inside a chip that already carries the fullaria-label, so a keyboard user tabs onto an unlabelled role-less stop. DroppingtabIndexis cleanest unless the tooltip carries content the chip label doesn't.- The URL range restores
from/toon mount, but the toolbar derives no preset from it — so/analytics?from=…&to=…matching 30d exactly still lands on Custom rather than lighting 30d. Cosmetic; noting it so it's a decision rather than an oversight.
CI: none — total_count: 0 on 63ac38d9. I ran nothing locally: this machine is on Node 25, where apps/web has a known ~63-failure localStorage/happy-dom incompatibility, so a local result would carry no signal either way.
Merge readiness: ❌ not ready, for one non-code reason. Get a single green run — retargeting after the #1985/#2098/#2083 stack lands is the simplest route and is required anyway per finding 2 — and this flips to approve with the four suggestions optional.
* feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): emit currency total for single-contributing-channel groups groupChannelTotalsByCurrency skipped a currency's Total row whenever only one channel contributed to it, so a deployment with exactly one connection per currency (e.g. one EUR shop) silently lost that row. No spec basis for the threshold — the by-channel currency total should render for every distinct currency present, regardless of how many channels contribute. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 re-review — deep-link connection resolution Reuses deriveCoverageHeadline's own connection resolution for the coverage deep link's connectionId param instead of re-deriving it with a weaker predicate, so the bulk-wizard link can never name a channel the headline declined to name. Also derives the all-clear checkedCount from the evaluated categories rather than a hardcoded literal. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import in sales-analytics.api.test.ts Pre-existing lint error surfaced while validating the 1986 merge — vi was imported but never used in this file. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a currency-neutral total on the failed-sync row `deriveFailedSyncHeadline` formatted `totalValue` with no currency symbol ("6,120.64 of orders never reached a destination"), which reads as a real monetary figure to an operator even though the DTO carries no currency at all — the same misrepresentation risk the mixedCurrency branch already guarded against, just less obviously so. Both branches now render the same count-only shape; `totalValue` stays on the wire for future consumers, this headline just stops reading it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a duplicate/colliding unconverted Total row groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)` row per distinct unconvertedCurrency found across channels, with no regard for whether that currency string collided with the real reporting-currency Total row's label — a domestic-currency channel simply awaiting its first FX-stamp pass produced a second, same-labelled "Total · PLN" row computed from unrelated fields, reading as a contradiction rather than two distinct facts. Drop that row entirely. countUnconvertedOrders reports the currency-agnostic total count as a single footnote sentence under the table instead ("N orders not yet converted to the reporting currency — excluded from the figures above"), never a competing Total row and never a bare currency-neutral amount. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop the coverage deep-link from naming a channel the headline declined to deriveCoverageHeadline's connection-naming rule requires items.length === totalCount, every item missing from exactly one connection, and one distinct id. The "Publish now" deep link recomputed its own, weaker predicate (only the last condition), so a partial-but-uniform sample could pin a connectionId into the wizard link while the headline right next to it correctly fell back to the connection-agnostic copy — sending the operator into a wizard pre-scoped to a channel the row never actually asserted (#2120 re-review, IMPORTANT). deriveCoverageHeadline now returns connectionId (string | null) alongside the copy, computed by the same predicate; the component reads it instead of re-deriving one. Added a regression test pinning the exact partial-sample/uniform-connection case from the review. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191) * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a03. * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on #2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review) Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal` was a bare, unparenthesized `X OR Y` string spliced into `${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR precedence turned that into `X OR (Y AND Z)` instead of the intended `(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly every unstamped row, the guard fired unconditionally and `unconverted_currency` fell to NULL far more often than the data warranted. Fixed by parenthesizing the constant at its definition (both getTopProductRanking and getProductChannelBreakdown); pinned by the existing int-spec against real Postgres (a mocked unit spec cannot observe operator precedence) and recorded in docs/lessons.md. Also addresses the two still-open review IMPORTANT findings on the FE table: - `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every channel cell (the enrichment failure makes missingFromConnectionIds unreliable for the whole response), rendering the real 0 instead, with a footnote explaining the check is unavailable. - `unresolvedProductCount > 0` is now disclosed via a footnote rather than silently absorbed. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): period-over-period delta on the sales KPI strip Adds a "vs previous period" delta to Orders, Order value, Units and Cancellation rate on the /analytics KPI strip — a second GET /analytics/sales call over the immediately-preceding period of the same length, refused outright (GapMark) unless the entire previous window is covered by ingested order history (per-connection earliest-order-date, #2083). Matches the design mockup's delta anatomy (docs/plans/mockups/ analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only spoken sentence, and count/amount deltas rendered as a relative "%" while rate deltas (cancellation rate) render as an absolute "pp" — a rate moves in points, not percent. Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): mount top-products table and reveal Publish on hover ProductSalesTable (#1991) was fully built end-to-end but never mounted on AnalyticsPage, so the top-products section never rendered. Also add the .cell-not-listed hover/focus CSS the component's own doc comment already described but that was never written — the "Not listed" label now swaps for a Publish action on hover/focus (with a light warning-yellow glow), staying permanently visible on touch pointers. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> * fix(web,analytics): make GapMark's caveat reachable without a mouse A native `title` on a non-interactive, non-focusable `<span>` whose entire content is a dagger glyph never surfaces for a keyboard user, and a screen reader has no accessible name to announce beyond "dagger". Add role="img" + aria-label={title} alongside the existing title so the caveat is announced regardless of input modality (#2120 review). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…, page (#2098) * docs(analytics): implementation plan for #1986 route shell Plan for the /analytics route shell (date-range control, trust header), branched off the current #1985 order-analytics-read-model state per the user's request, since two of its decisions (coverage-window row, degradation-banner rule) explicitly track #1985 and its follow-up #2083. Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy route, but the parameterized route-lazy contract test's expected count was never bumped, failing CI on this branch with "expected 52 to be 51". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2098 tech review + trust-header single-line layout - Sync docs/plans/implementation-plan-analytics-page-shell.md with the now-resolved Decision 3 (real earliestOrderDate coverage row) and Decision 4 (hasSalesInRange dropped), and note the Reusable Components divergences. - Replace the analytics-date-range-toolbar's Tooltip-based "Order date" caveat with a Popover on a real <button>, matching AnalyticsTrustHeader's pattern — Radix Tooltip ignores pointerType === 'touch', making the old trigger unreachable on mobile. - Trust header renders a single-line "data from X · synced Y" fact string with a per-channel colored dot, replacing the prior two-column label/value layout; adds TimeDisplay's 'time' format and formatAbsoluteTime helper it depends on. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact The "data from" prefix was rendered unconditionally, so a connection with no earliestOrderDate read "data from no orders yet" instead of just "no orders yet". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address remaining #2098 review findings - date-range.lib.ts: add toUtcRangeInstants — the single conversion point future /analytics/* consumers must use to turn this toolbar's local-day, inclusive from/to into the backend's UTC, to-exclusive range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/ exclusive and local/UTC mismatches flagged in the /pr-review pass, pinned with tests. - ingestion-trust.lib.ts: rename shouldShowDegradationBanner to selectDegradedConnections (it returns the degraded subset, not a boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus> with a comment on why 'unknown' is deliberately excluded. - analytics-page.tsx: adopt PageLayout instead of hand-rolled page-header markup; document the frozen `today` ref decision. - Sync the implementation plan doc with all of the above. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#2120) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): emit currency total for single-contributing-channel groups groupChannelTotalsByCurrency skipped a currency's Total row whenever only one channel contributed to it, so a deployment with exactly one connection per currency (e.g. one EUR shop) silently lost that row. No spec basis for the threshold — the by-channel currency total should render for every distinct currency present, regardless of how many channels contribute. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 re-review — deep-link connection resolution Reuses deriveCoverageHeadline's own connection resolution for the coverage deep link's connectionId param instead of re-deriving it with a weaker predicate, so the bulk-wizard link can never name a channel the headline declined to name. Also derives the all-clear checkedCount from the evaluated categories rather than a hardcoded literal. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import in sales-analytics.api.test.ts Pre-existing lint error surfaced while validating the 1986 merge — vi was imported but never used in this file. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a currency-neutral total on the failed-sync row `deriveFailedSyncHeadline` formatted `totalValue` with no currency symbol ("6,120.64 of orders never reached a destination"), which reads as a real monetary figure to an operator even though the DTO carries no currency at all — the same misrepresentation risk the mixedCurrency branch already guarded against, just less obviously so. Both branches now render the same count-only shape; `totalValue` stays on the wire for future consumers, this headline just stops reading it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a duplicate/colliding unconverted Total row groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)` row per distinct unconvertedCurrency found across channels, with no regard for whether that currency string collided with the real reporting-currency Total row's label — a domestic-currency channel simply awaiting its first FX-stamp pass produced a second, same-labelled "Total · PLN" row computed from unrelated fields, reading as a contradiction rather than two distinct facts. Drop that row entirely. countUnconvertedOrders reports the currency-agnostic total count as a single footnote sentence under the table instead ("N orders not yet converted to the reporting currency — excluded from the figures above"), never a competing Total row and never a bare currency-neutral amount. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop the coverage deep-link from naming a channel the headline declined to deriveCoverageHeadline's connection-naming rule requires items.length === totalCount, every item missing from exactly one connection, and one distinct id. The "Publish now" deep link recomputed its own, weaker predicate (only the last condition), so a partial-but-uniform sample could pin a connectionId into the wizard link while the headline right next to it correctly fell back to the connection-agnostic copy — sending the operator into a wizard pre-scoped to a channel the row never actually asserted (#2120 re-review, IMPORTANT). deriveCoverageHeadline now returns connectionId (string | null) alongside the copy, computed by the same predicate; the component reads it instead of re-deriving one. Added a regression test pinning the exact partial-sample/uniform-connection case from the review. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191) * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a03. * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on #2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review) Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal` was a bare, unparenthesized `X OR Y` string spliced into `${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR precedence turned that into `X OR (Y AND Z)` instead of the intended `(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly every unstamped row, the guard fired unconditionally and `unconverted_currency` fell to NULL far more often than the data warranted. Fixed by parenthesizing the constant at its definition (both getTopProductRanking and getProductChannelBreakdown); pinned by the existing int-spec against real Postgres (a mocked unit spec cannot observe operator precedence) and recorded in docs/lessons.md. Also addresses the two still-open review IMPORTANT findings on the FE table: - `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every channel cell (the enrichment failure makes missingFromConnectionIds unreliable for the whole response), rendering the real 0 instead, with a footnote explaining the check is unavailable. - `unresolvedProductCount > 0` is now disclosed via a footnote rather than silently absorbed. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): period-over-period delta on the sales KPI strip Adds a "vs previous period" delta to Orders, Order value, Units and Cancellation rate on the /analytics KPI strip — a second GET /analytics/sales call over the immediately-preceding period of the same length, refused outright (GapMark) unless the entire previous window is covered by ingested order history (per-connection earliest-order-date, #2083). Matches the design mockup's delta anatomy (docs/plans/mockups/ analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only spoken sentence, and count/amount deltas rendered as a relative "%" while rate deltas (cancellation rate) render as an absolute "pp" — a rate moves in points, not percent. Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): mount top-products table and reveal Publish on hover ProductSalesTable (#1991) was fully built end-to-end but never mounted on AnalyticsPage, so the top-products section never rendered. Also add the .cell-not-listed hover/focus CSS the component's own doc comment already described but that was never written — the "Not listed" label now swaps for a Publish action on hover/focus (with a light warning-yellow glow), staying permanently visible on touch pointers. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> * fix(web,analytics): make GapMark's caveat reachable without a mouse A native `title` on a non-interactive, non-focusable `<span>` whose entire content is a dagger glyph never surfaces for a keyboard user, and a screen reader has no accessible name to announce beyond "dagger". Add role="img" + aria-label={title} alongside the existing title so the caveat is announced regardless of input modality (#2120 review). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… split (#1988) (#2172) * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) Adds GET /analytics/top-products - products ranked by revenue or units for a date range, each row carrying its own per-channel breakdown, catalog metadata, and a listing-coverage-gap flag. Stacked on #1987's currency- correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM via each order's own implicit FX multiplier), never silently summing across currencies and always disclosing what's unstamped/cancelled. - OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown - buildTopProducts pure aggregation + IOrderRecordService.getTopProducts - TopProductsController/DTOs + apps/api-layer TopProductsService composing orders + products + listings (coverage-gap flag, O(connections) fan-out, degrades gracefully on failure - mirrors NeedsAttentionService) - Fixes a pre-existing gap: order_line_items was missing from the integration-test harness's tablesToTruncate list (no DB FK to cascade from order_records), which would leak rows between test files Built following docs/plans/implementation-plan-top-products-analytics.md (pre-implement gate: READY, included in this PR). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. (cherry picked from commit 5a39290) Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988) getTopProductRanking already folded a prior reporting-currency era (or a never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave the frontend no way to label that figure — unlike the #1987 by-channel read, which already carries unconvertedCurrency for the identical situation. Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow, TopProductView, TopProductRowDto): the one native currency shared by every order contributing to unconvertedRevenue, or null when that set mixes currencies, mirroring DailyOrderAggregateRow's existing rule. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics,products): address #2172 review findings on top-products ranking Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech review, all still open on this branch: - IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over a non-unique sort was non-deterministic in Postgres (ties could repeat on one page and be skipped on the next). Add addOrderBy('product_id', 'ASC'). - IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free) silently vanished from both revenue and unconvertedRevenue, since the FX multiplier (reportingTotalAmount / totalAmount) is NULL via NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead, same as a never-stamped order, in both getTopProductRanking and getProductChannelBreakdown. - SUGGESTION 3: documented, in the endpoint's @apioperation description, that ranking by revenue is blind to unconverted revenue for a product whose orders are all unstamped. - SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent getVariantsByProductId calls. Added a batch getVariantsByProductIds (ProductVariantRepositoryPort -> IProductsService) so the page's variant ids resolve in one query instead of one per product. - SUGGESTION 5: a coverage-gap enrichment failure degraded every row to missingFromConnectionIds: [], indistinguishable from "listed everywhere". Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the two apart. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review) The ranking row's unconvertedRevenue gained a currency label in an earlier fix, but the per-channel breakdown row didn't, so ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no unit. Inheriting the parent's label isn't sound either: the parent goes null on a mixed set, but an individual channel's own subset is routinely single-currency even then — a channel is strictly more labelable than the product as a whole, never less. Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape getTopProductRanking already uses, computed per (product, connection) in getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and ProductChannelBreakdownDto. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): address remaining #2172 review findings - applyTopProductsScope now requires rec."totalAmount" IS NOT NULL, matching applySalesAnalyticsScope; the doc comment no longer claims byte-for-byte alignment it didn't hold (IMPORTANT 1). - unconvertedCurrency's label guard now also requires zero NULL rec."currency" rows in the filtered set, since COUNT(DISTINCT ...) alone ignores NULLs and could mislabel a {NULL, 'PLN'} mix as 'PLN' (SUGGESTION 3, same fix needed on both getTopProductRanking and getProductChannelBreakdown). - TopProductRowDto.revenue now documents that it is LINE revenue, not a per-product slice of order revenue, and that ranking is blind to unconvertedRevenue (IMPORTANT 2). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): parenthesize unconvertedOrZeroTotal in top-products currency guard The unconverted_currency CASE guard concatenated the OR-joined unconvertedOrZeroTotal predicate with `AND rec."currency" IS NULL` without parentheses. Since SQL AND binds tighter than OR, this parsed as `A OR (B AND C)` instead of the intended `(A OR B) AND C`, so the "no NULL currency in the unconverted set" guard was satisfied whenever the reporting-currency mismatch term alone was true — the common case for any unstamped row — causing a single-currency channel/product to mislabel unconvertedCurrency as null. Same bug in both getTopProductRanking and getProductChannelBreakdown; fixed by wrapping the shared predicate in parens at both call sites. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): #1986 route shell — trust header, date-range toolbar, page (#2098) * docs(analytics): implementation plan for #1986 route shell Plan for the /analytics route shell (date-range control, trust header), branched off the current #1985 order-analytics-read-model state per the user's request, since two of its decisions (coverage-window row, degradation-banner rule) explicitly track #1985 and its follow-up #2083. Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy route, but the parameterized route-lazy contract test's expected count was never bumped, failing CI on this branch with "expected 52 to be 51". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2098 tech review + trust-header single-line layout - Sync docs/plans/implementation-plan-analytics-page-shell.md with the now-resolved Decision 3 (real earliestOrderDate coverage row) and Decision 4 (hasSalesInRange dropped), and note the Reusable Components divergences. - Replace the analytics-date-range-toolbar's Tooltip-based "Order date" caveat with a Popover on a real <button>, matching AnalyticsTrustHeader's pattern — Radix Tooltip ignores pointerType === 'touch', making the old trigger unreachable on mobile. - Trust header renders a single-line "data from X · synced Y" fact string with a per-channel colored dot, replacing the prior two-column label/value layout; adds TimeDisplay's 'time' format and formatAbsoluteTime helper it depends on. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact The "data from" prefix was rendered unconditionally, so a connection with no earliestOrderDate read "data from no orders yet" instead of just "no orders yet". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address remaining #2098 review findings - date-range.lib.ts: add toUtcRangeInstants — the single conversion point future /analytics/* consumers must use to turn this toolbar's local-day, inclusive from/to into the backend's UTC, to-exclusive range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/ exclusive and local/UTC mismatches flagged in the /pr-review pass, pinned with tests. - ingestion-trust.lib.ts: rename shouldShowDegradationBanner to selectDegradedConnections (it returns the degraded subset, not a boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus> with a comment on why 'unknown' is deliberately excluded. - analytics-page.tsx: adopt PageLayout instead of hand-rolled page-header markup; document the frozen `today` ref decision. - Sync the implementation plan doc with all of the above. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#2120) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): emit currency total for single-contributing-channel groups groupChannelTotalsByCurrency skipped a currency's Total row whenever only one channel contributed to it, so a deployment with exactly one connection per currency (e.g. one EUR shop) silently lost that row. No spec basis for the threshold — the by-channel currency total should render for every distinct currency present, regardless of how many channels contribute. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 re-review — deep-link connection resolution Reuses deriveCoverageHeadline's own connection resolution for the coverage deep link's connectionId param instead of re-deriving it with a weaker predicate, so the bulk-wizard link can never name a channel the headline declined to name. Also derives the all-clear checkedCount from the evaluated categories rather than a hardcoded literal. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import in sales-analytics.api.test.ts Pre-existing lint error surfaced while validating the 1986 merge — vi was imported but never used in this file. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a currency-neutral total on the failed-sync row `deriveFailedSyncHeadline` formatted `totalValue` with no currency symbol ("6,120.64 of orders never reached a destination"), which reads as a real monetary figure to an operator even though the DTO carries no currency at all — the same misrepresentation risk the mixedCurrency branch already guarded against, just less obviously so. Both branches now render the same count-only shape; `totalValue` stays on the wire for future consumers, this headline just stops reading it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a duplicate/colliding unconverted Total row groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)` row per distinct unconvertedCurrency found across channels, with no regard for whether that currency string collided with the real reporting-currency Total row's label — a domestic-currency channel simply awaiting its first FX-stamp pass produced a second, same-labelled "Total · PLN" row computed from unrelated fields, reading as a contradiction rather than two distinct facts. Drop that row entirely. countUnconvertedOrders reports the currency-agnostic total count as a single footnote sentence under the table instead ("N orders not yet converted to the reporting currency — excluded from the figures above"), never a competing Total row and never a bare currency-neutral amount. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop the coverage deep-link from naming a channel the headline declined to deriveCoverageHeadline's connection-naming rule requires items.length === totalCount, every item missing from exactly one connection, and one distinct id. The "Publish now" deep link recomputed its own, weaker predicate (only the last condition), so a partial-but-uniform sample could pin a connectionId into the wizard link while the headline right next to it correctly fell back to the connection-agnostic copy — sending the operator into a wizard pre-scoped to a channel the row never actually asserted (#2120 re-review, IMPORTANT). deriveCoverageHeadline now returns connectionId (string | null) alongside the copy, computed by the same predicate; the component reads it instead of re-deriving one. Added a regression test pinning the exact partial-sample/uniform-connection case from the review. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191) * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a03. * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on #2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review) Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal` was a bare, unparenthesized `X OR Y` string spliced into `${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR precedence turned that into `X OR (Y AND Z)` instead of the intended `(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly every unstamped row, the guard fired unconditionally and `unconverted_currency` fell to NULL far more often than the data warranted. Fixed by parenthesizing the constant at its definition (both getTopProductRanking and getProductChannelBreakdown); pinned by the existing int-spec against real Postgres (a mocked unit spec cannot observe operator precedence) and recorded in docs/lessons.md. Also addresses the two still-open review IMPORTANT findings on the FE table: - `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every channel cell (the enrichment failure makes missingFromConnectionIds unreliable for the whole response), rendering the real 0 instead, with a footnote explaining the check is unavailable. - `unresolvedProductCount > 0` is now disclosed via a footnote rather than silently absorbed. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): period-over-period delta on the sales KPI strip Adds a "vs previous period" delta to Orders, Order value, Units and Cancellation rate on the /analytics KPI strip — a second GET /analytics/sales call over the immediately-preceding period of the same length, refused outright (GapMark) unless the entire previous window is covered by ingested order history (per-connection earliest-order-date, #2083). Matches the design mockup's delta anatomy (docs/plans/mockups/ analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only spoken sentence, and count/amount deltas rendered as a relative "%" while rate deltas (cancellation rate) render as an absolute "pp" — a rate moves in points, not percent. Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): mount top-products table and reveal Publish on hover ProductSalesTable (#1991) was fully built end-to-end but never mounted on AnalyticsPage, so the top-products section never rendered. Also add the .cell-not-listed hover/focus CSS the component's own doc comment already described but that was never written — the "Not listed" label now swaps for a Publish action on hover/focus (with a light warning-yellow glow), staying permanently visible on touch pointers. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> * fix(web,analytics): make GapMark's caveat reachable without a mouse A native `title` on a non-interactive, non-focusable `<span>` whose entire content is a dagger glyph never surfaces for a keyboard user, and a screen reader has no accessible name to announce beyond "dagger". Add role="img" + aria-label={title} alongside the existing title so the caveat is announced regardless of input modality (#2120 review). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * docs(orders): explain why getTopProducts reads are sequential, not parallel getProductChannelBreakdown is scoped to the current page's productIds, which only exist once getTopProductRanking has returned — unlike the three independent Promise.all reads in getSalesAndChannelAnalytics above it. Note the distinction so a reader doesn't mistake the missing parallelisation for an oversight (#2172 review, SUGGESTION 2). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…2151) * feat(orders,analytics): add sales & channel aggregates endpoint (#1987) Adds GET /analytics/sales: revenue, order count, AOV, median order value, units sold, and cancelled count/value for a date range, plus a 7-day daily trend (revenue + order count), at headline level and broken down per source connection with a revenue share and a coverage-completeness signal (reusing #2083's getEarliestOrderDateByConnection so a channel that can't possibly cover the full requested range is identifiable in the response). Built entirely on top of the #1985 order analytics read model (order_records.placedAt/totalAmount/cancelledAt, order_line_items) - one new pure aggregation function in the orders domain layer, two new OrderRecordRepositoryPort methods (daily FILTER-clause aggregates, PERCENTILE_CONT median), one new OrderLineItemRepositoryPort method (units sold per connection), and one new IOrderRecordService method composing them - entirely intra-context, no new cross-context edge. Currency-mixing detection and gross/net tax-treatment normalization are deliberately out of scope - tracked under #2049/ADR-040 (currency) and a separate, not-yet-scoped tax-normalization effort respectively. totalAmount is summed as-is; this is called out explicitly in code comments so the omission reads as a scoping decision, not a gap. Includes the implementation plan doc this PR follows. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): report cancelled count/value per channel too (#1987) The issue's own follow-up comment asks for cancelled count/value "headline and, if feasible, per channel". It's feasible at zero extra cost: DailyOrderAggregateRow already carries cancelledCount/ cancelledValue per (day, connection), so this only sums data the existing query already returns - no new query, no new repository method. Adds ChannelSalesAnalytics.cancelledCount/cancelledValue, threads them through the aggregation function, the response DTO, and adds a test asserting the per-channel totals sum back to the headline figure. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): wire sales aggregates to reportingTotalAmount now that #2049 has landed This PR's own scope table deferred currency-mixing detection to #2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050) while this PR was still open, stamping order_records.reportingCurrency/ reportingTotalAmount - so the fix lands here rather than as a follow-up issue. - getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and medianOrderValue now sum/percentile reportingTotalAmount restricted to reportingCurrency IS NOT NULL - one comparable currency, never a naive cross-currency sum. - The complementary unstamped slice (pre-#2049 history, or a stamp still in flight) is surfaced explicitly via new unconvertedCount/ unconvertedValue fields (native totalAmount, informational, may itself mix currencies) rather than silently folded into revenue or silently dropped. - New `currency` field (headline + per channel) reports which reporting currency revenue/AOV/median are expressed in; null when nothing in range is stamped yet. - cancelledCount/cancelledValue deliberately left on native totalAmount, unchanged - a secondary figure, out of scope for this pass. - Threaded through the pure aggregation function, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label the unconverted-currency evidence per channel The by-channel table needs to show each market's own native-currency total for orders not yet FX-stamped, not just a single potentially mixed-currency number - the mockup this scope was designed against (03b · Two currencies) shows per-channel figures split by their own currency, with only the reporting-currency footer pooled. unconvertedCount/unconvertedValue already existed (#2049/ADR-040 follow-up) but carried no currency label and could legitimately mix currencies per the type's own doc comment. This is #1987's own scope, not an FX-epic deliverable: order_records.currency is the pre-existing native-currency column from #1985, untouched by the FX epic's reportingCurrency/reportingTotalAmount stamp - labelling the unconverted evidence is purely an aggregation-query addition. - getDailyOrderAggregates: adds unconverted_currency, the single native currency shared by every unconverted, non-cancelled order this day/connection, NULL when that set mixes currencies. - resolveUniformUnconvertedCurrency (aggregation layer): rolls the per-day label up to headline/channel, treating a day with zero unconverted orders as "nothing to report" rather than letting it poison the whole set to null. - Threaded through DailyOrderAggregateRow, SalesAnalyticsHeadline, ChannelSalesAnalytics, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): guard mixed-currency labels + UTC day buckets (#1987 review) IMPORTANT 1: getDailyOrderAggregates labelled a (day, connection) bucket's revenue with (array_agg(reportingCurrency))[1] — the first value happened to sort first — even though reportingCurrency isn't guaranteed single-valued within a bucket (an in-flight #2096 restatement can leave two live at once). Guarded it with the same COUNT(DISTINCT ...) <= 1 pattern unconvertedCurrency already uses, and gave the domain-layer pickCurrency the matching cross-row disagreement check (resolveUniformReportingCurrency) rather than "first non-null wins". IMPORTANT 2: date_trunc('day', placedAt) truncates at local midnight per the Postgres session TimeZone GUC, since placedAt is timestamptz — on a non-UTC server every bucket would land on the wrong calendar day and silently mismatch enumerateDayKeys's UTC keys, zeroing every trend point beneath a correct headline. Made the boundary explicit: date_trunc('day', placedAt AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'. SUGGESTIONS: medianOrderValue no longer flattens "no stamped order in range" to the same 0 as a genuine zero median (now number | null, plumbed through the DTO); documented the units-vs-orderCount scoping mismatch on OrderLineItemRepositoryPort; added sales-analytics-aggregates.int-spec.ts against Testcontainers Postgres to pin both IMPORTANT fixes against a real server (the reviewer's own root-cause note: the mocked query builder could never have caught either). Also merges in the latest 1985-order-analytics-read-model (this PR's base branch), which had picked up its own review fixes since this branch last merged from it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): make the UTC day-bucket int-spec actually guard the regression Testcontainers Postgres boots with session TimeZone = UTC, so the existing assertion passed identically with or without the AT TIME ZONE 'UTC' pair in getDailyOrderAggregates — it documented intent but couldn't fail on a regression (#2151 review, SUGGESTION). Force the session TimeZone to Europe/Warsaw for this one read (and restore it afterward), so a regression to a bare date_trunc('day', placedAt) actually flips the bucket to the following day and fails the test. SET TIME ZONE is session-scoped; dataSource.query and the repository read run back-to-back with nothing else contending for the pool, so the same just-released client is reused. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): address #2151 re-review — currency-era scoping, null flattening, units population - IMPORTANT 1: getUnitsSoldByConnection now splits into unitsSold/ unconvertedUnitsSold on the SAME reportingCurrency = current-era-stamped population orderCount/revenue use, instead of summing every non-cancelled line regardless of stamp state. - IMPORTANT 2: averageOrderValue and revenueShare now report null (not 0) when there is nothing to report, matching medianOrderValue's existing null-vs-zero distinction. - Notes (ported from #2172's getTopProductRanking fix): getDailyOrderAggregates and getMedianOrderValue now scope orderCount/revenue/median to reportingCurrency = currentReportingCurrency (resolved once per read via IReportingCurrencySettingsService.resolve()) instead of a bare IS NOT NULL, so a reporting-currency setting change folds prior-era stamps into the unconverted bucket rather than silently mixing two currencies into one sum. - Suggestion 3: GET /analytics/sales now rejects a range wider than 400 days. - Suggestion 4: unconverted_currency's uniformity guard now also fails when the unconverted set contains a row with no recorded native currency. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) (#2172) * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) Adds GET /analytics/top-products - products ranked by revenue or units for a date range, each row carrying its own per-channel breakdown, catalog metadata, and a listing-coverage-gap flag. Stacked on #1987's currency- correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM via each order's own implicit FX multiplier), never silently summing across currencies and always disclosing what's unstamped/cancelled. - OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown - buildTopProducts pure aggregation + IOrderRecordService.getTopProducts - TopProductsController/DTOs + apps/api-layer TopProductsService composing orders + products + listings (coverage-gap flag, O(connections) fan-out, degrades gracefully on failure - mirrors NeedsAttentionService) - Fixes a pre-existing gap: order_line_items was missing from the integration-test harness's tablesToTruncate list (no DB FK to cascade from order_records), which would leak rows between test files Built following docs/plans/implementation-plan-top-products-analytics.md (pre-implement gate: READY, included in this PR). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. (cherry picked from commit 5a39290) Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988) getTopProductRanking already folded a prior reporting-currency era (or a never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave the frontend no way to label that figure — unlike the #1987 by-channel read, which already carries unconvertedCurrency for the identical situation. Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow, TopProductView, TopProductRowDto): the one native currency shared by every order contributing to unconvertedRevenue, or null when that set mixes currencies, mirroring DailyOrderAggregateRow's existing rule. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics,products): address #2172 review findings on top-products ranking Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech review, all still open on this branch: - IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over a non-unique sort was non-deterministic in Postgres (ties could repeat on one page and be skipped on the next). Add addOrderBy('product_id', 'ASC'). - IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free) silently vanished from both revenue and unconvertedRevenue, since the FX multiplier (reportingTotalAmount / totalAmount) is NULL via NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead, same as a never-stamped order, in both getTopProductRanking and getProductChannelBreakdown. - SUGGESTION 3: documented, in the endpoint's @apioperation description, that ranking by revenue is blind to unconverted revenue for a product whose orders are all unstamped. - SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent getVariantsByProductId calls. Added a batch getVariantsByProductIds (ProductVariantRepositoryPort -> IProductsService) so the page's variant ids resolve in one query instead of one per product. - SUGGESTION 5: a coverage-gap enrichment failure degraded every row to missingFromConnectionIds: [], indistinguishable from "listed everywhere". Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the two apart. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review) The ranking row's unconvertedRevenue gained a currency label in an earlier fix, but the per-channel breakdown row didn't, so ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no unit. Inheriting the parent's label isn't sound either: the parent goes null on a mixed set, but an individual channel's own subset is routinely single-currency even then — a channel is strictly more labelable than the product as a whole, never less. Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape getTopProductRanking already uses, computed per (product, connection) in getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and ProductChannelBreakdownDto. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): address remaining #2172 review findings - applyTopProductsScope now requires rec."totalAmount" IS NOT NULL, matching applySalesAnalyticsScope; the doc comment no longer claims byte-for-byte alignment it didn't hold (IMPORTANT 1). - unconvertedCurrency's label guard now also requires zero NULL rec."currency" rows in the filtered set, since COUNT(DISTINCT ...) alone ignores NULLs and could mislabel a {NULL, 'PLN'} mix as 'PLN' (SUGGESTION 3, same fix needed on both getTopProductRanking and getProductChannelBreakdown). - TopProductRowDto.revenue now documents that it is LINE revenue, not a per-product slice of order revenue, and that ranking is blind to unconvertedRevenue (IMPORTANT 2). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): parenthesize unconvertedOrZeroTotal in top-products currency guard The unconverted_currency CASE guard concatenated the OR-joined unconvertedOrZeroTotal predicate with `AND rec."currency" IS NULL` without parentheses. Since SQL AND binds tighter than OR, this parsed as `A OR (B AND C)` instead of the intended `(A OR B) AND C`, so the "no NULL currency in the unconverted set" guard was satisfied whenever the reporting-currency mismatch term alone was true — the common case for any unstamped row — causing a single-currency channel/product to mislabel unconvertedCurrency as null. Same bug in both getTopProductRanking and getProductChannelBreakdown; fixed by wrapping the shared predicate in parens at both call sites. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): #1986 route shell — trust header, date-range toolbar, page (#2098) * docs(analytics): implementation plan for #1986 route shell Plan for the /analytics route shell (date-range control, trust header), branched off the current #1985 order-analytics-read-model state per the user's request, since two of its decisions (coverage-window row, degradation-banner rule) explicitly track #1985 and its follow-up #2083. Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy route, but the parameterized route-lazy contract test's expected count was never bumped, failing CI on this branch with "expected 52 to be 51". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2098 tech review + trust-header single-line layout - Sync docs/plans/implementation-plan-analytics-page-shell.md with the now-resolved Decision 3 (real earliestOrderDate coverage row) and Decision 4 (hasSalesInRange dropped), and note the Reusable Components divergences. - Replace the analytics-date-range-toolbar's Tooltip-based "Order date" caveat with a Popover on a real <button>, matching AnalyticsTrustHeader's pattern — Radix Tooltip ignores pointerType === 'touch', making the old trigger unreachable on mobile. - Trust header renders a single-line "data from X · synced Y" fact string with a per-channel colored dot, replacing the prior two-column label/value layout; adds TimeDisplay's 'time' format and formatAbsoluteTime helper it depends on. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact The "data from" prefix was rendered unconditionally, so a connection with no earliestOrderDate read "data from no orders yet" instead of just "no orders yet". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address remaining #2098 review findings - date-range.lib.ts: add toUtcRangeInstants — the single conversion point future /analytics/* consumers must use to turn this toolbar's local-day, inclusive from/to into the backend's UTC, to-exclusive range contract (SalesAnalyticsQueryDto.to). Fixes the inclusive/ exclusive and local/UTC mismatches flagged in the /pr-review pass, pinned with tests. - ingestion-trust.lib.ts: rename shouldShowDegradationBanner to selectDegradedConnections (it returns the degraded subset, not a boolean) and type DEGRADED_STATUSES as Set<ConnectionIngestionStatus> with a comment on why 'unknown' is deliberately excluded. - analytics-page.tsx: adopt PageLayout instead of hand-rolled page-header markup; document the frozen `today` ref decision. - Sync the implementation plan doc with all of the above. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#2120) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): emit currency total for single-contributing-channel groups groupChannelTotalsByCurrency skipped a currency's Total row whenever only one channel contributed to it, so a deployment with exactly one connection per currency (e.g. one EUR shop) silently lost that row. No spec basis for the threshold — the by-channel currency total should render for every distinct currency present, regardless of how many channels contribute. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 re-review — deep-link connection resolution Reuses deriveCoverageHeadline's own connection resolution for the coverage deep link's connectionId param instead of re-deriving it with a weaker predicate, so the bulk-wizard link can never name a channel the headline declined to name. Also derives the all-clear checkedCount from the evaluated categories rather than a hardcoded literal. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import in sales-analytics.api.test.ts Pre-existing lint error surfaced while validating the 1986 merge — vi was imported but never used in this file. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a currency-neutral total on the failed-sync row `deriveFailedSyncHeadline` formatted `totalValue` with no currency symbol ("6,120.64 of orders never reached a destination"), which reads as a real monetary figure to an operator even though the DTO carries no currency at all — the same misrepresentation risk the mixedCurrency branch already guarded against, just less obviously so. Both branches now render the same count-only shape; `totalValue` stays on the wire for future consumers, this headline just stops reading it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop rendering a duplicate/colliding unconverted Total row groupChannelTotalsByCurrency emitted a `Total · {currency} (unconverted)` row per distinct unconvertedCurrency found across channels, with no regard for whether that currency string collided with the real reporting-currency Total row's label — a domestic-currency channel simply awaiting its first FX-stamp pass produced a second, same-labelled "Total · PLN" row computed from unrelated fields, reading as a contradiction rather than two distinct facts. Drop that row entirely. countUnconvertedOrders reports the currency-agnostic total count as a single footnote sentence under the table instead ("N orders not yet converted to the reporting currency — excluded from the figures above"), never a competing Total row and never a bare currency-neutral amount. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): stop the coverage deep-link from naming a channel the headline declined to deriveCoverageHeadline's connection-naming rule requires items.length === totalCount, every item missing from exactly one connection, and one distinct id. The "Publish now" deep link recomputed its own, weaker predicate (only the last condition), so a partial-but-uniform sample could pin a connectionId into the wizard link while the headline right next to it correctly fell back to the connection-agnostic copy — sending the operator into a wizard pre-scoped to a channel the row never actually asserted (#2120 re-review, IMPORTANT). deriveCoverageHeadline now returns connectionId (string | null) alongside the copy, computed by the same predicate; the component reads it instead of re-deriving one. Added a regression test pinning the exact partial-sample/uniform-connection case from the review. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) (#2191) * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a03. * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on #2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): SQL precedence bug + surface coverageGapAvailable/unresolvedProductCount (#2172/#2191 review) Root cause of the failing top-products-ranking int-spec: `unconvertedOrZeroTotal` was a bare, unparenthesized `X OR Y` string spliced into `${unconvertedOrZeroTotal} AND rec."currency" IS NULL`. SQL's AND-before-OR precedence turned that into `X OR (Y AND Z)` instead of the intended `(X OR Y) AND Z` — since X (reportingCurrency mismatch) was true for nearly every unstamped row, the guard fired unconditionally and `unconverted_currency` fell to NULL far more often than the data warranted. Fixed by parenthesizing the constant at its definition (both getTopProductRanking and getProductChannelBreakdown); pinned by the existing int-spec against real Postgres (a mocked unit spec cannot observe operator precedence) and recorded in docs/lessons.md. Also addresses the two still-open review IMPORTANT findings on the FE table: - `coverageGapAvailable: false` now suppresses "Not listed"/Publish on every channel cell (the enrichment failure makes missingFromConnectionIds unreliable for the whole response), rendering the real 0 instead, with a footnote explaining the check is unavailable. - `unresolvedProductCount > 0` is now disclosed via a footnote rather than silently absorbed. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(analytics): period-over-period delta on the sales KPI strip Adds a "vs previous period" delta to Orders, Order value, Units and Cancellation rate on the /analytics KPI strip — a second GET /analytics/sales call over the immediately-preceding period of the same length, refused outright (GapMark) unless the entire previous window is covered by ingested order history (per-connection earliest-order-date, #2083). Matches the design mockup's delta anatomy (docs/plans/mockups/ analytics-ledger-2003.html): an aria-hidden ↑/↓/→ glyph, a sr-only spoken sentence, and count/amount deltas rendered as a relative "%" while rate deltas (cancellation rate) render as an absolute "pp" — a rate moves in points, not percent. Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): mount top-products table and reveal Publish on hover ProductSalesTable (#1991) was fully built end-to-end but never mounted on AnalyticsPage, so the top-products section never rendered. Also add the .cell-not-listed hover/focus CSS the component's own doc comment already described but that was never written — the "Not listed" label now swaps for a Publish action on hover/focus (with a light warning-yellow glow), staying permanently visible on touch pointers. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> * fix(web,analytics): make GapMark's caveat reachable without a mouse A native `title` on a non-interactive, non-focusable `<span>` whose entire content is a dagger glyph never surfaces for a keyboard user, and a screen reader has no accessible name to announce beyond "dagger". Add role="img" + aria-label={title} alongside the existing title so the caveat is announced regardless of input modality (#2120 review). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * docs(orders): explain why getTopProducts reads are sequential, not parallel getProductChannelBreakdown is scoped to the current page's productIds, which only exist once getTopProductRanking has returned — unlike the three independent Promise.all reads in getSalesAndChannelAnalytics above it. Note the distinction so a reader doesn't mistake the missing parallelisation for an oversight (#2172 review, SUGGESTION 2). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Signed-off-by: jakubret Signed-off-by: Jakub Retajczyk <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…s reporting (#2440) (#2442) * feat(orders,analytics): add sales & channel aggregates endpoint (#1987) Adds GET /analytics/sales: revenue, order count, AOV, median order value, units sold, and cancelled count/value for a date range, plus a 7-day daily trend (revenue + order count), at headline level and broken down per source connection with a revenue share and a coverage-completeness signal (reusing #2083's getEarliestOrderDateByConnection so a channel that can't possibly cover the full requested range is identifiable in the response). Built entirely on top of the #1985 order analytics read model (order_records.placedAt/totalAmount/cancelledAt, order_line_items) - one new pure aggregation function in the orders domain layer, two new OrderRecordRepositoryPort methods (daily FILTER-clause aggregates, PERCENTILE_CONT median), one new OrderLineItemRepositoryPort method (units sold per connection), and one new IOrderRecordService method composing them - entirely intra-context, no new cross-context edge. Currency-mixing detection and gross/net tax-treatment normalization are deliberately out of scope - tracked under #2049/ADR-040 (currency) and a separate, not-yet-scoped tax-normalization effort respectively. totalAmount is summed as-is; this is called out explicitly in code comments so the omission reads as a scoping decision, not a gap. Includes the implementation plan doc this PR follows. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): report cancelled count/value per channel too (#1987) The issue's own follow-up comment asks for cancelled count/value "headline and, if feasible, per channel". It's feasible at zero extra cost: DailyOrderAggregateRow already carries cancelledCount/ cancelledValue per (day, connection), so this only sums data the existing query already returns - no new query, no new repository method. Adds ChannelSalesAnalytics.cancelledCount/cancelledValue, threads them through the aggregation function, the response DTO, and adds a test asserting the per-channel totals sum back to the headline figure. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * docs(analytics): implementation plan for #1986 route shell Plan for the /analytics route shell (date-range control, trust header), branched off the current #1985 order-analytics-read-model state per the user's request, since two of its decisions (coverage-window row, degradation-banner rule) explicitly track #1985 and its follow-up #2083. Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): wire sales aggregates to reportingTotalAmount now that #2049 has landed This PR's own scope table deferred currency-mixing detection to #2049/ADR-040, summing totalAmount as-is. #2049 shipped (PR #2050) while this PR was still open, stamping order_records.reportingCurrency/ reportingTotalAmount - so the fix lands here rather than as a follow-up issue. - getDailyOrderAggregates / getMedianOrderValue: revenue, orderCount and medianOrderValue now sum/percentile reportingTotalAmount restricted to reportingCurrency IS NOT NULL - one comparable currency, never a naive cross-currency sum. - The complementary unstamped slice (pre-#2049 history, or a stamp still in flight) is surfaced explicitly via new unconvertedCount/ unconvertedValue fields (native totalAmount, informational, may itself mix currencies) rather than silently folded into revenue or silently dropped. - New `currency` field (headline + per channel) reports which reporting currency revenue/AOV/median are expressed in; null when nothing in range is stamped yet. - cancelledCount/cancelledValue deliberately left on native totalAmount, unchanged - a secondary figure, out of scope for this pass. - Threaded through the pure aggregation function, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): sales KPI strip + by-channel table (#1990) Adds GET /analytics/sales client, view-model helpers, and the two FE sections #1990 scopes: a 6-card KPI strip (Revenue, Orders, Order value w/ median, Units, Cancellations, Returns & refunds) and a by-channel DataTable, mounted into the #1986 route shell. Currency-aware per #1987/#2049/ADR-040: every money figure carries its currency (headline.reportingCurrency), and a channel's revenueBasis ('reporting' | 'native' | 'unavailable') drives whether its revenue/ share render as plain values, a same-currency-but-incomparable caveat, or an explicit empty value — never a blended or falsely-comparable number. taxTreatment 'mixed' surfaces an inline chip so gross/net incomparability is stated, not implied. A channel whose earliest order postdates the range start renders a "Partial history" flag. Fixes an exclusive-end date bug found in a prior implementation attempt: the toolbar hands this an inclusive yyyy-mm-dd end day, but the endpoint treats `to` as exclusive — toExclusiveEndInstant converts it so the selected range's last day isn't silently dropped. Also fixes a pre-existing test race in orders-list-page.test.tsx: a synchronous assertion on empty-state text that depends on an async query, following an await on a chip that mounts synchronously from a URL param — now awaited with findByText. Closes #1990 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): top-products endpoint with inline per-channel split (#1988) Adds GET /analytics/top-products - products ranked by revenue or units for a date range, each row carrying its own per-channel breakdown, catalog metadata, and a listing-coverage-gap flag. Stacked on #1987's currency- correctness pattern (FILTER (WHERE reportingCurrency IS NOT NULL) / SUM via each order's own implicit FX multiplier), never silently summing across currencies and always disclosing what's unstamped/cancelled. - OrderLineItemRepositoryPort +getTopProductRanking, +getProductChannelBreakdown - buildTopProducts pure aggregation + IOrderRecordService.getTopProducts - TopProductsController/DTOs + apps/api-layer TopProductsService composing orders + products + listings (coverage-gap flag, O(connections) fan-out, degrades gracefully on failure - mirrors NeedsAttentionService) - Fixes a pre-existing gap: order_line_items was missing from the integration-test harness's tablesToTruncate list (no DB FK to cascade from order_records), which would leak rows between test files Built following docs/plans/implementation-plan-top-products-analytics.md (pre-implement gate: READY, included in this PR). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): type the pending-promise mocks in KPI strip/channel table tests CI's `tsc -b` (project-references build) caught what a plain `tsc --noEmit -p tsconfig.json` run missed locally: `vi.fn(() => new Promise(() => {}))` infers `Mock<() => Promise<unknown>>`, which doesn't satisfy `getSales`'s `Promise<SalesAndChannelAnalytics>` return type. Pin the generic on the never-resolving Promise, matching the existing analytics-trust test precedent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): top products table with per-channel breakdown (#1991) Adds the /analytics top-products table: one row per product, per-channel units split, revenue/units sort toggle, and a Publish affordance for channels the product isn't listed on. Fixes a labeling gap found while manually testing against seeded data: a channel absent from the sales breakdown was always rendered "Not listed", even when the product was genuinely listed there and simply had no sale in the selected date range — now only a channel actually missing from `missingFromConnectionIds` gets the "Not listed" + Publish treatment; a listed-but-quiet channel renders the same real, full-weight `0` a channel with sales would. Closes #1991 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label the unconverted-currency evidence per channel The by-channel table needs to show each market's own native-currency total for orders not yet FX-stamped, not just a single potentially mixed-currency number - the mockup this scope was designed against (03b · Two currencies) shows per-channel figures split by their own currency, with only the reporting-currency footer pooled. unconvertedCount/unconvertedValue already existed (#2049/ADR-040 follow-up) but carried no currency label and could legitimately mix currencies per the type's own doc comment. This is #1987's own scope, not an FX-epic deliverable: order_records.currency is the pre-existing native-currency column from #1985, untouched by the FX epic's reportingCurrency/reportingTotalAmount stamp - labelling the unconverted evidence is purely an aggregation-query addition. - getDailyOrderAggregates: adds unconverted_currency, the single native currency shared by every unconverted, non-cancelled order this day/connection, NULL when that set mixes currencies. - resolveUniformUnconvertedCurrency (aggregation layer): rolls the per-day label up to headline/channel, treating a day with zero unconverted orders as "nothing to report" rather than letting it poison the whole set to null. - Threaded through DailyOrderAggregateRow, SalesAnalyticsHeadline, ChannelSalesAnalytics, the response DTOs, and every affected test fixture (repository, service, aggregation, controller specs). Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): align KPI strip/by-channel table with the real #1987 currency contract The frontend types were drafted ahead of the backend and assumed a shape it never shipped (non-null reportingCurrency, revenueBasis/nativeCurrency per channel, taxTreatmentMixed). Now that the actual #1987 currency wiring (reportingTotalAmount stamp + unconvertedCurrency labelling) has been merged in, rewrite the frontend to match it exactly: one nullable system-wide currency, unconvertedCount/Value/Currency per channel, and revenueShare always a number. - Cancellations KPI now leads with the rate (%), value/count as qualifiers. - By-channel table: a channel with no FX-stamped revenue yet falls back to its own unconverted-currency evidence instead of showing an empty cell, flagged with an "Awaiting FX stamp" chip. - Total rows: one reporting-currency total (real KPI aggregate) plus one informational unconverted-currency subtotal per distinct native currency — only emitted when more than one channel contributes, so a lone channel never gets a redundant duplicate total. - Orders/Avg daily/Units per order/Cancellation rate on the KPI strip now count every placed order (stamped + unconverted), not just the stamped subset. - Share and Trend columns reordered so Share sits immediately before Trend (previously Share was misplaced next to Revenue). - Fixed a CSS specificity bug where the Phase-6 dashboard-triage `.status-strip` rule silently won over `.status-strip--analytics` at >=1024px, packing the 6 KPI cards 4-then-2 instead of 3x2. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics route shell — date-range toolbar, trust header (#2115) * feat(web,analytics): /analytics route shell — date-range toolbar, trust header, degradation banner Implements #1986 per docs/plans/implementation-plan-analytics-page-shell.md: - New /analytics route (PageLayout, Operations nav item) - Date-range toolbar: 7d/30d/90d/Custom presets (apply immediately) + From/To fields with a draft-buffered Apply action (Decision 1) - Trust header (per-connection freshness + "Connected since" + status, Decision 3 — real "data from" coverage deferred to #2083/#1985) with a click-triggered info popover (touch-safe, unlike a hover-only Tooltip) - Degradation banner on stalled/disconnected connections — status-only for v1 (Decision 4); the mockup's range-gated "sold in this selected range" refinement is deferred until #1990 makes that fact honest rather than an approximation - Fresh-instance / still-arriving / loading / error states - New analyticsTrust API-client namespace consuming the already-shipped GET /analytics/trust (#1982) Post-review fixes (tech-review pass): - Order-date disclaimer is now a static span (chip+dagger, matches the design mockup verbatim) instead of the interactive Chip primitive, which rendered a toggle button with no effect - Banner timestamp uses the shared formatDateTime helper instead of a hand-rolled toLocaleString(), matching AnalyticsTrustHeader - Added a page-level loading-state test Ref #1986 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 review — style-guide entries, mockup refs, cleanup - Namespace .gap-mark/.info-popover-trigger to .analytics-* and document .trust-header__row in docs/frontend-ui-style-guide.md § Density & Row Heights, per the tech-lead review's documentation-obligation findings. - Drop code-comment claims of verbatim conformance to docs/plans/mockups/analytics-ledger-2003.html (not yet merged via #2018). - Replace inline style on analytics-trust-header.tsx with a real CSS class; drop the phantom trailing grid column. - Remove the dead toUtcRangeInstants export (no consumer yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): address PR #2115 re-review — failing tests, honest ingestion copy - Fix two failing tests: the 90d preset test asserted an off-by-one date (implementation was correct), and the disclaimer-chip test failed to match the tooltip-split text node. Also fixes a third, previously undetected failure in the degradation-banner "renders nothing" test, which asserted an empty DOM even though renderWithProviders always mounts a toast region. - Stop leaking schema jargon ("placedAt is not a column") into operator-facing aria-label/tooltip copy; move the rationale into a code comment and replace the bare `title` with a keyboard-reachable Tooltip. - Relabel the trust-header "Current to" row and the degradation banner's "has not ingested since" copy, both of which asserted data currency from `lastPollAt` — a pipe-liveness signal, not proof any order data arrived. Now "Last polled" / "has not been polled since". - Drop the stale "UTC-widening math" file-header claim in date-range.lib.ts (the file only does local-time formatting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): render the real earliestOrderDate now that #2083 shipped #2083 (real per-connection earliest-order-date read) landed as PR #2121 on this stack's base (1985-order-analytics-read-model), which Decision 3 in the plan flagged as making the "Connected since" coverage row's connectionCreatedAt swap a trivial follow-up rather than a rewrite. - Add earliestOrderDate to the FE ConnectionIngestionTrust type, mirroring the now-shipped ConnectionIngestionTrustResponseDto field. - Trust header: "Connected since"/connectionCreatedAt -> "Data from"/ earliestOrderDate (falls back to "No orders yet" when null), matching the mockup's actual coverage-window semantics instead of the connection-configured-since approximation. - Update the info popover copy and file header comment accordingly. - Add earliestOrderDate to every existing fixture; the never-ingested fixture in analytics-page.test.tsx gets null (no orders, consistent with its status), the rest get a fixed date. Add a "No orders yet" render test. - Plan doc: mark Decision 3 and its risk-register entry resolved rather than rewriting the historical record. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> --------- Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * docs(analytics): implementation plan for /analytics needs-attention section Plans issue #1989 — three actionable categories (coverage gaps, stock at risk, failed-sync value) consuming the already-shipped GET /analytics/needs-attention (#1983), mounted into the #1986 shell. No backend changes; resolves link targets, the mixedCurrency interim (tracked against #2049), and the ambiguous multi-connection copy case. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(web,analytics): /analytics needs-attention section (#1989) Renders the three needs-attention categories — coverage gaps, stock at risk, value stuck in failed syncs — mounted into the #1986 shell. Consumes the already-shipped GET /analytics/needs-attention (#1983) as-is; no backend changes. Either the open rows render or a single all-clear line does, never both, per the design mockup's rule. Each open row deep-links into the flow that resolves it: the unified publish wizard, the product detail page, or the orders list filtered to the needs_attention health bucket. Ambiguous multi-connection cases fall back to a connection-agnostic headline; the failed-sync total renders currency-neutral since the DTO carries no currency field in either the mixed or non-mixed case (interim pending #2049). Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): match needs-attention section to the #2003 mockup The plan (implementation-plan-analytics-needs-attention.md) required a client-side "checked HH:MM" timestamp in the panel header and a neutral-tone Clear badge, mirroring frame 02 of the design mockup (docs/plans/mockups/analytics-ledger-2003.html on the still-open #2018 branch). Both were dropped in the original implementation. Adds the checked-at timestamp (TimeDisplay driven by the query's own dataUpdatedAt, since the DTO carries no such field) and switches the all-clear badge from success to neutral, per spec. Signed-off-by: jakubret Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): add missing earliestOrderDate to a needs-attention fixture Rebase fallout from the earliestOrderDate swap (#2083): the #1989-cherry-picked "keep the trust header rendered when needs-attention fails" test fixture predates that field and failed type-check. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): scope top-products revenue to the current reporting currency (#1988) getTopProductRanking/getProductChannelBreakdown summed every stamped order's reportingTotalAmount regardless of which reporting-currency era it was pinned to, and labeled the mixed sum with an arbitrary array_agg[1] pick. Since a settings change is forward-only (older orders keep their original stamp), switching the reporting currency mixed two real currencies into one number under a wrong label instead of surfacing the older era as unconverted evidence like an unstamped order. OrderRecordService now resolves the current reporting currency and both queries filter revenue to reportingCurrency = current, folding any other era into unconvertedRevenue/unconvertedOrderCount alongside never-stamped orders. (cherry picked from commit 5a39290a44878d1f6a45d405f75a2ad8cc9fbe9f) Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(orders,analytics): scope top-products revenue to the current reporting currency (#1991)" This reverts commit 5a39290a44878d1f6a45d405f75a2ad8cc9fbe9f. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(orders,analytics): disclose the native currency behind unconverted top-products evidence (#1988) getTopProductRanking already folded a prior reporting-currency era (or a never-stamped order) into unconvertedRevenue/unconvertedOrderCount, but gave the frontend no way to label that figure — unlike the #1987 by-channel read, which already carries unconvertedCurrency for the identical situation. Adds unconvertedCurrency end to end (repository SQL, ProductRankingRow, TopProductView, TopProductRowDto): the one native currency shared by every order contributing to unconvertedRevenue, or null when that set mixes currencies, mirroring DailyOrderAggregateRow's existing rule. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web,analytics): show the native-currency evidence behind an unstamped top-products row (#1991) A product whose only orders in range were stamped under a PREVIOUS reporting-currency setting (or never stamped at all) rendered a bare "No FX-stamped order" empty value, even though the backend already exposed the native-currency figure as unconvertedRevenue/unconvertedCurrency (#1988). The Revenue column now falls back to that evidence when there is no current-era stamp, marked informational via a title tooltip — mirroring ChannelSalesTable's identical fallback for the #1987 by-channel read. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): guard mixed-currency labels + UTC day buckets (#1987 review) IMPORTANT 1: getDailyOrderAggregates labelled a (day, connection) bucket's revenue with (array_agg(reportingCurrency))[1] — the first value happened to sort first — even though reportingCurrency isn't guaranteed single-valued within a bucket (an in-flight #2096 restatement can leave two live at once). Guarded it with the same COUNT(DISTINCT ...) <= 1 pattern unconvertedCurrency already uses, and gave the domain-layer pickCurrency the matching cross-row disagreement check (resolveUniformReportingCurrency) rather than "first non-null wins". IMPORTANT 2: date_trunc('day', placedAt) truncates at local midnight per the Postgres session TimeZone GUC, since placedAt is timestamptz — on a non-UTC server every bucket would land on the wrong calendar day and silently mismatch enumerateDayKeys's UTC keys, zeroing every trend point beneath a correct headline. Made the boundary explicit: date_trunc('day', placedAt AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'. SUGGESTIONS: medianOrderValue no longer flattens "no stamped order in range" to the same 0 as a genuine zero median (now number | null, plumbed through the DTO); documented the units-vs-orderCount scoping mismatch on OrderLineItemRepositoryPort; added sales-analytics-aggregates.int-spec.ts against Testcontainers Postgres to pin both IMPORTANT fixes against a real server (the reviewer's own root-cause note: the mocked query builder could never have caught either). Also merges in the latest 1985-order-analytics-read-model (this PR's base branch), which had picked up its own review fixes since this branch last merged from it. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(web): bump the lazy-route contract count to 52 for /settings/mcp-tokens An earlier merge (feat(mcp): Resource-Server auth via user-issued Personal Access Tokens, #1486/#1912) added the /settings/mcp-tokens page as a lazy route, but the parameterized route-lazy contract test's expected count was never bumped, failing CI on this branch with "expected 52 to be 51". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics,products): address #2172 review findings on top-products ranking Two IMPORTANT correctness issues and three SUGGESTIONS from the #2172 tech review, all still open on this branch: - IMPORTANT 1: ORDER BY revenue/units had no tiebreaker, so pagination over a non-unique sort was non-deterministic in Postgres (ties could repeat on one page and be skipped on the next). Add addOrderBy('product_id', 'ASC'). - IMPORTANT 2: a stamped order with totalAmount = 0 (fully discounted/free) silently vanished from both revenue and unconvertedRevenue, since the FX multiplier (reportingTotalAmount / totalAmount) is NULL via NULLIF(totalAmount, 0). It now folds into the unconverted bucket instead, same as a never-stamped order, in both getTopProductRanking and getProductChannelBreakdown. - SUGGESTION 3: documented, in the endpoint's @ApiOperation description, that ranking by revenue is blind to unconverted revenue for a product whose orders are all unstamped. - SUGGESTION 4: resolveCoverageGaps fired up to `limit` concurrent getVariantsByProductId calls. Added a batch getVariantsByProductIds (ProductVariantRepositoryPort -> IProductsService) so the page's variant ids resolve in one query instead of one per product. - SUGGESTION 5: a coverage-gap enrichment failure degraded every row to missingFromConnectionIds: [], indistinguishable from "listed everywhere". Added TopProductsResponseDto.coverageGapAvailable so the FE can tell the two apart. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders): make the UTC day-bucket int-spec actually guard the regression Testcontainers Postgres boots with session TimeZone = UTC, so the existing assertion passed identically with or without the AT TIME ZONE 'UTC' pair in getDailyOrderAggregates — it documented intent but couldn't fail on a regression (#2151 review, SUGGESTION). Force the session TimeZone to Europe/Warsaw for this one read (and restore it afterward), so a regression to a bare date_trunc('day', placedAt) actually flips the bucket to the following day and fails the test. SET TIME ZONE is session-scoped; dataSource.query and the repository read run back-to-back with nothing else contending for the pool, so the same just-released client is reused. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(orders,analytics): label unconvertedCurrency per channel on top-products (#2172 review) The ranking row's unconvertedRevenue gained a currency label in an earlier fix, but the per-channel breakdown row didn't, so ProductChannelBreakdownDto.unconvertedRevenue stayed a bare number with no unit. Inheriting the parent's label isn't sound either: the parent goes null on a mixed set, but an individual channel's own subset is routinely single-currency even then — a channel is strictly more labelable than the product as a whole, never less. Lifts the same MAX(currency) FILTER (...) / COUNT(DISTINCT ...) <= 1 shape getTopProductRanking already uses, computed per (product, connection) in getProductChannelBreakdown, threaded through ProductChannelBreakdownRow and ProductChannelBreakdownDto. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2098 tech review + trust-header single-line layout - Sync docs/plans/implementation-plan-analytics-page-shell.md with the now-resolved Decision 3 (real earliestOrderDate coverage row) and Decision 4 (hasSalesInRange dropped), and note the Reusable Components divergences. - Replace the analytics-date-range-toolbar's Tooltip-based "Order date" caveat with a Popover on a real <button>, matching AnalyticsTrustHeader's pattern — Radix Tooltip ignores pointerType === 'touch', making the old trigger unreachable on mobile. - Trust header renders a single-line "data from X · synced Y" fact string with a per-channel colored dot, replacing the prior two-column label/value layout; adds TimeDisplay's 'time' format and formatAbsoluteTime helper it depends on. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): address #2120 tech review — sample-vs-total headline defect - BLOCKING: deriveCoverageHeadline/deriveStockHeadline only name a connection when the preview sample IS the total (items.length === totalCount); otherwise fall through to the connection-agnostic headline, so a headline never asserts something only a 20-item sample verified. - "Publish now" sub now discloses when it only seeds the sampled variants ("showing the first N of M"). - Fix the "1 variant have a listing gap" grammar bug to a verb-free form, updating the test that had locked it in. - Thread a BCP 47 locale into deriveFailedSyncHeadline instead of hardcoding toLocaleString('en-US'). - Drop the unreachable MAX_WIZARD_IDS cap; derive productIds/variantIds from the same item list instead of two independently sliced arrays. - Render AnalyticsNeedsAttention regardless of order-ingestion status — coverage gaps and stock-at-risk are listing facts, not order facts. - Render .attention-list as <ul>/<li> for list semantics. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * fix(analytics): drop dangling "data from" prefix on the no-orders-yet fact The "data from" prefix was rendered unconditionally, so a connection with no earliestOrderDate read "data from no orders yet" instead of just "no orders yet". Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * chore(tax): open the per-line tax rate epic branch Tracking branch for epic #2245. Children merge here; this branch merges to main only when every child has landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * docs(tax): ADR-052 per-line tax-rate resolution, provenance and rounding ownership Records the rule the #2245 epic implements: the rate arrives from the ProductMaster with the product, the marketplace is a fallback when the master does not know, and when neither knows the document is held rather than guessed. OpenLinker never computes a rate and never computes an amount excluding tax. Seven decisions: the shop-then-channel resolution chain, three answer states (0 is an answer, 'not yet checked' is a fourth read state that suggests a sync rather than blocking), percent-as-string representation with provenance-only country, projection-at-sync storage with the order snapshot as the only place a rate is settled, adapter-owned rounding, gate semantics (a missing rate blocks and also refuses the manual paths; a shop-versus-channel mismatch does not block and is not a SalesDocumentGateBlockReason at all), and the non-goals. Numbered 052, not the 050 the issue names: 050 and 051 are reserved by the ADR README for the #2162 async-work-layer epic and the index directs the next new ADR to 052. Also settles the two edits ADR-014's amendment owed: its 'proposal, not a recorded refinement' preamble is dropped and its Proposed-while-its-decisions-shipped status resolves to Accepted. Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(invoicing): settle the tax-rate notation on percent-as-string InvoiceLine.taxRate had no single reading. Core divided by 100 unconditionally, so '0.23' meant 0.23%; the inFakt adapter switched on n > 1, so '23' and '0.23' both resolved to 23% while a genuine 1% rate resolved to 100%. On a 123 PLN invoice the two readings diverge by 22.72 PLN. The blast radius is narrow only because the mapper still emits an empty taxRate and each adapter substitutes its own default. Once #2054 lands, every invoice line travels through this field, so the notation is pinned first and on its own. A guess is the wrong shape here: the writer knows which notation it used, so an ambiguous value is a defect upstream and must surface. A new pure module, tax-rate-notation.types, states the contract once and every reader goes through it. Fractional notation (numeric, strictly between 0 and 1) raises FractionalTaxRateNotationError rather than being multiplied by 100 - '0.23' is indistinguishable from a genuine 0.23% rate, so normalising it would invent a value nobody stated. '0' is deliberately not fractional: a zero rate is a real answer, and PrestaShop already distinguishes it from unknown. Readers aligned: core rateFraction, the inFakt tax_symbol map and gross-to-net split, the Subiekt stawkaVAT passthrough. KSeF already rejected a fractional code, since FA3_TAX_RATE_MAP has no such key - that is now pinned by a test rather than left incidental. The empty-string path is untouched on every adapter; #2257 removes those defaults. Closes #2247 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): shared-UI primitives for the tax-rate states Three additions the per-line tax-rate surfaces need, and nothing else. StatusBadgeTone gains 'conflict' - two sources disagreeing about the same fact, which needs attention but is not an error because the document still issued. The family has no -fg member, so the rule reads --status-conflict-strong; the ramp base gives roughly 2.5:1 and fails contrast, and nothing would have caught it since the token checker is one-directional and a missing token resolves to nothing silently. The family already had a consumer through a className override (.status-badge.delivery-rider-chip--not-connected), so that override is migrated onto the tone in the same change rather than left as a second owner with a second text token. The className survives as a layout hook, so the existing delivery-chip assertions still hold. AlertTone gains the same member. Alert picks role=alert only for 'error', so a conflict alert lands on role=status - the right politeness level for a non-blocking advisory, now following from the tone rather than from a per-call choice. SalesDocumentBlockCopy['tone'] widens to match, since its value is passed straight into Alert. AbsentValue moves out of listings-list-page into shared/ui. Absence versus zero is the central claim of this epic - a rate of 0 is a real answer and 'no rate' holds the document - so it needs one implementation. It renders the wording visually hidden rather than as an aria-label, because aria-label on a bare span is prohibited and commonly dropped. Adding the tone member compile-forced BLOCK_TONE_FOR_BADGE and TONE_CLASS, the timeline's two exhaustive records; both gain the member together with the TimelineEvent tone and the .order-activity__dot--conflict rule. Closes #2253 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(products): per-line tax rate on the order contract and the master reads Core had nothing to carry a tax rate in. OrderItem and IncomingOrderItem have no tax field, and the only signals on an order are one aggregate OrderTotals.tax and an order-wide taxTreatment - neither of which can describe a mixed-rate basket. So the mapper emits an empty rate and each provider adapter guesses. This gives core the field, the vocabulary, a way to ask the two masters, and a place to keep the answer. The rate is a string code (23, 8, 5, 0, zw, np, oo), not a number: 0, exempt, reverse charge and intra-EU zero are four different things on a document and all look like zero. Notation is percent-as-string, settled in #2247. Storage is a projection pulled at product sync onto products and product_variants, exactly as price and currency already are. Issuance must not depend on the shop being reachable, and 'which products lack a rate' has to be a query rather than a crawl - both partial indexes exist for that. Three distinctions are load-bearing and each has a test. Zero is an answer, never an absence. A read that establishes nothing reports kind: 'unknown' and is stored as a null code, never as '0'. Never-checked is not no-rate. taxRateReadAt separates them: null timestamp means nobody has asked, a timestamp with a null code means the master answered and has none. Without that split, the day this ships the whole catalogue reads as incomplete and the pre-rollout coverage count measures nothing. A variant override always wins where the shop carries one - the open question the epic left for this child. It is not a conflict to arbitrate: a variant value is the more specific statement of the same fact, and the shop had to be edited deliberately for the two to differ. An absent override means 'no opinion', so a product-keyed master resolves through the product row unchanged. A third resolution arm, 'inherited', exists for a WooCommerce variation whose tax_class is 'parent': storing nothing is honest, where recording unknown would show the variant as rate-less and copying the product's code down would leave a duplicate that goes stale. PrestaShop delegates to the existing PrestashopTaxRateResolver rather than re-walking the three-hop chain, so the sync path and the order-create path cannot disagree about one shop. Its transport unknown is re-raised rather than reported as unknown - a failed call says nothing about the shop's configuration, and recording it would freeze a false 'no rate' onto the row. WooCommerce resolves class name to the store's rate table for the store's own country; tax_status 'none' is a resolved zero, no row is not-configured, and several different rates is ambiguous rather than a pick, because picking would be OpenLinker computing tax. recordTaxRate is a separate writer from upsert on both repositories, and the columns are deliberately absent from toOrm - the sync upsert carries no rate, so round-tripping them would blank a value the tax read just wrote, and a blanked rate holds documents. Same single-writer rule as order_records.cancelledAt. Order ingestion settles the rate onto the stored snapshot, shop first and channel second, and carries taxSource plus taxRateReadAt alongside it so a reader can tell no rate, never read and pre-rollout apart (#2245 F3). One additive migration, nullable, no backfill. Closes #2054 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC boundary, aria-label, style guide - toExclusiveEndInstant now anchors on UTC midnight instead of local midnight, matching the controller's UTC-parsed `from` (was silently dropping/adding hours off UTC). - Sparkline aria-labels derive from the actual selected range instead of a hardcoded "last 7 days". - Register the analytics KPI card's 152px/3-col geometry as a documented carve-out in the style guide (Density table + parity matrix), per the "never introduce an undocumented row height" rule. - Fix "Data order" planned-tag typo -> "Planned"; section-infotip font-size to the rem token equivalent. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(invoicing): carry the tax rate onto the document and hold when it is absent The mapper stops emitting an empty taxRate and carries the code the order line was settled with. It stays a passthrough - no derivation, no default - and an empty value still reaches the adapter unchanged, because a mapper that threw would turn an operator-fixable data gap into a failed job. The gate is what refuses. missing-tax-rate joins SalesDocumentGateBlockReasonValues on both sides of the mirror. It is the first reason for which 'this cannot be issued' is literally true: every other one means 'auto-issue did not happen', and issuing by hand past those is a legitimate operator action. Issuing past this one means a provider substituting a guessed rate onto a real fiscal document, so it closes the manual paths too - InvoiceService.issueInvoice refuses before the lock and before any persisted state is touched, POST /invoices answers 422 with the reason and retryable:false, and bulk issue reports a named ineligibility rather than attempting it. The check runs on the COMMAND rather than on the order, so no caller can bypass it by composing lines itself, and the correction path - which composes its lines from an already-issued document - is unaffected. It runs BEFORE the trigger-model gate, because on a manual connection both apply and reporting the weaker reason would leave an operator clicking a button that refuses. A zero rate passes everywhere. Export, intra-EU and exempt goods are legitimately zero, and blocking on them would hold documents for a correctly configured catalogue. Shipping now splits across the rates in a mixed basket, in proportion to line gross, with the rounding remainder on the largest part so the parts sum exactly to what the buyer paid. A single-rate basket still yields one line. This is division, not tax computation: core groups an amount it was given and cuts it into parts that add back up to it. A single line with no rate makes the mix unknowable, so the split refuses and the whole document waits. Two instants record when a hold started and ended. The reason column is level-triggered and nulled the moment it clears, so without them the operator-facing age has no clock and the 'the rate arrived, the invoice issued' timeline entry has no instant to hang on. They are derived from the TRANSITION inside the same UPDATE, because only that statement knows both the old and the new value; blockedAt is stamped on none-to-blocked alone so a change of reason inside one episode does not reset an age somebody is watching. Closes #2248 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * Revert "fix(web): remove unused vi import breaking tsc build" This reverts commit b915a030ac81bac74c483b45472cb613cec194c8. * fix(web): remove unused vi import breaking tsc build CI (Docker Build Smoke Test) failed with TS6133 on an unused `vi` import in sales-analytics.api.test.ts, blocking `pnpm build`. Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(products): append-only tax-rate provenance journal A mutable 'rate source' field only says how things stand now. It cannot answer when the shop changed the rate, what OpenLinker last wrote onto a channel, or whether somebody overwrote it afterwards - and that last question is what makes a shop-versus-channel disagreement attributable rather than mysterious. So provenance is a journal: one row per CHANGE, never one per read. The catalogue sweep runs every twenty minutes and most rates never move, so writing unconditionally would grow the table by the size of the catalogue per tick and bury the handful of rows that matter. isNewTaxRateObservation owns that rule in one pure place. The dedup compares the value, the origin AND the frozen flag. A seller freezing a field without changing its value is a real change in what the value means - it is now something a person set - and losing it would leave the disagreement surface unable to say so. Append-only by construction, not by convention: the port declares append, findLatest and findLatestPerConnection, and no update, upsert or delete. A journal whose rows can be edited cannot answer the question it exists for, so adding a mutating method is not a refactor. Same discipline as ExchangeRateRepositoryPort. Origin distinguishes shop, channel and written-by-us. The third is the reason the journal exists at all: it records OpenLinker's own write onto a channel, so a later channel observation carrying a different value proves somebody changed it after we did. Master product sync records every rate it observes. The write is best-effort and separate from the catalogue write - the journal is provenance, so losing an entry costs an audit trail rather than a rate, and failing the sync over it would trade the thing that matters for the thing that explains it. One index serves both reads, since the latest-for-one-connection lookup and the latest-per-connection listing walk the same prefix. Note on scope: taxSource and taxRateReadAt already reach the ORDER SNAPSHOT line (#2054, epic F3). The order_line_items half of this issue is not implemented here because that table does not exist on main - it is defined in #2014, which is still open. Closes #2250 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(integrations): read the channel's tax rate and propagate the shop's onto offers The channel half of the resolution chain, and the write back. READS. Erli reports a required per-line taxRate on every order line and OL's own type already modelled it while the mapper discarded it - it now reaches the order contract. Allegro's lineItems[].tax has been live since March 2024 and the OL type had no field for it at all; it is modelled and read. Both map their platform vocabulary to the neutral code inside the adapter, tested in both directions, with a round-trip test over every value the Erli mapper claims to support. An unreadable value maps to absent, never to '0'. A rate OL cannot read is not a zero-rated sale, and Erli's enum is category-dependent and may gain values. WRITES. The shop's rate is stamped onto CreateOfferCommand by OfferBuilderService in the same pass that already carries price and stock, read from OL's own catalogue projection so publishing does not depend on the shop being reachable and the offer carries the same rate an invoice for it would. There is no OL-side rate field to type into, deliberately: a rate entered in OL would be a fourth source no master or channel could be corrected from. Nothing is published with the rate omitted. That is precisely how the rate-less offers this epic exists to fix were produced, and the failure surfaces months later on somebody's invoice rather than at publish time. Allegro refuses on no rate, on an exemption code (its rates array carries numbers), and on a rate the category does not allow - the last naming the permitted values, because when Allegro says the category allows 23% and OL sent 5% the shop record is almost certainly the wrong one. Erli refuses on no rate and on 'oo', which its enum cannot express; omitting would publish a product Erli then marks not-buyable with missingTaxRate, which nobody sees. The permitted-values read is best-effort. A failure to LIST is not a failure to publish, so it warns and proceeds with the shop's value; Allegro validates the body itself, and refusing because a secondary discovery call was unavailable would be worse than the check. The update path propagates too, so an offer's rate follows the catalogue instead of freezing at first publish. There an unmappable code is dropped rather than raised: an update that cannot state its tax is a partial update of an offer that already sells, and raising would take a title or price fix down with it. A frozen Erli taxRate is skipped, force is never sent, and it is reported once per connection at info level rather than as a recurring publish error - a seller freezing the field is a deliberate decision, and it is exactly the signal that makes a later disagreement attributable to a person. Closes #2249 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(orders): mark pre-rollout orders and measure catalogue rate coverage Two rollout chores that keep the first day honest. HISTORICAL ORDERS. An order ingested before per-line rates existed carries none on any line, and the document issued for it used whatever the provider adapter defaulted to. It is MARKED rather than blocked: blocking would stop history nobody is going to retrofit, and nothing about it can be corrected after the fact. The marker's only job is to keep a net-revenue figure honest - such an order is excluded from one rather than presented as a confirmed rate, because there is nothing to back-compute from. Recorded per RECORD, not per line. The lines live in a jsonb snapshot, so a per-line marker would rewrite every snapshot in the table for a value that is uniform across an order and that no surface renders per line. The frontend deliberately shows nothing for it - it appeared in one place with no action attached, so it is analytics data rather than a badge. This backfill is not the one the epic forbids. What must never be invented is a tax RATE; recording that an order predates the feature is a fact about OpenLinker's own history, and it is exactly what stops a later reader mistaking a defaulted rate for a stated one. The migration is idempotent by construction: it marks only rows where no line has ever carried a rate, so an order ingested between two runs is not retroactively called historical. There is no cutover instant to get wrong. COVERAGE. Counts are per shop connection, because that is the unit an operator fixes - 'the catalogue has no rates' is not actionable when three shops feed it and only one is incomplete. A product mapped on two connections counts under both, which is the honest answer since both shops would have to carry the rate. The grouping joins identifier_mappings by table name, the read-model posture the stock aggregation in findMany already uses, rather than importing another context's ORM entity. docs/operations/tax-rate-coverage.md carries the query, what the three states mean, how to read the answer, and why this gates #2257: until the defaults come out a rate-less product still produces a document with a guessed rate, and after it produces nothing at all. Running the two in the wrong order turns a slow, visible data problem into an immediate outage. The 2026-08-21 baseline measured zero coverage and is recorded on the issue. Closes #2256 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(fiscalization): hold a receipt when a line has no tax rate The same rule as the invoice, on the fiscalization path. FiscalRegistrationService.register refuses before the read gate and before any row is written, so a held sale leaves no pending record to reconcile. The order-to-command mapper stops emitting an empty rate and carries the line's own, and shipping splits across a mixed-rate basket exactly as it does on an invoice - a receipt has to state a rate per line too. The accepted cost is LATE registration, and it is chosen deliberately. The alternative is a receipt carrying a tax letter nobody confirmed, which reaches the buyer and the daily report and cannot be recalled. A late registration can be completed; a wrong one has to be corrected. THE REVERSAL POINT IS ONE BRANCH: the assertEveryLineHasATaxRate call in register. Nothing else in this context consults the rate, and the exception's docblock says so, so reversing this is a one-line change rather than a redesign. The eparagony adapter's own empty-rate arm is kept for the same reason. The per-connection tax letter stays supported but stops being a fallback. Its docs no longer describe it as "what we use when we do not know": core refuses one step earlier now, so that arm is unreachable while the gate stands, and presenting it as a safety net would describe a trade OpenLinker no longer makes. splitShippingAcrossRates moves from invoicing into sales-documents. Both document contexts need it and a fiscal receipt is not an invoice, so neither could own it for the other - sales-documents is the dependency-free leaf that exists for exactly this case, and the barrel-purity spec already enforces that it stays one. The module imports nothing, so the property holds. On the elapsed-time signal: a held sale has no fiscal record to hang a clock on, and it does not need one. The block is recorded on the ORDER as missing-tax-rate with salesDocumentBlockedAt (#2248), and that reason is document-kind agnostic - it says the order has no fiscal document, whichever kind was due. A second clock would be a second answer to one question. Closes #2252 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * fix(web,analytics): address #2191 tech review — units total, Publish gating, touch a11y, ESLint slug - Units column now reads row.units (server-ranked figure) instead of re-summing row.channels[], which could silently disagree with the sort order the header arrow claims. - The Publish action is gated on listings:write via useWriteAccess + ReadOnlyLock: hidden for an unauthorized non-demo session, rendered disabled with the read-only tooltip for a demo viewer. - Swapped the Chip (aria-pressed toggle) for a real Link styled as a button, so the one-shot publish navigation carries link semantics (middle-click, open-in-new-tab) instead of misrepresenting itself as a permanently-unpressed toggle to assistive tech. - @media (hover: none) now stacks the "Not listed" label and the Publish action, both visible, instead of hiding the label on touch — the #1991 AC's label-vs-action distinction was desktop-only before. - Added the analytics feature slug to both no-restricted-imports pattern groups in .eslintrc.js. Closes review findings on https://github.com/openlinker-project/openlinker/pull/2191 Signed-off-by: jakubret <jakub.retajczyk@blockydevs.com> * feat(invoicing): copy the per-line net back from the issued document OpenLinker computes no net amount (ADR-052), so a stored per-line net has to be the document's own figure rather than a recomputation. Otherwise the record disagrees with the paper by a grosz here and there and no reader can tell which is right. Nothing carried a provider-computed net before this. documentContent held core's own recomputation, marked NON-AUTHORITATIVE in the code, and issuedLineSnapshot carried only unitPriceGross plus taxRate. So this is mostly an adapter-contract change: IssueInvoiceResult gains an optional documentLines, matched to the command's lines by 1-based line number. The fallback is per LINE, not per document, so a provider that reports some lines and not others still contributes what it has. Shipping lines are part of the numbering because they are real document lines; what "skip shipping" means is that they have no ORDER line to transcribe onto, not that they shift the mapping. A correction reports its own amounts and they overwrite the stored ones, so the record follows the latest effective document rather than keeping pre-correction figures the paper no longer states. inFakt reads the created invoice's own services[] - it is the calculator on that path - converting integer groszy back to PLN on the adapter side where that wire detail belongs. KSeF has nothing external to copy, so the adapter reports what it wrote. The figures come from the same lineNet the XML uses, so the reported net cannot drift from P_11, and the offline pending-submission path reports them too: that window is about transmission, not about what the document says. The KSeF rounding bug is fixed and the rule is stated once. P_9A is TKwotowy2, which permits EIGHT fraction digits, and the builder was rendering it through the 2dp money(). On 100 x 1.99 at 23% the line net is 161.79, but a unit net rounded to 1.62 multiplies back to 162.00 - the document contradicted itself by 21 grosze and a reader checking P_9A x P_8B == P_11 was right to complain. The rule: the LINE is the unit of rounding and the unit price is derived from it at the schema's full precision, never the other way round. The buyer paid a gross line amount, so the line's net is what anchors to a real figure; a unit net is a derived display value that only sometimes has an exact 2dp form. Closes #2251 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): tax-rate states on the orders list, order detail and invoice panel Every tax-rate state on the orders surfaces, plus the backend contract two of them needed. BACKEND. A rate conflict gets its OWN field, its own count and its own filter, not a gate reason (epic F1). invoicingBlockedBadge returns null whenever an invoice plausibly exists, and a conflict does not stop the invoice, so routing it through the block machinery would make the badge unreachable on exactly the rows it describes - and SalesDocumentAttentionReasonValues would have counted it inside salesDocumentBlocked, against its own chip. The evidence is taxRateChannel on the order line, written ONLY when the channel disagreed with the shop. Its presence is the conflict, so no reader compares two fields and nothing goes wrong when only one system answered. The summary also reports the oldest still-held instant, so the blocked chip can carry an age. ORDERS LIST. A Rate conflict chip driven by its own count, mounting on filterActive || count for the nine-line reason the invoicing chip already documents: gating on the count alone unmounts the only way to clear the filter the moment remediation succeeds. No tone on it - .chip.chip--active overrides every .chip--{tone}, so an inactive conflict chip would read as pressed beside an active accent one. The age folds into the blocked chip's own label rather than becoming a third dotted badge in a row that already carries two SLA badges. The conflict badge uses the listings page's RowBadge shape - visible label plus the wording in a visually-hidden span - because the hint is the only statement of the fact on this surface and aria-label on a bare span is prohibited and commonly dropped. A new empty arm sits ABOVE both single-filter arms: with two filters active and no rows, "nothing is blocked from invoicing" would be a statement about a set the other filter narrowed. liveRegion stays polite, like the neighbour it sits beside. ORDER DETAIL. A tax-rate column on the line-items panel, following the epic's central claim: an answer is text, only an exception is a badge. A rate, a zero and an exemption all read the way the money beside them reads, with a provenance caption; only no-rate and conflict get colour. No hideBelow - this panel passes no cardView, so the class would simply delete the blocking state on a phone, on the one screen that diagnoses it. Only flagged and Jump to next flagged are borrowed from the bulk review step for long orders. The totals panel's Tax row keeps its snapshot value and gains a caption naming whose number it is, because Allegro and Erli report zero there and it will visibly disagree with the line rates. INVOICE PANEL. Three remedy branches, not one sentence: a blank rate on a mapped product, an item in no catalogue (fixing the offer will not release this order - the marketplace stamped the rate at purchase), and an ambiguous shop tax class. Plural-safe with a count, because a forty-line order with six rate-less lines cannot be told about one product. The Issue invoice button is disabled with the reason ON the control (epic F2). It renders on invoiceSettled && not-issued independently of the block copy, so until now a red "will not be issued" alert sat above a live button that issues it - and the backend now answers 422. The conflict alert is informational and lands on role="status" via the conflict tone. The shipping split preview lives here rather than beside the line items, because shipping has no order line: it exists only once a document is composed. One unknown line rate collapses it to a single waiting row rather than showing a proportion OpenLinker cannot compute. Fix and re-check names the latency and links to the products list rather than opening a sync dialog here: the connection to sync is the SHOP that owns the product, which this panel does not know. TIMELINE. The block entry is dated from the persisted instant instead of timestamp: null, and a release entry exists at all - by the time an order is released the reason is gone, so nothing else records that it was ever held. Closes #2254 Refs #2245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com> * feat(web): tax-rate states on products, publish wizar…
Summary
Implements #1986: the
/analyticsroute shell — page scaffold, date-range control, trust header, degradation banner. Zero revenue/order metrics (out of scope for this issue).Plan:
docs/plans/implementation-plan-analytics-page-shell.mdPre-implementation gate:
docs/plans/analysis/ANALYSIS-analytics-page-shell.md(READY)Base branch is intentionally
1985-order-analytics-read-model, notmain— see the plan's Decisions 3a/4, which reference #1985's schema and are pinned to be revisited once follow-up #1990 lands. (Decision 3's own dependency, #2083, has since shipped — see below.)What's here
/analyticsroute + nav item,features/analyticsmodule (API/types/hook/components), full test coverageearliestOrderDate, [TASK] Backend — real per-connection earliest-order-date read for analytics-trust coverage window #2083) + status, with a click-triggered info popoverKnown, deliberate gaps (see plan Decisions 3a/4 for full rationale)
never-ingestedis used as an approximation for "still arriving"Test plan
pnpm --filter @openlinker/web type-checkcleanpnpm --filter @openlinker/web lintclean (0 errors in touched files)pnpm --filter @openlinker/web test— not run this session, left for CI (per project convention)Note
Open (not draft) — stacked on #2098, which is stacked on #1985. Not closing #1986 yet: this shell has no revenue/order metrics, and every other
/analyticssection (#1989/#1990/#1991) mounts on top of it.