Calendar: items crossing midnight are clipped and missing from the next day (split into day segments) #469

Closed
opened 2026-09-29 19:00:25 +00:00 by kayg · 11 comments
Owner

Bug (owner, 2026-09-29 night, three screenshots, calternal.cloud)

A Log entry "Updating codex to get 6.1 sol as the subagent model", 23:58 – 00:30 (Tuesday 29 → Wednesday 30):

  1. In Week view on the 29th, the block is clipped at the bottom of the day (it starts at 23:58, so only a sliver is inside the day, and the title is cut off, unreadable).
  2. It does not appear on Wednesday the 30th at all, although it runs until 00:30. The 00:00–00:30 part is missing.
    Also seen in the same screenshot: an event "Attack on Titan 22:39 – 23:57" is cut off near the day's bottom edge (its title is hidden under the next block).

Expected (Apple Calendar and Fantastical behaviour)

  • An item that crosses midnight renders as two segments: the 29th from 23:58 to 24:00, and the 30th from 00:00 to 00:30. The segments share identity (selection, hover, preview and drag act on the one item). The continuing segment shows a subtle "continues" cue (squared corners on the cut edge, like Apple), and the second segment carries the title again.
  • A segment shorter than one text line uses the #384 one-line minimum without overflowing the day's bottom edge: when it would cross the day boundary, it grows upwards inside the day, and never draws past the grid bottom.
  • The same applies in the Day, Week, 3-day and Month views (Month: it shows on both days) and in the Agenda (on both days, with "continues" and "from yesterday" markers), and to events and tasks with times, not only Log entries.
  • Data: the Log line stays in the day it started (the Journal format is one line per entry in the start day's daily note). Only the rendering splits. The CalDAV projection (#356) must emit a correct DTSTART/DTEND across midnight: verify it.
  • Drag and resize across midnight: dragging the end below 24:00 extends into the next day (shown live, #418 labels "23:58 – 00:30").

Proof

Unit tests for splitting (midnight, DST changes, multi-day spans, 23:59–00:00), a production e2e (create 23:58–00:30, and see both segments in Day, Week, Month and Agenda), and screenshots at 390, 820 and 1440 px, light and dark. Web gates, plus per-crate gates if you touch Rust.

## Bug (owner, 2026-09-29 night, three screenshots, calternal.cloud) A Log entry "Updating codex to get 6.1 sol as the subagent model", **23:58 – 00:30** (Tuesday 29 → Wednesday 30): 1. In Week view on the 29th, the block is **clipped at the bottom** of the day (it starts at 23:58, so only a sliver is inside the day, and the title is cut off, unreadable). 2. It **does not appear on Wednesday the 30th at all**, although it runs until 00:30. The 00:00–00:30 part is missing. Also seen in the same screenshot: an event "Attack on Titan 22:39 – 23:57" is cut off near the day's bottom edge (its title is hidden under the next block). ## Expected (Apple Calendar and Fantastical behaviour) - An item that crosses midnight renders as **two segments**: the 29th from 23:58 to 24:00, and the 30th from 00:00 to 00:30. The segments share identity (selection, hover, preview and drag act on the one item). The continuing segment shows a subtle "continues" cue (squared corners on the cut edge, like Apple), and the second segment carries the title again. - A segment shorter than one text line uses the #384 one-line minimum without overflowing the day's bottom edge: when it would cross the day boundary, it grows **upwards** inside the day, and never draws past the grid bottom. - The same applies in the Day, Week, 3-day and Month views (Month: it shows on both days) and in the Agenda (on both days, with "continues" and "from yesterday" markers), and to events and tasks with times, not only Log entries. - **Data:** the Log line stays in the day it started (the Journal format is one line per entry in the start day's daily note). Only the rendering splits. The CalDAV projection (#356) must emit a correct DTSTART/DTEND across midnight: verify it. - **Drag and resize** across midnight: dragging the end below 24:00 extends into the next day (shown live, #418 labels "23:58 – 00:30"). ## Proof Unit tests for splitting (midnight, DST changes, multi-day spans, 23:59–00:00), a production e2e (create 23:58–00:30, and see both segments in Day, Week, Month and Agenda), and screenshots at 390, 820 and 1440 px, light and dark. Web gates, plus per-crate gates if you touch Rust.
Author
Owner

Starting #469 on branch job/crossday-469, based at 369ab6a2f9fc673e3564b94857fbecfeb04df404 (origin/dev). I’m tracing the calendar projection and rendering paths, then I’ll add regression coverage before changing behavior.

Starting #469 on branch `job/crossday-469`, based at `369ab6a2f9fc673e3564b94857fbecfeb04df404` (origin/dev). I’m tracing the calendar projection and rendering paths, then I’ll add regression coverage before changing behavior.
Author
Owner

Finding: GET /api/v1/calendar/range selects Log rows by their stored day, and fromRange puts each Log only in that day. entryRange then clips an overnight end to 24:00. The time grid applies the #384 title-line floor after the actual time, so a 23:58 entry can extend its drawn box past the grid bottom; no continuation is projected on the next date. Events already have per-day parts in the range adapter. The resize-end path also clamps to 24:00 and formats later minutes as 00:00, so it cannot show 23:58–00:30 while resizing. I’ll keep the Log line in its source Daily note and add render-only day segments, with tests around day boundaries and geometry.

Finding: `GET /api/v1/calendar/range` selects Log rows by their stored `day`, and `fromRange` puts each Log only in that day. `entryRange` then clips an overnight end to 24:00. The time grid applies the #384 title-line floor after the actual time, so a 23:58 entry can extend its drawn box past the grid bottom; no continuation is projected on the next date. Events already have per-day parts in the range adapter. The resize-end path also clamps to 24:00 and formats later minutes as 00:00, so it cannot show 23:58–00:30 while resizing. I’ll keep the Log line in its source Daily note and add render-only day segments, with tests around day boundaries and geometry.
Author
Owner

Finding: the Notes Task index stores start_time, scheduled_time and due_time, but GET /api/v1/notes/tasks/day serializes TaskView without them (crates/plugins/notes/src/tasks_api.rs::TaskView and view). This prevents the Calendar from placing timed Tasks or splitting a start-to-due span across midnight. I am exposing those three optional clock fields in the existing TaskView response and carrying them into the Calendar projection. Date-only Tasks remain all-day.

Finding: the Notes Task index stores `start_time`, `scheduled_time` and `due_time`, but `GET /api/v1/notes/tasks/day` serializes `TaskView` without them (`crates/plugins/notes/src/tasks_api.rs::TaskView` and `view`). This prevents the Calendar from placing timed Tasks or splitting a start-to-due span across midnight. I am exposing those three optional clock fields in the existing TaskView response and carrying them into the Calendar projection. Date-only Tasks remain all-day.
Author
Owner

Finding: task_items has civil-date columns only; start_time and due_time live in the camel-case TaskIndexEntry JSON payload (tasks_store.rs serializes it with rename_all = "camelCase"). The first timed-span SQL filter incorrectly referenced nonexistent columns. I changed it to read $.startTime and $.dueTime from the payload and added a TaskView clock projection test. The production e2e also checks the /notes/tasks/day result for a cross-day timed Task.

Finding: `task_items` has civil-date columns only; `start_time` and `due_time` live in the camel-case `TaskIndexEntry` JSON payload (`tasks_store.rs` serializes it with `rename_all = "camelCase"`). The first timed-span SQL filter incorrectly referenced nonexistent columns. I changed it to read `$.startTime` and `$.dueTime` from the payload and added a TaskView clock projection test. The production e2e also checks the `/notes/tasks/day` result for a cross-day timed Task.
Author
Owner

Starting #469 on job/crossday-469.

Base: origin/dev at 369ab6a2f9fc673e3564b94857fbecfeb04df404. The worktree has an initial branch commit at 9cbcf21980008718c9aab478c8d2934b45da2de6 and existing uncommitted implementation changes. I am preserving and reviewing those changes before committing the calendar rendering and data projection work.

Starting #469 on `job/crossday-469`. Base: `origin/dev` at `369ab6a2f9fc673e3564b94857fbecfeb04df404`. The worktree has an initial branch commit at `9cbcf21980008718c9aab478c8d2934b45da2de6` and existing uncommitted implementation changes. I am preserving and reviewing those changes before committing the calendar rendering and data projection work.
Author
Owner

Review finding for #469 proof: calendar-crossday.mjs queried Month rows with .row.log / .row.task, but MonthGrid.svelte renders those rows with only .row. The original Log fixture also ended at 00:00 and reached 00:30 only after the resize gesture. I corrected the selectors, made one Log start at 23:58–00:30, and kept a second 23:58–00:00 Log for the resize proof. The real Calendar range assertion checks that both Log records remain in the source day.

Review finding for #469 proof: `calendar-crossday.mjs` queried Month rows with `.row.log` / `.row.task`, but `MonthGrid.svelte` renders those rows with only `.row`. The original Log fixture also ended at `00:00` and reached `00:30` only after the resize gesture. I corrected the selectors, made one Log start at `23:58–00:30`, and kept a second `23:58–00:00` Log for the resize proof. The real Calendar range assertion checks that both Log records remain in the source day.
Author
Owner

Test finding for #469: the first full web run reported 836 passing tests and two failures in TimeGrid.svelte.test.ts. Both failures were the same stale assertion: the callback now received the stable key task:task-1, while the old assertion expected task:2026-09-27:task-1. The date was part of the old key, so adjacent day parts could not share selection. I updated that expectation to the stable Task identity required by this issue and added a comment at the assertion.

Test finding for #469: the first full web run reported 836 passing tests and two failures in `TimeGrid.svelte.test.ts`. Both failures were the same stale assertion: the callback now received the stable key `task:task-1`, while the old assertion expected `task:2026-09-27:task-1`. The date was part of the old key, so adjacent day parts could not share selection. I updated that expectation to the stable Task identity required by this issue and added a comment at the assertion.
Author
Owner

Contract review finding for #469: the Task API now returns start_time, scheduled_time, and due_time, but contracts/openapi.json still had the old TaskView schema while the generated TypeScript had been edited directly. I will regenerate the server contract and API client together so the public response shape and checked-in artifacts stay in sync.

Contract review finding for #469: the Task API now returns `start_time`, `scheduled_time`, and `due_time`, but `contracts/openapi.json` still had the old `TaskView` schema while the generated TypeScript had been edited directly. I will regenerate the server contract and API client together so the public response shape and checked-in artifacts stay in sync.
Author
Owner

#469 finished (continued by Claude after the Codex job hit its usage limit)

Head: d3811f9ef36cc5f0fe8e270ccf7513a8378523da on job/crossday-469 (origin/dev merged at e6c0bf9de). Not pushed or merged.

What was built

  • Cross-midnight Logs, Events and timed Tasks render as day parts that share one identity (selection, hover, preview, drag) in Day, Week, 3-day, Month and Agenda. Cut edges are square (--calendar-event-cut-radius); Agenda shows "Continues" and "From yesterday".
  • A short part at 23:58 grows upwards inside the day and never draws past the grid bottom (#384 one-line minimum).
  • Data: the Log line stays in its start day. Only rendering splits.
  • Task API: start_time, due_time and scheduled_time on TaskView. /tasks/day returns timed start-to-due spans on every covered day (role continuation).
  • CalDAV: tests pin DTSTART;TZID=Europe/Berlin:20260929T235800 / DTEND;TZID=Europe/Berlin:20260930T003000 for the Log projection and for Event creation.
  • Resize across midnight: a live 23:58 – 00:30 label, saved through the Journal PATCH with end: "00:30".
  • Fix found on the way: .b-content stuck at --head-h + 3px, but the header left the scroller in d2615c712. Every block that started at the top of the view (every 00:00 carry-in) showed its title one header height down. It now sticks at --calendar-event-padding-block.
  • New style values are tokens (cut radius, Task fills, block glyph size). The Event and Task title glyphs share one rule.

Gates (verbatim)

Web (apps/web):

Text sizes use shared role tokens.
COMPLETED 1908 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
 Test Files  131 passed (131)
      Tests  845 passed (845)

e2e bun e2e/calendar-crossday.mjs (production build + real server):

Cross-midnight Calendar proof passed; screenshots: /home/kayg/Developer/calternal-wt/crossday-469/artifacts/calendar-crossday

Rust:

cargo fmt --check: OK
== cargo clippy -p calternal-dav --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.72s
== cargo test -p calternal-dav
test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 31 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
== cargo clippy -p calternal-plugin-calendar --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.43s
== cargo test -p calternal-plugin-calendar
test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.43s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
== cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.25s
== cargo test -p calternal-plugin-notes
test tests::vtodo_wire_upgrade_rotates_sync_epoch_once ... FAILED
thread 'tests::vtodo_wire_upgrade_rotates_sync_epoch_once' (805456) panicked at crates/plugins/notes/src/lib.rs:5114:9:
test result: FAILED. 121 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 43.72s

Known issues that are not caused by this branch

  • vtodo_wire_upgrade_rotates_sync_epoch_once also fails on dev. The test pops the last notes migration and expects it to be 0018_reminder_wire_epoch, but #356 added 0019_dav_area_deletes. This branch adds no migration and does not touch that code. I did not change the expectation. It needs its own fix.
  • e2e/calendar.mjs times out on dev too: a toast intercepts the "Edit log entry" click after the top-edge resize. I reproduced this with the origin/dev UI and this server.

Decisions (please confirm)

  • Day columns narrower than the #384 lane floor (phone, 390 px) put the 23:58 Log part behind "+1", next to "Attack on Titan", because its upward one-line minimum overlaps the Event. The e2e opens the pill and finds the item there.
  • In narrow Week columns, Event and Task parts show only the tint bar, with no title. This is the existing #134/#384 "cramped" rule and is not changed here.
  • An open timed Task that is past due also shows on today (existing overdue rule). The Month and Agenda assertions count only the two span days.
  • MonthGrid cells now have data-date, so tests can find a cell by its date.

Adversarial

/tasks/day has no new input: the same from and to query parameters, all bound. The new overlap branch reads json_extract from the stored payload only.

Screenshots (60, not committed)

/home/kayg/Developer/calternal-wt/crossday-469/artifacts/calendar-crossday/: {day-log-source,day-log-next,day-event-source,day-event-next,day-task-source,day-task-next,week-source,week-next,month,agenda}-{390,820,1440}-{light,dark}.png

## #469 finished (continued by Claude after the Codex job hit its usage limit) Head: `d3811f9ef36cc5f0fe8e270ccf7513a8378523da` on `job/crossday-469` (origin/dev merged at e6c0bf9de). Not pushed or merged. ### What was built - Cross-midnight Logs, Events and timed Tasks render as day parts that share one identity (selection, hover, preview, drag) in Day, Week, 3-day, Month and Agenda. Cut edges are square (`--calendar-event-cut-radius`); Agenda shows "Continues" and "From yesterday". - A short part at 23:58 grows upwards inside the day and never draws past the grid bottom (#384 one-line minimum). - Data: the Log line stays in its start day. Only rendering splits. - Task API: `start_time`, `due_time` and `scheduled_time` on TaskView. `/tasks/day` returns timed start-to-due spans on every covered day (role `continuation`). - CalDAV: tests pin `DTSTART;TZID=Europe/Berlin:20260929T235800` / `DTEND;TZID=Europe/Berlin:20260930T003000` for the Log projection and for Event creation. - Resize across midnight: a live `23:58 – 00:30` label, saved through the Journal PATCH with `end: "00:30"`. - Fix found on the way: `.b-content` stuck at `--head-h + 3px`, but the header left the scroller in d2615c712. Every block that started at the top of the view (every 00:00 carry-in) showed its title one header height down. It now sticks at `--calendar-event-padding-block`. - New style values are tokens (cut radius, Task fills, block glyph size). The Event and Task title glyphs share one rule. ### Gates (verbatim) Web (`apps/web`): ``` Text sizes use shared role tokens. COMPLETED 1908 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS Test Files 131 passed (131) Tests 845 passed (845) ``` e2e `bun e2e/calendar-crossday.mjs` (production build + real server): ``` Cross-midnight Calendar proof passed; screenshots: /home/kayg/Developer/calternal-wt/crossday-469/artifacts/calendar-crossday ``` Rust: ``` cargo fmt --check: OK == cargo clippy -p calternal-dav --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.72s == cargo test -p calternal-dav test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s test result: ok. 31 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s == cargo clippy -p calternal-plugin-calendar --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.43s == cargo test -p calternal-plugin-calendar test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.43s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s == cargo clippy -p calternal-plugin-notes --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.25s == cargo test -p calternal-plugin-notes test tests::vtodo_wire_upgrade_rotates_sync_epoch_once ... FAILED thread 'tests::vtodo_wire_upgrade_rotates_sync_epoch_once' (805456) panicked at crates/plugins/notes/src/lib.rs:5114:9: test result: FAILED. 121 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 43.72s ``` ### Known issues that are not caused by this branch - `vtodo_wire_upgrade_rotates_sync_epoch_once` also fails on dev. The test pops the last notes migration and expects it to be `0018_reminder_wire_epoch`, but #356 added `0019_dav_area_deletes`. This branch adds no migration and does not touch that code. I did not change the expectation. It needs its own fix. - `e2e/calendar.mjs` times out on dev too: a toast intercepts the "Edit log entry" click after the top-edge resize. I reproduced this with the `origin/dev` UI and this server. ### Decisions (please confirm) - Day columns narrower than the #384 lane floor (phone, 390 px) put the 23:58 Log part behind "+1", next to "Attack on Titan", because its upward one-line minimum overlaps the Event. The e2e opens the pill and finds the item there. - In narrow Week columns, Event and Task parts show only the tint bar, with no title. This is the existing #134/#384 "cramped" rule and is not changed here. - An open timed Task that is past due also shows on today (existing overdue rule). The Month and Agenda assertions count only the two span days. - MonthGrid cells now have `data-date`, so tests can find a cell by its date. ### Adversarial `/tasks/day` has no new input: the same `from` and `to` query parameters, all bound. The new overlap branch reads `json_extract` from the stored payload only. ### Screenshots (60, not committed) `/home/kayg/Developer/calternal-wt/crossday-469/artifacts/calendar-crossday/`: `{day-log-source,day-log-next,day-event-source,day-event-next,day-task-source,day-task-next,week-source,week-next,month,agenda}-{390,820,1440}-{light,dark}.png`
Author
Owner

Merged into dev at 9a91f2df5 and pushed. Deploy status is on #203.

Merged into dev at 9a91f2df5 and pushed. Deploy status is on #203.
kayg closed this issue 2026-09-30 00:29:51 +00:00
Author
Owner

Owner confirmed the current behaviour (2026-10-01).

Owner confirmed the current behaviour (2026-10-01).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#469
No description provided.