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
+75 -23
View File
@@ -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<Date | null>(null);
const [items, setItems] = useState<EditableLineItem[]>([]);
@@ -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 <LoadingScreen message="Invalid invoice" />;
}
if (invoiceQuery.isLoading || businessesQuery.isLoading || clientsQuery.isLoading) {
if (
invoiceQuery.isLoading ||
businessesQuery.isLoading ||
clientsQuery.isLoading
) {
return <LoadingScreen message="Loading invoice…" />;
}
@@ -177,14 +207,16 @@ export default function InvoiceEditScreen() {
const clientEmail = invoice.client?.email?.trim() ?? "";
function updateItem(index: number, patch: Partial<EditableLineItem>) {
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}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
@@ -316,13 +353,13 @@ export default function InvoiceEditScreen() {
<Card title="Line items">
{!isDraft ? (
<Text style={styles.lockedHint}>
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.
</Text>
) : items.length === 0 ? (
<Text style={styles.emptyLines}>
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.
</Text>
) : 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 ? (
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Pressable
accessibilityRole="button"
onPress={addItem}
style={styles.addLine}
>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
) : null}
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 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)}
/>
</Card>
{lineItemsError ? <Text style={styles.error}>{lineItemsError}</Text> : null}
{lineItemsError ? (
<Text style={styles.error}>{lineItemsError}</Text>
) : 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"
+62 -20
View File
@@ -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<EditableLineItem[]>(() =>
@@ -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<EditableLineItem>) {
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}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
@@ -287,7 +309,11 @@ export default function NewInvoiceScreen() {
: "Add a client before creating an invoice."}
</Text>
<Button
title={businessOptions.length === 0 ? "Add business" : "Add client"}
title={
businessOptions.length === 0
? "Add business"
: "Add client"
}
variant="secondary"
onPress={() =>
router.push(
@@ -303,7 +329,9 @@ export default function NewInvoiceScreen() {
businessId={businessId}
onBusinessIdChange={setBusinessId}
businessOptions={businessOptions}
businessError={visible("business") ? businessError : undefined}
businessError={
visible("business") ? businessError : undefined
}
onBusinessBlur={() => touch("business")}
clientId={clientId}
onClientIdChange={setClientId}
@@ -334,8 +362,8 @@ export default function NewInvoiceScreen() {
<Card title="Line items">
{isBlank && items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Save this draft and clock time to it from the Timer tab,
or add lines here.
No line items yet. Save this draft and clock time to it from
the Timer tab, or add lines here.
</Text>
) : null}
{items.map((item, index) => (
@@ -351,27 +379,41 @@ export default function NewInvoiceScreen() {
/>
))}
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
<Pressable
accessibilityRole="button"
onPress={addItem}
style={styles.addLine}
>
<Text style={styles.addLineText}>+ Add another line</Text>
</Pressable>
<InvoiceTotals
subtotal={formatCurrency(subtotal, currency)}
taxLabel={parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined}
taxLabel={
parsedTaxRate > 0 ? `Tax (${parsedTaxRate}%)` : undefined
}
taxAmount={
parsedTaxRate > 0 ? formatCurrency(taxAmount, currency) : undefined
parsedTaxRate > 0
? formatCurrency(taxAmount, currency)
: undefined
}
total={formatCurrency(total, currency)}
/>
</Card>
{visible("lineItems") && lineItemsError ? (
<Text selectable style={styles.error}>{lineItemsError}</Text>
<Text selectable style={styles.error}>
{lineItemsError}
</Text>
) : null}
</>
)}
{error ? <Text selectable style={styles.error}>{error}</Text> : null}
{error ? (
<Text selectable style={styles.error}>
{error}
</Text>
) : null}
<InvoiceEditorFooter
primaryTitle={isBlank ? "Create blank invoice" : "Create invoice"}
+21 -4
View File
@@ -20,7 +20,9 @@ import { DateTimeField } from "@/components/ui/DateTimeField";
import {
formatZonedDateTime,
getDefaultScheduledSendAt,
getLocalTimeZone,
DEFAULT_TIME_ZONE,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
@@ -42,7 +44,8 @@ export default function InvoiceSendScreen() {
const [scheduledAt, setScheduledAt] = useState(() =>
getDefaultScheduledSendAt(),
);
const timeZone = useMemo(() => getLocalTimeZone(), []);
const profileQuery = api.settings.getProfile.useQuery();
const timeZone = profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE;
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -139,7 +142,21 @@ export default function InvoiceSendScreen() {
function handleSchedule() {
if (!clientEmail || invoice.items.length === 0) return;
if (scheduledAt.getTime() < Date.now() + 60_000) {
let instant: Date;
try {
instant = zonedDateTimeToInstant(
toLocalDateTimeInputValue(scheduledAt),
timeZone,
"earlier",
);
} catch (error) {
Alert.alert(
"Choose another time",
error instanceof Error ? error.message : "Invalid local time",
);
return;
}
if (instant.getTime() < Date.now() + 60_000) {
Alert.alert(
"Choose a future time",
"The scheduled time must be at least one minute from now.",
@@ -148,7 +165,7 @@ export default function InvoiceSendScreen() {
}
scheduleInvoice.mutate({
invoiceId: invoice.id,
scheduledAt,
scheduledAt: instant,
timeZone,
customMessage: customMessage.trim() || undefined,
});