Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/12081-calendar-date-window.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion content/docs/plugins/plugin-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions packages/app-shell/src/__tests__/listCalendarDateWindow-12081.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** `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<Row>({ items: rows });
const calls: Array<Record<string, any>> = [];
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(
<SchemaRendererProvider dataSource={ds}>
<ActionProvider>
<ListView
schema={{
type: 'list-view',
objectName: 'clm_contract',
viewType: 'calendar',
columns: ['name'],
calendar: { startDateField: 'end_date', titleField: 'name' },
} as any}
dataSource={ds}
/>
</ActionProvider>
</SchemaRendererProvider>,
);
}

/** 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();
});
});
16 changes: 16 additions & 0 deletions packages/plugin-calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 1 addition & 48 deletions packages/plugin-calendar/src/CalendarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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() &&
Expand Down
39 changes: 37 additions & 2 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -370,6 +385,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
onDateClick,
onNavigate,
onViewChange,
onVisibleRangeChange,
onEventDrop,
locale,
}) => {
Expand Down Expand Up @@ -427,6 +443,25 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
}
// 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).
Expand Down
Loading
Loading