Files

974 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import { setLastTimeClockClientId } from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
compact?: boolean;
header?: ReactNode;
};
type ClientRow = {
id: string;
name: string;
defaultHourlyRate: number | null;
currency?: string;
};
type StartMode = "now" | "at" | "ago";
const AGO_PRESETS = [
{ label: "15m", minutes: 15 },
{ label: "30m", minutes: 30 },
{ label: "1h", minutes: 60 },
{ label: "2h", minutes: 120 },
{ label: "4h", minutes: 240 },
] as const;
function clientRateText(client: ClientRow | undefined): string {
return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
}
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
compact = false,
header,
}: TimeClockPanelProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles);
const { activeAccountId } = useAccounts();
const utils = api.useUtils();
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const clientsQuery = api.clients.getAll.useQuery();
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");
const [agoMinutes, setAgoMinutes] = useState(60);
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const running = runningQuery.data;
const elapsed = useRunningElapsed(running?.startedAt);
const clients = clientsQuery.data ?? [];
const activeClientId = running?.clientId ?? clientId;
const billableQuery = api.invoices.getBillable.useQuery(
activeClientId ? { clientId: activeClientId } : undefined,
);
const billableInvoices = billableQuery.data ?? [];
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const entriesQuery = api.timeEntries.getAll.useQuery();
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => {
await utils.timeEntries.getRunning.invalidate();
},
});
const updateRunning = api.timeEntries.updateRunning.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.invoices.getBillable.invalidate(),
]);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
if (running?.clientId && activeAccountId) {
await setLastTimeClockClientId(activeAccountId, running.clientId);
}
const message = describeClockOutOutcome({
outcome: data.outcome,
hours: data.hours,
rate: data.rate,
invoice: data.invoice,
});
Alert.alert(
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
message,
);
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.invoices.getBillable.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
setDescription("");
setStopNote("");
},
});
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
setRunningStartedAt(new Date(running.startedAt));
}, [running]);
useEffect(() => {
if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId);
if (!client?.defaultHourlyRate) return;
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
rateText,
selectedClient?.defaultHourlyRate,
);
const displayRate = running
? (running.rate ?? effectiveRate ?? 0)
: (effectiveRate ?? 0);
const resolvedStartAt = useMemo(() => {
if (startMode === "now") return new Date();
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
return startedAt;
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
const next: { rate?: string; start?: string } = {};
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
if (startMode === "ago" && agoMinutes <= 0) {
next.start = "Enter how long ago you started";
}
return next;
}, [agoMinutes, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
const optionsSummary = useMemo(() => {
const rate =
effectiveRate ??
(selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
return `${rateLabel} · ${startLabel}`;
}, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
const todayEntries = useMemo(
() =>
(entriesQuery.data ?? []).filter(
(entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
),
[entriesQuery.data, todayStart],
);
const todayHours = useMemo(
() => todayEntries.reduce((total, entry) => total + Number(entry.hours ?? 0), 0),
[todayEntries],
);
async function persistClientChoice(nextClientId: string) {
if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId);
}
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
if (running) {
updateRunning.mutate({
clientId: nextClientId,
invoiceId: "",
rate: client?.defaultHourlyRate ?? undefined,
});
return;
}
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (nextClientId) {
void persistClientChoice(nextClientId);
}
}
function selectInvoice(nextInvoiceId: string) {
if (running) {
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
return;
}
setInvoiceId(nextInvoiceId);
}
function handleRunningDescriptionBlur() {
if (!running) return;
const next = resolveClockDescription(description);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtChange(date: Date) {
setRunningStartedAt(date);
if (!running || date > new Date()) return;
updateRunning.mutate({ startedAt: date });
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
if (mode === "now") {
setStartedAt(new Date());
return;
}
if (mode === "ago") {
setStartedAt(startedAtFromMinutesAgo(agoMinutes));
return;
}
setStartedAt((current) =>
Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
);
}
function selectAgoPreset(minutes: number) {
setStartMode("ago");
setAgoMinutes(minutes);
setAgoMinutesText(String(minutes));
setStartedAt(startedAtFromMinutesAgo(minutes));
}
function handleAgoMinutesChange(text: string) {
setAgoMinutesText(text);
const parsed = Number(text);
if (!Number.isNaN(parsed) && parsed > 0) {
setAgoMinutes(parsed);
setStartedAt(startedAtFromMinutesAgo(parsed));
}
}
async function handleClockIn() {
if (!canClockIn) {
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
return;
}
try {
const backdated =
startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
if (clientId) {
await persistClientChoice(clientId);
}
setStartMode("now");
setStartedAt(new Date());
setAgoMinutes(60);
setAgoMinutesText("60");
} catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: stopNote.trim() ? stopNote.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
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
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null,
displayRate ? `$${displayRate}/hr` : null,
]
.filter(Boolean)
.join(" · ");
return (
<TabScrollView
style={styles.scroll}
header={header}
refreshControl={
<PullToRefresh
onRefresh={() =>
Promise.all([
runningQuery.refetch(),
clientsQuery.refetch(),
billableQuery.refetch(),
entriesQuery.refetch(),
])
}
tintColor={colors.primary}
/>
}
>
{running ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={[styles.hero, running && styles.heroRunning]}>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabelRunning}>In progress</Text>
</View>
<Text selectable style={styles.timerValue}>
{formatElapsedSeconds(elapsed)}
</Text>
<Text selectable style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text selectable style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</View>
</GlassSurface>
) : null}
<GlassSurface style={styles.setupCard}>
{!running ? (
<View style={styles.idleIntro}>
<Text style={styles.idleEyebrow}>Ready to start</Text>
<Text style={styles.idleTitle}>What are you working on?</Text>
<Text style={styles.idleCopy}>
Add what you know now. You can update the rest while the timer runs.
</Text>
</View>
) : null}
{running ? (
<>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save entry"}
variant="danger"
leftIcon="stop"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
<Pressable
accessible
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [
styles.optionsToggle,
pressed && styles.optionsTogglePressed,
]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Edit timer details</Text>
<Text style={styles.optionsToggleSummary}>
Started {formatDateTime(runningStartedAt)}
</Text>
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? <View style={styles.formSection}>
<Input
label="What are you working on?"
value={description}
onChangeText={setDescription}
onBlur={handleRunningDescriptionBlur}
placeholder="What are you working on?"
returnKeyType="done"
/>
<DateTimeField
label="Started at"
value={runningStartedAt}
maximumDate={new Date()}
onChange={handleRunningStartedAtChange}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => selectClient("")}
/>
{clients.map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
))}
</View>
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => selectInvoice("")}
/>
{billableInvoices.map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => selectInvoice(invoice.id)}
/>
))}
</View>
</View>
) : null}
<Input
label="Note on stop (optional)"
value={stopNote}
onChangeText={setStopNote}
placeholder={
running.description?.trim()
? running.description
: "Update description when you stop"
}
returnKeyType="done"
/>
</View> : null}
</>
) : (
<>
<View style={styles.idleFields}>
<Input
label="Description"
value={description}
onChangeText={setDescription}
placeholder="e.g. Client kickoff…"
returnKeyType="done"
style={styles.titleField}
containerStyle={styles.timerField}
/>
<SelectField
label="Client"
placeholder="Select a client"
value={clientId}
options={[
{ label: "No client", value: "" },
...clients.map((client) => ({ label: client.name, value: client.id })),
]}
onValueChange={selectClient}
containerStyle={styles.timerField}
/>
<SelectField
label="Invoice (optional)"
placeholder={clientId ? "Select an invoice" : "Choose a client first"}
value={clientId ? invoiceId : "__client_required__"}
disabled={!clientId}
options={[
{ label: "Entry only — no invoice", value: "" },
...billableInvoices.map((invoice) => ({
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`,
value: invoice.id,
})),
]}
onValueChange={selectInvoice}
containerStyle={styles.timerField}
/>
</View>
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
{!optionsExpanded ? (
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
) : null}
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? (
<View style={styles.optionsBody}>
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder={
selectedClient?.defaultHourlyRate != null
? String(selectedClient.defaultHourlyRate)
: "0"
}
error={clockInErrors.rate}
/>
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
<Text style={styles.rateHint}>
Defaults to{" "}
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
</Text>
) : null}
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Now"
active={startMode === "now"}
onPress={() => selectStartMode("now")}
/>
<FilterChip
label="Pick time"
active={startMode === "at"}
onPress={() => selectStartMode("at")}
/>
<FilterChip
label="Time ago"
active={startMode === "ago"}
onPress={() => selectStartMode("ago")}
/>
</View>
{startMode === "at" ? (
<DateTimeField
label="Started at"
value={startedAt}
maximumDate={new Date()}
onChange={(date) => {
setStartedAt(date);
setStartMode("at");
}}
/>
) : null}
{startMode === "ago" ? (
<View style={styles.agoBlock}>
<View style={styles.chipWrap}>
{AGO_PRESETS.map((preset) => (
<FilterChip
key={preset.label}
label={preset.label}
active={agoMinutes === preset.minutes}
onPress={() => selectAgoPreset(preset.minutes)}
/>
))}
</View>
<View style={styles.agoCustomRow}>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
Started
</Text>
<TextInput
value={agoMinutesText}
onChangeText={handleAgoMinutesChange}
keyboardType="number-pad"
style={[
styles.agoInput,
{ color: colors.foreground, borderColor: colors.border },
]}
/>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
min ago
</Text>
</View>
</View>
) : null}
{clockInErrors.start ? (
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
) : null}
</View>
) : null}
</View>
<Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
</>
)}
</GlassSurface>
<Card title={`Today · ${todayHours.toFixed(2)}h`}>
{todayEntries.length > 0 ? todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
return (
<SwipeableRow
key={entry.id}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => setEditEntryId(entry.id),
},
{
key: "invoice",
label: "Invoice",
icon: "document-text-outline",
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => {
if (entry.invoice?.id) {
router.push(`/(app)/invoices/${entry.invoice.id}`);
} else {
setEditEntryId(entry.id);
}
},
},
]}
>
<View style={styles.entryRow}>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.description)}</Text>
<Text style={styles.entrySub}>
{entry.client?.name ?? "No client"}
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
</Text>
</View>
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
</View>
</SwipeableRow>
);
}) : (
<View style={styles.todayEmpty}>
<Text style={styles.todayEmptyTitle}>No time logged yet</Text>
<Text style={styles.todayEmptyCopy}>
Start your first timer or open history to add an entry manually.
</Text>
</View>
)}
<Button
title="View time history"
variant="secondary"
onPress={() => router.push("/(app)/more/time-entries")}
/>
</Card>
<TimeEntryEditSheet
entryId={editEntryId}
visible={editEntryId != null}
onClose={() => setEditEntryId(null)}
/>
</TabScrollView>
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
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.lg,
gap: spacing.sm,
},
heroRunning: {
alignItems: "center",
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
pulseDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
heroLabelRunning: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
color: colors.primary,
},
timerValue: {
fontSize: 56,
lineHeight: 60,
fontFamily: fonts.mono,
color: colors.primary,
fontVariant: ["tabular-nums"],
textAlign: "center",
},
runningTitle: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
color: colors.foreground,
textAlign: "center",
},
runningMeta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
setupCard: {
padding: spacing.lg,
gap: spacing.md,
},
idleIntro: {
gap: spacing.xs,
paddingBottom: spacing.xs,
},
idleEyebrow: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.7,
},
idleTitle: {
fontSize: 22,
lineHeight: 28,
fontFamily: fonts.heading,
color: colors.foreground,
},
idleCopy: {
fontSize: 13,
lineHeight: 18,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
formSection: {
gap: spacing.md,
},
titleField: {
minHeight: 48,
},
idleFields: {
gap: spacing.md,
},
timerField: {
gap: 6,
},
setupSection: {
gap: spacing.sm,
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabelInset: {
marginTop: spacing.sm,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
emptyClients: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
fieldError: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.destructive,
},
optionsToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
optionsTogglePressed: {
opacity: 0.7,
},
optionsToggleText: {
flex: 1,
gap: 2,
},
optionsToggleLabel: {
fontSize: 14,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
optionsToggleSummary: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
optionsChevron: {
fontSize: 20,
lineHeight: 22,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
optionsBody: {
gap: spacing.md,
paddingBottom: spacing.xs,
},
rateHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
agoBlock: {
gap: spacing.sm,
},
agoCustomRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
agoCustomLabel: {
fontSize: 14,
fontFamily: fonts.body,
},
agoInput: {
minWidth: 56,
fontSize: 16,
fontFamily: fonts.bodySemiBold,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 4,
textAlign: "center",
},
entryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
entryRowPressed: {
opacity: 0.65,
},
entryMeta: {
flex: 1,
gap: 2,
},
entryTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
entrySub: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 12,
},
entryHours: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
},
todayEmpty: {
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
},
todayEmptyTitle: {
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
fontSize: 14,
textAlign: "center",
},
todayEmptyCopy: {
fontFamily: fonts.body,
color: colors.mutedForeground,
fontSize: 13,
lineHeight: 18,
textAlign: "center",
},
});