Merge pull request #404 from MBombeck/release/v1.27.3

Release v1.27.3 — briefing resilience and insights readability
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-05 13:36:52 +02:00
commit 37449c74b0
15 files changed
+81 -114

No files matched your search

+14
View File
@@ -2,6 +2,20 @@
## [Unreleased]
## [1.27.3] — 2026-07-05 — Briefing resilience and Insights readability
### Fixed
- The daily briefing no longer disappears when the AI provider fails mid-generation. If the number-grounding recheck cannot run because the provider is unreachable, the previous day's briefing stays in place until the next successful run — a briefing that restates unverified numbers is still withheld, as before.
- Assessments show their full text right away; the three-line cut with the "show more" step is gone.
### Changed
- Insights read better: the assessment, the daily briefing, the page explainers, and the "usual range" note now use the regular text colour instead of grey — grey stays reserved for meta lines such as timestamps.
- The "usual range" note is a proper card now, with the same icon-and-title header as every other insights card.
- The page heading and explainer on each metric page line up with the card text below them instead of hanging to the left of it.
- The health-score card drops its "Ask the Coach" corner button.
## [1.27.2] — 2026-07-04 — Readable AI texts
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.2
version: 1.27.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} Tokens · {model}",
"askAboutThis": "Den Coach dazu fragen",
"readStrip": {
"label": "Coach-Einschätzung",
"label": "Üblicher Bereich",
"baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.",
"baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.",
"baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} tokens · {model}",
"askAboutThis": "Ask the coach about this",
"readStrip": {
"label": "Coach read",
"label": "Usual range",
"baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.",
"baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.",
"baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} tokens · {model}",
"askAboutThis": "Pregúntale al coach sobre esto",
"readStrip": {
"label": "Lectura del Coach",
"label": "Rango habitual",
"baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.",
"baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.",
"baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} jetons · {model}",
"askAboutThis": "Interroger le coach à ce sujet",
"readStrip": {
"label": "Lecture du Coach",
"label": "Plage habituelle",
"baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.",
"baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.",
"baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} token · {model}",
"askAboutThis": "Chiedi al coach",
"readStrip": {
"label": "Lettura del Coach",
"label": "Intervallo abituale",
"baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.",
"baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.",
"baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.",
+1 -1
View File
@@ -2353,7 +2353,7 @@
"tokensUsedWithModel": "{count} tokenów · {model}",
"askAboutThis": "Zapytaj trenera o to",
"readStrip": {
"label": "Ocena Coacha",
"label": "Typowy zakres",
"baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.",
"baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.",
"baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.2",
"version": "1.27.3",
"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",
@@ -8,7 +8,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
/**
@@ -109,37 +109,27 @@ export function CoachReadStrip({
// fires defensively.)
if (!baselineLine && !driverLine) return null;
// Standard card anatomy — the same TileHeader-led shape as every other
// insights card (foreground icon + title), instead of the former inline
// mini-icon strip that read as chrome outside the card system. Body prose
// in the regular foreground; muted stays reserved for meta lines.
return (
<div
data-slot="coach-read-strip"
className={cn(
"bg-card/60 border-border/60 rounded-xl border px-3.5 py-2.5",
"text-muted-foreground space-y-1 text-sm leading-relaxed",
)}
className="bg-card/60 border-border/60 space-y-2 rounded-xl border px-4 py-3.5"
>
<div className="flex items-start gap-2">
<Sparkles
className="text-primary/70 mt-0.5 size-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<span className="text-foreground/70 sr-only text-xs font-medium">
{t("insights.coach.readStrip.label")}
</span>
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p
data-slot="coach-read-driver"
className="text-muted-foreground/90 text-pretty"
>
{driverLine}
</p>
) : null}
</div>
<TileHeader icon={Sparkles} title={t("insights.coach.readStrip.label")} />
<div className="text-foreground min-w-0 space-y-1 text-sm leading-relaxed">
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p data-slot="coach-read-driver" className="text-pretty">
{driverLine}
</p>
) : null}
</div>
</div>
);
@@ -13,7 +13,6 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
import { AskCoachAction } from "./ask-coach-action";
/**
* v1.4.20 B5 — Personal Health Score panel.
@@ -703,16 +702,6 @@ export function HealthScoreCard({
{/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment"
card disclaimer is removed; the one-time onboarding acknowledgment
now covers the not-a-diagnosis framing app-wide. */}
{/* v1.21.0 (C4 H2) — the score is a whole-picture composite, so the
hand-off opens the Coach against the default snapshot (no scope)
seeded with the actual number. Mounts at the foot of the card as
a single discreet entry, not a primary CTA. */}
<div className="mt-auto flex justify-end pt-1">
<AskCoachAction
question={t("insights.coach.seed.healthScore", { score })}
/>
</div>
</div>
</div>
);
+1 -1
View File
@@ -300,7 +300,7 @@ export function HeroStrip({
spacing (still plain text children, no markdown). */}
<div
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm"
className="text-foreground max-w-3xl text-sm"
>
<ProseBlocks text={subtitle} />
</div>
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
@@ -287,67 +286,13 @@ export function InsightStatusCard({
* overflow, and re-measures on resize / font load via `ResizeObserver`.
*/
function StatusBody({ text }: { text: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// v1.22 (W6) — the assessment now renders through the shared `ProseBlocks`
// helper (real paragraphs). The 3-line clamp therefore moves from the single
// `<p>` to the BLOCK CONTAINER: a max-height + overflow-hidden when collapsed,
// measured on the wrapper node so multi-paragraph text doesn't re-collapse.
// When the text fits, `scrollHeight` equals `clientHeight`; an overflow means
// the clamp is hiding content and the toggle earns its place. Gated on
// `!expanded` so the comparison reads against the clamped element.
useEffect(() => {
if (expanded) return;
const node = wrapperRef.current;
if (!node) return;
const measure = () => {
setOverflows(node.scrollHeight > node.clientHeight + 1);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
// Re-measure whenever the source text changes (a fresh assessment) or
// the user collapses the card again.
}, [text, expanded]);
// The assessment shows in full — the 3-line clamp + "show more" toggle
// hid the prose behind an extra tap for no gain on a card whose whole
// point is the text. Body prose reads in the regular foreground; muted
// stays reserved for meta lines (timestamps, captions).
return (
<div className="space-y-1">
<div
ref={wrapperRef}
className={cn(
"text-muted-foreground text-sm",
// ~3 lines at text-sm / leading-relaxed; only when collapsed.
!expanded && "max-h-[4.5rem] overflow-hidden",
)}
>
<ProseBlocks text={text} />
</div>
{/* Only render the toggle on a genuine overflow. When the full text
fits inside three lines there is nothing more to show, so no
affordance is painted. */}
{(overflows || expanded) && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
data-slot="assessment-show-more"
className={cn(
"text-foreground/80 hover:text-foreground inline-flex text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{expanded
? t("insights.assessmentShowLess")
: t("insights.assessmentShowMore")}
</button>
)}
<div className="text-foreground text-sm">
<ProseBlocks text={text} />
</div>
);
}
+5 -2
View File
@@ -218,7 +218,10 @@ export function SubPageShell({
<div data-slot="insights-subpage" className="space-y-3 md:space-y-4">
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
{backLink}
<header className="space-y-1.5">
{/* The heading + explainer indent to the cards' inner text edge
(`px-6`, the Card content padding), so the page head reads as
part of the card column instead of hanging outside it. */}
<header className="space-y-1.5 px-6">
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
title / nudge / badge wrap together on the left.
@@ -337,7 +340,7 @@ export function SubPageShell({
// string was removed.
<p
data-slot="metric-explainer-inline"
className="text-muted-foreground text-sm leading-relaxed"
className="text-foreground text-sm leading-relaxed"
>
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
</p>
+29 -3
View File
@@ -1111,6 +1111,7 @@ export async function generateComprehensiveInsight(
// the briefing rather than persist a fabricated number.
{
const signals = features.signalsOfDay ?? null;
let retryTransportFailed = false;
let ungrounded = findUngroundedBriefingNumbers(
readBriefingBlock(insights),
signals,
@@ -1151,14 +1152,39 @@ export async function generateComprehensiveInsight(
ungrounded = retryUngrounded;
}
} catch {
// Retry failed — fall through to the strip below.
// Retry failed on TRANSPORT (provider outage / timeout), not on
// content — the model never got its correction chance.
retryTransportFailed = true;
}
}
if (ungrounded.length > 0 && insights && typeof insights === "object") {
(insights as Record<string, unknown>).dailyBriefing = null;
// A content-failed retry (the model repeated ungrounded numbers)
// still strips hard — a fabricated figure must never persist. But
// when the corrective retry died on transport, prefer the previous
// payload's briefing (it passed this same gate when it was written)
// over a hole: a day-old briefing beats a vanished one, and the next
// successful run replaces it.
let fallbackBriefing: unknown = null;
if (retryTransportFailed && dbUser?.insightsCachedText) {
try {
const prev = JSON.parse(dbUser.insightsCachedText) as Record<
string,
unknown
>;
fallbackBriefing = prev.dailyBriefing ?? null;
} catch {
// Unparseable previous payload — keep the hard strip.
}
}
(insights as Record<string, unknown>).dailyBriefing = fallbackBriefing;
annotate({
action: { name: "insights.generate.briefing_grounding_stripped" },
meta: { locale, ungroundedCount: ungrounded.length },
meta: {
locale,
ungroundedCount: ungrounded.length,
briefing_fallback:
fallbackBriefing != null ? "previous-cached" : "stripped",
},
});
}
}