From 70c08054fb0d7b8debbb674bbdc8bb12b2939be4 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 17 Aug 2026 18:15:39 -0400 Subject: [PATCH] Make scheduling and dates timezone-safe --- apps/mobile/app/(app)/invoices/edit/[id].tsx | 98 +++- apps/mobile/app/(app)/invoices/new.tsx | 82 ++- apps/mobile/app/(app)/invoices/send/[id].tsx | 25 +- apps/mobile/app/(app)/more/expenses/[id].tsx | 7 +- apps/mobile/app/(app)/more/expenses/index.tsx | 140 +++-- apps/mobile/app/(app)/more/settings.tsx | 3 + apps/mobile/app/(app)/more/time-entries.tsx | 68 ++- .../mobile/components/InvoiceReminderSync.tsx | 67 ++- .../components/expenses/ExpenseFormFields.tsx | 3 +- .../components/invoices/InvoiceSetupForm.tsx | 24 +- apps/mobile/components/ui/DateTimeField.tsx | 63 ++- apps/mobile/lib/format.ts | 6 +- apps/mobile/lib/invoice-number.ts | 6 +- apps/mobile/lib/invoice-status.ts | 7 +- apps/web/drizzle/0031_timezone_safety.sql | 124 +++++ apps/web/drizzle/meta/_journal.json | 7 + apps/web/src/app/api/mcp/route.ts | 70 ++- .../src/app/dashboard/clients/[id]/page.tsx | 11 +- apps/web/src/app/dashboard/expenses/page.tsx | 15 +- .../[id]/_components/invoice-items-table.tsx | 10 +- .../src/app/dashboard/invoices/[id]/page.tsx | 59 ++- .../app/dashboard/invoices/[id]/send/page.tsx | 64 ++- .../_components/invoices-data-table.tsx | 24 +- apps/web/src/app/dashboard/invoices/page.tsx | 8 +- .../app/dashboard/invoices/recurring/page.tsx | 217 ++++++-- apps/web/src/app/dashboard/reports/page.tsx | 52 +- .../settings/_components/settings-content.tsx | 26 +- apps/web/src/app/i/[token]/page.tsx | 130 +++-- .../data/current-open-invoice-card.tsx | 5 +- apps/web/src/components/data/invoice-list.tsx | 3 +- .../forms/invoice-calendar-view.tsx | 22 +- .../web/src/components/forms/invoice-form.tsx | 488 +++++++++--------- .../src/components/invoice-import-page.tsx | 17 +- .../time-clock/time-entries-history.tsx | 5 +- apps/web/src/components/ui/date-picker.tsx | 27 +- apps/web/src/lib/draft-invoice.ts | 6 +- .../src/lib/email-templates/invoice-email.ts | 24 +- .../src/lib/email-templates/reminder-email.ts | 35 +- apps/web/src/lib/invoice-import.ts | 28 +- apps/web/src/lib/invoice-status.ts | 9 +- apps/web/src/lib/pdf-export.tsx | 16 +- apps/web/src/lib/time-entry-display.ts | 11 +- .../server/api/lib/time-entry-invoice-sync.ts | 26 +- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/dashboard.ts | 60 ++- apps/web/src/server/api/routers/invoices.ts | 59 +++ .../src/server/api/routers/notifications.ts | 51 ++ .../server/api/routers/recurring-invoices.ts | 54 +- apps/web/src/server/api/routers/settings.ts | 16 + .../src/server/api/routers/time-entries.ts | 237 +++++++-- apps/web/src/server/db/schema.ts | 137 +++-- .../server/jobs/handlers/invoice-reminder.ts | 71 +++ .../server/jobs/handlers/recurring-invoice.ts | 16 +- .../src/server/services/recurring-invoices.ts | 64 ++- .../src/server/services/send-invoice-email.ts | 1 + apps/worker/src/index.ts | 5 + apps/worker/tests/recurring-invoices.test.ts | 8 +- docker-compose.coolify.yml | 2 + docker-compose.dev.yml | 1 + docker-compose.yml | 3 + packages/domain/src/invoice-status.ts | 46 +- packages/domain/src/time-zone.ts | 246 ++++++++- packages/domain/tests/domain.test.ts | 77 +++ 63 files changed, 2515 insertions(+), 779 deletions(-) create mode 100644 apps/web/drizzle/0031_timezone_safety.sql create mode 100644 apps/web/src/server/api/routers/notifications.ts create mode 100644 apps/web/src/server/jobs/handlers/invoice-reminder.ts diff --git a/apps/mobile/app/(app)/invoices/edit/[id].tsx b/apps/mobile/app/(app)/invoices/edit/[id].tsx index 857215d..600c1bb 100644 --- a/apps/mobile/app/(app)/invoices/edit/[id].tsx +++ b/apps/mobile/app/(app)/invoices/edit/[id].tsx @@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; -import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor"; +import { + LineItemEditor, + type EditableLineItem, +} from "@/components/invoices/LineItemEditor"; import { LoadingScreen } from "@/components/LoadingScreen"; import { Card } from "@/components/ui/Card"; import { fonts, spacing } from "@/constants/theme"; @@ -35,6 +38,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import type { ThemeColors } from "@/lib/theme-palette"; import { useThemedStyles } from "@/lib/use-themed-styles"; import { api } from "@/lib/trpc"; +import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone"; export default function InvoiceEditScreen() { const { colors } = useAppTheme(); @@ -53,7 +57,9 @@ export default function InvoiceEditScreen() { const [businessId, setBusinessId] = useState(""); const [clientId, setClientId] = useState(""); const [notes, setNotes] = useState(""); - const [dueDate, setDueDate] = useState(() => new Date()); + const [dueDate, setDueDate] = useState(() => + calendarDateFromLocalDate(new Date()), + ); const [taxRate, setTaxRate] = useState("0"); const [sendReminderAt, setSendReminderAt] = useState(null); const [items, setItems] = useState([]); @@ -68,7 +74,9 @@ export default function InvoiceEditScreen() { setNotes(invoice.notes ?? ""); setDueDate(new Date(invoice.dueDate)); setTaxRate(String(invoice.taxRate)); - setSendReminderAt(invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null); + setSendReminderAt( + invoice.sendReminderAt ? new Date(invoice.sendReminderAt) : null, + ); setItems( invoice.items.map((item) => ({ id: item.id, @@ -119,9 +127,14 @@ export default function InvoiceEditScreen() { [clientsQuery.data], ); - const selectedClient = clientsQuery.data?.find((client) => client.id === clientId); + const selectedClient = clientsQuery.data?.find( + (client) => client.id === clientId, + ); const currency = selectedClient?.currency ?? invoice?.currency ?? "USD"; - const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data); + const resolvedBusinessId = resolveInvoiceBusinessId( + businessId, + businessesQuery.data, + ); const subtotal = useMemo( () => @@ -137,8 +150,12 @@ export default function InvoiceEditScreen() { const taxAmount = subtotal * (parsedTaxRate / 100); const total = subtotal + taxAmount; const lineItemsError = isDraft ? validateLineItems(items) : null; - const taxError = isDraft && !isValidTaxRate(taxRate) ? "Tax rate must be between 0 and 100" : null; - const businessError = isDraft && !resolvedBusinessId ? "Select a business" : undefined; + const taxError = + isDraft && !isValidTaxRate(taxRate) + ? "Tax rate must be between 0 and 100" + : null; + const businessError = + isDraft && !resolvedBusinessId ? "Select a business" : undefined; const clientError = isDraft && !clientId ? "Select a client" : undefined; const canSave = isDraft ? !lineItemsError && !taxError && !businessError && !clientError @@ -159,13 +176,26 @@ export default function InvoiceEditScreen() { currency, items, }); - }, [invoice, resolvedBusinessId, clientId, dueDate, notes, parsedTaxRate, currency, items]); + }, [ + invoice, + resolvedBusinessId, + clientId, + dueDate, + notes, + parsedTaxRate, + currency, + items, + ]); if (!id) { return ; } - if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) { + if ( + invoiceQuery.isLoading || + businessesQuery.isLoading || + clientsQuery.isLoading + ) { return ; } @@ -177,14 +207,16 @@ export default function InvoiceEditScreen() { const clientEmail = invoice.client?.email?.trim() ?? ""; function updateItem(index: number, patch: Partial) { - setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); + setItems((prev) => + prev.map((item, i) => (i === index ? { ...item, ...patch } : item)), + ); } function addItem() { setItems((prev) => [ ...prev, { - date: new Date(), + date: calendarDateFromLocalDate(new Date()), description: "", hours: "1", rate: prev[prev.length - 1]?.rate ?? "0", @@ -260,8 +292,13 @@ export default function InvoiceEditScreen() { style={styles.flex} > @@ -316,13 +353,13 @@ export default function InvoiceEditScreen() { {!isDraft ? ( - Line items are locked after an invoice is sent. Mark as draft on the invoice - screen to edit entries. + Line items are locked after an invoice is sent. Mark as + draft on the invoice screen to edit entries. ) : items.length === 0 ? ( - No line items yet. Add lines here or clock time to this invoice from the - Timer tab. + No line items yet. Add lines here or clock time to this + invoice from the Timer tab. ) : null} {items.map((item, index) => ( @@ -334,26 +371,40 @@ export default function InvoiceEditScreen() { isLast={index === items.length - 1} onChange={(patch) => updateItem(index, patch)} onRemove={() => removeItem(index)} - onDuplicate={isDraft ? () => duplicateItem(index) : undefined} + onDuplicate={ + isDraft ? () => duplicateItem(index) : undefined + } readOnly={!isDraft} /> ))} {isDraft ? ( - + + Add another line ) : null} 0 ? `Tax (${parsedTaxRate}%)` : undefined} - taxAmount={parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined} + taxLabel={ + parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined + } + taxAmount={ + parsedTaxRate > 0 + ? formatCurrency(taxAmount, currency) + : undefined + } total={formatCurrency(total, currency)} /> - {lineItemsError ? {lineItemsError} : null} + {lineItemsError ? ( + {lineItemsError} + ) : null} )} @@ -367,7 +418,8 @@ export default function InvoiceEditScreen() { secondary={ status !== "paid" ? { - title: status === "draft" ? "Send invoice" : "Resend invoice", + title: + status === "draft" ? "Send invoice" : "Resend invoice", subtitle: clientEmail ? items.length === 0 ? "Add line items before sending" diff --git a/apps/mobile/app/(app)/invoices/new.tsx b/apps/mobile/app/(app)/invoices/new.tsx index 81aa3f3..6fd2d4a 100644 --- a/apps/mobile/app/(app)/invoices/new.tsx +++ b/apps/mobile/app/(app)/invoices/new.tsx @@ -20,7 +20,10 @@ import { InvoiceEditorFooter } from "@/components/invoices/InvoiceEditorFooter"; import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview"; import { InvoiceSetupForm } from "@/components/invoices/InvoiceSetupForm"; import { InvoiceTotals } from "@/components/invoices/InvoiceTotals"; -import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor"; +import { + LineItemEditor, + type EditableLineItem, +} from "@/components/invoices/LineItemEditor"; import { LoadingScreen } from "@/components/LoadingScreen"; import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; @@ -39,6 +42,7 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import type { ThemeColors } from "@/lib/theme-palette"; import { useThemedStyles } from "@/lib/use-themed-styles"; import { api } from "@/lib/trpc"; +import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone"; export default function NewInvoiceScreen() { const styles = useThemedStyles(createNewInvoiceStyles); @@ -53,8 +57,12 @@ export default function NewInvoiceScreen() { const [businessId, setBusinessId] = useState(""); const [clientId, setClientId] = useState(""); const [invoiceNumber, setInvoiceNumber] = useState(generateInvoiceNumber); - const [issueDate, setIssueDate] = useState(() => new Date()); - const [dueDate, setDueDate] = useState(() => defaultDueDate(new Date())); + const [issueDate, setIssueDate] = useState(() => + calendarDateFromLocalDate(new Date()), + ); + const [dueDate, setDueDate] = useState(() => + defaultDueDate(calendarDateFromLocalDate(new Date())), + ); const [notes, setNotes] = useState(""); const [taxRate, setTaxRate] = useState("0"); const [items, setItems] = useState(() => @@ -62,7 +70,7 @@ export default function NewInvoiceScreen() { ? [] : [ { - date: new Date(), + date: calendarDateFromLocalDate(new Date()), description: "", hours: "1", rate: "0", @@ -96,9 +104,14 @@ export default function NewInvoiceScreen() { [clientsQuery.data], ); - const selectedClient = clientsQuery.data?.find((client) => client.id === clientId); + const selectedClient = clientsQuery.data?.find( + (client) => client.id === clientId, + ); const currency = selectedClient?.currency ?? "USD"; - const resolvedBusinessId = resolveInvoiceBusinessId(businessId, businessesQuery.data); + const resolvedBusinessId = resolveInvoiceBusinessId( + businessId, + businessesQuery.data, + ); useEffect(() => { if (!selectedClient?.defaultHourlyRate) return; @@ -170,7 +183,9 @@ export default function NewInvoiceScreen() { const invoiceNumberError = isRequiredString(invoiceNumber) ? undefined : "Invoice number is required"; - const taxError = isValidTaxRate(taxRate) ? undefined : "Tax rate must be between 0 and 100"; + const taxError = isValidTaxRate(taxRate) + ? undefined + : "Tax rate must be between 0 and 100"; const lineItemsError = validateLineItems(items); const canCreate = businessOptions.length > 0 && @@ -187,7 +202,9 @@ export default function NewInvoiceScreen() { function updateItem(index: number, patch: Partial) { touch("lineItems"); - setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); + setItems((prev) => + prev.map((item, i) => (i === index ? { ...item, ...patch } : item)), + ); } function addItem() { @@ -195,7 +212,7 @@ export default function NewInvoiceScreen() { setItems((prev) => [ ...prev, { - date: new Date(), + date: calendarDateFromLocalDate(new Date()), description: "", hours: "1", rate: prev[prev.length - 1]?.rate ?? "0", @@ -266,8 +283,13 @@ export default function NewInvoiceScreen() { style={styles.flex} > @@ -287,7 +309,11 @@ export default function NewInvoiceScreen() { : "Add a client before creating an invoice."} @@ -383,7 +489,12 @@ export default function RecurringInvoicesPage() { title="Create your first recurring invoice" description="Automatically generate draft invoices on a schedule you choose." action={ - @@ -400,7 +511,11 @@ export default function RecurringInvoicesPage() {

{rec.name}

- + {rec.status}
@@ -408,14 +523,18 @@ export default function RecurringInvoicesPage() { {rec.client.name} · {scheduleLabel(rec.schedule)}

- Next: {formatDate(rec.nextDueAt)} + Next: {formatDate(rec.nextDueAt, rec.timeZone)} {rec.lastGeneratedAt && ( - <> · Last generated: {formatDate(rec.lastGeneratedAt)} + <> + {" "} + · Last generated:{" "} + {formatDate(rec.lastGeneratedAt, rec.timeZone)} + )}

-
+
- @@ -510,12 +649,18 @@ export default function RecurringInvoicesPage() { {/* Delete Confirmation */} - { if (!open) setDeleteId(null); }}> + { + if (!open) setDeleteId(null); + }} + > Delete recurring invoice - This will stop automatic generation. Already-generated invoices are not affected. + This will stop automatic generation. Already-generated invoices + are not affected. diff --git a/apps/web/src/app/dashboard/reports/page.tsx b/apps/web/src/app/dashboard/reports/page.tsx index 2687037..be95357 100644 --- a/apps/web/src/app/dashboard/reports/page.tsx +++ b/apps/web/src/app/dashboard/reports/page.tsx @@ -3,7 +3,10 @@ import { useMemo, useState } from "react"; import { api } from "~/trpc/react"; import { DashboardPageHeader } from "~/components/layout/page-header"; -import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; +import { + DashboardPage, + dashboardStatGridClass, +} from "~/components/layout/dashboard-page"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { StatusBadge } from "~/components/data/status-badge"; import { Button } from "~/components/ui/button"; @@ -24,6 +27,10 @@ import { import { formatCurrency } from "~/lib/currency"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import type { StoredInvoiceStatus } from "~/types/invoice"; +import { + formatCalendarDate, + getZonedDateTimeParts, +} from "@beenvoice/domain/time-zone"; import { AreaChart, Area, @@ -63,7 +70,9 @@ export default function ReportsPage() { const isLoading = invoicesLoading || expensesLoading; - const currentYear = new Date().getFullYear(); + const { data: profile } = api.settings.getProfile.useQuery(); + const reportTimeZone = profile?.timeZone ?? "America/New_York"; + const currentYear = getZonedDateTimeParts(new Date(), reportTimeZone).year; const [taxYear, setTaxYear] = useState(String(currentYear)); const filteredInvoices = useMemo(() => { @@ -76,10 +85,11 @@ export default function ReportsPage() { if (!filteredInvoices.length) return null; const now = new Date(); + const current = getZonedDateTimeParts(now, reportTimeZone); const monthMap: Record = {}; for (let i = 11; i >= 0; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const d = new Date(Date.UTC(current.year, current.month - 1 - i, 1)); + const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; monthMap[key] = 0; } @@ -91,10 +101,11 @@ export default function ReportsPage() { const status = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, + reportTimeZone, ); if (status === "paid") { totalRevenue += inv.totalAmount; - const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`; + const key = `${new Date(inv.issueDate).getUTCFullYear()}-${String(new Date(inv.issueDate).getUTCMonth() + 1).padStart(2, "0")}`; if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount; } else if (status === "sent" || status === "overdue") { totalPending += inv.totalAmount; @@ -103,7 +114,7 @@ export default function ReportsPage() { } const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({ - month: new Date(month + "-01").toLocaleDateString("en-US", { + month: formatCalendarDate(month + "-01", { month: "short", year: "2-digit", }), @@ -115,6 +126,7 @@ export default function ReportsPage() { const status = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, + reportTimeZone, ); if (status === "paid" && inv.client) { const id = inv.client.id; @@ -139,6 +151,7 @@ export default function ReportsPage() { const s = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, + reportTimeZone, ); statusCount[s] = (statusCount[s] ?? 0) + 1; } @@ -151,7 +164,7 @@ export default function ReportsPage() { totalHours, statusCount, }; - }, [filteredInvoices]); + }, [filteredInvoices, reportTimeZone]); // Tax summary for selected year const taxData = useMemo(() => { @@ -161,13 +174,14 @@ export default function ReportsPage() { const status = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, + reportTimeZone, ); return ( - status === "paid" && new Date(inv.issueDate).getFullYear() === year + status === "paid" && new Date(inv.issueDate).getUTCFullYear() === year ); }); const yearExpenses = expenses.filter( - (exp) => new Date(exp.date).getFullYear() === year, + (exp) => new Date(exp.date).getUTCFullYear() === year, ); const getSubtotal = (inv: (typeof yearInvoices)[number]) => { @@ -211,10 +225,12 @@ export default function ReportsPage() { return { label: `Q${q}`, income: yearInvoices - .filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())) + .filter((inv) => + qMonths.includes(new Date(inv.issueDate).getUTCMonth()), + ) .reduce((s, inv) => s + getSubtotal(inv), 0), expenses: yearExpenses - .filter((exp) => qMonths.includes(new Date(exp.date).getMonth())) + .filter((exp) => qMonths.includes(new Date(exp.date).getUTCMonth())) .reduce((s, exp) => s + exp.amount, 0), }; }); @@ -233,13 +249,13 @@ export default function ReportsPage() { yearInvoices, yearExpenses, }; - }, [filteredInvoices, expenses, taxYear]); + }, [filteredInvoices, expenses, taxYear, reportTimeZone]); const availableYears = useMemo(() => { const years = new Set([currentYear, currentYear - 1]); for (const inv of filteredInvoices) - years.add(new Date(inv.issueDate).getFullYear()); - for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); + years.add(new Date(inv.issueDate).getUTCFullYear()); + for (const exp of expenses) years.add(new Date(exp.date).getUTCFullYear()); return Array.from(years).sort((a, b) => b - a); }, [filteredInvoices, expenses, currentYear]); @@ -251,6 +267,7 @@ export default function ReportsPage() { getEffectiveInvoiceStatus( i.status as StoredInvoiceStatus, i.dueDate, + reportTimeZone, ) === "paid", ).length || 1) : 0; @@ -272,7 +289,7 @@ export default function ReportsPage() { const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal; const taxAmt = inv.totalAmount - invoiceSubtotal; return [ - new Date(inv.issueDate).toLocaleDateString("en-US"), + formatCalendarDate(inv.issueDate), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, invoiceSubtotal.toFixed(2), @@ -287,7 +304,7 @@ export default function ReportsPage() { "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", ...taxData.yearExpenses.map((exp) => [ - new Date(exp.date).toLocaleDateString("en-US"), + formatCalendarDate(exp.date), `"${exp.description}"`, `"${exp.category ?? ""}"`, exp.amount.toFixed(2), @@ -634,7 +651,7 @@ export default function ReportsPage() {

{inv.client?.name ?? "—"}

- {new Date(inv.issueDate).toLocaleDateString("en-US", { + {formatCalendarDate(inv.issueDate, { month: "short", day: "numeric", year: "numeric", @@ -647,6 +664,7 @@ export default function ReportsPage() { getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, + reportTimeZone, ) as never } /> diff --git a/apps/web/src/app/dashboard/settings/_components/settings-content.tsx b/apps/web/src/app/dashboard/settings/_components/settings-content.tsx index 7af69e6..8650db5 100644 --- a/apps/web/src/app/dashboard/settings/_components/settings-content.tsx +++ b/apps/web/src/app/dashboard/settings/_components/settings-content.tsx @@ -92,6 +92,7 @@ import type { PdfFontFamily, PdfTemplate } from "~/lib/appearance"; import { pdfFontFamilyOptions } from "~/lib/pdf-fonts"; import { ApiAccessSettings } from "./api-access-settings"; import { ImportPageHeaderActions } from "./invoice-import/import-page-header-actions"; +import { DEFAULT_TIME_ZONE } from "@beenvoice/domain/time-zone"; const InvoiceImportPage = dynamic( () => @@ -147,6 +148,7 @@ export function SettingsContent({ const { data: session } = useAuthSession(); const [name, setName] = useState(""); + const [timeZone, setTimeZone] = useState(DEFAULT_TIME_ZONE); const [nameInitialized, setNameInitialized] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [importData, setImportData] = useState(""); @@ -309,7 +311,7 @@ export function SettingsContent({ toast.error("Please enter your name"); return; } - updateProfileMutation.mutate({ name: name.trim() }); + updateProfileMutation.mutate({ name: name.trim(), timeZone }); }; const handleChangePassword = (e: React.FormEvent) => { @@ -423,8 +425,15 @@ export function SettingsContent({ if (nameInitialized || !profileFetched) return; // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field. setName(profile?.name ?? session?.user?.name ?? ""); + setTimeZone(profile?.timeZone ?? DEFAULT_TIME_ZONE); setNameInitialized(true); - }, [profile?.name, profileFetched, session?.user?.name, nameInitialized]); + }, [ + profile?.name, + profile?.timeZone, + profileFetched, + session?.user?.name, + nameInitialized, + ]); // (Removed direct DOM mutation; provider handles applying preferences globally) @@ -497,6 +506,19 @@ export function SettingsContent({ Email address cannot be changed

+
+ + setTimeZone(event.target.value)} + placeholder="America/New_York" + /> +

+ IANA time zone used for recurring schedules, reminders, and + reports. +

+
diff --git a/apps/web/src/components/data/current-open-invoice-card.tsx b/apps/web/src/components/data/current-open-invoice-card.tsx index dcd7ae6..6c55699 100644 --- a/apps/web/src/components/data/current-open-invoice-card.tsx +++ b/apps/web/src/components/data/current-open-invoice-card.tsx @@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Skeleton } from "~/components/ui/skeleton"; import { api } from "~/trpc/react"; +import { formatCalendarDate } from "@beenvoice/domain/time-zone"; export function CurrentOpenInvoiceCard() { const { data: currentInvoice, isLoading } = @@ -20,10 +21,10 @@ export function CurrentOpenInvoiceCard() { }; const formatDate = (date: Date) => { - return new Intl.DateTimeFormat("en-US", { + return formatCalendarDate(date, { month: "short", day: "numeric", - }).format(new Date(date)); + }); }; if (isLoading) { diff --git a/apps/web/src/components/data/invoice-list.tsx b/apps/web/src/components/data/invoice-list.tsx index cbe7d8a..0b75164 100644 --- a/apps/web/src/components/data/invoice-list.tsx +++ b/apps/web/src/components/data/invoice-list.tsx @@ -32,6 +32,7 @@ import { Plus, User, } from "lucide-react"; +import { formatCalendarDate } from "@beenvoice/domain/time-zone"; export function InvoiceList() { const [searchTerm, setSearchTerm] = useState(""); @@ -72,7 +73,7 @@ export function InvoiceList() { }; const formatDate = (date: Date) => { - return new Date(date).toLocaleDateString(); + return formatCalendarDate(date); }; const formatCurrency = (amount: number) => { diff --git a/apps/web/src/components/forms/invoice-calendar-view.tsx b/apps/web/src/components/forms/invoice-calendar-view.tsx index d99ee3e..9dc970b 100644 --- a/apps/web/src/components/forms/invoice-calendar-view.tsx +++ b/apps/web/src/components/forms/invoice-calendar-view.tsx @@ -24,6 +24,10 @@ import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { NumberInput } from "~/components/ui/number-input"; +import { + calendarDateFromLocalDate, + calendarDateToLocalDate, +} from "@beenvoice/domain/time-zone"; import { Plus, Trash2, @@ -77,7 +81,7 @@ export function InvoiceCalendarView({ return items .map((item, index) => ({ item, index })) .filter((wrapper) => { - const itemDate = new Date(wrapper.item.date); + const itemDate = calendarDateToLocalDate(wrapper.item.date); return isSameDay(itemDate, date); }); }, [items, date]); @@ -88,7 +92,7 @@ export function InvoiceCalendarView({ return items .map((item, index) => ({ item, index })) .filter((wrapper) => { - const itemDate = new Date(wrapper.item.date); + const itemDate = calendarDateToLocalDate(wrapper.item.date); return isSameDay(itemDate, targetDate); }); }, @@ -103,7 +107,7 @@ export function InvoiceCalendarView({ const handleAddNewItem = () => { if (date) { - onAddItem(date); + onAddItem(calendarDateFromLocalDate(date)); } }; @@ -407,7 +411,11 @@ export function InvoiceCalendarView({

{!readOnly ? ( - @@ -494,7 +502,11 @@ export function InvoiceCalendarView({ Total - ${calculateLineItemAmount(item.hours, item.rate).toFixed(2)} + $ + {calculateLineItemAmount( + item.hours, + item.rate, + ).toFixed(2)} diff --git a/apps/web/src/components/forms/invoice-form.tsx b/apps/web/src/components/forms/invoice-form.tsx index 1c7a463..6981b61 100644 --- a/apps/web/src/components/forms/invoice-form.tsx +++ b/apps/web/src/components/forms/invoice-form.tsx @@ -42,7 +42,8 @@ import { Mail, } from "lucide-react"; import { SUPPORTED_CURRENCIES } from "~/lib/currency"; -import { generateInvoiceNumber } from "~/lib/draft-invoice"; +import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice"; +import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone"; import { Textarea } from "~/components/ui/textarea"; import { DropdownMenu, @@ -108,13 +109,14 @@ function plainTextToHtml(value: string) { } function createDefaultInvoiceFormData(): InvoiceFormData { + const today = calendarDateFromLocalDate(new Date()); return { invoiceNumber: generateInvoiceNumber(), invoicePrefix: "#", businessId: "", clientId: "", - issueDate: new Date(), - dueDate: new Date(), + issueDate: today, + dueDate: defaultDueDate(today), status: "draft", notes: "", emailMessage: "", @@ -124,7 +126,7 @@ function createDefaultInvoiceFormData(): InvoiceFormData { items: [ { id: crypto.randomUUID(), - date: new Date(), + date: today, description: "", hours: 1, rate: 0, @@ -209,7 +211,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { : [ { id: crypto.randomUUID(), - date: new Date(), + date: calendarDateFromLocalDate(new Date()), description: "", hours: 1, rate: 0, @@ -275,10 +277,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { items: formData.items.map((item) => ({ date: item.date, description: item.description || "Service", - hours: item.hours, - rate: item.rate, - amount: calculateLineItemAmount(item.hours, item.rate), - })), + hours: item.hours, + rate: item.rate, + amount: calculateLineItemAmount(item.hours, item.rate), + })), }), [formData], ); @@ -320,7 +322,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { ...prev.items, { id: crypto.randomUUID(), - date: new Date(), + date: calendarDateFromLocalDate(new Date()), description: parsed.description, hours: parsed.hours ?? 1, rate: parsed.rate ?? prev.defaultHourlyRate ?? 0, @@ -350,7 +352,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { items: prev.items.map((item, i) => { if (i !== idx) return item; - if (field === "billingType" && (value === "hourly" || value === "fixed")) { + if ( + field === "billingType" && + (value === "hourly" || value === "fixed") + ) { const next = applyBillingTypeChange(value, item); return { ...item, @@ -401,7 +406,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { return; } - const itemsToSave = formData.items.filter((item) => item.description?.trim()); + const itemsToSave = formData.items.filter((item) => + item.description?.trim(), + ); let invalidItemIndex = -1; for (let i = 0; i < formData.items.length; i++) { @@ -515,7 +522,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { - + Details Items @@ -526,248 +537,256 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { {/* DETAILS TAB */}
- - - - Client Details - - - -
- - -
-
- - -
-
-
- - - - - Invoice Settings - - - -
+ + + + Client Details + + +
- - - updateField("issueDate", d ?? new Date()) - } - className="w-full" - /> -
-
- - - updateField("dueDate", d ?? new Date()) - } - className="w-full" - /> -
-
-
-
- - - updateField("invoicePrefix", e.target.value) - } - placeholder="#" - className="w-full" - /> -
-
- - - updateField("invoiceNumber", e.target.value) - } - placeholder="INV-20260428-000001" - className="w-full font-mono" - /> -
-
-
-
- - updateField("taxRate", v)} - suffix="%" - className="w-full" - /> -
-
- - updateField("defaultHourlyRate", v)} - prefix="$" - disabled={!formData.clientId} - className="w-full" - /> -
-
-
-
- +
- +
-
-
-
+ + - - - - - Email Message - - - - -