From 5c9b48afecbd90c1413be1c572bb0c38a6adefb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:15:39 +0000 Subject: [PATCH 1/8] wip(plugin-calendar): seed the calendar's date from calendar.initialDate Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- .../plugin-calendar/src/ObjectCalendar.tsx | 75 ++++++++++++++- .../plugin-calendar/src/initialDate.ts | 91 +++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 ui/packages/plugin-calendar/src/initialDate.ts diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx index 5f19dc2a575..af8fc1cafe5 100644 --- a/ui/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -26,6 +26,12 @@ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react' import type { ObjectCalendarSchema, DataSource, CalendarConfig } from '@object-ui/types'; import { CalendarView, type CalendarViewEvent } from './CalendarView'; import { getVisibleDateRange } from './visibleDays'; +import { + anchorDateFromResult, + isCalendarRecordAnchor, + recordAnchorQuery, + resolveInitialDateMacro, +} from './initialDate'; import { usePullToRefresh } from '@object-ui/mobile'; import { firstDayOfWeek, useDisplayLocale } from '@object-ui/i18n'; import { @@ -521,7 +527,16 @@ export const ObjectCalendar: React.FC = ({ * apart from one that is exactly that size. */ const [rowCeiling, setRowCeiling] = useState(null); - const [currentDate, setCurrentDate] = useState(new Date()); + // `calendar.initialDate` seeds the date ONCE, on mount (`./initialDate`): a + // date-macro token here, synchronously, so the first visible range reported + // is already the seeded one; a record anchor by the one-row read below. Absent + // or unresolvable ⇒ today. After the seed, user navigation owns the date. + const [currentDate, setCurrentDate] = useState( + () => resolveInitialDateMacro(schema.calendar?.initialDate) ?? new Date(), + ); + // Set by the first user navigation, so a record-anchor answer that arrives + // later never moves a calendar the user has already moved. + const userNavigatedRef = useRef(false); // Disclosure state of the "unscheduled" area (`bc5870c9f`). Collapsed by // default, as ruled: the count is always on screen, the list is opt-in. // Component state is the right home per AGENTS.md §5 #8 — nobody would share @@ -960,6 +975,63 @@ export const ObjectCalendar: React.FC = ({ }, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData, queryFilter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms, invalidationNonce]); + // `calendar.initialDate` as a record anchor (`first_record` / `next_record`): + // the period holding the earliest start date among the records the view's + // filter selects — its whole filter scope, not the drawn window, which is why + // it is its own one-row read rather than a scan of `data`. Read from the same + // source the record fetch above reads (a host that hands `data` down still + // hands this calendar its `dataSource` and `objectName`), behind the same + // object-schema gate, because the `next_record` bound is spelled by the start + // field's declared type: a `date` field compares as a calendar day. + // + // Seeds once: `anchorSeededRef` is set when an answer (or a failure) lands, + // not when the read starts, so a re-run that discards an in-flight answer + // asks again. No source to read, no qualifying record or a failed read ⇒ the + // calendar stays on today. + const declaredInitialDate = schema.calendar?.initialDate; + const initialDateAnchor = isCalendarRecordAnchor(declaredInitialDate) ? declaredInitialDate : undefined; + const anchorStartField = initialDateAnchor ? calendarConfig?.startDateField : undefined; + const anchorSeededRef = useRef(false); + useEffect(() => { + if (anchorSeededRef.current || !initialDateAnchor || !anchorStartField) return; + if (dataProvider === 'object' && !objectSchemaReady) return; + const source = dataProvider === 'value' + ? new ValueDataSource({ items: (dataItems as any[]) ?? [] }) + : dataProvider === 'object' && schemaObjectName && typeof dataSource?.find === 'function' + ? dataSource + : undefined; + if (!source) { + anchorSeededRef.current = true; + return; + } + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const declaredType = (objectSchema?.fields as Record | undefined)?.[anchorStartField]?.type; + const params = recordAnchorQuery( + initialDateAnchor, + anchorStartField, + queryFilter, + toStoredDateValue(startOfToday, declaredType, undefined), + ); + let live = true; + source.find(dataProvider === 'value' ? '' : (schemaObjectName as string), params).then( + (result) => { + if (!live) return; + anchorSeededRef.current = true; + if (userNavigatedRef.current) return; + const anchorDate = anchorDateFromResult(result, anchorStartField); + if (anchorDate) setCurrentDate(anchorDate); + }, + (err) => { + if (!live) return; + anchorSeededRef.current = true; + console.warn(`[ObjectCalendar] Could not read the ${initialDateAnchor} the calendar opens on; it opens on today:`, err); + }, + ); + return () => { live = false; }; + }, [initialDateAnchor, anchorStartField, dataProvider, dataItems, dataSource, schemaObjectName, + objectSchemaReady, objectSchema, queryFilter]); + // Transform data to calendar events, and separate out the records that have // no date to be placed on at all (`bc5870c9f` — see the early return in the // loop below). ONE pass, so the two lists are always answers about the same @@ -1572,6 +1644,7 @@ export const ObjectCalendar: React.FC = ({ // allows it (objectui#12082). onDateClick={onDateClick ?? dayClickCreate} onNavigate={(date) => { + userNavigatedRef.current = true; setCurrentDate(date); onNavigate?.(date); }} diff --git a/ui/packages/plugin-calendar/src/initialDate.ts b/ui/packages/plugin-calendar/src/initialDate.ts new file mode 100644 index 00000000000..ef57b330127 --- /dev/null +++ b/ui/packages/plugin-calendar/src/initialDate.ts @@ -0,0 +1,91 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `calendar.initialDate` — the date a calendar opens on (`@objectstack/spec` + * `CalendarConfigSchema.initialDate`). Two forms, and absence: + * + * - absent: today. + * - a wrapped date-macro token (`{today}`, `{next_month_start}`, + * `{3_months_from_now}`): the date the token resolves to when the calendar + * opens, through `resolveDateMacros` — the resolver filter values already + * use, so there is no second grammar here. + * - a record anchor, `first_record` / `next_record`: the earliest start date + * among the records the view's filter selects (on or after the start of + * today for `next_record`), read with its own one-row query because the + * calendar fetches only what it draws. No qualifying record ⇒ today. + * + * The calendar shows the period (month, week or day of the active mode) that + * holds the date. `ObjectCalendar` seeds it once on mount; user navigation owns + * the date afterwards. Not re-exported from the package index. + */ + +import { + convertSortToQueryParams, + extractRecords, + mergeFilterNodes, + resolveDateMacros, + toDisplayDate, +} from '@object-ui/core'; + +/** The record anchors `calendar.initialDate` accepts beside a date-macro token. */ +export type CalendarRecordAnchor = 'first_record' | 'next_record'; + +export function isCalendarRecordAnchor(value: unknown): value is CalendarRecordAnchor { + return value === 'first_record' || value === 'next_record'; +} + +/** + * The date a date-macro `initialDate` opens on, or `null` for every other + * value: absent, a record anchor, or a token the client resolver leaves as it + * found it. `null` means the caller opens on today. + */ +export function resolveInitialDateMacro(initialDate: unknown, now: Date = new Date()): Date | null { + if (typeof initialDate !== 'string' || initialDate === '' || isCalendarRecordAnchor(initialDate)) return null; + const resolved = resolveDateMacros(initialDate, now); + if (typeof resolved !== 'string' || resolved === initialDate) return null; + const date = toDisplayDate(resolved); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * The one-row query a record anchor reads: the view's filter, plus a start date + * that is present (`first_record`) or on or after `todayBound` (`next_record`), + * sorted by the start date ascending, one row. + * + * `todayBound` is the start of today spelled the way the start field stores it: + * the caller knows the field's declared type, this function does not. + */ +export function recordAnchorQuery( + anchor: CalendarRecordAnchor, + startDateField: string, + filter: unknown, + todayBound: string, +): Record { + const bound = anchor === 'next_record' + ? [startDateField, '>=', todayBound] + : [startDateField, 'is_not_null']; + const $filter = mergeFilterNodes(filter, bound); + return { + ...($filter !== undefined ? { $filter } : {}), + $orderby: convertSortToQueryParams([{ field: startDateField, order: 'asc' }]), + $top: 1, + }; +} + +/** + * The date a record anchor opens on, read off the one-row answer: the first + * record's start value, read the way the calendar places an event on its day. + * `null` when no record qualifies or its start value is not a date. + */ +export function anchorDateFromResult(result: unknown, startDateField: string): Date | null { + const value = extractRecords(result)[0]?.[startDateField]; + if (typeof value !== 'string' && typeof value !== 'number' && !(value instanceof Date)) return null; + const date = toDisplayDate(value); + return Number.isNaN(date.getTime()) ? null : date; +} From fec9e8ffdf8bd545dbcd051a9bce39d4d0449603 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:23:26 +0000 Subject: [PATCH 2/8] wip: forward initialDate through the calendar producers, lift the spec marker, key copies Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- packages/spec/liveness/view.json | 2 +- packages/spec/src/ui/view.zod.ts | 12 ++++-------- ui/packages/plugin-calendar/src/index.tsx | 2 +- ui/packages/plugin-list/src/ListView.tsx | 4 ++++ ui/packages/plugin-list/src/index.tsx | 2 +- ui/packages/plugin-view/src/ObjectView.tsx | 2 ++ ui/packages/types/src/designer.ts | 2 ++ 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index dcacfe16ee1..35cfdeb4adc 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -105,7 +105,7 @@ }, "calendar": { "status": "live", - "note": "objectui: calendar-view-renderer.tsx:147 (audit L16). One key rides this blanket verdict at PLANNED strength, as `viewMode` does on the gantt row: `initialDate` (the date the calendar opens on: a record anchor first_record / next_record, or a wrapped date-macro token such as {today} or {next_month_start} resolved when the calendar opens; absent meaning today) is declared ahead of its reader — at this repo's `ui/` tree on 549203d040, ui/packages/plugin-calendar/src/ObjectCalendar.tsx#ObjectCalendar still seeds `currentDate` with `useState(new Date())` and reads no anchor. Amend this note to plain live, citing that read, when the console half lands. Read points of the `calendar` view-binding row in packages/spec/src/kernel/functional-completeness.ts (moved out of spec source, #23434), re-read at objectui @b9814b334 (2026-10-14): objectui @b9814b334 packages/plugin-list/src/ListView.tsx#ListView (its `case 'calendar'` arm restates only the bindings the view declared; the `startDateField || 'start_date'` / `endDateField || 'end_date'` floors were deleted by objectui#7029); objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#getCalendarConfig returns `null` with neither a `calendar` block nor a flat `startDateField`, and the `if (!calendarConfig)` arm in objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#ObjectCalendar renders the localised `calendar.configRequired` refusal naming `startDateField` (objectui#10101; `titleField` is not required, objectui#8170)." + "note": "objectui: calendar-view-renderer.tsx:147 (audit L16). `initialDate` (the date the calendar opens on: a record anchor first_record / next_record, or a wrapped date-macro token such as {today} or {next_month_start}; absent meaning today) is live: ui/packages/plugin-calendar/src/ObjectCalendar.tsx#ObjectCalendar seeds `currentDate` from it once on mount, a token through `resolveDateMacros` and a record anchor through its own one-row read (ui/packages/plugin-calendar/src/initialDate.ts#recordAnchorQuery), and both producers of the `object-calendar` node (ui/packages/plugin-list/src/ListView.tsx#ListView, ui/packages/plugin-view/src/ObjectView.tsx#generateViewSchema) forward it inside the `calendar` block. Read points of the `calendar` view-binding row in packages/spec/src/kernel/functional-completeness.ts (moved out of spec source, #23434), re-read at objectui @b9814b334 (2026-10-14): objectui @b9814b334 packages/plugin-list/src/ListView.tsx#ListView (its `case 'calendar'` arm restates only the bindings the view declared; the `startDateField || 'start_date'` / `endDateField || 'end_date'` floors were deleted by objectui#7029); objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#getCalendarConfig returns `null` with neither a `calendar` block nor a flat `startDateField`, and the `if (!calendarConfig)` arm in objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#ObjectCalendar renders the localised `calendar.configRequired` refusal naming `startDateField` (objectui#10101; `titleField` is not required, objectui#8170)." }, "gantt": { "status": "live", diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 50b103ffe45..97c62be7b7f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2278,10 +2278,9 @@ export const CalendarConfigSchema = lazySchema(() => strictObject({ * stored on a view goes stale; and the same node retired `defaultDate` as * inert, which is a second reason not to spell this key `defaultDate`. * - * Marked `[PLANNED — not yet rendered]` in its describe until the console - * half (#28102) reads it where `ObjectCalendar` seeds its current date; that - * change removes the marker, as `navigation.dataSource` (`app.zod.ts`) and - * the bound-record empty state (`component.zod.ts`) do. + * Read where `ObjectCalendar` seeds its current date + * (`ui/packages/plugin-calendar/src/initialDate.ts`): a token through the + * client `resolveDateMacros`, a record anchor by its own one-row read. * * Two forms, and only two (see {@link CALENDAR_INITIAL_DATE_RE}): a record * anchor, or a wrapped, KNOWN date-macro token — the formula vocabulary the @@ -2299,11 +2298,8 @@ export const CalendarConfigSchema = lazySchema(() => strictObject({ * materialized default would write the key onto every parsed calendar and * read as an author choice. Absence means today, which is what every * calendar stored before this key did. - * - * Lands ahead of its reader: the renderer seeds its current date from the - * clock until the console half reads this key where it seeds that date. */ - initialDate: z.string().regex(CALENDAR_INITIAL_DATE_RE, { error: (issue) => calendarInitialDateRefusal(issue.input) }).optional().describe("[PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. \"Today\" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not"), + initialDate: z.string().regex(CALENDAR_INITIAL_DATE_RE, { error: (issue) => calendarInitialDateRefusal(issue.input) }).optional().describe("Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes."), })); /** diff --git a/ui/packages/plugin-calendar/src/index.tsx b/ui/packages/plugin-calendar/src/index.tsx index 6ff9574636a..5fcc4a91b98 100644 --- a/ui/packages/plugin-calendar/src/index.tsx +++ b/ui/packages/plugin-calendar/src/index.tsx @@ -446,7 +446,7 @@ export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any */ const OBJECT_CALENDAR_INPUTS: ComponentInput[] = [ { name: 'objectName', type: 'string', description: 'ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`; the `object-calendar` schema refuses a block that declares none of them, unless the node\'s `dataSource.object` names the object, which then lands on this key.' }, - { name: 'calendar', type: 'object', description: 'startDateField, endDateField, titleField, colorField' }, + { name: 'calendar', type: 'object', description: 'startDateField, endDateField, titleField, colorField, allDayField, initialDate (where the calendar opens: `first_record`, `next_record` or a date-macro token such as `{next_month_start}`; omitted, today)' }, { name: 'filter', type: 'array', description: 'Base query filter — the `ViewFilterRule` array `[{ field, operator, value }, ...]`, narrowing the records the calendar fetches. Lowered to `$filter` on the query.' }, { name: 'sort', type: 'array', description: 'Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query.' }, { name: 'defaultView', type: 'enum', enum: ['month', 'week', 'day'], description: 'The grid the calendar opens on. A narrow viewport downgrades `day` to `month`; the author’s choice is otherwise honoured on first render.' }, diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 13049501a64..7cccb00e205 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -4323,6 +4323,9 @@ export const ListView = React.forwardRef(({ // fold would re-create the dialect this closes. const colorField = schema.calendar?.colorField; const allDayField = schema.calendar?.allDayField; + // The date the calendar opens on: a block key, not a field binding, + // forwarded with the five so `ObjectCalendar` can seed its date from it. + const initialDate = schema.calendar?.initialDate; // ⭐ objectstack#25923 — the five go out NESTED, as the node's own // `calendar` block: the spec's `ComponentPropsMap['object-calendar']` // refuses them flat, so the published `ObjectCalendarSchema` no longer @@ -4342,6 +4345,7 @@ export const ListView = React.forwardRef(({ ...(titleField ? { titleField } : {}), ...(colorField ? { colorField } : {}), ...(allDayField ? { allDayField } : {}), + ...(initialDate ? { initialDate } : {}), } : undefined; return { diff --git a/ui/packages/plugin-list/src/index.tsx b/ui/packages/plugin-list/src/index.tsx index 9ecfcb0ef0b..25036abd5de 100644 --- a/ui/packages/plugin-list/src/index.tsx +++ b/ui/packages/plugin-list/src/index.tsx @@ -324,7 +324,7 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ { name: 'calendar', type: 'object', - description: 'The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`.', + description: 'The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField, initialDate }`; `initialDate` is where it opens: `first_record`, `next_record` or a date-macro token such as `{next_month_start}`, today when omitted.', }, { name: 'gantt', diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index e6b0bd354b5..67016a09c98 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -2354,6 +2354,8 @@ export const ObjectView: React.FC = ({ ...(calendarCfg.titleField ? { titleField: calendarCfg.titleField } : {}), ...(calendarCfg.colorField ? { colorField: calendarCfg.colorField } : {}), ...(calendarCfg.allDayField ? { allDayField: calendarCfg.allDayField } : {}), + // The date the calendar opens on, which `ObjectCalendar` seeds its date from. + ...(calendarCfg.initialDate ? { initialDate: calendarCfg.initialDate } : {}), }, } : {}), diff --git a/ui/packages/types/src/designer.ts b/ui/packages/types/src/designer.ts index 34303211361..7a21dcf653c 100644 --- a/ui/packages/types/src/designer.ts +++ b/ui/packages/types/src/designer.ts @@ -823,6 +823,8 @@ export interface UnifiedViewConfig { colorField?: string; allDayField?: string; defaultView?: string; + /** Where the calendar opens: `first_record`, `next_record` or a wrapped date-macro token. */ + initialDate?: string; }; /** Map-specific options */ map?: { From 503516b5c13df23128c50ac77fea5441050ced28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:25:19 +0000 Subject: [PATCH 3/8] wip: initialDate renderer pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- .../ObjectCalendar.initialDate-28102.test.tsx | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 ui/packages/plugin-calendar/src/ObjectCalendar.initialDate-28102.test.tsx diff --git a/ui/packages/plugin-calendar/src/ObjectCalendar.initialDate-28102.test.tsx b/ui/packages/plugin-calendar/src/ObjectCalendar.initialDate-28102.test.tsx new file mode 100644 index 00000000000..2f41adc9342 --- /dev/null +++ b/ui/packages/plugin-calendar/src/ObjectCalendar.initialDate-28102.test.tsx @@ -0,0 +1,276 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28102 — `ObjectCalendar` opens on `calendar.initialDate`. + * + * The spec half declares the key (`CalendarConfigSchema.initialDate`): absent ⇒ + * today; a wrapped date-macro token ⇒ the date `resolveDateMacros` gives it + * when the calendar opens; `first_record` / `next_record` ⇒ the earliest start + * date among the records the view's filter selects (on or after today for + * `next_record`), read with a one-row query; no qualifying record ⇒ today. + * The seed happens once; user navigation owns the date afterwards. + * + * `CalendarView` is stubbed to expose the date it was handed (as the local + * calendar day) and a navigation button, which is all these rows read. + */ + +import React from 'react'; +import { render, screen, waitFor, cleanup, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ObjectCalendar } from './ObjectCalendar'; + +vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ + ...(await importOriginal()), + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +vi.mock('./CalendarView', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + CalendarView: ({ currentDate, onNavigate }: any) => ( +
+ +
+ ), + }; +}); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}' }))); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +function localDay(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +const TODAY = localDay(new Date()); +/** A day `months` months from now, on the 10th, as `YYYY-MM-DD`. */ +function monthsAhead(months: number): string { + const now = new Date(); + return localDay(new Date(now.getFullYear(), now.getMonth() + months, 10)); +} +function monthsBack(months: number): string { + const now = new Date(); + return localDay(new Date(now.getFullYear(), now.getMonth() - months, 10)); +} + +const shownDay = () => screen.getByTestId('calendar-view').getAttribute('data-current-day'); +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +const VIEW_FILTER = ['stage', '=', 'pending']; + +/** + * A data source whose record fetch answers nothing and whose one-row anchor + * read (`$top: 1`) answers `anchorRows`; `due` is a `date` field. + */ +function makeDataSource(anchorRows: unknown[] | Promise | (() => Promise), dueType = 'date') { + return { + find: vi.fn(async (_object: string, params: Record) => + params?.$top === 1 + ? { data: await (typeof anchorRows === 'function' ? anchorRows() : anchorRows) } + : { data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: 'renewal', fields: { name: { type: 'text' }, due: { type: dueType } } })), + }; +} + +const anchorCalls = (ds: ReturnType) => + ds.find.mock.calls.filter(([, params]) => (params as any)?.$top === 1); + +function block(initialDate?: string, extra: Record = {}): any { + return { + type: 'object-calendar', + objectName: 'renewal', + filter: VIEW_FILTER, + calendar: { startDateField: 'due', titleField: 'name', ...(initialDate ? { initialDate } : {}) }, + ...extra, + }; +} + +describe('calendar.initialDate — absent (objectstack#28102)', () => { + it('CONTROL: a calendar without the key opens on today and issues no anchor read', async () => { + const ds = makeDataSource([{ id: '1', due: monthsAhead(4) }]); + render(); + await settle(); + expect(shownDay()).toBe(TODAY); + expect(anchorCalls(ds)).toHaveLength(0); + }); +}); + +describe('calendar.initialDate — a date-macro token (objectstack#28102)', () => { + it('`{next_month_start}` opens on the first day of next month, and the FIRST range reported is already that month', async () => { + const now = new Date(); + const target = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const ds = makeDataSource([]); + const onVisibleRangeChange = vi.fn(); + render( + , + ); + await waitFor(() => expect(shownDay()).toBe(localDay(target))); + // Seeded synchronously on mount, so a host that windows its fetch on the + // reported range never asks for today's month first. + const first = onVisibleRangeChange.mock.calls[0][0] as { start: Date; end: Date }; + expect(first.start.getTime()).toBeLessThanOrEqual(target.getTime()); + expect(first.end.getTime()).toBeGreaterThan(target.getTime()); + expect(anchorCalls(ds)).toHaveLength(0); + }); + + it('`{3_months_from_now}` opens on the date three months ahead', async () => { + const now = new Date(); + const ds = makeDataSource([]); + render(); + // The month the client resolver lands on (it rolls over at a short month's end). + const expected = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate()); + await waitFor(() => expect(shownDay()?.slice(0, 7)).toBe(localDay(expected).slice(0, 7))); + }); +}); + +describe('calendar.initialDate — `first_record` (objectstack#28102)', () => { + it('opens on the earliest start date the one-row read returns', async () => { + const target = monthsAhead(5); + const ds = makeDataSource([{ id: '7', due: target }]); + render(); + await waitFor(() => expect(shownDay()).toBe(target)); + }); + + it('reads ONE row, sorted by the start field ascending, under the view filter and a present start date', async () => { + const ds = makeDataSource([{ id: '7', due: monthsAhead(5) }]); + render(); + await waitFor(() => expect(anchorCalls(ds)).toHaveLength(1)); + const [object, params] = anchorCalls(ds)[0] as [string, Record]; + expect(object).toBe('renewal'); + expect(params.$top).toBe(1); + expect(params.$orderby).toEqual({ due: 'asc' }); + expect(params.$filter).toEqual(['and', ['stage', '=', 'pending'], ['due', 'is_not_null']]); + }); + + it('opens on today when no record qualifies', async () => { + const ds = makeDataSource([]); + render(); + await waitFor(() => expect(anchorCalls(ds)).toHaveLength(1)); + await settle(); + expect(shownDay()).toBe(TODAY); + }); + + it('opens on today when the anchor read fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ds = makeDataSource(() => Promise.reject(new Error('offline'))); + render(); + await waitFor(() => expect(anchorCalls(ds)).toHaveLength(1)); + await settle(); + expect(shownDay()).toBe(TODAY); + warn.mockRestore(); + }); + + it('reads inline `staticData` through the same query: the earliest dated row, undated rows skipped', async () => { + const early = monthsBack(2); + const rows = [ + { id: 'a', name: 'Later', due: monthsAhead(3) }, + { id: 'b', name: 'Undated', due: null }, + { id: 'c', name: 'Earliest', due: early }, + ]; + render(); + await waitFor(() => expect(shownDay()).toBe(early)); + }); +}); + +describe('calendar.initialDate — `next_record` (objectstack#28102)', () => { + it('bounds the read at the start of today, spelled as a calendar day for a `date` start field', async () => { + const target = monthsAhead(2); + const ds = makeDataSource([{ id: '3', due: target }]); + render(); + await waitFor(() => expect(shownDay()).toBe(target)); + const [, params] = anchorCalls(ds)[0] as [string, Record]; + expect(params.$top).toBe(1); + expect(params.$orderby).toEqual({ due: 'asc' }); + expect(params.$filter).toEqual(['and', ['stage', '=', 'pending'], ['due', '>=', TODAY]]); + }); + + it('bounds a `datetime` start field at the instant of local midnight today', async () => { + const ds = makeDataSource([], 'datetime'); + render(); + await waitFor(() => expect(anchorCalls(ds)).toHaveLength(1)); + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + const [, params] = anchorCalls(ds)[0] as [string, Record]; + expect(params.$filter).toEqual(['and', ['stage', '=', 'pending'], ['due', '>=', midnight.toISOString()]]); + }); + + it('skips past records on inline data and opens on the first one from today on', async () => { + const next = monthsAhead(1); + const rows = [ + { id: 'a', name: 'Past', due: monthsBack(1) }, + { id: 'b', name: 'Next', due: next }, + { id: 'c', name: 'Later', due: monthsAhead(6) }, + ]; + render(); + await waitFor(() => expect(shownDay()).toBe(next)); + }); + + it('opens on today when no record lies ahead', async () => { + const rows = [{ id: 'a', name: 'Past', due: monthsBack(1) }]; + render(); + await settle(); + expect(shownDay()).toBe(TODAY); + }); +}); + +describe('calendar.initialDate — navigation owns the date after the seed (objectstack#28102)', () => { + it('a navigation after the seed is not reset by a later re-render', async () => { + const target = monthsAhead(5); + const ds = makeDataSource([{ id: '7', due: target }]); + const { rerender } = render(); + await waitFor(() => expect(shownDay()).toBe(target)); + + fireEvent.click(screen.getByRole('button', { name: 'next' })); + const t = new Date(`${target}T00:00:00`); + const moved = localDay(new Date(t.getFullYear(), t.getMonth() + 1, 15)); + expect(shownDay()).toBe(moved); + + rerender(); + await settle(); + expect(shownDay()).toBe(moved); + expect(anchorCalls(ds)).toHaveLength(1); + }); + + it('an anchor answer that lands after the user navigated does not move the calendar', async () => { + let answer!: (rows: unknown[]) => void; + const pending = new Promise((resolve) => { answer = resolve; }); + const ds = makeDataSource(pending); + render(); + await waitFor(() => expect(anchorCalls(ds)).toHaveLength(1)); + + fireEvent.click(screen.getByRole('button', { name: 'next' })); + const now = new Date(); + const moved = localDay(new Date(now.getFullYear(), now.getMonth() + 1, 15)); + expect(shownDay()).toBe(moved); + + await act(async () => { answer([{ id: '7', due: monthsAhead(5) }]); }); + await settle(); + expect(shownDay()).toBe(moved); + }); +}); From b1a99d7627f1deda755bebb6f38ba6d41e545f70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:26:07 +0000 Subject: [PATCH 4/8] wip: producer pins for initialDate Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- ...istView.calendarInitialDate-28102.test.tsx | 86 +++++++++++++++++++ ...ectView.calendarInitialDate-28102.test.tsx | 79 +++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 ui/packages/plugin-list/src/__tests__/ListView.calendarInitialDate-28102.test.tsx create mode 100644 ui/packages/plugin-view/src/__tests__/ObjectView.calendarInitialDate-28102.test.tsx diff --git a/ui/packages/plugin-list/src/__tests__/ListView.calendarInitialDate-28102.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendarInitialDate-28102.test.tsx new file mode 100644 index 00000000000..f039f70d7a6 --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendarInitialDate-28102.test.tsx @@ -0,0 +1,86 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28102 — `ListView`'s calendar branch hands a stored view's + * `calendar.initialDate` on inside the `object-calendar` node's `calendar` + * block, the one place `ObjectCalendar` reads the date it opens on from. The + * branch forwards the block's keys by name, so a key it does not name never + * reaches the renderer: a renewals list view declaring `next_record` opened on + * today. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ListView } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const objectDef = { + name: 'renewal', + label: 'Renewal', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name' }, + renewal_date: { name: 'renewal_date', type: 'date', label: 'Renewal Date' }, + }, +}; + +let captured: Array> = []; + +ComponentRegistry.register( + 'object-calendar', + (props: Record) => { + captured.push(props); + return
; + }, + { namespace: 'test', label: 'Calendar spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => []), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + }) as any; + +async function calendarNode(calendar: Record) { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + return captured[captured.length - 1].schema; +} + +beforeEach(() => { + captured = []; +}); +afterEach(cleanup); + +describe('ListView calendar branch — calendar.initialDate reaches the node (objectstack#28102)', () => { + it.each(['next_record', 'first_record', '{next_month_start}'])('forwards `initialDate: %s` inside the calendar block', async (initialDate) => { + const node = await calendarNode({ startDateField: 'renewal_date', initialDate }); + expect(node.calendar).toEqual({ startDateField: 'renewal_date', initialDate }); + expect(node).not.toHaveProperty('initialDate'); + }); + + it('CONTROL: a block without the key carries none', async () => { + const node = await calendarNode({ startDateField: 'renewal_date' }); + expect(node.calendar).toEqual({ startDateField: 'renewal_date' }); + }); +}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarInitialDate-28102.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarInitialDate-28102.test.tsx new file mode 100644 index 00000000000..b6003558b6a --- /dev/null +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarInitialDate-28102.test.tsx @@ -0,0 +1,79 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28102 — `ObjectView`'s `generateViewSchema` hands a view's + * `calendar.initialDate` on inside the node's `calendar` block, the one place + * `ObjectCalendar` reads the date it opens on from. The branch forwards the + * block's keys by name, so a key it does not name never reaches the renderer. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +/** Every schema the view hands to SchemaRenderer, in order. */ +const rendered: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: () =>
, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +async function renderCalendarView(view: Record) { + rendered.length = 0; + const ds: any = { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'renewal', fields: {} }), + }; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; +} + +describe('ObjectView.generateViewSchema — calendar.initialDate reaches the node (objectstack#28102)', () => { + it.each(['next_record', 'first_record', '{next_month_start}'])('forwards `initialDate: %s` inside the calendar block', async (initialDate) => { + const schema = await renderCalendarView({ calendar: { startDateField: 'renewal_date', initialDate } }); + expect(schema.type).toBe('object-calendar'); + expect(schema.calendar).toEqual({ startDateField: 'renewal_date', initialDate }); + expect(schema).not.toHaveProperty('initialDate'); + }); + + it('CONTROL: a block without the key carries none', async () => { + const schema = await renderCalendarView({ calendar: { startDateField: 'renewal_date' } }); + expect(schema.calendar).toEqual({ startDateField: 'renewal_date' }); + }); +}); From 0c05e326506894b73337b697e3bde50d35156026 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:26:33 +0000 Subject: [PATCH 5/8] wip: changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- .../28102-calendar-opens-on-initial-date.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/28102-calendar-opens-on-initial-date.md diff --git a/.changeset/28102-calendar-opens-on-initial-date.md b/.changeset/28102-calendar-opens-on-initial-date.md new file mode 100644 index 00000000000..b64475cc45c --- /dev/null +++ b/.changeset/28102-calendar-opens-on-initial-date.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-view': patch +'@object-ui/types': patch +'@objectstack/spec': patch +--- + +fix(plugin-calendar): a calendar opens on the date its `calendar.initialDate` declares + +Clause-②: yes +Metadata-protocol: none + +`calendar.initialDate` is now read where `ObjectCalendar` seeds the date it opens on. Before, every calendar opened on today whatever the key said, so a renewals calendar whose records fall months ahead opened on an empty month. + +- **Absent:** today, as before. +- **A wrapped date-macro token** (`{today}`, `{next_month_start}`, `{3_months_from_now}`, …): the date the token resolves to when the calendar opens, through the same `resolveDateMacros` resolver filter values use. +- **`first_record` / `next_record`:** one extra one-row read — the view's filter, plus a present start date (`first_record`) or a start date on or after the start of today (`next_record`), sorted by the start field ascending, one row — and the calendar opens on that record's start date. A `date` start field is bounded as a calendar day, any other as the instant of local midnight. No qualifying record, or a failed read, opens on today. + +The calendar shows the month, week or day of its active mode that holds the date. The seed happens once, on mount: after it, the user's navigation owns the date, and an anchor answer that arrives after the user has navigated is discarded. + +Both producers of the `object-calendar` node — a list view's calendar branch (`ListView`) and `ObjectView`'s generated calendar view — now forward `initialDate` inside the node's `calendar` block; before, they forwarded the block's five field bindings by name and dropped it. The `[PLANNED — not yet rendered]` marker is gone from `CalendarConfigSchema.initialDate`'s description, which now reads one sentence per form, and the `calendar` registry input descriptions of `object-calendar` and `list-view`, plus the designer's `calendar` options type, list the key. From dbf9260647f323de4d85914ca3498dd3ce827242 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:29:20 +0000 Subject: [PATCH 6/8] wip: regenerate spec reference docs Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/view.mdx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index eb9940acf7d..71d96dfcdec 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -503,7 +503,7 @@ const result = ActionButtonPropsSchema.parse(data); | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | ### Nested Shape: `ObjectCalendarProps.filter[number]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 725e2c00af9..85408a02d0e 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -109,7 +109,7 @@ Appearance and visualization configuration | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | --- @@ -918,7 +918,7 @@ View filter rule | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | ### Nested Shape: `ListView.gantt` @@ -1289,7 +1289,7 @@ View filter rule | **titleField** | `string` | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **colorField** | `string` | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value | | **allDayField** | `string` | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day | -| **initialDate** | `string` | optional | [PLANNED — not yet rendered] Where the calendar opens. No console reads it yet: until the calendar renderer reads it, a calendar that declares it still opens on today. Two forms. A record anchor: `first_record` opens on the period containing the earliest start date (the startDateField value) among the records the view's filter selects (its whole filter scope, not only the visible window), `next_record` on the period containing the earliest start date on or after today among those records; when no record qualifies, the calendar opens on today. "Today" is the viewer's local calendar day, and a `date` start field compares as a calendar day. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`). Omit the key to open on today. The period is the month, week or day the active view mode shows. A fixed calendar date is not accepted: it goes stale as time passes, while a macro does not | +| **initialDate** | `string` | optional | Where the calendar opens: the month, week or day the active view mode shows that holds this date; omit it to open on today. A record anchor: `first_record` opens on the earliest start date (the startDateField value) among the records the view's filter selects, `next_record` on the earliest on or after the viewer's today (a `date` start field compares as a calendar day), and either opens on today when no record qualifies. Or a wrapped date-macro token, resolved each time the calendar opens: `{today}`, `{next_month_start}`, `{current_quarter_start}`, `{3_months_from_now}` (the full vocabulary is `DATE_MACRO_TOKENS` and the parameterised `DATE_MACRO_PARAM_RE` grammar in `@objectstack/spec/data`); a fixed calendar date is not accepted, because it goes stale as time passes. | ### Nested Shape: `ObjectListView.gantt` From a0933cacc4a4ff53f46f119f3b746d4ec4ecf78f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:36:07 +0000 Subject: [PATCH 7/8] wip: regenerate the SDUI manifest and the component reference Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- content/docs/ui/components/plugins.mdx | 4 ++-- scripts/sdui-manifest.record.json | 4 ++-- sdui.manifest.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx index 415135190c0..ecb6ce41073 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -21,7 +21,7 @@ Namespace `plugin-calendar`. | Input | Type | Required | Description | | :--- | :--- | :--- | :--- | | `objectName` | `string` | | ObjectQL object name. The record source is one of `data`, `staticData` and `objectName`; the `object-calendar` schema refuses a block that declares none of them, unless the node's `dataSource.object` names the object, which then lands on this key. | -| `calendar` | `object` | | startDateField, endDateField, titleField, colorField | +| `calendar` | `object` | | startDateField, endDateField, titleField, colorField, allDayField, initialDate (where the calendar opens: `first_record`, `next_record` or a date-macro token such as `{next_month_start}`; omitted, today) | | `filter` | `array` | | Base query filter — the `ViewFilterRule` array `[{ field, operator, value }, ...]`, narrowing the records the calendar fetches. Lowered to `$filter` on the query. | | `sort` | `array` | | Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query. | | `defaultView` | `enum`: `month`, `week`, `day` | | The grid the calendar opens on. A narrow viewport downgrades `day` to `month`; the author’s choice is otherwise honoured on first render. | @@ -312,7 +312,7 @@ Namespace `plugin-list`. | `bulkActions` | `array` of `string` | | Names of the actions offered once rows are selected. Needs a multi-row `selection`. | | `bulkActionDefs` | `array` of `object` | | Full bulk-action definitions, the spec's `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection. | | `kanban` | `object` | | The board, the spec's kanban block: `{ groupByField, columns, titleField, summarizeField }`. `groupByField` is the lane field and `columns` the fields on each card. | -| `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`. | +| `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField, initialDate }`; `initialDate` is where it opens: `first_record`, `next_record` or a date-macro token such as `{next_month_start}`, today when omitted. | | `gantt` | `object` | | The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it. | | `gallery` | `object` | | The gallery, `{ coverField, titleField, visibleFields, cardSize, … }`. | | `timeline` | `object` | | The timeline, `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. | diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 7fe1a7b312b..8c6f1a3c7be 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -13,8 +13,8 @@ ], "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-29", - "sha256": "d55576794739e7893f7ffdfeadfe8a0ebdd480469e95f431dc7bc80565f4e229", + "generatedAt": "2026-10-31", + "sha256": "f0383c2a81cd84d75d33a3cc599ceea71e82eea3b72114ff258b93073605bbd4", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/sdui.manifest.json b/sdui.manifest.json index fdd2963a716..5e8d2d0f5ae 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -407,7 +407,7 @@ { "name": "calendar", "type": "object", - "description": "The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`." + "description": "The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField, initialDate }`; `initialDate` is where it opens: `first_record`, `next_record` or a date-macro token such as `{next_month_start}`, today when omitted." }, { "name": "gantt", @@ -916,7 +916,7 @@ { "name": "calendar", "type": "object", - "description": "startDateField, endDateField, titleField, colorField" + "description": "startDateField, endDateField, titleField, colorField, allDayField, initialDate (where the calendar opens: `first_record`, `next_record` or a date-macro token such as `{next_month_start}`; omitted, today)" }, { "name": "filter", From 471be6e722d42329d229b032157594798cc632c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:24:13 +0000 Subject: [PATCH 8/8] chore: re-record the SDUI manifest on the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxTJuzeiDxzGXxzFbe --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 2c24082d8b4..0cc5f751861 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -14,7 +14,7 @@ "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", "generatedAt": "2026-10-31", - "sha256": "5030c05123d51da87188ea9a0569aeafee106fa612e251765adb254e84bb7144", + "sha256": "029ea9f8fca79fd46c518d889c713cec8e3487606c9ddbb1fe57972cf8090df8", "components": 106, "inTree": { "modulesRoot": "ui/apps/console",