From 1e95dea1e7a522eddc77d25ceefc7bef4d5a8573 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Wed, 6 May 2026 14:40:06 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20scheduled=20releases=20=E2=80=94=20?= =?UTF-8?q?calendar=20UI,=20publish=20scheduling,=20and=20section=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full scheduled-releases feature: - `api/tools/scheduled-releases.ts`: 5 new MCP tools (list, create, add_change, cancel, reschedule) backed by a local JSON store - `api/resources/scheduled-releases.ts`: MCP resource serving the unified HTML app for the list_scheduled_releases tool - `web/tools/scheduled-releases/index.tsx`: calendar + list view, release detail, create/reschedule/cancel dialogs - `web/tools/file-explorer/publish-dialog.tsx`: scheduling tab in the publish dialog — pick an upcoming release or create one, with date picker and time picker - `web/tools/file-explorer/index.tsx`: ScheduledChangesBadge on section rows, scheduled-changes state polling, props wired to CmsPanel and PublishDialog - `docs/superpowers/specs/2026-05-04-releases-ux-design.md`: UX design spec that guided the implementation Co-Authored-By: Claude Sonnet 4.6 --- api/main.ts | 2 + api/resources/scheduled-releases.ts | 27 + api/tools/index.ts | 12 + api/tools/scheduled-releases.ts | 354 ++++ .../specs/2026-05-04-releases-ux-design.md | 173 ++ web/router.tsx | 2 + web/tools/file-explorer/index.tsx | 178 +- web/tools/file-explorer/publish-dialog.tsx | 1474 +++++++++++------ web/tools/scheduled-releases/index.tsx | 1386 ++++++++++++++++ 9 files changed, 3064 insertions(+), 544 deletions(-) create mode 100644 api/resources/scheduled-releases.ts create mode 100644 api/tools/scheduled-releases.ts create mode 100644 docs/superpowers/specs/2026-05-04-releases-ux-design.md create mode 100644 web/tools/scheduled-releases/index.tsx diff --git a/api/main.ts b/api/main.ts index 3dc5bfa..7519895 100644 --- a/api/main.ts +++ b/api/main.ts @@ -8,6 +8,7 @@ import { monitorAppResource } from "./resources/monitor.ts"; import { pullRequestsAppResource } from "./resources/pull-requests.ts"; import { releasesAppResource } from "./resources/releases.ts"; import { renderHtmlAppResource } from "./resources/render-html.ts"; +import { scheduledReleasesAppResource } from "./resources/scheduled-releases.ts"; import { tools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; @@ -58,6 +59,7 @@ const runtime = withRuntime({ pullRequestsAppResource, releasesAppResource, renderHtmlAppResource, + scheduledReleasesAppResource, ], }); diff --git a/api/resources/scheduled-releases.ts b/api/resources/scheduled-releases.ts new file mode 100644 index 0000000..2e30506 --- /dev/null +++ b/api/resources/scheduled-releases.ts @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPublicResource } from "@decocms/runtime/tools"; +import { SCHEDULED_RELEASES_RESOURCE_URI } from "../tools/scheduled-releases.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +function getDistPath(): string { + const projectRoot = join(import.meta.dir, "../.."); + return join(projectRoot, "dist", "client", "index.html"); +} + +export const scheduledReleasesAppResource = createPublicResource({ + uri: SCHEDULED_RELEASES_RESOURCE_URI, + name: "Scheduled Releases UI", + description: + "Calendar-first view of upcoming and past scheduled releases (campaigns and permanent ships).", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await readFile(getDistPath(), "utf-8"); + return { + uri: SCHEDULED_RELEASES_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, +}); diff --git a/api/tools/index.ts b/api/tools/index.ts index 6db2f69..88acaa2 100644 --- a/api/tools/index.ts +++ b/api/tools/index.ts @@ -66,6 +66,13 @@ import { revertCommitTool, } from "./releases.ts"; import { renderHtmlTool } from "./render-html.ts"; +import { + addChangeToReleaseTool, + cancelScheduledReleaseTool, + createScheduledReleaseTool, + listScheduledReleasesTool, + rescheduleScheduledReleaseTool, +} from "./scheduled-releases.ts"; import { testLoaderTool } from "./test-loader.ts"; export const tools = [ @@ -126,5 +133,10 @@ export const tools = [ getErrorRateSeriesTool, podLogsTool, renderHtmlTool, + listScheduledReleasesTool, + createScheduledReleaseTool, + addChangeToReleaseTool, + cancelScheduledReleaseTool, + rescheduleScheduledReleaseTool, testLoaderTool, ]; diff --git a/api/tools/scheduled-releases.ts b/api/tools/scheduled-releases.ts new file mode 100644 index 0000000..7468f71 --- /dev/null +++ b/api/tools/scheduled-releases.ts @@ -0,0 +1,354 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; + +export const SCHEDULED_RELEASES_RESOURCE_URI = + "ui://mcp-app/scheduled-releases"; + +// ─── schemas ────────────────────────────────────────────────────────────────── + +export const scheduledChangeSchema = z.object({ + pagePath: z.string().describe("Page path the change targets, e.g. '/'"), + sectionLabel: z + .string() + .describe("Human-readable section label, e.g. 'Hero'"), + sectionIndex: z + .number() + .describe("Index of the section on the page (0-based)"), + previewSnippet: z + .string() + .optional() + .describe("Short snippet of the new content for previewing"), +}); +export type ScheduledChange = z.infer; + +export const releaseStatusSchema = z.enum(["scheduled", "live", "ended"]); +export type ReleaseStatus = z.infer; + +export const scheduledReleaseSchema = z.object({ + id: z.string(), + name: z.string().describe("Display name of the release"), + startDate: z + .string() + .nullable() + .describe( + "ISO 8601 timestamp when the release goes live; null = not yet scheduled", + ), + endDate: z + .string() + .nullable() + .describe( + "ISO 8601 timestamp when the release ends; null means permanent ship", + ), + status: releaseStatusSchema, + changes: z.array(scheduledChangeSchema), +}); +export type ScheduledRelease = z.infer; + +// ─── persistence ────────────────────────────────────────────────────────────── +// +// Releases are persisted to a JSON file on the API server's local disk so they +// survive dev-server restarts. This is a stand-in for the eventual variant- +// backed format (where a release is a logical grouping of date-matched variants +// stored across .deco/blocks/*.json files in the user's sandbox env). + +const STORE_PATH = join(process.cwd(), ".context", "scheduled-releases.json"); + +async function readStore(): Promise { + try { + const text = await readFile(STORE_PATH, "utf-8"); + const parsed = JSON.parse(text); + if (!Array.isArray(parsed)) return []; + return parsed as ScheduledRelease[]; + } catch { + return []; + } +} + +async function writeStore(releases: ScheduledRelease[]): Promise { + await mkdir(dirname(STORE_PATH), { recursive: true }); + await writeFile(STORE_PATH, JSON.stringify(releases, null, 2)); +} + +function deriveStatus( + startIso: string | null, + endIso: string | null, +): ReleaseStatus { + if (!startIso) return "scheduled"; + const now = Date.now(); + const start = new Date(startIso).getTime(); + if (now < start) return "scheduled"; + if (endIso) { + const end = new Date(endIso).getTime(); + if (now >= end) return "ended"; + } + return "live"; +} + +function withDerivedStatus(release: ScheduledRelease): ScheduledRelease { + return { + ...release, + status: deriveStatus(release.startDate, release.endDate), + }; +} + +function sortByDate(a: ScheduledRelease, b: ScheduledRelease): number { + const ta = a.startDate ? new Date(a.startDate).getTime() : Infinity; + const tb = b.startDate ? new Date(b.startDate).getTime() : Infinity; + return ta - tb; +} + +// ─── list_scheduled_releases ───────────────────────────────────────────────── + +export const listScheduledReleasesInputSchema = z.object({ + env: z.string().optional().describe("Sandbox environment (unused for now)"), +}); +export type ListScheduledReleasesInput = z.infer< + typeof listScheduledReleasesInputSchema +>; + +export const listScheduledReleasesOutputSchema = z.object({ + releases: z.array(scheduledReleaseSchema), +}); +export type ListScheduledReleasesOutput = z.infer< + typeof listScheduledReleasesOutputSchema +>; + +export const listScheduledReleasesTool = createTool({ + id: "list_scheduled_releases", + description: + "List all releases (campaigns and permanent ships). Backed by a local JSON store until variant-backed persistence lands.", + inputSchema: listScheduledReleasesInputSchema, + outputSchema: listScheduledReleasesOutputSchema, + _meta: { ui: { resourceUri: SCHEDULED_RELEASES_RESOURCE_URI } }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async () => { + const releases = (await readStore()) + .map(withDerivedStatus) + .sort(sortByDate); + return { releases }; + }, +}); + +// ─── create_scheduled_release ──────────────────────────────────────────────── + +export const createScheduledReleaseInputSchema = z.object({ + env: z.string().optional().describe("Sandbox environment (unused for now)"), + name: z.string().describe("Display name (required)"), + startDate: z + .string() + .optional() + .describe( + "Optional ISO 8601 timestamp when the release goes live; omit to leave unscheduled", + ), + endDate: z + .string() + .optional() + .describe("Optional ISO 8601 timestamp when the release ends"), +}); +export type CreateScheduledReleaseInput = z.infer< + typeof createScheduledReleaseInputSchema +>; + +export const createScheduledReleaseOutputSchema = z.object({ + release: scheduledReleaseSchema, +}); +export type CreateScheduledReleaseOutput = z.infer< + typeof createScheduledReleaseOutputSchema +>; + +export const createScheduledReleaseTool = createTool({ + id: "create_scheduled_release", + description: + "Create a new release. The release is stored in a local JSON file; variant-backed persistence is wired up in a follow-up.", + inputSchema: createScheduledReleaseInputSchema, + outputSchema: createScheduledReleaseOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + execute: async ({ context }) => { + const id = `rel_${crypto.randomUUID().slice(0, 8)}`; + const release: ScheduledRelease = { + id, + name: context.name.trim(), + startDate: context.startDate ?? null, + endDate: context.endDate ?? null, + status: deriveStatus(context.startDate ?? null, context.endDate ?? null), + changes: [], + }; + const releases = await readStore(); + releases.push(release); + await writeStore(releases); + return { release }; + }, +}); + +// ─── add_change_to_release ──────────────────────────────────────────────────── +// +// Until variant-wrapping lands, records the change as a placeholder so the user +// can visually verify that their action attached to the right release. + +export const addChangeToReleaseInputSchema = z.object({ + env: z.string(), + releaseId: z.string(), + pagePath: z.string().optional().describe("Page path the change targets"), + sectionLabel: z.string().optional().describe("Section label"), + sectionIndex: z + .number() + .optional() + .describe("Section's position in the page's sections array"), +}); +export type AddChangeToReleaseInput = z.infer< + typeof addChangeToReleaseInputSchema +>; + +export const addChangeToReleaseOutputSchema = z.object({ + ok: z.boolean(), + releaseId: z.string(), + release: scheduledReleaseSchema.nullable(), +}); +export type AddChangeToReleaseOutput = z.infer< + typeof addChangeToReleaseOutputSchema +>; + +export const addChangeToReleaseTool = createTool({ + id: "add_change_to_release", + description: + "Attach the current sandbox changes to an existing release. Records a placeholder change until variant-backed persistence lands.", + inputSchema: addChangeToReleaseInputSchema, + outputSchema: addChangeToReleaseOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + execute: async ({ context }) => { + const releases = await readStore(); + const idx = releases.findIndex((r) => r.id === context.releaseId); + if (idx === -1) { + return { ok: false, releaseId: context.releaseId, release: null }; + } + const release = releases[idx]; + const updated: ScheduledRelease = { + ...release, + changes: [ + ...release.changes, + { + pagePath: context.pagePath ?? "/", + sectionLabel: context.sectionLabel ?? "Section", + sectionIndex: context.sectionIndex ?? release.changes.length, + previewSnippet: "Pending — variant wiring", + }, + ], + }; + releases[idx] = updated; + await writeStore(releases); + return { + ok: true, + releaseId: release.id, + release: withDerivedStatus(updated), + }; + }, +}); + +// ─── cancel_scheduled_release ──────────────────────────────────────────────── + +export const cancelScheduledReleaseInputSchema = z.object({ + releaseId: z.string(), +}); +export type CancelScheduledReleaseInput = z.infer< + typeof cancelScheduledReleaseInputSchema +>; + +export const cancelScheduledReleaseOutputSchema = z.object({ + ok: z.boolean(), + releaseId: z.string(), +}); +export type CancelScheduledReleaseOutput = z.infer< + typeof cancelScheduledReleaseOutputSchema +>; + +export const cancelScheduledReleaseTool = createTool({ + id: "cancel_scheduled_release", + description: "Remove a release and all of its scheduled changes.", + inputSchema: cancelScheduledReleaseInputSchema, + outputSchema: cancelScheduledReleaseOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + execute: async ({ context }) => { + const releases = await readStore(); + const next = releases.filter((r) => r.id !== context.releaseId); + await writeStore(next); + return { + ok: next.length !== releases.length, + releaseId: context.releaseId, + }; + }, +}); + +// ─── reschedule_scheduled_release ──────────────────────────────────────────── + +export const rescheduleScheduledReleaseInputSchema = z.object({ + releaseId: z.string(), + startDate: z + .string() + .nullable() + .describe("New start ISO timestamp, or null to unschedule"), + endDate: z + .string() + .nullable() + .optional() + .describe("New end ISO timestamp, or null/omit for permanent ship"), +}); +export type RescheduleScheduledReleaseInput = z.infer< + typeof rescheduleScheduledReleaseInputSchema +>; + +export const rescheduleScheduledReleaseOutputSchema = z.object({ + ok: z.boolean(), + release: scheduledReleaseSchema.nullable(), +}); +export type RescheduleScheduledReleaseOutput = z.infer< + typeof rescheduleScheduledReleaseOutputSchema +>; + +export const rescheduleScheduledReleaseTool = createTool({ + id: "reschedule_scheduled_release", + description: "Update a release's start and/or end date.", + inputSchema: rescheduleScheduledReleaseInputSchema, + outputSchema: rescheduleScheduledReleaseOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + execute: async ({ context }) => { + const releases = await readStore(); + const idx = releases.findIndex((r) => r.id === context.releaseId); + if (idx === -1) return { ok: false, release: null }; + const updated: ScheduledRelease = { + ...releases[idx], + startDate: context.startDate, + endDate: context.endDate ?? null, + status: deriveStatus(context.startDate, context.endDate ?? null), + }; + releases[idx] = updated; + await writeStore(releases); + return { ok: true, release: updated }; + }, +}); diff --git a/docs/superpowers/specs/2026-05-04-releases-ux-design.md b/docs/superpowers/specs/2026-05-04-releases-ux-design.md new file mode 100644 index 0000000..610b33d --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-releases-ux-design.md @@ -0,0 +1,173 @@ +# Releases & Scheduled Changes — UX Design + +**Date:** 2026-05-04 +**Status:** Draft (brainstorm validated, ready for implementation plan) +**Owner:** rafavalls + +## 1. Context + +Today the admin uses **variants** as the conditional-rendering primitive on a section. Each section can be marked `isMultivariate` and carry an array of variants, where each variant has a `rule` (matcher: device, date range, location, random %, host, pathname) and a `value` (the section's content). + +Users have started repurposing the **date-range matcher** as a campaign scheduler: set a variant's rule to `{ start, end }` and the variant renders only during that window. It works, but the UX of "go into multivariate mode → pick the date matcher → fill in the variant value" is not what scheduling a campaign should feel like, and the word *variant* leaks an A/B-testing mental model into a campaign-shipping task. + +Underneath, the storage model is git-backed: each user gets a sandbox environment that is literally a branch (`api/tools/environments.ts`), saves write to its working tree, `git_publish` commits + pushes, and `promote_to_production` deploys a chosen commit SHA. None of this should ever be visible to the user. + +## 2. Principles + +1. **No git in the UX.** No branches, commits, merges, diffs, conflicts, push, pull, HEAD, main. Translate to product language: **Release**, **Live**, **Schedule**, **Draft**, **Revert**, **History**, **Compare to live**. +2. **Variants stay as a primitive — but the scheduling use case gets its own door.** A/B testing and device targeting still belong in the variants UI. Time-windowed campaigns get a first-class flow that happens to be implemented on top of variants. +3. **Keep the lightness of "lovable."** Default path for a single scheduled change is one click + a date — no new objects to manage, no mode-switching. +4. **Context lives at the section level, not globally.** The user is never "in Black Friday mode" across the whole admin. They only see Black Friday content when they've explicitly opened that section's release version. + +## 3. Conceptual Model + +- **Live** is the default context. Whatever is currently rendering in production. +- **Release** is the unit of "stuff that goes live together at a time." A release has: + - An optional **name** (e.g. *Black Friday 2026*). If omitted, the release is identified by its scheduled date. + - A **scheduled date** (start) — applies to the release as a whole; every section change inside the release goes live at this moment. + - An optional **end date** — also applies to the release as a whole; omitted = permanent ship. + - A **list of section changes** — one or more sections (across one or more pages) whose content changes when the release is live. + - A **status**: *Scheduled · Live · Ended*. + +End date being a release-level property (not per-section) means a campaign can't have inconsistent end dates across its sections, and the user only sets it once. + +A release with end-date set behaves like a temporary overlay (auto-reverts when the window closes, because the underlying date-matched variant stops firing). A release with no end is a permanent ship. + +**Single mental model, two natural shapes:** end-bounded campaigns and open-ended ships are the same thing with a different end value. + +## 4. Authoring Flow — the Publish Chevron + +The Publish button is the only way to put changes live. It **always opens a small popover** when clicked — there is no "click to publish instantly" path. Every publish is a deliberate choice. + +``` +┌─────────────────────────────────┐ +│ ◉ Publish now │ +│ ◯ Schedule for… ▢ date │ +│ │ +│ Release name (optional) │ +│ ┌────────────────────────────┐ │ +│ │ Black Friday ▾ │ │ ← suggests existing upcoming releases +│ └────────────────────────────┘ │ +│ │ +│ [ Cancel ] [ Save ]│ +└─────────────────────────────────┘ +``` + +**Behavior** + +- *Publish now* → updates the section's base content, commits, and goes live. No release is created. No variant is created. +- *Schedule for…* + a date → creates (or attaches to) a release. Under the hood, this writes a date-matched variant on the section; we never expose the variant primitive in this flow. +- The **Release name** field is optional: + - **Blank** → the change is grouped by its scheduled date in the Releases view (e.g. all blank-named changes scheduled for Nov 24 cluster under one *Nov 24* entry). + - **Typed** → first time creates a release with that name; the dropdown then suggests existing upcoming releases so other section edits can join the same one. +- The chevron only sets the **start** date. The **end date** is a property of the release as a whole and is set/edited on the release detail page (Section 6). Default = no end date = permanent ship. This avoids per-section drift in campaign end times. + +The chevron lives wherever Publish lives today. No new editing context is introduced for the simple case. + +## 5. Cross-Page Campaigns + +For a campaign like Black Friday that touches the home hero + category banner + nav CTA: + +1. User edits the home hero, opens Publish chevron, picks *Schedule for Nov 24*, types **Black Friday** in the name field, hits Save. +2. User navigates to the category page, edits the banner, opens chevron, picks *Schedule for Nov 24*, the name field's dropdown now suggests **Black Friday** — they pick it. +3. Same for the nav CTA. + +All three section changes belong to one *Black Friday* release. Visible together in the Releases view. + +## 6. The Releases View + +**Primary entry:** sidebar / top nav item labeled **Releases**. + +**Default view:** list, sorted by scheduled date soonest-first. A toggle in the header switches to a calendar view (same data, visual layout). Search by name, filter by status. + +**List rows show:** + +- Name (or scheduled date if unnamed) +- Scheduled date (and end date if set) +- Count of section changes +- Status pill: *Scheduled · Live · Ended* + +**Click a release → detail page/drawer:** + +- Header: name (editable), **start date**, **end date** (optional, "Set end date" if blank), status, *Cancel release* action. +- **Changes in this release** — a list, one row per (page, section), with a snippet/preview of the new content. +- Per row actions: *Edit content · Remove from release*. + +**Action semantics:** + +- *Reschedule* = edit start or end date in the header. Applies to the whole release. +- *Cancel release* = remove all of the release's section changes; live content is unaffected (it was never overwritten because the release hadn't gone live yet, or — if currently live — falls back to base on next render). Confirmation required. +- *Remove from release* = drop just that one section's change; the release continues with its remaining sections. + +Status transitions are derived from dates: *Scheduled* before start, *Live* between start and end, *Ended* after end. No manual state changes. + +## 7. Editing a Release's Section + +Clicking *Edit content* on a row in the release detail opens the section editor focused on that section, with a banner at the top: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Editing: Black Friday version of Home / Hero │ +│ Scheduled Nov 24 [ Switch to Live ]│ +└─────────────────────────────────────────────────────────────┘ +``` + +The Publish chevron in this context offers different options: + +- *Update this release* (default — saves to the release; goes live at the release's scheduled time) +- *Publish to Live now* (promotes this section's release content to base immediately and removes it from the release; the rest of the release continues unchanged) + +Rescheduling is done on the release detail page (start/end dates apply release-wide), not from the section editor. + +## 8. Discoverability from the Section Editor + +When a user opens a section normally (not via a release) and that section has any upcoming scheduled change, the section header shows a small badge: + +``` +Hero · 1 scheduled change · Nov 24 → +``` + +Click → jumps into that release's detail (or directly into editing that section's release version if there's only one). + +This means an editor working on Live can never be surprised by a future change "appearing" — there's always a marker. + +## 9. Editing a Currently-Live Release + +A release whose window contains "now" is rendering on production. Editing its content updates what's live immediately. + +**Decision:** Saving an edit to a currently-live release shows a small confirmation: + +> *"This release is live now — your edit will go live immediately. Continue?"* + +[ Cancel ] [ Update live ] + +Tiny friction, prevents oops moments. The case is rare; the friction won't annoy. + +## 10. Mapping to Existing Code (under the hood) + +| UX concept | Underlying mechanism | +|---|---| +| *Publish now* | Edit base section content → `git_publish` → `promote_to_production` | +| *Schedule for…* | Create/extend a `CmsVariant` on the section with `rule = { start, end }`, `value = new content` → `git_publish` → `promote_to_production` | +| *Release* (named) | Logical grouping of date-matched variants by a shared name. Stored either as metadata on each variant or as a sidecar release index. **Decision deferred to plan phase.** | +| *Release* (unnamed) | Logical grouping by exact scheduled date. No new storage; computed at read time by clustering variants whose start date matches. | +| *Live* | Current production deployment (existing concept). | +| *Switch to Live* | Navigate to base content of that section. | +| *Cancel release* | Remove the date-matched variants from involved sections → `git_publish` → `promote_to_production`. | + +## 11. Out of Scope (this design) + +- A/B testing UX. Variants as a primitive remain for non-time matchers (device, location, random %, etc.). Their UI is not changed by this design. +- **Conflict handling** when two releases overlap on the same section. For v1, last-scheduled wins inside the overlap window (matches current variant array order semantics). Surface a passive warning ("Overlaps with *Cyber Monday*") in the release detail when detected; full conflict resolution UX is a follow-up. +- **Approval / review workflows** (multi-user signoff before scheduling). +- **Audit log** of who created/edited a release. Likely needed soon, but not part of this design. +- **Release templates** ("clone last year's Black Friday"). + +## 12. Open Question for the Plan Phase + +How exactly to persist the *release* grouping — name, schedule, member sections — given the existing variant storage. Two viable shapes: + +- **Embed on each variant:** add a `releaseId` / `releaseName` field to the variant rule metadata. Simple, no new files. Reads must aggregate across the file tree to list a release. +- **Sidecar release index:** a `.deco/releases/{id}.json` file listing the release's metadata + member references. Cheap to list, but two write-paths to keep consistent. + +This is an implementation decision, not a UX one. Punt to the plan. diff --git a/web/router.tsx b/web/router.tsx index fec5d27..7d9bb49 100644 --- a/web/router.tsx +++ b/web/router.tsx @@ -14,6 +14,7 @@ import MonitorPage from "./tools/monitor/index.tsx"; import PullRequestsPage from "./tools/pull-requests/index.tsx"; import ReleasesPage from "./tools/releases/index.tsx"; import RenderHtmlPage from "./tools/render-html/index.tsx"; +import ScheduledReleasesPage from "./tools/scheduled-releases/index.tsx"; const TOOL_PAGES: Record = { fetch_assets: AssetsPage, @@ -22,6 +23,7 @@ const TOOL_PAGES: Record = { list_issues: IssuesPage, list_pull_requests: PullRequestsPage, list_releases: ReleasesPage, + list_scheduled_releases: ScheduledReleasesPage, render_html: RenderHtmlPage, }; diff --git a/web/tools/file-explorer/index.tsx b/web/tools/file-explorer/index.tsx index 4248ce3..985459e 100644 --- a/web/tools/file-explorer/index.tsx +++ b/web/tools/file-explorer/index.tsx @@ -170,7 +170,7 @@ import type { import type { GitRawOutput, GitStatus } from "../../../api/tools/git.ts"; import type { SchemaProperties } from "./cms-form.tsx"; import { SectionForm } from "./cms-form.tsx"; -import { PublishDialog } from "./publish-dialog.tsx"; +import { PublishDialog, type UpcomingRelease } from "./publish-dialog.tsx"; import type { CmsInspectPayload, EnvStatus, @@ -658,8 +658,63 @@ type CodeSelectionPrompt = { // ─── sortable section row ───────────────────────────────────────────────────── +const sectionBadgeDateFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +function ScheduledChangesBadge({ + changes, +}: { + changes: Array<{ releaseName: string; releaseStartDate: string | null }>; +}) { + const next = changes + .filter((c) => !!c.releaseStartDate) + .sort( + (a, b) => + new Date(a.releaseStartDate!).getTime() - + new Date(b.releaseStartDate!).getTime(), + )[0]; + const label = + changes.length === 1 + ? changes[0].releaseName + : `${changes.length} scheduled`; + const dateLabel = next?.releaseStartDate + ? ` · ${sectionBadgeDateFormatter.format(new Date(next.releaseStartDate))}` + : ""; + return ( + + + e.stopPropagation()} + className="shrink-0 truncate rounded-sm border border-sky-500/30 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-sky-700 dark:text-sky-300" + > + + {label} + {dateLabel} + + + + {changes.length === 1 + ? `In release "${changes[0].releaseName}"` + : `In ${changes.length} releases`} + {next?.releaseStartDate && ( + <> +
+ Next:{" "} + {new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(new Date(next.releaseStartDate))} + + )} +
+
+ ); +} + function SortableSectionItem({ section, + scheduledChanges, onSelect, onDuplicate, onRemove, @@ -675,6 +730,10 @@ function SortableSectionItem({ isSavedBlock?: boolean; isMultivariate?: boolean; }; + scheduledChanges?: Array<{ + releaseName: string; + releaseStartDate: string | null; + }>; onSelect: () => void; onDuplicate: () => void; onRemove: () => void; @@ -735,6 +794,9 @@ function SortableSectionItem({ > {section.label} + {scheduledChanges && scheduledChanges.length > 0 && ( + + )} - + <> + + + + {/* ── Header ── */} +
+
+
+

+ Publish changes +

+

+ {isLoadingGitDiff + ? "Loading…" + : diffCount === 0 + ? "No changes to publish" + : `${diffCount} ${diffCount === 1 ? "file" : "files"} changed`} +

-
- ) : ( -
- - - Description - - - Changes - - - {diffCount > 0 && ( - + {!isLoadingGitDiff && diffCount > 0 && ( + + + Pending + )}
- )} -
+ + + Description + + + Changed files + {diffCount > 0 && ( + + {diffCount} + + )} + + + -
+ - {/* Scrollable content */} -
- {isLoadingGitDiff ? ( -
- - Loading changes… -
- ) : ( - <> - -
-
-

Commit message

- -
-
- - setPublishTitle(e.target.value)} - placeholder={ - isGeneratingSuggestion - ? "Generating…" - : "Commit title…" - } - disabled={isPublishing} - className="text-sm" - /> -
-
- -