From 390484f58ae4cb650d4528bf38a8289579ebd3f7 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Sun, 2 Aug 2026 10:57:33 +0300 Subject: [PATCH] fix: derive calendar days in the app timezone, not UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard computed today/week/month via appDateBoundaries() (Asia/Jerusalem) while every write path used `new Date().toISOString().split("T")[0]` — the UTC day. lib/dates.ts was imported by exactly one file. Two bugs, very different blast radius: 1. Ad-hoc reports: `new Date(y, m, 1).toISOString().split("T")[0]` builds LOCAL midnight of the 1st and serializes it as UTC, so east of Greenwich the "first day of this month" preset resolved to the PREVIOUS month's last day — 100% of the time, silently pulling an extra day into every default report. 2. Timer/entry dates: a 2-3h nightly window (00:00-03:00 local) where a new entry landed on yesterday. /api/timer/stop never rewrites `date`, so it stayed wrong permanently. All call sites now use appToday() / appDateBoundaries().startOfMonth, including paid_at, the CSV import sample row, and the task overdue check. The 12-month chart window moves off CURRENT_DATE (DB session clock) onto the same app-timezone month start as the rest of the handler — verified against prod: identical window, identical row count. No migration, no change to existing rows. --- .../(auth)/reports/AdHocReportTab.tsx | 34 +++----- .../(auth)/reports/ChargePaymentsPanel.tsx | 5 +- app/[locale]/entries/import/page.tsx | 4 +- app/[locale]/entries/page.tsx | 12 ++- app/api/dashboard/stats/route.ts | 7 +- app/api/tasks/[id]/move/route.ts | 4 +- app/api/timer/start/route.ts | 6 +- components/tasks/task-card.tsx | 6 +- tests/unit/app-today.test.ts | 81 +++++++++++++++++++ 9 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 tests/unit/app-today.test.ts diff --git a/app/[locale]/(auth)/reports/AdHocReportTab.tsx b/app/[locale]/(auth)/reports/AdHocReportTab.tsx index 47b77818..516cbd6e 100644 --- a/app/[locale]/(auth)/reports/AdHocReportTab.tsx +++ b/app/[locale]/(auth)/reports/AdHocReportTab.tsx @@ -9,6 +9,10 @@ import { formatCurrency as formatCurrencyLib } from "@/lib/currency"; import { showSuccessToast, showErrorToast } from "@/lib/toast"; import { resolveDocumentLocale, type DocumentLanguage } from "@/lib/document-language"; import { useDocumentMessages } from "@/lib/document-messages"; +// NOT `new Date(y, m, 1).toISOString()` — that builds LOCAL midnight of the 1st and +// then serializes it as UTC, so in Israel (UTC+2/+3) the "first day of this month" +// preset resolved to the LAST day of the previous month, every single time. +import { appDateBoundaries, appToday } from "@/lib/dates"; import { printPdfContent } from "./printStyles"; import { PdfReportContent } from "./PdfReportContent"; import { @@ -207,10 +211,8 @@ export default function AdHocReportTab() { const [filters, setFilters] = useState({ clientId: "", projectId: "", - startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1) - .toISOString() - .split("T")[0], // First day of current month - endDate: new Date().toISOString().split("T")[0], // Today + startDate: appDateBoundaries().startOfMonth, // First day of current month + endDate: appToday(), // Today includeFixedCharges: true, }); const [error, setError] = useState(""); @@ -298,10 +300,8 @@ export default function AdHocReportTab() { setFilters({ clientId: clientId || "", projectId: projectId || "", - startDate: startDate || new Date(new Date().getFullYear(), new Date().getMonth(), 1) - .toISOString() - .split("T")[0], - endDate: endDate || new Date().toISOString().split("T")[0], + startDate: startDate || appDateBoundaries().startOfMonth, + endDate: endDate || appToday(), includeFixedCharges: includeFixedCharges !== "0", }); @@ -392,10 +392,8 @@ export default function AdHocReportTab() { setFilters({ clientId: preset.clientId || "", projectId: preset.projectId || "", - startDate: preset.startDate || new Date(new Date().getFullYear(), new Date().getMonth(), 1) - .toISOString() - .split("T")[0], - endDate: preset.endDate || new Date().toISOString().split("T")[0], + startDate: preset.startDate || appDateBoundaries().startOfMonth, + endDate: preset.endDate || appToday(), includeFixedCharges: true, }); setShowLoadPresetDialog(false); @@ -510,7 +508,7 @@ export default function AdHocReportTab() { // Extract filename from Content-Disposition header or use default const contentDisposition = response.headers.get("Content-Disposition"); - let filename = `report_${new Date().toISOString().split("T")[0]}.xlsx`; + let filename = `report_${appToday()}.xlsx`; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i); if (filenameMatch && filenameMatch[1]) { @@ -701,14 +699,8 @@ export default function AdHocReportTab() { setFilters({ clientId: "", projectId: "", - startDate: new Date( - new Date().getFullYear(), - new Date().getMonth(), - 1 - ) - .toISOString() - .split("T")[0], - endDate: new Date().toISOString().split("T")[0], + startDate: appDateBoundaries().startOfMonth, + endDate: appToday(), includeFixedCharges: true, }) } diff --git a/app/[locale]/(auth)/reports/ChargePaymentsPanel.tsx b/app/[locale]/(auth)/reports/ChargePaymentsPanel.tsx index d4acc385..d9c6413f 100644 --- a/app/[locale]/(auth)/reports/ChargePaymentsPanel.tsx +++ b/app/[locale]/(auth)/reports/ChargePaymentsPanel.tsx @@ -9,6 +9,7 @@ import { type PaymentMethod, } from "@/lib/charge-documents"; import { showSuccessToast, showErrorToast } from "@/lib/toast"; +import { appToday } from "@/lib/dates"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SimpleSelect } from "@/components/ui/simple-select"; @@ -55,7 +56,9 @@ export function ChargePaymentsPanel({ const [editingId, setEditingId] = useState(null); // Form fields shared between add and edit modes - const today = new Date().toISOString().slice(0, 10); + // App-timezone day — this becomes the stored `paid_at`, so the UTC day would + // book a late-night payment on the day before. + const today = appToday(); const [amount, setAmount] = useState(""); const [paidAt, setPaidAt] = useState(today); const [method, setMethod] = useState(""); diff --git a/app/[locale]/entries/import/page.tsx b/app/[locale]/entries/import/page.tsx index e4cb87ea..e8ef7343 100644 --- a/app/[locale]/entries/import/page.tsx +++ b/app/[locale]/entries/import/page.tsx @@ -19,6 +19,7 @@ import { type CsvImportRow, type ImportProject, } from "@/lib/csv-entry-import"; +import { appToday } from "@/lib/dates"; const EMPTY_PROJECTS: ImportProject[] = []; @@ -49,7 +50,8 @@ export default function CsvEntryImportPage() { [rows, selectedRows] ); const templateHref = useMemo(() => { - const today = new Date().toISOString().slice(0, 10); + // The sample row is often imported as-is, so this date can become real data. + const today = appToday(); const csv = locale === "he" ? `\uFEFFתאריך,לקוח,פרויקט,תיאור,משך_בדקות,הערות,לחיוב,תעריף\n${today},שם לקוח,שם פרויקט,פגישת עבודה,60,,כן,250` : `date,client,project,description,duration_minutes,notes,billable,rate\n${today},Client name,Project name,Work session,60,,yes,250`; diff --git a/app/[locale]/entries/page.tsx b/app/[locale]/entries/page.tsx index 7d4e9846..ae418036 100644 --- a/app/[locale]/entries/page.tsx +++ b/app/[locale]/entries/page.tsx @@ -15,6 +15,7 @@ import { showSuccessToast, showErrorToast } from "@/lib/toast"; import { messageForError } from "@/lib/api-error"; import { readRecentWorkContext, writeRecentWorkContext } from "@/lib/recent-work-context"; import { validateRequired, validateDate, validateNumber } from "@/lib/validation"; +import { appToday } from "@/lib/dates"; import { useValidationMessage } from "@/lib/validation-messages"; import { pickDefaultHourlyRate, type ClientRate } from "@/lib/schemas/rates"; import { applyPercentDiscount, calcHourlyAmount, calcItemAmount } from "@/lib/money"; @@ -146,10 +147,13 @@ export default function EntriesPage() { // Two-step form selection: with several clients, pick the client first and // see only its projects (mirrors the timer start modal). const [formClientId, setFormClientId] = useState(""); + // `appToday()` — NOT toISOString(), which is the UTC day and pre-fills yesterday + // between local midnight and 03:00. Hardcoding the app timezone (rather than the + // browser's) keeps this default aligned with the server's month/week boundaries. const [formData, setFormData] = useState({ projectId: "", taskId: "", - date: new Date().toISOString().split("T")[0], + date: appToday(), duration: "", description: "", notes: "", @@ -554,7 +558,7 @@ export default function EntriesPage() { setFormData({ projectId: "", taskId: "", - date: new Date().toISOString().split("T")[0], + date: appToday(), duration: "", description: "", notes: "", @@ -625,7 +629,7 @@ export default function EntriesPage() { setFormData({ projectId: "", taskId: "", - date: new Date().toISOString().split("T")[0], + date: appToday(), duration: "", description: "", notes: "", @@ -653,7 +657,7 @@ export default function EntriesPage() { setFormData({ projectId: recentProject?.id ?? "", taskId: "", - date: new Date().toISOString().split("T")[0], + date: appToday(), duration: "", description: "", notes: "", diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts index 2a7b266a..0e2cb9ae 100644 --- a/app/api/dashboard/stats/route.ts +++ b/app/api/dashboard/stats/route.ts @@ -214,10 +214,13 @@ export async function GET(_request: NextRequest) { ) crd ON TRUE WHERE te.user_id = $1 AND te.is_billable = TRUE - AND te.date >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '11 months') + AND te.date >= ($2::date - INTERVAL '11 months') GROUP BY TO_CHAR(te.date, 'YYYY-MM') ORDER BY month ASC`, - [userId] + // Window anchored to the app-timezone month start, not CURRENT_DATE — that + // reads the DB session clock (Neon = UTC) and would disagree with every + // other boundary in this handler on the 1st of the month before 03:00. + [userId, startOfMonthStr] ), // Hours by project this month (folded in from /api/dashboard/project-hours). query<{ project_id: string; project_name: string; total_minutes: string }>( diff --git a/app/api/tasks/[id]/move/route.ts b/app/api/tasks/[id]/move/route.ts index ac70ac09..9d043289 100644 --- a/app/api/tasks/[id]/move/route.ts +++ b/app/api/tasks/[id]/move/route.ts @@ -3,6 +3,7 @@ import type { PoolClient } from "pg"; import { getUser } from "@/lib/auth"; import { parseBody } from "@/lib/api-validation"; import { moveTaskSchema } from "@/lib/schemas/tasks"; +import { appToday } from "@/lib/dates"; import { createLogger } from "@/lib/logger"; const logger = createLogger("tasks:move"); @@ -65,7 +66,8 @@ export async function PATCH( if (running.rows.length > 0) return running.rows[0].id; const now = new Date(); - const today = now.toISOString().split("T")[0]; + // App-timezone day, not the UTC runtime's — see the note in /api/timer/start. + const today = appToday(now); const inserted = await client.query<{ id: string }>( `INSERT INTO time_entries (id, user_id, project_id, task_id, description, start_time, date, duration, diff --git a/app/api/timer/start/route.ts b/app/api/timer/start/route.ts index ed4164e1..cc1d37aa 100644 --- a/app/api/timer/start/route.ts +++ b/app/api/timer/start/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { withTransaction } from "@/lib/db"; import { getUser } from "@/lib/auth"; import { parseBody } from "@/lib/api-validation"; +import { appToday } from "@/lib/dates"; import { createLogger } from "@/lib/logger"; const logger = createLogger("timer:start"); @@ -38,7 +39,10 @@ export async function POST(request: NextRequest) { projectId = parsed.data.projectId; const now = new Date(); - const today = now.toISOString().split("T")[0]; + // The entry's calendar day must be the user's, not the runtime's. Vercel runs + // in UTC, so toISOString() would file a timer started at 01:00 in Israel under + // YESTERDAY — and /timer/stop never rewrites `date`, so it would stay wrong. + const today = appToday(now); const newEntry = await withTransaction(async (client) => { // Verify the project belongs to the user. diff --git a/components/tasks/task-card.tsx b/components/tasks/task-card.tsx index 76f5e17c..d7bc88ff 100644 --- a/components/tasks/task-card.tsx +++ b/components/tasks/task-card.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { type TaskRecord } from "@/lib/tasks-types"; +import { appToday } from "@/lib/dates"; interface TaskCardProps { task: TaskRecord; @@ -32,8 +33,9 @@ const cardTone: Record = { function isOverdue(dueDate: string | null): boolean { if (!dueDate) return false; - const today = new Date().toISOString().slice(0, 10); - return dueDate < today; + // App-timezone day: the UTC day would flag a task due today as overdue + // between local midnight and 03:00. + return dueDate < appToday(); } export function TaskCard({ task, isTimerRunning, onClick }: TaskCardProps) { diff --git a/tests/unit/app-today.test.ts b/tests/unit/app-today.test.ts new file mode 100644 index 00000000..f38d239a --- /dev/null +++ b/tests/unit/app-today.test.ts @@ -0,0 +1,81 @@ +/** + * Guards the day boundary used by every WRITE path (timer start, task→in_progress, + * new-entry form default). + * + * The bug this locks down: those paths used `new Date().toISOString().split("T")[0]` + * — the UTC calendar day — while the dashboard computes today/week/month in + * Asia/Jerusalem. That left a nightly 2-3h window (00:00-03:00 local, DST-dependent) + * where a fresh entry landed on YESTERDAY. `timer/stop` never rewrites `date`, so it + * stayed wrong forever and "שעות היום" read 0:00 while the work sat on the day before. + */ + +import { appToday, appDateBoundaries, addDays } from "../../lib/dates"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function eq(actual: string, expected: string, message: string): void { + assert(actual === expected, `${message}\n expected: ${expected}\n actual: ${actual}`); +} + +let passed = 0; +function check(fn: () => void): void { + fn(); + passed++; +} + +function run(): void { + // --- The window that used to break ----------------------------------------- + // 22:30 UTC on Jul 30 is 01:30 on Jul 31 in Israel (IDT, UTC+3). + // toISOString() would say "2026-07-30". The user's calendar says the 31st. + check(() => eq(appToday(new Date("2026-07-30T22:30:00Z")), "2026-07-31", "IDT: 01:30 local must be the NEXT day")); + + // Winter (IST, UTC+2) — the window narrows to 2h but does not disappear. + check(() => eq(appToday(new Date("2026-01-15T22:30:00Z")), "2026-01-16", "IST: 00:30 local must be the NEXT day")); + + // One minute before the local rollover the day must NOT advance yet. + check(() => eq(appToday(new Date("2026-07-30T20:59:00Z")), "2026-07-30", "23:59 local is still the same day")); + + // Exactly local midnight. + check(() => eq(appToday(new Date("2026-07-30T21:00:00Z")), "2026-07-31", "local midnight rolls the day over")); + + // --- Regression: the ordinary daytime case must be untouched --------------- + // A real prod row: created 2026-08-02 07:04:48Z = 10:04 in Israel. + check(() => eq(appToday(new Date("2026-08-02T07:04:48Z")), "2026-08-02", "daytime must be unchanged")); + + // --- Month boundary, the case that decides which month revenue lands in ---- + // 21:30 UTC on Jul 31 is 00:30 on Aug 1 local → a NEW month, not the old one. + const b = appDateBoundaries(new Date("2026-07-31T21:30:00Z")); + check(() => eq(b.today, "2026-08-01", "crossing midnight into the 1st must land in the new month")); + check(() => eq(b.startOfMonth, "2026-08-01", "startOfMonth must follow the local day, not UTC")); + check(() => eq(b.endOfMonth, "2026-08-31", "endOfMonth must be the last day of the LOCAL month")); + + // February in a non-leap year — the classic off-by-one in hand-rolled month math. + check(() => + eq(appDateBoundaries(new Date("2026-02-10T12:00:00Z")).endOfMonth, "2026-02-28", "Feb 2026 ends on the 28th") + ); + + // Week starts Sunday (Israeli convention). 2026-08-02 is a Sunday → it IS the start. + check(() => + eq(appDateBoundaries(new Date("2026-08-02T07:00:00Z")).startOfWeek, "2026-08-02", "Sunday is its own week start") + ); + + // addDays must not drift across a DST change (Israel ends DST 2026-10-25). + check(() => eq(addDays("2026-10-24", 3), "2026-10-27", "addDays must survive the DST transition")); + + // --- The report-preset bug: startOfMonth must ALWAYS be the 1st ------------ + // The ad-hoc report built it as `new Date(y, m, 1).toISOString().split("T")[0]` + // — local midnight of the 1st, serialized as UTC. East of Greenwich that is the + // PREVIOUS month's last day, every single time, which silently pulled an extra + // day of the previous month into every "this month" report. + for (const iso of ["2026-08-02T07:00:00Z", "2026-01-01T00:30:00Z", "2026-03-15T23:00:00Z"]) { + const start = appDateBoundaries(new Date(iso)).startOfMonth; + check(() => assert(start.endsWith("-01"), `startOfMonth must be the 1st, got ${start} for ${iso}`)); + check(() => eq(start.slice(0, 7), appToday(new Date(iso)).slice(0, 7), `startOfMonth must be in TODAY's month (${iso})`)); + } + + console.log(`app-today: ${passed} passed, 0 failed`); +} + +run();