mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(modules): unify Vorsorge, Illness and Labs with the medication pattern
- add a wrench beside each module's add button linking to the module's own settings page; rename every add button to a plain "hinzufügen" - give each module a settings page: view (cards/list) + manual reorder; Labs also gets a sort-order choice (by last reading) and biomarker CRUD/reorder - collapse the Illness "abgeschlossen" list by default - rebuild the Vorsorge card on the medication card's building blocks: a green "Jetzt messen" action, the interval as a chip beside the name, and an in-interval progress signal; drop the kebab "anpassen" and stray actions - persist per-module view/order/sort client-side; add reverse-coverage-clean strings across all six locales
This commit is contained in:
1 parent
ae3091df9e
commit
267dcad13d
22 files changed
+1834
-273
No files matched your search
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Illness module's own settings page body.
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user reorder conditions/episodes + pick the card-vs-list page view. Order
|
||||
* + view persist client-side via `useModuleListPrefs("illness")`.
|
||||
*/
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
type ReorderItem,
|
||||
} from "@/components/module-list/module-order-editor";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { useIllnessEpisodes } from "./use-illness";
|
||||
|
||||
export function IllnessSettings() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const { prefs, setView, setOrder } = useModuleListPrefs("illness");
|
||||
|
||||
const ordered = applyOrder(episodes ?? [], prefs.order, (e) => e.id);
|
||||
const reorderItems: ReorderItem[] = ordered.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.label,
|
||||
secondary: `${t(`illness.type.${e.type}`)} · ${
|
||||
e.resolvedAt
|
||||
? t("illness.status.recovered")
|
||||
: e.lifecycle === "CHRONIC_ONGOING"
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
} · ${fmt.dateShort(new Date(e.onsetAt))}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.viewHeading")}
|
||||
</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Stethoscope } from "lucide-react";
|
||||
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -32,6 +32,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
@@ -58,6 +59,7 @@ interface EpisodeCardProps {
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
view: "cards" | "list";
|
||||
}
|
||||
|
||||
function EpisodeCard({
|
||||
@@ -67,6 +69,7 @@ function EpisodeCard({
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
view,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -74,6 +77,58 @@ function EpisodeCard({
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
const flareCount = flares?.length ?? 0;
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list row: a single divided line per episode
|
||||
// (label + status chips + onset; tap navigates to the detail surface).
|
||||
if (view === "list") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Link
|
||||
href={`/illness/${episode.id}`}
|
||||
className="block truncate text-sm font-medium hover:underline focus-visible:underline"
|
||||
>
|
||||
{episode.label}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<Badge variant="secondary">
|
||||
{t(`illness.type.${episode.type}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
? isChronic
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{fmt.dateShort(new Date(episode.onsetAt))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3 md:gap-3">
|
||||
<CardHeader className="pb-0">
|
||||
@@ -170,6 +225,8 @@ function EpisodeGroup({
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
view,
|
||||
collapsible = false,
|
||||
}: {
|
||||
title: string;
|
||||
parents: IllnessEpisodeDTO[];
|
||||
@@ -178,26 +235,71 @@ function EpisodeGroup({
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
view: "cards" | "list";
|
||||
/**
|
||||
* v1.18.6 (MOD-05) — the resolved group collapses by default behind a
|
||||
* disclosure trigger so a long recovered history never pushes the active
|
||||
* conditions below the fold.
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
}) {
|
||||
// Hooks must run unconditionally; bail on the empty render afterwards.
|
||||
const [open, setOpen] = useState(false);
|
||||
if (parents.length === 0) return null;
|
||||
|
||||
const grid = (
|
||||
<div
|
||||
className={cn(
|
||||
view === "list" ? "space-y-2" : "grid gap-4 sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{parents.map((episode) => (
|
||||
<EpisodeCard
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
flares={childrenByParent.get(episode.id)}
|
||||
resolving={resolving}
|
||||
onLogDay={onLogDay}
|
||||
onEdit={onEdit}
|
||||
onResolve={onResolve}
|
||||
view={view}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium uppercase tracking-wide transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform motion-reduce:transition-none",
|
||||
open ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
<span className="text-muted-foreground/70 normal-case">
|
||||
({parents.length})
|
||||
</span>
|
||||
</button>
|
||||
{open ? grid : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{parents.map((episode) => (
|
||||
<EpisodeCard
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
flares={childrenByParent.get(episode.id)}
|
||||
resolving={resolving}
|
||||
onLogDay={onLogDay}
|
||||
onEdit={onEdit}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{grid}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -206,6 +308,7 @@ export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
const { prefs } = useModuleListPrefs("illness");
|
||||
|
||||
const today = todayLocal();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
@@ -248,8 +351,14 @@ export function IllnessView() {
|
||||
}
|
||||
}
|
||||
|
||||
return { activeParents, resolvedParents, childrenByParent };
|
||||
}, [episodes]);
|
||||
return {
|
||||
// v1.18.6 (MOD-03) — honour the user's persisted manual order within
|
||||
// each status group (ids absent from the order sort after the block).
|
||||
activeParents: applyOrder(activeParents, prefs.order, (e) => e.id),
|
||||
resolvedParents: applyOrder(resolvedParents, prefs.order, (e) => e.id),
|
||||
childrenByParent,
|
||||
};
|
||||
}, [episodes, prefs.order]);
|
||||
|
||||
const hasEpisodes = (episodes?.length ?? 0) > 0;
|
||||
|
||||
@@ -269,17 +378,37 @@ export function IllnessView() {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="illness" />
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
|
||||
the one-time acknowledgment now lives at onboarding and the legal
|
||||
text stays reachable on the public privacy page. */}
|
||||
|
||||
{isLoading ? (
|
||||
<div className={cn("grid gap-4 sm:grid-cols-2")}>
|
||||
@@ -297,6 +426,7 @@ export function IllnessView() {
|
||||
parents={grouped.activeParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
view={prefs.view}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
@@ -306,6 +436,8 @@ export function IllnessView() {
|
||||
parents={grouped.resolvedParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
view={prefs.view}
|
||||
collapsible
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
|
||||
Reference in new issue
Block a user