Skip to content

docs(analytics): /analytics v1 "Ledger" — visual & interaction design package - #2018

Merged
piotrswierzy merged 14 commits into
mainfrom
2003-analytics-kpi-strip-mockup
Aug 18, 2026
Merged

docs(analytics): /analytics v1 "Ledger" — visual & interaction design package#2018
piotrswierzy merged 14 commits into
mainfrom
2003-analytics-kpi-strip-mockup

Conversation

@martasl00

Copy link
Copy Markdown
Collaborator

The visual and interaction design package for /analytics v1 "Ledger" — the missing half of a spec that says of itself "wireframes are structural, not visual", so that #1986, #1989, #1990 and #1991 can each cite one source instead of re-deciding the same questions apart from one another.

Docs only. One file: docs/plans/mockups/analytics-ledger-2003.html. No application code is touched.

Live version: https://claude.ai/code/artifact/d1e9bc65-197f-408d-aadd-29a77e1dd169

What it contains

17 frames covering the page at 1440, tablet and phone; the loading, recalculating, error and empty states; a fresh instance; the degradation rule; the microcopy contract; and a handover table. Every colour, radius, shadow and spacing value is a token copied from apps/web/src/index.css, and every component is transcribed from an existing primitive in apps/web/src/shared/ui/ with the line range it came from.

Read this part first

The last commit is a design-system audit rather than a design round: the page was re-read against the live index.css instead of against the notes of the round that transcribed it. Two findings would otherwise have reached implementation.

The .status-strip ladder was written against a rule that no longer applies. index.css:6241-6260 already overrides the auto-fit line the earlier rounds argued with, so the measured "three columns plus an orphan at 768px" never described the running app. Worse, the correction would have lost the cascade: a base repeat(3) plus one 768–1023.98 query is beaten by the shipped @media (min-width: 1024px) { repeat(4) } — same specificity, later in the file — giving four columns on the desktop the page is designed for. The rule now restates all three steps under a .status-strip--analytics modifier.

Card edits were unscoped. min-height, the value row's wrap and the strip columns were edited on the bare classes the dashboard at / also renders, while the file claimed the dashboard was untouched. All three are now scoped, so the claim holds in fact rather than only in prose. #1990 should implement this as a page-level modifier on the strip, not as an edit to the primitive.

One defect this surfaced in the app itself

.data-table__cell--right (index.css:2770, specificity 0,1,0) loses to .data-table th, .data-table td { text-align: left } (index.css:2797, 0,1,1) whatever the source order — so every right-aligned column in the product renders left-aligned today: orders, products, sync jobs, connection diagnostics, bulk batch progress and the dashboard, wherever a column declares align: 'right'.

The mockup fixes it locally by qualifying with the type selector, which is the same one-line fix index.css needs. It is deliberately not fixed in this PR — it predates this epic and touches six pages, so it should be its own issue rather than a stray line in a docs change.

Figures the platform cannot produce yet

Six of the twelve figures on the strip are blocked, and so are all six period-over-period deltas. On two cards — Returns & refunds, and Cancellations — both figures are blocked.

Those two cards stay drawn, and that is deliberate. The brief was to choose the figures a seller needs; a figure the platform cannot serve yet is a requirement rather than an omission, and a requirement drawn to scale is what a backend issue can be written against. They carry an explicit state (dashed border, Data order tag, muted figures) so nothing on them is mistaken for a measurement.

Frame 14 turns every one of those markers into a list — figure, what is missing, and the shape of the answer — generated from the same calls that render the markers, so the page and the backlog cannot drift. The register audits the finished page: a marker nobody filed appears as an Unfiled row instead of being dropped.

The useful thing for planning is the asymmetry it makes visible. Everything under placedAt is one column plus an index on data OpenLinker already holds, and it unblocks the range filter, all six deltas and the daily rate at once. Returns and refunds are a different order of magnitude — no entity exists anywhere in the repo, so it is a capture problem before it is a reporting one.

Cost to #1990, stated up front

  • KpiCard gains five props (metric, qualifiers, delta, info, planned). All additive; the dashboard strip renders unchanged when they are absent.
  • DataTable needs a footer — the Total row and the per-currency subtotals. The component has no footer API today, so this is a component change of the same size as the card's, not a stylesheet addition.
  • The "no new primitive" acceptance criterion holds in the sense that matters (no new component), but neither of the above is free.

Closes #2003

🤖 Generated with Claude Code

https://claude.ai/code/session_01KNExZyQ1nVYybNyTSm6YWB

Reworks the four-card KPI strip in the #2003 "Ledger" mockup so each card's
second figure qualifies the first, and adds a period-over-period reading.

- Revenue: GMV spelled out with a click-to-open definition popover; the
  cancellation rate is replaced by Net revenue (GMV minus returned value),
  which actually qualifies the amount above it — GMV already excludes
  cancelled orders, so a cancellation rate there qualified nothing.
- Transactions: gains Cancelled (count and share) beside Units rather than
  instead of it, since units are what make channels comparable.
- All four cards gain a period-over-period delta. Arrow follows the number,
  colour follows the meaning (a falling return rate is green), and a rate
  moves in percentage points, not percent.

Every new figure is derived from the dataset, never typed, so the cards, the
currency breakdown and the by-channel table cannot drift (AC #1990). Four of
the nine figures and all four deltas carry the gap marker: the deltas need a
filterable placedAt, the same missing column that blocks the range filter.

The delta is a scope addition — it contradicts this file's own "no
period-over-period" scope note and makes KpiCard need a third prop. It is
named as such in the handover rather than absorbed silently, alongside two
new CSS rules (9: the delta, 10: the infotip popover).

Refs #2003

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
Splits the strip from four cards into six, along the metric model rather
than along the row width: Revenue, Orders, Order value, Units, Returns &
refunds, Cancellations. Units and Cancelled no longer ride as qualifiers
on the orders card, and Cancelled value - a money figure A7 asks for that
previously had nowhere to live - gets a home.

Each card gains one definition popover in its eyebrow, covering both of
its figures (term, definition, formula, caveat). Cards therefore carry
the abbreviations an operator uses (GMV, AOV), which reverses round 6:
the argument against introducing them was that nothing defined them.

The period-over-period delta moves to its own line under the figure and
states its basis in visible text; round 6 had it in the value row with
"vs previous period" only in the sr-only sentence.

Column ladder becomes 3x2 -> 2x3 -> vertical, superseding the style
guide's literal 1x4 (its intent - fixed count per breakpoint, even grid,
no orphan row - is preserved). The currency note stops printing a single
rate, which described no order on the page, and states the method plus
the rate table's freshness instead; the breakdown is cut to the six money
figures, since counts and rates do not convert.

Two pre-existing defects found and fixed in place: every
.data-table__cell--right rendered LEFT-aligned (the bare class loses on
specificity to `.data-table th, .data-table td`), and the device-frame
scaffolding needed explicit column declarations once auto-fit stopped
absorbing narrow containers.

KpiCard now needs four props rather than three; the handover costs it
explicitly rather than absorbing it.

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
…d its chip

The "Not listed" label sat 67 px left of the right edge its own column's
numbers use. Cause: the Publish chip was a flex sibling at opacity 0 -
invisible, but still occupying layout - so it pushed the label out of the
column's alignment by its own width plus the gap.

The chip is now out of flow (position: absolute; right: 0), anchored to
the slot's right edge, and the label yields to it on row hover. The column
therefore has one right edge in every state and nothing reflows. A fixed
~64 px action gutter per channel column was the alternative and was
rejected: this table already scrolls horizontally at many channels.

Cost, stated in the file: while the pointer is in the row the cell no
longer says "not listed" - a Publish button asserts it, and the label
never leaves the accessibility tree.

Worth recording why this went unnoticed for six rounds: it was masked by
the specificity defect fixed in round 7. While every cell rendered
left-aligned, an invisible box pushing content rightward was
unobservable. Two defects were cancelling each other out.

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
A design-system audit rather than a design round: the page was re-read against
the live index.css instead of against the notes of the round that transcribed
it. Two findings would have shipped wrong.

The status-strip ladder was written against a rule that no longer applies.
index.css:6241-6260 already overrides the auto-fit line rounds 6-7 argued with,
so the measured "three columns plus an orphan at 768px" never described the
running app - and a base repeat(3) plus one 768-1023.98 query would have lost
to the shipped @media (min-width: 1024px) { repeat(4) }, giving four columns on
the desktop this page is designed for. Rule 6 now restates all three steps
under a .status-strip--analytics modifier.

The card edits were unscoped. min-height, the value row's wrap and the strip
columns were edited on the bare classes the dashboard at / also renders, while
this file claimed the dashboard was untouched. All three now hang off the
modifier, so the claim holds in fact and not only in prose.

Also in this round:

- Deletes 16 dead CSS rules. One was carrying an argument: the
  .kpi-card__qualifier-share block still defended a treatment round 7 had
  removed, so the file specified something no frame drew.
- The two cards whose figures are both blocked (Returns & refunds,
  Cancellations) get an explicit state rather than two easily-missed daggers:
  new rule 11, .kpi-card--planned - dashed border, a "Data order" tag in the
  eyebrow, muted figures. The figures stay drawn: this page chooses the
  numbers a seller needs, so an unavailable figure is a requirement, and one
  drawn to scale is what a backend issue can be written against.
- New frame 14 generates the whole data order from the same blocked() calls
  the markers render, so the page and the backlog cannot drift. The register
  audits the finished page and surfaces an unregistered marker as an unfiled
  row instead of dropping it.
- Draws the cardView empty state the file had declared a rule for.
- Costs DataTable's footer as the component change it is (rule 5 read as CSS
  only), and records that .data-table__cell--right is broken in the app
  itself - index.css:2770 loses to index.css:2797, so six pages render
  right-aligned columns to the left today. Flagged for its own issue.

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
martasl00 and others added 2 commits August 11, 2026 14:31
Shortens three frame captions and rewrites eleven handover rows to drop
references to the design rounds that produced them. Content unchanged --
only the process narration comes out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zZekoLPDA3vnoxvwKCa8k
Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
Every frame note was a wall of prose -- 5,800 words across 20 blocks, the
longest 878 words in five paragraphs, mixing rationale, round-by-round
history, implementation requirements, data gaps and microcopy in the same
sentence. A developer holding one ticket could not find their part.

Each note is now a card with four fields in a fixed order: BUILD (what to
build), RULES (numbered, one line each), BLOCKED (what cannot be produced
yet and why) and WATCH OUT (the trap that breaks the implementation). An
empty field is omitted. Frame 00 becomes a map of frames to issues; frames
01b and 13 stay free text, since neither describes building a component;
frame 06 splits into two cards, so the CSS defect gets its own WATCH OUT.

Same treatment for the handover tables (frame 13), the microcopy rationale
column (frame 12) and the opening comment of this file. Every reference to
the design rounds that produced a decision comes out -- 58 "round N", 25
"correction N", 11 accounts of rejected variants. What changes an
implementation stays verbatim: issue numbers, spec clauses, gap markers,
component paths, data field names and the canonical microcopy strings.

Visible prose: 5,793 -> 3,330 words in the frame notes, 5,078 -> 3,945 in
the handover, 2,029 -> 843 in the microcopy rationale, 2,017 -> ~950 in the
opening comment. No mockup markup changed -- the frames render as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zZekoLPDA3vnoxvwKCa8k
Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
martasl00 and others added 2 commits August 12, 2026 13:55
The stock row promised data v1 will not have. Spec §4 flags E1 as
`🟡 [L] for velocity + 🟢 for the stock read`, and §6 takes only the
green half into v1 — so #1983 computes masterStock − stockSafetyBuffer
and never learns how fast anything sells. "4 fast movers" became
"4 variants publish as out of stock": the consequence in the channel,
derived from max(0, masterStock − buffer) (#1844), with the buffer
stated so the arithmetic is checkable.

The coverage row carried the same debt less visibly — "ranked by units
sold · last 30 days" appears in no issue, and #1983 lists sales-weighting
the coverage gaps as out of scope. It now ranks by mapping presence, and
says "are listed on" rather than "sell on".

Dropping the invented threshold unblocks the row: with no reorderPoint
needed, the gap marker, the Blocked block and the registerGap entry are
gone, and the missing reorder point is filed as a v2 follow-up instead.
The handover records the deliberate departure from the wording in #1989
and the spec, so a dev reading the issue body does not restore it.

Also removes two dead microcopy strings for the per-category resolved
state that correction 16 deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8jNVwK19jWDazBmsWbfMk
Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
…1844

The stock row printed "buffer 2" with no account of where that number
comes from. The formula lived only in the handover table and in a code
comment nobody renders; per-connection scope was mentioned in passing;
the default of 0, the three subtraction sites and the worked examples
were nowhere.

Frame 02 now carries the functional model: max(0, masterStock − buffer)
with the 10/3/1/0 examples, the buffer belonging to a connection rather
than to OpenLinker, the three places it is subtracted, and the default
of 0 — with links out to #1844, which ships the mechanic, and #1983,
which computes the aggregate.

Adds a watch-out that changes how the row reads today: no control for
connection.config.stockSafetyBuffer exists anywhere in apps/web, so on
a shipped instance every buffer is 0 and the row reduces to "master
stock is 0". The frames draw buffer 2 because that is the case worth
designing for.

Link styling for the build cards is scaffold, not a new app rule — it
styles nothing inside an .app-frame, so section 5's count is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8jNVwK19jWDazBmsWbfMk
Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tech-lead review — 🔄 Substance approved; two things to record before #1990 starts

Reviewed the single added file (docs/plans/mockups/analytics-ledger-2003.html, 4,865 lines) against docs/frontend-ui-style-guide.md, docs/frontend-architecture.md, apps/web/src/shared/ui/index.ts, apps/web/src/shared/theme/tokens.ts and apps/web/src/index.css at main (648cfc6).

This is the strongest design package I've reviewed in this repo, and the token discipline is real rather than claimed.

Verified

  • Token containment holds. All 107 oklch() literals are confined to the transcribed :root / html[data-theme='dark'] block at mock.html:193-397, and the single raw hex (#ffffff, mock.html:201) is a byte-faithful copy of index.css:202. Every rule below the token block reads through var(--…) — top consumers --space-3 ×34, --space-2 ×31, --text-muted ×30, --font-mono ×28. Radii come only from --radius-{xs,sm,md,lg}.
  • Accent and status hues obey the reservation rule. Status tones appear only as status-*-soft/border/fg on genuine status surfaces; accent use is limited to the guide's sanctioned list (primary button, segmented-control active fill, KPI top-rule at mock.html:877).
  • The odd type sizes vindicate the transcription, not the mockup. .eyebrow: 0.76rem and .data-table-skeleton__table: 0.92rem match index.css:1992 / index.css:3245 exactly, and .page-title: 1.5rem/700 (mock.html:680) correctly records the running app — .page-title carries no font-size in index.css, so h2 inherits the UA 1.5em/bold. The guide's "page title 28 / 600" is what's stale here.
  • A11y is designed in, not bolted on. Nine :focus-visible rules all via --shadow-focus; 16 .sr-only usages; aria-live/role="status" on all four transient states; delta colour paired with an aria-hidden arrow and a full sr-only sentence (colour never the only signal); 11 tabular-nums sites. The deliberate Popover-over-Tooltip choice at mock.html:2915 — because Radix Tooltip bails on pointerType === 'touch' — is exactly right for a first-class-mobile surface.
  • Responsive parity honoured. Both tables use the real cardView prop below 767.98px rather than the narrower .data-table--stackable, and the only horizontal scroll is inside .data-table__container, a documented exception.
  • No new primitives. Every component maps to an existing export in shared/ui/index.ts; the eleven new CSS rules are page-scoped modifiers.
  • Finding the .data-table__cell--right specificity defect (0,1,0 vs index.css:2797's 0,1,1) and declining to fix it here is the correct call.

The findings below are pre-implementation questions and one guide-sync obligation — not corrections to the design.

IMPORTANT

1. The style guide isn't updated, and three divergences require it. This PR changes exactly one file. The guide is explicit at frontend-ui-style-guide.md:621"Never introduce a row height that isn't on this list without updating the guide first" — and its own precedent for a parity-matrix departure (the picker modal, :652) lives in the guide, not in a mockup. Three items qualify:

  • mock.html:874.status-strip--analytics .kpi-card { min-height: 9.5rem } (152 px) against the guide's KPI card | auto, ~96 px row (:618).
  • mock.html:1233-1246 — the six-card 1 → 2 → 3 ladder against the parity matrix's KPI strip | 1 × 4 → 2 × 2 → 1 × 4 (:645). The reasoning in the comment is sound (six cards can't honour a 4-wide step without an orphan row) and the intent is preserved; it just needs recording where the next author will look.
  • mock.html:3076 — the package's own observation that cardView is opt-in while the guide's Tables row (:643) reads as automatic. The mockup literally says "Worth a line in the guide so the next page does not rediscover it." Agreed — write it.

Add a Documented departure — /analytics six-card strip paragraph, the 9.5rem analytics-card height in the Density table, and one clarifying clause on the Tables parity row. Cheapest here, since this is already a docs PR; otherwise make it a named checklist item on #1990. Please don't leave it implicit.

2. The DataTable footer has no card-view answer. The package flags this itself at mock.html:3011: "a footer has no obvious card equivalent, and this mockup does not answer it — both tables carry totals in the desktop table only." Since mobile is first-class, a seller on a phone loses the Total row and the per-currency subtotals entirely. This is the one substantive hole and it should be closed before #1990 starts — a totals summary line above the card stack, or a pinned first card, are both cheap. The same paragraph should state whether the new footer API is DataTable-generic or analytics-local.

3. Two primitive API changes are named but not reflected in the catalog. KpiCard gains metric / qualifiers / delta / info / planned, and DataTable gains a footer (mock.html:2924, :2926, :3011). The guide documents both components' API surfaces (:510 for DataTable), so these belong in the same sync as finding 1.

SUGGESTION

  • mock.html:1199.kpi-card__qualifier-value: 0.9375rem (15 px) is the one authored size off the canonical scale (:432-440: 11/12/13/14/17/28/32). Use 0.875rem or 1.0625rem. Unlike 0.76rem/0.92rem, this one isn't inherited from index.css.
  • mock.html:1031-1033 — frozen-column classes are renamed (.data-table__cell--sticky / --sticky-edge) and the boundary is always-on, while the shipped implementation is .data-table__sticky-col gated behind .data-table--sticky-scrolled (index.css:2833+). The accent-tinted edge itself is the shipped pattern, so this is naming + gating only — but #1990 should use the shipped classes and inherit the gating rather than transcribe these.
  • mock.html:553@media (max-width: 720px) is off-ladder. It only governs mockup chrome (.gap-legend--card, .build-gaps__row), never a page surface — worth a one-line marker so it can't be copied into app CSS.
  • File the .data-table__cell--right specificity issue as an issue now. The analysis is correct and the blast radius (six pages) is real; keeping the fix out of this PR is right, but a defect described only inside a mockup is easy to lose.

Merge readiness: 🔄 Substance is sound and implementable as specified. Merge once finding 2 has an answer recorded in the file and finding 1's guide sync is either committed here or written as an explicit #1990 checklist item; finding 3 and the suggestions can ride along with either.

martasl00 and others added 2 commits August 14, 2026 11:13
The definitions behind the KPI strip existed only as pasted copies inside
the mockup's info popovers. Nothing in the repo held the original, so a
dev implementing #1990 or #1983 had no way to check whether a rendered
tooltip still matched what was agreed with the customer -- and an audit
against the source turns up several places where it no longer does.

docs/specs/metrics-analytics-dashboard.md is that source: twelve metrics,
one per figure on the six KPI cards, each with its definition, formula and
data source, plus the rules that apply across all of them (net of VAT,
single reporting currency, cohort by order placement date).

Two things were added on top of the agreed text. A UI-label mapping table
records that three figures render under a shorter label than their metric
name (Refunded value, Cancelled value, Units per order) -- the cards are
narrow and the shorthand is deliberate, but without the table a dev cannot
tell a nickname from a different metric. And the metric formerly called
Units per Transaction is renamed Units per Order throughout, matching what
the card already says; the UPT abbreviation is dropped rather than kept as
a synonym, since it appears nowhere in the product.

The rule file is what makes the spec reachable without anyone remembering
to cite it: .claude/rules/*.md load automatically when a matching path is
touched, so editing the mockup or a future apps/web analytics page pulls
in the instruction to quote the spec rather than paraphrase it, and to
treat a divergence as a bug to raise rather than a wording call to make
locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YtRQ8VC5n8e1knJsQypmW
Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
The mockup's info popovers held pasted copies of the metric definitions and
had drifted from the agreed source, now versioned at
docs/specs/metrics-analytics-dashboard.md. Every definition is restated from
the spec: GMV is gross and before discounts, AOV and Net sales are net and
after them, return rate counts orders rather than units, cancelled value
covers fully cancelled orders only, and rates convert at the day preceding
the order.

Two rules the spec did not cover are added to it rather than to the mockup:
what a partially cancelled order does to every figure, and the deliberate
consequence that the value of items cancelled out of a shipped order is
reported nowhere.

Decision 3 is reversed. The mockup treated the tax basis as a property of
each source and degraded the by-channel total when sources disagreed; the
spec puts VAT on OpenLinker, calculated by country of delivery. Revenue
therefore always totals, and the Gross? badge, the Tax column, the
"n of m channels assumed gross" note and the mixed-basis alert are removed.
Frame 05 is withdrawn and kept as the record of the reversal, because the
work does not disappear with the UI: Allegro and Erli report tax: 0 and
PrestaShop declares nothing, so #1983 now needs VAT rate tables per delivery
country and a rule for orders with no known delivery country. That is stated
in the handover, so a build reading only this file does not reinvent the
badge.

Refs #2003 #1990 #1983

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
The mockup told a developer that OpenLinker calculates VAT itself and that
#1983 owes the machinery for it. Both halves were wrong.

#1983 is closed and never covered tax — it is the "needs attention"
aggregates issue (coverage gaps, stock at risk, failed-sync value). The
real scope is #2054, which records the opposite model: the VAT rate
arrives from the ProductMaster per (product, delivery country) and
OpenLinker never computes it. The drafted TaxCalculationPort was
withdrawn in full, so the rate tables this file asked for will never
exist.

That reverses three of the four gaps the handover listed. Rate tables are
not ours to build; the delivery country is already resolved by
PrestashopTaxRateResolver; and a source reporting tax: 0 is not an open
question because a missing rate blocks invoice issue outright. Only the
exchange rate survives as an open item — #2054 does not touch currency —
so it is stated on the existing FX row rather than as a second one.

The blocking status stays. What changed is the reason: not an undecided
design, but the sequencing of #2054's seven steps, of which one has
started. Step 4 is the one this page needs, and it is scoped for exactly
this purpose — net, gross and the rate stored per line on the order
snapshot "so analytics can query margin by category without unpacking a
per-order flag". Until it lands, no-rate and conflicted orders are
excluded from the net aggregate with the excluded count stated, rather
than counted at gross.

Decision 3 keeps its 14 Aug reversal and gets a truthful argument for it:
revenue totals because the rate rides on the order line, not because we
own the arithmetic. Applied in all seven places that asserted otherwise
so the file stops contradicting itself.

The metric spec gains the rule rather than being left to be inferred
from. It said "VAT calculated by country of delivery", which reads as
either the axis the rate is resolved on or a licence for OpenLinker to
compute it — the mockup had taken the second reading. "Rules common to
all metrics" now states the first explicitly, names the ProductMaster as
the source, and carries the net-aggregate exclusion. The addition is
flagged as post-call and not part of the text agreed with the customer,
matching the convention the partially-cancelled-orders rule already set.
No existing definition, formula or source line is touched — the model of
who supplies the rate changes none of them.

Part of #2003

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
Reverses the Revenue pair on the #2003 mockup: net sales is the headline
and GMV the qualifier, on the card and everywhere money is reported. An
operator asks how much money STAYED sold, and a gross figure in the
headline slot reads as money kept.

- KPI card: metric/value/delta all move to net sales; delta is now
  net-vs-net rather than GMV-vs-GMV. The infotip states Net sales first,
  GMV second, and its caveat points "below" instead of "above".
- Sparkline stays but is relabelled "GMV trend": the series is a GMV
  series and net sales has no daily one, so the line now describes the
  qualifier rather than the figure above it.
- By channel + its mobile cards, Top products (both currency states) and
  the currency breakdown report net sales; the breakdown's Net sales
  column leads, following the card order. AOV is untouched — it keeps
  the spec's definition and its own basis.
- PRODUCTS gains per-product `returned`/`returnedEur` so the table can
  state a net figure at all; one product carries a real 0.
- Every net-sales heading carries the † through one shared
  NET_SALES_NEED string, because the frame 14 register audits the page by
  marker title and a second wording would file the gap twice.

What the reversal costs is stated rather than discovered: the headline is
the blocked half, so the strip #1990 ships first renders GMV alone under
an empty slot and both tables lose their money column. Frame 14 and the
BUILD/BLOCKED cards say so, and say not to promote GMV back into the
headline while returns are uncaptured.

Also makes the Top products "By net sales / By units" toggle live — it
was a dead specimen, which stopped being defensible once the money column
became net sales: on this dataset ranking by units nearly inverts the
table, which is the same argument the by-channel alert makes about units
versus money. The control changes the order only, never the columns; the
arrow and aria-sort follow it. Fixes an append-instead-of-replace bug in
productTable that would have duplicated thead/caption on first click.

The spec is untouched: no definition, formula or source changed — only
which figure leads and the order the infotip reads.

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
…the handover tables be read

Two unrelated readability fixes to the same file, both in the parts a
developer reads rather than in anything the page renders.

The [G] corrections still described the world before decision 3 was
reversed on 14 Aug, and contradicted the decision list three lines below
them:

- Correction 1 promised a DECLARED vs ASSUMED marker, a conditional total
  and a degraded state. All three are withdrawn — the rate rides on the
  order line, supplied by the ProductMaster per (product, delivery
  country), and OpenLinker never computes one (#2054). The FINDING
  survives and is restated as what it is: `total` is gross while the spec
  says PrestaShop prices net, a spec correction #1984/#1985 still owe.
- Correction 2 ended "Revenue stays per-row, as reported", which is the
  pre-reversal reading. It now says so, and points at why the
  impossibility it describes is the reason the rate must ARRIVE from the
  master rather than be reconstructed on this page.
- The dataset comment documented a `basis` field that no longer exists
  (it left with the Tax column), and the CHANNELS_MULTI comment said
  frame 05 "uses" Shop DE to demonstrate a net-basis source in the
  present tense, though that frame is withdrawn.

The handover tables were unreadable for a mechanical reason:
`.gap-table td:first-child` was `white-space: nowrap`, so on "What this
mockup surfaced" the longest finding set the first column to 738 of
1222 px and pushed Note — the column carrying the reasoning — into 203 px
of horizontal scroll. Now 293 / 176 / 753 with no scroll.

- First column wraps: 24% with a 9rem floor, plus `overflow-wrap` for the
  identifiers, which have no space to break at.
- Status column is opt-in (`--status`) at a stated 11rem, so the one long
  badge breaks at its em dash. Two wrong turns are recorded in the CSS
  comments: nowrapping every second column squeezed the frame 14 register's
  third column to 109 px, and `width: 1%` shrank the status column to its
  longest word, breaking short badges onto three lines.
- The microcopy table had the same shape at a smaller scale (first column
  466 px); fixed via `#microcopy-rows` so the small specimens elsewhere,
  which are correctly proportioned, are untouched.
- Below 720px the stated widths are released — 9rem + 11rem is most of a
  phone. Each table keeps its own scroll container, so the page still
  never scrolls sideways (verified at 360px).

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>
… it (new rule 11b)

The net-first reversal left the Revenue card with an unbuildable
headline — no refund amount is stored anywhere, so net sales cannot be
computed — and three build cards said so in prose while nothing drew it.
A state a developer has to render from imagination gets rendered
differently by each developer, and most likely by putting GMV in the big
slot, which is exactly what the reversal removed.

New rule 11b, `.kpi-card--planned-half`, for the case rule 11 does not
cover: rule 11 is a card where BOTH figures are a data order (dashed
border, Returns & refunds and Cancellations); here only the headline is,
while the qualifier under it is a real measurement.

- Headline renders `EmptyValue` raised to the figure's own font size.
  `EmptyValue` carries `.text-muted` at 13px, which is right in a table
  cell and wrong in a slot sized for a four-figure amount — a small dash
  reads as a missing element rather than a missing number.
- No delta: a delta of an uncomputable figure is uncomputable twice, and
  all six are blocked on `placedAt` regardless.
- The `Data order` tag takes the sparkline slot even though GMV has a
  series. Status outranks trend on a card whose top half is a requirement.
- Border stays SOLID and the qualifier renders normally. Dashing it would
  tell the operator to distrust the one figure they can trust — the whole
  reason this is 11b and not 11.
- `planned` becomes `true | 'half'` rather than a boolean.

Drawn in frame 03 as a second app-frame under the target strip.

The card's two definitions are hoisted into REVENUE_DEFINITIONS rather
than copied, since two cards now render them and the repo rule is that
the spec and every pasted copy stay in agreement — one copy is easier to
keep honest than two. Verified in the browser that both infotips are
character-identical.

Bookkeeping the file's own numbers require: the header comment's "exactly
ELEVEN new CSS rules" becomes TWELVE, the handover table gains a row for
11b, frame 03's rule 5 points at the drawn state, and the "KpiCard needs
FIVE new props" paragraph records that `planned` changed type.

Signed-off-by: Marta Słomka <marta.slomka@silksh.pl>

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tech-lead re-review — 🔄 Two items unchanged, and the window has narrowed

Head moved 2709429cbd288978. Diffed against merge-base(origin/main, head) = de7594e7, since the recorded base is stale: 4 files, +5,390, docs only.

The new work is real but orthogonal to my open findings — docs/specs/metrics-analytics-dashboard.md plus .claude/rules/analytics-metrics.md making that spec authoritative over the mockup's popover copies. That's a good addition and it closes a drift channel I hadn't flagged. It's also, as I'll come back to, exactly the mechanism my finding 1 is asking for.

Prior findings

1. Style guide not updated — ❌ not addressed. frontend-ui-style-guide.md is untouched by this PR. On main the Density table still reads KPI card | auto, ~96 px with no analytics entry, against the mockup's 9.5rem (152 px) — and the guide's own instruction at :626 is categorical: "Never introduce a row height that isn't on this list without updating the guide first."

The parity-matrix departure is still declared only inside the mockup. It has been revised since my review — now 3×2 → 2×3 → vertical ("New rule 6" at :3046) with better reasoning, since auto-fit gives six ~200 px columns at 1280 px — but a revised divergence recorded in the wrong file is still recorded in the wrong file. Same for the cardView-is-opt-in observation. The mockup itself still says "Worth a line in the guide so the next page does not rediscover it."

2. DataTable footer has no card-view answer — ❌ not addressed. :3146 is verbatim unchanged. On a surface the guide declares first-class-mobile, a seller on a phone loses the Total row and the per-currency subtotals.

The round-7 rationale added since my review raises the stakes rather than settling them: the file now argues the footer is the evidence — "a table whose claim and proof cannot be seen together fails at the only job it has" (:4143). That is precisely the property mobile currently loses. The document now contains its own strongest argument against its own open question.

3. KpiCard / DataTable API changes not in the catalog — ❌ not addressed. KpiCard appears nowhere in the guide (zero grep hits on main), and the DataTable entry documents no footer. Both still named only in the mockup's handover table.

Suggestions: .kpi-card__qualifier-value: 0.9375rem still the one authored size off the canonical scale; frozen-column classes still renamed and always-on vs the shipped .data-table__sticky-col gated behind .data-table--sticky-scrolled; off-ladder @media (max-width: 720px) still unmarked. The .data-table__cell--right specificity defect — ✅ discharged: #2031 exists, correctly scoped, closed 2026-08-14, and the issue found more than the mockup did (Orders/Products had independently grown --end-suffixed workarounds).

Timing — a correction to what I said last time

I previously said these were "about to be built" by #2115. That's not right: #2115 implements #1986 (the route shell), not #1990 (the KPI strip and tables). So the card height, the six-card ladder and the footer are still ahead of the work that needs them.

That's better than I implied — but it's the next issue, not a distant one. The window to record these is open and narrow.

The pattern you just demonstrated

The metrics spec + .claude/rules pairing is the right shape: name one file canonical, mark the mockup copy explicitly a copy, and state an on-divergence rule ("the spec wins, say so rather than silently picking one"). You've applied it to metric definitions. Finding 1 is the same problem for the visual system, and you've now built the precedent for solving it.

CI: all 8 green on bd288978. mergeable_state: behind — needs a main refresh. Docs-only.

Merge readiness: 🔄 The substance remains the strongest design package in this repo and I'm not asking for design changes. But both open items are open in the same words as five days ago, and one of them is a decision the implementer will otherwise make silently under deadline. Merge once:

  1. Finding 2 has an answer recorded in the file — a totals summary above the card stack, a pinned first card, or an explicit "desktop-only, accepted, here's why". Any of the three. What isn't acceptable is shipping #1990 with the question still marked open.
  2. Finding 1 is either committed here (three small edits: the analytics row in the Density table, a Documented departure — /analytics six-card strip paragraph, one clause on the Tables parity row) or written as a named checklist item on #1990 — not left implicit in a mockup comment.

Finding 3 and the suggestions can ride with either. Given the metrics-spec precedent, folding the guide sync in here is the cheaper path.

jakubretajczykBD added a commit that referenced this pull request Aug 17, 2026
…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>
jakubretajczykBD added a commit that referenced this pull request Aug 17, 2026
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>
@piotrswierzy
piotrswierzy merged commit 41e736e into main Aug 18, 2026
8 checks passed
@piotrswierzy
piotrswierzy deleted the 2003-analytics-kpi-strip-mockup branch August 18, 2026 09:21
jakubretajczykBD added a commit that referenced this pull request Aug 18, 2026
…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>
jakubretajczykBD added a commit that referenced this pull request Aug 18, 2026
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>
piotrswierzy pushed a commit that referenced this pull request Aug 20, 2026
…st 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>
jakubretajczykBD added a commit that referenced this pull request Aug 20, 2026
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>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
* 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>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
…, 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>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
… 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>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
…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>
jakubretajczykBD added a commit that referenced this pull request Aug 24, 2026
…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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[PRODUCT-DESIGN] /analytics v1 — visual & interaction design package (mockup, states, microcopy)

2 participants