chore(release): v1.28.22 — full sync survives dense histories; quieter document chrome

Google Health: the fullSync rollup-defer tracker collected its touched keys via
a single spread push — one argument PER READING, so a multi-year heart-rate
history (six figures of samples) overflowed the call stack and aborted the
metrics collection mid-cycle, exactly on the accounts with the most data. A
plain loop replaces the spread. Reported by a self-hoster; the v1.28.21 sync
diagnostics confirmed every other read on that account healthy in one log line.

Documents: the vault card's read-by-AI tag no longer takes its own line — a
small glyph rides inline on the date-and-size row. The detail view drops the
status bar under the preview entirely; with automatic reading on and nothing to
review the whole block collapses, so the content starts straight with the
document's fields. The provenance contract moves with it: the e2e specs now pin
the card marker instead of the sheet pill, and the unmounted status-pill
component is removed rather than left dead.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 23:02:52 +02:00
1 parent 94ad48527e
commit 869f5e561b
18 files changed
+95 -209

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
## [Unreleased]
## [1.28.22] — 2026-07-10 — Full sync survives dense histories; quieter document chrome
A full Google Health sync no longer fails on sample-dense accounts. Collecting
the rollup bookkeeping for a multi-year heart-rate history overflowed the call
stack, which aborted the metrics pass mid-cycle — exactly on the accounts with
the most data. Reported by a self-hoster whose sync log made the diagnosis
immediate; the new sync diagnostics from the previous release confirmed every
other read on that account healthy.
The documents vault gets quieter chrome: the "read by AI" tag no longer takes
its own line on each card — a small glyph rides inline on the date-and-size row
instead. The document view drops the status bar under the preview entirely;
when reading is automatic and nothing needs review, the content starts straight
with the document's fields.
## [1.28.21] — 2026-07-10 — Sleep history heals itself; plateau context; sync diagnostics
Three follow-ups to the recent sleep fix, plus better Google Health diagnostics.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.21
version: 1.28.22
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+7 -9
View File
@@ -345,10 +345,6 @@ test.describe("document vault — AI assist + content search", () => {
await expect(read).toBeVisible();
await expect(read).toHaveText(/Read with AI/);
// The status pill starts at the calm "not searchable yet".
const status = sheet.locator('[data-slot="content-search-status"]');
await expect(status).toHaveAttribute("data-state", "none");
await read.click();
await expect(page.getByText("The AI read your document.")).toBeVisible();
expect(indexCalls).toBe(1);
@@ -366,11 +362,13 @@ test.describe("document vault — AI assist + content search", () => {
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The detail status pill reflects the AI-read source — asserted on every
// project (this is the provenance contract; it holds on desktop + mobile).
const status = sheet.locator('[data-slot="content-search-status"]');
await expect(status).toHaveAttribute("data-state", "ai-read");
await expect(status).toHaveText(/Read by AI/);
// v1.28.22 — the provenance marker moved out of the sheet onto the vault
// card's meta row (an inline glyph beside date · size). The sheet carries
// no status chrome; the CARD is the provenance contract now.
const marker = page.locator(
`[data-document-id="${CONTENT_DOC_ID}"] [data-slot="document-card-ai-read"]`,
);
await expect(marker.first()).toBeAttached();
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
+8 -6
View File
@@ -252,19 +252,21 @@ test.describe("automatic AI reading — vault per-document contract", () => {
page,
}) => {
// Auto-read reads every upload with no per-document tap, so the manual
// Read / Suggest actions are redundant and collapse away. The section
// header and the searchable status pill stay.
// Read / Suggest actions are redundant and the WHOLE block collapses
// (v1.28.22 — no status chrome; provenance lives on the vault card).
await mockDocumentCapability(page, { egress: "external", autoRead: true });
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const section = sheet.locator('[data-slot="document-ai-section"]');
await expect(section).toBeVisible();
// v1.28.22 — with auto-read ON and nothing pending the WHOLE block
// collapses (no status chrome; provenance lives on the vault card), so the
// sheet content starts straight with the document fields.
await expect(sheet.locator("form")).toBeVisible();
await expect(
section.locator('[data-slot="content-search-status"]'),
).toBeVisible();
sheet.locator('[data-slot="document-ai-section"]'),
).toHaveCount(0);
await expect(sheet.locator('[data-slot="document-read-ai"]')).toHaveCount(
0,
);
-3
View File
@@ -8325,9 +8325,6 @@
},
"ai": {
"statusAiRead": "Von KI gelesen",
"statusSearchable": "Durchsuchbar",
"statusIndexing": "Wird durchsuchbar gemacht…",
"statusNotYet": "Noch nicht durchsuchbar",
"readDone": "Die KI hat dein Dokument gelesen.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
-3
View File
@@ -8325,9 +8325,6 @@
},
"ai": {
"statusAiRead": "Read by AI",
"statusSearchable": "Searchable",
"statusIndexing": "Making searchable…",
"statusNotYet": "Not searchable yet",
"readDone": "The AI read your document.",
"read": "Read with AI",
"reread": "Read again",
-3
View File
@@ -8325,9 +8325,6 @@
},
"ai": {
"statusAiRead": "Leído por IA",
"statusSearchable": "Buscable",
"statusIndexing": "Haciéndolo buscable…",
"statusNotYet": "Aún no buscable",
"readDone": "La IA leyó tu documento.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
-3
View File
@@ -8325,9 +8325,6 @@
},
"ai": {
"statusAiRead": "Lu par l’IA",
"statusSearchable": "Consultable",
"statusIndexing": "Rendu consultable…",
"statusNotYet": "Pas encore consultable",
"readDone": "L’IA a lu votre document.",
"read": "Lire avec l’IA",
"reread": "Relire",
-3
View File
@@ -8325,9 +8325,6 @@
},
"ai": {
"statusAiRead": "Letto dall’IA",
"statusSearchable": "Ricercabile",
"statusIndexing": "Rendo ricercabile…",
"statusNotYet": "Non ancora ricercabile",
"readDone": "L’IA ha letto il tuo documento.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
-3
View File
@@ -8325,9 +8325,6 @@
},
"ai": {
"statusAiRead": "Odczytane przez AI",
"statusSearchable": "Wyszukiwalne",
"statusIndexing": "Udostępnianie do wyszukiwania…",
"statusNotYet": "Jeszcze nie do wyszukania",
"readDone": "AI odczytało Twój dokument.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.21",
"version": "1.28.22",
"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.21";
/* @sw-version-fallback */ "v1.28.22";
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,7 +6,6 @@ import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents"
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentSearchStatus,
DocumentSummaryPanel,
} from "../document-ai-panels";
@@ -172,48 +171,3 @@ describe("<DocumentSummaryPanel>", () => {
expect(html).toContain("read the document");
});
});
describe("<ContentSearchStatus>", () => {
it("shows a calm not-searchable-yet pill before the auto-index lands", () => {
const html = render(
<ContentSearchStatus
hasContentIndex={false}
source={null}
isPending={false}
/>,
);
expect(html).toContain('data-slot="content-search-status"');
expect(html).toContain('data-state="none"');
expect(html).toContain("Not searchable yet");
// A status, never a chore button.
expect(html).not.toContain("<button");
});
it("highlights an AI-read document distinctly from a locally-indexed one", () => {
const aiRead = render(
<ContentSearchStatus hasContentIndex source="vision" isPending={false} />,
);
expect(aiRead).toContain('data-state="ai-read"');
expect(aiRead).toContain("Read by AI");
expect(aiRead).toContain("text-primary");
const local = render(
<ContentSearchStatus
hasContentIndex
source="local-pdf"
isPending={false}
/>,
);
expect(local).toContain('data-state="searchable"');
expect(local).toContain("Searchable");
expect(local).not.toContain("Read by AI");
});
it("reflects a running read as an indexing state", () => {
const html = render(
<ContentSearchStatus hasContentIndex={false} source={null} isPending />,
);
expect(html).toContain('data-state="indexing"');
expect(html).toContain("Making searchable…");
});
});
@@ -80,9 +80,9 @@ describe("<DocumentAiSection>", () => {
);
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");
// v1.28.22 — no status pill any more; the re-read label alone carries the
// provenance in this block (the marker lives on the vault card).
expect(html).not.toContain('data-state="ai-read"');
});
it("shows the calm settings pointer (no 422 actions) when no provider is available", () => {
@@ -98,8 +98,9 @@ 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"');
// v1.28.22 — the status pill is gone entirely (provenance lives on the
// vault card's meta row); only the calm hint renders here.
expect(html).not.toContain('data-slot="content-search-status"');
});
it("keeps a locally-indexed document honestly searchable without a provider", () => {
@@ -112,8 +113,10 @@ describe("<DocumentAiSection>", () => {
contentIndexSource: "local-pdf",
}),
);
expect(html).toContain('data-state="searchable"');
expect(html).toContain("Searchable");
// v1.28.22 — the searchable pill is gone; only the calm provider hint
// renders for a locally-indexed document without a provider.
expect(html).not.toContain('data-state="searchable"');
expect(html).toContain('data-slot="assist-unavailable"');
expect(html).not.toContain("Read by AI");
});
@@ -163,11 +166,10 @@ describe("<DocumentAiSection>", () => {
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.
expect(html).toContain('data-slot="document-ai-section"');
expect(html).toContain('data-slot="content-search-status"');
// v1.28.22 — with auto-read ON and nothing pending the WHOLE block
// collapses: no chrome, no status pill; the sheet content starts straight
// with the document fields under the preview.
expect(html).toBe("");
});
it("shows the manual AI action row when auto-read is off", () => {
@@ -12,12 +12,8 @@
* user applies it (and the title lands in the editable field, not on disk).
* - `DocumentSummaryPanel` — the transient summary / extracted-text panel with
* the persistent "not saved · not a diagnosis" note.
* - `ContentSearchStatus` — the per-document searchable pill reflecting the
* auto-index: "Read by AI" (provider read the original), "Searchable"
* (locally indexed), "Making searchable…" (a read is running), or the calm
* "not searchable yet". A STATUS, never a chore button.
*/
import { Loader2, ScanSearch, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Check, FileText, X } from "lucide-react";
import Link from "next/link";
@@ -26,8 +22,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
@@ -270,77 +264,3 @@ export function DocumentSummaryPanel({
</div>
);
}
/**
* The per-document searchable status pill. Reflects the auto-index (indexing is
* automatic on upload, so this is a state, never a to-do): a document is "Read
* by AI" when a provider read the original, "Searchable" when it is only locally
* indexed, "Making searchable…" while a read runs, and a calm "not searchable
* yet" otherwise. The AI-read pill is highlighted (primary) — that read is the
* richer one and worth surfacing.
*/
export function ContentSearchStatus({
hasContentIndex,
source,
isPending,
}: {
hasContentIndex: boolean;
source: DocumentContentIndexSourceValue | null;
/** A read/index is running right now. */
isPending: boolean;
}) {
const { t } = useTranslations();
if (isPending) {
return (
<span
data-slot="content-search-status"
data-state="indexing"
className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
>
<Loader2
className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.ai.statusIndexing")}
</span>
);
}
if (!hasContentIndex) {
return (
<span
data-slot="content-search-status"
data-state="none"
className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
>
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{t("documents.ai.statusNotYet")}
</span>
);
}
if (isAiReadSource(source)) {
return (
<span
data-slot="content-search-status"
data-state="ai-read"
className="bg-primary/10 text-primary inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
>
<Sparkles className="size-3.5 shrink-0" aria-hidden />
{t("documents.ai.statusAiRead")}
</span>
);
}
return (
<span
data-slot="content-search-status"
data-state="searchable"
className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
>
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{t("documents.ai.statusSearchable")}
</span>
);
}
@@ -7,8 +7,9 @@
* are pinned by static-render tests.
*
* Indexing is AUTOMATIC on upload, so this block never nags a "please index"
* chore. It shows the document's searchable status (auto-indexed) as a pill, and
* offers AI reading as a capability the user reaches for when they want more:
* chore. It carries NO header or status chrome (the AI-read provenance marker
* lives on the vault card's meta row) and offers AI reading as a capability the
* user reaches for when they want more:
*
* - "Read with AI" (prominent) runs a provider read of the document — the
* richer pass that also refreshes the searchable index. Labelled "Read
@@ -16,11 +17,13 @@
* - "Suggest details" drafts a title / type / date to review before saving.
* - "Summarise" / "Show text" surface a transient, session-only read-out.
*
* With auto-read ON and nothing pending to review the block collapses entirely —
* the sheet content starts straight with the document fields under the preview.
*
* Presentational: the sheet owns the mutations + capability probe and passes
* their state + handlers down. When `aiEnabled` is false the actions collapse to
* the calm "set up an AI provider" pointer (never an error) — the searchable
* status pill stays, honestly reflecting any local auto-index, and the manual
* form below stays fully usable.
* the calm "set up an AI provider" pointer (never an error) and the manual form
* below stays fully usable.
*/
import { FileText, ScanText, WandSparkles } from "lucide-react";
@@ -37,7 +40,6 @@ import {
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentSearchStatus,
DocumentSummaryPanel,
} from "./document-ai-panels";
import type { DocumentDescribeResult } from "./use-document-assist";
@@ -113,23 +115,26 @@ export function DocumentAiSection({
? t("documents.ai.reread")
: t("documents.ai.read");
// v1.28.22 — the block carries NO header/status chrome any more (the
// provenance pill moved to the vault card's meta row; auto-read makes an
// in-sheet "read by AI" banner redundant). With auto-read ON and nothing
// pending to review, nothing below would render — collapse the whole box so
// the content under the preview starts straight with the fields.
if (
aiEnabled &&
autoReadEnabled &&
!suggestErrorKey &&
!suggestion &&
!summaryOutput
) {
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"
>
{/* 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}
isPending={indexPending}
/>
</div>
{aiEnabled ? (
<>
{!autoReadEnabled ? (
+15 -15
View File
@@ -133,22 +133,22 @@ export function DocumentCard({
)}
<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")}
<p className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
<span className="truncate">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</span>
) : null}
{aiRead ? (
// AI-read marker rides INLINE on the meta row — a bare glyph,
// no pill line (the tag ate a whole row in the dense grid).
<Sparkles
data-slot="document-card-ai-read"
role="img"
aria-label={t("documents.ai.statusAiRead")}
className="text-primary size-3 shrink-0"
/>
) : null}
</p>
</div>
<Checkbox
checked={selected}
+9 -1
View File
@@ -543,7 +543,15 @@ export async function upsertGoogleHealthMeasurements(
// of the cycle. The incremental path keeps the inline per-day hook here.
if (opts.deferRollup) {
const tracker = rollupDeferStorage.getStore();
if (tracker) tracker.keys.push(...touched);
// Plain loop, never `push(...touched)`: `touched` carries ONE entry PER
// READING, and a full-history batch on a sample-dense account (a
// multi-year heart-rate series is six figures of rows) blows the call
// stack when spread into a single call — the engine caps argument counts.
// The RangeError aborted the whole metrics collection on exactly the
// accounts with the most data.
if (tracker) {
for (const t of touched) tracker.keys.push(t);
}
return { imported, touched };
}