mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #437 from MBombeck/release/v1.27.27
Release v1.27.27 — reorderable hero rings
This commit is contained in:
17 files changed
+652
-135
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.27] — 2026-07-08 — Reorderable hero rings
|
||||
|
||||
### Changed
|
||||
|
||||
- The dashboard hero rings now lead with the health score by default, and the order is yours to set: drag (or use the up/down arrows) to reorder the rings in Settings → Appearance, and the order persists across the mobile carousel and the desktop row. The health score is pinned as the anchor ring — it can be repositioned but not removed.
|
||||
|
||||
## [1.27.26] — 2026-07-08 — Hero and wellbeing card polish
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.27.26
|
||||
version: 1.27.27
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@
|
||||
"heroToggleLabel": "Tagesüberblick",
|
||||
"heroToggleDescription": "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
|
||||
"scoreRingsTitle": "Score-Ringe",
|
||||
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue.",
|
||||
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@
|
||||
"heroToggleLabel": "Daily overview",
|
||||
"heroToggleDescription": "Shows your greeting, daily focus and score rings at the top of the dashboard.",
|
||||
"scoreRingsTitle": "Score rings",
|
||||
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence.",
|
||||
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@
|
||||
"heroToggleLabel": "Resumen del día",
|
||||
"heroToggleDescription": "Muestra el saludo, el foco del día y los anillos de puntuación en la parte superior del panel.",
|
||||
"scoreRingsTitle": "Anillos de puntuación",
|
||||
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento.",
|
||||
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
|
||||
"layoutReset": "Restablecer valores por defecto",
|
||||
"layoutSaveSuccess": "Diseño del panel guardado",
|
||||
"layoutSaveError": "No se ha podido guardar el diseño",
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@
|
||||
"heroToggleLabel": "Aperçu du jour",
|
||||
"heroToggleDescription": "Affiche le message d’accueil, le focus du jour et les anneaux de score tout en haut du tableau de bord.",
|
||||
"scoreRingsTitle": "Anneaux de score",
|
||||
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance.",
|
||||
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
|
||||
"layoutReset": "Rétablir les valeurs par défaut",
|
||||
"layoutSaveSuccess": "Mise en page enregistrée",
|
||||
"layoutSaveError": "Impossible d’enregistrer la mise en page",
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@
|
||||
"heroToggleLabel": "Panoramica del giorno",
|
||||
"heroToggleDescription": "Mostra saluto, focus del giorno e anelli dei punteggi in cima alla dashboard.",
|
||||
"scoreRingsTitle": "Anelli dei punteggi",
|
||||
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza.",
|
||||
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
|
||||
"layoutReset": "Ripristina i valori predefiniti",
|
||||
"layoutSaveSuccess": "Layout della dashboard salvato",
|
||||
"layoutSaveError": "Impossibile salvare il layout",
|
||||
|
||||
+1
-1
@@ -402,7 +402,7 @@
|
||||
"heroToggleLabel": "Przegląd dnia",
|
||||
"heroToggleDescription": "Pokazuje powitanie, fokus dnia i pierścienie wyników na górze pulpitu.",
|
||||
"scoreRingsTitle": "Pierścienie wyników",
|
||||
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń.",
|
||||
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
|
||||
"layoutReset": "Przywróć wartości domyślne",
|
||||
"layoutSaveSuccess": "Układ panelu zapisany",
|
||||
"layoutSaveError": "Nie udało się zapisać układu",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.27.26",
|
||||
"version": "1.27.27",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.27.26";
|
||||
/* @sw-version-fallback */ "v1.27.27";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -24,9 +24,12 @@ import {
|
||||
CHART_OVERLAY_KEYS,
|
||||
SCORE_RING_IDS,
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HERO_RING_IDS,
|
||||
MAX_HERO_RING_ORDER,
|
||||
type ChartOverlayPrefsMap,
|
||||
type DashboardLayout,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -131,6 +134,15 @@ const layoutSchema = z.object({
|
||||
.array(z.enum(SCORE_RING_IDS))
|
||||
.max(MAX_SELECTED_SCORE_RINGS)
|
||||
.optional(),
|
||||
// v1.27.27 — hero ring display order (health-score ring + selected score
|
||||
// rings). Optional with the same preserve-when-absent contract; the
|
||||
// resolver reconciles it against the selected set on read/serialize, so a
|
||||
// stale or over-long array is clamped rather than rejected. `.max()`
|
||||
// bounds the wire length (health-score + up to three score rings).
|
||||
heroRingOrder: z
|
||||
.array(z.enum(HERO_RING_IDS))
|
||||
.max(MAX_HERO_RING_ORDER)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
|
||||
@@ -281,10 +293,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
|
||||
let mergedScoreRings: ScoreRingId[] | undefined =
|
||||
parsed.data.selectedScoreRings;
|
||||
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedHeroVisible === undefined ||
|
||||
mergedScoreRings === undefined
|
||||
mergedScoreRings === undefined ||
|
||||
mergedHeroRingOrder === undefined
|
||||
) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
@@ -302,12 +316,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (mergedScoreRings === undefined) {
|
||||
mergedScoreRings = existingLayout.selectedScoreRings;
|
||||
}
|
||||
if (mergedHeroRingOrder === undefined) {
|
||||
mergedHeroRingOrder = existingLayout.heroRingOrder;
|
||||
}
|
||||
}
|
||||
const normalized = serializeDashboardLayout({
|
||||
...parsed.data,
|
||||
chartOverlayPrefs: mergedChartOverlayPrefs,
|
||||
heroVisible: mergedHeroVisible,
|
||||
selectedScoreRings: mergedScoreRings,
|
||||
heroRingOrder: mergedHeroRingOrder,
|
||||
} as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -386,10 +386,13 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
|
||||
);
|
||||
const row = html.match(/data-slot="dashboard-hero-rings"/g) ?? [];
|
||||
expect(row).toHaveLength(1);
|
||||
// Selection order preserved, health ring trailing (3 rings total).
|
||||
// v1.27.27 — default order leads with the health-score ring, then the
|
||||
// selected rings in selection order (3 rings total).
|
||||
const healthIdx = html.indexOf('href="/insights"');
|
||||
const readinessIdx = html.indexOf('data-ring="READINESS"');
|
||||
const complianceIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
|
||||
expect(readinessIdx).toBeGreaterThan(-1);
|
||||
expect(healthIdx).toBeGreaterThan(-1);
|
||||
expect(readinessIdx).toBeGreaterThan(healthIdx);
|
||||
expect(complianceIdx).toBeGreaterThan(readinessIdx);
|
||||
const rings = html.match(/data-slot="score-ring"/g) ?? [];
|
||||
expect(rings).toHaveLength(3);
|
||||
@@ -562,6 +565,90 @@ describe("<DashboardHero> — ring links (v1.27.24)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — ring order (v1.27.27)", () => {
|
||||
const threeRings = {
|
||||
healthScore: { score: 82, band: "green" as const, delta: 2 },
|
||||
scoreRings: [
|
||||
{ id: "READINESS" as const, score: 71, band: "green" as const },
|
||||
{ id: "SLEEP_SCORE" as const, score: 66, band: "yellow" as const },
|
||||
{
|
||||
id: "MED_COMPLIANCE" as const,
|
||||
score: 33,
|
||||
band: "yellow" as const,
|
||||
doses: { taken: 1, scheduled: 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("default (no persisted order) leads with the health-score ring", () => {
|
||||
const html = render(baseSnapshot(threeRings));
|
||||
// The health-score slide (its /insights link) precedes every score ring.
|
||||
const healthIdx = html.indexOf('href="/insights"');
|
||||
const readinessIdx = html.indexOf('data-ring="READINESS"');
|
||||
const sleepIdx = html.indexOf('data-ring="SLEEP_SCORE"');
|
||||
const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
|
||||
expect(healthIdx).toBeGreaterThan(-1);
|
||||
expect(readinessIdx).toBeGreaterThan(healthIdx);
|
||||
expect(sleepIdx).toBeGreaterThan(readinessIdx);
|
||||
expect(medIdx).toBeGreaterThan(sleepIdx);
|
||||
// First carousel dot is current (slide 0 = health score).
|
||||
const firstDot = html.match(
|
||||
/<button[^>]*data-slot="dashboard-hero-ring-dot"[^>]*>/,
|
||||
);
|
||||
expect(firstDot![0]).toContain('aria-current="true"');
|
||||
});
|
||||
|
||||
it("honours a persisted custom order (health score moved off the leading edge)", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
...threeRings,
|
||||
layout: {
|
||||
version: 1,
|
||||
widgets: [],
|
||||
heroRingOrder: ["READINESS", "MED_COMPLIANCE", "HEALTH_SCORE"],
|
||||
selectedScoreRings: ["READINESS", "SLEEP_SCORE", "MED_COMPLIANCE"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const readinessIdx = html.indexOf('data-ring="READINESS"');
|
||||
const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
|
||||
const healthIdx = html.indexOf('href="/insights"');
|
||||
// READINESS first, MED_COMPLIANCE second, health score last.
|
||||
expect(readinessIdx).toBeGreaterThan(-1);
|
||||
expect(medIdx).toBeGreaterThan(readinessIdx);
|
||||
expect(healthIdx).toBeGreaterThan(medIdx);
|
||||
// A rendered ring the order omits (SLEEP_SCORE) is still drawn (appended).
|
||||
expect(html).toContain('data-ring="SLEEP_SCORE"');
|
||||
// Every slide still renders exactly once.
|
||||
expect(html.match(/data-carousel-slide=""/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("drops an ordered id whose ring isn't rendered (no data / disabled)", () => {
|
||||
// The persisted order names RECOVERY_SCORE, but the snapshot resolved
|
||||
// no such ring — it must not leave a gap or a phantom slide.
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
healthScore: { score: 60, band: "yellow", delta: 0 },
|
||||
scoreRings: [{ id: "READINESS", score: 71, band: "green" }],
|
||||
layout: {
|
||||
version: 1,
|
||||
widgets: [],
|
||||
heroRingOrder: ["RECOVERY_SCORE", "HEALTH_SCORE", "READINESS"],
|
||||
selectedScoreRings: ["READINESS"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Only the two rendered rings appear (health + readiness), in the
|
||||
// surviving order: health score then readiness.
|
||||
expect(html.match(/data-carousel-slide=""/g)).toHaveLength(2);
|
||||
expect(html).not.toContain('data-ring="RECOVERY_SCORE"');
|
||||
const healthIdx = html.indexOf('href="/insights"');
|
||||
const readinessIdx = html.indexOf('data-ring="READINESS"');
|
||||
expect(healthIdx).toBeGreaterThan(-1);
|
||||
expect(readinessIdx).toBeGreaterThan(healthIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — greeting", () => {
|
||||
it("personalises from the snapshot username with the server greeting hour", () => {
|
||||
const html = render(
|
||||
|
||||
@@ -63,7 +63,12 @@ import {
|
||||
type DashboardVerdictVariant,
|
||||
} from "@/lib/dashboard/verdict";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
import type { ScoreRingId } from "@/lib/dashboard-layout";
|
||||
import {
|
||||
HEALTH_SCORE_RING_ID,
|
||||
resolveHeroRingOrder,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
@@ -269,78 +274,97 @@ export function DashboardHero({
|
||||
// contract), so an older cached snapshot renders the health ring alone.
|
||||
const scoreRings = snapshot.scoreRings ?? [];
|
||||
|
||||
// Unified slide set — selected rings in selection order, then the
|
||||
// health-score ring on the trailing edge. Feeds the carousel on mobile
|
||||
// and the inline row on desktop from a SINGLE render of each ring node.
|
||||
const ringSlides: HeroRingSlide[] = [
|
||||
...scoreRings.map((ring) => ({
|
||||
key: ring.id,
|
||||
ringId: ring.id,
|
||||
// Each ring links to its natural existing detail surface; a real
|
||||
// <Link> (in the carousel) keeps tap-vs-drag native on mobile.
|
||||
href: RING_HREF[ring.id],
|
||||
linkLabel: t("dashboard.hero.ringLink", {
|
||||
metric: t(RING_LABEL_KEY[ring.id]),
|
||||
}),
|
||||
node:
|
||||
// Dose ring — today's tally ("1/3") over the constant med-family
|
||||
// arc (`hue="meds"` = --primary, the tone every medication surface
|
||||
// in Insights paints); the arc still sweeps on the 0..100 progress
|
||||
// `score`. `band="green"` stays as the stable data-band anchor — a
|
||||
// pending morning dose is not an alert state — while the hue owns
|
||||
// the paint. Falls through to the score render when a cached
|
||||
// pre-doses snapshot carries no tally.
|
||||
ring.id === "MED_COMPLIANCE" && ring.doses ? (
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band="green"
|
||||
hue="meds"
|
||||
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
|
||||
ariaLabel={t("dashboard.hero.ringDosesAria", {
|
||||
taken: ring.doses.taken,
|
||||
scheduled: ring.doses.scheduled,
|
||||
})}
|
||||
size="sm"
|
||||
flat
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
) : (
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band={ring.band}
|
||||
size="sm"
|
||||
flat
|
||||
hue={RING_HUE_BY_ID[ring.id]}
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
{
|
||||
key: "health-score",
|
||||
// The health-score ring opens the Insights overview — the same
|
||||
// destination the health-score card links (why the hero verdict
|
||||
// dropped its redundant "open Insights" button).
|
||||
href: "/insights",
|
||||
linkLabel: t("dashboard.hero.ringLink", {
|
||||
metric: t("dashboard.hero.scoreLabel"),
|
||||
}),
|
||||
node: (
|
||||
<ScoreRing
|
||||
score={snapshot.healthScore?.score ?? null}
|
||||
band={snapshot.healthScore?.band}
|
||||
size="sm"
|
||||
flat
|
||||
label={
|
||||
snapshot.healthScore
|
||||
? t("dashboard.hero.scoreLabel")
|
||||
: hasScoreInputs
|
||||
? t("dashboard.hero.scoreComputing")
|
||||
: t("dashboard.hero.scoreProvisional")
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
// One slide per ring, keyed by its `HeroRingId`. Each score ring links
|
||||
// to its natural existing detail surface; a real <Link> (in the
|
||||
// carousel) keeps tap-vs-drag native on mobile.
|
||||
const scoreRingSlides = new Map<HeroRingId, HeroRingSlide>(
|
||||
scoreRings.map((ring) => [
|
||||
ring.id,
|
||||
{
|
||||
key: ring.id,
|
||||
ringId: ring.id,
|
||||
href: RING_HREF[ring.id],
|
||||
linkLabel: t("dashboard.hero.ringLink", {
|
||||
metric: t(RING_LABEL_KEY[ring.id]),
|
||||
}),
|
||||
node:
|
||||
// Dose ring — today's tally ("1/3") over the constant med-family
|
||||
// arc (`hue="meds"` = --primary, the tone every medication surface
|
||||
// in Insights paints); the arc still sweeps on the 0..100 progress
|
||||
// `score`. `band="green"` stays as the stable data-band anchor — a
|
||||
// pending morning dose is not an alert state — while the hue owns
|
||||
// the paint. Falls through to the score render when a cached
|
||||
// pre-doses snapshot carries no tally.
|
||||
ring.id === "MED_COMPLIANCE" && ring.doses ? (
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band="green"
|
||||
hue="meds"
|
||||
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
|
||||
ariaLabel={t("dashboard.hero.ringDosesAria", {
|
||||
taken: ring.doses.taken,
|
||||
scheduled: ring.doses.scheduled,
|
||||
})}
|
||||
size="sm"
|
||||
flat
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
) : (
|
||||
<ScoreRing
|
||||
score={ring.score}
|
||||
band={ring.band}
|
||||
size="sm"
|
||||
flat
|
||||
hue={RING_HUE_BY_ID[ring.id]}
|
||||
label={t(RING_LABEL_KEY[ring.id])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const healthScoreSlide: HeroRingSlide = {
|
||||
key: "health-score",
|
||||
// The health-score ring opens the Insights overview — the same
|
||||
// destination the health-score card links (why the hero verdict
|
||||
// dropped its redundant "open Insights" button).
|
||||
href: "/insights",
|
||||
linkLabel: t("dashboard.hero.ringLink", {
|
||||
metric: t("dashboard.hero.scoreLabel"),
|
||||
}),
|
||||
node: (
|
||||
<ScoreRing
|
||||
score={snapshot.healthScore?.score ?? null}
|
||||
band={snapshot.healthScore?.band}
|
||||
size="sm"
|
||||
flat
|
||||
label={
|
||||
snapshot.healthScore
|
||||
? t("dashboard.hero.scoreLabel")
|
||||
: hasScoreInputs
|
||||
? t("dashboard.hero.scoreComputing")
|
||||
: t("dashboard.hero.scoreProvisional")
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// v1.27.27 — the hero ring sequence honours the persisted order (health
|
||||
// score + selected rings). The reconciler defaults to health-score-first
|
||||
// when the user hasn't customised, drops any ordered id whose ring isn't
|
||||
// rendered (no data / disabled module), and appends a rendered ring the
|
||||
// order omits — so the order can never hide or duplicate a slide. Feeds
|
||||
// the carousel on mobile and the inline row on desktop from a SINGLE
|
||||
// render of each ring node.
|
||||
const heroRingOrder = resolveHeroRingOrder(
|
||||
snapshot.layout?.heroRingOrder,
|
||||
scoreRings.map((ring) => ring.id),
|
||||
);
|
||||
const ringSlides: HeroRingSlide[] = heroRingOrder
|
||||
.map((id) =>
|
||||
id === HEALTH_SCORE_RING_ID ? healthScoreSlide : scoreRingSlides.get(id),
|
||||
)
|
||||
.filter((slide): slide is HeroRingSlide => slide !== undefined);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -455,8 +455,10 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
expect(html).toContain('data-slot="score-rings-picker"');
|
||||
// v1.27.27 — one switch per score ring (4) plus the always-present
|
||||
// health-score anchor row = 5.
|
||||
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
|
||||
expect(switches).toHaveLength(4);
|
||||
expect(switches).toHaveLength(5);
|
||||
// The default selection (MED_COMPLIANCE) reads checked.
|
||||
const compliance = html.match(
|
||||
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="MED_COMPLIANCE"[^>]*>/,
|
||||
@@ -465,23 +467,25 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
|
||||
expect(compliance![0]).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("hides rings whose owning module is disabled", () => {
|
||||
it("hides rings whose owning module is disabled (anchor stays)", () => {
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
authState.modules = { recovery: false, sleep: false };
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
|
||||
// READINESS + RECOVERY_SCORE (recovery) and SLEEP_SCORE (sleep) are
|
||||
// gone; only the adherence ring remains offered.
|
||||
expect(switches).toHaveLength(1);
|
||||
// gone; the adherence ring plus the health-score anchor remain = 2.
|
||||
expect(switches).toHaveLength(2);
|
||||
expect(html).toContain('data-ring="MED_COMPLIANCE"');
|
||||
expect(html).toContain('data-ring="HEALTH_SCORE"');
|
||||
});
|
||||
|
||||
it("hides the derived rings when the insights module is off", () => {
|
||||
it("hides the derived rings when the insights module is off (anchor stays)", () => {
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
authState.modules = { insights: false };
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const switches = html.match(/data-slot="score-ring-switch"/g) ?? [];
|
||||
expect(switches).toHaveLength(1);
|
||||
// Adherence ring + health-score anchor.
|
||||
expect(switches).toHaveLength(2);
|
||||
expect(html).toContain('data-ring="MED_COMPLIANCE"');
|
||||
});
|
||||
|
||||
@@ -507,3 +511,89 @@ describe("<DashboardLayoutSection> — hero score-ring picker (v1.27.7)", () =>
|
||||
expect(readiness![0]).not.toContain('disabled=""');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.27.27 — the health-score ring is a first-class, always-present,
|
||||
* reorderable member of the hero ring row. The picker renders it as a
|
||||
* pinned row (no on/off toggle — a disabled, checked switch) that leads by
|
||||
* default and reorders with the same drag-handle + arrow-button pair as the
|
||||
* score rings and the widget list.
|
||||
*/
|
||||
describe("<DashboardLayoutSection> — health-score anchor + reorder (v1.27.27)", () => {
|
||||
it("renders the health-score anchor as a reorderable row, leading by default", () => {
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// A sortable ring row exists for the health-score anchor.
|
||||
expect(html).toContain('data-slot="score-ring-row"');
|
||||
const anchorRow = html.match(
|
||||
/<div[^>]*data-slot="score-ring-row"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
|
||||
);
|
||||
expect(anchorRow).not.toBeNull();
|
||||
// Default order leads with the anchor: its row precedes the adherence
|
||||
// ring's row.
|
||||
const anchorIdx = html.indexOf('data-ring="HEALTH_SCORE"');
|
||||
const medIdx = html.indexOf(
|
||||
'data-slot="score-ring-row" data-ring="MED_COMPLIANCE"',
|
||||
);
|
||||
expect(anchorIdx).toBeGreaterThan(-1);
|
||||
// The anchor row appears before any selected score-ring row.
|
||||
expect(anchorIdx).toBeLessThan(html.indexOf('data-ring="MED_COMPLIANCE"'));
|
||||
expect(medIdx === -1 || anchorIdx < medIdx).toBe(true);
|
||||
});
|
||||
|
||||
it("the anchor switch is checked and disabled (locked-on, no toggle-off)", () => {
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const anchorSwitch = html.match(
|
||||
/<button[^>]*data-slot="score-ring-switch"[^>]*data-ring="HEALTH_SCORE"[^>]*>/,
|
||||
);
|
||||
expect(anchorSwitch).not.toBeNull();
|
||||
expect(anchorSwitch![0]).toContain('data-state="checked"');
|
||||
expect(anchorSwitch![0]).toContain('disabled=""');
|
||||
});
|
||||
|
||||
it("the anchor carries a drag handle + move arrows (reorderable)", () => {
|
||||
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// The health-score row is a full sortable row: its drag handle exists.
|
||||
const handles = html.match(/data-slot="score-ring-drag-handle"/g) ?? [];
|
||||
// One handle per hero-order row = anchor + default MED_COMPLIANCE = 2.
|
||||
expect(handles.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("honours a persisted order that moves the anchor off the lead", () => {
|
||||
queryState.layout = {
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
heroVisible: true,
|
||||
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
|
||||
heroRingOrder: ["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
};
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const readinessRow = html.indexOf(
|
||||
'data-slot="score-ring-row" data-ring="READINESS"',
|
||||
);
|
||||
const anchorRow = html.indexOf(
|
||||
'data-slot="score-ring-row" data-ring="HEALTH_SCORE"',
|
||||
);
|
||||
const medRow = html.indexOf(
|
||||
'data-slot="score-ring-row" data-ring="MED_COMPLIANCE"',
|
||||
);
|
||||
expect(readinessRow).toBeGreaterThan(-1);
|
||||
expect(anchorRow).toBeGreaterThan(readinessRow);
|
||||
expect(medRow).toBeGreaterThan(anchorRow);
|
||||
});
|
||||
|
||||
it("the ring mutation PUTs both selectedScoreRings and heroRingOrder (source pin)", () => {
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/settings/dashboard-layout-section.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain("selectedScoreRings: next.selectedScoreRings");
|
||||
expect(src).toContain("heroRingOrder: next.heroRingOrder");
|
||||
// The order is the single source of truth: selection is derived from it.
|
||||
expect(src).toMatch(/id !== HEALTH_SCORE_RING_ID/);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type DashboardWidgetId,
|
||||
type ComparisonBaseline,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
COMPARISON_BASELINES,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
DASHBOARD_WIDGET_IDS,
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
SCORE_RING_IDS,
|
||||
SCORE_RING_MODULE,
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HEALTH_SCORE_RING_ID,
|
||||
resolveHeroRingOrder,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import {
|
||||
Select,
|
||||
@@ -169,6 +172,16 @@ const SCORE_RING_LABEL_KEYS: Record<ScoreRingId, string> = {
|
||||
MED_COMPLIANCE: "dashboard.hero.ringDoses",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.27.27 — label key for any hero ring id, including the always-present
|
||||
* health-score anchor ring. Reuses the hero's own "Health Score" label so
|
||||
* the picker row and the hero ring name match verbatim.
|
||||
*/
|
||||
const HERO_RING_LABEL_KEYS: Record<HeroRingId, string> = {
|
||||
[HEALTH_SCORE_RING_ID]: "dashboard.hero.scoreLabel",
|
||||
...SCORE_RING_LABEL_KEYS,
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -312,11 +325,15 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
* settle so the hero reflects the choice on the next visit.
|
||||
*/
|
||||
const ringMutation = useMutation({
|
||||
mutationFn: async (next: ScoreRingId[]) => {
|
||||
mutationFn: async (next: {
|
||||
selectedScoreRings: ScoreRingId[];
|
||||
heroRingOrder: HeroRingId[];
|
||||
}) => {
|
||||
if (!remote) throw new Error("layout not loaded");
|
||||
return apiPut<DashboardLayout>("/api/dashboard/widgets", {
|
||||
...remote,
|
||||
selectedScoreRings: next,
|
||||
selectedScoreRings: next.selectedScoreRings,
|
||||
heroRingOrder: next.heroRingOrder,
|
||||
});
|
||||
},
|
||||
onMutate: async (next) => {
|
||||
@@ -329,7 +346,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
if (previous) {
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), {
|
||||
...previous,
|
||||
selectedScoreRings: next,
|
||||
selectedScoreRings: next.selectedScoreRings,
|
||||
heroRingOrder: next.heroRingOrder,
|
||||
});
|
||||
}
|
||||
return { previous };
|
||||
@@ -351,60 +369,79 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
/** Apply a new ring selection/order instantly (see `ringMutation`). */
|
||||
function applyScoreRings(next: ScoreRingId[]) {
|
||||
/**
|
||||
* v1.27.27 — apply a new hero ring ORDER instantly (see `ringMutation`).
|
||||
* The order is the single source of truth: `selectedScoreRings` is
|
||||
* derived from it (order minus the always-present health-score ring) so
|
||||
* the server resolver and the hero read one consistent sequence.
|
||||
*/
|
||||
function applyHeroRingOrder(next: HeroRingId[]) {
|
||||
if (!remote) return;
|
||||
const selectedScoreRings = next.filter(
|
||||
(id): id is ScoreRingId => id !== HEALTH_SCORE_RING_ID,
|
||||
);
|
||||
// Keep an open draft's ring slice in sync so a later Save of the
|
||||
// OTHER layout edits can't revert the instantly-applied choice.
|
||||
if (draft) setDraft({ ...draft, selectedScoreRings: next });
|
||||
ringMutation.mutate(next);
|
||||
if (draft) setDraft({ ...draft, selectedScoreRings, heroRingOrder: next });
|
||||
ringMutation.mutate({ selectedScoreRings, heroRingOrder: next });
|
||||
}
|
||||
|
||||
/** The reconciled hero ring order (health-score anchor + selected rings). */
|
||||
function currentHeroOrder(l: DashboardLayout): HeroRingId[] {
|
||||
return resolveHeroRingOrder(l.heroRingOrder, l.selectedScoreRings ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.27.7 — toggle one hero score ring. Selection order is preserved
|
||||
* (a newly-enabled ring appends), the count is capped at
|
||||
* `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the
|
||||
* cap). v1.27.8 — applies instantly via `applyScoreRings`.
|
||||
* v1.27.7 — toggle one hero score ring on/off. Enabling appends to the
|
||||
* order (capped at `MAX_SELECTED_SCORE_RINGS`; the remaining switches
|
||||
* disable at the cap); disabling drops it from the order. The
|
||||
* health-score ring has no toggle — it is the always-present anchor.
|
||||
* v1.27.8 / v1.27.27 — applies instantly via `applyHeroRingOrder`.
|
||||
*/
|
||||
function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) {
|
||||
if (!layout) return;
|
||||
const current = layout.selectedScoreRings ?? [];
|
||||
const next = enabled
|
||||
? current.includes(ringId)
|
||||
? current
|
||||
: [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS)
|
||||
: current.filter((id) => id !== ringId);
|
||||
applyScoreRings(next);
|
||||
const order = currentHeroOrder(layout);
|
||||
if (enabled) {
|
||||
if (order.includes(ringId)) return;
|
||||
const selectedCount = order.filter(
|
||||
(id) => id !== HEALTH_SCORE_RING_ID,
|
||||
).length;
|
||||
if (selectedCount >= MAX_SELECTED_SCORE_RINGS) return;
|
||||
applyHeroRingOrder([...order, ringId]);
|
||||
} else {
|
||||
applyHeroRingOrder(order.filter((id) => id !== ringId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.27.8 — reorder the selected rings (the array order IS the render
|
||||
* order on the hero; the server preserves it). Same interaction pair
|
||||
* as the widget rows below: drag handle + arrow buttons.
|
||||
* v1.27.8 / v1.27.27 — reorder the hero rings (the array order IS the
|
||||
* render order on the hero; the health-score anchor moves like any other
|
||||
* ring). Same interaction pair as the widget rows below: drag handle +
|
||||
* arrow buttons.
|
||||
*/
|
||||
function moveScoreRing(ringId: ScoreRingId, delta: -1 | 1) {
|
||||
function moveHeroRing(ringId: HeroRingId, delta: -1 | 1) {
|
||||
if (!layout) return;
|
||||
const current = layout.selectedScoreRings ?? [];
|
||||
const idx = current.indexOf(ringId);
|
||||
const order = currentHeroOrder(layout);
|
||||
const idx = order.indexOf(ringId);
|
||||
const target = idx + delta;
|
||||
if (idx < 0 || target < 0 || target >= current.length) return;
|
||||
const next = [...current];
|
||||
if (idx < 0 || target < 0 || target >= order.length) return;
|
||||
const next = [...order];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
applyScoreRings(next);
|
||||
applyHeroRingOrder(next);
|
||||
}
|
||||
|
||||
function handleRingDragEnd(event: DragEndEvent) {
|
||||
if (!layout) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const current = layout.selectedScoreRings ?? [];
|
||||
const from = current.indexOf(active.id as ScoreRingId);
|
||||
const to = current.indexOf(over.id as ScoreRingId);
|
||||
const order = currentHeroOrder(layout);
|
||||
const from = order.indexOf(active.id as HeroRingId);
|
||||
const to = order.indexOf(over.id as HeroRingId);
|
||||
if (from < 0 || to < 0) return;
|
||||
const next = [...current];
|
||||
const next = [...order];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
applyScoreRings(next);
|
||||
applyHeroRingOrder(next);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,8 +633,17 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const selected = (layout.selectedScoreRings ?? []).filter(
|
||||
(ringId) => available.includes(ringId),
|
||||
);
|
||||
// v1.27.27 — the reorderable list is the health-score anchor
|
||||
// ring plus the selected score rings, in the persisted order
|
||||
// (health-score first by default). The health-score row has no
|
||||
// on/off switch — it is always present — but drags/moves like
|
||||
// any other ring.
|
||||
const heroOrder = resolveHeroRingOrder(
|
||||
layout.heroRingOrder,
|
||||
selected,
|
||||
);
|
||||
const unselected = available.filter(
|
||||
(ringId) => !selected.includes(ringId),
|
||||
(ringId) => !heroOrder.includes(ringId),
|
||||
);
|
||||
const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS;
|
||||
const busy = ringMutation.isPending;
|
||||
@@ -609,23 +655,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
onDragEnd={handleRingDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selected}
|
||||
items={heroOrder}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{selected.map((ringId, index) => (
|
||||
{heroOrder.map((ringId, index) => (
|
||||
<SortableRingRow
|
||||
key={ringId}
|
||||
ringId={ringId}
|
||||
label={t(SCORE_RING_LABEL_KEYS[ringId])}
|
||||
label={t(HERO_RING_LABEL_KEYS[ringId])}
|
||||
index={index}
|
||||
total={selected.length}
|
||||
total={heroOrder.length}
|
||||
dragHintId={dragHintId}
|
||||
disabled={busy}
|
||||
// The health-score ring is the anchor — no toggle.
|
||||
pinned={ringId === HEALTH_SCORE_RING_ID}
|
||||
moveUpLabel={t("dashboard.moveUp")}
|
||||
moveDownLabel={t("dashboard.moveDown")}
|
||||
dragHandleLabel={t("dashboard.dragHandle")}
|
||||
onToggle={toggleScoreRing}
|
||||
onMove={moveScoreRing}
|
||||
onMove={moveHeroRing}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -979,23 +1027,30 @@ function SortableRingRow({
|
||||
total,
|
||||
dragHintId,
|
||||
disabled,
|
||||
pinned = false,
|
||||
moveUpLabel,
|
||||
moveDownLabel,
|
||||
dragHandleLabel,
|
||||
onToggle,
|
||||
onMove,
|
||||
}: {
|
||||
ringId: ScoreRingId;
|
||||
ringId: HeroRingId;
|
||||
label: string;
|
||||
index: number;
|
||||
total: number;
|
||||
dragHintId: string;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* v1.27.27 — the health-score anchor ring: always present, no on/off
|
||||
* switch (a disabled, checked switch communicates the locked-on state),
|
||||
* but reorderable like any other ring.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
moveUpLabel: string;
|
||||
moveDownLabel: string;
|
||||
dragHandleLabel: string;
|
||||
onToggle: (id: ScoreRingId, value: boolean) => void;
|
||||
onMove: (id: ScoreRingId, delta: -1 | 1) => void;
|
||||
onMove: (id: HeroRingId, delta: -1 | 1) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -1040,8 +1095,12 @@ function SortableRingRow({
|
||||
</span>
|
||||
<Switch
|
||||
checked
|
||||
onCheckedChange={(v) => onToggle(ringId, v)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={
|
||||
pinned ? undefined : (v) => onToggle(ringId as ScoreRingId, v)
|
||||
}
|
||||
// The health-score anchor cannot be switched off — a disabled,
|
||||
// checked switch reads as "always on, locked".
|
||||
disabled={disabled || pinned}
|
||||
aria-label={label}
|
||||
data-slot="score-ring-switch"
|
||||
data-ring={ringId}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
DASHBOARD_IOS_ONLY_WIDGET_IDS,
|
||||
DASHBOARD_WIDGET_CATALOGUE_IDS,
|
||||
IOS_PIN_ONLY_WIDGET_IDS,
|
||||
DEFAULT_HERO_RING_ORDER,
|
||||
resolveHeroRingOrder,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
@@ -746,3 +748,119 @@ describe("resolveDashboardLayout() — selectedScoreRings", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.27.27 — hero ring ORDER (`heroRingOrder`). The health-score ring is a
|
||||
* first-class, always-present, reorderable member of the hero row that
|
||||
* leads by default. The order rides the layout blob (no migration) and the
|
||||
* resolver reconciles it against the selected set on every read/serialize.
|
||||
*/
|
||||
describe("resolveHeroRingOrder()", () => {
|
||||
it("defaults a missing / malformed order to health-score-first", () => {
|
||||
for (const bad of [undefined, null, "READINESS", 3, { a: 1 }]) {
|
||||
expect(
|
||||
resolveHeroRingOrder(bad, ["READINESS", "MED_COMPLIANCE"]),
|
||||
).toEqual(["HEALTH_SCORE", "READINESS", "MED_COMPLIANCE"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("honours a stored order that moves the health-score ring off the lead", () => {
|
||||
expect(
|
||||
resolveHeroRingOrder(
|
||||
["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
["READINESS", "MED_COMPLIANCE"],
|
||||
),
|
||||
).toEqual(["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"]);
|
||||
});
|
||||
|
||||
it("drops an ordered id whose ring is no longer selected", () => {
|
||||
expect(
|
||||
resolveHeroRingOrder(
|
||||
["READINESS", "HEALTH_SCORE", "SLEEP_SCORE"],
|
||||
["READINESS"], // SLEEP_SCORE deselected
|
||||
),
|
||||
).toEqual(["READINESS", "HEALTH_SCORE"]);
|
||||
});
|
||||
|
||||
it("appends a newly-selected ring the stored order didn't place", () => {
|
||||
expect(
|
||||
resolveHeroRingOrder(
|
||||
["HEALTH_SCORE", "READINESS"],
|
||||
["READINESS", "MED_COMPLIANCE"], // MED_COMPLIANCE freshly added
|
||||
),
|
||||
).toEqual(["HEALTH_SCORE", "READINESS", "MED_COMPLIANCE"]);
|
||||
});
|
||||
|
||||
it("drops unknown ids and collapses duplicates, always keeping the health-score ring", () => {
|
||||
expect(
|
||||
resolveHeroRingOrder(
|
||||
["READINESS", "BOGUS", "READINESS", "HEALTH_SCORE"],
|
||||
["READINESS"],
|
||||
),
|
||||
).toEqual(["READINESS", "HEALTH_SCORE"]);
|
||||
});
|
||||
|
||||
it("appends the health-score ring when a corrupt order omits it entirely", () => {
|
||||
expect(resolveHeroRingOrder(["READINESS"], ["READINESS"])).toEqual([
|
||||
"READINESS",
|
||||
"HEALTH_SCORE",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDashboardLayout() / serialize — heroRingOrder", () => {
|
||||
const base = {
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
};
|
||||
|
||||
it("a legacy blob (field missing) resolves to health-score-first over the selected rings", () => {
|
||||
const resolved = resolveDashboardLayout({
|
||||
...base,
|
||||
selectedScoreRings: ["READINESS", "SLEEP_SCORE"],
|
||||
});
|
||||
expect(resolved.heroRingOrder).toEqual([
|
||||
"HEALTH_SCORE",
|
||||
"READINESS",
|
||||
"SLEEP_SCORE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reconciles a stored order against the selected set on read", () => {
|
||||
const resolved = resolveDashboardLayout({
|
||||
...base,
|
||||
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
|
||||
heroRingOrder: ["MED_COMPLIANCE", "HEALTH_SCORE", "READINESS"],
|
||||
});
|
||||
expect(resolved.heroRingOrder).toEqual([
|
||||
"MED_COMPLIANCE",
|
||||
"HEALTH_SCORE",
|
||||
"READINESS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("serialize persists a reconciled order and round-trips", () => {
|
||||
const serialized = serializeDashboardLayout({
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
selectedScoreRings: ["READINESS", "MED_COMPLIANCE"],
|
||||
heroRingOrder: ["READINESS", "HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
} as DashboardLayout);
|
||||
expect(serialized.heroRingOrder).toEqual([
|
||||
"READINESS",
|
||||
"HEALTH_SCORE",
|
||||
"MED_COMPLIANCE",
|
||||
]);
|
||||
expect(resolveDashboardLayout(serialized).heroRingOrder).toEqual([
|
||||
"READINESS",
|
||||
"HEALTH_SCORE",
|
||||
"MED_COMPLIANCE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("the default layout leads with the health-score ring", () => {
|
||||
expect(DEFAULT_DASHBOARD_LAYOUT.heroRingOrder).toEqual(
|
||||
DEFAULT_HERO_RING_ORDER,
|
||||
);
|
||||
expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE");
|
||||
});
|
||||
});
|
||||
+117
-2
@@ -364,6 +364,87 @@ export const MAX_SELECTED_SCORE_RINGS = 3;
|
||||
|
||||
export const DEFAULT_SELECTED_SCORE_RINGS: ScoreRingId[] = ["MED_COMPLIANCE"];
|
||||
|
||||
/**
|
||||
* v1.27.27 — hero ring ORDER. The health-score ring is a first-class,
|
||||
* always-present member of the hero ring row that the user can position
|
||||
* anywhere in the sequence (it has no on/off toggle — it is the anchor
|
||||
* ring). `HERO_RING_IDS` is the health-score id plus the selectable score
|
||||
* rings; `HeroRingId` is the union the persisted order carries.
|
||||
*/
|
||||
export const HEALTH_SCORE_RING_ID = "HEALTH_SCORE" as const;
|
||||
export const HERO_RING_IDS = [HEALTH_SCORE_RING_ID, ...SCORE_RING_IDS] as const;
|
||||
export type HeroRingId = (typeof HERO_RING_IDS)[number];
|
||||
|
||||
/** The order carries the health-score ring plus up to three score rings. */
|
||||
export const MAX_HERO_RING_ORDER = MAX_SELECTED_SCORE_RINGS + 1;
|
||||
|
||||
/**
|
||||
* Default hero ring order: the health-score ring leads, then the default
|
||||
* selected score rings in their default order. The maintainer's contract —
|
||||
* "always start with the health score by default, but let the user
|
||||
* reorder" — lives here: a user who never opens the picker gets the
|
||||
* health-score ring on the leading edge.
|
||||
*/
|
||||
export const DEFAULT_HERO_RING_ORDER: HeroRingId[] = [
|
||||
HEALTH_SCORE_RING_ID,
|
||||
...DEFAULT_SELECTED_SCORE_RINGS,
|
||||
];
|
||||
|
||||
function isHeroRingId(value: unknown): value is HeroRingId {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(HERO_RING_IDS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the persisted hero ring order against the CURRENT available
|
||||
* ring set (the always-present health-score ring plus the passed selected
|
||||
* score rings). The order is the single source of truth for how the hero
|
||||
* sequences its rings:
|
||||
*
|
||||
* - a missing / malformed order → the default: health score first, then
|
||||
* the selected rings in selection order (the maintainer's contract);
|
||||
* - a stored order is filtered to the available set — deselected /
|
||||
* unknown ids drop, duplicates collapse;
|
||||
* - any available ring the stored order didn't place (a newly-selected
|
||||
* ring, or the health-score ring a corrupt order somehow omits) is
|
||||
* appended so a ring never silently vanishes.
|
||||
*
|
||||
* Passing the RESOLVED score rings (what actually renders) lets the hero
|
||||
* order only the rings it draws; passing the SELECTED score rings lets the
|
||||
* Settings picker and the layout resolver share the identical reconciler.
|
||||
*/
|
||||
export function resolveHeroRingOrder(
|
||||
rawOrder: unknown,
|
||||
selected: ScoreRingId[],
|
||||
): HeroRingId[] {
|
||||
const available: HeroRingId[] = [HEALTH_SCORE_RING_ID, ...selected];
|
||||
if (!Array.isArray(rawOrder)) {
|
||||
// Default — health score leads, selected rings follow in order.
|
||||
return available;
|
||||
}
|
||||
const availableSet = new Set<HeroRingId>(available);
|
||||
const seen = new Set<HeroRingId>();
|
||||
const out: HeroRingId[] = [];
|
||||
for (const entry of rawOrder) {
|
||||
if (!isHeroRingId(entry)) continue;
|
||||
if (!availableSet.has(entry)) continue;
|
||||
if (seen.has(entry)) continue;
|
||||
out.push(entry);
|
||||
seen.add(entry);
|
||||
}
|
||||
// Append available rings the stored order didn't place (new ring, or a
|
||||
// health-score ring a corrupt order lacks), preserving `available` order.
|
||||
for (const id of available) {
|
||||
if (!seen.has(id)) {
|
||||
out.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring id → owning toggleable module. Mirrors the derived routes'
|
||||
* `DERIVED_MODULE` map for the three derived rings (READINESS rides the
|
||||
@@ -428,6 +509,14 @@ export interface DashboardLayout {
|
||||
* `SCORE_RING_IDS`; the resolver clamps/dedupes/drops-unknown.
|
||||
*/
|
||||
selectedScoreRings?: ScoreRingId[];
|
||||
/**
|
||||
* v1.27.27 — hero ring display ORDER over the health-score ring plus the
|
||||
* selected score rings. The single source of truth for the hero ring
|
||||
* sequence; defaults to health-score first (see `resolveHeroRingOrder` +
|
||||
* `DEFAULT_HERO_RING_ORDER`). The resolver reconciles it against the
|
||||
* selected set on every read.
|
||||
*/
|
||||
heroRingOrder?: HeroRingId[];
|
||||
}
|
||||
|
||||
const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
@@ -445,6 +534,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
// One medication-adherence ring next to the health score by default —
|
||||
// the successor of the hero dose row's information role.
|
||||
selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS],
|
||||
// v1.27.27 — the health-score ring leads the row by default; the user
|
||||
// can reorder (see `resolveHeroRingOrder`).
|
||||
heroRingOrder: [...DEFAULT_HERO_RING_ORDER],
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "bp", visible: true, tileVisible: true, order: 1 },
|
||||
@@ -568,6 +660,12 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
tileVisible: typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
|
||||
}));
|
||||
|
||||
// Resolve the selected score rings once — the hero-ring-order reconciler
|
||||
// below keys off the same resolved set the layout persists.
|
||||
const resolvedSelectedScoreRings = coerceSelectedScoreRings(
|
||||
candidate.selectedScoreRings,
|
||||
);
|
||||
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: [...normalized, ...appended].sort((a, b) => a.order - b.order),
|
||||
@@ -589,13 +687,23 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
// v1.27.7 — hero score rings: unknown ids drop, duplicates collapse,
|
||||
// the list clamps to three. A legacy blob without the field gets the
|
||||
// default single MED_COMPLIANCE ring.
|
||||
selectedScoreRings: coerceSelectedScoreRings(candidate.selectedScoreRings),
|
||||
selectedScoreRings: resolvedSelectedScoreRings,
|
||||
// v1.27.27 — hero ring order, reconciled against the resolved selected
|
||||
// set. A legacy blob without the field gets the default (health-score
|
||||
// first, then the selected rings in order).
|
||||
heroRingOrder: resolveHeroRingOrder(
|
||||
candidate.heroRingOrder,
|
||||
resolvedSelectedScoreRings,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeDashboardLayout(
|
||||
layout: DashboardLayout,
|
||||
): DashboardLayout {
|
||||
const resolvedSelectedScoreRings = coerceSelectedScoreRings(
|
||||
layout.selectedScoreRings,
|
||||
);
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: layout.widgets
|
||||
@@ -622,6 +730,13 @@ export function serializeDashboardLayout(
|
||||
heroVisible: layout.heroVisible === true,
|
||||
// v1.27.7 — persist the score-ring selection through the same
|
||||
// coercion the resolver runs so the wire shape is stable.
|
||||
selectedScoreRings: coerceSelectedScoreRings(layout.selectedScoreRings),
|
||||
selectedScoreRings: resolvedSelectedScoreRings,
|
||||
// v1.27.27 — persist the hero ring order, reconciled against the
|
||||
// resolved selected set (same reconciler as the resolver) so the wire
|
||||
// shape is stable and a deselected ring can't linger in the order.
|
||||
heroRingOrder: resolveHeroRingOrder(
|
||||
layout.heroRingOrder,
|
||||
resolvedSelectedScoreRings,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user