diff --git a/prisma/migrations/20260323133000_add_monthly_insight_unique/migration.sql b/prisma/migrations/20260323161758_add_monthly_insight_unique/migration.sql similarity index 100% rename from prisma/migrations/20260323133000_add_monthly_insight_unique/migration.sql rename to prisma/migrations/20260323161758_add_monthly_insight_unique/migration.sql diff --git a/prisma/migrations/20260323203003_add_pay_schedule/migration.sql b/prisma/migrations/20260323203003_add_pay_schedule/migration.sql new file mode 100644 index 0000000..8ac3181 --- /dev/null +++ b/prisma/migrations/20260323203003_add_pay_schedule/migration.sql @@ -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 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a870cf9..1cb7601 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/pay-schedule/route.ts b/src/app/pay-schedule/route.ts new file mode 100644 index 0000000..58e584e --- /dev/null +++ b/src/app/pay-schedule/route.ts @@ -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 }); +} diff --git a/src/components/paycheck-workspace.tsx b/src/components/paycheck-workspace.tsx index 0e5180f..3062ce6 100644 --- a/src/components/paycheck-workspace.tsx +++ b/src/components/paycheck-workspace.tsx @@ -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([]); + const [schedule, setSchedule] = useState(null); + const [projectedDates, setProjectedDates] = useState([]); + 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(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) { + 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) { event.preventDefault(); setBusy(true); @@ -75,92 +160,272 @@ export function PaycheckWorkspace() { } return ( -
-
-
+
+ {/* Biweekly schedule panel */} +
+
-

Income entry

-

Record each paycheck on the date it lands

+

Biweekly schedule

+

+ {schedule ? "Active pay schedule" : "Set up your pay schedule"} +

-
-

Tracked paychecks

-

{formatCurrencyFromCents(totalIncome)}

-
-
- -
- - - - -
-

{error}

- -
-
-
- -
-
-

Income history

-

Recent paychecks

-
- -
- {paychecks.length === 0 ? ( -
- No paychecks yet. Add the next deposit to start cash-flow tracking. + {schedule ? ( +
+ +
) : ( - paychecks.map((paycheck) => ( -
setShowScheduleForm(true)} + className="rounded-full bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-800" > -
-

Paycheck

-

{paycheck.payDate}

-
-
-

{formatCurrencyFromCents(paycheck.amountCents)}

- -
-
- )) + Set up schedule + + ) )}
+ + {showScheduleForm && ( +
+ + +
+ + +
+
+ )} + + {schedule && !showScheduleForm && ( +
+
+ + Amount: {formatCurrencyFromCents(schedule.amountCents)} + + + Cadence: Every 2 weeks + + + Anchor: {schedule.anchorDate} + +
+ + {projectedDates.length > 0 && ( +
+

+ Projected pay dates this month +

+
+ {projectedDates.map((date) => { + const confirmed = manualPayDates.has(date); + return ( +
+ {date} + {confirmed ? ( + ✓ Confirmed + ) : ( + + )} +
+ ); + })} +
+ {pendingProjectedDates.length > 0 && ( +

+ {pendingProjectedDates.length} pending — included in dashboard totals as projected income. +

+ )} +
+ )} +
+ )}
+ +
+ {/* Manual paycheck entry */} +
+
+
+

One-off entry

+

Log a bonus or extra deposit

+
+
+

Tracked paychecks

+

{formatCurrencyFromCents(totalIncome)}

+
+
+ +
+ + + + +
+

{error}

+ +
+
+
+ + {/* Paycheck history */} +
+
+

Income history

+

Recent paychecks

+
+ +
+ {paychecks.length === 0 ? ( +
+ No paychecks yet. Use "Mark received" on a projected date or add one manually. +
+ ) : ( + paychecks.map((paycheck) => ( +
+
+

Paycheck

+

{paycheck.payDate}

+
+
+

{formatCurrencyFromCents(paycheck.amountCents)}

+ +
+
+ )) + )} +
+
+
); } + +/** + * 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; +} diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts index 56c01ac..f048c27 100644 --- a/src/lib/dashboard.ts +++ b/src/lib/dashboard.ts @@ -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 }); } diff --git a/src/lib/pay-schedule.ts b/src/lib/pay-schedule.ts new file mode 100644 index 0000000..5375b70 --- /dev/null +++ b/src/lib/pay-schedule.ts @@ -0,0 +1,58 @@ +import { db } from "@/lib/db"; + +export type PaySchedule = { + id: string; + amountCents: number; + anchorDate: string; +}; + +export async function getActiveSchedule(): Promise { + const schedule = await db.paySchedule.findFirst({ where: { active: true }, orderBy: { createdAt: "desc" } }); + return schedule ?? null; +} + +export async function saveSchedule(amountCents: number, anchorDate: string): Promise { + await db.paySchedule.updateMany({ where: { active: true }, data: { active: false } }); + return db.paySchedule.create({ data: { amountCents, anchorDate, active: true } }); +} + +export async function clearSchedule(): Promise { + 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; +}