Make scheduling and dates timezone-safe
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user