Skip to content

Non-Custodial Partner Program — partner statistics behind the normal login - #1262

Open
joshuakrueger-dfx wants to merge 20 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/partner-dashboard
Open

Non-Custodial Partner Program — partner statistics behind the normal login#1262
joshuakrueger-dfx wants to merge 20 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/partner-dashboard

Conversation

@joshuakrueger-dfx

@joshuakrueger-dfx joshuakrueger-dfx commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Offen — nicht aus diesem Branch schließbar

  • @dfx.swiss/core mit UserRole.NON_CUSTODIAL_WALLET_PARTNER releasen. Der Member liegt
    als PR: feat(core): add the non-custodial wallet partner user role packages#205 (eine Zeile, Wert zeichengleich zu
    src/shared/auth/user-role.enum.ts:15 in DFXswiss/api#4587). Aus diesem Branch nicht
    möglich:
    die Version entsteht über developmain in DFXswiss/packages und
    npm publish; ein Branch hier kann keine Paketversion veröffentlichen. Wer: wer in
    DFXswiss/packages mergen und releasen darf. Danach hier @dfx.swiss/react anheben und
    PARTNER_DASHBOARD_ROLES in src/hooks/guard.hook.ts von der String-Allowlist auf den
    Enum-Member umstellen — eine Stelle.
  • DFXswiss/api#4587 mergen und deployen. Ohne die Rolle lässt der Guard niemanden durch und
    die Endpunkte weisen das Token ab. Aus diesem Branch nicht möglich: anderes Repo.
    Wer: Reviewer und Deployer von api#4587.
  • Handbuch: Screenshot-Baseline + scripts/handbook/metadata.json-Eintrag für
    /partner/dashboard
    — geliefert. Die frühere Begründung („braucht die Rolle auf einer
    laufenden Umgebung") hielt nicht: der Rollen-Guard ist clientseitig (jwtDecode ohne
    Signaturprüfung), die App übernimmt einen Token aus ?session=, und beide Statistik-Endpunkte
    werden per page.route bedient — dieselbe Technik wie in e2e/support-issue-receiver-iban.spec.ts
    und e2e/compliance-kyc-stats.spec.ts. e2e/partner-dashboard.spec.ts deckt vier
    Produktzustände ab (gefüllt hell, gefüllt dunkel, Fehler, leer); der metadata.json-Eintrag
    liegt zusammen mit den Bildern, also ohne orphan-Warnung. Damit greift auch
    handbook-check.yaml erstmals auf diesem PR.

Why

Not symptom-driven: no incident triggered this. Wallet partners integrating DFX have no way to
see how their integration performs. DFXswiss/api#4587 adds the two endpoints that answer it; this is
the page that shows them.

Scale: the first of several wallet partners; the reference partner is in the six-figure range of
registered users. No exact figure here — this body is public, and the repository's own
„production-checked" reference set disagrees with the number this line carried before.

Smaller fix considered: hand partners the raw JSON and let them build their own view. Rejected —
every partner would rebuild the same charts, and the numbers only become useful once someone can
see a trend rather than a payload.

What

A screen at /partner/dashboard, reached from the burger menu, gated by a role: volume and
transaction counts over time, breakdowns by asset, fiat currency, blockchain and payment method,
the referral position, and the all-time figures behind them.

Nothing partner-specific is coded anywhere. Any wallet gets its own numbers through the same page.

It lives in the app, not beside it

It began as a separate build target with its own entry point, its own login screen and its own token
handling. That login asked for a wallet signature — so anyone who wanted to look at a chart
needed the partner's wallet key, their master secret, in a browser.

It is a screen now. The role guard follows the pattern of the support dashboard
(useSupportDashboardGuardusePartnerDashboardGuard), the API calls go through the app's own
useGuardedApi with the ordinary session token, and the standalone entry point, login screen, auth
hook, brand registry and build scripts are gone. Per-partner branding went with them: inside the DFX
app behind the DFX login, a partner logo would claim something the page no longer is.

The frontend's role list is deliberately narrower than the API's. The endpoints accept
ClientCompany, KycClientCompany, NonCustodialWalletPartner, Admin and SuperAdmin; the page
admits only NonCustodialWalletPartner. An admin would see their own wallet, since there is no
"show me partner X" parameter — a menu entry leading to an empty page or a 403 is worse than no
entry. This is a decision, not an oversight; please do not widen it without deciding the same
question again.

@dfx.swiss/core does not yet carry NonCustodialWalletPartner, so the guard still compares the
literal string. The SDK member is now split out as DFXswiss/packages#205 and this side switches to
the enum once that version ships — see the open list at the top. It is also the only role comparison
in src that does not go through UserRole; every other one (labels.ts:173,
navigation.tsx:237, navigation.tsx:246, support-dashboard-issue.screen.tsx:34) already does,
which is why the string is a temporary state and not a second pattern.

Every day is shown

The API used to withhold days below a k-anonymity threshold. It no longer does, and neither does the
page: a day with a single transaction is a low point on the curve, a day with none sits on the zero
line. There is no gap, and there cannot be one — timelineSeries returns [number, number], so a
hole is not representable and reintroducing one fails to compile.

Breakdown rows with no activity at all disappear rather than showing a permanent zero. The API
cannot emit such a row today (GROUP BY only yields values that occur), so the filter is a
second line rather than a fix — it stops the page trusting an invariant it does not own.

Coverage

Every file this PR touches is at 100 % statements, branches, functions and lines, measured on
head 0d2b1d05 with --collectCoverageFrom restricted to the PR's own files. This was not true
when this section was first written: src/hooks/guard.hook.ts sat at 100 / 96.15 / 100 / 100
(50 of 52 branches, uncovered line 66), because two unreachable default arguments of
useUserRoleGuard had never been removed although this body and the commit message of d89d160b
said they had. Both are gone now and the file measures 100 on all four metrics.

The one gap that mattered was the error path: getPartnerStatistic rejecting had never been
exercised by any test, error-state.tsx was at 0 %, and the component carried a
data-testid="dashboard-error" placed for tests that were never written. It is covered now with the
failure produced rather than simulated — a rejecting call, an Error without a message, a
non-Error rejection, and a Retry that provably triggers a second load.

The ApexCharts formatters — the 1000 → "1k" rule, the tooltip date via toLocaleDateString, the
Number.isNaN → ABSENT_LABEL branch — are called with real values and their return is asserted, so
they are pinned rather than merely executed.

Reaching 100 % branches required removing four default arguments no caller can reach plus one
if (buckets.length > 0) that Math.max(1, …) makes unreachable. d89d160b removed two of them
(NavigationMenu, the fixture's makeBucket); the two on useUserRoleGuard followed only in
0d2b1d05 — the earlier claim that all of them were done was wrong. Each is module-private with
every call site passing the value explicitly, so the change is behaviour-neutral. src/App.tsx and
src/components/navigation.tsx are included too — the inherited gaps in the route table and the
menu, not only the lines this PR added.

Verification

  • 105 suites, 1014 tests on head 0d2b1d05 (104 / 1001 before the fix round, 94 / 883 before
    the review round), lint empty under the
    repo's own --max-warnings 0, tsc -p tsconfig.build.json --noEmit exit 0.
  • Mutation probes rather than deletion probes, each applied with an asserted hit count of one,
    the diff shown, and reverted afterwards. Eleven in total; the ones that matter:
    • value === allowed!== in the partner role check — 11 of 1001 tests fail across
      4 suites, and the suite still runs (100 suites stay green), so it is a semantic failure and
      not a crash.
    • val >= 1000>= 100 in the volume formatter — 1 test fails.
    • Number.isNaN inverted in both chart tooltips — 1 test each.
    • catch sets setError(null) — 5 tests fail.
    • Retry stops incrementing reloadToken — 1 test fails.
    • MARKETING removed from SUPPORT_DASHBOARD_ROLES — 1 test fails.
    • Route parameter corrupted in App.tsx — 4 tests fail.
    • Admin role check inverted in navigation.tsx — 2 tests fail.
  • Not mutation-proven, and named rather than glossed over: setStatistic(null) in the catch
    is executed by the tests, but removing it keeps the suite green — the UI is gated on error, not
    on an empty statistic. The line is defensive, and the test suite does not prove it is needed.
  • The contract was checked field by field against a real response from a locally running API,
    not against the types: enum casing (Day, Buy), the lowercase JSON keys (volume.buy),
    nullability, and the fields the sample does not cover are named rather than assumed.
  • Seen against that running API end to end, not only in fixtures: a real login, a real token, the
    menu entry, and a day carrying a single transaction of 60 CHF rendered as a value.

For reviewers

  • DFXswiss/api#4587 must ship first. Without the NonCustodialWalletPartner role the guard
    admits nobody and the endpoints reject the token.
  • This branch now carries develop (merge commit): the handbook tooling from feat(handbook): add static handbook served at handbook.app.dfx.swiss #1265/fix(handbook): discover documents instead of listing them, and fix slug collisions #1268 did not
    exist on the branch when the review was written, which is why the path-filter question below
    could not be answered from it.
  • On the handbook path filter: it used to be true that handbook-check.yaml never triggered for
    this PR, because none of its files touched a filtered surface — the consequence of the missing
    baseline, not a fault in the filter. With the baselines and the metadata.json entry in the diff
    the job now runs and passes on 0d2b1d05. The filter covers exactly the surfaces the handbook is built from
    (screenshots, docs, tokens, logo); src/** is deliberately not among them because the build
    reads no application code. One gap is worth naming: the dashboard's own design tokens live in
    src/partner-dashboard/styles/tokens.css, while the filter knows only tailwind.config.js as a
    token source. Harmless while the handbook builds no token page from that file — but that is where
    the filter will be too narrow next.
  • The token scoping was reviewed and left as it is, deliberately. The generic names — --bg,
    --text, --primary, --surface — sit on .theme-light / .theme-dark, not on :root; what
    :root carries is namespaced primitives (--navy-*, --n-*, --fs-*) plus six shared ones
    (--error, --info, --success, --warning, --font-sans, --font-mono). Nothing outside the
    dashboard defines any of them — the app has exactly one other stylesheet. Scoping them to the
    dashboard element would break chart colours: readThemeCssVar measures through a probe element
    attached to the document root carrying only the theme class, which is how the charts get their
    palette on a theme switch before the DOM class has caught up.
  • The translation test reads all three files (de, fr, it) via describe.each, checks key-set
    equality in both directions and rejects any value left as its English source outside a documented
    six-entry allow-list. The earlier sentence here — „no test reads those two files" — was already
    wrong when it was written: that check landed in 9a6652eb, three days before.

Fix round on 0d2b1d05 — what the review passes found

Seven defects, five of them visible in the handbook baselines that came with the previous commit:

  1. The referral hero printed the currency twice („285.40 EUR EUR") — formatAmount already appends it.
  2. load() wrote every response unconditionally, so switching period or granularity mid-request let
    the slower answer win. Now guarded by a request id checked before each setter, matching
    support-dashboard-overview.screen.tsx and safe.hook.ts.
  3. translate sat in load's dependency list, so every language switch refetched both endpoints and
    blanked the screen — against a budget of 120 requests/hour shared by all staff of one wallet.
  4. The aggregated tail row was appended after sorting, taking the top of the bar scale and shrinking
    every real row; its label was hard-coded German inside a util that never saw i18n.
  5. The screen did not pass textStart, so the layout centred every label above its left-aligned value.
  6. The bar palette spread over all eight shades: the lowest rows reached 1.05 : 1 against the
    track, so a two-row list showed one bar. It now cycles four shades that clear 3 : 1 in both themes
    (light 4.54 / 3.68 / 3.27 / 3.08, dark 3.03 / 3.78 / 4.74 / 5.87); the referral paid segment went
    from 1.11 : 1 to ≈ 6.2 : 1.
  7. useUserRoleGuard's two unreachable defaults — see the coverage section above.

New tests pin what was missing rather than what already passed, and each was verified by landing the
mutation it is meant to catch: the route path (partner/dashboardpartner/dashboards left
1001 of 1001 tests green before), the KPI field mapping, the query parameters, the sell and swap
series, the i18n namespace, the colour steps, the bar widths, and textStart. The e2e spec pins
timezoneId: 'UTC' (the axis labels go through toLocaleDateString, so the baselines were
machine-dependent), asserts the first axis tick exactly, fails loudly on an unmocked statistic
endpoint, and covers the negative-role redirect.

The four baselines carry invented figures. They previously showed the values from
src/partner-dashboard/fixtures/partner-statistic.fixture.ts, which describes itself as
„production-checked Cake values (not scaled)" — those numbers do not belong on a published handbook
page. The fixture itself still carries them; that is an open owner decision, not something this
branch settled.

Final pass (0d2b1d0):
Coherent: one subject — the partner dashboard behind the normal login: screen, guard, view,
translations, tests, and the handbook surface that documents it. The fix round changes only files
this PR already owned, plus guard.hook.ts, which it already touched.
Nothing extra: the smallest change that answers the request is the dashboard itself; every
addition beyond it is traceable to a stated requirement — the coverage the reviewer demanded, the
handbook baseline he demanded, and the seven defects the review passes found with a named failing
scenario each. Deliberately not built: no standalone partner app (removed in f5631770), no
suppression/k-anonymity handling (the api revision this consumes does not send it), and the fixture
is left untouched pending the owner decision above.
Sources closed: Issue — none linked. Reviewer (issues/1262/comments, one comment of
2026-08-07; pulls/1262/reviews and pulls/1262/comments are both empty, and the reviewer has no
commits on this branch): 100 % coverage on every touched file — met, and the gap that was hidden
behind a wrong claim is named above; handbook baseline plus metadata entry, and the path-filter
question answered
— met, the job runs and passes; SDK enum member first, then consumeopen,
DFXswiss/packages#205 is a draft and the guard still compares the string, listed as a checkbox at
the top; self-contained — the two remaining items are in other repositories, which is why they are
checkboxes with an owner rather than a follow-up PR. Own plan and commit messages: the claims of
d89d160b and of this body were checked against the diff, and three were wrong — each is corrected
in place above rather than quietly rewritten.

@TaprootFreak

Copy link
Copy Markdown
Contributor

Measured locally on 9a6652eb with Node 20, full suite: 91 suites / 871 tests green, ESLint clean. Coverage below is react-app-rewired test --coverage with --collectCoverageFrom restricted to the files this PR adds or touches.

Coverage on the files this PR touches

Aggregate over the PR's own files: 82.36 % statements, 67.41 % branches, 78.87 % functions, 82.15 % lines.

File % Stmts % Branch % Funcs Uncovered
partner-dashboard/components/error-state.tsx 0 100 0 9-11
partner-dashboard/util/series.ts 62.50 75 100 58-67
partner-dashboard/components/transactions-time-chart.tsx 70.83 20 62.50 87-105
partner-dashboard/components/volume-time-chart.tsx 70.83 16.66 62.50 78-95
partner-dashboard/util/theme.ts 76.74 50 72.72 68-73, 120-123
partner-dashboard/util/timeline-axis.ts 86.36 77.77 90 106-110
partner-dashboard/components/period-controls.tsx 87.50 100 77.77 64, 82
partner-dashboard/components/error-boundary.tsx 88.88 75 75 30
partner-dashboard/App.tsx 89.65 75.86 88.23 62-67, 115
partner-dashboard/util/i18n.ts 100 57.14 80 35-37
partner-dashboard/components/kpi-tile.tsx 100 75 100 22, 45
hooks/guard.hook.ts 35.55 25 20 7-23, 29, 67-103

Two of these are worth more than their number.

The dashboard's entire error path is untested. Not "thinly covered" — untested. App.tsx:62-67 is the catch block that derives the message and clears the statistic and timeline; App.tsx:115 is the <ErrorState> render; error-state.tsx is at 0 % because no test ever renders it; error-boundary.tsx:30 is uncovered too. So if getPartnerStatistic rejects, nothing in the suite has ever exercised what the partner actually sees. The component even carries data-testid="dashboard-error" — a hook placed for tests that were never written. This is the one gap I would not merge without, independently of the percentage rule.

The chart formatters are the uncovered part of the charts. Lines 78-95 and 87-105 are the ApexCharts formatter callbacks: the y-axis 1000 → "1k" rule, the tooltip date via toLocaleDateString(locale), and the Number.isNaN(val) → ABSENT_LABEL branch. On a metrics dashboard those callbacks are the product — they decide the numbers a partner reads off the chart — and they sit at 16-20 % branch coverage.

guard.hook.ts is the one entry to read carefully: the uncovered ranges (7-23, 29, 67-103) are the pre-existing guards, while this PR's own additions (PARTNER_DASHBOARD_ROLES, isPartnerDashboardRole, usePartnerDashboardGuard, lines 42-64) are covered. Under the "100 % on every touched file" rule it still has to come up, but the debt is inherited, not introduced.

Handbook coverage is zero, and the gate cannot fire

scripts/handbook/metadata.json holds 37 entries and none of them is the partner dashboard; no spec under e2e/ mentions partner; there is no baseline among the 154 committed screenshots. And handbook-check.yaml filters on e2e/screenshots/**, scripts/handbook/**, tailwind.config.js, src/static/assets/**, public/logo.png, docs/**, README.md, CONTRIBUTING.md, Dockerfile.handbook, handbook.nginx.conf and the two workflow files — this PR touches 58 files and not one of them matches. The gate does not skip, it never triggers. A whole new screen with its own navigation entry needs a baseline and a metadata entry before merge.

The role belongs in the SDK

PARTNER_DASHBOARD_ROLES = ['NonCustodialWalletPartner'] as const with string equality, because @dfx.swiss/react has no such enum member — the comment says so itself and adds "the value must stay character-identical to the API enum". That constraint is exactly what a shared enum exists to enforce. The member belongs in DFXswiss/packages (UserRole in the core definitions), released, then consumed here; the string allow-list is a workaround at the call site, which is the pattern we do not take. Same rule as on #1270.

Hard requirements

  • Self-contained. Everything above is fixed in this PR; a follow-up needs an explicit exception, not an author's choice.
  • 100 % coverage on every touched file, error path first.
  • Handbook: baseline + metadata entry for the new screen, and the path filter question answered — a gate that cannot trigger for any file this PR touches is a gate in name only.
  • SDK enum member first, then consume.

Verified and good

partner-dashboard.screen.tsx, referral-block.tsx, skeleton.tsx, empty-state.tsx, chart-theme.ts and partner-dashboard.config.ts are at 100 % across all four metrics. partner-dashboard.hook.ts is at 100 % statements with a genuine fixture mode that keeps presentation tests off the network. The theme tokens are split per light/dark against the actual navy surface rather than derived, and the colour choice is argued rather than picked. 871 tests green with zero failures is not nothing at this size.

@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

Thank you — the coverage read was accurate to the line. Measured it again independently before
touching anything, and your table held; it also surfaced two files your list does not name,
src/App.tsx (52.35 %) and src/components/navigation.tsx (32.6 %), which this PR touches as
well. They are included below rather than argued away.

Head is now b06d81b. All numbers here are measured on that revision.

100 % coverage on every touched file — done

Every file this PR touches is at 100 % statements, branches, functions and lines. Suite went
from 94 suites / 883 tests to 104 suites / 1001 tests; lint output empty under
--max-warnings 0, tsc -p tsconfig.build.json --noEmit exit 0.

The error path first, as you asked. It is covered with the failure produced, not simulated: a
rejecting getPartnerStatistic, an Error with an empty message, a non-Error rejection, and a
Retry that provably triggers a second load. error-state.tsx, the catch block, the
<ErrorState> render and error-boundary.tsx are all pinned — the data-testid="dashboard-error"
now has the test it was placed for.

The chart formatters are called with real values and their return asserted, so the 1000 → "1k"
rule, the tooltip date via toLocaleDateString(locale) and the Number.isNaN → ABSENT_LABEL
branch are pinned rather than merely executed.

Mutation probes rather than deletion probes — eleven, each applied with an asserted hit count of
one, diff shown, reverted afterwards:

Mutation Result
value === allowed!== in the partner role check 11 of 1001 fail across 4 suites; 100 suites stay green, so it is a semantic failure, not a crash
catch sets setError(null) 5 fail
val >= 1000>= 100 (volume y-axis) 1 fails
Number.isNaN inverted (both tooltips) 1 each
Retry stops incrementing reloadToken 1 fails
MARKETING dropped from SUPPORT_DASHBOARD_ROLES 1 fails
Route parameter corrupted in App.tsx 4 fail
Admin role check inverted in navigation.tsx 2 fail

One line is covered but not mutation-proven, and I would rather say so than let the percentage
imply otherwise:
removing setStatistic(null) from the catch keeps the whole suite green. The
UI is gated on error, not on an empty statistic, so the assignment is defensive and the tests do
not prove it is needed.

Reaching 100 % branches needed four unreachable spots removed, in a separate, behaviour-neutral
commit (d89d160): the default arguments of useUserRoleGuard, NavigationMenu and the fixture's
makeBucket — all three module-private with every call site passing the value — plus
if (buckets.length > 0) in the fixture, where bucketCount = Math.max(1, …) guarantees at least
one bucket. guard.hook.ts's inherited debt is included, as you asked, not deferred.

SDK enum member first — split out

DFXswiss/packages#205 adds NON_CUSTODIAL_WALLET_PARTNER = 'NonCustodialWalletPartner' to
UserRole in the core definitions, value character-identical to
src/shared/auth/user-role.enum.ts:15 in DFXswiss/api#4587. Same shape as the rule on #1270.

Checked the consumers rather than assuming additivity: nothing maps the enum exhaustively —
packages has no switch/Record<UserRole, …>, services has one
Partial<Record<UserRole, Department[]>> (src/util/support-helpers.ts:20), dfx-wallet none.

This is the one thing that cannot close from this branch: the member needs a published
version, which comes from developmain and npm publish in DFXswiss/packages. It is the
first item in the open list at the top of the PR body, with who can close it. The switch here is a
single line (PARTNER_DASHBOARD_ROLES in src/hooks/guard.hook.ts).

Worth adding as supporting evidence for your point: the string allow-list is the only role
comparison in the whole of src that does not go through UserRolelabels.ts:173,
navigation.tsx:237, navigation.tsx:246 and support-dashboard-issue.screen.tsx:34 all use the
enum already.

Handbook — and why the gate could not fire

You were right that the gate cannot trigger, and the reason turned out to be one layer below the
filter: this branch did not carry the handbook tooling at all. It branched off before #1265 and
#1268, so scripts/handbook/metadata.json and handbook-check.yaml did not exist on it. develop
is merged in now, which is also why the diff grew.

On the filter itself: it covers exactly the surfaces the handbook is built from — screenshots,
docs, tokens, logo — and src/** is deliberately not among them, because build.js reads no
application code. So it is not a gate in name only; it is a gate whose input this PR does not yet
produce. One place where it is too narrow is worth naming: the dashboard's own design tokens live
in src/partner-dashboard/styles/tokens.css, while the filter knows only tailwind.config.js as a
token source. Harmless while no token page is built from that file.

The baseline itself I cannot produce from here, and I am carrying it as an open item rather than
shipping a substitute.
The screen is gated on NonCustodialWalletPartner; until api#4587 is
merged and deployed, the role cannot be granted on any running environment, so there is no real
screenshot to take. A fixture screenshot would document example numbers as a product state, which
is worse than a missing page. The metadata.json entry deliberately waits for the images too — an
entry without matching screenshots emits an orphan warning on every handbook build
(scripts/handbook/build.js:671).

If you would rather have the entry and a fixture baseline now and the real one later, say so and
it goes in — but I did not want to make that trade silently.

Self-contained

Two of the four are closed in this PR (coverage, and the unreachable-branch cleanup it required).
The other two — the SDK release and the handbook baseline — depend on DFXswiss/packages and
DFXswiss/api#4587 respectively, and both are now checkboxes at the top of the PR body with what is
needed and who can do it, rather than prose in the middle.

@TaprootFreak
TaprootFreak force-pushed the feat/partner-dashboard branch from 0d2b1d0 to e7c022b Compare August 11, 2026 07:32
Reads the aggregated partner statistics endpoints and renders them as a white-label
dashboard: KPI tiles, volume and transaction time series, breakdowns by asset, currency,
blockchain and payment method, and the referral position. A fixture mode carries demo data
so the whole surface runs without an API or a token.

Withheld values are shown as a placeholder rather than a zero, and a suppressed day is
marked in the charts instead of drawing a gap — a gap reads as "no business that day",
which is not what the API means by null.

Built as its own entry point next to the existing app, so the shared build is untouched.
The API moved its enum values to PascalCase, so the dashboard was sending `day` where the
timeline endpoint now only accepts `Day` — every real timeline call would have returned 400 —
and it read `buy` from the asset breakdown where the API now writes `Buy`, so rows were sorted
into nothing. Only fixture mode hid it. The values are defined once and derived from there;
the JSON field names stay lowercase, since the API maps the enum onto them separately.

The period buttons were wired but had nothing to act on: the demo data ignored the requested
range and always returned the same month, which is why "365 days" showed thirty buckets. It
now honours range and granularity, keeps the withheld buckets and the genuine zero day, and
still marks the edge buckets as partial.
The partner dashboard carried its texts hardcoded in German while the rest of the repo runs
on English keys with translation files. The first partner is an international product, and
more are planned, so the surface was unusable as it stood. It now uses the same mechanism as
everything else: English keys, the German wording moved into the existing translation file
unchanged, and number, date and currency formats following the selected language rather than
staying German.

French and Italian fall back to English until their keys are filled in — text work, no code.
Collects the dashboard work that was sitting unsaved in a scratchpad directory: the wallet
login gate and its auth hook, the per-partner brand registry with a fallback for partners
without a logo, light and dark themes with a switcher, German and English with a switcher,
the even-spaced timeline axis, and the table that starts scrolling past twenty rows.

Committed as one unit to get it under version control. It is not yet reviewed as a whole.
The API no longer withholds anything, so the dashboard stops drawing withholding. Gone are
the hatched bands, the interpolation that bridged withheld days, the dash placeholders and
the threshold that produced them. A day with a single transaction is now a low point on the
curve, and a day with none sits on the zero line — the shape falls, it does not break.

That is enforced by the type rather than by a test: the series carries `[number, number]`,
so a gap cannot be expressed at all. Reintroducing one no longer compiles.

The completion block went with it. It rendered `statistic.completion`, which the API does
not send and never did — against the real endpoint it would have drawn from `undefined`.
It belongs to a separate change that is deliberately parked, so it leaves with its types,
fixtures and translations rather than waiting as scenery.

Edge buckets keep their partial marker. That one is still true: the first and last bucket
of a period really can extend past the period's ends.
The API has always returned two figures the dashboard never showed: how many of a wallet's
registered users ever traded, and the lifetime volume behind them. The first is the number
a partner actually wants — installs are easy, a first trade is not, and the ratio between
them says whether their users find the way there at all. It is shown as a conversion rate
beside the raw counts. A partner with no installs yet has no ratio rather than a misleading
zero percent, and a test pins that the page never renders NaN there.

Adding them exposed a problem that was already present. Eight tiles now sat in one
six-column grid, leaving a ragged row of two — but the real fault was that they were two
different kinds of number wearing the same clothes. Five move when the period selector
moves; registered users, trading users and lifetime volume are lifetime counters the API
computes without any date range at all. Someone switching from thirty days to a year
watched five figures change and three sit still, and had every reason to read that as a bug.

They are two labelled groups now: the period figures under the selector that governs them,
the all-time figures below and visually subordinate, being context rather than headline.
Five and three fill their rows exactly, so the ragged edge goes with it. A test pins which
tile belongs to which group by DOM containment, so moving a period metric into the all-time
block fails rather than quietly restoring the confusion.
The dashboard had its own build target, its own login screen and its own token handling. That
login asked for a wallet signature, which meant anyone wanting to look at a chart needed the
partner's wallet key — their master secret, handed to whoever works in operations.

It is a screen in the app now, reached from the burger menu, gated by a role the way the
support dashboard is, and it reads the two endpoints with the ordinary session token. The
separate entry point, the login screen, the partner auth hook, the white-label brand registry
and the standalone build and start scripts are gone with it.

Branding per partner goes too. Inside the DFX app behind the DFX login, a partner logo would
claim something the page no longer is.
The move into the app dropped the language and theme switchers on the argument that the app
provides them. It provides language only, and only on the settings page — a partner mid-analysis
would have to leave the page to change it. Dark mode it does not provide at all, so the dashboard
simply lost it, though its token system had never gone away; only the setter had.

Both sit in the header again as one quiet group. Language drives the app's own language state
rather than a second copy beside it, so the rest of the app follows along.
The header carried the program name twice: a small spaced-out line above the heading and the
heading itself, word for word the same. A kicker earns its place by saying something the
heading does not; this one only repeated it. The bold line stays and is now the page's only
title, with a test that fails if a second heading with the same text appears.
The language switcher offers all four languages the app ships, but the partner namespace
existed only in German — French and Italian users got the English source strings back. Both
now carry the full set, matching the German key for key.

Three values stay as they are: Blockchains and Date read the same in French, and Referral is
left untranslated in Italian as it is in German.
"Non-Custodial Partner Program" wrapped onto two lines in the burger menu while every other
entry sat on one. Menu and heading now have their own keys, so the list stays even at "NC
Partner Program" and the page still announces itself in full.
The guard's decision was covered: inverting it failed six tests. Its wiring was not. Removing
the guard call from the screen outright left all eighty-three green, and so did replacing the
menu's role condition with a constant true — the whole access control of the page could be
deleted without a red test.

Two things caused that. The only test rendering the real screen mocked the guard hook to a
no-op, and the menu test rendered a hand-written copy of the condition instead of the real
navigation, which no test in the repository imported at all.

Both are gone. The screen test lets the real guard run, the menu test renders the real
navigation, and both mutations above now fail.
A shared mock helper sat in src/__tests__/helpers/. Create React App treats every file under
__tests__ as a suite, so a file with no tests in it failed the whole run — while the test count
still read 863 of 863 passing, because a suite that never runs contributes no tests to count.

It lives in src/test-helpers/ now, alongside no test matcher. Every other test file in this
repository sits flat in __tests__ for exactly this reason.
The shared mock helper had to stay under __tests__, because tsconfig.build.json excludes that
directory and the helper uses jest globals — moving it out put it in the production compile,
where the namespace does not exist. But Create React App treats every file under __tests__ as a
suite, so the helper failed the run while the test count still read 863 of 863 passing: a suite
that never runs contributes no tests to count.

Jest now ignores the helpers directory. `testMatch` would have been the tidier key and is listed
as supported, but react-app-rewired drops it — asking Jest directly which files it collects showed
the helper still in the list of ninety-two. `testPathIgnorePatterns` is the key this repository
already uses, and it takes: ninety-one now.
The test asked only whether a value was a non-empty string. A key left standing as its English
source text passed, and French and Italian were read by no test at all — the two files carrying
fifty-five keys each could lose one without a red line anywhere.

All three languages are now compared key for key against each other, and a value identical to its
English source fails unless it is on a documented list: Blockchains and Date read the same in
French, Blockchains and Swap in German, Referral in Italian. Each of those was checked against how
the rest of the repository already treats the word.
`useUserRoleGuard`, `NavigationMenu` and the fixture's `makeBucket` are all
module-private, and every call site passes the argument explicitly. The defaults
were unreachable, so no test could ever cover them and the branch coverage of the
three files could not reach 100 percent.

Behaviour is unchanged: the callers already pass exactly the values the defaults
declared. The same applies to `if (buckets.length > 0)` in the fixture, where
`bucketCount = Math.max(1, ...)` guarantees at least one bucket.
The dashboard's error path had no test at all: the catch block that derives the
message and clears the data, the ErrorState render, the error boundary. The
component even carried a data-testid nobody used.

Covered now, with the failure produced rather than simulated: a rejecting
getPartnerStatistic, an Error without a message, a non-Error rejection, and a
Retry that provably triggers a second load. The ApexCharts formatters are called
with real values and their return is asserted, so the '1k' rule, the localised
tooltip date and the NaN branch are pinned rather than merely executed.

Also covers the pre-existing guards in guard.hook.ts, the route table in App.tsx
and the menu in navigation.tsx, which the PR touches and which carried inherited
gaps.
…data entry

The partner dashboard had no handbook coverage: no Playwright baseline, no
scripts/handbook/metadata.json entry, and because the PR touched neither
e2e/screenshots/** nor scripts/handbook/**, handbook-check.yaml never triggered
for it — the gate could not fail, so its absence was not visible.

Adds e2e/partner-dashboard.spec.ts with four product states (filled light,
filled dark, error, empty) and the four committed baselines, plus the metadata
entry that gives the handbook block its title and description.

The screenshots need neither a DEV role grant nor live API data: the role guard
is client-side (jwtDecode without signature check), so a synthetic token carries
the role, and both statistic endpoints are mocked. The clock is frozen at
2026-06-30T12:00:00Z because the period controls and chart axes derive their
ranges from the current time.

Viewport is fitted to content per state instead of using a fixed tall viewport
with fullPage: on short pages the stitched capture repeated the sticky header at
the bottom of the image, so the error and empty baselines showed a state the
product never renders.
…e the bars legible

Seven defects, all reachable in production and five of them visible in the
committed handbook baselines:

- The referral hero printed the currency twice ("285.40 EUR EUR"): formatAmount
  already appends it, and the component appended it again.
- load() wrote every response unconditionally, so switching period or granularity
  mid-request let the slower answer win. Now guarded by a request id checked before
  each setter, matching support-dashboard-overview and safe.hook.
- translate sat in load's dependency list, so every language switch refetched both
  endpoints and blanked the screen behind the skeleton - against a rate budget of
  120 requests per hour shared by all staff of one wallet. The error is now stored
  as a marker and translated at render time.
- The aggregated tail row was appended after sorting, so a large tail took the top
  of the bar scale and shrank every real row; its label was hard-coded German in a
  util that never saw i18n.
- The screen did not pass textStart, so the layout centred every label above its
  left-aligned value.
- The bar palette spread across all eight shades: the lowest rows reached 1.05:1
  against the track, so a two-row list showed one bar. The palette now cycles four
  shades that clear 3:1 in both themes; the referral paid segment likewise.
- useUserRoleGuard kept two unreachable default arguments, which held guard.hook.ts
  at 96.15 % branches. Removed; the file is now at 100 % on all four metrics.

Tests pin what was missing rather than what already passed: the route path, the
KPI field mapping, the query parameters, the sell and swap series, the i18n
namespace, the colour steps and the bar widths. Each was verified by landing the
mutation it is meant to catch. The e2e spec pins the timezone, asserts the first
axis tick exactly, fails loudly on an unmocked statistic endpoint, and covers the
negative-role redirect; the four baselines carry invented figures instead of the
fixture's production-checked partner numbers.
@TaprootFreak
TaprootFreak force-pushed the feat/partner-dashboard branch from e7c022b to 2c557d6 Compare August 11, 2026 07:53
The full-stack route-coverage gate now really runs since the full-run flag was
repaired, and it rightly flags the new route: no registry entry, no suite that
opens it. The new suite proves the negative case in the real stack - a plain
user is redirected away by the frontend guard. The positive case needs the
NonCustodialWalletPartner role, which the API only grants once its side ships,
so it stays with the mocked handbook spec and is named as not covered here.
@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@marassteiner please review

This is a PR, not an issue: 77 files against develop, head f7219fc, 7/7 checks green.

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.

2 participants