Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## [1.11.4] — 2026-06-04 — sleep totals, trend captions, and a round of fixes

### Fixed

- **Sleep now shows the whole night.** The sleep tile and sleep chart previously showed a single sleep stage's minutes; they now show the night's total time asleep, grouped by sleep session so a night that crosses midnight counts as one night, with the unit shown explicitly. When two sources report the same night, the higher-priority one is used rather than adding them together.
- **The daily briefing no longer makes the Insights overview wait.** The overview reads the existing briefing immediately and refreshes it in the background instead of blocking the page on generation.
- **Recent achievements no longer flash in and vanish.** Cards that depend on your layout or your data now wait for that to load before appearing, so nothing shows up only to be retracted a moment later.
- **Charts with only a day or two of data now show those points** instead of withholding them behind a "more days needed" card. A short series still notes that more days will fill out the trend.
- **A reading logged by hand and mirrored to Apple Health no longer appears twice.** The same physical reading arriving from both sources is merged into one.
- Long wellness-score labels (for example "Sleep score") no longer clip.
- The mood-by-time-of-day chart no longer overflows its card.
- The Trends row cards are now equal height and the mood card's axis labels stay inside the card.

### Changed

- **The Trends row now describes the actual trend.** When there is no written summary yet, each trend card states the direction and size of the change over the period (for example "rising over 30 days") drawn from your own data, instead of always showing "awaiting more data."
- **Settings → Advanced:** the erase-data and delete-account buttons now sit beside their descriptions, matching the rest of the app.

## [1.11.3] — 2026-06-04 — WHOOP body data, quality-of-life polish, fuller translations

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.11.3
version: 1.11.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down
9 changes: 9 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.",
"noDataInRangeTitle": "Keine Daten in diesem Zeitraum",
"noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.",
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
Expand Down Expand Up @@ -2811,6 +2812,14 @@
"veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.",
"fired": "Dein Gerät hat dieses Ereignis erkannt."
}
},
"trendDescriptor": {
"rising": "Über 30 Tage steigend ({delta}{unit}).",
"falling": "Über 30 Tage fallend ({delta}{unit}).",
"stable": "Über die letzten 30 Tage stabil.",
"moodImproved": "Stimmung über 30 Tage leicht verbessert.",
"moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.",
"moodStable": "Stimmung über die letzten 30 Tage stabil."
}
},
"thresholds": {
Expand Down
9 changes: 9 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.",
"noDataInRangeTitle": "No data in this range",
"noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.",
"sparseDataCaption": "More days will fill out the trend.",
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
Expand Down Expand Up @@ -2811,6 +2812,14 @@
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
},
"trendDescriptor": {
"rising": "Rising over 30 days ({delta}{unit}).",
"falling": "Falling over 30 days ({delta}{unit}).",
"stable": "Stable over the last 30 days.",
"moodImproved": "Mood trended slightly higher over 30 days.",
"moodDeclined": "Mood trended slightly lower over 30 days.",
"moodStable": "Mood held steady over the last 30 days."
}
},
"thresholds": {
Expand Down
9 changes: 9 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.",
"noDataInRangeTitle": "Sin datos en este rango",
"noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.",
"sparseDataCaption": "Más días completarán la tendencia.",
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
Expand Down Expand Up @@ -2811,6 +2812,14 @@
"veryLow": "Tu dispositivo detectó una estabilidad al caminar muy baja.",
"fired": "Tu dispositivo señaló este evento."
}
},
"trendDescriptor": {
"rising": "Al alza en 30 días ({delta}{unit}).",
"falling": "A la baja en 30 días ({delta}{unit}).",
"stable": "Estable en los últimos 30 días.",
"moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.",
"moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.",
"moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días."
}
},
"thresholds": {
Expand Down
9 changes: 9 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.",
"noDataInRangeTitle": "Aucune donnée sur cette période",
"noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.",
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
Expand Down Expand Up @@ -2811,6 +2812,14 @@
"veryLow": "Votre appareil a détecté une très faible stabilité à la marche.",
"fired": "Votre appareil a signalé cet événement."
}
},
"trendDescriptor": {
"rising": "En hausse sur 30 jours ({delta}{unit}).",
"falling": "En baisse sur 30 jours ({delta}{unit}).",
"stable": "Stable sur les 30 derniers jours.",
"moodImproved": "Humeur en légère hausse sur 30 jours.",
"moodDeclined": "Humeur en légère baisse sur 30 jours.",
"moodStable": "Humeur stable sur les 30 derniers jours."
}
},
"thresholds": {
Expand Down
9 changes: 9 additions & 0 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.",
"noDataInRangeTitle": "Nessun dato in questo intervallo",
"noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.",
"sparseDataCaption": "Più giorni completeranno il trend.",
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
Expand Down Expand Up @@ -2811,6 +2812,14 @@
"veryLow": "Il tuo dispositivo ha rilevato una stabilità nella camminata molto bassa.",
"fired": "Il tuo dispositivo ha segnalato questo evento."
}
},
"trendDescriptor": {
"rising": "In aumento su 30 giorni ({delta}{unit}).",
"falling": "In calo su 30 giorni ({delta}{unit}).",
"stable": "Stabile negli ultimi 30 giorni.",
"moodImproved": "Umore in lieve miglioramento su 30 giorni.",
"moodDeclined": "Umore in lieve calo su 30 giorni.",
"moodStable": "Umore stabile negli ultimi 30 giorni."
}
},
"thresholds": {
Expand Down
9 changes: 9 additions & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.",
"noDataInRangeTitle": "Brak danych w tym zakresie",
"noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.",
"sparseDataCaption": "Więcej dni dopełni trend.",
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
Expand Down Expand Up @@ -2811,6 +2812,14 @@
"veryLow": "Twoje urządzenie wykryło bardzo niską stabilność chodu.",
"fired": "Twoje urządzenie zgłosiło to zdarzenie."
}
},
"trendDescriptor": {
"rising": "Rośnie w ciągu 30 dni ({delta}{unit}).",
"falling": "Spada w ciągu 30 dni ({delta}{unit}).",
"stable": "Stabilnie przez ostatnie 30 dni.",
"moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.",
"moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.",
"moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni."
}
},
"thresholds": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.11.3",
"version": "1.11.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
Expand Down
59 changes: 58 additions & 1 deletion src/app/api/dashboard/summary/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { NextRequest } from "next/server";

vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: vi.fn() },
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
// the night-total sleep tile.
measurement: { groupBy: vi.fn(), findMany: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
// v1.4.39 W-SERVER-FIX-2 — the dashboard route now backfills
Expand Down Expand Up @@ -34,6 +36,13 @@ vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));

// v1.11.4 — the route loads the user's source-priority ladder to collapse a
// dual-source sleep night; pin it to the defaults (null) so the test stays
// hermetic.
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));

vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
Expand Down Expand Up @@ -82,6 +91,10 @@ beforeEach(() => {
// per-type all-time count + most-recent timestamp. Default to an
// empty aggregate so legacy tests keep their "no data" expectations.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
// v1.11.4 — the sleep tile reads the raw per-stage SLEEP_DURATION rows
// via `measurement.findMany` to reconstruct the night total. Default to
// no rows so legacy tests keep their "no sleep" expectation.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
Expand Down Expand Up @@ -443,6 +456,50 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});

it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// tile must SUM the asleep stages of the latest night and convert to
// hours, NOT surface a single stage. Excludes IN_BED + AWAKE.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 5 },
_max: { measuredAt: wake },
},
] as never);
// Raw per-stage rows for the night (the sleep findMany read).
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
{ value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
metrics: Array<{
id: string;
latestValue: number | null;
unit: string | null;
sleepStages: Record<string, number> | null;
}>;
};
};
const sleep = body.data.metrics.find((m) => m.id === "sleep");
expect(sleep, "sleep tile must be emitted").toBeDefined();
// Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410 min → 6.83 h.
// (IN_BED 480 + AWAKE 20 excluded.)
expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2);
expect(sleep?.unit).toBe("h");
// Stage breakdown is exposed in hours for a future detail view.
expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2);
expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2);
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
});

it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
Expand Down
Loading
Loading