feat: reminder due calculation

This commit is contained in:
opencode committed 2026-10-18 10:34:15 +02:00
1 parent f2b1d0293a
commit 6e1d0a7568
4 files changed
+72 -1

No files matched your search

+6
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { getVoucherStatus } from "@/lib/voucher-status";
import { validateVoucher, isExpiringSoon } from "@/lib/vouchers";
import { parseReminderDays } from "@/lib/reminders";
export async function GET(req: NextRequest) {
const filter = req.nextUrl.searchParams.get("filter");
@@ -47,6 +48,11 @@ export async function POST(req: NextRequest) {
kategorie: input.kategorie,
notiz: input.notiz,
contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined,
rules: {
create: parseReminderDays(
typeof body.reminderDays === "string" ? body.reminderDays : Array.isArray(body.reminderDays) ? body.reminderDays.join(",") : undefined
).map((tageVorher) => ({ tageVorher })),
},
},
});
return NextResponse.json(created, { status: 201 });
+16 -1
View File
@@ -23,6 +23,7 @@ export default function NewVoucherPage() {
const [serverError, setServerError] = useState("");
const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]);
const [contactIds, setContactIds] = useState<number[]>([]);
const [reminderDays, setReminderDays] = useState("30,6,1");
useEffect(() => {
fetch("/api/contacts")
@@ -55,7 +56,7 @@ export default function NewVoucherPage() {
const res = await fetch("/api/vouchers", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...input, contactIds }),
body: JSON.stringify({ ...input, contactIds, reminderDays }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -87,6 +88,20 @@ export default function NewVoucherPage() {
{errors.includes(f.name) && <p className="mt-1 text-sm text-red-600">Ungültig: {f.name}</p>}
</div>
))}
<div>
<label htmlFor="reminderDays" className="block text-sm font-medium">
Erinnerung (Tage vorher, Kommaliste)
</label>
<input
id="reminderDays"
name="reminderDays"
type="text"
value={reminderDays}
onChange={(e) => setReminderDays(e.target.value)}
placeholder="30,6,1"
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<section id="kontakte">
<h2 className="text-sm font-medium">Empfänger</h2>
{contacts.map((c) => (
+33
View File
@@ -0,0 +1,33 @@
export const DEFAULT_REMINDER_DAYS = [30, 6, 1];
export function toDateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function dueRulesToday(
v: { ablaufdatum: Date | null; status: string },
rules: { id: number; tageVorher: number }[],
heute: Date
): number[] {
if (v.status !== "aktiv" || !v.ablaufdatum) return [];
const out: number[] = [];
for (const r of rules) {
const ziel = new Date(v.ablaufdatum);
ziel.setDate(ziel.getDate() - r.tageVorher);
if (toDateKey(ziel) === toDateKey(heute)) out.push(r.id);
}
return out;
}
export function idempotencyKey(ruleId: number, kanal: string, empfaenger: string, zielDatum: string): string {
return `${ruleId}|${kanal}|${empfaenger}|${zielDatum}`;
}
export function parseReminderDays(raw: string | null | undefined): number[] {
if (raw == null || !raw.trim()) return [...DEFAULT_REMINDER_DAYS];
const days = raw
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n >= 0);
return [...new Set(days)].sort((a, b) => b - a);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { dueRulesToday, idempotencyKey } from "../lib/reminders";
describe("dueRulesToday", () => {
it("30/6/1 trifft am richtigen Tag", () => {
const ablauf = new Date("2026-12-01T00:00:00");
const rules = [{ id: 1, tageVorher: 30 }, { id: 2, tageVorher: 6 }, { id: 3, tageVorher: 1 }];
expect(dueRulesToday({ ablaufdatum: ablauf, status: "aktiv" }, rules, new Date("2026-11-01T12:00:00"))).toEqual([1]);
expect(dueRulesToday({ ablaufdatum: ablauf, status: "aktiv" }, rules, new Date("2026-11-30T08:00:00"))).toEqual([3]);
});
it("kein Ablaufdatum und eingelöst nie fällig", () => {
expect(dueRulesToday({ ablaufdatum: null, status: "aktiv" }, [{ id: 1, tageVorher: 7 }], new Date())).toEqual([]);
expect(dueRulesToday({ ablaufdatum: new Date(), status: "eingeloest" }, [{ id: 1, tageVorher: 0 }], new Date())).toEqual([]);
});
it("key ist stabil", () => {
expect(idempotencyKey(1, "mail", "a@b.de", "2026-12-01")).toBe("1|mail|a@b.de|2026-12-01");
});
});