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.
This commit is contained in:
kumarabhirup 2026-03-05 21:19:58 -08:00
parent 0a2d426834
commit 6044372614
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
2 changed files with 118 additions and 1 deletions

View File

@ -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);
});
});

View File

@ -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<WorkspaceUrlState>): 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();
}