diff --git a/src/app/api/dashboard/activity/route.ts b/src/app/api/dashboard/activity/route.ts index 20371f7..1c28b5c 100644 --- a/src/app/api/dashboard/activity/route.ts +++ b/src/app/api/dashboard/activity/route.ts @@ -16,8 +16,8 @@ import MealPlan from "@/lib/models/meal-plan"; import Expense from "@/lib/models/expense"; import CustomEntry from "@/lib/models/custom-entry"; import SectionTemplate from "@/lib/models/section-template"; -import { startOfMonth, endOfMonth, format } from "date-fns"; import { DEFAULT_ENABLED_SECTIONS, type SectionId } from "@/lib/constants"; +import { toDateKey, utcMonthRange } from "@/lib/date-key"; export async function GET(req: NextRequest) { const session = await auth(); @@ -31,15 +31,7 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const monthParam = searchParams.get("month"); // "2026-05" - let start: Date, end: Date; - if (monthParam) { - const d = new Date(monthParam + "-01"); - start = startOfMonth(d); - end = endOfMonth(d); - } else { - start = startOfMonth(new Date()); - end = endOfMonth(new Date()); - } + const { start, end } = utcMonthRange(monthParam ? new Date(monthParam + "-01") : new Date()); const user = await User.findById(userId).lean(); if (!user) { @@ -54,7 +46,7 @@ export async function GET(req: NextRequest) { const addDates = (dates: Date[], sectionId: string) => { for (const d of dates) { - const key = format(d, "yyyy-MM-dd"); + const key = toDateKey(d); if (!activity[key]) activity[key] = []; if (!activity[key].includes(sectionId)) activity[key].push(sectionId); } @@ -154,7 +146,7 @@ export async function GET(req: NextRequest) { for (const doc of docs) { const slug = templateMap.get(String(doc.templateId)); if (slug) { - const key = format(doc.date, "yyyy-MM-dd"); + const key = toDateKey(doc.date); if (!activity[key]) activity[key] = []; if (!activity[key].includes(slug)) activity[key].push(slug); } diff --git a/src/lib/__tests__/date-key.test.ts b/src/lib/__tests__/date-key.test.ts new file mode 100644 index 0000000..0837c5f --- /dev/null +++ b/src/lib/__tests__/date-key.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { toDateKey, utcMonthRange } from "../date-key"; + +describe("toDateKey", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + process.env.TZ = originalTz; + }); + + it("returns the intended calendar day regardless of the server process's local timezone", () => { + // Date-only fields are stored as UTC midnight of the intended day (e.g. + // "2026-01-15" parses to 2026-01-15T00:00:00.000Z). A server running in a + // negative-UTC-offset timezone must not read this back as the prior day. + const utcMidnight = new Date("2026-01-15T00:00:00.000Z"); + + process.env.TZ = "America/New_York"; // UTC-5 in January + expect(toDateKey(utcMidnight)).toBe("2026-01-15"); + + process.env.TZ = "Pacific/Kiritimati"; // UTC+14 — the other extreme + expect(toDateKey(utcMidnight)).toBe("2026-01-15"); + + process.env.TZ = "UTC"; + expect(toDateKey(utcMidnight)).toBe("2026-01-15"); + }); +}); + +describe("utcMonthRange", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + process.env.TZ = originalTz; + }); + + it("spans the intended UTC month regardless of the server process's local timezone", () => { + // "2026-01-01" parses to UTC midnight Jan 1. date-fns' startOfMonth/ + // endOfMonth (local-timezone-based) would shift this range onto December + // on a server running in a negative-UTC-offset timezone like EST. + const anchor = new Date("2026-01-01T00:00:00.000Z"); + + process.env.TZ = "America/New_York"; + let { start, end } = utcMonthRange(anchor); + expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(end.toISOString()).toBe("2026-01-31T23:59:59.999Z"); + + process.env.TZ = "Pacific/Kiritimati"; + ({ start, end } = utcMonthRange(anchor)); + expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(end.toISOString()).toBe("2026-01-31T23:59:59.999Z"); + }); +}); diff --git a/src/lib/date-key.ts b/src/lib/date-key.ts new file mode 100644 index 0000000..0b0cd9f --- /dev/null +++ b/src/lib/date-key.ts @@ -0,0 +1,26 @@ +/** + * Converts a Date to its "yyyy-MM-dd" calendar-day key using UTC getters. + * + * Date-only fields throughout this app are stored as UTC midnight of the + * intended calendar day (e.g. the string "2026-01-15" parses to + * 2026-01-15T00:00:00.000Z). Reading such a value back with local-timezone + * getters (as date-fns' format() does) can shift the key by a day whenever + * the process isn't running in UTC. Using UTC getters keeps the key + * independent of the server's local timezone. + */ +export function toDateKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** + * Returns the [start, end] instants spanning the UTC calendar month + * containing `d`. Using date-fns' startOfMonth/endOfMonth here would use the + * server process's local timezone, which can shift the queried range by a + * day (or onto the wrong month entirely) relative to UTC-anchored dates. + */ +export function utcMonthRange(d: Date): { start: Date; end: Date } { + return { + start: new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)), + end: new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0, 23, 59, 59, 999)), + }; +}