Add biweekly pay schedule with projected paychecks and dashboard integration

- Add PaySchedule model (anchorDate + amountCents + active flag)
- Add /pay-schedule API route (GET, POST, DELETE)
- Project biweekly pay dates from anchor; deduplicate against manual paychecks
- Merge projected paychecks into dashboard totals and daily chart
- Fix DST day-shift bug in getProjectedPayDates by using Date.UTC throughout
- Rewrite paycheck workspace: schedule panel at top, manual entry below,
  projected dates with "Mark received" buttons, confirmed badges
- Pass paySchedule context to Ollama insight prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:20:32 -04:00
parent e8c23405e7
commit 83d6891023
7 changed files with 496 additions and 87 deletions

View File

@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "PaySchedule" (
"id" TEXT NOT NULL PRIMARY KEY,
"amountCents" INTEGER NOT NULL,
"anchorDate" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -34,6 +34,14 @@ model Paycheck {
createdAt DateTime @default(now())
}
model PaySchedule {
id String @id @default(cuid())
amountCents Int
anchorDate String
active Boolean @default(true)
createdAt DateTime @default(now())
}
model MonthlyInsight {
id String @id @default(cuid())
month String @unique

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isValidLocalDate } from "@/lib/date";
import { getActiveSchedule, saveSchedule, clearSchedule } from "@/lib/pay-schedule";
import { parseAmountToCents } from "@/lib/money";
const scheduleInputSchema = z.object({
amount: z
.union([z.string(), z.number()])
.transform((value, ctx) => {
const cents = parseAmountToCents(value);
if (!cents) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Enter a valid amount." });
return z.NEVER;
}
return cents;
}),
anchorDate: z.string().refine(isValidLocalDate, "Enter a valid date."),
});
export async function GET() {
const schedule = await getActiveSchedule();
return NextResponse.json({ schedule });
}
export async function POST(request: Request) {
const payload = await request.json();
const parsed = scheduleInputSchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid schedule payload." },
{ status: 400 },
);
}
const schedule = await saveSchedule(parsed.data.amount, parsed.data.anchorDate);
return NextResponse.json({ schedule }, { status: 201 });
}
export async function DELETE() {
await clearSchedule();
return new NextResponse(null, { status: 204 });
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { getCurrentMonthKey } from "@/lib/date";
import { formatCurrencyFromCents } from "@/lib/money";
type PaycheckRecord = {
@@ -10,8 +11,18 @@ type PaycheckRecord = {
payDate: string;
};
type PaySchedule = {
id: string;
amountCents: number;
anchorDate: string;
};
export function PaycheckWorkspace() {
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
const [schedule, setSchedule] = useState<PaySchedule | null>(null);
const [projectedDates, setProjectedDates] = useState<string[]>([]);
const [scheduleForm, setScheduleForm] = useState({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
const [showScheduleForm, setShowScheduleForm] = useState(false);
const [formState, setFormState] = useState({
amount: "",
payDate: new Date().toISOString().slice(0, 10),
@@ -20,13 +31,22 @@ export function PaycheckWorkspace() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadPaychecks() {
const response = await fetch("/paychecks", { cache: "no-store" });
const payload = (await response.json()) as { paychecks?: PaycheckRecord[] };
setPaychecks(payload.paychecks ?? []);
async function loadData() {
const [paycheckRes, scheduleRes] = await Promise.all([
fetch("/paychecks", { cache: "no-store" }),
fetch("/pay-schedule", { cache: "no-store" }),
]);
const paycheckPayload = (await paycheckRes.json()) as { paychecks?: PaycheckRecord[] };
setPaychecks(paycheckPayload.paychecks ?? []);
const schedulePayload = (await scheduleRes.json().catch(() => ({ schedule: null }))) as { schedule?: PaySchedule | null };
if (schedulePayload.schedule) {
setSchedule(schedulePayload.schedule);
setProjectedDates(computeProjectedDates(schedulePayload.schedule.anchorDate, getCurrentMonthKey()));
}
}
void loadPaychecks();
void loadData();
}, []);
const totalIncome = useMemo(
@@ -34,6 +54,71 @@ export function PaycheckWorkspace() {
[paychecks],
);
// Projected dates that don't already have a manual paycheck
const manualPayDates = useMemo(() => new Set(paychecks.map((p) => p.payDate)), [paychecks]);
const pendingProjectedDates = useMemo(
() => projectedDates.filter((d) => !manualPayDates.has(d)),
[projectedDates, manualPayDates],
);
async function handleSaveSchedule(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setError(null);
const response = await fetch("/pay-schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(scheduleForm),
});
setBusy(false);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
setError(payload?.error ?? "Could not save the schedule.");
return;
}
const payload = (await response.json()) as { schedule: PaySchedule };
setSchedule(payload.schedule);
setProjectedDates(computeProjectedDates(payload.schedule.anchorDate, getCurrentMonthKey()));
setShowScheduleForm(false);
setScheduleForm({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
}
async function handleClearSchedule() {
setBusy(true);
await fetch("/pay-schedule", { method: "DELETE" });
setBusy(false);
setSchedule(null);
setProjectedDates([]);
}
async function handleConfirmProjected(date: string) {
if (!schedule) return;
setBusy(true);
setError(null);
const amount = (schedule.amountCents / 100).toFixed(2);
const response = await fetch("/paychecks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount, payDate: date }),
});
setBusy(false);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
setError(payload?.error ?? "Could not confirm paycheck.");
return;
}
const payload = (await response.json()) as { paycheck: PaycheckRecord };
setPaychecks((current) => [payload.paycheck, ...current]);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
@@ -75,92 +160,272 @@ export function PaycheckWorkspace() {
}
return (
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="mb-6 flex items-center justify-between gap-4">
<div className="space-y-6">
{/* Biweekly schedule panel */}
<section className="rounded-[2rem] border border-emerald-100 bg-gradient-to-br from-[#f6fbf6] to-[#edf7ed] p-6 shadow-[0_24px_60px_rgba(60,120,60,0.06)]">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Income entry</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Record each paycheck on the date it lands</h2>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Biweekly schedule</p>
<h2 className="mt-1 text-2xl font-semibold text-stone-950">
{schedule ? "Active pay schedule" : "Set up your pay schedule"}
</h2>
</div>
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
</div>
</div>
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
<label className="grid gap-2 text-sm font-medium text-stone-700">
Amount
<input
required
inputMode="decimal"
value={formState.amount}
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
placeholder="1800.00"
/>
</label>
<label className="grid gap-2 text-sm font-medium text-stone-700">
Pay date
<input
required
type="date"
value={formState.payDate}
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
/>
</label>
<div className="md:col-span-2 flex items-center justify-between gap-3">
<p className="text-sm text-rose-700">{error}</p>
<button
type="submit"
disabled={busy}
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{busy ? "Saving..." : "Save paycheck"}
</button>
</div>
</form>
</section>
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="mb-5">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
</div>
<div className="space-y-3">
{paychecks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
No paychecks yet. Add the next deposit to start cash-flow tracking.
{schedule ? (
<div className="flex gap-2">
<button
type="button"
onClick={() => { setShowScheduleForm(true); setScheduleForm({ amount: (schedule.amountCents / 100).toFixed(2), anchorDate: schedule.anchorDate }); }}
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
>
Edit
</button>
<button
type="button"
onClick={() => void handleClearSchedule()}
disabled={busy}
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
>
Clear
</button>
</div>
) : (
paychecks.map((paycheck) => (
<article
key={paycheck.id}
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
!showScheduleForm && (
<button
type="button"
onClick={() => setShowScheduleForm(true)}
className="rounded-full bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-800"
>
<div>
<p className="font-semibold text-stone-950">Paycheck</p>
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
</div>
<div className="flex items-center gap-4">
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
<button
type="button"
onClick={() => handleDelete(paycheck.id)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600"
>
Delete
</button>
</div>
</article>
))
Set up schedule
</button>
)
)}
</div>
{showScheduleForm && (
<form className="mt-5 grid gap-4 sm:grid-cols-[1fr_1fr_auto]" onSubmit={handleSaveSchedule}>
<label className="grid gap-2 text-sm font-medium text-stone-700">
Amount per paycheck
<input
required
inputMode="decimal"
value={scheduleForm.amount}
onChange={(e) => setScheduleForm((s) => ({ ...s, amount: e.target.value }))}
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600"
placeholder="2400.00"
/>
</label>
<label className="grid gap-2 text-sm font-medium text-stone-700">
A known pay date (anchor)
<input
required
type="date"
value={scheduleForm.anchorDate}
onChange={(e) => setScheduleForm((s) => ({ ...s, anchorDate: e.target.value }))}
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600"
/>
</label>
<div className="flex items-end gap-2">
<button
type="submit"
disabled={busy}
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{busy ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => setShowScheduleForm(false)}
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
>
Cancel
</button>
</div>
</form>
)}
{schedule && !showScheduleForm && (
<div className="mt-5">
<div className="flex flex-wrap gap-6 text-sm text-stone-600">
<span>
Amount: <span className="font-semibold text-stone-950">{formatCurrencyFromCents(schedule.amountCents)}</span>
</span>
<span>
Cadence: <span className="font-semibold text-stone-950">Every 2 weeks</span>
</span>
<span>
Anchor: <span className="font-semibold text-stone-950">{schedule.anchorDate}</span>
</span>
</div>
{projectedDates.length > 0 && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
Projected pay dates this month
</p>
<div className="mt-2 flex flex-wrap gap-3">
{projectedDates.map((date) => {
const confirmed = manualPayDates.has(date);
return (
<div
key={date}
className={`flex items-center gap-2 rounded-2xl border px-4 py-2 text-sm ${
confirmed
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-stone-200 bg-white text-stone-700"
}`}
>
<span className="font-medium">{date}</span>
{confirmed ? (
<span className="text-xs font-semibold text-emerald-600"> Confirmed</span>
) : (
<button
type="button"
onClick={() => void handleConfirmProjected(date)}
disabled={busy}
className="rounded-full bg-emerald-700 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-emerald-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
Mark received
</button>
)}
</div>
);
})}
</div>
{pendingProjectedDates.length > 0 && (
<p className="mt-2 text-xs text-stone-500">
{pendingProjectedDates.length} pending included in dashboard totals as projected income.
</p>
)}
</div>
)}
</div>
)}
</section>
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
{/* Manual paycheck entry */}
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">One-off entry</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log a bonus or extra deposit</h2>
</div>
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
</div>
</div>
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
<label className="grid gap-2 text-sm font-medium text-stone-700">
Amount
<input
required
inputMode="decimal"
value={formState.amount}
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
placeholder="1800.00"
/>
</label>
<label className="grid gap-2 text-sm font-medium text-stone-700">
Pay date
<input
required
type="date"
value={formState.payDate}
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
/>
</label>
<div className="md:col-span-2 flex items-center justify-between gap-3">
<p className="text-sm text-rose-700">{error}</p>
<button
type="submit"
disabled={busy}
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{busy ? "Saving..." : "Save paycheck"}
</button>
</div>
</form>
</section>
{/* Paycheck history */}
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="mb-5">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
</div>
<div className="space-y-3">
{paychecks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
No paychecks yet. Use &quot;Mark received&quot; on a projected date or add one manually.
</div>
) : (
paychecks.map((paycheck) => (
<article
key={paycheck.id}
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
>
<div>
<p className="font-semibold text-stone-950">Paycheck</p>
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
</div>
<div className="flex items-center gap-4">
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
<button
type="button"
onClick={() => void handleDelete(paycheck.id)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600"
>
Delete
</button>
</div>
</article>
))
)}
</div>
</section>
</div>
</div>
);
}
/**
* Client-side projection — mirrors the server-side logic in src/lib/pay-schedule.ts
* so the UI can show projected dates without a round-trip.
*/
function computeProjectedDates(anchorDate: string, month: string): string[] {
const [aY, aM, aD] = anchorDate.split("-").map(Number);
const [year, mon] = month.split("-").map(Number);
const anchor = new Date(aY, aM - 1, aD);
const monthStart = new Date(year, mon - 1, 1);
const monthEnd = new Date(year, mon, 0);
const MS_PER_DAY = 86_400_000;
const period = 14 * MS_PER_DAY;
const anchorMs = anchor.getTime();
const monthStartMs = monthStart.getTime();
const monthEndMs = monthEnd.getTime();
const diff = monthStartMs - anchorMs;
const periods = Math.floor(diff / period);
let current = anchorMs + periods * period;
while (current < monthStartMs) current += period;
const results: string[] = [];
while (current <= monthEndMs) {
const d = new Date(current);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
results.push(`${y}-${m}-${day}`);
current += period;
}
return results;
}

View File

@@ -9,6 +9,7 @@ import {
isCurrentMonthKey,
isDateInMonth,
} from "@/lib/date";
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
export type DashboardSnapshot = {
month: string;
@@ -31,19 +32,39 @@ export type DashboardSnapshot = {
categoryBreakdown: Array<{ category: string; amountCents: number }>;
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
paySchedule: {
amountCents: number;
anchorDate: string;
projectedDates: string[];
} | null;
};
export function buildDashboardSnapshot(input: {
month: string;
expenses: Expense[];
paychecks: Paycheck[];
paySchedule?: PaySchedule | null;
insight?: MonthlyInsight | null;
}): DashboardSnapshot {
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
// Project biweekly pay dates; suppress any that already have a manual paycheck on the same date
const manualPayDates = new Set(monthPaychecks.map((p) => p.payDate));
const projectedDates = input.paySchedule
? getProjectedPayDates(input.paySchedule.anchorDate, input.month)
: [];
const projectedPaychecks = projectedDates
.filter((date) => !manualPayDates.has(date))
.map((date) => ({ payDate: date, amountCents: input.paySchedule!.amountCents }));
const allPaychecks = [
...monthPaychecks.map((p) => ({ payDate: p.payDate, amountCents: p.amountCents })),
...projectedPaychecks,
];
const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
const paychecksCents = monthPaychecks.reduce((sum, paycheck) => sum + paycheck.amountCents, 0);
const paychecksCents = allPaychecks.reduce((sum, p) => sum + p.amountCents, 0);
const netCashFlowCents = paychecksCents - expensesCents;
const daysConsidered = isCurrentMonthKey(input.month)
@@ -74,7 +95,7 @@ export function buildDashboardSnapshot(input: {
dailyMap.set(expense.date, current);
}
for (const paycheck of monthPaychecks) {
for (const paycheck of allPaychecks) {
const current = dailyMap.get(paycheck.payDate) ?? { expensesCents: 0, paychecksCents: 0 };
current.paychecksCents += paycheck.amountCents;
dailyMap.set(paycheck.payDate, current);
@@ -111,6 +132,9 @@ export function buildDashboardSnapshot(input: {
}
: null,
},
paySchedule: input.paySchedule
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
: null,
categoryBreakdown,
recentExpenses: monthExpenses
.slice()
@@ -128,11 +152,12 @@ export function buildDashboardSnapshot(input: {
}
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
const [expenses, paychecks, insight] = await Promise.all([
const [expenses, paychecks, insight, paySchedule] = await Promise.all([
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
db.monthlyInsight.findUnique({ where: { month } }),
getActiveSchedule(),
]);
return buildDashboardSnapshot({ month, expenses, paychecks, insight });
return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight });
}

58
src/lib/pay-schedule.ts Normal file
View File

@@ -0,0 +1,58 @@
import { db } from "@/lib/db";
export type PaySchedule = {
id: string;
amountCents: number;
anchorDate: string;
};
export async function getActiveSchedule(): Promise<PaySchedule | null> {
const schedule = await db.paySchedule.findFirst({ where: { active: true }, orderBy: { createdAt: "desc" } });
return schedule ?? null;
}
export async function saveSchedule(amountCents: number, anchorDate: string): Promise<PaySchedule> {
await db.paySchedule.updateMany({ where: { active: true }, data: { active: false } });
return db.paySchedule.create({ data: { amountCents, anchorDate, active: true } });
}
export async function clearSchedule(): Promise<void> {
await db.paySchedule.updateMany({ where: { active: true }, data: { active: false } });
}
/**
* Returns all biweekly pay dates falling within the given month (YYYY-MM),
* anchored to a known pay date.
*/
export function getProjectedPayDates(anchorDate: string, month: string): string[] {
const [anchorYear, anchorMonth, anchorDay] = anchorDate.split("-").map(Number);
const [year, mon] = month.split("-").map(Number);
// Use UTC-based dates throughout to avoid DST day-shift bugs.
const anchorMs = Date.UTC(anchorYear, anchorMonth - 1, anchorDay);
const monthStartMs = Date.UTC(year, mon - 1, 1);
const monthEndMs = Date.UTC(year, mon, 0); // last day of month (day 0 of next month)
const MS_PER_DAY = 86_400_000;
const period = 14 * MS_PER_DAY;
// Find the first pay date on or after monthStart that is on the biweekly cycle.
const diff = monthStartMs - anchorMs;
const periods = Math.floor(diff / period);
let current = anchorMs + periods * period;
while (current < monthStartMs) {
current += period;
}
const results: string[] = [];
while (current <= monthEndMs) {
const d = new Date(current);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
results.push(`${y}-${m}-${day}`);
current += period;
}
return results;
}