Skip to content

Commit f8e8a12

Browse files
committed
feat(routines): a page with every routine, coming up first
The sidebar gets a Routines page once a bot has one: what runs next across all crews, soonest first, with interval routines keeping watch, and below, each bot's routines under its mascot, foldable, with a button for a new one.
1 parent 8b1f2d6 commit f8e8a12

10 files changed

Lines changed: 367 additions & 7 deletions

File tree

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ListAvatar } from "../bots/ListAvatar";
2121
import { toolAction, toolTitle } from "../chat/toolNames";
2222
import { CrewDialog } from "./CrewDialog";
2323
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
24-
import { QuestionsEntry, row, SearchEntry } from "./SidebarPages";
24+
import { QuestionsEntry, RoutinesEntry, row, SearchEntry } from "./SidebarPages";
2525

2626
/** Crews as sections and their bots as conversations (spec 15.1). */
2727
export function Sidebar() {
@@ -65,6 +65,7 @@ export function Sidebar() {
6565
</div>
6666
<SearchEntry />
6767
<QuestionsEntry />
68+
<RoutinesEntry />
6869
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
6970
{crews.map((crew) => (
7071
<CrewEntry key={crew.id} crew={crew} />

‎app/src/features/crews/SidebarPages.tsx‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
// The pages at the top of the sidebar: the search (spec 8.8) and the
2-
// question box (spec 23.6).
1+
// The pages at the top of the sidebar: the search (spec 8.8), the
2+
// question box (spec 23.6) and every routine (spec 20.9).
33

4-
import { MessageCircleQuestion, Search } from "lucide-react";
4+
import { AlarmClock, MessageCircleQuestion, Search } from "lucide-react";
55
import { useT } from "../../i18n";
66
import { useApp } from "../../store/context";
77
import { openQuestions } from "../../store/questions";
@@ -60,3 +60,27 @@ export function QuestionsEntry() {
6060
</div>
6161
);
6262
}
63+
64+
/** Every routine of every bot (spec 20.9), shown once there is one. */
65+
export function RoutinesEntry() {
66+
const t = useT();
67+
const any = useApp((state) => Object.keys(state.routines).length > 0);
68+
const open = useApp((state) => state.page === "routines");
69+
const openPage = useApp((state) => state.openPage);
70+
if (!any && !open) {
71+
return null;
72+
}
73+
return (
74+
<div className="shrink-0 px-2">
75+
<button
76+
type="button"
77+
aria-current={open ? "page" : undefined}
78+
onClick={() => openPage("routines")}
79+
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
80+
>
81+
<AlarmClock aria-hidden size={16} className="text-muted" />
82+
<span className="min-w-0 flex-1 truncate font-medium">{t.routines.page.title}</span>
83+
</button>
84+
</div>
85+
);
86+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Every routine in one place (spec 20.9): what runs next across all crews,
2+
// in the order it will run, and below, each bot's routines under its
3+
// mascot, folded by the owner if they like.
4+
5+
import { AlarmClock, ChevronDown, LoaderCircle, Plus, Radar } from "lucide-react";
6+
import { useMemo, useState } from "react";
7+
import { useShallow } from "zustand/react/shallow";
8+
import { useT } from "../../i18n";
9+
import { AVATAR_PALETTE, type Bot, type Routine } from "../../lib/protocol.gen";
10+
import { crewList } from "../../store/app";
11+
import { useApp } from "../../store/context";
12+
import { Button } from "../../ui/Button";
13+
import { EmptyState } from "../../ui/EmptyState";
14+
import { BotAvatar } from "../bots/BotAvatar";
15+
import { describeSchedule, nextText } from "./describe";
16+
import { RoutineDialog } from "./RoutineDialog";
17+
import { RoutineRow } from "./RoutineRow";
18+
19+
/** How many upcoming routines show before "Show more". */
20+
const FIRST = 5;
21+
22+
interface Owned {
23+
bot: Bot;
24+
routine: Routine;
25+
}
26+
27+
const capitalize = (text: string) => text.charAt(0).toLocaleUpperCase() + text.slice(1);
28+
29+
const byCreation = (a: { createdAt: number; id: string }, b: { createdAt: number; id: string }) =>
30+
a.createdAt - b.createdAt || a.id.localeCompare(b.id);
31+
32+
/** The bots with routines, crew by crew, each with its routines. */
33+
function useGroups(): { bot: Bot; crew: string; routines: Routine[] }[] {
34+
// The store's own objects, so the selections stay equal between renders.
35+
const crews = useApp(useShallow(crewList));
36+
const bots = useApp(useShallow((state) => Object.values(state.bots)));
37+
const routines = useApp(useShallow((state) => Object.values(state.routines)));
38+
return useMemo(
39+
() =>
40+
crews.flatMap((crew) =>
41+
bots
42+
.filter((bot) => bot.crewId === crew.id)
43+
.sort(byCreation)
44+
.flatMap((bot) => {
45+
const own = routines.filter((routine) => routine.botId === bot.id).sort(byCreation);
46+
return own.length ? [{ bot, crew: crew.name, routines: own }] : [];
47+
}),
48+
),
49+
[crews, bots, routines],
50+
);
51+
}
52+
53+
/** Routines that run on time, soonest first; signals wait, so they are left out. */
54+
function upcoming(groups: { bot: Bot; routines: Routine[] }[]): Owned[] {
55+
return groups
56+
.flatMap(({ bot, routines }) => routines.map((routine) => ({ bot, routine })))
57+
.filter(
58+
({ routine }) =>
59+
routine.enabled && routine.nextRunAt !== null && routine.schedule.kind !== "signal",
60+
)
61+
.sort((a, b) => (a.routine.nextRunAt ?? 0) - (b.routine.nextRunAt ?? 0));
62+
}
63+
64+
function Upcoming({ bot, routine }: Owned) {
65+
const r = useT().routines;
66+
const [editing, setEditing] = useState(false);
67+
const schedule = describeSchedule(routine.schedule, r.when);
68+
// A routine every few minutes is keeping watch more than it is due.
69+
const watching = routine.schedule.kind === "interval";
70+
const running = routine.lastRun?.status === "queued";
71+
const when = watching
72+
? r.page.watching
73+
: capitalize(nextText(routine.nextRunAt ?? 0, routine.timezone, r));
74+
return (
75+
<li>
76+
<button
77+
type="button"
78+
onClick={() => setEditing(true)}
79+
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-sunken"
80+
>
81+
<BotAvatar color={bot.color} size={28} mood={running ? "working" : undefined} />
82+
<span className="min-w-0 flex-1">
83+
<span className="block truncate font-medium">{routine.name}</span>
84+
<span className="flex items-center gap-1.5 truncate text-muted text-sm">
85+
{running ? (
86+
<>
87+
<LoaderCircle aria-hidden size={12} className="shrink-0 animate-spin text-work" />
88+
<span className="text-work">{r.last.queued}</span>
89+
</>
90+
) : (
91+
<>
92+
{watching && <Radar aria-hidden size={12} className="shrink-0 text-ok" />}
93+
<span className={watching ? "text-ok" : "text-ink-soft"}>{when}</span>
94+
</>
95+
)}
96+
<span aria-hidden>·</span>
97+
<span className="truncate">{schedule}</span>
98+
</span>
99+
</span>
100+
<span className="shrink-0 text-muted text-xs">{bot.name}</span>
101+
</button>
102+
{editing && <RoutineDialog bot={bot} routine={routine} onClose={() => setEditing(false)} />}
103+
</li>
104+
);
105+
}
106+
107+
function BotGroup({ bot, crew, routines }: { bot: Bot; crew: string; routines: Routine[] }) {
108+
const r = useT().routines;
109+
const [open, setOpen] = useState(true);
110+
const [creating, setCreating] = useState(false);
111+
return (
112+
<section aria-label={bot.name} className="flex flex-col gap-2">
113+
<div className="flex items-center gap-1">
114+
<button
115+
type="button"
116+
aria-expanded={open}
117+
onClick={() => setOpen(!open)}
118+
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-sunken"
119+
>
120+
<BotAvatar color={bot.color} size={22} />
121+
<span className="truncate font-semibold">{bot.name}</span>
122+
<span className="truncate text-muted text-xs">{crew}</span>
123+
<ChevronDown
124+
aria-hidden
125+
size={14}
126+
className={`shrink-0 text-muted transition-transform ${open ? "" : "-rotate-90"}`}
127+
/>
128+
</button>
129+
<Button
130+
variant="ghost"
131+
size="sm"
132+
icon={Plus}
133+
label={r.dialog.newTitle(bot.name)}
134+
onClick={() => setCreating(true)}
135+
/>
136+
</div>
137+
{open && (
138+
<ul aria-label={r.page.of(bot.name)} className="flex flex-col gap-2">
139+
{routines.map((routine) => (
140+
<RoutineRow key={routine.id} routine={routine} bot={bot} showBot={false} />
141+
))}
142+
</ul>
143+
)}
144+
{creating && <RoutineDialog bot={bot} onClose={() => setCreating(false)} />}
145+
</section>
146+
);
147+
}
148+
149+
export function RoutinesPage() {
150+
const r = useT().routines;
151+
const groups = useGroups();
152+
const next = upcoming(groups);
153+
const [all, setAll] = useState(false);
154+
const shown = all ? next : next.slice(0, FIRST);
155+
return (
156+
<div className="min-h-0 flex-1 overflow-y-auto">
157+
<div className="mx-auto flex max-w-3xl flex-col gap-8 px-5 py-6">
158+
<header>
159+
<h1 className="font-semibold text-xl">{r.page.title}</h1>
160+
<p className="text-muted text-sm">{r.page.lead}</p>
161+
</header>
162+
{groups.length === 0 ? (
163+
<EmptyState
164+
color={AVATAR_PALETTE[0]}
165+
icon={AlarmClock}
166+
title={r.page.emptyTitle}
167+
body={r.page.emptyBody}
168+
/>
169+
) : (
170+
<>
171+
{next.length > 0 && (
172+
<section aria-labelledby="routines-upcoming" className="flex flex-col gap-1">
173+
<h2
174+
id="routines-upcoming"
175+
className="px-3 font-medium text-muted text-xs uppercase tracking-wide"
176+
>
177+
{r.page.upcoming}
178+
</h2>
179+
<ul aria-labelledby="routines-upcoming" className="flex flex-col">
180+
{shown.map((item) => (
181+
<Upcoming key={item.routine.id} {...item} />
182+
))}
183+
</ul>
184+
{next.length > FIRST && (
185+
<button
186+
type="button"
187+
onClick={() => setAll(!all)}
188+
className="self-start rounded-lg px-3 py-1.5 text-muted text-sm transition-colors hover:bg-sunken hover:text-ink"
189+
>
190+
{all ? r.page.showLess : r.page.showMore(next.length - FIRST)}
191+
</button>
192+
)}
193+
</section>
194+
)}
195+
<div className="flex flex-col gap-6">
196+
{groups.map((group) => (
197+
<BotGroup key={group.bot.id} {...group} />
198+
))}
199+
</div>
200+
</>
201+
)}
202+
</div>
203+
</div>
204+
);
205+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import type { BotId, Schedule } from "../../lib/protocol.gen";
5+
import { crewOpened, renderApp } from "../../test/app";
6+
7+
afterEach(cleanup);
8+
9+
function add(fake: FakeBotloft, botId: BotId, name: string, schedule: Schedule) {
10+
return fake.routines.handlers()["routines.create"]({
11+
botId,
12+
name,
13+
prompt: "Do it",
14+
schedule,
15+
timezone: "UTC",
16+
});
17+
}
18+
19+
const weekdays: Schedule = { kind: "weekly", days: [1, 2, 3, 4, 5], time: "09:00" };
20+
21+
/** Two crews, three bots, routines on two of them. */
22+
function setup() {
23+
const fake = new FakeBotloft();
24+
const ops = fake.addCrew("Ops");
25+
const site = fake.addCrew("Site");
26+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
27+
fake.addBot(ops.id, "Writer", "Writes");
28+
const watcher = fake.addBot(site.id, "Watcher", "Watches");
29+
add(fake, scout.id, "Morning summary", weekdays);
30+
const alerts = add(fake, watcher.id, "Camper van alerts", { kind: "interval", minutes: 30 });
31+
// Every 30 minutes: due before the morning summary.
32+
alerts.nextRunAt = fake.now + 30 * 60_000;
33+
add(fake, watcher.id, "On deploy", { kind: "signal", name: "deployed" });
34+
return fake;
35+
}
36+
37+
async function openPage(fake: FakeBotloft) {
38+
renderApp(fake);
39+
await crewOpened("Ops");
40+
fireEvent.click(screen.getByRole("button", { name: "Routines" }));
41+
return screen.findByRole("heading", { level: 1, name: "Routines" });
42+
}
43+
44+
describe("the routines page", () => {
45+
test("is in the sidebar only once there is a routine", async () => {
46+
const fake = new FakeBotloft();
47+
fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources");
48+
renderApp(fake);
49+
await crewOpened("Ops");
50+
expect(screen.queryByRole("button", { name: "Routines" })).toBeNull();
51+
});
52+
53+
test("lists what comes up across crews, interval routines as keeping watch", async () => {
54+
await openPage(setup());
55+
const coming = screen.getByRole("list", { name: "Coming up" });
56+
const rows = within(coming).getAllByRole("listitem");
57+
// A signal waits for one: it never comes up on its own.
58+
expect(rows.map((row) => row.querySelector(".font-medium")?.textContent)).toEqual([
59+
"Camper van alerts",
60+
"Morning summary",
61+
]);
62+
expect(rows[0]?.textContent).toContain("Keeping watch");
63+
expect(rows[0]?.textContent).toContain("Every 30 minutes");
64+
expect(rows[1]?.textContent).toContain("Weekdays at 09:00");
65+
expect(rows[1]?.textContent).toContain("Scout");
66+
67+
// A click opens the routine to edit.
68+
fireEvent.click(within(rows[1] as HTMLElement).getByRole("button"));
69+
expect(screen.getByRole("dialog", { name: "Edit Morning summary" })).toBeDefined();
70+
});
71+
72+
test("groups each bot's routines under its mascot, folded on demand", async () => {
73+
await openPage(setup());
74+
const watcher = screen.getByRole("region", { name: "Watcher" });
75+
const list = within(watcher).getByRole("list", { name: "Watcher's routines" });
76+
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
77+
// Writer has none, so no group.
78+
expect(screen.queryByRole("region", { name: "Writer" })).toBeNull();
79+
80+
fireEvent.click(within(watcher).getByRole("button", { name: /Watcher/, expanded: true }));
81+
expect(within(watcher).queryByRole("list")).toBeNull();
82+
fireEvent.click(within(watcher).getByRole("button", { name: "New routine for Watcher" }));
83+
expect(screen.getByRole("dialog", { name: "New routine for Watcher" })).toBeDefined();
84+
});
85+
});

‎app/src/i18n/en/routines.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ export const routines = {
3232
? "Last time was skipped: the computer was off"
3333
: `${count} times were skipped: the computer was off`,
3434
},
35+
/** Every routine of every bot, a page of the sidebar. */
36+
page: {
37+
title: "Routines",
38+
lead: "What your bots do on their own, in every crew.",
39+
upcoming: "Coming up",
40+
watching: "Keeping watch",
41+
showMore: (count: number) => `Show ${count} more`,
42+
showLess: "Show fewer",
43+
of: (bot: string) => `${bot}'s routines`,
44+
emptyTitle: "No routines yet",
45+
emptyBody:
46+
"A routine makes a bot work on its own at set times. Ask a bot in the chat, or open a bot's Routines tab.",
47+
},
3548
when: {
3649
everyDay: (time: string) => `Every day at ${time}`,
3750
weekdays: (time: string) => `Weekdays at ${time}`,

‎app/src/i18n/es/routines.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ export const routines: Messages["routines"] = {
3434
? "La última vez se saltó: el ordenador estaba apagado"
3535
: `Se saltaron ${count} veces: el ordenador estaba apagado`,
3636
},
37+
page: {
38+
title: "Rutinas",
39+
lead: "Lo que tus bots hacen solos, en todos los equipos.",
40+
upcoming: "Próximas",
41+
watching: "Vigilando",
42+
showMore: (count) => `Mostrar ${count} más`,
43+
showLess: "Mostrar menos",
44+
of: (bot) => `Rutinas de ${bot}`,
45+
emptyTitle: "Aún no hay rutinas",
46+
emptyBody:
47+
"Una rutina hace que un bot trabaje solo a horas fijas. Pídeselo a un bot en el chat, o abre la pestaña Rutinas de un bot.",
48+
},
3749
when: {
3850
everyDay: (time: string) => `Todos los días a las ${time}`,
3951
weekdays: (time: string) => `Días laborables a las ${time}`,

0 commit comments

Comments
 (0)