bf62e3fbf1
## Summary Rebuilds the routine detail page as **variation C** — a sub-sidebar shell that splits the page into **ROUTINE** (Overview · Triggers · Variables · Secrets · Delivery) and **OPERATE** (Runs · Activity · History), per the engineering spec on PAP-10730. Replaces the previous 5-tab `?tab=…` layout in `ui/src/pages/RoutineDetail.tsx`. Implements PAP-10732. Design source of truth: PAP-10730 `spec` document; approved direction PAP-10709. ## What changed - **Routing** (`ui/src/App.tsx`): real sub-routes under `routines/:routineId/:section`. Bare `/routines/:id` redirects to the last-viewed section (`localStorage`) or `overview`; old `?tab=…` URLs redirect to the matching section for back-compat. Every section URL is bookmarkable. - **Shell** (`RoutineDetail.tsx`): slim 56px sticky header (title + managed-by-plugin chip + Run / Active toggle), page-local sub-sidebar, full-canvas section body, per-section sticky save bar. All routine state/mutations stay in the shell and flow to sections via a `RoutineDetailContext`. - **New components**: `RoutineSubSidebar` (+ mobile `<Select>` picker, roving keyboard nav), `RoutineSaveBar` (scoped dirty count, ⌘/Ctrl+S save, Esc-discard confirm, 409 conflict recovery with Reload / Overwrite), `RadioCard` primitive (Delivery), `RoutineTriggerCard` (extracted from the inline editor, with human-readable cron), `RoutineActivityRow` (expandable JSON), `lib/cron-readable`, and the per-section components. - **Reuse**: History mounts the existing `RoutineHistoryTab`; Variables mounts `RoutineVariablesEditor` with a provenance banner; Secrets reuses `EnvVarEditor` + the one-time reveal banner. No backend or schema changes. - **States**: per-section loading/empty/error/save-conflict and read-only strip scaffolding (§1.6). ## Testing - New unit tests: sub-sidebar navigation/active/dirty markers, save-bar dirty + ⌘S + conflict recovery, cron helper. - Existing routine tests still pass: `Routines.test.tsx`, `RoutineHistoryTab.test.tsx`, `RoutineRunVariablesDialog.test.tsx`. - `vitest run` (routine scope): **36 passed**. Production `vite build`: **green**. - Screenshots at 1440×900 + 390×844 attached to [PAP-10732](https://example.invalid) (rendered via a new Storybook story with fixture data). ## Out of scope (per spec) - `/routines` list-page redo (follow-up). - Non-owner secret-value visibility (Open Q6 — CEO escalation; built with the spec default). --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
80 lines
2.9 KiB
TypeScript
80 lines
2.9 KiB
TypeScript
/**
|
|
* Tiny best-effort cron → plain-English helper for the routine Triggers section.
|
|
* Not a full cron parser: it covers the common shapes Paperclip schedule triggers
|
|
* produce (every N minutes/hours, daily at HH:MM, weekday/weekend, day-of-week).
|
|
* Falls back to the raw expression when it can't confidently describe it.
|
|
*/
|
|
|
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
|
|
function pad2(value: number): string {
|
|
return value.toString().padStart(2, "0");
|
|
}
|
|
|
|
function describeTime(minute: string, hour: string): string | null {
|
|
const m = Number(minute);
|
|
const h = Number(hour);
|
|
if (!Number.isInteger(m) || !Number.isInteger(h)) return null;
|
|
if (m < 0 || m > 59 || h < 0 || h > 23) return null;
|
|
return `${pad2(h)}:${pad2(m)}`;
|
|
}
|
|
|
|
function describeDayOfWeek(dow: string): string | null {
|
|
if (dow === "*" || dow === "?") return "every day";
|
|
if (dow === "1-5") return "every weekday";
|
|
if (dow === "0,6" || dow === "6,0" || dow === "0,7") return "every weekend";
|
|
const parts = dow.split(",").map((part) => part.trim());
|
|
const names = parts.map((part) => {
|
|
const n = Number(part);
|
|
if (!Number.isInteger(n)) return null;
|
|
return DOW_NAMES[n % 7];
|
|
});
|
|
if (names.some((name) => name === null)) return null;
|
|
if (names.length === 1) return `every ${names[0]}`;
|
|
return `every ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
}
|
|
|
|
export function describeCron(expression: string | null | undefined): string | null {
|
|
if (!expression) return null;
|
|
const trimmed = expression.trim();
|
|
const fields = trimmed.split(/\s+/);
|
|
// Standard 5-field cron: minute hour day-of-month month day-of-week
|
|
if (fields.length !== 5) return null;
|
|
const [minute, hour, dom, month, dow] = fields;
|
|
|
|
// Every N minutes
|
|
const everyMinutes = minute.match(/^\*\/(\d+)$/);
|
|
if (everyMinutes && hour === "*" && dom === "*" && month === "*" && dow === "*") {
|
|
return `Every ${everyMinutes[1]} minutes`;
|
|
}
|
|
|
|
// Every N hours, on the minute
|
|
const everyHours = hour.match(/^\*\/(\d+)$/);
|
|
if (everyHours && /^\d+$/.test(minute) && dom === "*" && month === "*" && dow === "*") {
|
|
return `Every ${everyHours[1]} hours at :${pad2(Number(minute))}`;
|
|
}
|
|
|
|
// Hourly
|
|
if (/^\d+$/.test(minute) && hour === "*" && dom === "*" && month === "*" && dow === "*") {
|
|
return `Every hour at :${pad2(Number(minute))}`;
|
|
}
|
|
|
|
// Daily / weekly at a fixed time
|
|
if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && month === "*") {
|
|
const time = describeTime(minute, hour);
|
|
if (!time) return null;
|
|
if (dom === "*" && (dow === "*" || dow === "?")) {
|
|
return `Every day at ${time}`;
|
|
}
|
|
if (dom === "*") {
|
|
const dowText = describeDayOfWeek(dow);
|
|
if (dowText) return `${dowText[0].toUpperCase()}${dowText.slice(1)} at ${time}`;
|
|
}
|
|
if (/^\d+$/.test(dom) && (dow === "*" || dow === "?")) {
|
|
return `Day ${dom} of every month at ${time}`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|