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

@@ -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>;
};