mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an ordinary VORSORGE reminder keyed on the dose's primary antigen; the published origin enum and reminder DTO are unchanged - idempotent by antigen — a second confirmation re-anchors the existing reminder rather than minting a second (WR-9 seen red on the reminder count, reverted by content hash) - rolling day cadence so logging the next dose re-anchors the due date through the Phase 1 satisfy matcher; the whole loop proven through real routes - booster prompt copy in six locales, plural-aware
This commit is contained in:
1 parent
f540298521
commit
b5ce44628a
14 files changed
+849
No files matched your search
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Rung 2, at the point of offer: after a dose whose catalogue entry carries a
|
||||
* booster interval, a prefilled — never imposed — prompt to plan the next one.
|
||||
*
|
||||
* The interval and the label are prefilled from the catalogue and the person's
|
||||
* own record, and both are editable before they confirm; declining is one tap
|
||||
* and leaves no state behind. Nothing here computes what a person is due: the
|
||||
* catalogue's typical interval seeds a reminder the person owns, and that is
|
||||
* the whole of the "recommendation". Confirming writes an ordinary Vorsorge
|
||||
* reminder that keys on the dose's antigen; the satisfy matcher usually
|
||||
* re-anchored one already during the create, so the common path simply confirms
|
||||
* the new due date.
|
||||
*
|
||||
* The prompt never blocks: the create has already completed and the sheet has
|
||||
* already closed by the time this appears.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
|
||||
import { useBoosterMint, type Vaccination } from "./use-vaccinations";
|
||||
|
||||
/**
|
||||
* Whether a freshly created dose should raise the offer at all. Only a
|
||||
* catalogue entry that carries a booster interval does; a free-text row and a
|
||||
* one-off vaccine never prompt.
|
||||
*/
|
||||
export function boosterOfferFor(
|
||||
record: Vaccination,
|
||||
): { intervalMonths: number; catalogName: string } | null {
|
||||
const entry = resolveCatalogEntry(record.antigenSlug);
|
||||
if (!entry || entry.boosterIntervalMonths === null) return null;
|
||||
return {
|
||||
intervalMonths: entry.boosterIntervalMonths,
|
||||
catalogName: entry.slug,
|
||||
};
|
||||
}
|
||||
|
||||
const MONTH_OPTIONS = [6, 12, 24, 36, 60, 120] as const;
|
||||
|
||||
export function BoosterMintPrompt({
|
||||
record,
|
||||
onClose,
|
||||
}: {
|
||||
/** The just-created dose, or null when there is nothing to offer. */
|
||||
record: Vaccination | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const mint = useBoosterMint();
|
||||
const offer = record ? boosterOfferFor(record) : null;
|
||||
|
||||
// The default label is the catalogue name plus the booster word, composed in
|
||||
// the person's own locale — the server never resolves an i18n key.
|
||||
const defaultLabel =
|
||||
record && offer
|
||||
? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}`
|
||||
: "";
|
||||
const [label, setLabel] = useState(defaultLabel);
|
||||
const [months, setMonths] = useState<number>(offer?.intervalMonths ?? 12);
|
||||
|
||||
// The prompt is only mounted when there is an offer; guard for the type.
|
||||
if (!record || !offer) return null;
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
await mint.mutateAsync({
|
||||
id: record.id,
|
||||
body: { intervalMonths: months, label: label.trim() || defaultLabel },
|
||||
});
|
||||
toast.success(t("vaccinations.booster.planned"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("vaccinations.booster.failed"),
|
||||
);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const intervalOptions = MONTH_OPTIONS.includes(
|
||||
offer.intervalMonths as (typeof MONTH_OPTIONS)[number],
|
||||
)
|
||||
? MONTH_OPTIONS
|
||||
: ([offer.intervalMonths, ...MONTH_OPTIONS] as readonly number[]);
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={(open) => (!open ? onClose() : undefined)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("vaccinations.booster.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("vaccinations.booster.body")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FieldGroup
|
||||
htmlFor="booster-interval"
|
||||
label={t("vaccinations.booster.interval")}
|
||||
>
|
||||
<NativeSelect
|
||||
id="booster-interval"
|
||||
value={String(months)}
|
||||
onChange={(event) => setMonths(Number(event.target.value))}
|
||||
>
|
||||
{intervalOptions.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value % 12 === 0
|
||||
? tCount("vaccinations.booster.everyYears", value / 12, {
|
||||
count: value / 12,
|
||||
})
|
||||
: tCount("vaccinations.booster.everyMonths", value, {
|
||||
count: value,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="booster-label"
|
||||
label={t("vaccinations.booster.label")}
|
||||
>
|
||||
<Input
|
||||
id="booster-label"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-slot="vaccination-booster-decline">
|
||||
{t("vaccinations.booster.decline")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-slot="vaccination-booster-confirm"
|
||||
disabled={mint.isPending}
|
||||
onClick={(event) => {
|
||||
// Keep the dialog until the write settles, then close it.
|
||||
event.preventDefault();
|
||||
void confirm();
|
||||
}}
|
||||
>
|
||||
{t("vaccinations.booster.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -115,3 +115,34 @@ export function useVaccinationMutations() {
|
||||
|
||||
return { create, update, remove, restore };
|
||||
}
|
||||
|
||||
/** What the booster confirm carries — the user's accepted-or-edited values. */
|
||||
export interface BoosterMintBody {
|
||||
intervalMonths: number;
|
||||
label: string;
|
||||
notifyHour?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint (or re-anchor) the booster reminder a dose suggests.
|
||||
*
|
||||
* The response is an ordinary Vorsorge reminder; the write fans out through the
|
||||
* dose dependent-keys and the reminder reads so the new checkup appears on
|
||||
* `/checkups` the instant it is confirmed.
|
||||
*/
|
||||
export function useBoosterMint() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.vaccinationBooster(),
|
||||
mutationFn: ({ id, body }: { id: string; body: BoosterMintBody }) =>
|
||||
apiPost<{ reminder: unknown; minted: boolean }>(
|
||||
`${BASE}/${id}/booster`,
|
||||
body,
|
||||
),
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, vaccinationDependentKeys),
|
||||
invalidateReminderReads(qc),
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { VaccinationList } from "./vaccination-list";
|
||||
import { VaccinationSheet } from "./vaccination-sheet";
|
||||
import { BoosterMintPrompt, boosterOfferFor } from "./booster-mint-prompt";
|
||||
import { useVaccinations, type Vaccination } from "./use-vaccinations";
|
||||
|
||||
export function VaccinationsView() {
|
||||
@@ -34,6 +35,9 @@ export function VaccinationsView() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Vaccination | null>(null);
|
||||
const [session, setSession] = useState(0);
|
||||
// The just-created dose whose catalogue entry carries a booster interval;
|
||||
// drives the one-time mint offer, cleared on confirm or decline.
|
||||
const [boosterFor, setBoosterFor] = useState<Vaccination | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
@@ -72,6 +76,17 @@ export function VaccinationsView() {
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
vaccination={editing}
|
||||
onCreated={(created) => {
|
||||
// Offer the booster only when the catalogue entry carries an
|
||||
// interval; a free-text or one-off dose never prompts.
|
||||
if (boosterOfferFor(created)) setBoosterFor(created);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BoosterMintPrompt
|
||||
key={boosterFor?.id ?? "none"}
|
||||
record={boosterFor}
|
||||
onClose={() => setBoosterFor(null)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
Reference in new issue
Block a user