Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/src/dev/seedRoutines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function seedRoutines(fake: FakeBotloft, bots: { scout: BotId; analyst: B
messageId: null,
createdAt: fake.now - 2 * HOUR,
finishedAt: fake.now - HOUR,
signal: null,
...run,
};
};
Expand Down Expand Up @@ -62,4 +63,14 @@ export function seedRoutines(fake: FakeBotloft, bots: { scout: BotId; analyst: B
});
backup.enabled = false;
backup.nextRunAt = null;

// Spec 20.13: runs when Analyst says the numbers are in.
const recheck = create(bots.scout, {
name: "Recheck the sources",
prompt: "The numbers changed: check that every claim in the report still has a source.",
schedule: { kind: "signal", name: "numbers-updated" },
});
lastRun(recheck, {
signal: { name: "numbers-updated", fromBotId: bots.analyst, note: null },
});
}
64 changes: 36 additions & 28 deletions app/src/features/routines/MoreOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// "More options" in the routine form (spec 20.9): the time zone, what to
// do when the last run has not finished or the computer was off, and a
// cron expression for those who want one.
// cron expression for those who want one. A routine that waits for a
// signal (spec 20.13) has no time: only the overlap rule is left.

import { ChevronRight } from "lucide-react";
import { useT } from "../../i18n";
Expand All @@ -18,19 +19,22 @@ export function MoreOptions({
}) {
const d = useT().routines.dialog;
const zones = allZones();
const timed = form.frequency !== "signal" || form.useCron;
return (
<details className="group rounded-xl border border-line">
<summary className="flex cursor-default items-center gap-1.5 px-3 py-2 font-medium text-ink-soft text-sm hover:text-ink">
<ChevronRight aria-hidden size={14} className="transition-transform group-open:rotate-90" />
{d.more}
</summary>
<div className="flex flex-col gap-4 border-line border-t px-3 py-3">
<SelectField
label={d.timezone}
value={form.timezone}
onChange={(event) => change({ timezone: event.target.value })}
options={zones.map((zone) => ({ value: zone, label: zoneLabel(zone) }))}
/>
{timed && (
<SelectField
label={d.timezone}
value={form.timezone}
onChange={(event) => change({ timezone: event.target.value })}
options={zones.map((zone) => ({ value: zone, label: zoneLabel(zone) }))}
/>
)}
<div className="flex flex-col gap-1.5">
<p className="font-medium text-ink-soft text-sm">{d.overlap}</p>
<Choices
Expand All @@ -43,27 +47,31 @@ export function MoreOptions({
onChange={(overlap) => change({ overlap })}
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-ink-soft text-sm">{d.missed}</p>
<Choices
label={d.missed}
value={form.missed}
options={[
{ value: "run_once", label: d.missedRunOnce },
{ value: "skip", label: d.missedSkip },
]}
onChange={(missed) => change({ missed })}
/>
<p className="text-muted text-xs">{d.wakeNote}</p>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.useCron}
onChange={(event) => change({ useCron: event.target.checked })}
/>
{d.advanced}
</label>
{timed && (
<div className="flex flex-col gap-1.5">
<p className="font-medium text-ink-soft text-sm">{d.missed}</p>
<Choices
label={d.missed}
value={form.missed}
options={[
{ value: "run_once", label: d.missedRunOnce },
{ value: "skip", label: d.missedSkip },
]}
onChange={(missed) => change({ missed })}
/>
<p className="text-muted text-xs">{d.wakeNote}</p>
</div>
)}
{timed && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.useCron}
onChange={(event) => change({ useCron: event.target.checked })}
/>
{d.advanced}
</label>
)}
{form.useCron && (
<TextField
label={d.cron}
Expand Down
2 changes: 1 addition & 1 deletion app/src/features/routines/RoutineDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function RoutineDialog({ bot, routine, onClose }: Props) {
placeholder={d.promptPlaceholder}
required
/>
<ScheduleFields form={form} change={change} />
<ScheduleFields form={form} change={change} bot={bot.name} />
<MoreOptions form={form} change={change} />
{error && (
<p role="alert" className="text-danger text-sm">
Expand Down
2 changes: 1 addition & 1 deletion app/src/features/routines/RoutineRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function RoutineRequestCard({ approval, bot }: { approval: ApprovalItem;
rows={4}
onChange={(event) => change({ prompt: event.target.value })}
/>
<ScheduleFields form={form} change={change} />
<ScheduleFields form={form} change={change} bot={runner.name} />
<MoreOptions form={form} change={change} />
<p className="text-muted text-xs">{r.explain(runner.name)}</p>
{error && (
Expand Down
23 changes: 19 additions & 4 deletions app/src/features/routines/RoutineRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,13 @@ export function RoutineRow({
{describeSchedule(routine.schedule, r.when)}
<span className="text-muted">
{" · "}
{routine.enabled && routine.nextRunAt !== null
? r.next(nextText(routine.nextRunAt, routine.timezone, r))
: r.off}
{!routine.enabled
? r.off
: routine.schedule.kind === "signal"
? r.waitsSignal
: routine.nextRunAt !== null
? r.next(nextText(routine.nextRunAt, routine.timezone, r))
: r.off}
</span>
</p>
{routine.lastRun && <LastRun run={routine.lastRun} words={r.last} />}
Expand Down Expand Up @@ -112,26 +116,34 @@ export function RoutineRow({

function LastRun({ run, words }: { run: RoutineRun; words: Messages["routines"]["last"] }) {
const line = "mt-1 flex items-center gap-1.5 text-xs";
// Who sent the signal it ran for (spec 20.13), while that bot is around.
const sender = useApp((state) =>
run.signal?.fromBotId ? (state.bots[run.signal.fromBotId]?.name ?? null) : null,
);
const from = sender && <span className="text-muted">· {words.signalFrom(sender)}</span>;
switch (run.status) {
case "queued":
return (
<p className={`${line} text-work`}>
<LoaderCircle aria-hidden size={12} className="animate-spin" />
{words.queued}
{from}
</p>
);
case "done":
return (
<p className={`${line} text-ok`}>
<CircleCheck aria-hidden size={12} />
{words.done}
{from}
</p>
);
case "failed":
return (
<p className={`${line} text-danger`}>
<CircleAlert aria-hidden size={12} />
{words.failed}
{from}
</p>
);
case "skipped": {
Expand All @@ -140,11 +152,14 @@ function LastRun({ run, words }: { run: RoutineRun; words: Messages["routines"][
? words.missed(run.skippedCount)
: run.reason === "bot_paused"
? words.bot_paused
: words.overlap;
: run.reason === "too_soon"
? words.too_soon
: words.overlap;
return (
<p className={`${line} text-muted`}>
<SkipForward aria-hidden size={12} />
{text}
{from}
</p>
);
}
Expand Down
24 changes: 23 additions & 1 deletion app/src/features/routines/ScheduleFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,23 @@ const control =
export function ScheduleFields({
form,
change,
bot,
}: {
form: RoutineForm;
change(patch: Partial<RoutineForm>): void;
/** The bot's name, for the signal's hint. */
bot: string;
}) {
const d = useT().routines.dialog;
const timeId = useId();
const everyId = useId();
const signalId = useId();
const frequencies: { value: Frequency; label: string }[] = [
{ value: "daily", label: d.frequency.daily },
{ value: "weekdays", label: d.frequency.weekdays },
{ value: "days", label: d.frequency.days },
{ value: "interval", label: d.frequency.interval },
{ value: "signal", label: d.frequency.signal },
];

const toggleDay = (day: number) =>
Expand Down Expand Up @@ -65,7 +70,24 @@ export function ScheduleFields({
})}
</fieldset>
)}
{form.frequency === "interval" ? (
{form.frequency === "signal" ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<label htmlFor={signalId} className="text-ink-soft text-sm">
{d.signal}
</label>
<input
id={signalId}
required
value={form.signal}
placeholder={d.signalPlaceholder}
onChange={(event) => change({ signal: event.target.value })}
className={`${control} w-56`}
/>
</div>
<p className="text-muted text-xs">{d.signalHint(bot)}</p>
</div>
) : form.frequency === "interval" ? (
<div className="flex items-center gap-2">
<label htmlFor={everyId} className="text-ink-soft text-sm">
{d.every}
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/routines/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function describeSchedule(schedule: Schedule, words: Words["when"]): stri
: words.everyMinutes(schedule.minutes);
case "cron":
return words.cron(schedule.expr);
case "signal":
return words.signal(schedule.name);
}
}

Expand Down
15 changes: 12 additions & 3 deletions app/src/features/routines/form.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// The routine form's fields and the schedule they stand for (spec 20.9):
// every day, weekdays, chosen days or every N minutes or hours, or a cron
// expression under "More options".
// every day, weekdays, chosen days, every N minutes or hours, or when a
// bot signals (spec 20.13), or a cron expression under "More options".

import type { Missed, Overlap, Routine, Schedule } from "../../lib/protocol.gen";
import { systemZone } from "./describe";

export type Frequency = "daily" | "weekdays" | "days" | "interval";
export type Frequency = "daily" | "weekdays" | "days" | "interval" | "signal";
export type Unit = "minutes" | "hours";

export interface RoutineForm {
Expand All @@ -23,6 +23,8 @@ export interface RoutineForm {
missed: Missed;
useCron: boolean;
cron: string;
/** The signal the routine waits for. */
signal: string;
}

const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7];
Expand All @@ -42,6 +44,7 @@ export function blankForm(): RoutineForm {
missed: "run_once",
useCron: false,
cron: "",
signal: "",
};
}

Expand Down Expand Up @@ -86,6 +89,10 @@ export function formOf(routine: RoutineFields): RoutineForm {
form.useCron = true;
form.cron = schedule.expr;
break;
case "signal":
form.frequency = "signal";
form.signal = schedule.name;
break;
}
return form;
}
Expand All @@ -107,5 +114,7 @@ export function scheduleOf(form: RoutineForm): Schedule {
kind: "interval",
minutes: Math.round(form.every) * (form.unit === "hours" ? 60 : 1),
};
case "signal":
return { kind: "signal", name: form.signal.trim() };
}
}
1 change: 1 addition & 0 deletions app/src/features/routines/routines.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ describe("failed runs", () => {
messageId: null,
createdAt: Date.now(),
finishedAt: Date.now() + 1000,
signal: null,
},
});
});
Expand Down
Loading
Loading