Let a delegate read the dashboard they land on

Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.

Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.

The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.

Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.

Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.

The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 21:12:35 +02:00
1 parent a0fc404fb8
commit 508cc8a60a
17 files changed
+1025 -49

No files matched your search

@@ -0,0 +1,81 @@
/**
* Whose profile the dashboard's client-side band fallback is allowed to use.
*
* The fallback draws reference bands — blood-pressure targets, the weight
* range, every shaded chart zone — from the profile the page already holds,
* so that charts are not blank for the frames before the server-resolved
* bands arrive. That profile comes from `/api/auth/me`, an actor surface, so
* under an account switch it describes the DELEGATE while the charts underneath
* carry the OWNER's readings. A band is a claim about the person whose numbers
* it shades; borrowing one from the person looking is the same wrong-record
* paint the RSC prefetch already had to be taught to avoid, and it is quieter,
* because a shaded zone looks exactly as authoritative either way.
*
* These assert the emptiness has TEETH: the same profile that produces a real
* band unswitched must produce none switched, so an implementation that
* returned its input regardless fails on the second half of every case rather
* than on a shape check.
*
* Mutation checks, run:
* - `return profile;` unconditionally in `viewerBandProfile` → the three
* switched cases go red, printing the delegate's own BP target numbers as
* the value that reached the band.
*/
import { describe, expect, it } from "vitest";
import { buildDashboardBands, viewerBandProfile } from "@/lib/dashboard/bands";
/** A delegate with a complete profile — the worst case, not the empty one. */
const DELEGATE_PROFILE = {
dateOfBirth: new Date("1996-04-02T00:00:00Z"),
gender: "FEMALE" as const,
heightCm: 165,
weightTargetOverride: { min: 55, max: 62 },
};
describe("viewerBandProfile", () => {
it("hands the profile through untouched when nobody has switched", () => {
expect(viewerBandProfile(DELEGATE_PROFILE, false)).toEqual(
DELEGATE_PROFILE,
);
});
it("answers with no profile facts at all while acting on another record", () => {
expect(viewerBandProfile(DELEGATE_PROFILE, true)).toEqual({
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
});
});
});
describe("the bands that result", () => {
it("are real for the caller's own dashboard", () => {
// The evidence half. Without it, "no band under a switch" is satisfied by
// a profile that never produced one.
const own = buildDashboardBands(viewerBandProfile(DELEGATE_PROFILE, false));
expect(own.bpTargets).not.toBeNull();
expect(own.weightRange).not.toBeNull();
expect(own.weightBands).not.toBeNull();
});
it("carry none of the caller's profile onto a record they are acting on", () => {
const own = buildDashboardBands(viewerBandProfile(DELEGATE_PROFILE, false));
const shared = buildDashboardBands(
viewerBandProfile(DELEGATE_PROFILE, true),
);
expect(shared.bpTargets).toBeNull();
expect(shared.bpSysRange).toBeNull();
expect(shared.bpDiaRange).toBeNull();
expect(shared.weightRange).toBeNull();
expect(shared.weightBands).toBeNull();
// The pulse and body-fat bands have no null arm — they fall back to the
// published population range — so the claim there is that the numbers are
// the NEUTRAL ones and not the ones this caller's age and sex produce.
expect(shared.pulseDisplayRange).not.toEqual(own.pulseDisplayRange);
expect(shared.bodyFatRange).not.toEqual(own.bodyFatRange);
});
});
+42
View File
@@ -181,3 +181,45 @@ export function buildDashboardBands(profile: {
// The snapshot builder re-exports `buildDashboardBands` under the historic
// `buildTargetBands` name (see dashboard/snapshot.ts) so callers and the
// parity test keep working without a duplicate export living in this file.
/** The profile facts `buildDashboardBands` derives a band from. */
export interface BandProfile {
dateOfBirth: Date | null;
gender: ProfileSex;
heightCm: number | null;
weightTargetOverride: { min: number; max: number } | null;
}
/**
* Which profile the dashboard's CLIENT-side band fallback may compute from.
*
* The fallback exists because the server-resolved bands arrive with the
* snapshot and the charts have to draw before that: it recomputes the same
* numbers from the profile the page already holds. That profile comes from
* `/api/auth/me`, which is an actor surface — it answers about the person
* logged in and deliberately never about the record they are acting on. So
* while a switch is active it describes the DELEGATE, and drawing a band from
* it puts one person's reference ranges over another person's readings: a
* thirty-year-old helper's blood-pressure target shading an eighty-year-old's
* chart, which looks like a reading and is not one.
*
* Under a switch this therefore answers with nothing. The charts render
* unshaded until the snapshot's own bands land, and unshaded is honest —
* an absent band says nothing, while a borrowed one says something false.
*
* A pure function rather than three ternaries at the call site, because this
* is the whole of the decision and it should be possible to state it, test it,
* and break it in one place.
*/
export function viewerBandProfile(
profile: BandProfile,
inSharedRecord: boolean,
): BandProfile {
if (!inSharedRecord) return profile;
return {
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
};
}