Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.

Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:48 -04:00
co-authored by Cursor
parent e17c4c6854
commit 6762a9bff3
60 changed files with 2544 additions and 1091 deletions
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { api } from "@/lib/trpc";
/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
export function TimeClockLiveActivitySync() {
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 60_000,
});
const running = runningQuery.data;
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity(running, seconds);
};
sync();
const interval = setInterval(sync, 60_000);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") sync();
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [running]);
return null;
}
+97 -131
View File
@@ -31,12 +31,12 @@ import {
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
@@ -90,6 +90,7 @@ export function TimeClockPanel({
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [stopNote, setStopNote] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
@@ -139,18 +140,6 @@ export function TimeClockPanel({
},
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
onError: (err) => {
Alert.alert("Could not update timer", err.message);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
@@ -175,6 +164,7 @@ export function TimeClockPanel({
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
setStopNote("");
},
});
@@ -194,7 +184,7 @@ export function TimeClockPanel({
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description?.trim() ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
}, [running]);
@@ -247,24 +237,6 @@ export function TimeClockPanel({
setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity({ ...running, description }, seconds);
};
sync();
const interval = setInterval(sync, 15_000);
return () => clearInterval(interval);
}, [running, description]);
const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
@@ -404,43 +376,19 @@ export function TimeClockPanel({
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: description.trim() ? description.trim() : undefined,
description: stopNote.trim() ? stopNote.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleRunningClientChange(nextClientId: string) {
if (!running) return;
setClientId(nextClientId);
setInvoiceId("");
try {
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
const client = clients.find((c) => c.id === nextClientId);
setRateText(clientRateText(client));
await persistClientChoice(nextClientId);
} catch {
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
}
}
async function handleRunningInvoiceChange(nextInvoiceId: string) {
if (!running) return;
const previous = invoiceId;
setInvoiceId(nextInvoiceId);
try {
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
} catch {
setInvoiceId(previous);
}
}
if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />;
}
const runningTitle = formatRunningTimerLabel(running?.description);
const runningMeta = [
running?.client?.name ?? (running ? "No client" : null),
running?.invoice
@@ -451,7 +399,6 @@ export function TimeClockPanel({
.filter(Boolean)
.join(" · ");
const controlsDisabled = Boolean(running && updateRunning.isPending);
function renderClientChip(client: (typeof clients)[number]) {
return (
@@ -459,11 +406,7 @@ export function TimeClockPanel({
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningClientChange(client.id);
else selectClient(client.id);
}}
onPress={() => selectClient(client.id)}
/>
);
}
@@ -487,18 +430,18 @@ export function TimeClockPanel({
>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}>
<View style={[styles.hero, running && styles.heroRunning]}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabel}>Running</Text>
<Text style={styles.heroLabelRunning}>Timer running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningMeta}>
Started {formatDateTime(running.startedAt)}
{runningMeta ? ` · ${runningMeta}` : ""}
</Text>
<Text style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</>
) : (
<Text style={styles.idleHint}>
@@ -510,13 +453,37 @@ export function TimeClockPanel({
) : null}
<GlassSurface style={styles.setupCard}>
<Input
label="Title"
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
{running ? (
<View style={styles.formSection}>
<Input
label="Note on stop (optional)"
value={stopNote}
onChangeText={setStopNote}
placeholder={
running.description?.trim()
? running.description
: "Update description when you stop"
}
returnKeyType="done"
/>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
) : (
<>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholderTextColor={colors.mutedForeground}
returnKeyType="done"
style={[styles.titleInput, !description.trim() && styles.titleInputPlaceholder]}
style={[styles.titleField, { color: colors.foreground }]}
/>
<View style={styles.setupSection}>
@@ -533,10 +500,7 @@ export function TimeClockPanel({
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => {
if (controlsDisabled) return;
setClientsExpanded((open) => !open);
}}
onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
</View>
@@ -547,7 +511,7 @@ export function TimeClockPanel({
) : null}
</>
)}
{clockInErrors.clientId && !running ? (
{clockInErrors.clientId ? (
<Text style={styles.fieldError}>{clockInErrors.clientId}</Text>
) : null}
</View>
@@ -559,11 +523,7 @@ export function TimeClockPanel({
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange("");
else setInvoiceId("");
}}
onPress={() => setInvoiceId("")}
/>
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
@@ -572,11 +532,7 @@ export function TimeClockPanel({
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => {
if (controlsDisabled) return;
if (running) void handleRunningInvoiceChange(invoice.id);
else setInvoiceId(invoice.id);
}}
onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
@@ -584,7 +540,7 @@ export function TimeClockPanel({
</View>
) : null}
{!running && clientId ? (
{clientId ? (
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
@@ -690,28 +646,20 @@ export function TimeClockPanel({
) : null}
</View>
) : null}
</View>
) : null}
<Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
</>
)}
</GlassSurface>
{running ? (
<Button
title="Clock out"
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
) : (
<Button
title="Clock in"
loading={clockIn.isPending}
disabled={!canClockIn || clients.length === 0}
onPress={handleClockIn}
/>
)}
{todayEntries.length > 0 ? (
<Card title="Today">
<Card title="Today's entries">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
@@ -720,7 +668,7 @@ export function TimeClockPanel({
const row = (
<>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{resolveClockDescription(entry.description)}</Text>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
@@ -762,41 +710,52 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
},
hero: {
padding: spacing.md,
padding: spacing.lg,
gap: spacing.sm,
},
heroRunning: {
alignItems: "center",
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
heroLabel: {
fontSize: 13,
heroLabelRunning: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.4,
color: colors.primary,
},
timerValue: {
fontSize: 52,
lineHeight: 56,
fontSize: 56,
lineHeight: 60,
fontFamily: fonts.mono,
color: colors.foreground,
color: colors.primary,
fontVariant: ["tabular-nums"],
textAlign: "center",
},
runningTitle: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
color: colors.foreground,
textAlign: "center",
},
runningMeta: {
fontSize: 13,
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
idleHint: {
fontSize: 14,
@@ -805,20 +764,27 @@ const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
lineHeight: 20,
},
setupCard: {
padding: spacing.md,
padding: spacing.lg,
gap: spacing.lg,
},
cardTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
formSection: {
gap: spacing.md,
},
titleField: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
minHeight: 48,
paddingVertical: spacing.xs,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
titleInput: {
minHeight: 44,
textAlignVertical: "center",
},
titleInputPlaceholder: {
textAlign: "center",
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,