evOo-local/apps/web/src/pages/RoutineSchedule.tsx
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

282 lines
8.1 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-[14.5px] text-foreground">{lead}</span>
{detail ? (
<span className="flex-1 text-[14.5px] 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>
);
}