Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 13 additions & 21 deletions app/[locale]/(auth)/reports/AdHocReportTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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",
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -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,
})
}
Expand Down
5 changes: 4 additions & 1 deletion app/[locale]/(auth)/reports/ChargePaymentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,7 +56,9 @@ export function ChargePaymentsPanel({
const [editingId, setEditingId] = useState<string | null>(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<PaymentMethod | "">("");
Expand Down
4 changes: 3 additions & 1 deletion app/[locale]/entries/import/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type CsvImportRow,
type ImportProject,
} from "@/lib/csv-entry-import";
import { appToday } from "@/lib/dates";

const EMPTY_PROJECTS: ImportProject[] = [];

Expand Down Expand Up @@ -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`;
Expand Down
12 changes: 8 additions & 4 deletions app/[locale]/entries/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -554,7 +558,7 @@ export default function EntriesPage() {
setFormData({
projectId: "",
taskId: "",
date: new Date().toISOString().split("T")[0],
date: appToday(),
duration: "",
description: "",
notes: "",
Expand Down Expand Up @@ -625,7 +629,7 @@ export default function EntriesPage() {
setFormData({
projectId: "",
taskId: "",
date: new Date().toISOString().split("T")[0],
date: appToday(),
duration: "",
description: "",
notes: "",
Expand Down Expand Up @@ -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: "",
Expand Down
7 changes: 5 additions & 2 deletions app/api/dashboard/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>(
Expand Down
4 changes: 3 additions & 1 deletion app/api/tasks/[id]/move/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion app/api/timer/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions components/tasks/task-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,8 +33,9 @@ const cardTone: Record<TaskRecord["priority"], string> = {

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) {
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/app-today.test.ts
Original file line number Diff line number Diff line change
@@ -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();