chore(release): v1.28.19 — document and card polish

Documents vault card: date + size and an AI-read marker now sit directly under
the title instead of the meta line running off under the thumbnail.

Document detail view: a larger close control replaces the small X and the
redundant top-right shortcut; the inline read-with-AI row under the preview is
gone (the suggest / summarise / show-text actions stay); a button to ask about
the document sits next to share and download.

Mental-wellbeing cards no longer wash the whole tile on hover — only the title
is the open target, matching the medication cards.

Medication API settings render inline instead of behind an expander that never
had anything beside it.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 13:43:24 +02:00
1 parent 719ba84673
commit c2c4d56b44
17 files changed
+174 -214

No files matched your search

+17 -1
View File
@@ -2,7 +2,23 @@
## [Unreleased]
## [1.28.18] — 2026-07-10 — Google Health sleep reads its true total
## [1.28.19] — 2026-07-10 — Document and card polish
Small refinements across a few surfaces.
In the documents vault, each card now carries the date, size and an "read by AI"
marker directly under the document's title, instead of the meta line sitting off
under the thumbnail.
The document view gets a larger, easier close button, drops a redundant top-right
shortcut and the inline "read with AI" row under the preview, and gains a button
to ask about the document right next to share and download.
The mental-wellbeing cards no longer wash the whole tile on hover — only the
title is the tap target now, matching how the medication cards behave.
The medication API settings render inline instead of behind an expander that
never had anything else beside it.
Sleep imported from Google Health could read far too long — a night of about
seven and a half hours showing as ten. Google re-scores a night after the fact,
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.18
version: 1.28.19
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -4
View File
@@ -8317,10 +8317,6 @@
"close": "Ausblenden"
},
"ai": {
"blockTitle": "Mit KI auslesen",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
"reading": "Liest…",
"statusAiRead": "Von KI gelesen",
"statusSearchable": "Durchsuchbar",
"statusIndexing": "Wird durchsuchbar gemacht…",
@@ -8357,6 +8353,7 @@
"errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.",
"errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.",
"openAria": "Über dieses Dokument chatten",
"openButton": "Zu diesem Dokument fragen",
"scopeBadge": "Chat über: {title}"
}
},
+1 -4
View File
@@ -8317,10 +8317,6 @@
"close": "Hide"
},
"ai": {
"blockTitle": "Read with AI",
"read": "Read with AI",
"reread": "Read again",
"reading": "Reading…",
"statusAiRead": "Read by AI",
"statusSearchable": "Searchable",
"statusIndexing": "Making searchable…",
@@ -8357,6 +8353,7 @@
"errorConsent": "Turn on AI consent in settings to chat about this document.",
"errorNetwork": "You appear to be offline. Check your connection and try again.",
"openAria": "Chat about this document",
"openButton": "Ask about this document",
"scopeBadge": "Chatting about: {title}"
}
},
+1 -4
View File
@@ -8317,10 +8317,6 @@
"close": "Ocultar"
},
"ai": {
"blockTitle": "Leer con IA",
"read": "Leer con IA",
"reread": "Leer de nuevo",
"reading": "Leyendo…",
"statusAiRead": "Leído por IA",
"statusSearchable": "Buscable",
"statusIndexing": "Haciéndolo buscable…",
@@ -8357,6 +8353,7 @@
"errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.",
"errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.",
"openAria": "Chatea sobre este documento",
"openButton": "Pregunta sobre este documento",
"scopeBadge": "Chateando sobre: {title}"
}
},
+1 -4
View File
@@ -8317,10 +8317,6 @@
"close": "Masquer"
},
"ai": {
"blockTitle": "Lire avec l’IA",
"read": "Lire avec l’IA",
"reread": "Relire",
"reading": "Lecture…",
"statusAiRead": "Lu par l’IA",
"statusSearchable": "Consultable",
"statusIndexing": "Rendu consultable…",
@@ -8357,6 +8353,7 @@
"errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.",
"errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.",
"openAria": "Discuter de ce document",
"openButton": "Poser une question sur ce document",
"scopeBadge": "Discussion sur : {title}"
}
},
+1 -4
View File
@@ -8317,10 +8317,6 @@
"close": "Nascondi"
},
"ai": {
"blockTitle": "Leggi con l’IA",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
"reading": "Lettura…",
"statusAiRead": "Letto dall’IA",
"statusSearchable": "Ricercabile",
"statusIndexing": "Rendo ricercabile…",
@@ -8357,6 +8353,7 @@
"errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.",
"errorNetwork": "Sembri offline. Controlla la connessione e riprova.",
"openAria": "Chatta su questo documento",
"openButton": "Fai una domanda su questo documento",
"scopeBadge": "Stai chattando su: {title}"
}
},
+1 -4
View File
@@ -8317,10 +8317,6 @@
"close": "Ukryj"
},
"ai": {
"blockTitle": "Odczytaj z AI",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
"reading": "Odczytywanie…",
"statusAiRead": "Odczytane przez AI",
"statusSearchable": "Wyszukiwalne",
"statusIndexing": "Udostępnianie do wyszukiwania…",
@@ -8357,6 +8353,7 @@
"errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.",
"errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.",
"openAria": "Porozmawiaj o tym dokumencie",
"openButton": "Zapytaj o ten dokument",
"scopeBadge": "Rozmowa o: {title}"
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.18",
"version": "1.28.19",
"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.28.18";
/* @sw-version-fallback */ "v1.28.19";
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/*`
@@ -6,11 +6,13 @@ import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents"
import { DocumentAiSection } from "../document-ai-section";
/**
* The detail sheet's "Read with AI" block is availability-gated: with a provider
* it offers the prominent "Read with AI" action alongside suggest / summary;
* without one the actions collapse to the calm settings pointer (never an action
* the endpoint would 422). The searchable status pill reflects the auto-index in
* both states. The summary panel, when open, keeps the session-only note visible.
* The detail sheet's AI-assist block is availability-gated: with a provider it
* offers the review-first Suggest / Summarise / Show-text actions; without one
* the actions collapse to the calm settings pointer (never an action the
* endpoint would 422). The redundant "read with AI" button + the searchable
* status pill were removed — reading + indexing happen automatically on upload,
* so with auto-read ON and no active suggestion/summary the whole block
* collapses. The summary panel, when open, keeps the session-only note visible.
*/
function render(node: React.ReactNode) {
@@ -62,29 +64,20 @@ function base(
}
describe("<DocumentAiSection>", () => {
it("leads with the prominent Read-with-AI action when a provider is available", () => {
it("offers the review-first assist actions when a provider is available", () => {
const html = render(base({ aiEnabled: true }));
expect(html).toContain('data-slot="document-read-ai"');
expect(html).toContain("Read with AI");
// The standalone "read with AI" button is gone — reading is automatic.
expect(html).not.toContain('data-slot="document-read-ai"');
expect(html).toContain('data-slot="assist-suggest"');
expect(html).toContain("Suggest details");
expect(html).toContain("Summarise");
expect(html).toContain("Show extracted text");
// The searchable status pill was removed from this block.
expect(html).not.toContain('data-slot="content-search-status"');
// No unavailable pointer when the actions are offered.
expect(html).not.toContain('data-slot="assist-unavailable"');
});
it("labels the action as a re-read once a provider has already read it", () => {
const html = render(
base({ hasContentIndex: true, contentIndexSource: "vision" }),
);
expect(html).toContain('data-slot="document-read-ai"');
expect(html).toContain("Read again");
// The status pill reflects the AI-read provenance.
expect(html).toContain('data-state="ai-read"');
expect(html).toContain("Read by AI");
});
it("shows the calm settings pointer (no 422 actions) when no provider is available", () => {
const html = render(
base({
@@ -98,23 +91,6 @@ describe("<DocumentAiSection>", () => {
// Every AI action must be absent — never a 422-guaranteed tap.
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain('data-slot="document-read-ai"');
// But the searchable status pill still renders — auto-index is honest here.
expect(html).toContain('data-slot="content-search-status"');
});
it("keeps a locally-indexed document honestly searchable without a provider", () => {
const html = render(
base({
aiEnabled: false,
indexEnabled: false,
unavailableReason: "no-provider",
hasContentIndex: true,
contentIndexSource: "local-pdf",
}),
);
expect(html).toContain('data-state="searchable"');
expect(html).toContain("Searchable");
expect(html).not.toContain("Read by AI");
});
it("renders the reviewed suggestion drafts when present", () => {
@@ -147,32 +123,36 @@ describe("<DocumentAiSection>", () => {
expect(html).not.toContain('data-slot="document-ai-egress-notice"');
// The actions are still offered.
expect(html).toContain('data-slot="assist-suggest"');
expect(html).toContain('data-slot="document-read-ai"');
});
it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => {
const html = render(base({ aiEnabled: true, indexEnabled: false }));
expect(html).toContain('data-slot="assist-suggest"');
expect(html).not.toContain('data-slot="document-read-ai"');
});
it("hides the manual AI action row when auto-read is on, keeping status", () => {
it("collapses the whole block when auto-read is on with nothing to review", () => {
const html = render(base({ aiEnabled: true, autoReadEnabled: true }));
// Auto-read reads on upload — the manual per-document actions are gone.
expect(html).not.toContain('data-slot="document-read-ai"');
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain("Summarise");
expect(html).not.toContain("Show extracted text");
// The header and the searchable status pill stay. The scoped chat entry
// now lives in the detail-sheet header (a neutral Coach icon), not in this
// section, so this block no longer owns a chat slot.
// Auto-read reads on upload — the manual per-document actions are gone,
// and with no active suggestion/summary the block renders nothing at all
// rather than an empty bordered box.
expect(html).toBe("");
});
it("keeps the review panels when auto-read is on and a suggestion is present", () => {
const html = render(
base({
aiEnabled: true,
autoReadEnabled: true,
suggestion,
suggestionKindLabel: "Imaging",
suggestionDateLabel: "14 Feb 2026",
}),
);
// The manual action row stays hidden, but the block itself renders to host
// the review panel.
expect(html).toContain('data-slot="document-ai-section"');
expect(html).toContain('data-slot="content-search-status"');
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).toContain('data-slot="assist-suggestion-review"');
});
it("shows the manual AI action row when auto-read is off", () => {
const html = render(base({ aiEnabled: true, autoReadEnabled: false }));
expect(html).toContain('data-slot="document-read-ai"');
expect(html).not.toContain('data-slot="document-read-ai"');
expect(html).toContain('data-slot="assist-suggest"');
expect(html).toContain("Summarise");
});
@@ -22,13 +22,11 @@
* status pill stays, honestly reflecting any local auto-index, and the manual
* form below stays fully usable.
*/
import { FileText, ScanText, WandSparkles } from "lucide-react";
import { FileText, WandSparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
@@ -37,7 +35,6 @@ import {
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentSearchStatus,
DocumentSummaryPanel,
} from "./document-ai-panels";
import type { DocumentDescribeResult } from "./use-document-assist";
@@ -45,7 +42,6 @@ import type { DocumentDescribeResult } from "./use-document-assist";
export function DocumentAiSection({
aiEnabled,
autoReadEnabled = false,
indexEnabled,
unavailableReason,
actionsDisabled,
onSuggest,
@@ -65,10 +61,6 @@ export function DocumentAiSection({
summaryResult,
summaryErrorKey,
onCloseSummary,
hasContentIndex,
contentIndexSource,
indexPending,
onIndex,
}: {
aiEnabled: boolean;
/**
@@ -106,58 +98,30 @@ export function DocumentAiSection({
onIndex: () => void;
}) {
const { t } = useTranslations();
const aiRead = hasContentIndex && isAiReadSource(contentIndexSource);
const readLabel = indexPending
? t("documents.ai.reading")
: aiRead
? t("documents.ai.reread")
: t("documents.ai.read");
// The redundant "read with AI / read again" button + the searchable-status
// pill header have been removed — reading + indexing happen automatically on
// upload, so this block now only carries the review-first assist actions
// (Suggest / Summarise / Show text) and their transient panels. With
// auto-read ON and no active suggestion/summary there is nothing to show, so
// the whole block collapses rather than render an empty bordered box.
const hasContent =
!aiEnabled ||
!autoReadEnabled ||
Boolean(suggestErrorKey) ||
Boolean(suggestion) ||
Boolean(summaryOutput);
if (!hasContent) return null;
return (
<div
data-slot="document-ai-section"
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3 md:p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<WandSparkles
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<p className="text-sm font-semibold">
{t("documents.ai.blockTitle")}
</p>
</div>
<ContentSearchStatus
hasContentIndex={hasContentIndex}
source={contentIndexSource}
isPending={indexPending}
/>
</div>
{aiEnabled ? (
<>
{!autoReadEnabled ? (
<div className="flex flex-wrap items-center gap-2">
{indexEnabled ? (
<Button
type="button"
size="sm"
data-slot="document-read-ai"
onClick={onIndex}
disabled={indexPending || actionsDisabled}
>
<ScanText
className={cn(
"size-4",
indexPending &&
"animate-pulse motion-reduce:animate-none",
)}
aria-hidden
/>
{readLabel}
</Button>
) : null}
<Button
type="button"
variant="outline"
+26 -7
View File
@@ -15,7 +15,7 @@
* whole card is clickable through an invisible overlay button; the checkbox
* floats above it.
*/
import { Download, X } from "lucide-react";
import { Download, Sparkles, X } from "lucide-react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
@@ -24,7 +24,10 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
isAiReadSource,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
@@ -84,6 +87,8 @@ export function DocumentCard({
const size = formatBytes(document.byteSize, locale);
const showFilename =
document.filename !== null && document.filename !== title;
const aiRead =
document.hasContentIndex && isAiReadSource(document.contentIndexSource);
return (
<Card
@@ -126,7 +131,25 @@ export function DocumentCard({
aria-hidden
/>
)}
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</p>
{aiRead ? (
// Discreet AI-read indicator — same visual as the detail sheet's
// ContentSearchStatus ai-read pill (bg-primary/10 text-primary,
// Sparkles). Kept compact for the dense grid card.
<span
data-slot="document-card-ai-read"
className="bg-primary/10 text-primary mt-1 inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
>
<Sparkles className="size-3 shrink-0" aria-hidden />
{t("documents.ai.statusAiRead")}
</span>
) : null}
</div>
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
@@ -145,10 +168,6 @@ export function DocumentCard({
)}
/>
</div>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
@@ -25,6 +25,7 @@ import {
Share2,
Sparkles,
Trash2,
X,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
@@ -49,12 +50,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
apiDelete,
ApiError,
@@ -434,32 +429,24 @@ export function DocumentDetailSheet({
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
showCloseButton={false}
headerAction={
doc && aiEnabled ? (
// Neutral, ghost Coach entry pinned top-right of the document —
// mirrors the Insights `AskCoachIconButton`. Deliberately NOT the
// purple FAB styling: `text-muted-foreground` only. Opens the
// doc-scoped chat drawer; the drawer body shows the calm
// read-it-first hint when the document is not yet indexed.
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="document-chat-open"
aria-label={t("documents.chat.openAria")}
onClick={() => setDocChatOpen(true)}
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:size-8"
>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("documents.chat.openAria")}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : undefined
// Enlarged close control rendered in the header's trailing slot —
// the sheet's own X glyph is `size-4` via the primitive; we hide it
// (`showCloseButton={false}`) and render this larger `size-5` X with
// a comfortable min tap target instead. Scoped to this sheet only,
// so the global primitive default is unchanged for other callers.
<Button
type="button"
variant="ghost"
size="icon"
data-slot="document-detail-close"
aria-label={t("common.close")}
onClick={() => onOpenChange(false)}
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:size-9"
>
<X className="size-5" aria-hidden="true" />
</Button>
}
footer={
doc ? (
@@ -480,6 +467,19 @@ export function DocumentDetailSheet({
{t("documents.detail.delete")}
</Button>
<div className="flex items-center gap-2">
{aiEnabled ? (
<Button
variant="outline"
onClick={() => setDocChatOpen(true)}
data-slot="document-chat-open"
aria-label={t("documents.chat.openAria")}
>
<Sparkles className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.chat.openButton")}
</span>
</Button>
) : null}
<Button
variant="outline"
onClick={() => setShareOpen(true)}
@@ -732,8 +732,6 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="api-group-external-api"
collapsible
defaultOpen={false}
>
<div className="py-3">
<ApiTokensRow
@@ -109,58 +109,59 @@ export function InstrumentCard({
</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.
The short instrument titles clear the top-right info control that
floats over the header's right padding. */}
{/* Only the HEADER region is the navigable open-target — mirrors the
medication card header (focus-visible ring only, NO hover background
wash, no whole-card click). The last-result block below is passive;
the Start button 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}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring/60 hover:bg-accent/40 flex cursor-pointer flex-col gap-3 rounded-t-xl text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="focus-visible:ring-ring block w-full rounded-t-xl text-left focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
name={title}
dose=""
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
/>
{/* Label/value block in the Vorsorge card's next-last grammar. With
no history yet, one calm line replaces the pair — no dashes
pretending a value exists. */}
{last ? (
<div className="min-h-[2.75rem] w-full space-y-1.5 px-4 text-sm md:px-6">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastScore")}
</span>
<span
className="text-foreground text-right"
data-slot="instrument-card-last-score"
>
{last.totalScore}
{" · "}
{t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
</span>
</div>
</div>
) : (
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
{t("mentalHealth.noResultYet")}
</div>
)}
</button>
{/* Label/value block in the Vorsorge card's next-last grammar. With
no history yet, one calm line replaces the pair — no dashes
pretending a value exists. */}
{last ? (
<div className="min-h-[2.75rem] w-full space-y-1.5 px-4 text-sm md:px-6">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastScore")}
</span>
<span
className="text-foreground text-right"
data-slot="instrument-card-last-score"
>
{last.totalScore}
{" · "}
{t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
</span>
</div>
</div>
) : (
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
{t("mentalHealth.noResultYet")}
</div>
)}
<CardContent className="flex h-full flex-col">
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
@@ -9,7 +9,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { updateManyMock } = vi.hoisted(() => ({
updateManyMock: vi.fn(async (_args: unknown) => ({ count: 0 })),
updateManyMock: vi.fn(async () => ({ count: 0 })),
}));
vi.mock("@/lib/db", () => ({
@@ -53,7 +53,7 @@ describe("replaceStaleGoogleHealthSleep — bounded replace-by-window", () => {
expect(removed).toBe(1);
expect(updateManyMock).toHaveBeenCalledTimes(1);
const arg = updateManyMock.mock.calls[0]![0] as {
const arg = (updateManyMock.mock.calls[0]! as unknown[])[0] as {
where: Record<string, unknown>;
data: Record<string, unknown>;
};