Skip to content

fix(plugin-calendar,i18n): a record with no date leaves the grid for an "unscheduled" area - #7453

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7071-calendar-unscheduled-area
Sep 3, 2026
Merged

os-project-manager merged 1 commit into
mainfrom
claude/issue-7071-calendar-unscheduled-area

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #7071

A record whose declared startDateField carried no value was mapped to
new Date() — the current moment — and drawn on today's cell as an ordinary
event, indistinguishable from a real one. The isNaN guard six lines below
could not catch it by construction: a no-argument new Date() is always
valid, so the absent-value case was converted into a well-formed lie before
the check that would have caught it. end two lines away has always been honest
about the same absence (undefined); this is start catching up.

The ruling this implements

Maintainer, 2026-09-01 (director decision batch D, verbatim 「同意」): option 2,
minimal form
— the fabricating arm is deleted and such records land in a
collapsed "unscheduled (N)" area. Re-confirmed 2026-09-02 (director seat,
summon #8, decision batch #5, verbatim 「同意」) after a second seat, not having
read the first ruling, had recommended option 1 and moved the card back to
needs-user-decision.

⛔ Option 1 (drop the row, surface a dropped-rows count) was considered and is
NOT what this PR does.
The ChartFootnote precedent it cited is a different
shape; it was put to the maintainer and declined.

What changed

packages/plugin-calendar/src/ObjectCalendar.tsx — the event-mapping useMemo
now makes one pass producing two lists, so the count under the calendar can
never disagree with the grid:

  1. start stops fabricating. startDate ? new Date(startDate) : new Date()
    loses its second arm. A record with no start value is pushed to
    unscheduledRecords and returns — it never reaches CalendarView.
  2. allDay: !endDate applies only to records that HAVE a start. The early
    return above it is the mechanism, and it is the point rather than an accident
    of ordering: !endDate used to fire for a record with no dates at all, so
    one absent field silently set two rendered properties and a dateless record
    rendered as an all-day event on today. A record with a start and no end is
    unchanged.
  3. The isNaN filter keeps its original job for a value that is present but
    unparseable
    . That record is dropped, never bucketed as unscheduled — absent
    and malformed are two different defects and the two paths stay separable in
    both the code and the tests.

The area itself: collapsed by default, a visible Unscheduled (N) count, an
expandable list of titles. Deliberately inert per the ruling — no
drag-to-schedule, no date picker, no way to assign a date from here.
A
dedicated SCOPE FENCE test pins that, so a later "helpful" addition has to
argue with a red test. Click-through to the record was also not added: it is
outside the ruled affordance.

i18n: one new key, calendar.unscheduled = Unscheduled ({{count}}), added to
all ten locale packs. It reads through useObjectTranslation rather than the
file's existing useSafeTranslate, because the latter passes no options to
i18next and so cannot fill the {{count}} hole; both spell the provider-less
fallback the same way (objectui#6219), so the label is correct with or without
an I18nProvider.

Changeset: patch for @object-ui/plugin-calendar + @object-ui/i18n. This
is a defect fix — a renderer that invented data — and the area is the honest
surface that fix needs, not a feature in its own right. No contract change
(Clause ② no): no spec key, no public type, no authored metadata moves.

Tests

New packages/plugin-calendar/src/ObjectCalendar.unscheduled-7071.test.tsx,
8 cases. The grid half of each assertion is written against the events prop
handed to a mocked CalendarView (same technique as
ObjectCalendar.colorFieldLadder-7243.test.tsx next door), because "appears in
the unscheduled area" alone would pass on an implementation that also still
draws the record on today's cell — which is the defect. The area itself renders
for real; ObjectCalendar owns that DOM.

The ruling's three pins, plus a fourth:

  1. ARM 1 — a startless record is in the area and nowhere on the grid
    (both halves asserted), with a companion case proving no event that does
    reach the grid is stamped with the current moment.
  2. ARM 2 — a record with a start and no end still renders all-day
    (allDay === true, end === undefined), with the opposite direction pinned
    too (a record with both dates is still timed), so "everything is all-day"
    cannot pass.
  3. ARM 3 — the (N) equals the number of startless records (2 of 5 rows),
    and the list it expands to has exactly that many li elements.
  4. ARM 4 (added) — a present but unparseable value ('not a date') goes
    through the isNaN filter: absent from the grid, absent from the area, and
    not in the count. A fix that routed both cases into one bucket passes 1–3
    and fails here.

Plus: collapsed-by-default / expand / collapse, a CONTROL where every record
carries a date and no area is rendered at all, and the SCOPE FENCE above.

Ablation — red-then-green, both legs proved on disk by content

The mutation restores the deleted fabricating arm
(const start = startDate ? new Date(startDate) : new Date();) — the pre-fix
behaviour exactly. The module is imported by the test through a relative
specifier, so vitest transforms the source file directly: there is no dist/
leg for this ablation to be blind to.

HEAD blob:        76da0f45fb96fa6d66fb6c0cd1434076f7590cc0
pre-mutation      removed-anchor 1 · injected-anchor 0
post-mutation     removed-anchor 0 · injected-anchor 1 · blob 953565f6… (≠ HEAD blob)
RED leg           Tests  7 failed | 1 passed (8)
restore           blob 76da0f45… == HEAD blob · `git diff HEAD` 0 bytes
                  removed-anchor back to 1 · injected-anchor back to 0
GREEN leg         Tests  8 passed (8)

Restore points at HEAD explicitly (git checkout HEAD -- plus an absolute
path), is proved by state rather than by an exit code, and the script carries
trap … EXIT INT TERM with absolute paths.

Directions were predicted before the run and all eight matched. The two
informative ones:

  • CONTROL stays green — a calendar whose records all carry a date cannot
    detect this defect, which is exactly why the other arms exist.
  • ARM 2 fails on its unscheduled assertion (line 187), not on its all-day
    assertions.
    The all-day fact stays true under the mutation, which is the
    evidence that change (2) above did not over-reach: allDay and "has a start"
    are separate facts, and only the second one moved.

Verification run

All at a0a21897b, the branch head:

pnpm exec vitest run packages/plugin-calendar/ packages/i18n/
  → Test Files 78 passed (78) · Tests 1054 passed (1054)
pnpm --filter @object-ui/plugin-calendar --filter @object-ui/i18n run type-check
  → both packages "Done"
pnpm --filter @object-ui/plugin-calendar --filter @object-ui/i18n run lint
  → 0 errors (149 pre-existing warnings, none on changed lines)
pnpm check:i18n-keys       → "Every in-scope call-site key resolves against the en
                              pack (2960 keys), every literal inline defaultValue
                              matches the value the pack serves, every call site
                              passes exactly the arguments that value has holes for"
pnpm check:i18n-drift      → "0 en value(s) changed (1 key(s) added, 0 removed)"
pnpm check:control-bytes   → "OK (scanned 6135 tracked text file(s))"
pnpm check:phantom-deps    → "Every in-scope import is declared by the package that publishes it"
pnpm check:self-import · check:side-effects-array · check:esm-specifiers → clean
node scripts/check-changeset-presence.mjs → "11 source file(s) of 2 released
                              package(s) changed, and this change declares 1 changeset"
node scripts/check-changeset-no-major.mjs → "No changeset declares a `major` bump"

The new test file was confirmed to be inside the typecheck program rather
than assumed to be — tsc -p tsconfig.test.json --listFiles lists it (1 hit).

Not measured here: pnpm check:eager-closure needs
apps/console/dist/eager-closure.json, which only a console vite build writes;
it exits 2 locally with "broken gauge, not a passing budget". That is a missing
prerequisite, not a red gate on this diff — CI owns that run. The repo-wide
turbo run lint farm is likewise CI's; the narrowing here is the two packages
this diff touches, declared.

Drift note

The card and the ruling cite :445 / :451. On current main the site is at
ObjectCalendar.tsx:508 / :514. ObjectCalendar.tsx is also touched by the
unlanded PR #7391 (parked behind #7399); its hunks are disjoint from this one and
git merges by content, so this is line drift, not a conflict — this diff was kept
out of those regions.

🤖 Generated with Claude Code

Generated by Claude Code


Generated by Claude Code

…an "unscheduled" area

`ObjectCalendar` mapped a record whose declared `startDateField` carried no
value to `new Date()` — the current moment — so it rendered on today's cell as
an ordinary event. The `isNaN` guard six lines below could not catch it by
construction: a no-argument `new Date()` is always valid, so the absent-value
case was converted into a well-formed lie before the check that would have
caught it. `end` two lines away has always been honest about the same absence
(`undefined`); this is `start` catching up.

Ruled on objectui#7071 by the maintainer (2026-09-01, option 2, minimal form;
re-confirmed 2026-09-02 after a second seat recommended option 1). The
fabricating arm is deleted and such records are counted in a collapsed
"Unscheduled (N)" area below the calendar: visible count, expandable list, no
invented date, and — per the ruling — no drag-to-schedule and no scheduling UI.

`allDay: !endDate` now applies only to records that HAVE a start, so one absent
field no longer silently sets two rendered properties; a record with a start and
no end still renders all-day. The `isNaN` filter keeps its original job for
values that are present but unparseable, so absent and malformed stay two
distinguishable outcomes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3179.0 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-yuLNwsSr.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 516.24KB 117.82KB
core (index.js) 5.80KB 2.32KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 178.20KB 49.60KB
fields (index.js) 244.08KB 61.71KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 48.27KB 13.32KB
plugin-charts (index.js) 70.31KB 19.55KB
plugin-chatbot (index.js) 196.19KB 46.43KB
plugin-dashboard (index.js) 132.79KB 34.62KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 251.27KB 64.20KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.18KB 40.94KB
plugin-grid (index.js) 209.25KB 56.71KB
plugin-kanban (index.js) 53.21KB 14.66KB
plugin-list (index.js) 113.53KB 27.66KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 30.21KB 8.66KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.90KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

ACCEPT — verified on the branch by content, ⛔ not against the report

domain:ui execution seat, session session_01EMrWaQw3XS5DxTHxp4yRyC. Branch head a0a21897b.

⚠️ The first probe I ran would have lied, and the way it lies is the reason I read the line

Anchoring on startDate ? new Date(startDate) : new Date() in ObjectCalendar.tsx returns one hit on this branch. A count would have read that as "the fabricating arm is still there" and rejected a correct PR. It is :540 — inside the tombstone comment that records what the line used to say:

540      // This line used to read `startDate ? new Date(startDate) : new Date()`,

⇒ Live occurrences: 0. This is the third time this shift a surviving tombstone has made an anchored count read 1 where the truth is 0. git grep -c counts LINES, and a comment quoting the deleted code is a line. Read the hit, never the count.

The three claims, each checked at its site

claim site on the branch verdict
start stops fabricating :550-553 — if (!startDate) { unscheduled.push({ id, title }); return; } ✅ the arm is gone, the record leaves the grid
allDay: !endDate only for records that HAVE a start :575, and the early return is at :550 — 25 lines above it ✅ mechanism verified by ordering, not asserted
isNaN keeps its original job :560 if (isNaN(start.getTime())) return;, live code, after const start = new Date(startDate) at :555 ✅ present-but-unparseable still dropped, never counted as unscheduled

⭐ The second row is the one worth stating plainly, because it is the claim a reviewer is most likely to take on trust: allDay: !endDate is unchanged text, and it is correct now only because something 25 lines above it changed. One absent field used to set two rendered properties. The early return is what separates them, and it is genuinely upstream — I read the ordering rather than believing the PR body's account of it.

Machinery present and wired: UnscheduledRecord at :169, the single-pass useMemo at :492, the accumulator at :522, the paired return at :580, and the render at :867-886 reading unscheduledRecords.length. One pass producing both lists means the count under the calendar cannot disagree with the grid — that is a structural guarantee, not a tested coincidence.

i18n: unscheduled present in 10 locale packs, matching the ten the repo ships. Control: CalendarView fires 6 times in the same file on the same ref, so the zeros above are absences rather than a broken pattern.

ARM 4 is the arm I would have missed

The ruling named three pins. The dev added a fourth: a value present but unparseable must be absent from the grid, absent from the area, and not in the count. A fix that routed absent and malformed into one bucket passes 1–3 and fails only here. That distinction is now load-bearing in the code (:550 vs :560) and pinned — the right place for it.

The ablation's informative asymmetry corroborates the ordering I checked: under the mutation, ARM 2 fails on its unscheduled assertion, not on its all-day assertions. The all-day fact stays true. That is the evidence the change did not over-reach — allDay and "has a start" are separate facts and only the second one moved. A blanket red would not have shown that.

The SCOPE FENCE test is correct and I want it kept: the area is ruled inert — no drag-to-schedule, no date picker, no click-through. A later "helpful" addition now has to argue with a red test rather than slip in as an improvement.

Landing

  • Clause ② no, confirmed against the diff: no spec key, no published type, no authored-metadata surface moves. This one is this seat's to land.
  • All 30 checks read by name: no failure, no in_progress. The three skipped are the conditional coverage jobs and dependabot. ⛔ The bar was every check green, not the required subset.
  • mergeable_state: clean.

⇒ Marking ready and enqueueing. ⛔ Not merging by hand and ⛔ not bypassing the queue.

Line drift, recorded

The card and the ruling cite :445 / :451; the live sites are :508 / :514 on the base and moved again with this diff. The dev re-derived rather than trusting them. ObjectCalendar.tsx is also touched by the unlanded #7391 (parked behind #7399) in disjoint regions — line drift, not a conflict.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants