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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user