Merge pull request #436 from MBombeck/release/v1.27.26

Release v1.27.26 — hero and wellbeing card polish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-08 00:57:18 +02:00
commit b00a2206c5
15 files changed
+227 -43

No files matched your search

+7
View File
@@ -2,6 +2,13 @@
## [Unreleased]
## [1.27.26] — 2026-07-08 — Hero and wellbeing card polish
### Changed
- The dashboard hero tightens the gap between the ring row and the briefing another step, and each ring is now a link to its detail — readiness / recovery / sleep open their Insights score view, the dose ring opens medications, the health-score ring opens the Insights overview. On a phone the carousel keeps swiping; a tap navigates.
- The mental-wellbeing surface is titled "Mental wellbeing" (was a longer check-in phrase) and its instrument cards follow the medication-card anatomy. The questionnaire source/licence note moves out from under the Start button into a discreet info control in the card header, so the Start button stands alone.
## [1.27.25] — 2026-07-08 — Documents filter dropdowns
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.25
version: 1.27.26
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+3 -1
View File
@@ -316,6 +316,7 @@
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "{metric}-Details öffnen",
"scoreProvisional": "Noch nicht genug Daten",
"scoreComputing": "Score wird berechnet…",
"ringDoses": "Dosen heute",
@@ -7704,9 +7705,10 @@
}
},
"mentalHealth": {
"pageTitle": "Check-in zum seelischen Wohlbefinden",
"pageTitle": "Seelisches Wohlbefinden",
"pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
"attributionLabel": "Quelle",
"attributionShow": "Quelle und Lizenz für {instrument}",
"choosePrompt": "Check-in auswählen",
"start": "Starten",
"back": "Zurück",
+3 -1
View File
@@ -316,6 +316,7 @@
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Open {metric} details",
"scoreProvisional": "Not enough data yet",
"scoreComputing": "Calculating score…",
"ringDoses": "Doses today",
@@ -7704,9 +7705,10 @@
}
},
"mentalHealth": {
"pageTitle": "Mental wellbeing check-in",
"pageTitle": "Mental wellbeing",
"pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
"attributionLabel": "Attribution",
"attributionShow": "Source and licence for {instrument}",
"choosePrompt": "Choose a check-in",
"start": "Start",
"back": "Back",
+3 -1
View File
@@ -316,6 +316,7 @@
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Abrir detalles de {metric}",
"scoreProvisional": "Aún no hay datos suficientes",
"scoreComputing": "Calculando la puntuación …",
"ringDoses": "Dosis de hoy",
@@ -7704,9 +7705,10 @@
}
},
"mentalHealth": {
"pageTitle": "Revisión de bienestar mental",
"pageTitle": "Bienestar mental",
"pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
"attributionLabel": "Fuente",
"attributionShow": "Fuente y licencia de {instrument}",
"choosePrompt": "Elige una revisión",
"start": "Empezar",
"back": "Atrás",
+3 -1
View File
@@ -316,6 +316,7 @@
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Ouvrir les détails de {metric}",
"scoreProvisional": "Pas encore assez de données",
"scoreComputing": "Calcul du score en cours …",
"ringDoses": "Doses du jour",
@@ -7704,9 +7705,10 @@
}
},
"mentalHealth": {
"pageTitle": "Point sur le bien-être mental",
"pageTitle": "Bien-être mental",
"pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
"attributionLabel": "Source",
"attributionShow": "Source et licence pour {instrument}",
"choosePrompt": "Choisis un bilan",
"start": "Commencer",
"back": "Retour",
+3 -1
View File
@@ -316,6 +316,7 @@
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Apri i dettagli di {metric}",
"scoreProvisional": "Dati ancora insufficienti",
"scoreComputing": "Calcolo del punteggio in corso …",
"ringDoses": "Dosi di oggi",
@@ -7704,9 +7705,10 @@
}
},
"mentalHealth": {
"pageTitle": "Check-in sul benessere mentale",
"pageTitle": "Benessere mentale",
"pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
"attributionLabel": "Fonte",
"attributionShow": "Fonte e licenza di {instrument}",
"choosePrompt": "Scegli un check-in",
"start": "Inizia",
"back": "Indietro",
+3 -1
View File
@@ -316,6 +316,7 @@
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Otwórz szczegóły: {metric}",
"scoreProvisional": "Za mało danych",
"scoreComputing": "Trwa obliczanie wyniku …",
"ringDoses": "Dzisiejsze dawki",
@@ -7704,9 +7705,10 @@
}
},
"mentalHealth": {
"pageTitle": "Sprawdzenie dobrostanu psychicznego",
"pageTitle": "Dobrostan psychiczny",
"pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
"attributionLabel": "Źródło",
"attributionShow": "Źródło i licencja: {instrument}",
"choosePrompt": "Wybierz sprawdzenie",
"start": "Rozpocznij",
"back": "Wstecz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.25",
"version": "1.27.26",
"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
View File
@@ -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.25";
/* @sw-version-fallback */ "v1.27.26";
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/*`
@@ -521,6 +521,47 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
});
});
describe("<DashboardHero> — ring links (v1.27.24)", () => {
it("each ring links to its natural detail surface with an aria-label", () => {
const html = render(
baseSnapshot({
healthScore: { score: 82, band: "green", delta: 2 },
scoreRings: [
{ id: "READINESS", score: 71, band: "green" },
{ id: "SLEEP_SCORE", score: 66, band: "yellow" },
{
id: "MED_COMPLIANCE",
score: 33,
band: "yellow",
doses: { taken: 1, scheduled: 3 },
},
],
}),
);
// Derived rings → their score-anatomy detail page.
const readiness = html.slice(
html.indexOf('data-ring="READINESS"'),
html.indexOf('data-ring="SLEEP_SCORE"'),
);
expect(readiness).toContain('data-slot="dashboard-hero-ring-link"');
expect(readiness).toContain('href="/insights/scores/readiness"');
expect(readiness).toContain("aria-label=");
const sleep = html.slice(
html.indexOf('data-ring="SLEEP_SCORE"'),
html.indexOf('data-ring="MED_COMPLIANCE"'),
);
expect(sleep).toContain('href="/insights/scores/sleep"');
// Dose ring → the medications surface.
const med = html.slice(html.indexOf('data-ring="MED_COMPLIANCE"'));
expect(med).toContain('href="/medications"');
// Health-score ring → the Insights overview.
expect(html).toContain('href="/insights"');
// Every ring link is keyboard-focusable (native anchor) — one per slide.
const links = html.match(/data-slot="dashboard-hero-ring-link"/g) ?? [];
expect(links).toHaveLength(4);
});
});
describe("<DashboardHero> — greeting", () => {
it("personalises from the snapshot username with the server greeting hour", () => {
const html = render(
@@ -128,6 +128,29 @@ const RING_LABEL_KEY: Record<ScoreRingId, string> = {
MED_COMPLIANCE: "dashboard.hero.ringDoses",
};
/**
* v1.27.24 — per-ring detail destination. Each hero ring links to the
* SAME surface its sibling already owns elsewhere in the app, so tapping
* a ring opens the natural deeper view rather than inventing one:
*
* - the three derived rings route to their score-anatomy detail page
* (`/insights/scores/<slug>`) — exactly where the /insights wellness
* strip links each ring;
* - the dose ring routes to the medications surface (`/medications`) —
* the today/intake destination the dashboard dose CTA already uses.
*
* The health-score ring is wired separately to the Insights overview
* (`/insights`, the destination the health-score card carries). A ring
* with no sensible destination would be left non-interactive; every id
* in the closed set has one, so the map is total.
*/
const RING_HREF: Record<ScoreRingId, string> = {
READINESS: "/insights/scores/readiness",
RECOVERY_SCORE: "/insights/scores/recovery",
SLEEP_SCORE: "/insights/scores/sleep",
MED_COMPLIANCE: "/medications",
};
export function DashboardHero({
snapshot,
onQuickEntry,
@@ -253,6 +276,12 @@ export function DashboardHero({
...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
@@ -288,6 +317,13 @@ export function DashboardHero({
})),
{
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}
@@ -318,12 +354,13 @@ export function DashboardHero({
"min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
)}
>
{/* Vertical rhythm: greeting/ring row and the briefing sit one
spacing step apart — tight enough that the briefing doesn't feel
adrift below a floating greeting. The top row aligns to its start
on desktop so the greeting anchors to the top edge instead of
{/* Vertical rhythm: greeting/ring row and the briefing sit one tight
spacing step apart on desktop (md:gap-2) so the briefing hugs the
ring row instead of drifting below a floating greeting; mobile
keeps the roomier gap-4. The top row aligns to its start on
desktop so the greeting anchors to the top edge instead of
drifting to the vertical centre of the taller ring column. */}
<div className="flex h-full flex-col gap-4 md:gap-3">
<div className="flex h-full flex-col gap-4 md:gap-2">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting leads the typographic hierarchy: larger + heavier
@@ -27,6 +27,7 @@
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useTranslations } from "@/lib/i18n/context";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import type { ScoreRingId } from "@/lib/dashboard-layout";
@@ -41,6 +42,13 @@ export interface HeroRingSlide {
ringId?: ScoreRingId;
/** The rendered `<ScoreRing>` node. */
node: React.ReactNode;
/** Optional detail destination — wraps the ring in a focusable link so a
* tap opens the metric's detail. A real anchor keeps tap-vs-drag native
* on the mobile carousel: a swipe scrolls the track, only a tap fires
* navigation. Omit to leave the ring non-interactive. */
href?: string;
/** aria-label for the ring link ("Open {metric} details"). */
linkLabel?: string;
}
export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) {
@@ -122,7 +130,21 @@ export function HeroRingCarousel({ slides }: { slides: HeroRingSlide[] }) {
"md:w-auto",
)}
>
{slide.node}
{slide.href ? (
// A real anchor sized to the ring: a tap navigates, a
// horizontal drag scrolls the track (native tap-vs-drag), and
// it is keyboard-focusable with a rounded focus ring.
<Link
href={slide.href}
aria-label={slide.linkLabel}
data-slot="dashboard-hero-ring-link"
className="focus-visible:ring-ring/50 rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
{slide.node}
</Link>
) : (
slide.node
)}
</div>
))}
</div>
@@ -405,13 +405,33 @@ describe("instrument card (med-/Vorsorge card anatomy + detail opener)", () => {
expect(html).toContain(mh.start);
// No history yet → the calm no-check-in line, no fabricated dashes.
expect(html).toContain(mh.noResultYet);
// v1.27.9 — the required attribution footer rides every card.
expect(html).toContain('data-slot="instrument-card-attribution"');
// v1.27.24 — the attribution moved off the space under Start to a
// top-right info control on the header. The trigger renders; nothing
// sits under the Start button anymore.
expect(html).toContain('data-slot="instrument-card-attribution-trigger"');
// The citation now lives in the trigger's popover (portaled, opened on
// tap), never as a paragraph beneath the action.
const startIdx = html.indexOf(mh.start);
expect(startIdx).toBeGreaterThan(-1);
expect(html.slice(startIdx)).not.toContain(
'data-slot="instrument-card-attribution"',
);
});
it("carries the instrument's licence line on the WHO-5 / SCI cards", () => {
expect(card("WHO5")).toContain("CC BY-NC-SA 3.0 IGO");
expect(card("SCI")).toContain("Sleepio Limited");
it("the header info control is a sibling of the detail-open button, never nested", () => {
const html = card("WHO5");
// Both the info trigger and the detail-open button render, but neither is
// inside the other (nested interactive controls are invalid + would let a
// tap open both). A simple structural check: the trigger appears before
// the open button's closing markup boundary is irrelevant — assert both
// slots exist and the trigger carries its own accessible name.
expect(html).toContain('data-slot="instrument-card-attribution-trigger"');
expect(html).toContain('data-slot="instrument-card-open"');
const trigger = html.match(
/<button[^>]*data-slot="instrument-card-attribution-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger![0]).toContain("aria-label=");
});
it("shows last test (relative) and last result (score + band word)", () => {
@@ -12,17 +12,33 @@
* through discreet text only, never a card wash).
*
* v1.27.9 — the card generalises over the four-instrument registry and
* carries the instrument's required attribution/licence line as a small
* factual footer (WHO CC BY-NC-SA citation for the WHO-5, the Sleepio
* CC BY-NC citation for the SCI, the Pfizer grant for PHQ/GAD). A locale
* without validated item wording additionally gets the honest
* "validated in English" note. The card BODY (header + last-result block)
* opens the per-instrument detail — the Vorsorge-/med-card interaction —
* while the bottom-pinned Start button stays a separate, non-overlapping
* action.
* carries the instrument's required attribution/licence line (WHO
* CC BY-NC-SA citation for the WHO-5, the Sleepio CC BY-NC citation for
* the SCI, the Pfizer grant for PHQ/GAD). A locale without validated item
* wording additionally gets the honest "validated in English" note. The
* card BODY (header + last-result block) opens the per-instrument detail —
* the Vorsorge-/med-card interaction — while the bottom-pinned Start
* button stays a separate, non-overlapping action.
*
* v1.27.24 — the attribution left the space UNDER the Start button (which
* crowded the action) for a discreet info affordance at the top-right of
* the card header, at the heading's height. A single icon button opens a
* popover carrying the verbatim citation (+ the "validated in English"
* note where it applies), so the Start button stands alone with clean
* space below it. The citation still renders verbatim in full view on the
* result and detail surfaces; here it is one tap away. The trigger is a
* sibling of the detail-open button (never nested inside it) so the two
* actions never collide.
*/
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
@@ -56,10 +72,48 @@ export function InstrumentCard({
const title = t(`mentalHealth.instrument.${key}`);
return (
<Card className="h-full gap-3 md:gap-3" data-slot="instrument-card">
<Card
className="relative h-full gap-3 md:gap-3"
data-slot="instrument-card"
>
{/* Source / licence — a discreet info control at the top-right of the
header, at the heading's height. A sibling of the detail-open
button (never nested), so a tap opens the citation popover without
also opening the detail. */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
data-slot="instrument-card-attribution-trigger"
aria-label={t("mentalHealth.attributionShow", {
instrument: title,
})}
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring absolute top-4 right-4 z-10 flex size-8 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none md:top-6 md:right-6"
>
<Info className="size-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="max-w-xs">
<p className="text-foreground text-xs font-medium">
{t("mentalHealth.attributionLabel")}
</p>
<p
className="text-muted-foreground mt-1 text-xs leading-snug"
data-slot="instrument-card-attribution"
>
{!hasValidatedItems(instrument, locale) && (
<>{t("mentalHealth.validatedInEnglishNote")} </>
)}
{def.attribution}
</p>
</PopoverContent>
</Popover>
{/* Body is the detail target: clicking the header / last-result block
opens this instrument's detail (chart + dated history), like a
medication card. The Start button below stays a separate action. */}
medication card. The Start button below stays a separate action.
The short instrument titles clear the top-right info control that
floats over the header's right padding. */}
<button
type="button"
onClick={onOpenDetail}
@@ -108,22 +162,13 @@ export function InstrumentCard({
</button>
<CardContent className="flex h-full flex-col">
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */}
<div className="mt-auto space-y-2 pt-0">
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space. */}
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
{/* Required attribution — factual and unobtrusive; identical slot on
every card so the grid stays symmetric. */}
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="instrument-card-attribution"
>
{!hasValidatedItems(instrument, locale) && (
<>{t("mentalHealth.validatedInEnglishNote")} </>
)}
{def.attribution}
</p>
</div>
</CardContent>
</Card>