diff --git a/routes/cookbook_schedule_routes.py b/routes/cookbook_schedule_routes.py
index b298311..a87b427 100644
--- a/routes/cookbook_schedule_routes.py
+++ b/routes/cookbook_schedule_routes.py
@@ -205,4 +205,62 @@ def setup_cookbook_schedule_routes() -> APIRouter:
from src.cookbook_scheduler import _reconcile_once
return await _reconcile_once()
+ @router.post("/ensure-calendar")
+ async def ensure_calendar(request: Request):
+ """Ensure a calendar named \"Cookbook\" exists for the current user
+ and is registered as the scheduler calendar in settings. Idempotent.
+
+ Used by the Schedule button so the user never has to pick: the
+ first click creates the calendar and wires it up; subsequent
+ clicks return the existing href.
+ """
+ require_admin(request)
+ from src.settings import get_setting, _save_settings, _load_settings
+ existing = get_setting("cookbook_schedule_calendar_href", "") or ""
+ # Verify the saved href still exists in /api/calendar/calendars
+ # (the user might have deleted the calendar manually) by hitting
+ # the list endpoint loopback.
+ import httpx
+ from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
+ headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
+ live: list = []
+ try:
+ async with httpx.AsyncClient(timeout=10) as c:
+ r = await c.get("http://localhost:7000/api/calendar/calendars", headers=headers)
+ live = (r.json() or {}).get("calendars", []) if r.status_code < 400 else []
+ except Exception:
+ live = []
+ if existing and any(c.get("href") == existing for c in live):
+ match = next(c for c in live if c.get("href") == existing)
+ return {"ok": True, "href": existing, "name": match.get("name"), "created": False}
+ # No usable cookbook calendar — see if one named "Cookbook" already
+ # exists (perhaps from a previous setup), else create a fresh one.
+ match = next((c for c in live if (c.get("name") or "").lower() == "cookbook"), None)
+ if match:
+ href = match.get("href")
+ else:
+ try:
+ async with httpx.AsyncClient(timeout=10) as c:
+ r = await c.post(
+ "http://localhost:7000/api/calendar/calendars",
+ params={"name": "Cookbook", "color": "#3b82f6"},
+ headers=headers,
+ )
+ data = r.json() if r.content else {}
+ except Exception as exc:
+ raise HTTPException(500, f"create calendar failed: {exc}")
+ if not data.get("ok"):
+ raise HTTPException(500, f"create calendar failed: {data}")
+ href = data.get("id") or ""
+ if not href:
+ raise HTTPException(500, "no href returned from calendar create")
+ # Persist into settings (bypasses DEFAULT_SETTINGS guard by using
+ # _load/_save directly since we know this key is whitelisted).
+ s = _load_settings()
+ s["cookbook_schedule_calendar_href"] = href
+ if not s.get("cookbook_scheduler_enabled"):
+ s["cookbook_scheduler_enabled"] = True
+ _save_settings(s)
+ return {"ok": True, "href": href, "name": "Cookbook", "created": True}
+
return router
diff --git a/static/js/calendar.js b/static/js/calendar.js
index 31a4423..4088867 100644
--- a/static/js/calendar.js
+++ b/static/js/calendar.js
@@ -3346,3 +3346,42 @@ window.addEventListener('calendar-refresh', () => {
const calendarModule = { openCalendar, closeCalendar, isCalendarOpen };
export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen };
export default calendarModule;
+
+// ── Cookbook scheduler hook ─────────────────────────────────────────────
+// Lets the Cookbook tab's Schedule button open the standard calendar
+// event-create form pre-filled with the model's name + cookbook YAML in
+// the description, on the auto-created Cookbook calendar. Keeps the
+// Schedule UX identical to creating any other calendar event — no
+// custom modal, just the existing flow that users already know.
+window.cookbookOpenScheduleForm = function (draft) {
+ // Open the calendar first so #cal-body exists for _showEventForm.
+ if (!_open) openCalendar();
+ // Defer a tick so the modal DOM is mounted before we touch it.
+ setTimeout(() => {
+ _showEventForm(null, _today(), _today());
+ setTimeout(() => {
+ try {
+ const sumEl = document.getElementById('cal-f-sum');
+ if (sumEl && draft && draft.summary) sumEl.value = draft.summary;
+ const descEl = document.getElementById('cal-f-desc');
+ if (descEl && draft && draft.description) descEl.value = draft.description;
+ const rrEl = document.getElementById('cal-f-rrule');
+ if (rrEl && draft && draft.rrule) rrEl.value = draft.rrule;
+ // Calendar selector lives behind the "Add details" expand; force-
+ // expand so the user sees it's heading into the Cookbook calendar.
+ const form = document.querySelector('.cal-form-bespoke');
+ if (form) form.classList.add('is-expanded');
+ const calSel = document.getElementById('cal-f-cal');
+ if (calSel && draft && draft.calendar_href) {
+ const opt = Array.from(calSel.options).find(o => o.value === draft.calendar_href);
+ if (opt) calSel.value = draft.calendar_href;
+ }
+ // Focus the title so the user can immediately type if they want
+ // to rename the event (rare, but cheap to enable).
+ if (sumEl) sumEl.focus();
+ } catch (e) {
+ console.warn('cookbook schedule prefill failed:', e);
+ }
+ }, 60);
+ }, _open ? 0 : 80);
+};
diff --git a/static/js/cookbookSchedule.js b/static/js/cookbookSchedule.js
index c0f612a..21a7659 100644
--- a/static/js/cookbookSchedule.js
+++ b/static/js/cookbookSchedule.js
@@ -201,16 +201,18 @@
}
// Click-binding: any .hwfit-serve-schedule button inside a serve
- // panel opens the modal with the panel's current config.
- document.addEventListener("click", (e) => {
+ // panel routes to the STANDARD calendar event-creation form, with the
+ // model's name pre-filled as the event title and a `cookbook:` YAML
+ // block in the description. The event lands on the auto-created
+ // "Cookbook" calendar so the reconciler picks it up. The custom
+ // openModal() above is kept as a fallback in case the calendar
+ // module hasn't loaded.
+ document.addEventListener("click", async (e) => {
const btn = e.target.closest && e.target.closest(".hwfit-serve-schedule");
if (!btn) return;
e.preventDefault();
e.stopPropagation();
- // Reach into the serve panel to read current config. cookbookServe.js
- // stores the active config on the panel root via data attributes
- // that we read here without coupling further.
const panel = btn.closest("[data-cookbook-serve-panel]") || btn.closest(".doclib-card-expanded") || btn.closest(".doclib-card");
const ds = panel ? panel.dataset || {} : {};
const config = {
@@ -221,7 +223,47 @@
host: ds.host || "",
port: ds.port ? Number(ds.port) : undefined,
};
- openModal(config);
+
+ // Ensure the Cookbook calendar exists and is configured. Returns
+ // the href to feed into the event form.
+ btn.disabled = true;
+ let calHref = "";
+ try {
+ const r = await fetch("/api/cookbook/schedule/ensure-calendar", {
+ method: "POST", credentials: "same-origin",
+ });
+ if (r.ok) {
+ const data = await r.json();
+ calHref = data.href || "";
+ }
+ } catch (_) {}
+ btn.disabled = false;
+
+ // Build the cookbook: YAML block that goes into the event description.
+ // The reconciler parses this to know HOW to launch when the window
+ // opens. If only the title is set, the reconciler title-matches
+ // against saved presets.
+ const yamlLines = ["cookbook:"];
+ for (const k of ["preset", "repo_id", "cmd", "host", "port"]) {
+ if (config[k]) yamlLines.push(` ${k}: ${config[k]}`);
+ }
+ if (yamlLines.length === 1 && config.title) {
+ yamlLines.push(` preset: ${config.title}`);
+ }
+ const draft = {
+ summary: config.title,
+ description: yamlLines.join("\n"),
+ rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", // default: weekdays
+ calendar_href: calHref,
+ };
+
+ if (typeof window.cookbookOpenScheduleForm === "function") {
+ window.cookbookOpenScheduleForm(draft);
+ } else {
+ // Fallback to the legacy in-house modal if the calendar module
+ // hasn't loaded for some reason.
+ openModal(config);
+ }
});
// Reveal Schedule buttons once we confirm the feature is enabled.
diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js
index 160a140..945a353 100644
--- a/static/js/cookbookServe.js
+++ b/static/js/cookbookServe.js
@@ -744,12 +744,14 @@ function _rerenderCachedModels() {
// pushes Cancel + Launch to the right.
panelHtml += ``;
panelHtml += ``;
- // Schedule button — opens a modal that converts the current serve
- // config into a calendar event. Hidden unless the scheduler feature
- // flag is on (cookbookSchedule.js reads /api/cookbook/schedule/upcoming
- // on init and toggles visibility).
- panelHtml += ``;
+ // Launch + Schedule pair. The little ° button sits flush against
+ // Launch and opens the calendar event-creation form pre-filled
+ // with this model's config. Hidden until cookbookSchedule.js
+ // confirms the scheduler feature flag is on.
+ panelHtml += ``;
panelHtml += ``;
+ panelHtml += ``;
+ panelHtml += ``;
panelHtml += ``;
panelHtml += ``;
diff --git a/static/style.css b/static/style.css
index aa58b43..75f5279 100644
--- a/static/style.css
+++ b/static/style.css
@@ -35829,3 +35829,29 @@ body.theme-frosted .modal {
.cookbook-schedule-modal { width: 96vw; }
.cookbook-schedule-day { padding: 8px 14px; font-size: 14px; }
}
+
+/* Launch+Schedule paired button group. The ° button is a tiny icon
+ button glued to the right side of Launch with a thin divider. */
+.hwfit-serve-launch-group {
+ display: inline-flex;
+ align-items: stretch;
+ vertical-align: middle;
+}
+.hwfit-serve-launch-group .hwfit-serve-launch {
+ border-top-right-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+ margin-right: 0 !important;
+}
+.hwfit-serve-launch-group .hwfit-serve-schedule {
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ border-left: 1px solid rgba(255,255,255,.18) !important;
+ padding: 0 8px !important;
+ min-width: 26px;
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+}
+.hwfit-serve-launch-group .hwfit-serve-schedule svg {
+ flex-shrink: 0;
+}