Make scheduling and dates timezone-safe

This commit is contained in:
2026-08-17 18:15:39 -04:00
parent 1853eaa963
commit 70c08054fb
63 changed files with 2515 additions and 779 deletions
+3 -3
View File
@@ -1,3 +1,5 @@
import { addCalendarDays } from "@beenvoice/domain/time-zone";
/** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string {
const date = [
@@ -10,7 +12,5 @@ export function generateInvoiceNumber(now = new Date()): string {
}
export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
return addCalendarDays(issueDate, 30);
}
@@ -1,11 +1,19 @@
import { getAppUrl } from "~/lib/app-url";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
// with SVG (Outlook and several webmail clients strip or refuse it), so
// non-raster logos are requested through the same on-the-fly PNG
// rasterization the PDF export uses.
function resolveEmailLogoUrl(
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
business:
| {
id?: string;
logoStorageKey?: string | null;
logoMimeType?: string | null;
}
| null
| undefined,
baseUrl: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
@@ -57,6 +65,7 @@ interface InvoiceEmailTemplateProps {
userName?: string;
userEmail?: string;
baseUrl?: string;
timeZone?: string;
}
export function generateInvoiceEmailTemplate({
@@ -66,13 +75,14 @@ export function generateInvoiceEmailTemplate({
userName,
userEmail,
baseUrl = getAppUrl(),
timeZone = "America/New_York",
}: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
return formatCalendarDate(date, {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
});
};
const formatCurrency = (amount: number) => {
@@ -83,7 +93,13 @@ export function generateInvoiceEmailTemplate({
};
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
const hour = Number(
new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "numeric",
hourCycle: "h23",
}).format(new Date()),
);
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
@@ -1,3 +1,8 @@
import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
interface ReminderEmailTemplateProps {
invoice: {
invoiceNumber: string;
@@ -15,6 +20,7 @@ interface ReminderEmailTemplateProps {
customMessage?: string;
userName?: string;
userEmail?: string;
timeZone?: string;
}
export function generateReminderEmailTemplate({
@@ -22,11 +28,18 @@ export function generateReminderEmailTemplate({
customMessage,
userName,
userEmail,
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
timeZone = "America/New_York",
}: ReminderEmailTemplateProps): {
html: string;
text: string;
subject: string;
} {
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
new Date(date),
);
formatCalendarDate(date, {
year: "numeric",
month: "long",
day: "numeric",
});
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("en-US", {
@@ -34,14 +47,14 @@ export function generateReminderEmailTemplate({
currency: invoice.currency ?? "USD",
}).format(amount);
const senderName =
invoice.business?.name
? invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name
: userName ?? "Your service provider";
const senderName = invoice.business?.name
? invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name
: (userName ?? "Your service provider");
const isOverdue = new Date(invoice.dueDate) < new Date();
const isOverdue =
getEffectiveInvoiceStatus("sent", invoice.dueDate, timeZone) === "overdue";
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber}${formatCurrency(invoice.totalAmount)}`;
+18 -10
View File
@@ -1,3 +1,8 @@
import {
addCalendarDays,
calendarDateFromLocalDate,
} from "@beenvoice/domain/time-zone";
export type ImportFormat = "csv" | "json";
export interface ImportItem {
@@ -86,8 +91,9 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
// ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) {
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
const key = `${isoMatch[1]}-${isoMatch[2]}-${isoMatch[3]}`;
const d = new Date(`${key}T12:00:00.000Z`);
if (!isNaN(d.getTime()) && d.toISOString().slice(0, 10) === key) return d;
}
// M/DD/YY or M/DD/YYYY
@@ -98,11 +104,11 @@ export function parseFlexibleDate(dateStr: string): Date | undefined {
let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000;
const d = new Date(year, month, day);
if (!isNaN(d.getTime())) return d;
if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
}
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
if (!isNaN(d.getTime())) return calendarDateFromLocalDate(d);
return undefined;
}
@@ -128,13 +134,11 @@ function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
return fallback ?? new Date();
return fallback ?? calendarDateFromLocalDate(new Date());
}
function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
return addCalendarDays(issueDate, 30);
}
export function parseInvoiceCSV(
@@ -262,7 +266,9 @@ function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const rate = item.rate ?? 0;
if (!description || description === "Imported item") {
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: description required`,
);
}
if (quantity <= 0) {
errors.push(
@@ -356,7 +362,9 @@ export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
{
name: "JSON Import",
items: [],
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
errors: [
'No invoices found (expected { "invoices": [...] } or an array)',
],
},
];
}
+6 -3
View File
@@ -13,22 +13,25 @@ import type {
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone?: string,
): EffectiveInvoiceStatus {
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate, timeZone);
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone?: string,
): boolean {
return isSharedInvoiceOverdue(storedStatus, dueDate);
return isSharedInvoiceOverdue(storedStatus, dueDate, timeZone);
}
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone?: string,
): number {
return getSharedDaysPastDue(storedStatus, dueDate);
return getSharedDaysPastDue(storedStatus, dueDate, timeZone);
}
export const statusConfig = {
+5 -11
View File
@@ -9,9 +9,8 @@ import {
type Styles,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
import {
isFixedLineItem,
} from "~/lib/invoice-line-item";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
import { isFixedLineItem } from "~/lib/invoice-line-item";
import React from "react";
import {
type PdfFontFamily,
@@ -136,10 +135,7 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
function mapLegacyPdfFont(
fontFamily: string,
fonts: ResolvedPdfFonts,
): string {
function mapLegacyPdfFont(fontFamily: string, fonts: ResolvedPdfFonts): string {
switch (fontFamily) {
case "Helvetica-Bold":
return fonts.bold;
@@ -177,9 +173,7 @@ type PdfStyleBundle = {
styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts;
getStatusStyle: (
status: string,
) => Array<Record<string, string | number>>;
getStatusStyle: (status: string) => Array<Record<string, string | number>>;
};
const pdfStyleCache = new Map<string, PdfStyleBundle>();
@@ -816,7 +810,7 @@ const formatCurrency = (amount: number, currency = "USD") => {
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("en-US", {
return formatCalendarDate(date, {
year: "numeric",
month: "2-digit",
day: "2-digit",
+9 -2
View File
@@ -1,3 +1,8 @@
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
export function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
@@ -37,12 +42,13 @@ export type TimeEntryListItem = {
export function groupEntriesByDate<T extends { startedAt: Date }>(
entries: T[],
timeZone = DEFAULT_TIME_ZONE,
): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const parts = getZonedDateTimeParts(entry.startedAt, timeZone);
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const existing = groups.get(dateKey);
if (existing) {
existing.push(entry);
@@ -58,6 +64,7 @@ export function groupEntriesByDate<T extends { startedAt: Date }>(
year: "numeric",
month: "long",
day: "numeric",
timeZone,
});
return { dateKey, label, entries: groupEntries };
});