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:
@@ -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
|
||||||
|
);
|
||||||
@@ -34,6 +34,14 @@ model Paycheck {
|
|||||||
createdAt DateTime @default(now())
|
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 {
|
model MonthlyInsight {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
month String @unique
|
month String @unique
|
||||||
|
|||||||
45
src/app/pay-schedule/route.ts
Normal file
45
src/app/pay-schedule/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import { getCurrentMonthKey } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type PaycheckRecord = {
|
type PaycheckRecord = {
|
||||||
@@ -10,8 +11,18 @@ type PaycheckRecord = {
|
|||||||
payDate: string;
|
payDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaySchedule = {
|
||||||
|
id: string;
|
||||||
|
amountCents: number;
|
||||||
|
anchorDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function PaycheckWorkspace() {
|
export function PaycheckWorkspace() {
|
||||||
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
|
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({
|
const [formState, setFormState] = useState({
|
||||||
amount: "",
|
amount: "",
|
||||||
payDate: new Date().toISOString().slice(0, 10),
|
payDate: new Date().toISOString().slice(0, 10),
|
||||||
@@ -20,13 +31,22 @@ export function PaycheckWorkspace() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPaychecks() {
|
async function loadData() {
|
||||||
const response = await fetch("/paychecks", { cache: "no-store" });
|
const [paycheckRes, scheduleRes] = await Promise.all([
|
||||||
const payload = (await response.json()) as { paychecks?: PaycheckRecord[] };
|
fetch("/paychecks", { cache: "no-store" }),
|
||||||
setPaychecks(payload.paychecks ?? []);
|
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(
|
const totalIncome = useMemo(
|
||||||
@@ -34,6 +54,71 @@ export function PaycheckWorkspace() {
|
|||||||
[paychecks],
|
[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>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -75,12 +160,155 @@ export function PaycheckWorkspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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">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>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
!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"
|
||||||
|
>
|
||||||
|
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]">
|
<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)]">
|
<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="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Income entry</p>
|
<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">Record each paycheck on the date it lands</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log a bonus or extra deposit</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
|
<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="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
|
||||||
@@ -125,6 +353,7 @@ export function PaycheckWorkspace() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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)]">
|
<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">
|
<div className="mb-5">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
||||||
@@ -134,7 +363,7 @@ export function PaycheckWorkspace() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paychecks.length === 0 ? (
|
{paychecks.length === 0 ? (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
<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.
|
No paychecks yet. Use "Mark received" on a projected date or add one manually.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paychecks.map((paycheck) => (
|
paychecks.map((paycheck) => (
|
||||||
@@ -150,7 +379,7 @@ export function PaycheckWorkspace() {
|
|||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(paycheck.id)}
|
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"
|
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
|
Delete
|
||||||
@@ -162,5 +391,41 @@ export function PaycheckWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isCurrentMonthKey,
|
isCurrentMonthKey,
|
||||||
isDateInMonth,
|
isDateInMonth,
|
||||||
} from "@/lib/date";
|
} from "@/lib/date";
|
||||||
|
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
|
||||||
|
|
||||||
export type DashboardSnapshot = {
|
export type DashboardSnapshot = {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -31,19 +32,39 @@ export type DashboardSnapshot = {
|
|||||||
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
||||||
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
|
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
|
||||||
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
||||||
|
paySchedule: {
|
||||||
|
amountCents: number;
|
||||||
|
anchorDate: string;
|
||||||
|
projectedDates: string[];
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildDashboardSnapshot(input: {
|
export function buildDashboardSnapshot(input: {
|
||||||
month: string;
|
month: string;
|
||||||
expenses: Expense[];
|
expenses: Expense[];
|
||||||
paychecks: Paycheck[];
|
paychecks: Paycheck[];
|
||||||
|
paySchedule?: PaySchedule | null;
|
||||||
insight?: MonthlyInsight | null;
|
insight?: MonthlyInsight | null;
|
||||||
}): DashboardSnapshot {
|
}): DashboardSnapshot {
|
||||||
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
||||||
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, 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 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 netCashFlowCents = paychecksCents - expensesCents;
|
||||||
|
|
||||||
const daysConsidered = isCurrentMonthKey(input.month)
|
const daysConsidered = isCurrentMonthKey(input.month)
|
||||||
@@ -74,7 +95,7 @@ export function buildDashboardSnapshot(input: {
|
|||||||
dailyMap.set(expense.date, current);
|
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 };
|
const current = dailyMap.get(paycheck.payDate) ?? { expensesCents: 0, paychecksCents: 0 };
|
||||||
current.paychecksCents += paycheck.amountCents;
|
current.paychecksCents += paycheck.amountCents;
|
||||||
dailyMap.set(paycheck.payDate, current);
|
dailyMap.set(paycheck.payDate, current);
|
||||||
@@ -111,6 +132,9 @@ export function buildDashboardSnapshot(input: {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
paySchedule: input.paySchedule
|
||||||
|
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
|
||||||
|
: null,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
recentExpenses: monthExpenses
|
recentExpenses: monthExpenses
|
||||||
.slice()
|
.slice()
|
||||||
@@ -128,11 +152,12 @@ export function buildDashboardSnapshot(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
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.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
|
||||||
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
|
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
|
||||||
db.monthlyInsight.findUnique({ where: { month } }),
|
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
58
src/lib/pay-schedule.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user