From b2c946b08d8deaea62e4d5fe8f11667065036f3e Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:07:54 -0800 Subject: [PATCH] test(web): cover multi-view resolution and scheduling interactions --- apps/web/app/api/workspace/objects.test.ts | 1 + .../workspace/object-calendar.test.ts | 156 ++++++++++++++ .../workspace/object-timeline.test.ts | 190 ++++++++++++++++++ .../workspace/object-view-active-view.test.ts | 108 ++++++++++ apps/web/lib/object-view-types.test.ts | 142 +++++++++++++ 5 files changed, 597 insertions(+) create mode 100644 apps/web/app/components/workspace/object-calendar.test.ts create mode 100644 apps/web/app/components/workspace/object-timeline.test.ts create mode 100644 apps/web/lib/object-view-types.test.ts diff --git a/apps/web/app/api/workspace/objects.test.ts b/apps/web/app/api/workspace/objects.test.ts index 0c4ea27385f..aee28a87012 100644 --- a/apps/web/app/api/workspace/objects.test.ts +++ b/apps/web/app/api/workspace/objects.test.ts @@ -147,6 +147,7 @@ describe("Workspace Objects API", () => { }, ], activeView: "Important", + viewSettings: undefined, }); let queryCall = 0; diff --git a/apps/web/app/components/workspace/object-calendar.test.ts b/apps/web/app/components/workspace/object-calendar.test.ts new file mode 100644 index 00000000000..f9fb76cd24e --- /dev/null +++ b/apps/web/app/components/workspace/object-calendar.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + startOfMonth, endOfMonth, startOfWeek, endOfWeek, + eachDayOfInterval, format, + parseISO, addDays, +} from "date-fns"; + +/** + * Pure calendar grid computation extracted for testing. + * These functions mirror the logic inside ObjectCalendar's MonthView. + */ + +function buildMonthGrid(date: Date): { days: Date[]; weekCount: number } { + const monthStart = startOfMonth(date); + const monthEnd = endOfMonth(date); + const calStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + const days = eachDayOfInterval({ start: calStart, end: calEnd }); + return { days, weekCount: days.length / 7 }; +} + +function groupEventsByDay( + events: { id: string; date: Date; endDate?: Date }[], +): Map { + const map = new Map(); + for (const ev of events) { + const key = format(ev.date, "yyyy-MM-dd"); + if (!map.has(key)) {map.set(key, []);} + map.get(key)!.push(ev.id); + if (ev.endDate && format(ev.date, "yyyy-MM-dd") !== format(ev.endDate, "yyyy-MM-dd")) { + const spanDays = eachDayOfInterval({ start: addDays(ev.date, 1), end: ev.endDate }); + for (const d of spanDays) { + const dk = format(d, "yyyy-MM-dd"); + if (!map.has(dk)) {map.set(dk, []);} + map.get(dk)!.push(ev.id); + } + } + } + return map; +} + +// --------------------------------------------------------------------------- +// Month grid generation +// --------------------------------------------------------------------------- + +describe("buildMonthGrid", () => { + it("always produces a multiple of 7 days (grid must be rectangular)", () => { + for (let m = 0; m < 12; m++) { + const { days } = buildMonthGrid(new Date(2026, m, 15)); + expect(days.length % 7).toBe(0); + } + }); + + it("includes padding days from previous and next months (prevents empty cells)", () => { + const { days } = buildMonthGrid(new Date(2026, 2, 15)); // March 2026 + const marchStart = startOfMonth(new Date(2026, 2, 15)); + const firstDay = days[0]; + const lastDay = days[days.length - 1]; + + expect(firstDay <= marchStart).toBe(true); + expect(lastDay >= endOfMonth(new Date(2026, 2, 15))).toBe(true); + }); + + it("starts grid on Monday (weekStartsOn: 1)", () => { + const { days } = buildMonthGrid(new Date(2026, 0, 15)); // January 2026 + expect(format(days[0], "EEEE")).toBe("Monday"); + }); + + it("ends grid on Sunday", () => { + const { days } = buildMonthGrid(new Date(2026, 5, 15)); // June 2026 + expect(format(days[days.length - 1], "EEEE")).toBe("Sunday"); + }); + + it("produces 4-6 weeks for any month (standard calendar range)", () => { + for (let m = 0; m < 12; m++) { + const { weekCount } = buildMonthGrid(new Date(2026, m, 1)); + expect(weekCount).toBeGreaterThanOrEqual(4); + expect(weekCount).toBeLessThanOrEqual(6); + } + }); +}); + +// --------------------------------------------------------------------------- +// Event grouping by day +// --------------------------------------------------------------------------- + +describe("groupEventsByDay", () => { + it("groups single-day events by their date key", () => { + const events = [ + { id: "a", date: parseISO("2026-03-10") }, + { id: "b", date: parseISO("2026-03-10") }, + { id: "c", date: parseISO("2026-03-11") }, + ]; + const grouped = groupEventsByDay(events); + expect(grouped.get("2026-03-10")).toEqual(["a", "b"]); + expect(grouped.get("2026-03-11")).toEqual(["c"]); + }); + + it("spans multi-day events across all covered days (prevents disappearing events)", () => { + const events = [ + { + id: "multiday", + date: parseISO("2026-03-10"), + endDate: parseISO("2026-03-13"), + }, + ]; + const grouped = groupEventsByDay(events); + expect(grouped.get("2026-03-10")).toContain("multiday"); + expect(grouped.get("2026-03-11")).toContain("multiday"); + expect(grouped.get("2026-03-12")).toContain("multiday"); + expect(grouped.get("2026-03-13")).toContain("multiday"); + }); + + it("does not span same-day events (endDate === date)", () => { + const events = [ + { + id: "sameday", + date: parseISO("2026-03-10"), + endDate: parseISO("2026-03-10"), + }, + ]; + const grouped = groupEventsByDay(events); + expect(grouped.get("2026-03-10")).toEqual(["sameday"]); + expect(grouped.has("2026-03-11")).toBe(false); + }); + + it("returns empty map for no events", () => { + const grouped = groupEventsByDay([]); + expect(grouped.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Date parsing edge cases +// --------------------------------------------------------------------------- + +describe("calendar date parsing", () => { + it("parseISO handles ISO date strings correctly", () => { + const d = parseISO("2026-03-15"); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(2); // 0-indexed + expect(d.getDate()).toBe(15); + }); + + it("parseISO handles datetime strings (calendar should use date portion only)", () => { + const d = parseISO("2026-03-15T14:30:00Z"); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(2); + expect(d.getDate()).toBe(15); + }); + + it("parseISO returns Invalid Date for garbage input (calendar should skip these)", () => { + const d = parseISO("not-a-date"); + expect(Number.isNaN(d.getTime())).toBe(true); + }); +}); diff --git a/apps/web/app/components/workspace/object-timeline.test.ts b/apps/web/app/components/workspace/object-timeline.test.ts new file mode 100644 index 00000000000..2b2985bc7d9 --- /dev/null +++ b/apps/web/app/components/workspace/object-timeline.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { + parseISO, differenceInDays, addDays, subMonths, addMonths, + eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval, +} from "date-fns"; + +/** + * Pure timeline computation extracted for testing. + * Mirrors the logic inside ObjectTimeline. + */ + +type TimelineZoom = "day" | "week" | "month" | "quarter"; + +function getTimelineBounds( + items: { startDate: Date; endDate: Date }[], + zoom: TimelineZoom, +): { start: Date; end: Date } { + if (items.length === 0) { + const now = new Date(); + return { start: subMonths(now, 1), end: addMonths(now, 2) }; + } + const earliest = new Date(Math.min(...items.map((i) => i.startDate.getTime()))); + const latest = new Date(Math.max(...items.map((i) => i.endDate.getTime()))); + const paddingDays = zoom === "day" ? 3 : zoom === "week" ? 7 : zoom === "month" ? 14 : 30; + return { + start: addDays(earliest, -paddingDays), + end: addDays(latest, paddingDays), + }; +} + +function dateToX(date: Date, timelineStart: Date, dayWidth: number): number { + return differenceInDays(date, timelineStart) * dayWidth; +} + +function getHeaderTicks( + start: Date, + end: Date, + zoom: TimelineZoom, +): Date[] { + switch (zoom) { + case "day": + return eachDayOfInterval({ start, end }); + case "week": + return eachWeekOfInterval({ start, end }, { weekStartsOn: 1 }); + case "month": + return eachMonthOfInterval({ start, end }); + case "quarter": + return eachMonthOfInterval({ start, end }).filter((d) => d.getMonth() % 3 === 0); + } +} + +// --------------------------------------------------------------------------- +// Timeline bounds +// --------------------------------------------------------------------------- + +describe("getTimelineBounds", () => { + it("provides sensible defaults for empty item list (prevents zero-width timeline)", () => { + const { start, end } = getTimelineBounds([], "week"); + expect(end > start).toBe(true); + const span = differenceInDays(end, start); + expect(span).toBeGreaterThan(30); + }); + + it("adds padding around the earliest and latest dates", () => { + const items = [ + { startDate: parseISO("2026-03-10"), endDate: parseISO("2026-03-20") }, + ]; + const { start, end } = getTimelineBounds(items, "week"); + expect(start < items[0].startDate).toBe(true); + expect(end > items[0].endDate).toBe(true); + }); + + it("uses zoom-appropriate padding (day zoom = tight, quarter zoom = wide)", () => { + const items = [ + { startDate: parseISO("2026-06-01"), endDate: parseISO("2026-06-30") }, + ]; + const dayBounds = getTimelineBounds(items, "day"); + const quarterBounds = getTimelineBounds(items, "quarter"); + const dayPadding = differenceInDays(items[0].startDate, dayBounds.start); + const quarterPadding = differenceInDays(items[0].startDate, quarterBounds.start); + expect(quarterPadding).toBeGreaterThan(dayPadding); + }); + + it("handles single-day items (start === end causes zero-width bar without padding)", () => { + const items = [ + { startDate: parseISO("2026-03-15"), endDate: parseISO("2026-03-15") }, + ]; + const { start, end } = getTimelineBounds(items, "week"); + expect(differenceInDays(end, start)).toBeGreaterThan(7); + }); +}); + +// --------------------------------------------------------------------------- +// Date-to-pixel conversion +// --------------------------------------------------------------------------- + +describe("dateToX", () => { + const start = parseISO("2026-03-01"); + + it("returns 0 for the timeline start date", () => { + expect(dateToX(start, start, 30)).toBe(0); + }); + + it("scales linearly with day count", () => { + expect(dateToX(addDays(start, 10), start, 30)).toBe(300); + }); + + it("produces different widths at different zoom levels (zoom changes dayWidth)", () => { + const d = addDays(start, 7); + expect(dateToX(d, start, 80)).toBe(560); // day zoom + expect(dateToX(d, start, 30)).toBe(210); // week zoom + expect(dateToX(d, start, 10)).toBe(70); // month zoom + expect(dateToX(d, start, 4)).toBe(28); // quarter zoom + }); + + it("returns negative for dates before timeline start (used for clipping)", () => { + const before = addDays(start, -5); + expect(dateToX(before, start, 30)).toBe(-150); + }); +}); + +// --------------------------------------------------------------------------- +// Header tick generation +// --------------------------------------------------------------------------- + +describe("getHeaderTicks", () => { + const start = parseISO("2026-03-01"); + const end = parseISO("2026-06-30"); + + it("'day' zoom produces one tick per day", () => { + const shortEnd = addDays(start, 6); + const ticks = getHeaderTicks(start, shortEnd, "day"); + expect(ticks.length).toBe(7); + }); + + it("'week' zoom produces one tick per week", () => { + const ticks = getHeaderTicks(start, end, "week"); + const expectedWeeks = Math.ceil(differenceInDays(end, start) / 7); + expect(ticks.length).toBeGreaterThanOrEqual(expectedWeeks - 1); + expect(ticks.length).toBeLessThanOrEqual(expectedWeeks + 1); + }); + + it("'month' zoom produces one tick per month", () => { + const ticks = getHeaderTicks(start, end, "month"); + expect(ticks.length).toBe(4); // Mar, Apr, May, Jun + }); + + it("'quarter' zoom only includes quarter-start months (Jan, Apr, Jul, Oct)", () => { + const yearStart = parseISO("2026-01-01"); + const yearEnd = parseISO("2026-12-31"); + const ticks = getHeaderTicks(yearStart, yearEnd, "quarter"); + expect(ticks.length).toBe(4); + for (const tick of ticks) { + expect(tick.getMonth() % 3).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Bar width computation +// --------------------------------------------------------------------------- + +describe("timeline bar positioning", () => { + const timelineStart = parseISO("2026-03-01"); + const dayWidth = 30; + + it("bar width equals (endDate - startDate) * dayWidth", () => { + const item = { + startDate: parseISO("2026-03-10"), + endDate: parseISO("2026-03-15"), + }; + const x = dateToX(item.startDate, timelineStart, dayWidth); + const w = dateToX(item.endDate, timelineStart, dayWidth) - x; + expect(x).toBe(9 * 30); + expect(w).toBe(5 * 30); + }); + + it("minimum bar width prevents invisible zero-width bars", () => { + const item = { + startDate: parseISO("2026-03-10"), + endDate: parseISO("2026-03-10"), // same day + }; + const x = dateToX(item.startDate, timelineStart, dayWidth); + const rawW = dateToX(item.endDate, timelineStart, dayWidth) - x; + const minW = dayWidth * 0.5; + const w = Math.max(rawW, minW); + expect(w).toBe(minW); // zero-width gets minimum + expect(w).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/app/workspace/object-view-active-view.test.ts b/apps/web/app/workspace/object-view-active-view.test.ts index 34b4a9f1279..a7704882015 100644 --- a/apps/web/app/workspace/object-view-active-view.test.ts +++ b/apps/web/app/workspace/object-view-active-view.test.ts @@ -89,4 +89,112 @@ describe("resolveActiveViewSyncDecision", () => { expect(missingActive).toBeNull(); expect(unknownActive).toBeNull(); }); + + // --- New tests for viewType and settings --- + + it("detects viewType change and triggers re-apply (prevents stale view mode after external edit)", () => { + const kanbanView: SavedView = { + name: "Board", + view_type: "kanban", + settings: { kanbanField: "Status" }, + }; + + const decision = resolveActiveViewSyncDecision({ + savedViews: [kanbanView], + activeView: "Board", + currentActiveViewName: "Board", + currentFilters: emptyFilterGroup(), + currentViewColumns: undefined, + currentViewType: "table", + currentSettings: undefined, + }); + + expect(decision).not.toBeNull(); + expect(decision?.shouldApply).toBe(true); + expect(decision?.nextViewType).toBe("kanban"); + expect(decision?.nextSettings).toEqual({ kanbanField: "Status" }); + }); + + it("detects settings change while name and filters stay the same (calendar mode changed externally)", () => { + const calView: SavedView = { + name: "Monthly", + view_type: "calendar", + settings: { calendarDateField: "Due Date", calendarMode: "month" }, + }; + + const decision = resolveActiveViewSyncDecision({ + savedViews: [calView], + activeView: "Monthly", + currentActiveViewName: "Monthly", + currentFilters: emptyFilterGroup(), + currentViewColumns: undefined, + currentViewType: "calendar", + currentSettings: { calendarDateField: "Due Date", calendarMode: "week" }, + }); + + expect(decision?.shouldApply).toBe(true); + expect(decision?.nextSettings?.calendarMode).toBe("month"); + }); + + it("does not re-apply when viewType and settings are already aligned", () => { + const view: SavedView = { + name: "Timeline", + view_type: "timeline", + settings: { timelineStartField: "Start", timelineEndField: "End" }, + }; + + const decision = resolveActiveViewSyncDecision({ + savedViews: [view], + activeView: "Timeline", + currentActiveViewName: "Timeline", + currentFilters: emptyFilterGroup(), + currentViewColumns: undefined, + currentViewType: "timeline", + currentSettings: { timelineStartField: "Start", timelineEndField: "End" }, + }); + + expect(decision?.shouldApply).toBe(false); + }); + + it("detects viewType-only change when settings are identical (prevents stuck view mode)", () => { + const view: SavedView = { + name: "Gallery", + view_type: "gallery", + settings: { galleryTitleField: "Name" }, + }; + + const decision = resolveActiveViewSyncDecision({ + savedViews: [view], + activeView: "Gallery", + currentActiveViewName: "Gallery", + currentFilters: emptyFilterGroup(), + currentViewColumns: undefined, + currentViewType: "table", + currentSettings: { galleryTitleField: "Name" }, + }); + + expect(decision?.shouldApply).toBe(true); + expect(decision?.nextViewType).toBe("gallery"); + }); + + it("propagates undefined viewType without crashing (backwards compat with old saved views)", () => { + const legacyView: SavedView = { + name: "Legacy", + filters: statusFilter("Active"), + }; + + const decision = resolveActiveViewSyncDecision({ + savedViews: [legacyView], + activeView: "Legacy", + currentActiveViewName: undefined, + currentFilters: emptyFilterGroup(), + currentViewColumns: undefined, + currentViewType: "table", + currentSettings: {}, + }); + + expect(decision?.shouldApply).toBe(true); + expect(decision?.nextViewType).toBeUndefined(); + expect(decision?.nextSettings).toBeUndefined(); + }); }); diff --git a/apps/web/lib/object-view-types.test.ts b/apps/web/lib/object-view-types.test.ts new file mode 100644 index 00000000000..dac605181ba --- /dev/null +++ b/apps/web/lib/object-view-types.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + resolveViewType, + resolveViewSettings, + autoDetectViewField, + type SavedView, + type ViewType, + type ViewTypeSettings, + type FieldMeta, + VIEW_TYPES, +} from "./object-filters"; + +// --------------------------------------------------------------------------- +// resolveViewType +// --------------------------------------------------------------------------- + +describe("resolveViewType", () => { + it("uses saved view's view_type when present (view overrides all defaults)", () => { + const view: SavedView = { name: "Board", view_type: "kanban" }; + expect(resolveViewType(view, "table", "table")).toBe("kanban"); + }); + + it("falls back to currentViewType when saved view has no view_type", () => { + const view: SavedView = { name: "Filtered" }; + expect(resolveViewType(view, "calendar", "table")).toBe("calendar"); + }); + + it("falls back to object default when both saved view and current are undefined", () => { + expect(resolveViewType(undefined, undefined, "kanban")).toBe("kanban"); + }); + + it("falls back to 'table' when nothing is specified", () => { + expect(resolveViewType(undefined, undefined, undefined)).toBe("table"); + }); + + it("rejects invalid view types in saved view and falls through (prevents garbage data from breaking UI)", () => { + const view = { name: "Bad", view_type: "nonexistent" as ViewType }; + expect(resolveViewType(view, undefined, "kanban")).toBe("kanban"); + }); + + it("rejects invalid object default and falls to 'table' (prevents DB corruption from crashing)", () => { + expect(resolveViewType(undefined, undefined, "invalid_type")).toBe("table"); + }); + + it("accepts every valid ViewType without falling through", () => { + for (const vt of VIEW_TYPES) { + const view: SavedView = { name: "Test", view_type: vt }; + expect(resolveViewType(view, undefined, undefined)).toBe(vt); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveViewSettings +// --------------------------------------------------------------------------- + +describe("resolveViewSettings", () => { + it("returns saved view settings when no object defaults exist", () => { + const settings: ViewTypeSettings = { kanbanField: "Priority" }; + expect(resolveViewSettings(settings, undefined)).toEqual({ kanbanField: "Priority" }); + }); + + it("returns object defaults when no saved view settings exist", () => { + const defaults: ViewTypeSettings = { calendarDateField: "Due Date" }; + expect(resolveViewSettings(undefined, defaults)).toEqual({ calendarDateField: "Due Date" }); + }); + + it("saved view settings override object defaults (per-view customization works)", () => { + const defaults: ViewTypeSettings = { kanbanField: "Status", calendarDateField: "Date" }; + const override: ViewTypeSettings = { kanbanField: "Priority" }; + const result = resolveViewSettings(override, defaults); + expect(result.kanbanField).toBe("Priority"); + expect(result.calendarDateField).toBe("Date"); + }); + + it("returns empty object when both inputs are undefined", () => { + expect(resolveViewSettings(undefined, undefined)).toEqual({}); + }); + + it("does not override object defaults with undefined saved view values (partial override is safe)", () => { + const defaults: ViewTypeSettings = { + kanbanField: "Status", + calendarDateField: "Due Date", + timelineStartField: "Start", + }; + const partial: ViewTypeSettings = { kanbanField: "Priority" }; + const result = resolveViewSettings(partial, defaults); + expect(result.kanbanField).toBe("Priority"); + expect(result.calendarDateField).toBe("Due Date"); + expect(result.timelineStartField).toBe("Start"); + }); +}); + +// --------------------------------------------------------------------------- +// autoDetectViewField +// --------------------------------------------------------------------------- + +describe("autoDetectViewField", () => { + const fields: FieldMeta[] = [ + { name: "Title", type: "text" }, + { name: "Description", type: "text" }, + { name: "Status", type: "enum" }, + { name: "Priority", type: "enum" }, + { name: "Due Date", type: "date" }, + { name: "Start Date", type: "date" }, + { name: "End Date", type: "date" }, + { name: "Active", type: "boolean" }, + ]; + + it("detects kanbanField by preferring 'status' in name (correct heuristic for boards)", () => { + expect(autoDetectViewField("kanban", "kanbanField", fields)).toBe("Status"); + }); + + it("falls back to first enum when no field contains 'status'", () => { + const noStatus: FieldMeta[] = [ + { name: "Priority", type: "enum" }, + { name: "Category", type: "enum" }, + ]; + expect(autoDetectViewField("kanban", "kanbanField", noStatus)).toBe("Priority"); + }); + + it("detects calendarDateField by preferring 'due/start/begin' in name", () => { + expect(autoDetectViewField("calendar", "calendarDateField", fields)).toBe("Due Date"); + }); + + it("detects timelineEndField by preferring 'end/finish/close' in name", () => { + expect(autoDetectViewField("timeline", "timelineEndField", fields)).toBe("End Date"); + }); + + it("detects galleryTitleField by preferring 'name/title' in name", () => { + expect(autoDetectViewField("gallery", "galleryTitleField", fields)).toBe("Title"); + }); + + it("returns undefined for galleryCoverField (no cover detection)", () => { + expect(autoDetectViewField("gallery", "galleryCoverField", fields)).toBeUndefined(); + }); + + it("returns undefined when no matching field type exists (prevents crash on sparse schemas)", () => { + const noDate: FieldMeta[] = [{ name: "Name", type: "text" }]; + expect(autoDetectViewField("calendar", "calendarDateField", noDate)).toBeUndefined(); + }); +});