mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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"
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user