mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Switching is three acts in a fixed order, and the order is the safety argument. Ask the server, so a refused switch leaves the browser where it was. Empty the caches a reload cannot reach, awaited rather than fired, because a wipe racing the navigation is a wipe that sometimes does not happen. Then reload the document, which is what discards the in-memory cache, the rendered React trees, and every request already in flight against the old record. The banner is the feature, not decoration. Someone who forgets they are switched will log their own blood pressure into another person's record, and there is no undo for a reading that was never theirs. So it states whose record is open, that it is read-only, and carries the way out — in the one visual register reserved for "something about this screen is not normal", at the top of the shell above every other strip. A subtle tint is what people stop seeing on the third switch. The switcher lives in one component mounted in both user menus. A way in on the laptop and no way out on the phone is how the two menus have drifted before. Nav entries that sharing does not cover drop out, and a page reached by bookmark says so instead of painting a row of refusals. Both are paint: the server refuses those routes on its own, from a frozen list this code cannot reach. Settings, connected services, notifications and the AI surfaces are the account around a record rather than the record itself. A grant can end while a browser is inside the record. Revocation clears the delegate's sessions in the same transaction; expiry does not, and leaves a session stamped with an account it may no longer act on — correct refusals under a screen that lies. One cache subscriber notices the stable errorCode and leaves, rather than asking two hundred call sites to remember.
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
import {
|
|
Activity,
|
|
Bell,
|
|
Brain,
|
|
Droplets,
|
|
FileScan,
|
|
FlaskConical,
|
|
Home,
|
|
Lightbulb,
|
|
MessagesSquare,
|
|
Pill,
|
|
Settings,
|
|
Stethoscope,
|
|
Thermometer,
|
|
Trophy,
|
|
Waves,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
import type { ModuleKey } from "@/lib/modules/registry";
|
|
|
|
/**
|
|
* v1.17.1 — the single navigation information-model.
|
|
*
|
|
* Before this module the desktop sidebar and the mobile bottom-nav each
|
|
* hand-curated their own destination list, and the two had drifted: the
|
|
* sidebar listed Measurements / Mood inline but hid Workouts; the bottom
|
|
* bar buried Measurements / Mood in "More" but promoted Workouts — and
|
|
* the Coach, the stated differentiator, had no nav home on either. A user
|
|
* who learned the product on one platform re-learned it on the other.
|
|
*
|
|
* This list is the ONE ordered destination model. Both bars render it:
|
|
* the sidebar shows every entry in order; the bottom bar keeps its 5-slot
|
|
* ergonomic shape (Home · Meds · capture · Insights · More) and derives
|
|
* its "More" hub from the SAME list (every destination not already a
|
|
* primary slot), so the two bars are re-skins of one story rather than
|
|
* two curated lists that drift.
|
|
*/
|
|
export interface NavDestination {
|
|
href: string;
|
|
/** i18n key under the `nav.*` namespace. */
|
|
tKey: string;
|
|
icon: LucideIcon;
|
|
/**
|
|
* Stable onboarding-tour anchor. Matches `data-tour-id` lookups in the
|
|
* spotlight tour — renaming silently breaks the cutout for that step.
|
|
*/
|
|
tourId?: string;
|
|
/**
|
|
* v1.18.0 — gate the entry on a per-user module toggle. When set, the
|
|
* entry is dropped unless the account's resolved module map (from
|
|
* `GET /api/auth/me`'s `modules`) has the key enabled. Core destinations
|
|
* (weight / BP / pulse + always-on pages) carry no key and always render.
|
|
* `cycle` and `coach` are delegated keys (cycle → gender +
|
|
* opt-in, coach → operator flag + per-user opt-out); the auth/me map
|
|
* already reflects that delegation, so reading them here is correct and
|
|
* not a re-derivation.
|
|
*/
|
|
requiresModule?: ModuleKey;
|
|
/**
|
|
* v1.36.0 — is this destination part of what account sharing covers.
|
|
*
|
|
* `false` (the default, so the omission is the safe answer) means the entry
|
|
* disappears while the browser is acting on somebody else's record. It is
|
|
* PAINT and nothing else: the server refuses every non-delegable route under
|
|
* a switch on its own, from a frozen allowlist that this file cannot reach
|
|
* and does not mirror. Hiding the entry only spares a delegate a click that
|
|
* ends in a refusal — both ends, always, and this is the cosmetic end.
|
|
*
|
|
* The line the flag draws is the design's (§4, §6): a delegate reads the
|
|
* health RECORD, and never the account around it. So the tracking surfaces
|
|
* are marked, and everything that configures, connects, exports, or asks an
|
|
* AI about the account is not. AI is the one that looks arbitrary and is
|
|
* not: server-managed LLM egress of the owner's data rides a consent the
|
|
* owner gave for their own use, so those surfaces stay owner-only in v1.
|
|
*/
|
|
sharedRecord?: boolean;
|
|
}
|
|
|
|
/**
|
|
* The canonical ordered destination list. Cycle sits where it always has
|
|
* (after Medications) and is filtered out when the account gate is off;
|
|
* the order is otherwise identical for both surfaces.
|
|
*/
|
|
// v1.19.1 (S4) — the clinical/insight spine below the dashboard/measurements/
|
|
// mood/cycle head follows one fixed sequence, each entry directly below the
|
|
// previous: Medications → Vorsorge → Labs → Illness → Insights → Coach →
|
|
// Achievements. A disabled module simply drops out; the rest keep this order.
|
|
export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
|
{
|
|
href: "/",
|
|
sharedRecord: true,
|
|
tKey: "nav.dashboard",
|
|
icon: Home,
|
|
tourId: "nav-dashboard",
|
|
},
|
|
{
|
|
href: "/measurements",
|
|
sharedRecord: true,
|
|
tKey: "nav.measurements",
|
|
icon: Activity,
|
|
tourId: "nav-measurements",
|
|
},
|
|
{
|
|
href: "/mood",
|
|
sharedRecord: true,
|
|
tKey: "nav.mood",
|
|
icon: Waves,
|
|
tourId: "nav-mood",
|
|
requiresModule: "mood",
|
|
},
|
|
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood.
|
|
// Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off)
|
|
// key from the resolved module map, so the entry is absent until the account
|
|
// turns the module on from the Modules hub. The destination is the dedicated
|
|
// top-level `/mental-wellbeing` check-in surface (its own module page — it
|
|
// no longer borrows the Insights tab strip / layout shell).
|
|
{
|
|
href: "/mental-wellbeing",
|
|
sharedRecord: true,
|
|
tKey: "nav.mentalWellbeing",
|
|
icon: Brain,
|
|
tourId: "nav-mental-wellbeing",
|
|
requiresModule: "mentalHealth",
|
|
},
|
|
{
|
|
href: "/cycle",
|
|
sharedRecord: true,
|
|
tKey: "nav.cycle",
|
|
icon: Droplets,
|
|
tourId: "nav-cycle",
|
|
requiresModule: "cycle",
|
|
},
|
|
{
|
|
href: "/medications",
|
|
sharedRecord: true,
|
|
tKey: "nav.medications",
|
|
icon: Pill,
|
|
tourId: "nav-medications",
|
|
// v1.18.1 (D3) — medications graduated from a CORE domain to a toggleable
|
|
// module; the nav entry now drops when the account turns the module off.
|
|
requiresModule: "medications",
|
|
},
|
|
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
|
|
// clinical spine. It is a first-class tracking surface ("wann muss ich was
|
|
// wo machen"), not pure configuration, so it belongs in the model both bars
|
|
// render — not buried three taps deep under Settings → Reminders. The
|
|
// Reminders hub still links to it; this is the direct front door.
|
|
{
|
|
href: "/checkups",
|
|
sharedRecord: true,
|
|
tKey: "nav.vorsorge",
|
|
icon: Stethoscope,
|
|
tourId: "nav-vorsorge",
|
|
// v1.18.1 — deliberately NOT module-gated (no `requiresModule`). Unlike
|
|
// labs / illness / cycle (opt-in clinical-spine verticals born off by
|
|
// default), preventive-care reminders are a CORE surface available to
|
|
// every account from birth: a reminder can target core vitals
|
|
// (weight / BP / pulse) that are never behind a module toggle, and a
|
|
// free-text "Großes Blutbild" reminder belongs to no module at all.
|
|
// Gating the entry would orphan reminders the user can still create.
|
|
},
|
|
{
|
|
href: "/labs",
|
|
sharedRecord: true,
|
|
tKey: "nav.labs",
|
|
icon: FlaskConical,
|
|
tourId: "nav-labs",
|
|
requiresModule: "labs",
|
|
},
|
|
// v1.18.1 — the illness/condition journal sits in the clinical spine
|
|
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
|
|
// opt-in `illness` key from the resolved module map, so the entry is
|
|
// absent until the account turns the module on from the Modules hub.
|
|
{
|
|
href: "/illness",
|
|
sharedRecord: true,
|
|
tKey: "nav.illness",
|
|
icon: Thermometer,
|
|
tourId: "nav-illness",
|
|
requiresModule: "illness",
|
|
},
|
|
// v1.18.0 — Workouts and Recovery both left the left-nav: each already
|
|
// surfaces as an Insights tab-strip pill (`/insights/workouts` gated on
|
|
// a workout row, `/insights/recovery` always present), so neither is a
|
|
// top-level `NAV_DESTINATIONS` entry any more.
|
|
// v1.25.0 (W-DOCS-IN) — inbound clinical documents sit in the clinical
|
|
// spine after Illness. Born-gated: `requiresModule: "inboundDocuments"`
|
|
// reads the opt-in key from the resolved module map, so the entry is absent
|
|
// until the account turns the module on from the Modules hub.
|
|
{
|
|
href: "/documents",
|
|
sharedRecord: true,
|
|
tKey: "nav.documents",
|
|
icon: FileScan,
|
|
tourId: "nav-documents",
|
|
requiresModule: "inboundDocuments",
|
|
},
|
|
// v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they
|
|
// drop out under a switch. Both are AI surfaces, and AI egress of a
|
|
// person's health data rides the consent THAT person gave for their own
|
|
// use; a delegate triggering it would create a consent-shaped act the owner
|
|
// never made. Non-delegable in v1, server-side and here (design §4).
|
|
{
|
|
href: "/insights",
|
|
tKey: "nav.insights",
|
|
icon: Lightbulb,
|
|
tourId: "nav-insights",
|
|
requiresModule: "insights",
|
|
},
|
|
// v1.17.1 (F-3) — the Coach finally gets a single labeled nav home. It
|
|
// was reachable from seven scattered entry points (FAB, hero CTA, empty
|
|
// states, per-metric icons …) but nowhere in the nav, so a new user
|
|
// could miss the differentiator entirely. The other entry points stay.
|
|
{
|
|
href: "/coach",
|
|
tKey: "nav.coach",
|
|
icon: MessagesSquare,
|
|
tourId: "nav-coach",
|
|
requiresModule: "coach",
|
|
},
|
|
{
|
|
href: "/achievements",
|
|
sharedRecord: true,
|
|
tKey: "nav.achievements",
|
|
icon: Trophy,
|
|
tourId: "nav-achievements",
|
|
requiresModule: "achievements",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
|
|
*
|
|
* Settings and Notifications are reachable on both bars but are not feature
|
|
* destinations: on desktop they live in the sidebar footer + avatar menu, on
|
|
* mobile at the tail of the "More" hub. They used to be a second hand-curated
|
|
* list on each bar — the exact drift the one-model contract above set out to
|
|
* kill, just pushed down a level. This list is the single source both bars
|
|
* consume for the tail, so the two surfaces can no longer disagree on which
|
|
* utility links exist or in which order.
|
|
*
|
|
* Order is the footer/hub order: Settings → Notifications.
|
|
*
|
|
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
|
|
* surface (the mobile bar never exposes it), so it is not a shared tail
|
|
* destination and stays local to the sidebar.
|
|
*/
|
|
export interface NavUtilityDestination {
|
|
href: string;
|
|
/** i18n key under the `nav.*` namespace. */
|
|
tKey: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
|
|
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
|
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
|
];
|
|
|
|
/**
|
|
* The utility tail visible to this account. Both bars consume this for their
|
|
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
|
* surfaces share one definition of the utility links.
|
|
*/
|
|
export function visibleUtilityDestinations(
|
|
opts: { sharedRecord?: boolean } = {},
|
|
): NavUtilityDestination[] {
|
|
// v1.36.0 — the utility tail is account configuration by definition:
|
|
// Settings holds credentials, integrations, notification channels and grant
|
|
// management, and Notifications is the delegate's own device business. Every
|
|
// route behind both refuses under a switch; the tail simply stops being
|
|
// offered so nobody walks into the refusal.
|
|
if (opts.sharedRecord) return [];
|
|
return [...NAV_UTILITY_DESTINATIONS];
|
|
}
|
|
|
|
/**
|
|
* A partial map of `ModuleKey → enabled`. This is the `modules` field
|
|
* `GET /api/auth/me` returns (resolved server-side, with cycle + coach
|
|
* already delegated). A `false` value hides the gated entry; a missing
|
|
* key, an `undefined` map (auth not yet loaded), or `true` keeps it —
|
|
* fail-open, mirroring the gate's default-on contract so a stale /me
|
|
* payload never blanks the nav.
|
|
*/
|
|
export type ModuleVisibilityMap = Partial<Record<ModuleKey, boolean>>;
|
|
|
|
/**
|
|
* Whether a destination is visible under the given module map. Core
|
|
* destinations (no `requiresModule`) always pass; a gated entry passes
|
|
* unless its module resolves to an explicit `false`.
|
|
*
|
|
* `mounted` (default `true`) is the hydration gate the nav bars thread in.
|
|
* The resolved module map rides the client-only `/api/auth/me` query, which
|
|
* can still be unresolved on SSR and the first client paint. Reading the map
|
|
* then would fail OPEN and flicker a disabled module's entry in for one frame
|
|
* before the query lands and filters it out (the #418-class SSR/client
|
|
* divergence). So before mount a gated entry is treated as hidden —
|
|
* fail-CLOSED — making SSR and first paint identical (core-only); once
|
|
* mounted the real map applies. The default keeps the pure helper fail-open
|
|
* for non-component callers that pass a settled map.
|
|
*/
|
|
function isNavDestinationVisible(
|
|
d: NavDestination,
|
|
modules: ModuleVisibilityMap | undefined,
|
|
mounted = true,
|
|
sharedRecord = false,
|
|
): boolean {
|
|
if (sharedRecord && d.sharedRecord !== true) return false;
|
|
if (!d.requiresModule) return true;
|
|
if (!mounted) return false;
|
|
return modules?.[d.requiresModule] !== false;
|
|
}
|
|
|
|
/**
|
|
* v1.36.0 — is this path part of what account sharing covers?
|
|
*
|
|
* Answers from the one destination list rather than from a second literal, so
|
|
* the mobile bar's fixed slots, the sidebar and the shell's deep-link guard
|
|
* cannot end up disagreeing about which surfaces a delegate is offered.
|
|
*
|
|
* Matches a destination and everything beneath it (`/measurements/123` rides
|
|
* `/measurements`), with the dashboard matching exactly — otherwise `"/"`
|
|
* would swallow every path in the app. A path no destination claims answers
|
|
* `false`: a surface nobody has classified is not one to open inside somebody
|
|
* else's record.
|
|
*/
|
|
export function isDestinationInSharedRecord(pathname: string): boolean {
|
|
return NAV_DESTINATIONS.some((d) => {
|
|
if (!d.sharedRecord) return false;
|
|
if (d.href === "/") return pathname === "/";
|
|
return pathname === d.href || pathname.startsWith(`${d.href}/`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The ordered destinations visible to this account — drops a module-gated
|
|
* entry (mood, cycle, labs, coach, achievements …) when its module is
|
|
* disabled in the account's resolved module map. Both bars start from this.
|
|
* v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule:
|
|
* "cycle"` and reads the delegated `cycle` key from the same map.
|
|
*
|
|
* v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing
|
|
* covers, so a delegate is not offered a door the server will shut. Paint
|
|
* only; see the `sharedRecord` field's docblock.
|
|
*/
|
|
export function visibleNavDestinations(
|
|
modules: ModuleVisibilityMap | undefined,
|
|
mounted = true,
|
|
sharedRecord = false,
|
|
): NavDestination[] {
|
|
return NAV_DESTINATIONS.filter((d) =>
|
|
isNavDestinationVisible(d, modules, mounted, sharedRecord),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The mobile bottom-nav's three always-visible primary slots
|
|
* (Home · Meds · Insights). Every other feature destination falls into the
|
|
* "More" hub. Kept here (not in the bar) so the headline F-1 invariant —
|
|
* the hub is the shared feature list minus the primary slots, plus the
|
|
* shared utility tail — is a tested model function, not inline bar logic.
|
|
*/
|
|
export const BOTTOM_NAV_PRIMARY_SLOT_HREFS: ReadonlyArray<string> = [
|
|
"/",
|
|
"/medications",
|
|
"/insights",
|
|
];
|
|
|
|
export interface MobileMoreHubEntry {
|
|
href: string;
|
|
tKey: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
/**
|
|
* The ordered "More" hub for the mobile bottom-nav: every visible feature
|
|
* destination that isn't a primary slot, in model order. Feature destinations
|
|
* only — the account utilities (Settings, Notifications) are NOT appended here.
|
|
* They live solely in the user/avatar menu (mobile top-bar dropdown; desktop
|
|
* sidebar avatar menu + footer), so surfacing them in the More hub too would
|
|
* duplicate a utility across two menus reachable from the same screen. The
|
|
* desktop sidebar renders the same feature list inline, so the two bars cannot
|
|
* drift into two hand-curated feature lists.
|
|
*/
|
|
export function mobileMoreHubDestinations(opts: {
|
|
modules: ModuleVisibilityMap | undefined;
|
|
/** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */
|
|
mounted?: boolean;
|
|
/** v1.36.0 — acting on somebody else's record. Defaults to own. */
|
|
sharedRecord?: boolean;
|
|
}): MobileMoreHubEntry[] {
|
|
return visibleNavDestinations(
|
|
opts.modules,
|
|
opts.mounted ?? true,
|
|
opts.sharedRecord ?? false,
|
|
)
|
|
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
|
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
|
}
|
|
|
|
/**
|
|
* Whether `href` is the active nav destination for the current `pathname`,
|
|
* resolved against the full destination set so the most-specific entry
|
|
* wins. Without this, a plain `startsWith("/insights")` would light up
|
|
* Insights while the user is on its sibling `/insights/workouts`, which
|
|
* is its own nav home (Coach lives at the top-level `/coach`).
|
|
* The dashboard (`/`) only matches an exact path.
|
|
*/
|
|
export function isNavDestinationActive(
|
|
href: string,
|
|
pathname: string,
|
|
destinations: ReadonlyArray<NavDestination> = NAV_DESTINATIONS,
|
|
): boolean {
|
|
if (href === "/") return pathname === "/";
|
|
const matches = (candidate: string) =>
|
|
pathname === candidate || pathname.startsWith(`${candidate}/`);
|
|
if (!matches(href)) return false;
|
|
// A longer sibling that also matches is the more specific home — defer
|
|
// to it (e.g. on `/insights/workouts`, `/insights` must NOT read active).
|
|
const moreSpecific = destinations.some(
|
|
(d) => d.href !== href && d.href.startsWith(`${href}/`) && matches(d.href),
|
|
);
|
|
return !moreSpecific;
|
|
}
|