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,
});
+5 -2
View File
@@ -19,6 +19,7 @@ import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
import { api } from "@/lib/trpc";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
type ReceiptSplitDraft = Pick<
ReceiptScanResult,
@@ -37,7 +38,7 @@ export default function ExpenseDetailScreen() {
const [form, setForm] = useState<ExpenseFormState>({
description: "",
amountText: "",
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
category: "",
businessId: "",
clientId: "",
@@ -165,7 +166,9 @@ export default function ExpenseDetailScreen() {
return (
<AppBackground>
<TabPage showMoreBack>
<TabScrollView header={<PageHeader title="Expense" subtitle="Expense details" />}>
<TabScrollView
header={<PageHeader title="Expense" subtitle="Expense details" />}
>
<Text style={{ color: colors.mutedForeground }}>
Expense not found
</Text>
+106 -34
View File
@@ -1,12 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
@@ -26,6 +21,7 @@ import { api } from "@/lib/trpc";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type ExpenseFilter = "all" | "billable" | "receipts";
type Expense = inferRouterOutputs<AppRouter>["expenses"]["getAll"][number];
@@ -120,17 +116,27 @@ export default function ExpensesScreen() {
<View
style={[
styles.emptyCard,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<View style={[styles.emptyIcon, { backgroundColor: colors.muted }]}>
<Ionicons name="receipt-outline" size={24} color={colors.primary} />
<View
style={[styles.emptyIcon, { backgroundColor: colors.muted }]}
>
<Ionicons
name="receipt-outline"
size={24}
color={colors.primary}
/>
</View>
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
No expenses yet
</Text>
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
Scan a receipt or add a manual entry when something needs to be tracked, billed, or reimbursed.
Scan a receipt or add a manual entry when something needs to be
tracked, billed, or reimbursed.
</Text>
<Button
title="Add expense"
@@ -140,9 +146,18 @@ export default function ExpensesScreen() {
) : (
<>
<View style={styles.summaryGrid}>
<SummaryTile label="Visible total" value={formatCurrency(summary.total)} />
<SummaryTile label="Billable" value={formatCurrency(summary.billable)} />
<SummaryTile label="Receipts" value={String(summary.receiptCount)} />
<SummaryTile
label="Visible total"
value={formatCurrency(summary.total)}
/>
<SummaryTile
label="Billable"
value={formatCurrency(summary.billable)}
/>
<SummaryTile
label="Receipts"
value={String(summary.receiptCount)}
/>
</View>
<ScrollView
@@ -174,14 +189,21 @@ export default function ExpensesScreen() {
) : (
groupedExpenses.map(([monthLabel, group]) => (
<View key={monthLabel} style={styles.monthGroup}>
<Text style={[styles.monthLabel, { color: colors.mutedForeground }]}>
<Text
style={[
styles.monthLabel,
{ color: colors.mutedForeground },
]}
>
{monthLabel}
</Text>
{group.map((expense) => (
<ExpenseRow
key={expense.id}
expense={expense}
onDelete={() => deleteExpense.mutate({ id: expense.id })}
onDelete={() =>
deleteExpense.mutate({ id: expense.id })
}
/>
))}
</View>
@@ -209,14 +231,23 @@ function SummaryTile({ label, value }: { label: string; value: string }) {
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text style={[styles.summaryValue, { color: colors.foreground }]} numberOfLines={1}>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
numberOfLines={1}
>
{value}
</Text>
</View>
);
}
function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => void }) {
function ExpenseRow({
expense,
onDelete,
}: {
expense: Expense;
onDelete: () => void;
}) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
@@ -236,7 +267,8 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
icon: "open-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/more/expenses/${expense.id}` as never),
onPress: () =>
router.push(`/(app)/more/expenses/${expense.id}` as never),
},
{
key: "delete",
@@ -249,45 +281,77 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
]}
>
<View style={[styles.categoryIcon, { backgroundColor: colors.muted }]}>
<Ionicons name={expenseIcon(expense.category)} size={18} color={colors.primary} />
<Ionicons
name={expenseIcon(expense.category)}
size={18}
color={colors.primary}
/>
</View>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: colors.foreground }]} numberOfLines={1}>
<Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description}
</Text>
{expense.receiptCount ? (
<View
style={[
styles.receiptPill,
{ borderColor: colors.border, backgroundColor: colors.background },
{
borderColor: colors.border,
backgroundColor: colors.background,
},
]}
>
<Ionicons name="document-attach-outline" size={13} color={colors.primary} />
<Ionicons
name="document-attach-outline"
size={13}
color={colors.primary}
/>
<Text style={[styles.receiptPillText, { color: colors.primary }]}>
{expense.receiptCount}
</Text>
</View>
) : null}
</View>
<Text style={[styles.sub, { color: colors.mutedForeground }]} numberOfLines={1}>
<Text
style={[styles.sub, { color: colors.mutedForeground }]}
numberOfLines={1}
>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.tagRow}>
{expense.billable ? (
<Text style={[styles.tag, { color: colors.primary, borderColor: colors.border }]}>
<Text
style={[
styles.tag,
{ color: colors.primary, borderColor: colors.border },
]}
>
Billable
</Text>
) : null}
{expense.reimbursable ? (
<Text style={[styles.tag, { color: colors.foreground, borderColor: colors.border }]}>
<Text
style={[
styles.tag,
{ color: colors.foreground, borderColor: colors.border },
]}
>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text style={[styles.tag, { color: colors.success, borderColor: colors.border }]}>
<Text
style={[
styles.tag,
{ color: colors.success, borderColor: colors.border },
]}
>
Tax
</Text>
) : null}
@@ -297,7 +361,11 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.mutedForeground} />
<Ionicons
name="chevron-forward"
size={16}
color={colors.mutedForeground}
/>
</View>
</SwipeableRow>
);
@@ -306,8 +374,7 @@ function ExpenseRow({ expense, onDelete }: { expense: Expense; onDelete: () => v
function groupExpensesByMonth(expenses: Expense[]) {
const groups = new Map<string, Expense[]>();
for (const expense of expenses) {
const date = new Date(expense.date);
const key = date.toLocaleDateString(undefined, {
const key = formatCalendarDate(expense.date, {
month: "long",
year: "numeric",
});
@@ -320,11 +387,16 @@ function groupExpensesByMonth(expenses: Expense[]) {
function expenseIcon(category: string | null): keyof typeof Ionicons.glyphMap {
const normalized = category?.toLowerCase() ?? "";
if (normalized.includes("travel") || normalized.includes("mileage")) return "airplane-outline";
if (normalized.includes("meal") || normalized.includes("food")) return "restaurant-outline";
if (normalized.includes("software") || normalized.includes("subscription")) return "laptop-outline";
if (normalized.includes("office") || normalized.includes("supply")) return "briefcase-outline";
if (normalized.includes("phone") || normalized.includes("internet")) return "wifi-outline";
if (normalized.includes("travel") || normalized.includes("mileage"))
return "airplane-outline";
if (normalized.includes("meal") || normalized.includes("food"))
return "restaurant-outline";
if (normalized.includes("software") || normalized.includes("subscription"))
return "laptop-outline";
if (normalized.includes("office") || normalized.includes("supply"))
return "briefcase-outline";
if (normalized.includes("phone") || normalized.includes("internet"))
return "wifi-outline";
return "receipt-outline";
}
+3
View File
@@ -303,6 +303,9 @@ export default function SettingsScreen() {
Role: {profile.role}
</Text>
) : null}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Time zone: {profile?.timeZone ?? "America/New_York"}
</Text>
</Card>
<Card title="Accounts">
+51 -17
View File
@@ -17,36 +17,53 @@ import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import { api } from "@/lib/trpc";
import type { AppRouter } from "beenvoice/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import {
DEFAULT_TIME_ZONE,
getZonedDateTimeParts,
} from "@beenvoice/domain/time-zone";
type TimeEntry = inferRouterOutputs<AppRouter>["timeEntries"]["getAll"][number];
function groupByDate(entries: TimeEntry[]) {
function groupByDate(entries: TimeEntry[], timeZone: string) {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const key = d.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
const list = groups.get(key) ?? [];
const parts = getZonedDateTimeParts(d, timeZone);
const dateKey = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
const list = groups.get(dateKey) ?? [];
list.push(entry);
groups.set(key, list);
groups.set(dateKey, list);
}
return Array.from(groups.entries());
return Array.from(groups.entries()).map(
([, groupedEntries]) =>
[
new Date(groupedEntries[0]!.startedAt).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
timeZone,
}),
groupedEntries,
] as const,
);
}
export default function TimeEntriesScreen() {
const { colors } = useAppTheme();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const entriesQuery = api.timeEntries.getAll.useQuery();
const profileQuery = api.settings.getProfile.useQuery();
const completed = useMemo(
() => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
[entriesQuery.data],
);
const grouped = useMemo(() => groupByDate(completed), [completed]);
const grouped = useMemo(
() =>
groupByDate(completed, profileQuery.data?.timeZone ?? DEFAULT_TIME_ZONE),
[completed, profileQuery.data?.timeZone],
);
if (entriesQuery.isLoading) {
return <LoadingScreen message="Loading time entries…" />;
@@ -57,7 +74,10 @@ export default function TimeEntriesScreen() {
<AppBackground>
<TabPage showMoreBack>
<View style={styles.errorBox}>
<PageHeader title="Time entries" subtitle="Completed work history" />
<PageHeader
title="Time entries"
subtitle="Completed work history"
/>
<Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(entriesQuery.error)}
</Text>
@@ -72,7 +92,10 @@ export default function TimeEntriesScreen() {
<TabPage showMoreBack>
<TabScrollView
header={
<PageHeader title="Time entries" subtitle={`${completed.length} completed entries`} />
<PageHeader
title="Time entries"
subtitle={`${completed.length} completed entries`}
/>
}
refreshControl={
<PullToRefresh
@@ -82,7 +105,9 @@ export default function TimeEntriesScreen() {
}
>
{grouped.length === 0 ? (
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
<Text
style={{ color: colors.mutedForeground, fontFamily: fonts.body }}
>
No completed entries yet. Start the timer from the Timer tab.
</Text>
) : (
@@ -104,17 +129,26 @@ export default function TimeEntriesScreen() {
>
<View style={styles.row}>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.title, { color: colors.foreground }]}>
<Text
style={[styles.title, { color: colors.foreground }]}
>
{formatRunningTimerLabel(entry.description)}
</Text>
<Text style={{ color: colors.mutedForeground, fontFamily: fonts.body }}>
<Text
style={{
color: colors.mutedForeground,
fontFamily: fonts.body,
}}
>
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: " · not billed"}
</Text>
</View>
<Text style={[styles.title, { color: colors.foreground }]}>
<Text
style={[styles.title, { color: colors.foreground }]}
>
{entry.hours ?? "—"}h
</Text>
</View>
+48 -19
View File
@@ -1,12 +1,18 @@
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useEffect, useRef, useState } from "react";
import { AppState, Platform, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
import {
ensureNotificationPermissions,
syncInvoiceSendReminders,
} from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
function openInvoiceFromNotification(
data: Record<string, unknown> | undefined,
) {
if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return;
@@ -21,35 +27,58 @@ export function InvoiceReminderSync() {
{ staleTime: 60_000 },
);
const wasBackgrounded = useRef(false);
const [remotePushReady, setRemotePushReady] = useState(false);
const registerPushToken = api.notifications.registerPushToken.useMutation();
useEffect(() => {
if (Platform.OS !== "ios" && Platform.OS !== "android") return;
void (async () => {
if (!(await ensureNotificationPermissions())) return;
const projectId =
Constants.easConfig?.projectId ??
(Constants.expoConfig?.extra?.eas as { projectId?: string } | undefined)
?.projectId;
if (!projectId) return;
const { data: token } = await Notifications.getExpoPushTokenAsync({
projectId,
});
await registerPushToken.mutateAsync({ token, platform: Platform.OS });
setRemotePushReady(true);
})().catch(() => {
// Local reminders remain available when remote push registration is unavailable.
});
}, [registerPushToken]);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data);
}, [invoicesQuery.data]);
void syncInvoiceSendReminders(remotePushReady ? [] : invoicesQuery.data);
}, [invoicesQuery.data, remotePushReady]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
const subscription = AppState.addEventListener(
"change",
(nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
});
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
},
);
return () => subscription.remove();
}, [utils.invoices.getAll]);
useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
const responseSubscription =
Notifications.addNotificationResponseReceivedListener((response) => {
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
},
);
});
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return;
@@ -6,6 +6,7 @@ import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
import { calendarDateFromLocalDate } from "@beenvoice/domain/time-zone";
const NONE = "__none__";
@@ -37,7 +38,7 @@ export function defaultExpenseFormState(
return {
description: "",
amountText: "",
date: new Date(),
date: calendarDateFromLocalDate(new Date()),
category: "",
businessId: defaultBusinessId,
clientId: "",
@@ -6,6 +6,7 @@ import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { defaultDueDate } from "@/lib/invoice-number";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
type SelectOption = { label: string; value: string };
@@ -120,7 +121,9 @@ export function InvoiceSetupForm({
{invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
<Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Invoice number
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -141,11 +144,13 @@ export function InvoiceSetupForm({
{issueDateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
<Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Issue date
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()}
{formatCalendarDate(issueDate)}
</Text>
</View>
) : (
@@ -160,11 +165,18 @@ export function InvoiceSetupForm({
/>
)}
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
<DateTimeField
label="Due date"
mode="date"
value={dueDate}
onChange={onDueDateChange}
/>
{taxRateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
<Text
style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}
>
Tax rate
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
@@ -186,7 +198,7 @@ export function InvoiceSetupForm({
<>
<DateTimeField
label="Remind me to send"
mode="date"
mode="datetime"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
+51 -12
View File
@@ -3,11 +3,22 @@ import DateTimePicker, {
type DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { useState } from "react";
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
import {
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatDate, formatDateTime } from "@/lib/format";
import {
calendarDateFromLocalDate,
calendarDateToLocalDate,
} from "@beenvoice/domain/time-zone";
type DateTimeFieldProps = {
label: string;
@@ -31,17 +42,18 @@ export function DateTimeField({
const [draft, setDraft] = useState(value);
function openPicker() {
setDraft(value);
setDraft(mode === "date" ? calendarDateToLocalDate(value) : value);
setOpen(true);
}
function applyDate(next: Date) {
const normalized = mode === "date" ? calendarDateFromLocalDate(next) : next;
const clamped =
next.getTime() > maximumDate.getTime()
normalized.getTime() > maximumDate.getTime()
? maximumDate
: minimumDate && next.getTime() < minimumDate.getTime()
: minimumDate && normalized.getTime() < minimumDate.getTime()
? minimumDate
: next;
: normalized;
onChange(clamped);
}
@@ -60,7 +72,9 @@ export function DateTimeField({
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
<Text style={[styles.label, { color: colors.mutedForeground }]}>
{label}
</Text>
<Pressable
accessible
accessibilityLabel={`${label}, ${
@@ -81,28 +95,53 @@ export function DateTimeField({
<Text style={[styles.value, { color: colors.foreground }]}>
{mode === "date" ? formatDate(value) : formatDateTime(value)}
</Text>
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
<Ionicons
name="calendar-outline"
size={18}
color={colors.mutedForeground}
/>
</Pressable>
{Platform.OS === "ios" ? (
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
<Modal
visible={open}
transparent
animationType="slide"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.card }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<View
style={[
styles.sheetHeader,
{ borderBottomColor: colors.border },
]}
>
<Pressable onPress={() => setOpen(false)}>
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
<Text
style={[
styles.sheetAction,
{ color: colors.mutedForeground },
]}
>
Cancel
</Text>
</Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>
{label}
</Text>
<Pressable
onPress={() => {
applyDate(draft);
setOpen(false);
}}
>
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
<Text style={[styles.sheetAction, { color: colors.primary }]}>
Done
</Text>
</Pressable>
</View>
<DateTimePicker
+4 -2
View File
@@ -1,3 +1,5 @@
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
export function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
@@ -7,7 +9,7 @@ export function formatCurrency(amount: number, currency = "USD") {
}
export function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
return formatCalendarDate(date, {
month: "short",
day: "numeric",
year: "numeric",
@@ -15,7 +17,7 @@ export function formatDate(date: Date | string) {
}
export function formatShortDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
return formatCalendarDate(date, {
month: "short",
day: "numeric",
});
+3 -3
View File
@@ -1,3 +1,5 @@
import { addCalendarDays } from "@beenvoice/domain/time-zone";
/** Matches web invoice-form default numbering. */
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);
}
+6 -1
View File
@@ -6,11 +6,16 @@ export type InvoiceStatus = EffectiveInvoiceStatus;
export function getInvoiceStatus(invoice: {
status: string;
dueDate: Date | string;
createdBy?: { timeZone: string } | null;
}): InvoiceStatus {
if (invoice.status === "paid" || invoice.status === "draft") {
return invoice.status;
}
return getEffectiveInvoiceStatus("sent", invoice.dueDate);
return getEffectiveInvoiceStatus(
"sent",
invoice.dueDate,
invoice.createdBy?.timeZone ?? "America/New_York",
);
}
export const statusLabels: Record<InvoiceStatus, string> = {