Merge pull request #463 from MBombeck/release/v1.28.19

release/v1.28.19 — document and card polish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-10 14:18:22 +02:00
commit 5f558abc33
16 files changed
+154 -125

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.
+5 -5
View File
@@ -8317,15 +8317,14 @@
"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…",
"statusNotYet": "Noch nicht durchsuchbar",
"readDone": "Die KI hat dein Dokument gelesen."
"readDone": "Die KI hat dein Dokument gelesen.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
"reading": "Liest…"
},
"contentIndex": {
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
@@ -8357,6 +8356,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}"
}
},
+5 -5
View File
@@ -8317,15 +8317,14 @@
"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…",
"statusNotYet": "Not searchable yet",
"readDone": "The AI read your document."
"readDone": "The AI read your document.",
"read": "Read with AI",
"reread": "Read again",
"reading": "Reading…"
},
"contentIndex": {
"indexPrompt": "Make document contents searchable.",
@@ -8357,6 +8356,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}"
}
},
+5 -5
View File
@@ -8317,15 +8317,14 @@
"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…",
"statusNotYet": "Aún no buscable",
"readDone": "La IA leyó tu documento."
"readDone": "La IA leyó tu documento.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
"reading": "Leyendo…"
},
"contentIndex": {
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
@@ -8357,6 +8356,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}"
}
},
+5 -5
View File
@@ -8317,15 +8317,14 @@
"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…",
"statusNotYet": "Pas encore consultable",
"readDone": "L’IA a lu votre document."
"readDone": "L’IA a lu votre document.",
"read": "Lire avec l’IA",
"reread": "Relire",
"reading": "Lecture…"
},
"contentIndex": {
"indexPrompt": "Rendre le contenu des documents consultable.",
@@ -8357,6 +8356,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}"
}
},
+5 -5
View File
@@ -8317,15 +8317,14 @@
"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…",
"statusNotYet": "Non ancora ricercabile",
"readDone": "L’IA ha letto il tuo documento."
"readDone": "L’IA ha letto il tuo documento.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
"reading": "Lettura…"
},
"contentIndex": {
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
@@ -8357,6 +8356,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}"
}
},
+5 -5
View File
@@ -8317,15 +8317,14 @@
"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…",
"statusNotYet": "Jeszcze nie do wyszukania",
"readDone": "AI odczytało Twój dokument."
"readDone": "AI odczytało Twój dokument.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
"reading": "Odczytywanie…"
},
"contentIndex": {
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
@@ -8357,6 +8356,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/*`
@@ -118,16 +118,11 @@ export function DocumentAiSection({
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>
{/* v1.28.19 — the "read with AI" block title + wand were redundant with
the read-provenance pill (auto-read already surfaces "read by AI"), so
only the provenance pill leads the block now. The pill itself stays —
it is the read-state contract the recipient/owner relies on. */}
<div className="flex items-center justify-end">
<ContentSearchStatus
hasContentIndex={hasContentIndex}
source={contentIndexSource}
+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>;
};