Make scheduling and dates timezone-safe
This commit is contained in:
@@ -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)}`;
|
||||
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user