feat: Datumseingabe TT.MM.JJJJ (App) + Erinnerung an Folgeimpfung

Datum (found.md, iOS-Feedback): neue DateField-Komponente mit Ziffern-
tastatur und automatisch eingefügten Punkten (TT.MM.JJJJ) für Geburtstag,
Adoptionstag, Gewicht, Tierarzt und Impfungen. Parser akzeptiert weiter
"-" und "/" sowie einstellige Tage/Monate. Wheel-Picker bleibt in
PENDING-NATIVE-BUILD.md (natives Modul).

Impf-Erinnerung (found.md, iOS-Feedback): täglicher Cron
/api/cron/vaccine-reminders erinnert einmalig 30 Tage vor
Vaccine.nextDueDate per Mitteilung (VACCINE_DUE) + Push mit Impfname und
Datum. Schema: NotificationType.VACCINE_DUE, Notification.vaccineId
(Cascade), Vaccine.reminderSentAt (Claim gegen Doppelversand, wird bei
Fehler wieder freigegeben). Web + App zeigen die Mitteilung an, Tippen
öffnet die Gesundheitsseite. App: Schnellauswahl +1/+3 Jahre für den
Folgetermin plus Hinweis auf die Erinnerung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 12:42:20 +02:00
1 parent 7169840000
commit c74bbffccb
20 files changed
+461 -44

No files matched your search

+2 -1
View File
@@ -482,7 +482,8 @@
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
"typePetSuggestion": "könnte dir gefallen",
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!"
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!",
"typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren."
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
+2 -1
View File
@@ -482,7 +482,8 @@
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
"typePetSuggestion": "might be a good match",
"typeReferralJoined": "joined PawFeed through your invite",
"typeReferralRankUp": "You reached a new referral rank. Take a look!"
"typeReferralRankUp": "You reached a new referral rank. Take a look!",
"typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment."
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
+13 -1
View File
@@ -9,6 +9,7 @@ import { useActivePet } from "../../../src/context/ActivePetContext";
import { useTheme } from "../../../src/context/ThemeContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
import { isoToDisplayDate } from "../../../src/lib/date-format";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { NotificationItem } from "../../../src/api/types";
@@ -37,6 +38,11 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
// independently of the row's own tap target, ported from web's
// notifications/page.tsx (name as a <Link>, rest as plain text).
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
if (item.type === "VACCINE_DUE") {
if (!item.vaccine?.nextDueDate) return t("notifications.vaccineDueGeneric");
const iso = new Date(item.vaccine.nextDueDate).toISOString().slice(0, 10);
return t("notifications.vaccineDue", { name: item.vaccine.name, date: isoToDisplayDate(iso) });
}
const selfKey = SELF_MESSAGE_KEYS[item.type];
if (selfKey) return t(selfKey);
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
@@ -44,6 +50,11 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
return item.type;
}
const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "🎂",
VACCINE_DUE: "💉",
};
/**
* Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
@@ -64,6 +75,7 @@ function getNotificationRoute(item: NotificationItem): string | null {
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
case "REACTION":
case "COMMENT":
@@ -160,7 +172,7 @@ export default function NotificationsScreen() {
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
) : (
<View style={styles.selfIcon}>
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
<Text style={styles.selfIconText}>{SELF_ICONS[item.type] ?? "🏠"}</Text>
</View>
)}
<View style={styles.rowText}>
+3 -2
View File
@@ -8,6 +8,7 @@ import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
import { TextField } from "../../../../src/components/TextField";
import { DateField } from "../../../../src/components/DateField";
import { Button } from "../../../../src/components/Button";
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
@@ -156,10 +157,10 @@ export default function EditPetScreen() {
/>
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
{error ? <Text style={styles.error}>{error}</Text> : null}
+25
View File
@@ -0,0 +1,25 @@
import type { TextInputProps } from "react-native";
import { TextField } from "./TextField";
import { formatDateInput } from "../lib/date-format";
const DISPLAY_DATE_LENGTH = 10; // DD.MM.YYYY
type DateFieldProps = Omit<TextInputProps, "onChangeText" | "keyboardType" | "maxLength"> & {
onChangeText: (value: string) => void;
};
/**
* Text date input with a DD.MM.YYYY mask: number pad only, the dots are
* inserted automatically (iOS' number pad has no "." key). A native wheel
* picker needs @react-native-community/datetimepicker → PENDING-NATIVE-BUILD.md.
*/
export function DateField({ onChangeText, ...props }: DateFieldProps) {
return (
<TextField
{...props}
keyboardType="number-pad"
maxLength={DISPLAY_DATE_LENGTH}
onChangeText={(text) => onChangeText(formatDateInput(text))}
/>
);
}
@@ -6,10 +6,14 @@ import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { TextField } from "../TextField";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
/** Übliche Auffrisch-Intervalle (z. B. Tollwut 1–3 Jahre) als Schnellauswahl für den Folgetermin. */
const NEXT_DUE_PRESET_YEARS = [1, 3];
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
export function VaccineSection({ petId }: { petId: string }) {
@@ -80,8 +84,24 @@ export function VaccineSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
<DateField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
<DateField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
<View style={styles.presetRow}>
{NEXT_DUE_PRESET_YEARS.map((years) => {
const suggestion = addYearsToDisplayDate(dateGiven, years);
return (
<Pressable
key={years}
style={[styles.preset, !suggestion && styles.presetDisabled]}
disabled={!suggestion}
onPress={() => suggestion && setNextDueDate(suggestion)}
>
<Text style={styles.presetText}>{t("health.vaccineNextDuePreset", { count: years })}</Text>
</Pressable>
);
})}
</View>
<Text style={styles.hint}>{t("health.vaccineReminderHint")}</Text>
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
<Button
title={addMutation.isPending ? "…" : t("common.save")}
@@ -134,5 +154,10 @@ function makeStyles(colors: ColorTokens) {
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
overdue: { color: colors.alertRed },
presetRow: { flexDirection: "row", gap: 8 },
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
presetDisabled: { opacity: 0.4 },
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
});
}
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { TextField } from "../TextField";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
@@ -72,7 +73,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { TextField } from "../TextField";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
@@ -65,7 +66,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
<Button
+10 -5
View File
@@ -66,7 +66,7 @@
"breedLabel": "Rasse",
"birthdayLabel": "Geburtstag (optional)",
"adoptedAtLabel": "Adoptionstag (optional)",
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben oder leer lassen.",
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben oder leer lassen.",
"deleteConfirmTitle": "Tier wirklich löschen?",
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
"deleteConfirmMessageFallback": "Dieses Tier",
@@ -139,19 +139,22 @@
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
"revokeLink": "Widerrufen",
"weightTitle": "Gewicht",
"weightDatePlaceholder": "TT-MM-JJJJ",
"weightDatePlaceholder": "TT.MM.JJJJ",
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
"notesPlaceholder": "Notizen (optional)",
"weightEmpty": "Noch keine Gewichtseinträge",
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben.",
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
"vetTitle": "Tierarzt",
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
"vetEmpty": "Noch keine Tierarztbesuche",
"vaccineTitle": "Impfungen",
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
"vaccineGivenPlaceholder": "Gegeben am (TT-MM-JJJJ)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT-MM-JJJJ)",
"vaccineGivenPlaceholder": "Gegeben am (TT.MM.JJJJ)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT.MM.JJJJ)",
"vaccineNextDuePreset_one": "+{{count}} Jahr",
"vaccineNextDuePreset_other": "+{{count}} Jahre",
"vaccineReminderHint": "Mit Folgetermin erinnern wir dich 30 Tage vorher.",
"vaccineEmpty": "Noch keine Impfungen eingetragen",
"vaccineGiven": "Gegeben {{date}}",
"vaccineDue": "Fällig {{date}}",
@@ -190,6 +193,8 @@
"moreActions": "Weitere Aktionen"
},
"notifications": {
"vaccineDue": "💉 Die Impfung „{{name}}“ ist am {{date}} fällig – Zeit, einen Termin zu vereinbaren.",
"vaccineDueGeneric": "💉 Eine Impfung wird bald fällig.",
"follow": "folgt dir jetzt.",
"reaction": "hat deinen Beitrag mit einer Pfote versehen.",
"comment": "hat deinen Beitrag kommentiert.",
+10 -5
View File
@@ -66,7 +66,7 @@
"breedLabel": "Breed",
"birthdayLabel": "Birthday (optional)",
"adoptedAtLabel": "Adoption date (optional)",
"invalidDate": "Please enter a valid date as DD-MM-YYYY, or leave it empty.",
"invalidDate": "Please enter a valid date as DD.MM.YYYY, or leave it empty.",
"deleteConfirmTitle": "Delete this pet?",
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
"deleteConfirmMessageFallback": "This pet",
@@ -139,19 +139,22 @@
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
"revokeLink": "Revoke",
"weightTitle": "Weight",
"weightDatePlaceholder": "DD-MM-YYYY",
"weightDatePlaceholder": "DD.MM.YYYY",
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
"notesPlaceholder": "Notes (optional)",
"weightEmpty": "No weight entries yet",
"invalidDate": "Please enter a valid date in DD-MM-YYYY format.",
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
"vetTitle": "Vet",
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
"vetNamePlaceholder": "Vet / clinic (optional)",
"vetEmpty": "No vet visits yet",
"vaccineTitle": "Vaccinations",
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
"vaccineGivenPlaceholder": "Given on (DD-MM-YYYY)",
"vaccineNextDuePlaceholder": "Next due (optional, DD-MM-YYYY)",
"vaccineGivenPlaceholder": "Given on (DD.MM.YYYY)",
"vaccineNextDuePlaceholder": "Next due (optional, DD.MM.YYYY)",
"vaccineNextDuePreset_one": "+{{count}} year",
"vaccineNextDuePreset_other": "+{{count}} years",
"vaccineReminderHint": "With a next due date, we'll remind you 30 days ahead.",
"vaccineEmpty": "No vaccinations recorded yet",
"vaccineGiven": "Given {{date}}",
"vaccineDue": "Due {{date}}",
@@ -190,6 +193,8 @@
"moreActions": "More actions"
},
"notifications": {
"vaccineDue": "💉 The \"{{name}}\" vaccination is due on {{date}} – time to book an appointment.",
"vaccineDueGeneric": "💉 A vaccination is due soon.",
"follow": "is now following you.",
"reaction": "pawed your post.",
"comment": "commented on your post.",
+40 -10
View File
@@ -1,25 +1,30 @@
/**
* The health forms take a free-text date field validated against the
* backend's strict `YYYY-MM-DD` regex (src/trpc/routers/health.ts). Typing
* dates in that order isn't the German convention (DD-MM-YYYY) — found.md:
* users typing the natural order got a validation error the mutations never
* surfaced (no onError handler), which looked exactly like "data isn't being
* saved". Both problems share one fix: accept DD-MM-YYYY in the UI, convert
* to ISO right before the mutation call, and reject anything that doesn't
* parse as a real calendar date instead of silently sending garbage.
* dates in that order isn't the German convention — found.md: users typing
* the natural order got a validation error the mutations never surfaced (no
* onError handler), which looked exactly like "data isn't being saved". Fix:
* accept DD.MM.YYYY in the UI, convert to ISO right before the mutation call,
* and reject anything that doesn't parse as a real calendar date instead of
* silently sending garbage.
*
* Display uses "." (found.md Neu7: "-" is unusual in German). Parsing still
* accepts "-" and "/" and one-digit day/month so older habits keep working.
*/
const DISPLAY_PATTERN = /^(\d{2})-(\d{2})-(\d{4})$/;
const DISPLAY_SEPARATOR = ".";
const DISPLAY_PATTERN = /^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/;
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const MAX_DATE_DIGITS = 8;
export function isoToDisplayDate(iso: string): string {
const match = ISO_PATTERN.exec(iso);
if (!match) return iso;
const [, year, month, day] = match;
return `${day}-${month}-${year}`;
return [day, month, year].join(DISPLAY_SEPARATOR);
}
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD-MM-YYYY calendar date. */
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD.MM.YYYY calendar date. */
export function displayDateToIso(display: string): string | null {
const match = DISPLAY_PATTERN.exec(display.trim());
if (!match) return null;
@@ -33,7 +38,32 @@ export function displayDateToIso(display: string): string | null {
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
if (!isRealDate) return null;
return `${yearStr}-${monthStr}-${dayStr}`;
return `${yearStr}-${monthStr.padStart(2, "0")}-${dayStr.padStart(2, "0")}`;
}
/**
* Live mask for typing: keeps digits only and inserts the separators itself
* (so the number pad, which has no "." on iOS, is enough). A separator is only
* added once a following digit exists — otherwise backspace over "04." would
* immediately re-add the dot and the field would get stuck.
*/
export function formatDateInput(text: string): string {
const digits = text.replace(/\D/g, "").slice(0, MAX_DATE_DIGITS);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2)}`;
return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2, 4)}${DISPLAY_SEPARATOR}${digits.slice(4)}`;
}
/**
* Folgetermin-Vorschlag „+1 / +3 Jahre“ für Impfungen. Null bei ungültigem
* Ausgangsdatum. 29.02. + 1 Jahr landet (wie Date selbst) auf dem 01.03.
*/
export function addYearsToDisplayDate(display: string, years: number): string | null {
const iso = displayDateToIso(display);
if (!iso) return null;
const [year, month, day] = iso.split("-").map(Number);
const shifted = new Date(Date.UTC(year + years, month - 1, day));
return isoToDisplayDate(shifted.toISOString().slice(0, 10));
}
export function todayAsDisplayDate(): string {
+15 -8
View File
@@ -758,14 +758,18 @@ model VetVisit {
}
model Vaccine {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
name String @db.VarChar(100)
dateGiven DateTime @db.Date
nextDueDate DateTime? @db.Date
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
name String @db.VarChar(100)
dateGiven DateTime @db.Date
nextDueDate DateTime? @db.Date
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
// Gesetzt vom Cron /api/cron/vaccine-reminders, sobald die Erinnerung
// (30 Tage vor nextDueDate) raus ist — verhindert Doppel-Erinnerungen.
reminderSentAt DateTime?
notifications Notification[]
@@index([petId, dateGiven(sort: Desc)])
}
@@ -837,6 +841,7 @@ enum NotificationType {
PET_SUGGESTION
REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen
REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null)
VACCINE_DUE // Impfung wird in <= 30 Tagen fällig (vaccineId gesetzt, actorPetId = null)
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
@@ -852,6 +857,8 @@ model Notification {
postId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId String?
vaccine Vaccine? @relation(fields: [vaccineId], references: [id], onDelete: Cascade)
vaccineId String?
read Boolean @default(false)
createdAt DateTime @default(now())
+2
View File
@@ -284,6 +284,7 @@ interface MockVaccineClient {
create: MockFn;
delete: MockFn;
deleteMany: MockFn;
updateMany: MockFn;
}
interface MockHealthCardClient {
@@ -654,6 +655,7 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
updateMany: vi.fn(),
},
healthCard: {
findUnique: vi.fn(),
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { addYearsToDisplayDate, displayDateToIso, formatDateInput, isoToDisplayDate } from "../../mobile/src/lib/date-format";
describe("mobile date-format", () => {
it("displays ISO dates with dots", () => {
expect(isoToDisplayDate("2026-10-04")).toBe("04.10.2026");
});
it("parses dotted, dashed and slashed input to ISO", () => {
expect(displayDateToIso("04.10.2026")).toBe("2026-10-04");
expect(displayDateToIso("04-10-2026")).toBe("2026-10-04");
expect(displayDateToIso("04/10/2026")).toBe("2026-10-04");
});
it("pads one-digit day and month", () => {
expect(displayDateToIso("4.1.2026")).toBe("2026-01-04");
});
it("rejects impossible calendar dates and garbage", () => {
expect(displayDateToIso("31.02.2026")).toBeNull();
expect(displayDateToIso("2026-10-04")).toBeNull();
expect(displayDateToIso("abc")).toBeNull();
});
it("masks typed digits as DD.MM.YYYY", () => {
expect(formatDateInput("04")).toBe("04");
expect(formatDateInput("041")).toBe("04.1");
expect(formatDateInput("0410")).toBe("04.10");
expect(formatDateInput("04102026")).toBe("04.10.2026");
expect(formatDateInput("041020269")).toBe("04.10.2026");
});
it("lets backspace remove a trailing separator instead of re-adding it", () => {
expect(formatDateInput("04.")).toBe("04");
});
it("suggests a next due date some years ahead", () => {
expect(addYearsToDisplayDate("04.10.2026", 1)).toBe("04.10.2027");
expect(addYearsToDisplayDate("04.10.2026", 3)).toBe("04.10.2029");
expect(addYearsToDisplayDate("29.02.2028", 1)).toBe("01.03.2029");
expect(addYearsToDisplayDate("", 1)).toBeNull();
});
});
@@ -0,0 +1,131 @@
/**
* /api/cron/vaccine-reminders — erinnert 30 Tage vor dem Impf-Folgetermin.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const { sendExpoPushNotification } = vi.hoisted(() => ({
sendExpoPushNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/push", () => ({ sendExpoPushNotification }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
const mockPrisma = createMockPrisma();
// Getter: vi.mock wird nach oben gehoben, mockPrisma existiert erst beim Zugriff.
vi.mock("@/lib/prisma", () => ({
get prisma() {
return mockPrisma;
},
}));
import { GET } from "@/app/api/cron/vaccine-reminders/route";
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
const request = (auth?: string) =>
new Request("https://pawfeed.org/api/cron/vaccine-reminders", { headers: auth ? { authorization: auth } : {} });
const dueVaccine = {
id: "vac-1",
petId: "pet-1",
name: "Tollwut",
nextDueDate: new Date("2026-11-01T00:00:00Z"),
pet: { name: "Bubi" },
};
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = "test-secret";
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
});
describe("GET /api/cron/vaccine-reminders", () => {
it("rejects requests without the cron secret", async () => {
expect((await GET(request())).status).toBe(401);
expect((await GET(request("Bearer wrong"))).status).toBe(401);
expect(mockPrisma.vaccine.findMany).not.toHaveBeenCalled();
});
it("returns the run summary", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
const res = await GET(request("Bearer test-secret"));
expect(await res.json()).toEqual({ ok: true, sent: 1, failed: 0, more: false });
});
});
describe("sendVaccineReminders", () => {
const now = new Date("2026-10-04T08:00:00Z");
it("only looks at unreminded vaccines due between today and 30 days from now", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendVaccineReminders(mockPrisma as any, now);
const where = mockPrisma.vaccine.findMany.mock.calls[0][0].where;
expect(where.reminderSentAt).toBeNull();
expect(where.nextDueDate.gte.toISOString()).toBe("2026-10-04T00:00:00.000Z");
expect(where.nextDueDate.lte.toISOString()).toBe("2026-11-03T00:00:00.000Z");
});
it("claims the vaccine, creates a VACCINE_DUE notification and pushes the details", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 0, more: false });
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
where: { id: "vac-1", reminderSentAt: null },
data: { reminderSentAt: now },
});
expect(mockPrisma.notification.create).toHaveBeenCalledWith({
data: { recipientPetId: "pet-1", type: "VACCINE_DUE", vaccineId: "vac-1" },
});
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect.objectContaining({
title: "Impfung bald fällig",
body: expect.stringContaining("„Tollwut“ für Bubi ist am 01.11.2026 fällig"),
})
);
});
it("skips a vaccine another run already claimed", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result.sent).toBe(0);
expect(mockPrisma.notification.create).not.toHaveBeenCalled();
expect(sendExpoPushNotification).not.toHaveBeenCalled();
});
it("still counts the reminder when the push fails", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
sendExpoPushNotification.mockRejectedValueOnce(new Error("expo down"));
vi.spyOn(console, "error").mockImplementation(() => {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 0, more: false });
});
it("keeps going after a failing vaccine and reports it", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine, { ...dueVaccine, id: "vac-2" }]);
mockPrisma.notification.create.mockRejectedValueOnce(new Error("db")).mockResolvedValueOnce({});
vi.spyOn(console, "error").mockImplementation(() => {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 1, more: false });
// Fehlgeschlagene Erinnerung wird freigegeben, damit der nächste Lauf sie nachholt.
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
where: { id: "vac-1" },
data: { reminderSentAt: null },
});
});
});
+18 -3
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -13,10 +13,10 @@ import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
import type { PostCardPost } from "@/components/feed/PostCard";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { formatRelativeCompact } from "@/lib/format-relative-time";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE";
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -31,6 +31,7 @@ function notificationIcon(type: NotificationType) {
case "PET_SUGGESTION": return <Sparkles className="h-4 w-4 text-amber-500" />;
case "REFERRAL_JOINED": return <Gift className="h-4 w-4 text-orange-500" />;
case "REFERRAL_RANK_UP": return <Award className="h-4 w-4 text-orange-500" />;
case "VACCINE_DUE": return <Syringe className="h-4 w-4 text-sky-500" />;
}
}
@@ -41,6 +42,7 @@ export default function NotificationsPage() {
const { activePetId } = useActivePet();
const trt = useTranslations("RelativeTime");
const t = useTranslations("NotificationsPage");
const locale = useLocale();
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
const { data, isLoading } = useQuery({
@@ -99,6 +101,7 @@ export default function NotificationsPage() {
return "/einladen";
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return notif.recipientPetId ? `/pets/${notif.recipientPetId}/health` : null;
default:
return null;
@@ -193,6 +196,7 @@ export default function NotificationsPage() {
<AvatarFallback className="text-xs bg-muted">
{notif.type === "BIRTHDAY" ? "🎂" :
notif.type === "ADOPTION_DAY" ? "🏠" :
notif.type === "VACCINE_DUE" ? "💉" :
notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
@@ -233,6 +237,17 @@ export default function NotificationsPage() {
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
{notif.type === "REFERRAL_JOINED" && t("typeReferralJoined")}
{notif.type === "REFERRAL_RANK_UP" && t("typeReferralRankUp")}
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
t("typeVaccineDue", {
name: notif.vaccine.name,
// @db.Date kommt als UTC-Mitternacht — in UTC formatieren, sonst kippt der Tag.
date: new Date(notif.vaccine.nextDueDate).toLocaleDateString(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
}),
})}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
function authorized(req: Request): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) return false;
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
}
/**
* Erinnert 30 Tage vor dem Folgetermin einer Impfung (`Vaccine.nextDueDate`)
* per Mitteilung + Push daran, einen Impftermin zu vereinbaren. Ausgelöst
* täglich vom NAS-Crontab über docker/run-cron.sh.
*/
export async function GET(req: Request) {
if (!authorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await sendVaccineReminders(prisma);
return NextResponse.json({ ok: result.failed === 0, ...result });
}
+8 -3
View File
@@ -8,6 +8,7 @@ interface NotifyParams {
actorPetId?: string;
postId?: string;
commentId?: string;
vaccineId?: string;
}
/**
@@ -41,6 +42,7 @@ const PUSH_COPY: Record<
PET_SUGGESTION: { title: "Vorschlag für dich", genericBody: "Ein Tier könnte dir gefallen.", actorBody: (a) => `${a} könnte dir gefallen!` },
REFERRAL_JOINED: { title: "Deine Einladung hat geklappt", genericBody: "Ein neues Tier ist über deine Einladung zu PawFeed gekommen.", actorBody: (a) => `${a} ist über deine Einladung zu PawFeed gekommen.` },
REFERRAL_RANK_UP: { title: "Neuer Rang", genericBody: "Du hast einen neuen Empfehlungsrang erreicht.", actorBody: () => "Du hast einen neuen Empfehlungsrang erreicht." },
VACCINE_DUE: { title: "Impfung bald fällig", genericBody: "Eine Impfung wird bald fällig.", actorBody: () => "Eine Impfung wird bald fällig." },
};
/**
@@ -51,11 +53,14 @@ const PUSH_COPY: Record<
*
* Exported (not just used via notify() above) so a batch cron job that needs
* an awaited, counted prisma.notification.create (see notify()'s own doc
* comment) can still trigger the matching push afterward.
* comment) can still trigger the matching push afterward. `bodyOverride` lets
* such a job send a more specific text than PUSH_COPY's fixed one (e.g. the
* vaccine name and due date for VACCINE_DUE).
*/
export async function sendPushForNotification(
prisma: Pick<PrismaClient, "pet" | "owner">,
params: NotifyParams
params: NotifyParams,
bodyOverride?: string
): Promise<void> {
const recipientPet = await prisma.pet.findUnique({
where: { id: params.recipientPetId },
@@ -76,7 +81,7 @@ export async function sendPushForNotification(
const copy = PUSH_COPY[params.type];
await sendExpoPushNotification(owner.expoPushToken, {
title: copy.title,
body: actorPet ? copy.actorBody(actorPet.name) : copy.genericBody,
body: bodyOverride ?? (actorPet ? copy.actorBody(actorPet.name) : copy.genericBody),
data: { type: params.type, postId: params.postId ?? "", commentId: params.commentId ?? "" },
});
}
+82
View File
@@ -0,0 +1,82 @@
import "server-only";
import type { PrismaClient } from "@prisma/client";
import { sendPushForNotification } from "@/lib/notify";
/** Erinnerung geht raus, sobald die nächste Impfung höchstens so viele Tage entfernt ist. */
export const VACCINE_REMINDER_LEAD_DAYS = 30;
/** Obergrenze pro Lauf (run-cron.sh bricht nach 60 s ab, jede Erinnerung kostet einen Push-Aufruf) — der Rest folgt am nächsten Tag. */
const MAX_REMINDERS_PER_RUN = 100;
const DAY_MS = 24 * 60 * 60 * 1000;
type ReminderPrisma = Pick<PrismaClient, "vaccine" | "notification" | "pet" | "owner">;
export interface VaccineReminderResult {
sent: number;
failed: number;
more: boolean;
}
/** `nextDueDate` ist ein reines Datum (@db.Date) — Vergleich gegen UTC-Mitternacht. */
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function formatGermanDate(date: Date): string {
const day = String(date.getUTCDate()).padStart(2, "0");
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${day}.${month}.${date.getUTCFullYear()}`;
}
/**
* Erinnert einmalig an jede Impfung, deren Folgetermin (`nextDueDate`) in den
* nächsten 30 Tagen liegt (In-App-Mitteilung VACCINE_DUE + Push). Bereits
* überfällige Termine werden nicht nachträglich erinnert — die zeigt die
* Gesundheitsseite ohnehin als „überfällig“.
*
* Jede Impfung wird vor dem Versand per bedingtem updateMany „beansprucht“
* (reminderSentAt IS NULL → now). So verschickt auch ein doppelt gestarteter
* Lauf (3 Replikas, manueller Aufruf) keine zweite Erinnerung.
*/
export async function sendVaccineReminders(prisma: ReminderPrisma, now = new Date()): Promise<VaccineReminderResult> {
const today = startOfUtcDay(now);
const horizon = new Date(today.getTime() + VACCINE_REMINDER_LEAD_DAYS * DAY_MS);
const dueVaccines = await prisma.vaccine.findMany({
where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon } },
select: { id: true, petId: true, name: true, nextDueDate: true, pet: { select: { name: true } } },
orderBy: { nextDueDate: "asc" },
take: MAX_REMINDERS_PER_RUN,
});
let sent = 0;
let failed = 0;
for (const vaccine of dueVaccines) {
const claim = await prisma.vaccine.updateMany({
where: { id: vaccine.id, reminderSentAt: null },
data: { reminderSentAt: now },
});
if (claim.count === 0) continue;
try {
const params = { recipientPetId: vaccine.petId, type: "VACCINE_DUE" as const, vaccineId: vaccine.id };
await prisma.notification.create({ data: params });
sent++;
const dueDate = vaccine.nextDueDate ? formatGermanDate(vaccine.nextDueDate) : "";
const body = `Die Impfung „${vaccine.name}“ für ${vaccine.pet.name} ist am ${dueDate} fällig — Zeit, einen Termin zu vereinbaren.`;
await sendPushForNotification(prisma, params, body).catch((err) =>
console.error("[vaccine-reminders] push failed", vaccine.id, err)
);
} catch (err) {
failed++;
console.error("[vaccine-reminders] failed", vaccine.id, err);
// Freigeben, damit der nächste Lauf es erneut versucht.
await prisma.vaccine
.updateMany({ where: { id: vaccine.id }, data: { reminderSentAt: null } })
.catch((releaseErr) => console.error("[vaccine-reminders] release failed", vaccine.id, releaseErr));
}
}
return { sent, failed, more: dueVaccines.length === MAX_REMINDERS_PER_RUN };
}
+1
View File
@@ -30,6 +30,7 @@ export const notificationsRouter = router({
actorPet: { select: petIdentitySelect },
post: { select: { id: true, petId: true, images: { take: 1, orderBy: { position: "asc" } } } },
comment: { select: { id: true, body: true } },
vaccine: { select: { id: true, name: true, nextDueDate: true } },
},
});