evOo-local/apps/web/src/pages/RoutineSchedule.tsx
cynarAI 738811dbe7
Read the web app at 16px base size on phones (#155)
Text sizes now follow a --font-scale variable that is 1 on wider screens and
8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops
zooming into fields at that size) and every other size grows by the same
ratio. One-off pixel sizes in the shell move onto the shared text tokens so
they scale too. The stage subtitle now truncates inside the screen instead
of spilling past both edges.


Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 12:42:30 +02:00

282 lines
8.2 KiB
TypeScript

import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import {
CRON_FREQS,
type CronFreq,
type CronPreset,
type CronUnit,
cronFromPreset,
} from "@rakazo/core";
import { Input, NativeSelect, NativeSelectOption } from "@rakazo/ui-web";
import { Clock } from "lucide-react";
const UNITS: CronUnit[] = ["minutes", "hours", "days"];
const NUMBERS = [1, 2, 3, 5, 10, 15, 30, 45];
const TIMES = [
"6:00 AM",
"7:00 AM",
"8:00 AM",
"9:00 AM",
"12:00 PM",
"3:00 PM",
"6:00 PM",
"9:00 PM",
];
const TIMED: CronFreq[] = ["Every day", "Weekdays", "Every week", "Every month"];
/** Translate a cron frequency into the active UI locale. */
function cronFreqLabel(freq: CronFreq): string {
switch (freq) {
case "Every hour":
return t`Every hour`;
case "Every day":
return t`Every day`;
case "Weekdays":
return t`Weekdays`;
case "Every week":
return t`Every week`;
case "Every month":
return t`Every month`;
case "Interval":
return t`Interval`;
case "Advanced":
return t`Advanced`;
default:
return freq;
}
}
/** Translate a cron interval unit in its plural form. */
function cronUnitLabel(unit: CronUnit): string {
switch (unit) {
case "minutes":
return t`minutes`;
case "hours":
return t`hours`;
case "days":
return t`days`;
default:
return unit;
}
}
/** Translate a cron interval unit in its singular form. */
function cronUnitLabelSingular(unit: CronUnit): string {
switch (unit) {
case "minutes":
return t`minute`;
case "hours":
return t`hour`;
case "days":
return t`day`;
default:
return unit;
}
}
/** Select the Russian noun form for a numeric interval. */
function russianPluralForm(value: number, one: string, few: string, many: string): string {
const absolute = Math.abs(value);
const mod10 = absolute % 10;
const mod100 = absolute % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
}
/** Return the Russian interval unit with the case required after an amount. */
function russianIntervalUnit(value: number, unit: CronUnit): string {
switch (unit) {
case "minutes":
return russianPluralForm(value, "минуту", "минуты", "минут");
case "hours":
return russianPluralForm(value, "час", "часа", "часов");
case "days":
return russianPluralForm(value, "день", "дня", "дней");
default:
return unit;
}
}
/** Return the Russian lead word for a recurring interval. */
function russianIntervalLead(value: number, unit: CronUnit): string {
if (value !== 1) return "Каждые";
return unit === "minutes" ? "Каждую" : "Каждый";
}
/** Return the neutral lead used by the interval controls. */
function russianIntervalControlLead(): string {
// The amount select stays visible for 1, so use a construction that keeps
// the number in the sentence grammatical: “Раз в 1 час”, “Раз в 2 часа”.
return "Раз в";
}
/** Build the Russian summary shown above the interval controls. */
function describeRussianInterval(
value: number,
unit: CronUnit,
): {
lead: string;
detail: string;
} {
return {
lead: russianIntervalLead(value, unit),
detail:
value === 1
? russianIntervalUnit(value, unit)
: `${value} ${russianIntervalUnit(value, unit)}`,
};
}
/** Build the localized summary for any routine schedule preset. */
function describeCronPresetLocalized(
preset: CronPreset,
locale: string,
): { lead: string; detail: string } {
if (preset.freq === "Interval") {
if (locale === "ru") {
return describeRussianInterval(preset.n, preset.unit);
}
const unitLabel =
preset.n === 1 ? cronUnitLabelSingular(preset.unit) : cronUnitLabel(preset.unit);
return {
lead: t`Every`,
detail: t`${preset.n} ${unitLabel}`,
};
}
if (preset.freq === "Every hour") {
return { lead: t`Every hour`, detail: "" };
}
if (preset.freq === "Advanced") {
return { lead: t`Cron`, detail: preset.cron || "*/3 * * * *" };
}
if (preset.freq === "Weekdays") {
return { lead: t`Weekdays`, detail: t`at ${preset.time}` };
}
if (preset.freq === "Every week") {
return { lead: t`Every Monday`, detail: t`at ${preset.time}` };
}
if (preset.freq === "Every month") {
return { lead: t`Monthly`, detail: t`on the 1st at ${preset.time}` };
}
return { lead: t`Every day`, detail: t`at ${preset.time}` };
}
/** Render schedule selectors and the localized summary for a routine. */
export function RoutineSchedule({
value,
onChange,
}: {
value: CronPreset;
onChange: (next: CronPreset) => void;
}) {
const { i18n, t } = useLingui();
const { lead, detail } = describeCronPresetLocalized(value, i18n.locale);
const times = TIMES.includes(value.time) ? TIMES : [...TIMES, value.time];
const numbers = NUMBERS.includes(value.n) ? NUMBERS : [...NUMBERS, value.n].sort((a, b) => a - b);
function patch(partial: Partial<CronPreset>) {
onChange({ ...value, ...partial });
}
const intervalAmountSelect = (
<NativeSelect
size="sm"
value={String(value.n)}
aria-label={t`Interval amount`}
onChange={(event) => patch({ n: Number(event.target.value) })}
>
{numbers.map((n) => (
<NativeSelectOption key={n} value={n}>
{n}
</NativeSelectOption>
))}
</NativeSelect>
);
const intervalUnitSelect = (
<NativeSelect
size="sm"
value={value.unit}
aria-label={t`Interval unit`}
onChange={(event) => patch({ unit: event.target.value as CronUnit })}
>
{UNITS.map((unit) => (
<NativeSelectOption key={unit} value={unit}>
{i18n.locale === "ru" ? russianIntervalUnit(value.n, unit) : cronUnitLabel(unit)}
</NativeSelectOption>
))}
</NativeSelect>
);
const timeSelect = (
<NativeSelect
size="sm"
value={value.time}
aria-label={t`Time of day`}
onChange={(event) => patch({ time: event.target.value })}
>
{times.map((time) => (
<NativeSelectOption key={time} value={time}>
{time}
</NativeSelectOption>
))}
</NativeSelect>
);
return (
<div className="mt-2 rounded-xl border border-border p-3">
<div className="flex items-center gap-2.5 px-0.5">
<Clock size={17} strokeWidth={1.6} className="shrink-0 text-muted-foreground" aria-hidden />
<span className="text-ui-body-sm text-foreground">{lead}</span>
{detail ? (
<span className="flex-1 text-ui-body-sm text-muted-foreground">{detail}</span>
) : null}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<NativeSelect
size="sm"
value={value.freq}
aria-label={t`How often`}
onChange={(event) => {
const freq = event.target.value as CronFreq;
if (freq === "Advanced") {
patch({ freq, cron: cronFromPreset(value) });
return;
}
patch({ freq });
}}
>
{CRON_FREQS.map((freq) => (
<NativeSelectOption key={freq} value={freq}>
{cronFreqLabel(freq)}
</NativeSelectOption>
))}
</NativeSelect>
{value.freq === "Interval" ? (
i18n.locale === "ru" ? (
<>
{russianIntervalControlLead()} {intervalAmountSelect} {intervalUnitSelect}
</>
) : (
<Trans>
every {intervalAmountSelect} {intervalUnitSelect}
</Trans>
)
) : null}
{TIMED.includes(value.freq) ? <Trans>at {timeSelect}</Trans> : null}
{value.freq === "Advanced" ? (
<Input
value={value.cron}
placeholder="*/3 * * * *"
aria-label={t`Cron expression`}
onChange={(event) => patch({ cron: event.target.value })}
className="h-7 min-w-[120px] flex-1 font-mono text-ui-body-sm md:text-ui-body-sm"
/>
) : null}
</div>
</div>
);
}