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
+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>