|
| 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 | +} |
0 commit comments