Sync time entries with invoice lines and harden auth for mobile.

Link clocked time to invoice items with bidirectional sync, add entry editing on web, broaden session cookie detection for Expo clients, and handle API rate limits without signing users out.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 21:44:19 -04:00
co-authored by Cursor
parent c267b3e1c8
commit 31151e7f39
19 changed files with 849 additions and 111 deletions
+89 -1
View File
@@ -37,11 +37,68 @@ import {
} from "~/lib/time-clock";
import { invoiceLabel } from "~/lib/time-entry-display";
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago";
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
function RunningTextFields({
running,
updateRunningPending,
onDescriptionCommit,
onStartedAtCommit,
}: {
running: { id: string; description: string | null; startedAt: Date };
updateRunningPending: boolean;
onDescriptionCommit: (description: string) => void;
onStartedAtCommit: (startedAt: Date) => void;
}) {
const [title, setTitle] = useState(running.description ?? "");
const [runningStartedAt, setRunningStartedAt] = useState(() =>
toDatetimeLocalValue(running.startedAt),
);
return (
<>
<div className="space-y-2">
<Label htmlFor="clock-running-title">What are you working on?</Label>
<Input
id="clock-running-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => onDescriptionCommit(title)}
placeholder="What are you working on?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="clock-running-start">Started at</Label>
<Input
id="clock-running-start"
type="datetime-local"
value={runningStartedAt}
onChange={(e) => {
const value = e.target.value;
setRunningStartedAt(value);
if (!value) return;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return;
onStartedAtCommit(parsed);
}}
disabled={updateRunningPending}
/>
</div>
</>
);
}
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
@@ -109,6 +166,7 @@ export function TimeClockPanel({
const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState("");
const [minutesAgo, setMinutesAgo] = useState("30");
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId;
@@ -185,6 +243,18 @@ export function TimeClockPanel({
onError: (e) => toast.error(e.message),
});
function handleRunningDescriptionCommit(nextTitle: string) {
if (!running) return;
const next = resolveClockDescription(nextTitle);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtCommit(parsed: Date) {
if (!running) return;
updateRunning.mutate({ startedAt: parsed });
}
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: (data) => {
const message = describeClockOutOutcome({
@@ -496,6 +566,14 @@ export function TimeClockPanel({
</>
) : (
<>
<RunningTextFields
key={running.id}
running={running}
updateRunningPending={updateRunning.isPending}
onDescriptionCommit={handleRunningDescriptionCommit}
onStartedAtCommit={handleRunningStartedAtCommit}
/>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
@@ -621,7 +699,10 @@ export function TimeClockPanel({
</CardHeader>
<CardContent>
{todayEntries?.some((e) => e.endedAt) ? (
<TimeEntryList entries={todayEntries} />
<TimeEntryList
entries={todayEntries}
onEdit={(entry) => setEditEntryId(entry.id)}
/>
) : (
<p className="text-muted-foreground py-4 text-center text-sm">
No entries today.{" "}
@@ -637,6 +718,13 @@ export function TimeClockPanel({
</Card>
) : null}
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
import { Clock, Play } from "lucide-react";
import { groupEntriesByDate } from "~/lib/time-entry-display";
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
import type { TimeEntryListItem } from "~/lib/time-entry-display";
export function TimeEntriesHistory() {
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const completedEntries = useMemo(
() => (entries ?? []).filter((e) => e.endedAt),
@@ -57,25 +60,35 @@ export function TimeEntriesHistory() {
}
return (
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
/>
))}
</CardContent>
</Card>
))}
</div>
<>
<div className="space-y-6">
{grouped.map((group) => (
<Card key={group.dateKey}>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{group.label}
</CardTitle>
</CardHeader>
<CardContent>
{group.entries.map((entry, index) => (
<TimeEntryRow
key={entry.id}
entry={entry}
isLast={index === group.entries.length - 1}
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
/>
))}
</CardContent>
</Card>
))}
</div>
<TimeEntryEditDialog
entryId={editEntryId}
open={editEntryId != null}
onOpenChange={(open) => {
if (!open) setEditEntryId(null);
}}
/>
</>
);
}
@@ -0,0 +1,274 @@
"use client";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { invoiceLabel } from "~/lib/time-entry-display";
import type { RouterOutputs } from "~/trpc/react";
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
function toDatetimeLocalValue(value: Date | string) {
const start = new Date(value);
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
return start.toISOString().slice(0, 16);
}
export type TimeEntryEditDialogProps = {
entryId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
type TimeEntryEditFormProps = {
entry: TimeEntry;
entryId: string;
clients: RouterOutputs["clients"]["getAll"];
onClose: () => void;
};
function TimeEntryEditForm({
entry,
entryId,
clients,
onClose,
}: TimeEntryEditFormProps) {
const utils = api.useUtils();
const [description, setDescription] = useState(entry.description ?? "");
const [clientId, setClientId] = useState(entry.clientId ?? "");
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
const [rate, setRate] = useState(entry.rate ?? 0);
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
const [endedAt, setEndedAt] = useState(() =>
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
);
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: Boolean(clientId) },
);
const hoursPreview = useMemo(() => {
if (!startedAt || !endedAt) return null;
const start = new Date(startedAt);
const end = new Date(endedAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
toast.success("Time entry updated");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
toast.success("Time entry deleted");
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (e) => toast.error(e.message),
});
function handleSave() {
const start = new Date(startedAt);
const end = endedAt ? new Date(endedAt) : undefined;
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
toast.error("Invalid start or end time");
return;
}
if (end && end <= start) {
toast.error("End time must be after start time");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate,
startedAt: start,
endedAt: end,
hours: hoursPreview ?? undefined,
});
}
return (
<>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="entry-description">Description</Label>
<Input
id="entry-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Client</Label>
<Select
value={clientId || "__none__"}
onValueChange={(v) => {
setClientId(v === "__none__" ? "" : v);
setInvoiceId("");
}}
>
<SelectTrigger>
<SelectValue placeholder="No client" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
disabled={!clientId}
>
<SelectTrigger>
<SelectValue placeholder="Not on invoice" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Not on invoice</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Hourly rate</Label>
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="entry-start">Started</Label>
<Input
id="entry-start"
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="entry-end">Ended</Label>
<Input
id="entry-end"
type="datetime-local"
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
/>
</div>
</div>
{hoursPreview != null ? (
<p className="text-muted-foreground text-sm">
Duration: {hoursPreview.toFixed(2)}h
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
</p>
) : null}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
type="button"
variant="destructive"
disabled={deleteEntry.isPending}
onClick={() => deleteEntry.mutate({ id: entryId })}
>
Delete
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
Save
</Button>
</div>
</DialogFooter>
</>
);
}
export function TimeEntryEditDialog({
entryId,
open,
onOpenChange,
}: TimeEntryEditDialogProps) {
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: Boolean(entryId) && open },
);
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit time entry</DialogTitle>
</DialogHeader>
{entryQuery.isLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : entryQuery.data && entryId ? (
<TimeEntryEditForm
key={entryQuery.data.id}
entry={entryQuery.data}
entryId={entryId}
clients={clients}
onClose={() => onOpenChange(false)}
/>
) : (
<p className="text-muted-foreground text-sm">Time entry not found.</p>
)}
</DialogContent>
</Dialog>
);
}
+26 -2
View File
@@ -6,11 +6,13 @@ import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entr
export function TimeEntryRow({
entry,
isLast,
onEdit,
}: {
entry: TimeEntryListItem;
isLast?: boolean;
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const href = entryHref(entry);
const href = onEdit ? null : entryHref(entry);
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
@@ -50,6 +52,21 @@ export function TimeEntryRow({
);
}
if (onEdit) {
return (
<button
type="button"
onClick={() => onEdit(entry)}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
)}
>
{content}
</button>
);
}
return (
<div className={rowClassName}>
{content}
@@ -57,7 +74,13 @@ export function TimeEntryRow({
);
}
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
export function TimeEntryList({
entries,
onEdit,
}: {
entries: TimeEntryListItem[];
onEdit?: (entry: TimeEntryListItem) => void;
}) {
const completed = entries.filter((e) => e.endedAt);
if (completed.length === 0) return null;
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
key={entry.id}
entry={entry}
isLast={index === completed.length - 1}
onEdit={onEdit}
/>
))}
</>