mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
+17
-1
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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