Files
paseo/packages/app/src/components/schedules/cadence-editor.tsx
T
Mohamed Boudra 5ec26f0c09 feat(app): align interface typography to base size
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
2026-08-17 22:55:46 +02:00

143 lines
4.7 KiB
TypeScript

import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetDisplay,
resolveCronPresetId,
} from "@/schedules/schedule-cadence-options";
import { getDeviceTimeZone } from "@/utils/device-timezone";
import { describeCron, validateCron } from "@/utils/schedule-format";
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
}
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
testID: `schedule-cadence-preset-${option.id}`,
}));
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
if (error || !expression) {
return null;
}
return describeCron({ type: "cron", expression, timezone }) ?? expression;
}
function buildCronCadence(expression: string, timezone: string): CronCadence {
return { type: "cron", expression, timezone };
}
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
const [cronText, setCronText] = useState(() => normalizedValue.expression);
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
const trimmedCron = cronText.trim();
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
const effectiveError = error ?? localCronError;
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
const currentCadence = useMemo<CronCadence>(
() => buildCronCadence(trimmedCron, cronTimeZone),
[cronTimeZone, trimmedCron],
);
const selectedPresetId = resolveCronPresetId(currentCadence);
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
const handlePresetChange = useCallback(
(presetId: string) => {
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
if (!preset) {
return;
}
setCronText(preset.expression);
bumpFieldResetKey();
onChange(buildCronCadence(preset.expression, cronTimeZone));
},
[cronTimeZone, onChange],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
onChange(buildCronCadence(text.trim(), cronTimeZone));
},
[cronTimeZone, onChange],
);
let feedback: ReactNode = null;
if (effectiveError) {
feedback = <Text style={styles.error}>{effectiveError}</Text>;
} else if (preview) {
feedback = <Text style={styles.preview}>{preview}</Text>;
}
return (
<Field label="Cadence">
<View style={styles.stack}>
<SelectField
label="Cadence"
value={selectedPresetId === "custom" ? null : selectedPresetId}
selectedDisplay={selectedPresetDisplay}
options={PRESET_OPTIONS}
onChange={handlePresetChange}
placeholder="Select cadence"
emptyText="No cadences found"
searchable={false}
title="Cadence"
size={size}
triggerTestID="schedule-cadence-preset-trigger"
field={false}
/>
<FormTextInput
size={size}
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{feedback}
</View>
</Field>
);
}
const styles = StyleSheet.create((theme) => ({
stack: {
gap: theme.spacing[3],
},
cronInput: {
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.red[300],
},
}));