From 495af1e0b9118e0818cc9a3df12996a42b4e5d62 Mon Sep 17 00:00:00 2001 From: advancedresearcharray Date: Tue, 30 Jun 2026 05:22:55 +0000 Subject: [PATCH] feat(ui): calendar view for frontmatter dates Wire KiwiCalendar into App shell with toolbar toggle, Mod+Shift+C, /view/calendar deep links, and DQL month/week queries. Add calendar feature flag, keybinding, and regression tests for routing and dismiss. Closes #427 Signed-off-by: advancedresearcharray Co-authored-by: Cursor --- internal/config/ui_features.go | 2 + internal/config/ui_features_test.go | 11 +- internal/keybindings/keybindings.go | 2 + ui/src/App.tsx | 98 ++++++- ui/src/components/KiwiCalendar.tsx | 345 +++++++++++++++++++++++++ ui/src/components/__mocks__/apiMock.ts | 30 ++- ui/src/lib/api.ts | 2 +- ui/src/lib/appViewRoutes.test.ts | 54 ++++ ui/src/lib/appViewRoutes.ts | 49 ++++ ui/src/lib/calendarView.test.ts | 166 ++++++++++++ ui/src/lib/calendarView.ts | 259 +++++++++++++++++++ ui/src/lib/hostConfig.ts | 1 + ui/src/lib/kiwiKeybindings.test.ts | 70 +++++ ui/src/lib/kiwiKeybindings.ts | 20 ++ ui/src/lib/overlayDismiss.test.ts | 3 + ui/src/lib/overlayDismiss.ts | 3 + ui/src/lib/toolbarComposition.ts | 2 + ui/src/lib/uiFeatures.test.ts | 4 +- ui/src/lib/uiFeatures.ts | 3 + 19 files changed, 1111 insertions(+), 13 deletions(-) create mode 100644 ui/src/components/KiwiCalendar.tsx create mode 100644 ui/src/lib/appViewRoutes.test.ts create mode 100644 ui/src/lib/appViewRoutes.ts create mode 100644 ui/src/lib/calendarView.test.ts create mode 100644 ui/src/lib/calendarView.ts diff --git a/internal/config/ui_features.go b/internal/config/ui_features.go index 2f1ad184..5fe11d19 100644 --- a/internal/config/ui_features.go +++ b/internal/config/ui_features.go @@ -8,6 +8,7 @@ type UIFeaturesConfig struct { Canvas *bool `toml:"canvas"` Whiteboard *bool `toml:"whiteboard"` Timeline *bool `toml:"timeline"` + Calendar *bool `toml:"calendar"` Bases *bool `toml:"bases"` DataSources *bool `toml:"data_sources"` } @@ -24,6 +25,7 @@ func (f UIFeaturesConfig) Resolved() map[string]bool { "canvas": featureEnabled(f.Canvas), "whiteboard": featureEnabled(f.Whiteboard), "timeline": featureEnabled(f.Timeline), + "calendar": featureEnabled(f.Calendar), "bases": featureEnabled(f.Bases), "data_sources": featureEnabled(f.DataSources), } diff --git a/internal/config/ui_features_test.go b/internal/config/ui_features_test.go index 7658f7cc..f6b4b53d 100644 --- a/internal/config/ui_features_test.go +++ b/internal/config/ui_features_test.go @@ -4,7 +4,7 @@ import "testing" func TestUIFeaturesConfigDefaults(t *testing.T) { f := UIFeaturesConfig{}.Resolved() - for _, key := range []string{"graph", "kanban", "canvas", "whiteboard", "timeline", "bases", "data_sources"} { + for _, key := range []string{"graph", "kanban", "canvas", "whiteboard", "timeline", "calendar", "bases", "data_sources"} { if !f[key] { t.Fatalf("expected %s enabled by default", key) } @@ -14,11 +14,12 @@ func TestUIFeaturesConfigDefaults(t *testing.T) { func TestUIFeaturesConfigExplicitFalse(t *testing.T) { falseVal := false f := UIFeaturesConfig{ - Kanban: &falseVal, - Graph: &falseVal, + Kanban: &falseVal, + Graph: &falseVal, + Calendar: &falseVal, }.Resolved() - if f["kanban"] || f["graph"] { - t.Fatal("expected kanban and graph disabled") + if f["kanban"] || f["graph"] || f["calendar"] { + t.Fatal("expected kanban, graph, and calendar disabled") } if !f["canvas"] || !f["bases"] { t.Fatal("expected unset features to remain enabled") diff --git a/internal/keybindings/keybindings.go b/internal/keybindings/keybindings.go index a1c3ff74..7e6bc004 100644 --- a/internal/keybindings/keybindings.go +++ b/internal/keybindings/keybindings.go @@ -19,6 +19,7 @@ var knownActions = map[string]struct{}{ "graph": {}, "toggle_bases": {}, "toggle_timeline": {}, + "toggle_calendar": {}, "toggle_kanban": {}, "toggle_mode": {}, "shortcuts_help": {}, @@ -37,6 +38,7 @@ var DefaultBindings = map[string]string{ "graph": "Mod+G", "toggle_bases": "Mod+Shift+B", "toggle_timeline": "Mod+Shift+T", + "toggle_calendar": "Mod+Shift+C", "toggle_kanban": "Mod+Shift+W", "toggle_mode": "Mod+Shift+E", "shortcuts_help": "Mod+/", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d9cc252a..2daed8c3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { + CalendarDays, Clock4, Columns3, Database, @@ -30,6 +31,7 @@ import { KiwiBases } from "./components/KiwiBases"; import { KiwiCanvasScreen } from "./components/KiwiCanvasScreen"; import { KiwiWhiteboardScreen } from "./components/KiwiWhiteboardScreen"; import { KiwiTimeline } from "./components/KiwiTimeline"; +import { KiwiCalendar } from "./components/KiwiCalendar"; import { KiwiKanban } from "./components/KiwiKanban"; import { KiwiRecentStart } from "./components/KiwiRecentStart"; import { KanbanDragProvider } from "./components/kanban/KanbanDragProvider"; @@ -48,6 +50,11 @@ import { useKeybindings } from "./hooks/useKeybindings"; import { useUIConfig } from "./hooks/useUIConfig"; import { usePreferences } from "./hooks/usePreferences"; import { formatChordDisplay, matchBoundAction, type KeybindingAction } from "./lib/kiwiKeybindings"; +import { + shouldOpenViewFromPathname, + shouldPreservePathnameForViewRoute, + type AppViewId, +} from "./lib/appViewRoutes"; import { resolveOverlayDismiss } from "./lib/overlayDismiss"; import { hasDeepLinkPath, resolveDashboardPath, resolveStartPage, shouldApplyStartPage } from "./lib/startPage"; import { formatDocumentTitle } from "./lib/pageTitle"; @@ -97,6 +104,7 @@ export default function App() { const [whiteboardOpen, setWhiteboardOpen] = useState(false); const [initialWhiteboardPath, setInitialWhiteboardPath] = useState(null); const [timelineOpen, setTimelineOpen] = useState(false); + const [calendarOpen, setCalendarOpen] = useState(false); const [kanbanOpen, setKanbanOpen] = useState(false); const [treeRevealRequest, setTreeRevealRequest] = useState(null); const treeRef = useRef(null); @@ -119,12 +127,42 @@ export default function App() { setCanvasOpen(false); setWhiteboardOpen(false); setTimelineOpen(false); + setCalendarOpen(false); setKanbanOpen(false); setDataOpen(false); setGraphOpen(false); setHistoryOpen(false); }, []); + const openBuiltinView = useCallback((id: AppViewId) => { + switch (id) { + case "graph": + setGraphOpen(true); + break; + case "bases": + setBasesOpen(true); + break; + case "canvas": + setCanvasOpen(true); + break; + case "whiteboard": + setWhiteboardOpen(true); + break; + case "timeline": + setTimelineOpen(true); + break; + case "calendar": + setCalendarOpen(true); + break; + case "kanban": + setKanbanOpen(true); + break; + case "data": + setDataOpen(true); + break; + } + }, []); + const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.innerWidth < 768); useEffect(() => { const mq = window.matchMedia("(max-width: 767px)"); @@ -201,6 +239,7 @@ export default function App() { canvasOpen, whiteboardOpen, timelineOpen, + calendarOpen, kanbanOpen, }); stateRef.current = { @@ -216,6 +255,7 @@ export default function App() { canvasOpen, whiteboardOpen, timelineOpen, + calendarOpen, kanbanOpen, }; @@ -327,8 +367,8 @@ export default function App() { setNewOpen(true); break; case "toggle_editor": { - const { activePath, graphOpen, historyOpen, dataOpen } = state; - if (!activePath || graphOpen || historyOpen || dataOpen) return; + const { activePath, graphOpen, historyOpen, dataOpen, calendarOpen } = state; + if (!activePath || graphOpen || historyOpen || dataOpen || calendarOpen) return; e.preventDefault(); setEditing((v) => !v); break; @@ -368,6 +408,13 @@ export default function App() { setTimelineOpen(next); break; } + case "toggle_calendar": { + e.preventDefault(); + const next = !state.calendarOpen; + closeAllViews(); + setCalendarOpen(next); + break; + } case "toggle_kanban": { e.preventDefault(); const next = !state.kanbanOpen; @@ -428,6 +475,9 @@ export default function App() { case "timeline": setTimelineOpen(false); break; + case "calendar": + setCalendarOpen(false); + break; case "kanban": setKanbanOpen(false); break; @@ -449,6 +499,7 @@ const handleSpaceSwitch = useCallback(() => { setBasesOpen(false); setCanvasOpen(false); setTimelineOpen(false); + setCalendarOpen(false); setKanbanOpen(false); setSpaceKey((k) => k + 1); setRefreshKey((k) => k + 1); @@ -527,6 +578,9 @@ const handleSpaceSwitch = useCallback(() => { case "timeline": setTimelineOpen(true); break; + case "calendar": + setCalendarOpen(true); + break; case "canvas": setCanvasOpen(true); break; @@ -540,10 +594,22 @@ const handleSpaceSwitch = useCallback(() => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [uiConfigLoaded]); + useEffect(() => { + if (!uiConfigLoaded || isDemoMode) return; + const viewId = shouldOpenViewFromPathname(window.location.pathname, features); + if (!viewId) return; + closeAllViews(); + openBuiltinView(viewId); + }, [uiConfigLoaded, isDemoMode, features, closeAllViews, openBuiltinView]); + useEffect(() => { if (isCloudMode || isDemoMode) return; if (!activePath) { - if (window.location.pathname !== "/") { + const pathname = window.location.pathname; + if ( + pathname !== "/" + && !shouldPreservePathnameForViewRoute(pathname) + ) { window.history.pushState(null, "", "/"); } return; @@ -579,15 +645,26 @@ const handleSpaceSwitch = useCallback(() => { setBasesOpen(false); setCanvasOpen(false); setTimelineOpen(false); + setCalendarOpen(false); setKanbanOpen(false); } else if (pathname === "/") { fromPopState.current = true; setActivePath(null); + closeAllViews(); + } else { + const viewId = shouldOpenViewFromPathname(pathname, features); + if (viewId) { + fromPopState.current = true; + setActivePath(null); + setEditing(false); + closeAllViews(); + openBuiltinView(viewId); + } } }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); - }, [isCloudMode, isDemoMode]); + }, [isCloudMode, isDemoMode, features, closeAllViews, openBuiltinView]); function revealActivePageInTree() { if (!activePath) return; @@ -635,6 +712,7 @@ const handleSpaceSwitch = useCallback(() => { setCanvasOpen(false); setWhiteboardOpen(false); setTimelineOpen(false); + setCalendarOpen(false); setKanbanOpen(false); recordVisit(path); if (isMobile) setSidebarOpen(false); @@ -703,6 +781,7 @@ const handleSpaceSwitch = useCallback(() => { canvas: canvasOpen, whiteboard: whiteboardOpen, timeline: timelineOpen, + calendar: calendarOpen, kanban: kanbanOpen, data: dataOpen, }[id]; @@ -723,6 +802,9 @@ const handleSpaceSwitch = useCallback(() => { case "timeline": setTimelineOpen(!wasOpen); break; + case "calendar": + setCalendarOpen(!wasOpen); + break; case "kanban": setKanbanOpen(!wasOpen); break; @@ -810,7 +892,7 @@ const handleSpaceSwitch = useCallback(() => { )} {/* Main content area */} -
+
{basesOpen ? ( setBasesOpen(false)} @@ -835,6 +917,11 @@ const handleSpaceSwitch = useCallback(() => { onClose={() => setTimelineOpen(false)} onNavigate={(p) => { setTimelineOpen(false); navigate(p); }} /> + ) : calendarOpen ? ( + setCalendarOpen(false)} + onNavigate={(p) => { setCalendarOpen(false); navigate(p); }} + /> ) : kanbanOpen ? ( setKanbanOpen(false)} @@ -1042,6 +1129,7 @@ const BUILTIN_TOOLBAR_BUTTONS: Record< canvas: { label: "Canvas", Icon: Presentation }, whiteboard: { label: "Whiteboard", Icon: PenTool }, timeline: { label: "Timeline", Icon: Clock4 }, + calendar: { label: "Calendar", Icon: CalendarDays }, kanban: { label: "Kanban", Icon: Columns3 }, data: { label: "Data sources", Icon: Database }, }; diff --git a/ui/src/components/KiwiCalendar.tsx b/ui/src/components/KiwiCalendar.tsx new file mode 100644 index 00000000..c202f947 --- /dev/null +++ b/ui/src/components/KiwiCalendar.tsx @@ -0,0 +1,345 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ArrowLeft, + CalendarDays, + ChevronLeft, + ChevronRight, + Loader2, +} from "lucide-react"; +import { format, startOfMonth } from "date-fns"; +import { api } from "@kw/lib/api"; +import { cn } from "@kw/lib/cn"; +import { + addMonths, + buildFieldDiscoveryQuery, + buildMobileWeekCells, + buildMonthGrid, + buildMonthQuery, + buildWeekQuery, + CALENDAR_DATE_FIELD_STORAGE_KEY, + discoverDateFields, + formatMonthLabel, + groupPagesByDate, + isTodayDate, + pageDotClass, + parseCalendarRows, + type CalendarDayCell, + type CalendarPage, +} from "@kw/lib/calendarView"; +import { titleize } from "@kw/lib/paths"; +import { Button } from "@kw/components/ui/button"; +import { Badge } from "@kw/components/ui/badge"; +import { Card, CardContent } from "@kw/components/ui/card"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@kw/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@kw/components/ui/select"; + +type Props = { + onClose: () => void; + onNavigate: (path: string) => void; +}; + +const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const MAX_VISIBLE_DOTS = 3; + +function loadStoredDateField(): string { + try { + return localStorage.getItem(CALENDAR_DATE_FIELD_STORAGE_KEY) ?? "date"; + } catch { + return "date"; + } +} + +function pageTitle(page: CalendarPage): string { + return page.title?.trim() || titleize(page.path.replace(/\.md$/i, "").split("/").pop() ?? page.path); +} + +type DayCellProps = { + cell: CalendarDayCell; + pages: CalendarPage[]; + onNavigate: (path: string) => void; +}; + +function CalendarDayCell({ cell, pages, onNavigate }: DayCellProps) { + const [open, setOpen] = useState(false); + const visibleDots = pages.slice(0, MAX_VISIBLE_DOTS); + const overflow = pages.length - MAX_VISIBLE_DOTS; + + const handleActivate = () => { + if (pages.length === 0) return; + if (pages.length === 1) { + onNavigate(pages[0]!.path); + return; + } + setOpen(true); + }; + + const cellButton = ( + + ); + + if (pages.length <= 1) { + return cellButton; + } + + return ( + + {cellButton} + +
+ {format(cell.date, "EEEE, MMMM d, yyyy")} +
+
+ {pages.map((page) => ( + { + setOpen(false); + onNavigate(page.path); + }} + > + + +
+
{pageTitle(page)}
+
{page.path}
+ {(page.state || page.status || page.tags?.length) && ( +
+ {(page.state ?? page.status) && ( + + {page.state ?? page.status} + + )} + {page.tags?.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+
+ ))} +
+
+
+ ); +} + +export function KiwiCalendar({ onClose, onNavigate }: Props) { + const [viewDate, setViewDate] = useState(() => startOfMonth(new Date())); + const [dateField, setDateField] = useState(loadStoredDateField); + const [fieldOptions, setFieldOptions] = useState(["date"]); + const [pages, setPages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isMobile, setIsMobile] = useState( + () => typeof window !== "undefined" && window.innerWidth < 768, + ); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 767px)"); + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + + const loadFields = useCallback(async () => { + try { + const result = await api.query(buildFieldDiscoveryQuery()); + const fields = discoverDateFields(result.rows ?? []); + setFieldOptions(fields); + if (!fields.includes(dateField)) { + setDateField(fields[0] ?? "date"); + } + } catch { + setFieldOptions(["date"]); + } + }, [dateField]); + + useEffect(() => { + loadFields(); + }, [loadFields]); + + const loadPages = useCallback(async () => { + setLoading(true); + setError(null); + try { + const dql = isMobile + ? buildWeekQuery(dateField, viewDate) + : buildMonthQuery(dateField, viewDate.getFullYear(), viewDate.getMonth()); + const result = await api.query(dql); + setPages(parseCalendarRows(result.rows ?? [], dateField)); + } catch (err) { + setPages([]); + setError(err instanceof Error ? err.message : "Failed to load calendar"); + } finally { + setLoading(false); + } + }, [dateField, isMobile, viewDate]); + + useEffect(() => { + loadPages(); + }, [loadPages]); + + useEffect(() => { + try { + localStorage.setItem(CALENDAR_DATE_FIELD_STORAGE_KEY, dateField); + } catch { + // ignore storage errors + } + }, [dateField]); + + const grouped = useMemo(() => groupPagesByDate(pages), [pages]); + const gridCells = useMemo( + () => + isMobile + ? buildMobileWeekCells(viewDate, viewDate) + : buildMonthGrid(viewDate), + [isMobile, viewDate], + ); + + const monthInputValue = format(viewDate, "yyyy-MM"); + + return ( +
+
+ +
+ + Calendar +
+ +
+ + +
+ + { + const [year, month] = e.target.value.split("-").map(Number); + if (year && month) { + setViewDate(startOfMonth(new Date(year, month - 1, 1))); + } + }} + className="h-8 rounded-md border border-border bg-background px-2 text-sm" + aria-label="Month picker" + /> + +
+ + +
+
+ +
+
+

{formatMonthLabel(viewDate)}

+ {loading && } +
+ + {error && ( +
+ {error} +
+ )} + +
+ {WEEKDAY_LABELS.map((label) => ( +
+ {label} +
+ ))} + {gridCells.map((cell) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/src/components/__mocks__/apiMock.ts b/ui/src/components/__mocks__/apiMock.ts index fd34d8f3..e765dc5b 100644 --- a/ui/src/components/__mocks__/apiMock.ts +++ b/ui/src/components/__mocks__/apiMock.ts @@ -198,7 +198,6 @@ function createMockFetch(overrides: MockOverrides = {}) { } if (url.includes("/query?") || url.endsWith("/query")) { - // Check if it's a CALENDAR query const qMatch = url.match(/[?&]q=([^&]+)/); const dql = qMatch ? decodeURIComponent(qMatch[1]) : ""; if (/^\s*CALENDAR\b/i.test(dql)) { @@ -212,6 +211,34 @@ function createMockFetch(overrides: MockOverrides = {}) { has_more: false, }); } + if (/striptime\(/i.test(dql) && /DATE\("/i.test(dql)) { + const rows = overrides.calendarRows ?? [ + { _path: "pages/frontmatter.md", title: "Frontmatter Guide", date: new Date().toISOString().slice(0, 10), tags: ["docs"], status: "published" }, + ]; + const columns = rows.length > 0 + ? ["_path", ...Object.keys(rows[0]).filter((k) => k !== "_path")] + : ["_path", "title", "date"]; + return jsonResponse({ + columns, + rows, + total: rows.length, + has_more: false, + }); + } + if (/date != null OR due != null/i.test(dql)) { + const rows = overrides.calendarRows ?? [ + { _path: "pages/frontmatter.md", date: "2026-06-20", due: "2026-06-25", created: "2026-06-01" }, + ]; + const columns = rows.length > 0 + ? ["_path", ...Object.keys(rows[0]).filter((k) => k !== "_path")] + : ["_path", "date", "due", "created"]; + return jsonResponse({ + columns, + rows, + total: rows.length, + has_more: false, + }); + } const rows = overrides.queryRows ?? [ { _path: "pages/frontmatter.md", title: "Frontmatter Guide", status: "published", priority: "high" }, { _path: "pages/wikilinks.md", title: "Wiki Links", status: "published", priority: "medium" }, @@ -403,6 +430,7 @@ function createMockFetch(overrides: MockOverrides = {}) { undo: "mod+z", focus_tree_filter: "mod+alt+f", close_overlay: "escape", + toggle_split_view: "mod+\\", }, defaults: {}, conflicts: [], diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 50563504..fe7484a0 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -625,7 +625,7 @@ export const api = { welcomeMessage?: string; }; features?: Partial>; toolbarViews?: string[] | null; diff --git a/ui/src/lib/appViewRoutes.test.ts b/ui/src/lib/appViewRoutes.test.ts new file mode 100644 index 00000000..abad6770 --- /dev/null +++ b/ui/src/lib/appViewRoutes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + shouldOpenViewFromPathname, + shouldPreservePathnameForViewRoute, + viewIdFromPathname, +} from "./appViewRoutes"; +import { DEFAULT_UI_FEATURES } from "./uiFeatures"; + +describe("viewIdFromPathname", () => { + it("maps /view/calendar to calendar", () => { + expect(viewIdFromPathname("/view/calendar")).toBe("calendar"); + }); + + it("maps /view/data to data", () => { + expect(viewIdFromPathname("/view/data")).toBe("data"); + }); + + it("returns null for page routes", () => { + expect(viewIdFromPathname("/page/notes/today.md")).toBeNull(); + expect(viewIdFromPathname("/")).toBeNull(); + }); +}); + +describe("shouldOpenViewFromPathname", () => { + it("opens calendar when feature is enabled", () => { + expect( + shouldOpenViewFromPathname("/view/calendar", DEFAULT_UI_FEATURES), + ).toBe("calendar"); + }); + + it("returns null when calendar feature is disabled", () => { + expect( + shouldOpenViewFromPathname("/view/calendar", { + ...DEFAULT_UI_FEATURES, + calendar: false, + }), + ).toBeNull(); + }); + + it("returns null for non-view paths", () => { + expect(shouldOpenViewFromPathname("/", DEFAULT_UI_FEATURES)).toBeNull(); + }); +}); + +describe("shouldPreservePathnameForViewRoute", () => { + it("preserves /view/calendar without an active page", () => { + expect(shouldPreservePathnameForViewRoute("/view/calendar")).toBe(true); + }); + + it("does not preserve root or page paths", () => { + expect(shouldPreservePathnameForViewRoute("/")).toBe(false); + expect(shouldPreservePathnameForViewRoute("/page/a.md")).toBe(false); + }); +}); diff --git a/ui/src/lib/appViewRoutes.ts b/ui/src/lib/appViewRoutes.ts new file mode 100644 index 00000000..95046fc9 --- /dev/null +++ b/ui/src/lib/appViewRoutes.ts @@ -0,0 +1,49 @@ +import { + isViewRouteAllowed, + viewFeatureFromPathname, + type UIFeatureKey, +} from "./uiFeatures"; + +/** Built-in full-screen views addressable via /view/{name}. */ +export type AppViewId = + | "graph" + | "bases" + | "canvas" + | "whiteboard" + | "timeline" + | "calendar" + | "kanban" + | "data"; + +const VIEW_FEATURE_TO_ID: Partial> = { + graph: "graph", + bases: "bases", + canvas: "canvas", + whiteboard: "whiteboard", + timeline: "timeline", + calendar: "calendar", + kanban: "kanban", + data_sources: "data", +}; + +/** Map a /view/* pathname to a built-in view id, or null when not a view route. */ +export function viewIdFromPathname(pathname: string): AppViewId | null { + const feature = viewFeatureFromPathname(pathname); + if (!feature) return null; + return VIEW_FEATURE_TO_ID[feature] ?? null; +} + +/** Resolve which view to open from the URL when the feature flag allows it. */ +export function shouldOpenViewFromPathname( + pathname: string, + features: Record, +): AppViewId | null { + if (!pathname.startsWith("/view/")) return null; + if (!isViewRouteAllowed(pathname, features)) return null; + return viewIdFromPathname(pathname); +} + +/** Keep /view/* URLs when no page is active (avoid redirecting to /). */ +export function shouldPreservePathnameForViewRoute(pathname: string): boolean { + return pathname.startsWith("/view/"); +} diff --git a/ui/src/lib/calendarView.test.ts b/ui/src/lib/calendarView.test.ts new file mode 100644 index 00000000..a2b34249 --- /dev/null +++ b/ui/src/lib/calendarView.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + buildFieldDiscoveryQuery, + buildMobileWeekCells, + buildMonthGrid, + buildMonthQuery, + buildWeekQuery, + discoverDateFields, + groupPagesByDate, + pageDotClass, + parseCalendarDateValue, + parseCalendarRows, + sanitizeDateField, + type CalendarPage, +} from "./calendarView"; + +describe("calendarView DQL builders", () => { + it("buildMonthQuery scopes striptime bounds", () => { + expect(buildMonthQuery("date", 2026, 5)).toBe( + 'TABLE _path, title, tags, state, status, date\nWHERE striptime(date) >= DATE("2026-06-01") AND striptime(date) < DATE("2026-07-01")', + ); + }); + + it("buildWeekQuery covers Monday through Sunday", () => { + expect(buildWeekQuery("due", new Date(2026, 5, 18))).toContain( + 'WHERE striptime(due) >= DATE("2026-06-15")', + ); + expect(buildWeekQuery("due", new Date(2026, 5, 18))).toContain( + 'AND striptime(due) < DATE("2026-06-22")', + ); + }); + + it("buildFieldDiscoveryQuery lists candidate columns", () => { + const q = buildFieldDiscoveryQuery(); + expect(q).toContain("TABLE _path, date, due"); + expect(q).toContain("date != null OR due != null"); + expect(q).toContain("LIMIT 200"); + }); +}); + +describe("sanitizeDateField", () => { + it("accepts valid field names", () => { + expect(sanitizeDateField("date")).toBe("date"); + expect(sanitizeDateField("next-review")).toBe("next-review"); + expect(sanitizeDateField("meta.due")).toBe("meta.due"); + }); + + it("rejects invalid field names", () => { + expect(sanitizeDateField("date; DROP")).toBeNull(); + expect(sanitizeDateField("")).toBeNull(); + expect(sanitizeDateField("9date")).toBeNull(); + }); +}); + +describe("parseCalendarDateValue", () => { + it("parses ISO date and datetime strings", () => { + expect(parseCalendarDateValue("2026-06-20")).toBe("2026-06-20"); + expect(parseCalendarDateValue("2026-06-20T14:30:00Z")).toBe("2026-06-20"); + }); + + it("returns null for empty or invalid values", () => { + expect(parseCalendarDateValue(null)).toBeNull(); + expect(parseCalendarDateValue("")).toBeNull(); + expect(parseCalendarDateValue("not-a-date")).toBeNull(); + }); +}); + +describe("parseCalendarRows and grouping", () => { + it("maps query rows to calendar pages", () => { + const pages = parseCalendarRows( + [ + { _path: "notes/a.md", title: "A", date: "2026-06-01", tags: ["bug"] }, + { _path: "notes/b.md", date: "2026-06-01T10:00:00Z", state: "accepted" }, + { _path: "notes/c.md", date: null }, + ], + "date", + ); + expect(pages).toHaveLength(2); + expect(pages[0]?.path).toBe("notes/a.md"); + expect(pages[1]?.dateKey).toBe("2026-06-01"); + }); + + it("groups pages by date key", () => { + const pages: CalendarPage[] = [ + { path: "a.md", dateKey: "2026-06-01" }, + { path: "b.md", dateKey: "2026-06-01" }, + { path: "c.md", dateKey: "2026-06-02" }, + ]; + const grouped = groupPagesByDate(pages); + expect(grouped.get("2026-06-01")).toHaveLength(2); + expect(grouped.get("2026-06-02")).toHaveLength(1); + }); +}); + +describe("discoverDateFields", () => { + it("orders fields by candidate priority", () => { + expect( + discoverDateFields([ + { _path: "x.md", due: "2026-06-01", date: "2026-06-02" }, + { _path: "y.md", created: "2026-05-01" }, + ]), + ).toEqual(["date", "due", "created"]); + }); + + it("falls back to date when discovery is empty", () => { + expect(discoverDateFields([])).toEqual(["date"]); + }); +}); + +describe("pageDotClass", () => { + it("prefers workflow state colors", () => { + expect(pageDotClass({ path: "a.md", dateKey: "2026-06-01", state: "accepted" })).toBe( + "bg-emerald-500", + ); + expect(pageDotClass({ path: "b.md", dateKey: "2026-06-01", status: "proposed" })).toBe( + "bg-amber-400", + ); + }); + + it("uses tag hash when no workflow state", () => { + const cls = pageDotClass({ path: "c.md", dateKey: "2026-06-01", tags: ["feature"] }); + expect(cls.startsWith("bg-")).toBe(true); + }); + + it("falls back to primary", () => { + expect(pageDotClass({ path: "d.md", dateKey: "2026-06-01" })).toBe("bg-primary"); + }); +}); + +describe("calendar grids", () => { + it("buildMonthGrid is Monday-first and includes adjacent month days", () => { + const cells = buildMonthGrid(new Date(2026, 2, 15)); + expect(cells[0]?.iso).toBe("2026-02-23"); + expect(cells.some((c) => c.iso === "2026-03-01")).toBe(true); + expect(cells.some((c) => c.iso === "2026-04-05")).toBe(true); + expect(cells.filter((c) => c.inMonth)).toHaveLength(31); + }); + + it("buildMobileWeekCells renders seven days for the focus week", () => { + const cells = buildMobileWeekCells(new Date(2026, 5, 15), new Date(2026, 5, 18)); + expect(cells).toHaveLength(7); + expect(cells[0]?.iso).toBe("2026-06-15"); + expect(cells[6]?.iso).toBe("2026-06-21"); + }); + + it("resolves cross-month week pages while viewing June", () => { + const cells = buildMobileWeekCells(new Date(2026, 5, 1), new Date(2026, 5, 30)); + const julyCell = cells.find((c) => c.iso === "2026-07-01"); + expect(julyCell).toBeDefined(); + expect(julyCell?.inMonth).toBe(false); + + const pages = parseCalendarRows( + [{ _path: "events/july.md", date: "2026-07-01", title: "July event" }], + "date", + ); + const grouped = groupPagesByDate(pages); + expect(grouped.get("2026-07-01")).toHaveLength(1); + expect(cells.some((c) => grouped.has(c.iso))).toBe(true); + }); +}); + +describe("invalid field sanitization in queries", () => { + it("falls back to date when field is invalid", () => { + expect(buildMonthQuery("bad;field", 2026, 0)).toContain("striptime(date)"); + }); +}); diff --git a/ui/src/lib/calendarView.ts b/ui/src/lib/calendarView.ts new file mode 100644 index 00000000..ce780d59 --- /dev/null +++ b/ui/src/lib/calendarView.ts @@ -0,0 +1,259 @@ +import { + addDays, + addMonths, + endOfMonth, + endOfWeek, + format, + isSameDay, + isSameMonth, + parseISO, + startOfMonth, + startOfWeek, +} from "date-fns"; + +export const CALENDAR_DATE_FIELD_STORAGE_KEY = "kiwifs-calendar-date-field"; + +/** Common frontmatter fields that may hold ISO dates. */ +export const DATE_FIELD_CANDIDATES = [ + "date", + "due", + "due_date", + "created", + "last_executed", + "reviewed", + "next-review", + "published_at", + "scheduled", + "deadline", +] as const; + +const DATE_FIELD_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.-]*$/; + +export type CalendarPage = { + path: string; + title?: string; + tags?: string[]; + state?: string; + status?: string; + dateKey: string; +}; + +export type CalendarDayCell = { + date: Date; + inMonth: boolean; + iso: string; +}; + +/** Sanitize a user-selected date field before DQL interpolation. */ +export function sanitizeDateField(field: string): string | null { + const trimmed = field.trim(); + if (!DATE_FIELD_PATTERN.test(trimmed)) return null; + return trimmed; +} + +function monthBounds(year: number, monthIndex: number): { start: string; end: string } { + const start = new Date(year, monthIndex, 1); + const next = addMonths(start, 1); + return { + start: format(start, "yyyy-MM-dd"), + end: format(next, "yyyy-MM-dd"), + }; +} + +/** DQL for pages within a calendar month (exclusive upper bound). */ +export function buildMonthQuery(dateField: string, year: number, monthIndex: number): string { + const field = sanitizeDateField(dateField) ?? "date"; + const { start, end } = monthBounds(year, monthIndex); + return [ + `TABLE _path, title, tags, state, status, ${field}`, + `WHERE striptime(${field}) >= DATE("${start}") AND striptime(${field}) < DATE("${end}")`, + ].join("\n"); +} + +/** DQL for a Monday–Sunday week containing anchorDate. */ +export function buildWeekQuery(dateField: string, anchorDate: Date): string { + const field = sanitizeDateField(dateField) ?? "date"; + const weekStart = startOfWeek(anchorDate, { weekStartsOn: 1 }); + const weekEnd = addDays(weekStart, 7); + return [ + `TABLE _path, title, tags, state, status, ${field}`, + `WHERE striptime(${field}) >= DATE("${format(weekStart, "yyyy-MM-dd")}")`, + `AND striptime(${field}) < DATE("${format(weekEnd, "yyyy-MM-dd")}")`, + ].join("\n"); +} + +/** Discover pages that populate any known date column. */ +export function buildFieldDiscoveryQuery(): string { + const columns = DATE_FIELD_CANDIDATES.join(", "); + const clauses = DATE_FIELD_CANDIDATES.map((f) => `${f} != null`).join(" OR "); + return `TABLE _path, ${columns}\nWHERE ${clauses}\nLIMIT 200`; +} + +/** Parse an ISO date (YYYY-MM-DD or datetime) to a calendar day key. */ +export function parseCalendarDateValue(raw: unknown): string | null { + if (raw == null) return null; + const text = String(raw).trim(); + if (!text) return null; + const dayMatch = text.match(/^(\d{4}-\d{2}-\d{2})/); + if (dayMatch) return dayMatch[1]!; + try { + const parsed = parseISO(text); + if (Number.isNaN(parsed.getTime())) return null; + return format(parsed, "yyyy-MM-dd"); + } catch { + return null; + } +} + +export function parseCalendarRows( + rows: Record[], + dateField: string, +): CalendarPage[] { + const field = sanitizeDateField(dateField) ?? "date"; + const pages: CalendarPage[] = []; + for (const row of rows) { + const path = String(row._path ?? row.path ?? "").trim(); + if (!path) continue; + const dateKey = parseCalendarDateValue(row[field]); + if (!dateKey) continue; + pages.push({ + path, + title: row.title != null ? String(row.title) : undefined, + tags: normalizeTags(row.tags), + state: row.state != null ? String(row.state) : undefined, + status: row.status != null ? String(row.status) : undefined, + dateKey, + }); + } + return pages; +} + +function normalizeTags(raw: unknown): string[] | undefined { + if (raw == null) return undefined; + if (Array.isArray(raw)) { + const tags = raw.map((t) => String(t).trim()).filter(Boolean); + return tags.length > 0 ? tags : undefined; + } + const single = String(raw).trim(); + return single ? [single] : undefined; +} + +/** Group parsed pages by YYYY-MM-DD. */ +export function groupPagesByDate(pages: CalendarPage[]): Map { + const map = new Map(); + for (const page of pages) { + const list = map.get(page.dateKey); + if (list) list.push(page); + else map.set(page.dateKey, [page]); + } + return map; +} + +/** Infer date fields present in discovery query rows, preserving candidate order. */ +export function discoverDateFields(rows: Record[]): string[] { + const found = new Set(); + for (const row of rows) { + for (const field of DATE_FIELD_CANDIDATES) { + if (parseCalendarDateValue(row[field]) != null) { + found.add(field); + } + } + } + const ordered = DATE_FIELD_CANDIDATES.filter((f) => found.has(f)); + return ordered.length > 0 ? ordered : ["date"]; +} + +const WORKFLOW_DOT_CLASSES: Record = { + accepted: "bg-emerald-500", + approved: "bg-emerald-500", + done: "bg-emerald-500", + completed: "bg-emerald-500", + published: "bg-emerald-500", + proposed: "bg-amber-400", + draft: "bg-amber-400", + review: "bg-violet-500", + "in-progress": "bg-sky-500", + blocked: "bg-red-500", + rejected: "bg-red-500", + deprecated: "bg-muted-foreground", + superseded: "bg-muted-foreground", +}; + +const TAG_DOT_CLASSES = [ + "bg-blue-500", + "bg-emerald-500", + "bg-violet-500", + "bg-amber-500", + "bg-pink-500", + "bg-teal-500", + "bg-rose-500", + "bg-indigo-500", + "bg-cyan-500", + "bg-lime-500", +]; + +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +/** Tailwind class for a page dot (workflow state, then tag, then primary). */ +export function pageDotClass(page: CalendarPage): string { + const workflow = (page.state ?? page.status ?? "").toLowerCase().trim(); + if (workflow && WORKFLOW_DOT_CLASSES[workflow]) { + return WORKFLOW_DOT_CLASSES[workflow]!; + } + const tag = page.tags?.[0]?.toLowerCase().trim(); + if (tag) { + return TAG_DOT_CLASSES[hashString(tag) % TAG_DOT_CLASSES.length]!; + } + return "bg-primary"; +} + +/** Monday-first month grid including leading/trailing adjacent-month days. */ +export function buildMonthGrid(viewDate: Date): CalendarDayCell[] { + const monthStart = startOfMonth(viewDate); + const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const monthEnd = endOfMonth(viewDate); + const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + const cells: CalendarDayCell[] = []; + let cursor = gridStart; + while (cursor <= gridEnd) { + cells.push({ + date: cursor, + inMonth: isSameMonth(cursor, viewDate), + iso: format(cursor, "yyyy-MM-dd"), + }); + cursor = addDays(cursor, 1); + } + return cells; +} + +/** Mobile week row: Mon–Sun for the week containing focusDate within viewMonth. */ +export function buildMobileWeekCells(viewDate: Date, focusDate: Date): CalendarDayCell[] { + const weekStart = startOfWeek(focusDate, { weekStartsOn: 1 }); + const cells: CalendarDayCell[] = []; + for (let i = 0; i < 7; i++) { + const date = addDays(weekStart, i); + cells.push({ + date, + inMonth: isSameMonth(date, viewDate), + iso: format(date, "yyyy-MM-dd"), + }); + } + return cells; +} + +export function formatMonthLabel(viewDate: Date): string { + return format(viewDate, "MMMM yyyy"); +} + +export function isTodayDate(date: Date, now = new Date()): boolean { + return isSameDay(date, now); +} + +export { addMonths, addDays, startOfMonth }; diff --git a/ui/src/lib/hostConfig.ts b/ui/src/lib/hostConfig.ts index 44821991..214724a6 100644 --- a/ui/src/lib/hostConfig.ts +++ b/ui/src/lib/hostConfig.ts @@ -73,6 +73,7 @@ export type KiwiDemoViewId = | "kanban" | "bases" | "timeline" + | "calendar" | "canvas" | "whiteboard" | "data"; diff --git a/ui/src/lib/kiwiKeybindings.test.ts b/ui/src/lib/kiwiKeybindings.test.ts index ec8f4c94..f4da593b 100644 --- a/ui/src/lib/kiwiKeybindings.test.ts +++ b/ui/src/lib/kiwiKeybindings.test.ts @@ -4,9 +4,11 @@ import { buildChordIndex, eventMatchesChord, formatChordDisplay, + isKeyboardShortcutTargetIgnored, matchBoundAction, mergeKeybindings, normalizeChord, + shouldTriggerBareShortcutsHelp, } from "./kiwiKeybindings"; describe("normalizeChord", () => { @@ -101,3 +103,71 @@ describe("formatChordDisplay", () => { expect(formatChordDisplay("mod+k")).toMatch(/K/i); }); }); + +describe("isKeyboardShortcutTargetIgnored", () => { + function mockTarget(match: boolean): EventTarget { + return { + closest: () => (match ? {} : null), + } as unknown as EventTarget; + } + + it("ignores native text inputs", () => { + expect(isKeyboardShortcutTargetIgnored(mockTarget(true))).toBe(true); + }); + + it("ignores CodeMirror editor surfaces", () => { + expect(isKeyboardShortcutTargetIgnored(mockTarget(true))).toBe(true); + }); + + it("allows shortcuts from non-editable targets", () => { + expect(isKeyboardShortcutTargetIgnored(mockTarget(false))).toBe(false); + }); + + it("allows shortcuts when target lacks closest", () => { + expect(isKeyboardShortcutTargetIgnored({} as EventTarget)).toBe(false); + }); +}); + +describe("shouldTriggerBareShortcutsHelp", () => { + function mockTarget(ignored: boolean): EventTarget { + return { + closest: () => (ignored ? {} : null), + } as unknown as EventTarget; + } + + it("opens on bare question mark outside inputs", () => { + const e = { + key: "?", + shiftKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + target: mockTarget(false), + } as KeyboardEvent; + expect(shouldTriggerBareShortcutsHelp(e)).toBe(true); + }); + + it("does not open when typing in an input", () => { + const e = { + key: "?", + shiftKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + target: mockTarget(true), + } as KeyboardEvent; + expect(shouldTriggerBareShortcutsHelp(e)).toBe(false); + }); + + it("does not open when mod is held (handled by shortcuts_help binding)", () => { + const e = { + key: "?", + shiftKey: true, + metaKey: true, + ctrlKey: false, + altKey: false, + target: mockTarget(false), + } as KeyboardEvent; + expect(shouldTriggerBareShortcutsHelp(e)).toBe(false); + }); +}); diff --git a/ui/src/lib/kiwiKeybindings.ts b/ui/src/lib/kiwiKeybindings.ts index 6c6bc94a..dee0cda1 100644 --- a/ui/src/lib/kiwiKeybindings.ts +++ b/ui/src/lib/kiwiKeybindings.ts @@ -14,6 +14,7 @@ export type KeybindingAction = | "graph" | "toggle_bases" | "toggle_timeline" + | "toggle_calendar" | "toggle_kanban" | "toggle_mode" | "shortcuts_help" @@ -48,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: Record = { graph: "mod+g", toggle_bases: "mod+shift+b", toggle_timeline: "mod+shift+t", + toggle_calendar: "mod+shift+c", toggle_kanban: "mod+shift+w", toggle_mode: "mod+shift+e", shortcuts_help: "mod+/", @@ -185,6 +187,7 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ { action: "graph", label: "Knowledge graph" }, { action: "toggle_bases", label: "Toggle Bases" }, { action: "toggle_timeline", label: "Toggle Timeline" }, + { action: "toggle_calendar", label: "Toggle Calendar" }, { action: "toggle_kanban", label: "Toggle Kanban" }, ], }, @@ -219,3 +222,20 @@ export function matchBoundAction( } return null; } + +const SHORTCUT_IGNORED_SELECTORS = + 'input, textarea, select, [contenteditable="true"], [role="textbox"], .cm-editor, .cm-content'; + +/** Skip global shortcuts while focus is in an editable field or CodeMirror surface. */ +export function isKeyboardShortcutTargetIgnored(target: EventTarget | null): boolean { + if (!target || typeof (target as Element).closest !== "function") return false; + return !!(target as Element).closest(SHORTCUT_IGNORED_SELECTORS); +} + +/** Open shortcuts help on bare "?" (Shift+/) outside editable targets. */ +export function shouldTriggerBareShortcutsHelp(e: KeyboardEvent): boolean { + if (isKeyboardShortcutTargetIgnored(e.target)) return false; + if (e.metaKey || e.ctrlKey) return false; + const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase(); + return key === "?" || (e.shiftKey && key === "/"); +} diff --git a/ui/src/lib/overlayDismiss.test.ts b/ui/src/lib/overlayDismiss.test.ts index b43f3c67..0ab790b7 100644 --- a/ui/src/lib/overlayDismiss.test.ts +++ b/ui/src/lib/overlayDismiss.test.ts @@ -12,6 +12,7 @@ const closed: OverlayState = { canvasOpen: false, whiteboardOpen: false, timelineOpen: false, + calendarOpen: false, kanbanOpen: false, }; @@ -37,6 +38,8 @@ describe("resolveOverlayDismiss", () => { it("dismisses full-screen views in stable priority order", () => { expect(resolveOverlayDismiss({ ...closed, graphOpen: true, kanbanOpen: true })).toBe("graph"); expect(resolveOverlayDismiss({ ...closed, historyOpen: true, dataOpen: true })).toBe("history"); + expect(resolveOverlayDismiss({ ...closed, timelineOpen: true, calendarOpen: true })).toBe("timeline"); + expect(resolveOverlayDismiss({ ...closed, calendarOpen: true, kanbanOpen: true })).toBe("calendar"); expect(resolveOverlayDismiss({ ...closed, kanbanOpen: true })).toBe("kanban"); }); }); diff --git a/ui/src/lib/overlayDismiss.ts b/ui/src/lib/overlayDismiss.ts index 582d0acd..9e37f827 100644 --- a/ui/src/lib/overlayDismiss.ts +++ b/ui/src/lib/overlayDismiss.ts @@ -10,6 +10,7 @@ export type OverlayState = { canvasOpen: boolean; whiteboardOpen: boolean; timelineOpen: boolean; + calendarOpen: boolean; kanbanOpen: boolean; }; @@ -24,6 +25,7 @@ export type OverlayDismissTarget = | "canvas" | "whiteboard" | "timeline" + | "calendar" | "kanban"; /** Returns the topmost overlay to dismiss, or null when nothing is open. */ @@ -38,6 +40,7 @@ export function resolveOverlayDismiss(state: OverlayState): OverlayDismissTarget if (state.canvasOpen) return "canvas"; if (state.whiteboardOpen) return "whiteboard"; if (state.timelineOpen) return "timeline"; + if (state.calendarOpen) return "calendar"; if (state.kanbanOpen) return "kanban"; return null; } diff --git a/ui/src/lib/toolbarComposition.ts b/ui/src/lib/toolbarComposition.ts index 31fd262b..8ac4525d 100644 --- a/ui/src/lib/toolbarComposition.ts +++ b/ui/src/lib/toolbarComposition.ts @@ -7,6 +7,7 @@ export const TOOLBAR_BUILTIN_VIEW_IDS = [ "canvas", "whiteboard", "timeline", + "calendar", "kanban", "data", ] as const; @@ -24,6 +25,7 @@ export const TOOLBAR_VIEW_FEATURE: Record = canvas: "canvas", whiteboard: "whiteboard", timeline: "timeline", + calendar: "calendar", kanban: "kanban", data: "data_sources", }; diff --git a/ui/src/lib/uiFeatures.test.ts b/ui/src/lib/uiFeatures.test.ts index 2d028ca9..f0d3ea02 100644 --- a/ui/src/lib/uiFeatures.test.ts +++ b/ui/src/lib/uiFeatures.test.ts @@ -18,13 +18,15 @@ describe("uiFeatures", () => { it("maps view routes to feature keys", () => { expect(viewFeatureFromPathname("/view/kanban")).toBe("kanban"); + expect(viewFeatureFromPathname("/view/calendar")).toBe("calendar"); expect(viewFeatureFromPathname("/view/data")).toBe("data_sources"); expect(viewFeatureFromPathname("/page/foo.md")).toBeNull(); }); it("blocks disabled view routes", () => { - const features = resolveUIFeatures({ kanban: false }); + const features = resolveUIFeatures({ kanban: false, calendar: false }); expect(isViewRouteAllowed("/view/kanban", features)).toBe(false); + expect(isViewRouteAllowed("/view/calendar", features)).toBe(false); expect(isViewRouteAllowed("/view/graph", features)).toBe(true); }); }); diff --git a/ui/src/lib/uiFeatures.ts b/ui/src/lib/uiFeatures.ts index 22ca2255..1269c67c 100644 --- a/ui/src/lib/uiFeatures.ts +++ b/ui/src/lib/uiFeatures.ts @@ -4,6 +4,7 @@ export type UIFeatureKey = | "canvas" | "whiteboard" | "timeline" + | "calendar" | "bases" | "data_sources"; @@ -13,6 +14,7 @@ export const DEFAULT_UI_FEATURES: Record = { canvas: true, whiteboard: true, timeline: true, + calendar: true, bases: true, data_sources: true, }; @@ -24,6 +26,7 @@ export const VIEW_ROUTE_ALIASES: Record = { canvas: "canvas", whiteboard: "whiteboard", timeline: "timeline", + calendar: "calendar", bases: "bases", data: "data_sources", data_sources: "data_sources",