| 1 | /** |
| 2 | * When routines run, and what a routine must have: pure, so it is tested |
| 3 | * on its own (routines.ts runs them). |
| 4 | */ |
| 5 | import type { NewRoutine, RoutineSchedule } from "@g1t/contracts"; |
| 6 | |
| 7 | const EVERY = ["hour", "day", "weekday", "week"] as const; |
| 8 | |
| 9 | /** A schedule as given, checked; a message when it isn't one. */ |
| 10 | export function checkSchedule(input: unknown): { ok: true; value: RoutineSchedule } | { ok: false; message: string } { |
| 11 | if (!input || typeof input !== "object") return { ok: false, message: "Say when it runs." }; |
| 12 | const s = input as Record<string, unknown>; |
| 13 | const every = EVERY.find((e) => e === s.every); |
| 14 | if (!every) return { ok: false, message: "A routine runs every hour, day, weekday or week." }; |
| 15 | const int = (value: unknown, min: number, max: number) => (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : null); |
| 16 | const minute = int(s.minute ?? 0, 0, 59); |
| 17 | const hour = int(s.hour ?? 9, 0, 23); |
| 18 | const weekday = int(s.weekday ?? 1, 0, 6); |
| 19 | if (minute === null || hour === null || weekday === null) return { ok: false, message: "The minute is 0 to 59, the hour 0 to 23 and the day 0 (Sunday) to 6." }; |
| 20 | return { ok: true, value: { every, minute, hour, weekday } }; |
| 21 | } |
| 22 | |
| 23 | /** When a routine next runs after `after`, in UTC. */ |
| 24 | export function nextRun(schedule: RoutineSchedule, after: Date): Date { |
| 25 | const next = new Date(after.getTime()); |
| 26 | next.setUTCSeconds(0, 0); |
| 27 | if (schedule.every === "hour") { |
| 28 | next.setUTCMinutes(schedule.minute); |
| 29 | if (next <= after) next.setUTCHours(next.getUTCHours() + 1); |
| 30 | return next; |
| 31 | } |
| 32 | next.setUTCHours(schedule.hour, schedule.minute); |
| 33 | if (next <= after) next.setUTCDate(next.getUTCDate() + 1); |
| 34 | for (let i = 0; i < 8; i++) { |
| 35 | const day = next.getUTCDay(); |
| 36 | const fits = schedule.every === "day" || (schedule.every === "weekday" ? day >= 1 && day <= 5 : day === schedule.weekday); |
| 37 | if (fits) return next; |
| 38 | next.setUTCDate(next.getUTCDate() + 1); |
| 39 | } |
| 40 | return next; |
| 41 | } |
| 42 | |
| 43 | /** A schedule in words: "Every weekday at 09:00 UTC". */ |
| 44 | export function describeSchedule(schedule: RoutineSchedule): string { |
| 45 | const pad = (n: number) => String(n).padStart(2, "0"); |
| 46 | const at = `${pad(schedule.hour)}:${pad(schedule.minute)} UTC`; |
| 47 | const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; |
| 48 | switch (schedule.every) { |
| 49 | case "hour": |
| 50 | return `Every hour at :${pad(schedule.minute)}`; |
| 51 | case "day": |
| 52 | return `Every day at ${at}`; |
| 53 | case "weekday": |
| 54 | return `Every weekday at ${at}`; |
| 55 | case "week": |
| 56 | return `Every ${days[schedule.weekday]} at ${at}`; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /** A routine as given, checked: a name, instructions and a schedule. */ |
| 61 | export function checkRoutine(input: NewRoutine): { ok: true; value: NewRoutine & { schedule: RoutineSchedule } } | { ok: false; message: string } { |
| 62 | const name = typeof input?.name === "string" ? input.name.trim().slice(0, 80) : ""; |
| 63 | const instructions = typeof input?.instructions === "string" ? input.instructions.trim().slice(0, 8000) : ""; |
| 64 | if (!name) return { ok: false, message: "Give the routine a name." }; |
| 65 | if (instructions.length < 10) return { ok: false, message: "Say what the routine does, in a sentence or more." }; |
| 66 | if (typeof input.channel_id !== "string" || !input.channel_id) return { ok: false, message: "Choose the channel it posts in." }; |
| 67 | const schedule = checkSchedule(input.schedule); |
| 68 | if (!schedule.ok) return schedule; |
| 69 | return { ok: true, value: { ...input, name, instructions, schedule: schedule.value, enabled: input.enabled !== false } }; |
| 70 | } |
| 71 | |