diff --git a/.changeset/12081-calendar-date-window.md b/.changeset/12081-calendar-date-window.md new file mode 100644 index 0000000000..1b71a67857 --- /dev/null +++ b/.changeset/12081-calendar-date-window.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-calendar': minor +--- + +A calendar view in a list draws every record of the days it shows, not the first 100 records of the object (objectui#12081 item 2). + +A list view's calendar used to draw the rows of the list's one unpaged fetch: the first 100 records of the object, with no date condition. A month holding more than 100 matching records was drawn from whichever 100 came first, and the rest never appeared. The only sign was the list's "Showing first 100 records" note. Measured on a contracts calendar with 122 records: up to 22 were never drawn. + +- **The list fetches the calendar's visible days.** The query selects the days on the calendar's start field, or, with an end field bound, every span that touches those days. It also selects the records with no start date, which the calendar counts as unscheduled. The view's own filter, the filter panel, the user filters and the search still narrow it. +- **It walks the window in steps of the fetch batch.** Every request asks for at most 100 records, the same batch as before. The list keeps asking until the window is exhausted, so every record of the month is drawn. It stops at the platform's non-grid ceiling of 2,000 records. It then draws the first 2,000 and shows a note under the calendar naming both numbers, the note the standalone calendar already shows. The "Showing first 100 records" note no longer appears on a calendar. +- **A declared page size no longer sizes a calendar's fetch.** On a calendar with a start date bound, every step is the fetch batch, whatever `pagination.pageSize` the view declares. A declared size still sizes the window of every other view, and of a calendar with no start date bound. +- **The rows-per-page picker is no longer offered on such a calendar.** A page size changes nothing it draws. The other views that offer it keep it. +- **Moving to another month fetches that month.** A move inside the window already fetched, such as switching to a week of the same month, fetches nothing. The calendar keeps drawing while the next window loads, and keeps the month it moved to. +- **Unchanged:** the grid's paging and its requests, every other view's one fetch batch, and a calendar with no start date bound. A calendar rendered on its own, outside a list, still fetches as before. + +**Clause-②: yes (widening)** — `ObjectCalendarComponentProps` gains one optional member, `onVisibleRangeChange?: (range: { start: Date; end: Date }) => void`. `ObjectCalendar` calls it on mount and whenever navigation or a view change moves the days it draws. `start` is local midnight of the first day drawn and `end` local midnight of the day after the last. The `object-calendar` renderer forwards it from a host, like the other callbacks, only when the value is a function. Nothing else on either package entry changes: no export is added or removed, no existing member changes type, and no locale key is added. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index a2c669cfc7..9999c7b692 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -194,7 +194,7 @@ Calendar component designed for use with ObjectQL data sources. - **ObjectQL Integration**: Works seamlessly with object/value data providers - **Automatic Field Mapping**: Maps database fields to calendar events - **Multiple View Modes**: Month, week, and day calendar views -- **Date Filtering**: Automatically filters records by date range +- **Visible days for a host**: Reports the days it draws, so a host that fetches its records can fetch only those days (see [Visible days](#visible-days)); on its own it fetches the whole filtered set, up to the platform row ceiling - **Event Interaction**: Click handling for events and dates - **Color Coding**: Support for event color customization @@ -511,6 +511,19 @@ export function DateClickCalendar({ dataSource }: { dataSource: DataSource }) { } ``` +### Visible days + +A host that fetches the calendar's records for it, and hands them over as +`data`, can fetch only the days on screen. `onVisibleRangeChange` receives +`{ start, end }` once on mount and again whenever navigation or a view change +moves them: `start` is local midnight of the first day drawn and `end` local +midnight of the day after the last. In the month view the range covers the +whole six-week grid, including the neighbouring months' leading and trailing +days, under the locale's first day of the week; in the week view it is the +week, and in the day view the day. An unchanged range is not reported again. +A list view's calendar uses it to fetch the days it shows instead of one batch +of the object (objectui#12081). + ## Examples ### Appointment Scheduler diff --git a/packages/app-shell/src/__tests__/listCalendarDateWindow-12081.test.tsx b/packages/app-shell/src/__tests__/listCalendarDateWindow-12081.test.tsx new file mode 100644 index 0000000000..409abd8c41 --- /dev/null +++ b/packages/app-shell/src/__tests__/listCalendarDateWindow-12081.test.tsx @@ -0,0 +1,137 @@ +/** + * 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. + */ + +/** + * objectui#12081 item 2 — a calendar under a list view draws every record of + * the month it shows, and moving to another month fetches that month. + * + * Measured on objectstack-ai/hotclm#87 (17.7.0): the Console's calendar over + * `clm_contract` asked for `top=100` with no date window, so with 122 contracts + * up to 22 were never drawn. The list view fetched one unpaged batch of the + * object and handed it to the calendar. + * + * ## Why this file lives in `app-shell` + * + * The two halves sit in two packages — `ListView` in `plugin-list`, which now + * windows its calendar fetch, and `ObjectCalendar` in `plugin-calendar`, which + * reports the days it draws — and neither depends on the other. `app-shell` + * depends on both, so the REAL calendar is mounted under the REAL list here + * with no new dependency edge (the reason + * `displayPageSizeFromSpec-9853.test.tsx` lives here too). Each half's own pin + * sits in its package: `ListView.calendarDateWindow-12081.test.tsx` and + * `ObjectCalendar.visibleRange-12081.test.tsx`. + * + * The records live in a `ValueDataSource`, which evaluates `$filter`, `$skip` + * and `$top` the way a backend does. October holds 122 contracts at most four + * a day, the most a month cell draws without a "+N more", so every one of them + * is an event on screen; 40 August contracts are listed ahead of them, which is + * what a first batch of the object reaches first. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ValueDataSource } from '@object-ui/core'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '@object-ui/plugin-list'; +// Registers the real `object-calendar` renderer, the one `ListView` mounts. +import '@object-ui/plugin-calendar'; + +const NOW = new Date(2026, 9, 14, 10, 0, 0); + +type Row = Record; + +/** `count` contracts ending on days of `month` (1-based) in 2026, spread over its days. */ +function contractsIn(month: number, count: number, prefix: string): Row[] { + const days = new Date(2026, month, 0).getDate(); + const mm = String(month).padStart(2, '0'); + return Array.from({ length: count }, (_, i) => ({ + id: `${prefix}-${i}`, + name: `${prefix} ${String(i).padStart(3, '0')}`, + end_date: `2026-${mm}-${String((i % days) + 1).padStart(2, '0')}`, + })); +} + +function makeDataSource(rows: Row[]) { + const store = new ValueDataSource({ items: rows }); + const calls: Array> = []; + return { + calls, + find: vi.fn(async (object: string, params: any) => { + calls.push(params); + return store.find(object, params); + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + fields: { id: { type: 'text' }, name: { type: 'text' }, end_date: { type: 'date' } }, + })), + } as any; +} + +function mountCalendarList(ds: any) { + return render( + + + + + , + ); +} + +/** The event chips the month grid drew whose title starts with `prefix`. */ +const drawn = (prefix: string) => + screen.queryAllByRole('button').filter((el) => (el.getAttribute('aria-label') ?? '').startsWith(`${prefix} `)); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('a calendar under a list view draws its whole month, and fetches the month it moves to (objectui#12081)', () => { + it('(a) all 122 October contracts are drawn, past the first fetch batch', async () => { + const ds = makeDataSource([...contractsIn(8, 40, 'aug'), ...contractsIn(10, 122, 'oct')]); + mountCalendarList(ds); + + await waitFor(() => expect(drawn('oct')).toHaveLength(122)); + expect(drawn('aug')).toHaveLength(0); + // No step asked for more than the fetch batch: walked, not a bigger cap. + for (const params of ds.calls) expect(params.$top).toBeLessThanOrEqual(100); + }); + + it('(b) moving to November fetches November, and draws it whole', async () => { + const ds = makeDataSource([...contractsIn(10, 20, 'oct'), ...contractsIn(11, 110, 'nov')]); + mountCalendarList(ds); + await waitFor(() => expect(drawn('oct')).toHaveLength(20)); + const before = ds.calls.length; + + fireEvent.click(screen.getByRole('button', { name: 'Next period' })); + + await waitFor(() => expect(drawn('nov')).toHaveLength(110)); + expect(ds.calls.length).toBeGreaterThan(before); + // The calendar kept the month it moved to across the refetch: it was not + // unmounted and reopened on today. + expect(screen.getByLabelText(/November 2026/)).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index 424005186e..a53ea82b14 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -77,6 +77,22 @@ The new record is optimistically inserted into local state so it appears immediately. To override (e.g. open your own create form), pass `onDateClick={(day) => …}` — the default behaviour is skipped. +## Reporting the visible days + +A host that fetches the calendar's records for it, and hands them over as +`data`, can fetch only the days on screen. `ObjectCalendar` reports them +through `onVisibleRangeChange={({ start, end }) => …}`: once on mount, and +again whenever navigation or a view change moves them. `start` is local +midnight of the first day drawn and `end` local midnight of the day after the +last, so the range is half-open. In the month view it covers the whole +six-week grid, including the leading and trailing days of the neighbouring +months, under the locale's first day of the week; in the week view it is the +week, and in the day view the day. An unchanged range is not reported again. + +`ListView` (`@object-ui/plugin-list`) uses it to fetch a calendar view's +visible days instead of one batch of the object (objectui#12081). Like the +other host callbacks, it is forwarded only when the value is a function. + ## Installation ```bash diff --git a/packages/plugin-calendar/src/CalendarView.tsx b/packages/plugin-calendar/src/CalendarView.tsx index 7384bbe436..6a3b5169b1 100644 --- a/packages/plugin-calendar/src/CalendarView.tsx +++ b/packages/plugin-calendar/src/CalendarView.tsx @@ -24,6 +24,7 @@ import { PopoverTrigger } from "@object-ui/components" import { createSafeTranslation, firstDayOfWeek, useDisplayLocale, type WeekdayIndex } from "@object-ui/i18n" +import { getMonthDays, getWeekStart } from "./visibleDays" const DEFAULT_EVENT_COLOR = "bg-blue-100 text-blue-900 border border-blue-200" const STABLE_DEFAULT_DATE = new Date() @@ -452,54 +453,6 @@ function CalendarView({ ) } -/** - * How many days `date` lies after the start of its week, for a week that - * starts on `weekStart` (objectui#11675): 0 on the first day, 6 on the last. - */ -function daysIntoWeek(date: Date, weekStart: WeekdayIndex): number { - return (date.getDay() - weekStart + 7) % 7 -} - -/** `date` moved back, on the local calendar, to the first day of its week. */ -function getWeekStart(date: Date, weekStart: WeekdayIndex): Date { - const d = new Date(date) - d.setDate(d.getDate() - daysIntoWeek(d, weekStart)) - return d -} - -function getMonthDays(date: Date, weekStart: WeekdayIndex): Date[] { - const year = date.getFullYear() - const month = date.getMonth() - const firstDay = new Date(year, month, 1) - const lastDay = new Date(year, month + 1, 0) - // The grid's first row opens on the week's first day, so the days of the - // previous month before the 1st fill the row up to it. - const leadingDays = daysIntoWeek(firstDay, weekStart) - const days: Date[] = [] - - // Add previous month days - for (let i = leadingDays - 1; i >= 0; i--) { - const prevDate = new Date(firstDay.getTime()) - prevDate.setDate(prevDate.getDate() - (i + 1)) - days.push(prevDate) - } - - // Add current month days - for (let i = 1; i <= lastDay.getDate(); i++) { - days.push(new Date(year, month, i)) - } - - // Add next month days - const remainingDays = 42 - days.length - for (let i = 1; i <= remainingDays; i++) { - const nextDate = new Date(lastDay.getTime()) - nextDate.setDate(nextDate.getDate() + i) - days.push(nextDate) - } - - return days -} - function isSameDay(date1: Date, date2: Date): boolean { return ( date1.getFullYear() === date2.getFullYear() && diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index abbb63beb4..0dcedca457 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -22,11 +22,12 @@ * - Works with object/value data providers */ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +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 { usePullToRefresh } from '@object-ui/mobile'; -import { useDisplayLocale } from '@object-ui/i18n'; +import { firstDayOfWeek, useDisplayLocale } from '@object-ui/i18n'; import { useNavigationOverlay, useSafeTranslate, @@ -176,6 +177,20 @@ export interface ObjectCalendarComponentProps { onDelete?: (record: any) => void; onNavigate?: (date: Date) => void; onViewChange?: (view: 'month' | 'week' | 'day') => void; + /** + * The days the calendar draws, reported to a host that fetches its records + * for it (objectui#12081): on mount, and again whenever navigation or a view + * change moves them. `start` is local midnight of the first day on screen and + * `end` local midnight of the day after the last, so the range is half-open. + * + * This is the channel `ListView` windows its calendar fetch through: the + * calendar alone knows which days it draws (the month grid's leading and + * trailing days, the locale's first day of the week, the phone's day view), + * so the host is told rather than left to infer them from `onNavigate` and + * `onViewChange`, neither of which fires for the state the calendar opens + * on. + */ + onVisibleRangeChange?: (range: { start: Date; end: Date }) => void; onEventDrop?: (record: any, newStart: Date, newEnd?: Date) => void; locale?: string; } @@ -370,6 +385,7 @@ export const ObjectCalendar: React.FC = ({ onDateClick, onNavigate, onViewChange, + onVisibleRangeChange, onEventDrop, locale, }) => { @@ -427,6 +443,25 @@ export const ObjectCalendar: React.FC = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMobile]); + + // objectui#12081 — the days on screen, for a host that fetches for this + // calendar. Under the locale's first day of the week, read by the rule + // `CalendarView` reads it with (`dialogLocale` is that rule, above), and + // through the helper the grids draw with, so the reported range is the drawn + // one. Keyed on the two instants, never on an identity (AGENTS.md #10): an + // unchanged range reports nothing. The host callback is read through a ref, + // refreshed after every commit and before the report runs, so a host that + // hands a fresh function each render does not re-report. + const visibleRange = getVisibleDateRange(currentDate, view, firstDayOfWeek(dialogLocale)); + const visibleStartMs = visibleRange.start.getTime(); + const visibleEndMs = visibleRange.end.getTime(); + const onVisibleRangeChangeRef = useRef(onVisibleRangeChange); + useEffect(() => { + onVisibleRangeChangeRef.current = onVisibleRangeChange; + }); + useEffect(() => { + onVisibleRangeChangeRef.current?.({ start: new Date(visibleStartMs), end: new Date(visibleEndMs) }); + }, [visibleStartMs, visibleEndMs]); const [refreshKey, setRefreshKey] = useState(0); // P2: Auto-subscribe to DataSource mutation events (standalone mode only). diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.visibleRange-12081.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.visibleRange-12081.test.tsx new file mode 100644 index 0000000000..2f51889b4b --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.visibleRange-12081.test.tsx @@ -0,0 +1,108 @@ +/** + * 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. + */ + +/** + * objectui#12081 item 2 — `ObjectCalendar` reports the days it draws. + * + * A host that fetches the calendar's records (`ListView`) windows its fetch on + * the calendar's visible days. Only the calendar knows them: the month grid is + * six weeks that open on the locale's first day of the week, so its first and + * last days fall outside the month, and the view (month, week, day) and the + * date move with navigation inside the component. So the calendar reports them + * through `onVisibleRangeChange`: on mount, which `onNavigate` and + * `onViewChange` never cover, and whenever navigation moves them. + * + * Each range below is the six-week grid worked out by hand for October and + * November 2026, under a Sunday-first locale (`en-US`) and a Monday-first one + * (`en-GB`): 1 October 2026 is a Thursday, 1 November a Sunday. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { ObjectCalendar } from '../ObjectCalendar'; + +const NOW = new Date(2026, 9, 14, 10, 0, 0); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +type Range = { start: Date; end: Date }; + +const SCHEMA = { + type: 'object-calendar', + objectName: 'clm_contract', + calendar: { startDateField: 'end_date', titleField: 'name' }, +} as const; + +/** Local midnight of a 2026-based calendar day (`month` 1-based). */ +const day = (year: number, month: number, date: number) => new Date(year, month - 1, date); + +/** A range as two local-midnight instants, for `toEqual`. */ +const spell = (r: Range) => ({ start: r.start.getTime(), end: r.end.getTime() }); + +function mount(props: { locale?: string; defaultView?: 'month' | 'week' | 'day' } = {}) { + const reports: Range[] = []; + const onVisibleRangeChange = (range: Range) => reports.push(range); + const ui = (handler: (range: Range) => void) => ( + + ); + const view = render(ui(onVisibleRangeChange)); + return { reports, rerenderWithFreshHandler: () => view.rerender(ui((range) => reports.push(range))) }; +} + +describe('ObjectCalendar reports the days it draws (objectui#12081)', () => { + it('on mount: the month grid, opening on the locale\'s first day of the week (Sunday)', () => { + const { reports } = mount({ locale: 'en-US' }); + expect(reports).toHaveLength(1); + // Sun 27 Sep … Sat 7 Nov: 4 leading days, 31, then 7 trailing. + expect(spell(reports[0])).toEqual(spell({ start: day(2026, 9, 27), end: day(2026, 11, 8) })); + }); + + it('on mount: the month grid under a Monday-first locale', () => { + const { reports } = mount({ locale: 'en-GB' }); + // Mon 28 Sep … Sun 8 Nov: 3 leading days, 31, then 8 trailing. + expect(spell(reports[reports.length - 1])).toEqual(spell({ start: day(2026, 9, 28), end: day(2026, 11, 9) })); + }); + + it('on navigation: the next month\'s grid', () => { + const { reports } = mount({ locale: 'en-US' }); + fireEvent.click(screen.getByRole('button', { name: 'Next period' })); + // 1 Nov 2026 is a Sunday: no leading days, 30, then 12 trailing. + expect(spell(reports[reports.length - 1])).toEqual(spell({ start: day(2026, 11, 1), end: day(2026, 12, 13) })); + }); + + it('in the week view: the week that holds the date', () => { + const { reports } = mount({ locale: 'en-US', defaultView: 'week' }); + // Wed 14 Oct sits in Sun 11 … Sat 17 Oct. + expect(spell(reports[reports.length - 1])).toEqual(spell({ start: day(2026, 10, 11), end: day(2026, 10, 18) })); + }); + + it('in the day view: the day', () => { + const { reports } = mount({ locale: 'en-US', defaultView: 'day' }); + expect(spell(reports[reports.length - 1])).toEqual(spell({ start: day(2026, 10, 14), end: day(2026, 10, 15) })); + }); + + it('an unchanged range is not reported again, whatever the handler\'s identity', () => { + const { reports, rerenderWithFreshHandler } = mount({ locale: 'en-US' }); + const before = reports.length; + act(() => rerenderWithFreshHandler()); + act(() => rerenderWithFreshHandler()); + expect(reports).toHaveLength(before); + }); +}); diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index 8b1dc2a774..6f50ce0bad 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -148,6 +148,9 @@ const HOST_CALLBACKS = [ 'onDelete', 'onNavigate', 'onViewChange', + // objectui#12081 — the days on screen, reported to the host that fetches for + // the calendar (`ListView` windows its calendar fetch on them). + 'onVisibleRangeChange', 'onEventDrop', ] as const; diff --git a/packages/plugin-calendar/src/visibleDays.ts b/packages/plugin-calendar/src/visibleDays.ts new file mode 100644 index 0000000000..165b3be8ee --- /dev/null +++ b/packages/plugin-calendar/src/visibleDays.ts @@ -0,0 +1,96 @@ +/** + * 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. + */ + +/** + * The days a calendar draws: the week arithmetic `CalendarView`'s three grids + * lay themselves out with, and the visible range read off it. + * + * Its own module (objectui#12081) because two components read it: the grids, + * and `ObjectCalendar`, which reports the visible range to a host that fetches + * for it (`onVisibleRangeChange`). Keeping it out of `CalendarView.tsx` also + * keeps that report alive under a test that replaces the `CalendarView` + * module with a stub. Not re-exported from the package index. + */ + +import type { WeekdayIndex } from "@object-ui/i18n" + +/** + * How many days `date` lies after the start of its week, for a week that + * starts on `weekStart` (objectui#11675): 0 on the first day, 6 on the last. + */ +function daysIntoWeek(date: Date, weekStart: WeekdayIndex): number { + return (date.getDay() - weekStart + 7) % 7 +} + +/** `date` moved back, on the local calendar, to the first day of its week. */ +export function getWeekStart(date: Date, weekStart: WeekdayIndex): Date { + const d = new Date(date) + d.setDate(d.getDate() - daysIntoWeek(d, weekStart)) + return d +} + +export function getMonthDays(date: Date, weekStart: WeekdayIndex): Date[] { + const year = date.getFullYear() + const month = date.getMonth() + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + // The grid's first row opens on the week's first day, so the days of the + // previous month before the 1st fill the row up to it. + const leadingDays = daysIntoWeek(firstDay, weekStart) + const days: Date[] = [] + + // Add previous month days + for (let i = leadingDays - 1; i >= 0; i--) { + const prevDate = new Date(firstDay.getTime()) + prevDate.setDate(prevDate.getDate() - (i + 1)) + days.push(prevDate) + } + + // Add current month days + for (let i = 1; i <= lastDay.getDate(); i++) { + days.push(new Date(year, month, i)) + } + + // Add next month days + const remainingDays = 42 - days.length + for (let i = 1; i <= remainingDays; i++) { + const nextDate = new Date(lastDay.getTime()) + nextDate.setDate(nextDate.getDate() + i) + days.push(nextDate) + } + + return days +} + +/** Local midnight of `date`'s day, moved by `days` calendar days (DST-safe). */ +function localMidnight(date: Date, days = 0): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days) +} + +/** + * The days a calendar showing `date` in `view` draws, as a half-open range of + * local midnights: `start` is the first day on screen and `end` the day after + * the last (objectui#12081). + * + * Read off the SAME helpers the three grids draw with — `getMonthDays` for the + * month grid (its leading and trailing days included), `getWeekStart` for the + * week columns, the day itself for the day column — so the range a host fetches + * for is the range drawn, under the locale's first day of the week. + */ +export function getVisibleDateRange( + date: Date, + view: "month" | "week" | "day", + weekStart: WeekdayIndex, +): { start: Date; end: Date } { + if (view === "month") { + const days = getMonthDays(date, weekStart) + return { start: localMidnight(days[0]), end: localMidnight(days[days.length - 1], 1) } + } + const start = localMidnight(view === "week" ? getWeekStart(date, weekStart) : date) + return { start, end: localMidnight(start, view === "week" ? 7 : 1) } +} diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index bbeb6cbe28..c408da589e 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -256,15 +256,25 @@ the view pages: grid's pager turns it. Its size is the default `@objectstack/spec` declares for `pagination.pageSize`; the list reads it from the spec rather than keeping a number of its own. -- **Every other view does not page** — kanban, calendar, gallery and the rest, - and a grouped grid. The window is one fetch batch of **100** records, and - records past it are not reachable; the record-count bar says so when the - batch comes back full. It is a fetch size, not a page size, so it does not - follow the spec's display default. - -A declared `pagination.pageSize` sizes the window on every view, as before. -With no declared size, switching between the grid view and another view -changes the window, so the list fetches again. +- **Every other view does not page** — kanban, gallery and the rest, and a + grouped grid. The window is one fetch batch of **100** records, and records + past it are not reachable; the record-count bar says so when the batch comes + back full. It is a fetch size, not a page size, so it does not follow the + spec's display default. +- **The calendar view fetches the days it shows.** With a start date bound + (`calendar.startDateField`), the window is the calendar's visible days on + that field (and on `calendar.endDateField` when one is bound, so a span that + runs into the month is fetched), plus the records with no start date, which + the calendar lists as unscheduled. The list walks that window in steps of the + same fetch batch of 100 until it is exhausted, so every record of the month + is drawn, and stops at the platform's non-grid ceiling of 2,000 records with + a note under the calendar naming both numbers. Moving to a month the fetched + window does not cover fetches that month. The calendar reports the days it + draws through its `onVisibleRangeChange` callback (objectui#12081). + +A declared `pagination.pageSize` sizes the window on every view but the +calendar, as before. With no declared size, switching between the grid view and +another view changes the window, so the list fetches again. ## Page binding — `dataSource` (referencing a saved view by name) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 9a536ecae8..db03daecb4 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -15,13 +15,22 @@ import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@obje import { ViewSwitcherDropdown } from './ViewSwitcher'; import { ViewSettingsPopover } from './components/ViewSettingsPopover'; import { UserFilters } from './UserFilters'; -import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; +import { + calendarWindowCovers, + calendarWindowFilter, + calendarWindowFor, + fetchCalendarWindow, + initialCalendarWindow, + type CalendarVisibleRange, + type CalendarWindow, +} from './calendarWindow'; +import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope, useResolvedFilter, NonGridRowCeilingNote } from '@object-ui/react'; import type { LoadErrorKind } from '@object-ui/react'; import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField, isSystemManagedField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort } from '@object-ui/core'; +import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult } from '@object-ui/core'; import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -1081,8 +1090,11 @@ const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); * A fetch batch, ⛔ not a page size (objectui#9853, ruling 5824040487, * structure B): its value is kept, and it does not follow the display default, * so no view silently loses reachable records when the protocol's page size - * moves. A DECLARED `pagination.pageSize` still sizes this fetch, as it always - * has; only the undeclared fallback is split by kind. + * moves. A DECLARED `pagination.pageSize` still sizes this fetch on every + * view but a windowed calendar; only the undeclared fallback is split by kind. + * A calendar with a start date bound walks its visible days in steps of this + * batch whatever page size is declared, so a declared size no longer sizes its + * fetch (`calendarWindowed`, objectui#12081). * * Exported for pins that assert "the fetch batch" rather than its value * (objectui#9853, ruling record 5909000462); the package index does not @@ -1090,6 +1102,22 @@ const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); */ export const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; +/** + * The rows of one `find` answer, in every envelope this view's fetch accepts: a + * bare array, or an object carrying `data`, `records` or `value`. One reader + * for the single window and for the calendar's date-window walk + * (objectui#12081), so the two accept the same answers. + */ +function readListRows(results: unknown): any[] { + if (Array.isArray(results)) return results; + if (results && typeof results === 'object') { + if (Array.isArray((results as any).data)) return (results as any).data; + if (Array.isArray((results as any).records)) return (results as any).records; + if (Array.isArray((results as any).value)) return (results as any).value; + } + return []; +} + /** * What the contract admits as a page size. The spec's view pagination config * declares the member a POSITIVE INTEGER with a default, and the spec's own @@ -2457,6 +2485,82 @@ export const ListView = React.forwardRef(({ !Array.isArray(schema.data) && (schema.data as any)?.provider !== 'value' && !ganttOwnsData; const invalidationNonce = useDataInvalidation(listFetchesForItself ? schema.objectName || undefined : undefined); + /** + * The calendar's date bindings, for the fetch effect's window + * (objectui#12081): the same two reads the calendar branch below makes for + * the node it hands the calendar, so the window is on the field the calendar + * draws by. Declared bindings only (objectui#7029): an undeclared one stays + * absent. ⚠️ Change one pair, change both. + */ + const calendarStartField: string | undefined = + schema.calendar?.startDateField || schema.options?.calendar?.startDateField || undefined; + const calendarEndField: string | undefined = + schema.calendar?.endDateField || schema.options?.calendar?.endDateField || undefined; + + /** + * Does this view fetch a DATE WINDOW for the calendar it hosts? + * (objectui#12081 item 2.) + * + * A hosted calendar draws the rows this component fetched. That fetch used + * to be the one unpaged window every non-grid kind gets — the first + * `DEFAULT_LIST_FETCH_BATCH_SIZE` records of the object, with no date + * condition — so a month holding more than one batch drew whichever records + * came first and never the rest (objectstack-ai/hotclm#87: 122 contracts, up + * to 22 never drawn), with this view's "Showing first 100 records" as the only + * sign. Now the fetch selects the days the calendar draws, on its start field + * (and end field, where one is bound), and walks them in fetch-batch steps up + * to the platform's non-grid ceiling (objectui#7210, ruling a′), whose + * footnote this view draws under the calendar when it bites. ⛔ No bigger + * `$top`: the step stays the batch (objectui#9853). + * + * Only where this component fetches, and only with a start binding: without + * one the calendar draws its refusal (objectui#7029), and there is no field + * to window on. + * + * The window comes from the calendar, which alone knows the days it draws: + * `ObjectCalendar` reports them through `onVisibleRangeChange`. Until it + * does, the window covers any month grid today can open on + * (`initialCalendarWindow`), so the first fetch does not wait for the + * calendar to mount and the calendar's first report falls inside it. + */ + const calendarWindowed = currentView === 'calendar' && listFetchesForItself && !!calendarStartField; + // The window this view fetches for the calendar, and whether its last fetch + // stopped at the ceiling: a truncated window is asked again for a narrower + // range even when it covers it, since the narrower one may fit. + const [heldCalendarWindow, setHeldCalendarWindow] = React.useState<{ window: CalendarWindow; truncated: boolean }>( + () => ({ window: initialCalendarWindow(), truncated: false }), + ); + const calendarWindow = heldCalendarWindow.window; + // The ceiling result of the last window fetch, for the footnote. + const [calendarCeiling, setCalendarCeiling] = React.useState(null); + // The host callback the calendar reports its days through. A `useState` + // initialiser, so its identity is React's guarantee rather than a memo's + // (AGENTS.md #10); it reads only a setter. A range the held window already + // covers moves nothing, so the calendar's first report, a view change inside + // the month or the phone's switch to the day view asks for nothing. + const [handleCalendarVisibleRange] = React.useState(() => (range: CalendarVisibleRange) => { + const next = calendarWindowFor(range); + setHeldCalendarWindow((held) => + calendarWindowCovers(held.window, next) && !held.truncated ? held : { window: next, truncated: false }, + ); + }); + // Leaving the calendar drops its window: the view pane is keyed on + // `currentView`, so a calendar shown again is a new one, opening on today. + // Adjusted during render, when the view changes, rather than in an effect, so + // a calendar shown again never fetches the window the last one was left on. + const [calendarWindowForView, setCalendarWindowForView] = React.useState(currentView); + if (calendarWindowForView !== currentView) { + setCalendarWindowForView(currentView); + if (currentView !== 'calendar') { + setHeldCalendarWindow({ window: initialCalendarWindow(), truncated: false }); + setCalendarCeiling(null); + } + } + // The window as the fetch effect's dependency: a string, compared by value, + // and empty when this view is not windowing, so the window of a calendar + // that is not on screen re-runs nothing. + const calendarWindowKey = calendarWindowed ? `${calendarWindow.from}/${calendarWindow.to}` : ''; + // objectui#10689 — the fetch effect's dep on the three view-level PREDICATE // carriers its projection harvests (objectui#3501), as a CONTENT key over the // operand NAMES alone: the shape `plugin-grid`'s `predicateProjectionKey` @@ -2890,11 +2994,20 @@ export const ListView = React.forwardRef(({ return Array.from(required); })(); + // [objectui#12081] A hosted calendar's fetch is its date window (see + // `calendarWindowed`): the window's node joins the effective filter + // under one `and`, so every view filter, panel filter, user filter and + // search still narrows it. + const calendarWindowNode = calendarWindowed && calendarStartField + ? calendarWindowFilter(calendarWindow, calendarStartField, calendarEndField) + : undefined; + const queryFilter = calendarWindowNode ? mergeFilterNodes(finalFilter, calendarWindowNode) : finalFilter; + // Only send $filter when there is one. Sending an empty array results in // `?filter=%5B%5D` which is wasted bandwidth and can defeat server-side // query parsing/caching. `buildEffectiveFilter` returns a non-empty AST // or `undefined`, so this is the whole test. - const hasFilter = finalFilter !== undefined; + const hasFilter = queryFilter !== undefined; // `fetchSkip` is resolved at render (see its definition) and named in // this effect's dependency list, so what reaches the wire and what @@ -2907,9 +3020,12 @@ export const ListView = React.forwardRef(({ // query — a second literal reconstructed for the consumer would be a // copy free to drift from what was actually asked. const findParams: Record = { - ...(hasFilter ? { $filter: finalFilter } : {}), + ...(hasFilter ? { $filter: queryFilter } : {}), $orderby: sort, - $top: effectivePageSize, + // [objectui#12081] The calendar's window is walked in fetch-batch + // steps, so its first request asks for the batch; the walk sets + // each later step's `$top` and `$skip` itself. + $top: calendarWindowNode ? DEFAULT_LIST_FETCH_BATCH_SIZE : effectivePageSize, ...(skip > 0 ? { $skip: skip } : {}), ...(selectFields ? { $select: selectFields } : {}), ...(expandFields.length > 0 ? { $expand: expandFields } : {}), @@ -2921,25 +3037,41 @@ export const ListView = React.forwardRef(({ } : {}), }; - const results = await dataSource.find(schema.objectName, findParams); - // Stale request guard: only apply the latest request's results - if (!isMounted || requestId !== fetchRequestIdRef.current) return; - - let items: any[] = []; - if (Array.isArray(results)) { - items = results; - } else if (results && typeof results === 'object') { - if (Array.isArray((results as any).data)) { - items = (results as any).data; - } else if (Array.isArray((results as any).records)) { - items = (results as any).records; - } else if (Array.isArray((results as any).value)) { - items = (results as any).value; - } + const isCurrentRequest = () => isMounted && requestId === fetchRequestIdRef.current; + + let results: unknown; + let items: any[]; + // [objectui#12081] The calendar's date window, walked to its end or to + // the platform ceiling (objectui#7210, ruling a′). The walk returns the + // probe row past the ceiling, and `applyNonGridRowCeiling` cuts it off + // and says whether it had to, for the footnote under the calendar. + let windowCeiling: NonGridCeilingResult | null = null; + if (calendarWindowNode) { + const objectName = schema.objectName; + const walked = await fetchCalendarWindow( + (params) => dataSource.find(objectName, params), + findParams, + DEFAULT_LIST_FETCH_BATCH_SIZE, + readListRows, + isCurrentRequest, + ); + if (walked === null) return; + windowCeiling = applyNonGridRowCeiling(walked); + results = walked; + items = windowCeiling.rows; + } else { + results = await dataSource.find(schema.objectName, findParams); + if (!isCurrentRequest()) return; + items = readListRows(results); } - + setData(items); + if (windowCeiling) { + const truncated = windowCeiling.truncated; + setHeldCalendarWindow((held) => (held.truncated === truncated ? held : { ...held, truncated })); + } + setCalendarCeiling(windowCeiling); // Capture the real match total (objectstack-ai/objectstack#2212: findData now returns it). // With a known total the grid pages server-side, so the "showing first N" @@ -2991,7 +3123,10 @@ export const ListView = React.forwardRef(({ // The "…but the real total is known, so nothing is hidden" half of the // old expression moved to the banner's own render gate below, for the // same reason `serverTotal` did (objectui#7394). - setDataLimitReached(items.length >= effectivePageSize); + // A calendar's window is walked to its end, so it hides no reachable + // row behind a batch: when the ceiling stops it, the a′ footnote says + // so instead (objectui#12081). + setDataLimitReached(!calendarWindowNode && items.length >= effectivePageSize); } catch (err) { // Only log + surface errors from the latest request. A failed fetch is // NOT an empty result — record it so the render shows an error panel @@ -3079,13 +3214,19 @@ export const ListView = React.forwardRef(({ // objectui#10689 — `predicateProjectionKey` is the harvested predicate // operands, a string compared by value; see its declaration above. // + // objectui#12081 — `calendarWindowKey` is the hosted calendar's date + // window, a string compared by value and empty on every other view, so it + // re-runs this effect when the calendar moves to days the held window does + // not cover, and on a switch into or out of a windowed calendar — the one + // switch whose query changes — and on nothing else (objectui#7394). + // // ⚠️ The directive below governs the NEXT LINE. Anything written between it // and the dependency array detaches it from the array and turns it into an // unused directive — which `eslint .` reports as an ERROR, and which also // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, ganttOwnsData, invalidationNonce, groupingNeedsHeaderQuery]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change + }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, ganttOwnsData, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -3680,6 +3821,9 @@ export const ListView = React.forwardRef(({ // these two now match it, and the whole branch matches the sibling // faces that never invent (`resolveTimelineDateBinding` above, // app-shell's `calendarViewOptions` / `defaultCalendarFromObject`). + // ⚠️ The fetch windows on these same two reads, made again at component + // scope as `calendarStartField` / `calendarEndField` (objectui#12081): + // change one pair, change both. const startDateField = schema.calendar?.startDateField || schema.options?.calendar?.startDateField; const endDateField = @@ -3725,6 +3869,10 @@ export const ListView = React.forwardRef(({ ...(titleField ? { titleField } : {}), ...(colorField ? { colorField } : {}), ...(allDayField ? { allDayField } : {}), + // objectui#12081 — the days the calendar draws come back here, and + // this view's fetch windows on them (`calendarWindowed`). Handed only + // where this view windows: elsewhere nothing would read the report. + ...(calendarWindowed ? { onVisibleRangeChange: handleCalendarVisibleRange } : {}), }; } case 'gallery': { @@ -4058,7 +4206,7 @@ export const ListView = React.forwardRef(({ // asynchronously (`/me/permissions`) and `objectDef` loads into state, so a // grid schema built before either resolved must be rebuilt when they do — // otherwise `editable` keeps the pre-verdict answer for the session. - }, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch]); + }, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch, calendarWindowed, handleCalendarVisibleRange]); const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0; @@ -5505,7 +5653,11 @@ export const ListView = React.forwardRef(({ )} - ) : loading && data.length === 0 ? ( + ) : loading && data.length === 0 && !calendarWindowed ? ( + // [objectui#12081] Not over a windowed calendar: the calendar holds + // the date it shows, and this panel would unmount it, so a refetch + // after moving to an empty month would reopen it on today. It draws + // its own loading state from the `loading` it is handed below.
(({ // it, so only the grid is handed it. ? { objectFields: objectDef.fields } : {})} - loading={loading} + // [objectui#12081] A windowed calendar keeps drawing while the + // next window loads (the refresh bar above says so), and shows its + // own loading state only when it has nothing to draw. + loading={calendarWindowed ? loading && data.length === 0 : loading} onRowSelect={setSelectedRows} {...(paginate && serverTotal != null ? { @@ -5669,6 +5824,13 @@ export const ListView = React.forwardRef(({ )}
+ {/* objectui#12081 — the hosted calendar's window stopped at the platform + ceiling (objectui#7210, ruling a′): it draws the first N, and this + says so, with both numbers. Under the calendar, as the chart's + footnote sits under the chart (objectui#7148); renders nothing when + the window fit. */} + {calendarWindowed && calendarCeiling && } + {/* Add Record (bottom position) */} {toolbarFlags.showAddRecordBottom && (
@@ -5793,7 +5955,10 @@ export const ListView = React.forwardRef(({ fallback selector for pager-less views (gallery/kanban/calendar). It is the shared `Select` (objectui#11865): a size in force that is not one of the options shows as itself, not as the first one. */} - {currentView !== 'grid' && schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && ( + {/* Not on a windowed calendar either (objectui#12081): its fetch walks + the date window in fetch batches, so a page size changes nothing + it draws. */} + {currentView !== 'grid' && !calendarWindowed && schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (
{t('table.rowsPerPage', { defaultValue: 'Rows per page' })} ; + +/** `count` contracts ending on days of `month` (0-based) in 2026, at most four a day. */ +function contractsIn(month: number, count: number, prefix: string): Row[] { + const days = new Date(2026, month + 1, 0).getDate(); + return Array.from({ length: count }, (_, i) => { + const day = (i % days) + 1; + const mm = String(month + 1).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + return { id: `${prefix}-${i}`, name: `${prefix} ${i}`, end_date: `2026-${mm}-${dd}`, status: 'active' }; + }); +} + +let lastCalendarProps: any = null; +let lastGridProps: any = null; +let lastKanbanProps: any = null; + +const STUBBED = ['object-calendar', 'object-grid', 'object-kanban'] as const; +const previous = new Map(); +beforeAll(() => { + for (const type of STUBBED) { + previous.set(type, ComponentRegistry.get(type)); + ComponentRegistry.register(type, (props: any) => { + if (type === 'object-calendar') lastCalendarProps = props; + if (type === 'object-grid') lastGridProps = props; + if (type === 'object-kanban') lastKanbanProps = props; + return
; + }); + } +}); +afterAll(() => { + for (const type of STUBBED) { + const prev = previous.get(type); + if (prev) ComponentRegistry.register(type, prev as any); + else ComponentRegistry.unregister(type); + } +}); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + lastCalendarProps = null; + lastGridProps = null; + lastKanbanProps = null; +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** A data source over `rows` that records every query it was asked. */ +function makeDataSource(rows: Row[]) { + const store = new ValueDataSource({ items: rows }); + const calls: Array> = []; + return { + calls, + find: vi.fn(async (object: string, params: any) => { + calls.push(params); + return store.find(object, params); + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + name: { type: 'text' }, + end_date: { type: 'date' }, + start_date: { type: 'date' }, + status: { type: 'select', options: [{ label: 'Active', value: 'active' }] }, + }, + }), + } as any; +} + +const CALENDAR_VIEW = { + viewType: 'calendar', + calendar: { startDateField: 'end_date', titleField: 'name' }, +}; + +const renderList = (schemaExtra: Record, ds: any) => + render( + + + , + ); + +/** The ids the calendar was last handed. */ +const handedIds = (): string[] => ((lastCalendarProps?.data ?? []) as Row[]).map((r) => String(r.id)); + +describe('ListView — a hosted calendar is handed every record of its visible window (objectui#12081 item 2)', () => { + it('(a) a visible month holding more than one fetch batch hands the calendar every record in it', async () => { + // 122 in October (the hotclm count), 40 in August listed ahead of them and + // 40 in December after. The month cannot be drawn from one batch, and the + // first batch of the object is mostly August. + const october = contractsIn(9, 122, 'oct'); + const rows = [...contractsIn(7, 40, 'aug'), ...october, ...contractsIn(11, 40, 'dec')]; + const ds = makeDataSource(rows); + renderList(CALENDAR_VIEW, ds); + + await waitFor(() => { + for (const r of october) expect(handedIds()).toContain(r.id); + }); + // Paged in fetch-batch steps: no request asks for more than the batch. + for (const params of ds.calls) expect(params.$top).toBeLessThanOrEqual(FETCH_BATCH); + // …and paged through, not capped: the walk took more than one step. + expect(ds.calls.length).toBeGreaterThan(1); + // Every step carries the window on the field the calendar draws by. + expect(lastCalendarProps.schema.startDateField).toBe('end_date'); + for (const params of ds.calls) expect(JSON.stringify(params.$filter)).toContain('"end_date"'); + // The records of months the grid does not show are not the calendar's: a + // window that was not sent would have handed August's over first. + expect(handedIds().filter((id) => id.startsWith('aug-') || id.startsWith('dec-'))).toEqual([]); + // Nothing this view cannot reach: no "Showing first 100" note. + expect(screen.queryByTestId('data-limit-warning')).toBeNull(); + }); + + it('(b) a reported range the held window does not cover fetches that window', async () => { + const rows = [...contractsIn(9, 30, 'oct'), ...contractsIn(11, 130, 'dec')]; + const ds = makeDataSource(rows); + renderList(CALENDAR_VIEW, ds); + await waitFor(() => expect(handedIds()).toContain('oct-29')); + expect(typeof lastCalendarProps.onVisibleRangeChange).toBe('function'); + + // The calendar moved to December 2026: its month grid, Monday-first. + const before = ds.calls.length; + await act(async () => { + lastCalendarProps.onVisibleRangeChange({ start: new Date(2026, 10, 30), end: new Date(2027, 0, 11) }); + }); + await waitFor(() => { + for (let i = 0; i < 130; i++) expect(handedIds()).toContain(`dec-${i}`); + }); + expect(ds.calls.length).toBeGreaterThan(before); + expect(handedIds().filter((id) => id.startsWith('oct-'))).toEqual([]); + }); + + it('(b) a reported range the held window covers asks for nothing', async () => { + const ds = makeDataSource(contractsIn(9, 30, 'oct')); + renderList(CALENDAR_VIEW, ds); + await waitFor(() => expect(handedIds()).toContain('oct-29')); + const before = ds.calls.length; + + // The calendar's own month grid, then a week inside it: both inside the + // window the first fetch covered. + await act(async () => { + lastCalendarProps.onVisibleRangeChange({ start: new Date(2026, 8, 28), end: new Date(2026, 10, 9) }); + }); + await act(async () => { + lastCalendarProps.onVisibleRangeChange({ start: new Date(2026, 9, 12), end: new Date(2026, 9, 19) }); + }); + expect(ds.calls.length).toBe(before); + }); + + it('(c) a window over the platform ceiling hands the calendar N rows and says N of M under it', async () => { + const total = NON_GRID_ROW_CEILING + 50; + const ds = makeDataSource(contractsIn(9, total, 'oct')); + renderList(CALENDAR_VIEW, ds); + + await waitFor(() => expect(handedIds()).toHaveLength(NON_GRID_ROW_CEILING)); + const note = await screen.findByRole('note'); + expect(note).toHaveAttribute('data-row-ceiling-note', 'non-grid'); + expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); + expect(note.textContent).toContain(String(total)); + // The walk never asked past one probe row beyond the ceiling. + const asked = ds.calls.reduce((sum: number, p: any) => sum + p.$top, 0); + expect(asked).toBe(NON_GRID_ROW_CEILING + 1); + for (const params of ds.calls) expect(params.$top).toBeLessThanOrEqual(FETCH_BATCH); + }); + + it('a window that fits draws no ceiling note', async () => { + const ds = makeDataSource(contractsIn(9, 122, 'oct')); + renderList(CALENDAR_VIEW, ds); + await waitFor(() => expect(handedIds()).toHaveLength(122)); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('the records with no start date ride along, for the calendar\'s unscheduled area', async () => { + const undated = [{ id: 'undated-1', name: 'Evergreen', end_date: null, status: 'active' }]; + const ds = makeDataSource([...contractsIn(9, 10, 'oct'), ...contractsIn(3, 10, 'apr'), ...undated]); + renderList(CALENDAR_VIEW, ds); + await waitFor(() => expect(handedIds()).toContain('oct-9')); + expect(handedIds()).toContain('undated-1'); + expect(handedIds().filter((id) => id.startsWith('apr-'))).toEqual([]); + }); + + it('with an end field bound, a span that opens before the window and runs into it is fetched', async () => { + const spans = [ + { id: 'into', name: 'Into October', start_date: '2026-08-20', end_date: '2026-10-05', status: 'active' }, + { id: 'before', name: 'All in August', start_date: '2026-08-01', end_date: '2026-08-10', status: 'active' }, + ]; + const ds = makeDataSource(spans); + renderList({ viewType: 'calendar', calendar: { startDateField: 'start_date', endDateField: 'end_date' } }, ds); + await waitFor(() => expect(handedIds()).toContain('into')); + expect(handedIds()).not.toContain('before'); + }); + + it('the view\'s own filter still narrows the window', async () => { + const rows = [ + ...contractsIn(9, 5, 'active'), + ...contractsIn(9, 5, 'closed').map((r) => ({ ...r, status: 'closed' })), + ]; + const ds = makeDataSource(rows); + renderList({ ...CALENDAR_VIEW, filter: [['status', '=', 'active']] }, ds); + await waitFor(() => expect(handedIds()).toContain('active-4')); + expect(handedIds().filter((id) => id.startsWith('closed-'))).toEqual([]); + }); +}); + +describe('CONTROL — every other view asks exactly what it asked before (objectui#12081)', () => { + /** The display default the paged grid reads from the protocol. */ + const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; + + it('the paged grid: one page, its `$top`, no window, and the next page by `$skip`', async () => { + const ds = makeDataSource(contractsIn(9, 122, 'oct')); + renderList({ viewType: 'grid' }, ds); + await waitFor(() => expect(lastGridProps?.manualPagination).toBe(true)); + expect(ds.calls).toEqual([ + { $orderby: undefined, $top: SPEC_DISPLAY_DEFAULT, $select: ['id', 'name', 'status'] }, + ]); + expect(typeof lastGridProps.onVisibleRangeChange).toBe('undefined'); + + await act(async () => { lastGridProps.onPageChange(2); }); + await waitFor(() => expect(ds.calls).toHaveLength(2)); + expect(ds.calls[1]).toEqual({ + $orderby: undefined, + $top: SPEC_DISPLAY_DEFAULT, + $skip: SPEC_DISPLAY_DEFAULT, + $select: ['id', 'name', 'status'], + }); + }); + + it('the board: still one fetch batch, no window', async () => { + const ds = makeDataSource(contractsIn(9, 122, 'oct')); + renderList({ viewType: 'kanban', kanban: { groupByField: 'status' } }, ds); + await waitFor(() => expect(lastKanbanProps?.data).toHaveLength(FETCH_BATCH)); + expect(ds.calls).toHaveLength(1); + expect(ds.calls[0].$top).toBe(FETCH_BATCH); + expect(ds.calls[0].$filter).toBeUndefined(); + expect(ds.calls[0].$skip).toBeUndefined(); + }); + + it('a calendar with no start binding is not windowed: it keeps the one batch and draws its refusal', async () => { + const ds = makeDataSource(contractsIn(9, 122, 'oct')); + renderList({ viewType: 'calendar' }, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await waitFor(() => expect(lastCalendarProps).not.toBeNull()); + expect(ds.calls).toHaveLength(1); + expect(ds.calls[0].$top).toBe(FETCH_BATCH); + expect(ds.calls[0].$filter).toBeUndefined(); + expect(lastCalendarProps.onVisibleRangeChange).toBeUndefined(); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx b/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx index b9d5f5ce38..75ae446b03 100644 --- a/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx @@ -24,8 +24,11 @@ * window) keeps its fetch batch, value 100, so no view silently loses * reachable records when the page size moves. * - * A declared `pagination.pageSize` still sizes the window on every view, as it - * always has (the CONTROL rows). The cost the split carries is pinned too: with + * A declared `pagination.pageSize` still sizes the window on every view but a + * windowed calendar, as it always has (the CONTROL rows). A calendar with a + * start date bound walks its visible days in steps of the fetch batch whatever + * page size is declared (objectui#12081; its row below asserts the walk). The + * cost the split carries is pinned too: with * no declared size, switching between the paged grid and an unpaged view moves * the window, so the fetch is re-issued (objectui#7394 pins that a DECLARED * size keeps one window across the same switch). @@ -149,7 +152,6 @@ const pageSizeWarnings = () => warnings.filter((w) => w.includes('ListView pagin const UNPAGED_VIEWS: Array<[string, Record]> = [ ['kanban', { viewType: 'kanban', kanban: { groupByField: 'status' } }], ['gallery', { viewType: 'gallery' }], - ['calendar', { viewType: 'calendar', calendar: { startDateField: 'due' } }], ['timeline', { viewType: 'timeline', timeline: { startDateField: 'due' } }], ['grouped grid', { viewType: 'grid', grouping: { fields: [{ field: 'status' }] } }], ]; @@ -179,6 +181,20 @@ describe('ListView — the paged grid reads the spec display default, every unpa expect(tops(ds)).not.toContain(SPEC_DISPLAY_DEFAULT); }); + it('the unpaged calendar walks its date window in fetch-batch steps, never the page size (objectui#12081)', async () => { + // Since objectui#12081 a calendar's fetch is the date window it draws, + // walked to its end rather than cut at one batch. The step is still the + // batch this ruling keeps: every request asks for it, none for the page + // size. This double ignores the window and pages the whole object, so the + // walk is one step per batch of TOTAL. + const ds = makeDataSource(); + renderList({ viewType: 'calendar', calendar: { startDateField: 'due' } }, ds); + + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(Math.ceil(TOTAL / FETCH_BATCH))); + expect(new Set(tops(ds))).toEqual(new Set([FETCH_BATCH])); + expect(tops(ds)).not.toContain(SPEC_DISPLAY_DEFAULT); + }); + it('CONTROL — a declared page size still sizes the window on a paged and an unpaged view', async () => { const paged = makeDataSource(); renderList({ viewType: 'grid', pagination: { pageSize: 5 } }, paged); diff --git a/packages/plugin-list/src/calendarWindow.ts b/packages/plugin-list/src/calendarWindow.ts new file mode 100644 index 0000000000..53d004bf32 --- /dev/null +++ b/packages/plugin-list/src/calendarWindow.ts @@ -0,0 +1,184 @@ +/** + * 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. + */ + +/** + * The date window a list view fetches for the calendar it hosts + * (objectui#12081 item 2). + * + * A calendar hosted by `ListView` draws the rows of ListView's own fetch. That + * fetch used to be the one unpaged window every non-grid kind gets — the first + * `DEFAULT_LIST_FETCH_BATCH_SIZE` (100) records of the object, with no date + * condition — so a month holding more than 100 matching records was drawn from + * whichever 100 came first, and the rest never appeared (measured on + * objectstack-ai/hotclm#87: 122 contracts, up to 22 never drawn). Triage's + * direction for the card, verbatim: "Fetch the visible date window, filtered on + * the calendar's date field, and page through it. ⛔ No bigger cap." + * + * So the calendar's fetch is narrowed to the days it draws, and walked in + * fetch-batch steps until the window is exhausted or the platform's non-grid + * ceiling is reached (objectui#7210, ruling a′, whose footnote ListView draws + * when it bites). This module holds the pure half of that: the window, the + * filter node it becomes, and the walk. Not exported from the package index. + */ + +import { NON_GRID_ROW_CEILING } from '@object-ui/core'; + +/** + * A fetch window over local calendar days, spelled `YYYY-MM-DD`: `from` is the + * first day asked for and `to` the day after the last, so the window is + * half-open. Held as two strings so it is compared, stored and keyed by VALUE. + */ +export interface CalendarWindow { + from: string; + to: string; +} + +/** The range a calendar reports: local midnights, `end` exclusive. */ +export interface CalendarVisibleRange { + start: Date; + end: Date; +} + +/** `date` as the local calendar day it falls on, `YYYY-MM-DD`. */ +function localDay(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +/** `date` moved by `days` days on the local calendar (DST-safe: `setDate`). */ +function moveDays(date: Date, days: number): Date { + const moved = new Date(date); + moved.setDate(moved.getDate() + days); + return moved; +} + +/** + * The window to fetch for a reported range: the drawn days, widened by ONE day + * on each side. + * + * The widening is what makes a day-bounded comparison safe on every stored + * shape. A date-time is stored as a UTC instant, and the calendar places it on + * the LOCAL day it falls on; the two differ by up to fourteen hours, so an event + * at 01:00 on the first visible day in UTC+8 is stored on the previous UTC day + * and would fall outside an unwidened `from`. One day covers every offset, and a + * date-only value (`YYYY-MM-DD`, compared as written) is covered either way. The + * extra day on each side is fetched and not drawn: the grids place an event by + * its day, so a row outside the drawn days costs a row of the batch and nothing + * on screen. + */ +export function calendarWindowFor(range: CalendarVisibleRange): CalendarWindow { + return { from: localDay(moveDays(range.start, -1)), to: localDay(moveDays(range.end, 1)) }; +} + +/** + * The window to fetch before the calendar has reported any range: one that + * covers whatever month grid it can open on today. + * + * The hosted calendar opens on today, in the month view (`ListView` hands it no + * date and no `defaultView`). Its month grid is six weeks that open on the + * locale's first day of the week, so it starts at most six days before the 1st + * and ends at most fourteen days after the last day of the month (a 28-day + * month that opens on the week's first day). Covering that bound means this + * window holds every day of the grid whatever the locale, so the calendar's + * first report, which is the exact grid, falls inside it and asks for nothing + * more. It also means a list view always has a window to fetch: one whose + * calendar never reports a range (a host's own `object-calendar` renderer) + * still draws today's month instead of nothing. + */ +export function initialCalendarWindow(now: Date = new Date()): CalendarWindow { + const first = new Date(now.getFullYear(), now.getMonth(), 1); + const firstOfNext = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return calendarWindowFor({ start: moveDays(first, -6), end: moveDays(firstOfNext, 14) }); +} + +/** Does `outer` hold every day of `inner`? (`YYYY-MM-DD` compares as written.) */ +export function calendarWindowCovers(outer: CalendarWindow, inner: CalendarWindow): boolean { + return outer.from <= inner.from && inner.to <= outer.to; +} + +/** + * The filter node that selects a window's records on the calendar's date + * field(s), plus the records that have no start date at all. + * + * - Without an end field, an event is a day: its start falls in the window. + * - With one, an event is a span, and it is drawn on every day it touches, so + * it is selected when it starts before the window ends AND either starts or + * ends inside the window or after it opens. A span with no end is a day + * again, which the `start >= from` arm covers. + * - ⛔ The records with NO start date are selected too. They are not on the + * grid: `ObjectCalendar` counts them under the grid in its "Unscheduled" + * area, which exists so that their absence from the grid is said rather + * than silent (`bc5870c9f`). A window on the start field alone would drop + * every one of them, and the area with them. + * + * Built in the filter AST the rest of this package sends (`mergeFilterNodes` + * joins it to the view's own filter under one `and`): `and` / `or` heads and + * `[field, operator, value]` triples, `is_null` as a 2-tuple. The bounds are + * `YYYY-MM-DD` strings, the spelling the date macros resolve to. + */ +export function calendarWindowFilter( + window: CalendarWindow, + startField: string, + endField?: string, +): unknown[] { + const inWindow = endField + ? ['and', [startField, '<', window.to], ['or', [startField, '>=', window.from], [endField, '>=', window.from]]] + : ['and', [startField, '>=', window.from], [startField, '<', window.to]]; + return ['or', inWindow, [startField, 'is_null']]; +} + +/** What {@link fetchCalendarWindow} brings back. */ +export interface CalendarWindowRows { + /** + * The window's records, at most {@link NON_GRID_ROW_CEILING} + 1: the walk + * stops one row past the ceiling, the probe row `applyNonGridRowCeiling` + * reads truncation from. + */ + data: any[]; + /** The window's size when the adapter reported one (`QueryResult.total`). */ + total?: number; +} + +/** + * Walk a window in fetch-batch steps: `$top` is the batch (or the rows still + * wanted, at the end), `$skip` moves by what came back, and the walk stops at + * the first short batch or one row past the ceiling. + * + * ⛔ The step is the batch, never a bigger `$top`: the batch is the unit the + * protocol is asked in (objectui#9853), and the ceiling is a stop condition, + * not a request size. + * + * `extract` reads the rows out of one answer: the caller's own reader, so the + * walk accepts exactly the envelopes the caller's single fetch accepts. + * `isCurrent` is asked after every answer: when it says no, a newer request + * owns the screen and the walk returns `null` without asking for more. + */ +export async function fetchCalendarWindow( + find: (params: Record) => Promise, + params: Record, + batch: number, + extract: (result: unknown) => any[], + isCurrent: () => boolean, +): Promise { + const wanted = NON_GRID_ROW_CEILING + 1; + const data: any[] = []; + let total: number | undefined; + while (data.length < wanted) { + const top = Math.min(batch, wanted - data.length); + const result = await find({ ...params, $top: top, ...(data.length > 0 ? { $skip: data.length } : {}) }); + if (!isCurrent()) return null; + const rows = extract(result); + const reported = result && typeof result === 'object' ? (result as { total?: unknown }).total : undefined; + if (typeof reported === 'number') total = reported; + data.push(...rows); + if (rows.length < top) break; + } + return { data, ...(total !== undefined ? { total } : {}) }; +}