mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
The mental-wellbeing screener surface shipped built but unreachable: no nav entry, no module key, ungated. Add a `mentalHealth` module key (opt-in, default OFF — a depression/anxiety self-assessment is at least as sensitive as mood) to the registry, the module-hub icon map, and the exhaustive module maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated on the module so it appears only once the account opts in, and redirect the page home on a direct hit when the module is off. Item answers stay encrypted and off the AI Coach / MCP regardless of the toggle; this only controls whether the surface is visible and reachable.
332 lines
12 KiB
TypeScript
332 lines
12 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;
|
|
}
|
|
|
|
/**
|
|
* 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: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
|
|
{
|
|
href: "/measurements",
|
|
tKey: "nav.measurements",
|
|
icon: Activity,
|
|
tourId: "nav-measurements",
|
|
},
|
|
{
|
|
href: "/mood",
|
|
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
|
|
// `/insights/mental-wellbeing` check-in surface (its own nav home — the
|
|
// active-detection helper defers `/insights` to it on that path).
|
|
{
|
|
href: "/insights/mental-wellbeing",
|
|
tKey: "nav.mentalWellbeing",
|
|
icon: Brain,
|
|
tourId: "nav-mental-wellbeing",
|
|
requiresModule: "mentalHealth",
|
|
},
|
|
{
|
|
href: "/cycle",
|
|
tKey: "nav.cycle",
|
|
icon: Droplets,
|
|
tourId: "nav-cycle",
|
|
requiresModule: "cycle",
|
|
},
|
|
{
|
|
href: "/medications",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
tKey: "nav.documents",
|
|
icon: FileScan,
|
|
tourId: "nav-documents",
|
|
requiresModule: "inboundDocuments",
|
|
},
|
|
{
|
|
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",
|
|
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(): NavUtilityDestination[] {
|
|
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`.
|
|
*/
|
|
function isNavDestinationVisible(
|
|
d: NavDestination,
|
|
modules: ModuleVisibilityMap | undefined,
|
|
): boolean {
|
|
if (!d.requiresModule) return true;
|
|
return modules?.[d.requiresModule] !== false;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function visibleNavDestinations(
|
|
modules: ModuleVisibilityMap | undefined,
|
|
): NavDestination[] {
|
|
return NAV_DESTINATIONS.filter((d) => isNavDestinationVisible(d, modules));
|
|
}
|
|
|
|
/**
|
|
* 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, followed by the
|
|
* shared utility tail (Settings, Notifications). The desktop sidebar renders
|
|
* the same feature list inline and consumes the same utility tail in its
|
|
* footer + avatar menu, so the two bars cannot drift into two hand-curated
|
|
* lists.
|
|
*/
|
|
export function mobileMoreHubDestinations(opts: {
|
|
modules: ModuleVisibilityMap | undefined;
|
|
}): MobileMoreHubEntry[] {
|
|
const features = visibleNavDestinations(opts.modules)
|
|
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
|
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
|
const tail = visibleUtilityDestinations().map((d) => ({
|
|
href: d.href,
|
|
tKey: d.tKey,
|
|
icon: d.icon,
|
|
}));
|
|
return [...features, ...tail];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|