From 60443726146cff514c442cde48bc103afd741e6d Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Thu, 5 Mar 2026 21:19:58 -0800 Subject: [PATCH] feat(cron): add URL-backed view state for cron dashboard and job detail Persist cronView, cronCalMode, cronDate, cronRunFilter, and cronRun in URL params so cron UI state survives navigation and refresh. --- apps/web/lib/workspace-links.test.ts | 84 ++++++++++++++++++++++++++++ apps/web/lib/workspace-links.ts | 35 +++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/workspace-links.test.ts b/apps/web/lib/workspace-links.test.ts index 52e78d74ced..5c1d6b8a9d7 100644 --- a/apps/web/lib/workspace-links.test.ts +++ b/apps/web/lib/workspace-links.test.ts @@ -540,3 +540,87 @@ describe("buildUrl", () => { expect(url).toContain("subagent=sa1"); }); }); + +// ─── Cron URL state ────────────────────────────────────────────── + +describe("cron URL state round-trips", () => { + it("parses cron dashboard view from URL", () => { + const state = parseUrlState("path=~cron&cronView=calendar&cronCalMode=week"); + expect(state.path).toBe("~cron"); + expect(state.cronView).toBe("calendar"); + expect(state.cronCalMode).toBe("week"); + }); + + it("round-trips cronView through serialize/parse", () => { + const qs = serializeUrlState({ path: "~cron", cronView: "insights" }); + const parsed = parseUrlState(qs); + expect(parsed.cronView).toBe("insights"); + }); + + it("omits cronView=overview from URL (default)", () => { + const qs = serializeUrlState({ path: "~cron", cronView: "overview" }); + expect(qs).not.toContain("cronView"); + }); + + it("round-trips cronCalMode through serialize/parse", () => { + const qs = serializeUrlState({ path: "~cron", cronView: "calendar", cronCalMode: "week" }); + const parsed = parseUrlState(qs); + expect(parsed.cronCalMode).toBe("week"); + }); + + it("round-trips cronDate through serialize/parse", () => { + const qs = serializeUrlState({ path: "~cron", cronView: "calendar", cronDate: "2026-03-05" }); + const parsed = parseUrlState(qs); + expect(parsed.cronDate).toBe("2026-03-05"); + }); + + it("round-trips cronRunFilter through serialize/parse", () => { + const qs = serializeUrlState({ path: "~cron/job-1", cronRunFilter: "error" }); + const parsed = parseUrlState(qs); + expect(parsed.cronRunFilter).toBe("error"); + }); + + it("omits cronRunFilter=all from URL (default)", () => { + const qs = serializeUrlState({ path: "~cron/job-1", cronRunFilter: "all" }); + expect(qs).not.toContain("cronRunFilter"); + }); + + it("round-trips cronRun timestamp through serialize/parse", () => { + const ts = 1772749020657; + const qs = serializeUrlState({ path: "~cron/job-1", cronRun: ts }); + const parsed = parseUrlState(qs); + expect(parsed.cronRun).toBe(ts); + }); + + it("rejects invalid cronView values", () => { + const state = parseUrlState("cronView=bogus"); + expect(state.cronView).toBeNull(); + }); + + it("rejects invalid cronCalMode values", () => { + const state = parseUrlState("cronCalMode=bogus"); + expect(state.cronCalMode).toBeNull(); + }); + + it("rejects invalid cronRunFilter values", () => { + const state = parseUrlState("cronRunFilter=bogus"); + expect(state.cronRunFilter).toBeNull(); + }); + + it("builds full cron calendar URL", () => { + const url = buildUrl({ path: "~cron", cronView: "calendar", cronCalMode: "week", cronDate: "2026-03-05" }); + const parsed = parseUrlState(url.replace("/?", "")); + expect(parsed.path).toBe("~cron"); + expect(parsed.cronView).toBe("calendar"); + expect(parsed.cronCalMode).toBe("week"); + expect(parsed.cronDate).toBe("2026-03-05"); + }); + + it("builds full cron job detail URL with run filter", () => { + const url = buildUrl({ path: "~cron/abc123", cronRunFilter: "error", cronRun: 12345 }); + const parsed = parseUrlState(url.replace("/?", "")); + expect(parsed.path).toBe("~cron/abc123"); + expect(parsed.cronRunFilter).toBe("error"); + expect(parsed.cronRun).toBe(12345); + }); +}); diff --git a/apps/web/lib/workspace-links.ts b/apps/web/lib/workspace-links.ts index 657743e897d..2fbe7e447e4 100644 --- a/apps/web/lib/workspace-links.ts +++ b/apps/web/lib/workspace-links.ts @@ -9,6 +9,7 @@ * Subagent: /?chat=parent-id&subagent=child-key * Browse: /?browse=/abs/path&hidden=1 * Cron: /?path=~cron or /?path=~cron/job-id + * Cron views: /?path=~cron&cronView=calendar&cronCalMode=week&cronDate=2026-03-05 * Object view: /?path=leads&viewType=kanban&filters=...&sort=...&search=...&page=1&pageSize=50&cols=a,b,c&view=MyView * Preview: /?path=file.md&preview=other.md * Send: /?send=install+duckdb (consumed immediately) @@ -17,7 +18,7 @@ * migrateWorkspaceUrl for backward compat. */ -import type { FilterGroup, SortRule, ViewType } from "./object-filters"; +import type { CalendarMode, FilterGroup, SortRule, ViewType } from "./object-filters"; // --------------------------------------------------------------------------- // Parsed link (simple) @@ -31,6 +32,9 @@ export type WorkspaceLink = // Full URL state // --------------------------------------------------------------------------- +export type CronDashboardView = "overview" | "calendar" | "timeline" | "insights"; +export type CronRunStatusFilter = "all" | "ok" | "error" | "running"; + export type WorkspaceUrlState = { path: string | null; chat: string | null; @@ -50,12 +54,26 @@ export type WorkspaceUrlState = { page: number | null; pageSize: number | null; cols: string[] | null; + /** Cron dashboard active tab (only relevant when path is ~cron). */ + cronView: CronDashboardView | null; + /** Calendar mode when cronView=calendar. */ + cronCalMode: CalendarMode | null; + /** Date anchor for cron calendar/timeline view (ISO date string). */ + cronDate: string | null; + /** Run status filter for cron job detail history. */ + cronRunFilter: CronRunStatusFilter | null; + /** Selected run timestamp in cron job detail. */ + cronRun: number | null; }; const VALID_VIEW_TYPES: ViewType[] = [ "table", "kanban", "calendar", "timeline", "gallery", "list", ]; +const VALID_CRON_VIEWS: CronDashboardView[] = ["overview", "calendar", "timeline", "insights"]; +const VALID_CRON_CAL_MODES: CalendarMode[] = ["day", "week", "month", "year"]; +const VALID_CRON_RUN_FILTERS: CronRunStatusFilter[] = ["all", "ok", "error", "running"]; + // --------------------------------------------------------------------------- // URL state codec // --------------------------------------------------------------------------- @@ -98,6 +116,11 @@ export function parseUrlState(search: string | URLSearchParams): WorkspaceUrlSta const colsRaw = params.get("cols"); const viewTypeRaw = params.get("viewType") as ViewType | null; + const cronViewRaw = params.get("cronView") as CronDashboardView | null; + const cronCalModeRaw = params.get("cronCalMode") as CalendarMode | null; + const cronRunFilterRaw = params.get("cronRunFilter") as CronRunStatusFilter | null; + const cronRunRaw = params.get("cronRun"); + return { path: params.get("path"), chat: params.get("chat"), @@ -119,6 +142,11 @@ export function parseUrlState(search: string | URLSearchParams): WorkspaceUrlSta page: pageRaw ? parseInt(pageRaw, 10) || null : null, pageSize: pageSizeRaw ? parseInt(pageSizeRaw, 10) || null : null, cols: colsRaw ? colsRaw.split(",").filter(Boolean) : null, + cronView: cronViewRaw && VALID_CRON_VIEWS.includes(cronViewRaw) ? cronViewRaw : null, + cronCalMode: cronCalModeRaw && VALID_CRON_CAL_MODES.includes(cronCalModeRaw) ? cronCalModeRaw : null, + cronDate: params.get("cronDate"), + cronRunFilter: cronRunFilterRaw && VALID_CRON_RUN_FILTERS.includes(cronRunFilterRaw) ? cronRunFilterRaw : null, + cronRun: cronRunRaw ? parseInt(cronRunRaw, 10) || null : null, }; } @@ -149,6 +177,11 @@ export function serializeUrlState(state: Partial): string { if (state.page != null && state.page > 1) params.set("page", String(state.page)); if (state.pageSize != null) params.set("pageSize", String(state.pageSize)); if (state.cols && state.cols.length > 0) params.set("cols", state.cols.join(",")); + if (state.cronView && state.cronView !== "overview") params.set("cronView", state.cronView); + if (state.cronCalMode) params.set("cronCalMode", state.cronCalMode); + if (state.cronDate) params.set("cronDate", state.cronDate); + if (state.cronRunFilter && state.cronRunFilter !== "all") params.set("cronRunFilter", state.cronRunFilter); + if (state.cronRun != null) params.set("cronRun", String(state.cronRun)); return params.toString(); }