Skip to main content

max / goingson

Draw the parts of a day that are not one bar in a column Three gaps that were all the same gap: the day view assumed every item starts and ends inside the day it is drawn on. An event that runs 22:00 to 02:00 is one row and two bars. The renderer positioned from the stored start, so on the second day it drew last night's 22:00 bar at 22:00 and ran the height off the bottom of the column. TimelineItem now carries the span clipped to the rendered day, computed in core where the window already lives, and the renderer positions from that. A clipped bar gets a dashed edge, an arrow, and the real time in its tooltip; it is not draggable, since the visible offset is not the item's offset. An all-day event covered all 24 hours of the column and buried the day. Those go in a strip above the timeline now, decided by covering the window rather than by a flag written at authoring time, so the middle day of a three-day event lands there too. They are also out of the conflict pass: one of them overlaps everything by definition, and counting it made the flag stop meaning double-booked. The month grid showed events only, so a calendar of what lands on a day was missing everything owed on it. Due tasks now render beside events, outlined rather than filled, over a new list_tasks_due_between command.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 17:51 UTC
Signed with PGP, not checked
Commit: 3a740e8eb4f5b22f43fe20f7b39bb32353a431f5
Parent: 7bcf9bc
11 files changed, +612 insertions, -35 deletions
@@ -314,6 +314,8 @@
314 314 <div class="day-plan-content">
315 315 <div class="day-plan-main" id="day-plan-container">
316 316 <p class="timeline-hint">Click and drag across time slots to schedule blocks</p>
317 + <!-- All-day items sit above the scrolling column, not in it. -->
318 + <div class="timeline-all-day hidden" id="timeline-all-day"></div>
317 319 <div class="timeline-container" id="timeline-container">
318 320 <div class="timeline-scroll-area">
319 321 <div class="timeline-current-time" id="timeline-current-time"></div>
@@ -67,6 +67,7 @@
67 67 // Tasks
68 68 $crate::commands::list_tasks,
69 69 $crate::commands::list_tasks_filtered,
70 + $crate::commands::list_tasks_due_between,
70 71 $crate::commands::get_task,
71 72 $crate::commands::create_task,
72 73 $crate::commands::quick_add_task,
@@ -7,7 +7,18 @@
7 7 use serde::Serialize;
8 8 use uuid::Uuid;
9 9
10 + /// Minutes in a day. The day view is a fixed 24-hour column, so a DST day is
11 + /// still drawn as 1440 minutes; the shift shows up as an hour of dead or doubled
12 + /// slot rather than as a column of a different length.
13 + pub const MINUTES_PER_DAY: i32 = 24 * 60;
14 +
10 15 /// A scheduled item on the day timeline.
16 + ///
17 + /// The `day_*` fields are the item as the rendered day sees it, not as it was
18 + /// stored: an event that runs 22:00 to 02:00 is one row in the database and two
19 + /// visible bars, and each day's bar has to know where its own portion starts and
20 + /// stops. Computing that here rather than in the renderer keeps one definition of
21 + /// "what part of this lands today", which the conflict pass and the view share.
11 22 #[derive(Debug, Serialize)]
12 23 #[serde(rename_all = "camelCase")]
13 24 pub struct TimelineItem {
@@ -16,12 +27,78 @@
16 27 pub title: String,
17 28 pub start_time: DateTime<Utc>,
18 29 pub end_time: Option<DateTime<Utc>>,
30 + /// The item's whole duration, unclamped. Drag-to-reschedule moves the whole
31 + /// item, so it needs the real length, not today's slice of it.
19 32 pub duration: Option<i32>,
20 33 pub project_id: Option<Uuid>,
21 34 pub project_name: Option<String>,
22 35 pub priority: Option<String>,
23 36 pub status: Option<String>,
24 37 pub block_type: Option<String>,
38 + /// Covers the rendered day end to end. These go in the all-day strip above
39 + /// the timeline: drawn in the column they would paper over all 24 hours of it.
40 + pub is_all_day: bool,
41 + /// Minutes from local midnight to this item's visible start, 0 when it began
42 + /// on an earlier day.
43 + pub day_offset_minutes: i32,
44 + /// Visible length inside the rendered day, at least one minute so a bar is
45 + /// never invisible.
46 + pub visible_duration_minutes: i32,
47 + /// Started before this day / runs past its end. The renderer marks these so a
48 + /// clamped bar does not read as an item that genuinely starts at midnight.
49 + pub continues_before: bool,
50 + pub continues_after: bool,
51 + }
52 +
53 + /// The part of a span that falls inside one local day.
54 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
55 + pub struct DaySpan {
56 + pub day_offset_minutes: i32,
57 + pub visible_duration_minutes: i32,
58 + pub continues_before: bool,
59 + pub continues_after: bool,
60 + pub is_all_day: bool,
61 + }
62 +
63 + /// Clip a span to the rendered day window.
64 + ///
65 + /// `day_start` and `day_end_excl` are the local day's bounds as UTC instants, so
66 + /// the arithmetic is in real elapsed minutes and a DST day clips correctly even
67 + /// though the column it feeds is a fixed 1440.
68 + ///
69 + /// An absent `end` is treated as `default_duration_minutes` long, matching the
70 + /// conflict pass rather than inventing a second rule for a missing end time.
71 + pub fn clip_to_day(
72 + start: DateTime<Utc>,
73 + end: Option<DateTime<Utc>>,
74 + day_start: DateTime<Utc>,
75 + day_end_excl: DateTime<Utc>,
76 + default_duration_minutes: i32,
77 + ) -> DaySpan {
78 + let end = end.unwrap_or_else(|| {
79 + start + chrono::Duration::minutes(default_duration_minutes.max(0) as i64)
80 + });
81 + // A backwards or zero-length span still gets a visible bar, so a bad row is
82 + // something you can see and fix rather than something that silently vanishes.
83 + let end = end.max(start);
84 +
85 + let visible_start = start.max(day_start);
86 + let visible_end = end.min(day_end_excl);
87 +
88 + let day_offset_minutes = (visible_start - day_start).num_minutes().max(0) as i32;
89 + let visible_duration_minutes = (visible_end - visible_start).num_minutes().max(1) as i32;
90 + let day_minutes = (day_end_excl - day_start).num_minutes().max(1) as i32;
91 +
92 + DaySpan {
93 + day_offset_minutes,
94 + visible_duration_minutes,
95 + continues_before: start < day_start,
96 + continues_after: end > day_end_excl,
97 + // Covering the window is what "all day" means to the view. It catches an
98 + // authored midnight-to-midnight event and the middle day of a three-day
99 + // one alike, without either having to be flagged when it was written.
100 + is_all_day: day_offset_minutes == 0 && visible_duration_minutes >= day_minutes,
101 + }
25 102 }
26 103
27 104 /// A detected overlap between two timeline items.
@@ -38,15 +115,20 @@
38 115 ///
39 116 /// Compares all pairs of items and returns any overlapping time ranges.
40 117 /// Items without an explicit end time use their duration (defaulting to 30 minutes).
118 + ///
119 + /// All-day items are skipped. One of them overlaps everything on the day by
120 + /// definition, so counting it would mark the whole column as conflicting and the
121 + /// flag would stop meaning "these two double-book you", which is the only thing
122 + /// it is read for.
41 123 pub fn detect_conflicts(items: &[TimelineItem]) -> Vec<Conflict> {
42 124 let mut conflicts = Vec::new();
43 125
44 - for (i, item1) in items.iter().enumerate() {
126 + for (i, item1) in items.iter().enumerate().filter(|(_, it)| !it.is_all_day) {
45 127 let end1 = item1.end_time.unwrap_or_else(|| {
46 128 item1.start_time + chrono::Duration::minutes(item1.duration.unwrap_or(30).max(0) as i64)
47 129 });
48 130
49 - for item2 in items.iter().skip(i + 1) {
131 + for item2 in items.iter().skip(i + 1).filter(|it| !it.is_all_day) {
50 132 let end2 = item2.end_time.unwrap_or_else(|| {
51 133 item2.start_time
52 134 + chrono::Duration::minutes(item2.duration.unwrap_or(30).max(0) as i64)
@@ -75,20 +157,34 @@
75 157 use super::*;
76 158 use chrono::{Duration, TimeZone};
77 159
160 + /// The day these fixtures live on, as the window a renderer would pass.
161 + fn day_window() -> (DateTime<Utc>, DateTime<Utc>) {
162 + let start = Utc.with_ymd_and_hms(2026, 3, 15, 0, 0, 0).unwrap();
163 + (start, start + Duration::days(1))
164 + }
165 +
78 166 fn make_timeline_item(hour: u32, minute: u32, duration_mins: i32) -> TimelineItem {
79 167 let start = Utc.with_ymd_and_hms(2026, 3, 15, hour, minute, 0).unwrap();
168 + let end = start + Duration::minutes(duration_mins as i64);
169 + let (day_start, day_end) = day_window();
170 + let span = clip_to_day(start, Some(end), day_start, day_end, 30);
80 171 TimelineItem {
81 172 id: Uuid::new_v4(),
82 173 item_type: "event".to_string(),
83 174 title: format!("Event at {hour}:{minute:02}"),
84 175 start_time: start,
85 - end_time: Some(start + Duration::minutes(duration_mins as i64)),
176 + end_time: Some(end),
86 177 duration: Some(duration_mins),
87 178 project_id: None,
88 179 project_name: None,
89 180 priority: None,
90 181 status: None,
91 182 block_type: None,
183 + is_all_day: span.is_all_day,
184 + day_offset_minutes: span.day_offset_minutes,
185 + visible_duration_minutes: span.visible_duration_minutes,
186 + continues_before: span.continues_before,
187 + continues_after: span.continues_after,
92 188 }
93 189 }
94 190
@@ -154,6 +250,11 @@
154 250 priority: None,
155 251 status: None,
156 252 block_type: None,
253 + is_all_day: false,
254 + day_offset_minutes: 9 * 60,
255 + visible_duration_minutes: 30,
256 + continues_before: false,
257 + continues_after: false,
157 258 },
158 259 TimelineItem {
159 260 id: Uuid::new_v4(),
@@ -167,6 +268,11 @@
167 268 priority: None,
168 269 status: None,
169 270 block_type: None,
271 + is_all_day: false,
272 + day_offset_minutes: 9 * 60 + 15,
273 + visible_duration_minutes: 30,
274 + continues_before: false,
275 + continues_after: false,
170 276 },
171 277 ];
172 278
@@ -198,4 +304,143 @@
198 304 assert_eq!(conflicts[0].item1_id, id1);
199 305 assert_eq!(conflicts[0].item2_id, id2);
200 306 }
307 +
308 + /// An all-day item overlaps everything on the day, so counting it would
309 + /// paint the whole column as double-booked.
310 + #[test]
311 + fn an_all_day_item_conflicts_with_nothing() {
312 + let (day_start, day_end) = day_window();
313 + let span = clip_to_day(day_start, Some(day_end), day_start, day_end, 30);
314 + assert!(span.is_all_day);
315 +
316 + let all_day = TimelineItem {
317 + id: Uuid::new_v4(),
318 + item_type: "event".to_string(),
319 + title: "Conference".to_string(),
320 + start_time: day_start,
321 + end_time: Some(day_end),
322 + duration: Some(MINUTES_PER_DAY),
323 + project_id: None,
324 + project_name: None,
325 + priority: None,
326 + status: None,
327 + block_type: None,
328 + is_all_day: span.is_all_day,
329 + day_offset_minutes: span.day_offset_minutes,
330 + visible_duration_minutes: span.visible_duration_minutes,
331 + continues_before: span.continues_before,
332 + continues_after: span.continues_after,
333 + };
334 +
335 + // Against a normal meeting inside it, and against a second all-day item.
336 + let items = vec![all_day, make_timeline_item(9, 0, 60)];
337 + assert!(detect_conflicts(&items).is_empty());
338 +
339 + // The two ordinary items still conflict with each other.
340 + let items = vec![make_timeline_item(9, 0, 60), make_timeline_item(9, 30, 60)];
341 + assert_eq!(detect_conflicts(&items).len(), 1);
342 + }
343 +
344 + mod clip {
345 + use super::*;
346 +
347 + fn day() -> (DateTime<Utc>, DateTime<Utc>) {
348 + day_window()
349 + }
350 +
351 + #[test]
352 + fn an_ordinary_span_is_untouched() {
353 + let (ds, de) = day();
354 + let start = Utc.with_ymd_and_hms(2026, 3, 15, 9, 30, 0).unwrap();
355 + let span = clip_to_day(start, Some(start + Duration::minutes(45)), ds, de, 30);
356 + assert_eq!(span.day_offset_minutes, 9 * 60 + 30);
357 + assert_eq!(span.visible_duration_minutes, 45);
358 + assert!(!span.continues_before && !span.continues_after && !span.is_all_day);
359 + }
360 +
361 + /// The evening half of a 22:00-02:00 event: it starts today and runs out
362 + /// the bottom of the column.
363 + #[test]
364 + fn a_span_running_past_midnight_is_clipped_at_the_end() {
365 + let (ds, de) = day();
366 + let start = Utc.with_ymd_and_hms(2026, 3, 15, 22, 0, 0).unwrap();
367 + let span = clip_to_day(start, Some(start + Duration::hours(4)), ds, de, 30);
368 + assert_eq!(span.day_offset_minutes, 22 * 60);
369 + assert_eq!(
370 + span.visible_duration_minutes, 120,
371 + "only tonight's two hours"
372 + );
373 + assert!(span.continues_after);
374 + assert!(!span.continues_before);
375 + }
376 +
377 + /// The morning half of the same event, read on the following day. Without
378 + /// clipping this drew at 22:00 on the wrong day and overflowed the column.
379 + #[test]
380 + fn a_span_starting_before_the_day_is_clipped_at_the_start() {
381 + let ds = Utc.with_ymd_and_hms(2026, 3, 16, 0, 0, 0).unwrap();
382 + let de = ds + Duration::days(1);
383 + let start = Utc.with_ymd_and_hms(2026, 3, 15, 22, 0, 0).unwrap();
384 + let span = clip_to_day(start, Some(start + Duration::hours(4)), ds, de, 30);
385 + assert_eq!(span.day_offset_minutes, 0);
386 + assert_eq!(
387 + span.visible_duration_minutes, 120,
388 + "this morning's two hours"
389 + );
390 + assert!(span.continues_before);
391 + assert!(!span.continues_after);
392 + assert!(!span.is_all_day, "two hours is not the whole day");
393 + }
394 +
395 + #[test]
396 + fn a_midnight_to_midnight_span_is_all_day() {
397 + let (ds, de) = day();
398 + let span = clip_to_day(ds, Some(de), ds, de, 30);
399 + assert!(span.is_all_day);
400 + assert_eq!(span.day_offset_minutes, 0);
401 + assert_eq!(span.visible_duration_minutes, MINUTES_PER_DAY);
402 + assert!(!span.continues_before && !span.continues_after);
403 + }
404 +
405 + /// The middle day of a three-day event covers this day end to end without
406 + /// starting or ending on it, and belongs in the strip just the same.
407 + #[test]
408 + fn the_middle_of_a_multi_day_span_is_all_day() {
409 + let (ds, de) = day();
410 + let span = clip_to_day(
411 + ds - Duration::days(1),
412 + Some(de + Duration::days(1)),
413 + ds,
414 + de,
415 + 30,
416 + );
417 + assert!(span.is_all_day);
418 + assert!(span.continues_before && span.continues_after);
419 + }
420 +
421 + #[test]
422 + fn a_missing_end_uses_the_default_duration() {
423 + let (ds, de) = day();
424 + let start = Utc.with_ymd_and_hms(2026, 3, 15, 9, 0, 0).unwrap();
425 + let span = clip_to_day(start, None, ds, de, 30);
426 + assert_eq!(span.visible_duration_minutes, 30);
427 + }
428 +
429 + /// A zero-length or backwards span still draws. A bar you can see and
430 + /// delete beats a row that is in the database and nowhere on screen.
431 + #[test]
432 + fn a_degenerate_span_still_gets_a_visible_minute() {
433 + let (ds, de) = day();
434 + let start = Utc.with_ymd_and_hms(2026, 3, 15, 9, 0, 0).unwrap();
435 + assert_eq!(
436 + clip_to_day(start, Some(start), ds, de, 30).visible_duration_minutes,
437 + 1
438 + );
439 + assert_eq!(
440 + clip_to_day(start, Some(start - Duration::hours(2)), ds, de, 30)
441 + .visible_duration_minutes,
442 + 1
443 + );
444 + }
445 + }
201 446 }
@@ -61,7 +61,9 @@
61 61 NewSocialHandle, SocialHandle, UpdateContact, merge_activity,
62 62 };
63 63 pub use date_parser::parse_natural_date;
64 - pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
64 + pub use day_planning::{
65 + Conflict, DaySpan, MINUTES_PER_DAY, TimelineItem, clip_to_day, detect_conflicts,
66 + };
65 67 pub use email_compose::{
66 68 ComposePrefill, forward_body, forward_subject, quoted_reply_body, reply_recipients,
67 69 reply_subject,
@@ -4282,6 +4282,70 @@
4282 4282 border-radius: var(--radius-sm);
4283 4283 }
4284 4284
4285 + /* All-day strip. Sits above the scrolling column and stays put while it scrolls:
4286 + an item that covers the whole day has no position within it, so it gets a rail
4287 + of its own rather than a bar papering over all 24 hours. */
4288 + .timeline-all-day {
4289 + display: flex;
4290 + align-items: flex-start;
4291 + gap: var(--gap-peer);
4292 + margin-bottom: var(--gap-peer);
4293 + padding: var(--gap-bound) var(--gap-peer);
4294 + background: var(--surface-overlay);
4295 + border: var(--border-width) solid var(--border);
4296 + border-radius: var(--radius-sm);
4297 + }
4298 +
4299 + .all-day-label {
4300 + flex: none;
4301 + /* Lines up with the timeline's own time gutter (.timeline-item left: 60px). */
4302 + width: 52px;
4303 + padding-top: var(--gap-bound);
4304 + font-size: var(--font-size-xs);
4305 + font-weight: 600;
4306 + text-transform: uppercase;
4307 + letter-spacing: 0.05em;
4308 + color: var(--content-secondary);
4309 + }
4310 +
4311 + .all-day-items {
4312 + flex: 1;
4313 + display: flex;
4314 + flex-direction: column;
4315 + gap: var(--gap-bound);
4316 + min-width: 0;
4317 + }
4318 +
4319 + .all-day-item {
4320 + display: flex;
4321 + align-items: baseline;
4322 + gap: var(--gap-peer);
4323 + padding: var(--gap-bound) var(--gap-peer);
4324 + border: var(--border-width) solid var(--border);
4325 + border-radius: var(--radius-sm);
4326 + background: var(--action);
4327 + color: var(--content-on-action);
4328 + cursor: pointer;
4329 + }
4330 +
4331 + .all-day-item.task { background: var(--success); color: var(--content); }
4332 + .all-day-item.block-free_time { background: var(--category-six); color: var(--content); }
4333 + .all-day-item.block-personal { background: var(--warning); color: var(--content); }
4334 + .all-day-item.block-vacation { background: var(--category-five); color: var(--content-on-action); }
4335 + .all-day-item.block-focus { background: var(--danger); color: var(--content-on-action); }
4336 +
4337 + .all-day-item-title {
4338 + font-weight: 600;
4339 + overflow: hidden;
4340 + text-overflow: ellipsis;
4341 + white-space: nowrap;
4342 + }
4343 +
4344 + .all-day-item-meta {
4345 + font-size: var(--font-size-xs);
4346 + opacity: 0.85;
4347 + }
4348 +
4285 4349 .timeline-slot-area {
4286 4350 position: relative;
4287 4351 cursor: grab;
@@ -4337,6 +4401,34 @@
4337 4401 color: var(--content-on-action);
4338 4402 }
4339 4403
4404 + /* A bar clipped by midnight. The flat edge and the arrow say the item goes on
4405 + past the end of the column, so a 22:00-02:00 event does not read as ending at
4406 + midnight; it is also not draggable, hence the plain cursor. */
4407 + .timeline-item.continues-before,
4408 + .timeline-item.continues-after {
4409 + cursor: pointer;
4410 + }
4411 +
4412 + .timeline-item.continues-before {
4413 + border-top-style: dashed;
4414 + border-top-left-radius: 0;
4415 + border-top-right-radius: 0;
4416 + }
4417 +
4418 + .timeline-item.continues-after {
4419 + border-bottom-style: dashed;
4420 + border-bottom-left-radius: 0;
4421 + border-bottom-right-radius: 0;
4422 + }
4423 +
4424 + .timeline-item.continues-before .timeline-item-title::before {
4425 + content: "\2191 ";
4426 + }
4427 +
4428 + .timeline-item.continues-after .timeline-item-title::after {
4429 + content: " \2193";
4430 + }
4431 +
4340 4432 .timeline-item.conflict {
4341 4433 box-shadow: 0 0 0 3px var(--danger);
4342 4434 }
@@ -8820,6 +8912,52 @@
8820 8912 .cal-event-chip.block-personal { background: var(--category-four); color: var(--content); }
8821 8913 .cal-event-chip.block-free_time { background: var(--category-two); color: var(--content); }
8822 8914
8915 + /* A due task in a month cell. Outlined rather than filled, so a deadline reads
8916 + as a different kind of thing from an event without needing a legend: events
8917 + are blocks of time, tasks are dates something is owed. */
8918 + .cal-task-chip {
8919 + font-size: var(--font-size-xs);
8920 + padding: var(--step-hair) var(--step-tight);
8921 + margin-top: var(--step-hair);
8922 + border-radius: var(--radius-xs);
8923 + border: var(--border-width-sm) solid var(--action);
8924 + background: var(--surface-raised);
8925 + color: var(--content);
8926 + white-space: nowrap;
8927 + overflow: hidden;
8928 + text-overflow: ellipsis;
8929 + cursor: pointer;
8930 + }
8931 +
8932 + .cal-task-chip:hover {
8933 + background: var(--surface-overlay);
8934 + }
8935 +
8936 + .cal-task-chip.overdue {
8937 + border-color: var(--danger);
8938 + color: var(--danger);
8939 + font-weight: 600;
8940 + }
8941 +
8942 + .cal-task-chip.done {
8943 + border-color: var(--border);
8944 + color: var(--content-muted);
8945 + text-decoration: line-through;
8946 + }
8947 +
8948 + .cal-day-detail-task .cal-detail-time {
8949 + font-weight: 600;
8950 + }
8951 +
8952 + .cal-day-detail-task.overdue .cal-detail-time {
8953 + color: var(--danger);
8954 + }
8955 +
8956 + .cal-day-detail-task.done .cal-detail-title {
8957 + color: var(--content-muted);
8958 + text-decoration: line-through;
8959 + }
8960 +
8823 8961 .cal-event-more {
8824 8962 font-size: var(--font-size-xxs);
8825 8963 color: var(--content-secondary);
@@ -105,6 +105,7 @@
105 105 tasks: {
106 106 list: () => invoke('list_tasks'),
107 107 listFiltered: (filters) => invoke('list_tasks_filtered', { filters }), // Server-side filter + paginate
108 + listDueBetween: (start, end) => invoke('list_tasks_due_between', { start, end }), // Calendar month grid
108 109 listByProject: (projectId) => invoke('list_tasks_for_project', { projectId }),
109 110 get: (id) => invoke('get_task', { id }),
110 111 getOverview: (id) => invoke('get_task_overview', { id }),
@@ -83,8 +83,12 @@
83 83 }
84 84 slotsContainer.innerHTML = slotsHtml;
85 85
86 - // Render timeline items
87 - if (!dayPlanData) return;
86 + // Render timeline items. With no data the strip is cleared too, or the
87 + // previous day's all-day items would sit above an empty column.
88 + if (!dayPlanData) {
89 + renderAllDayStrip([]);
90 + return;
91 + }
88 92
89 93 const conflictIds = new Set();
90 94 dayPlanData.conflicts.forEach(c => {
@@ -92,19 +96,30 @@
92 96 conflictIds.add(c.item2Id);
93 97 });
94 98
99 + // All-day items are pulled out of the column: drawn in it they would cover
100 + // all 24 hours and hide the day they belong to.
101 + const allDayItems = dayPlanData.timelineItems.filter(item => item.isAllDay);
102 + const timedItems = dayPlanData.timelineItems.filter(item => !item.isAllDay);
103 + renderAllDayStrip(allDayItems);
104 +
95 105 let itemsHtml = '';
96 - dayPlanData.timelineItems.forEach(item => {
97 - const startTime = new Date(item.startTime);
98 - const startHour = startTime.getHours();
99 - const startMinute = startTime.getMinutes();
106 + timedItems.forEach(item => {
107 + // Position from the day-clipped span, not from the stored start: an
108 + // item that began yesterday starts at the top of today's column, and
109 + // one that runs into tomorrow stops at the bottom of it. Reading
110 + // `startTime.getHours()` instead put last night's 22:00 event at 22:00
111 + // on today's column and ran its height off the end.
112 + const topOffset = (item.dayOffsetMinutes / 15) * slotHeight;
113 + const height = (item.visibleDurationMinutes / 15) * slotHeight;
100 114
101 - // Calculate position using 15-min slots
102 - const startSlotIndex = startHour * 4 + Math.floor(startMinute / 15);
103 - const topOffset = startSlotIndex * slotHeight + (startMinute % 15) / 15 * slotHeight;
104 -
105 - // Calculate height based on duration
115 + // The whole item's length, which is what a drag moves.
106 116 const duration = item.duration || 30;
107 - const height = (duration / 15) * slotHeight;
117 + const clipped = !!(item.continuesBefore || item.continuesAfter);
118 + const continuationClass = [
119 + item.continuesBefore ? 'continues-before' : '',
120 + item.continuesAfter ? 'continues-after' : '',
121 + ].filter(Boolean).join(' ');
122 + const spanHint = continuationHint(item);
108 123
109 124 const hasConflict = conflictIds.has(item.id);
110 125
@@ -115,20 +130,23 @@
115 130 ? blockLabel
116 131 : [item.projectName, item.priority].filter(Boolean).join(' - ');
117 132 // Touch: tap opens, long-press opens action sheet (wired post-render). No mouse drag.
118 - const dragHandler = isTouch
133 + // A clipped bar is not draggable either way: the visible offset is not
134 + // the item's offset, so a drag would move it by the wrong amount.
135 + const dragHandler = (isTouch || clipped)
119 136 ? ''
120 137 : ` data-mousedown="dayPlan.onItemDragStart" data-a1="@event" data-a2="${escAttr(item.id)}" data-a3="${escAttr(item.itemType)}"`;
121 - const titleHint = isTouch ? '' : ' (drag to reschedule)';
138 + const titleHint = (isTouch || clipped) ? '' : ' (drag to reschedule)';
122 139 itemsHtml += `
123 - <div class="timeline-item ${item.itemType} ${blockClass} ${hasConflict ? 'conflict' : ''}"
140 + <div class="timeline-item ${item.itemType} ${blockClass} ${continuationClass} ${hasConflict ? 'conflict' : ''}"
124 141 style="top: ${topOffset}px; height: ${height}px;"
125 142 data-id="${escAttr(item.id)}"
126 143 data-type="${escAttr(item.itemType)}"
127 144 data-duration="${duration}"
145 + data-clipped="${clipped}"
128 146 data-act="dayPlan.openTimelineItem" data-a1="${escAttr(item.id)}" data-a2="${escAttr(item.itemType)}"${dragHandler}
129 147 data-keydown="dayPlan.handleTimelineItemKeydown" data-a1="@event" data-a2="${escAttr(item.id)}" data-a3="${escAttr(item.itemType)}"
130 - title="${escAttr(item.title)}${titleHint}${keyboardHint}"
131 - tabindex="0" role="button" aria-label="${escAttr(item.title)}${keyboardHint}">
148 + title="${escAttr(item.title)}${spanHint}${titleHint}${keyboardHint}"
149 + tabindex="0" role="button" aria-label="${escAttr(item.title)}${spanHint}${keyboardHint}">
132 150 <div class="timeline-item-title">${esc(item.title)}</div>
133 151 <div class="timeline-item-meta">${esc(metaText)}</div>
134 152 </div>
@@ -137,6 +155,78 @@
137 155 itemsContainer.innerHTML = itemsHtml;
138 156 }
139 157
158 + /**
159 + * Human note for a bar that is only part of its item, e.g. " (from 10:30pm
160 + * yesterday)". Empty for an item that starts and ends inside the day.
161 + * @param {Object} item - Timeline item with continuesBefore/continuesAfter
162 + * @returns {string}
163 + */
164 + function continuationHint(item) {
165 + const bits = [];
166 + if (item.continuesBefore) bits.push(`from ${formatMoment(item.startTime)}`);
167 + if (item.continuesAfter && item.endTime) bits.push(`until ${formatMoment(item.endTime)}`);
168 + return bits.length ? ` (${bits.join(', ')})` : '';
169 + }
170 +
171 + /**
172 + * A timestamp as a day-relative wall clock: "10:30 PM yesterday". The day word
173 + * is the point, since these appear on bars whose own day is not the item's.
174 + */
175 + function formatMoment(iso) {
176 + const when = new Date(iso);
177 + const time = when.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
178 + const today = new Date();
179 + const dayDelta = Math.round(
180 + (new Date(when.getFullYear(), when.getMonth(), when.getDate())
181 + - new Date(today.getFullYear(), today.getMonth(), today.getDate())) / 86400000
182 + );
183 + const dayWord = { '-1': ' yesterday', 0: '', 1: ' tomorrow' }[dayDelta]
184 + ?? ` on ${when.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
185 + return `${time}${dayWord}`;
186 + }
187 +
188 + /**
189 + * Render the all-day strip above the timeline: one full-width bar per item
190 + * that covers the whole day. Hidden entirely when there are none, so the
191 + * column does not lose height to an empty rail.
192 + * @param {Array<Object>} items - All-day timeline items
193 + */
194 + function renderAllDayStrip(items) {
195 + const strip = document.getElementById('timeline-all-day');
196 + if (!strip) return;
197 +
198 + if (!items.length) {
199 + strip.classList.add('hidden');
200 + strip.innerHTML = '';
201 + return;
202 + }
203 +
204 + strip.classList.remove('hidden');
205 + const bars = items.map(item => {
206 + const blockClass = item.blockType ? `block-${item.blockType}` : '';
207 + const meta = item.itemType === 'block'
208 + ? (BLOCK_TYPE_LABELS[item.blockType] || item.blockType || '')
209 + : (item.projectName || '');
210 + const spanHint = continuationHint(item);
211 + return `
212 + <div class="all-day-item ${item.itemType} ${blockClass}"
213 + data-id="${escAttr(item.id)}"
214 + data-type="${escAttr(item.itemType)}"
215 + data-act="dayPlan.openTimelineItem" data-a1="${escAttr(item.id)}" data-a2="${escAttr(item.itemType)}"
216 + title="${escAttr(item.title)}${spanHint}"
217 + tabindex="0" role="button" aria-label="All day: ${escAttr(item.title)}${spanHint}">
218 + <span class="all-day-item-title">${esc(item.title)}</span>
219 + ${meta ? `<span class="all-day-item-meta">${esc(meta)}</span>` : ''}
220 + </div>
221 + `;
222 + }).join('');
223 +
224 + strip.innerHTML = `
225 + <span class="all-day-label">All day</span>
226 + <div class="all-day-items">${bars}</div>
227 + `;
228 + }
229 +
140 230 /**
141 231 * Render an unscheduled task item for the sidebar list.
142 232 * @param {Object} task - Task object with id, description, priority, projectName
@@ -207,6 +297,7 @@
207 297 GoingsOn.dayPlanRender = {
208 298 BLOCK_TYPE_LABELS,
209 299 renderTimeline,
300 + renderAllDayStrip,
210 301 renderUnscheduledTaskItem,
211 302 updateCurrentTimeIndicator,
212 303 getSlotHeight,
@@ -414,6 +414,11 @@
414 414 const slotsContainer = document.getElementById('timeline-slots');
415 415 if (!slotsContainer) return;
416 416
417 + // A bar clipped at either end of the day shows part of the item, so
418 + // dragging it by the visible offset would move the item by the wrong
419 + // amount. Open it and edit the times instead.
420 + if (el.dataset.clipped === 'true') return;
421 +
417 422 const startY = event.clientY;
418 423 const origTop = parseFloat(el.style.top);
419 424 const slotHeight = GoingsOn.dayPlanRender.getSlotHeight();
@@ -13,6 +13,7 @@
13 13 let currentMonthDate = new Date();
14 14 let currentWeekDate = new Date();
15 15 let monthEvents = [];
16 + let monthTasks = [];
16 17 let weekEvents = [];
17 18 let weekSwipeCleanup = null;
18 19
@@ -52,6 +53,21 @@
52 53 return map;
53 54 }
54 55
56 + /**
57 + * Group tasks by the local day they are due on. A task with no due date is
58 + * dropped: the grid is a calendar, and an undated task has no cell.
59 + */
60 + function groupTasksByDueDate(tasks) {
61 + const map = new Map();
62 + for (const t of tasks) {
63 + if (!t.due) continue;
64 + const key = toDateKey(new Date(t.due));
65 + if (!map.has(key)) map.set(key, []);
66 + map.get(key).push(t);
67 + }
68 + return map;
69 + }
70 +
55 71 function truncate(str, len) {
56 72 if (!str || str.length <= len) return str || '';
57 73 return str.substring(0, len - 1) + '\u2026';
@@ -83,14 +99,18 @@
83 99 const endOffset = (7 - ((lastDay.getDay() + 6) % 7 + 1)) % 7;
84 100 gridEnd.setDate(lastDay.getDate() + endOffset + 1);
85 101
86 - try {
87 - monthEvents = await GoingsOn.api.events.listBetween(
88 - gridStart.toISOString(), gridEnd.toISOString()
89 - );
90 - } catch (err) {
91 - console.error('Failed to load month events:', err);
92 - monthEvents = [];
93 - }
102 + // Events and due tasks together: the month grid answers "what lands on
103 + // this day", and a deadline lands on a day exactly as an event does.
104 + // Fetched over the whole grid, so the leading and trailing days of the
105 + // neighbouring months are populated too.
106 + const [events, tasks] = await Promise.all([
107 + GoingsOn.api.events.listBetween(gridStart.toISOString(), gridEnd.toISOString())
108 + .catch(err => { console.error('Failed to load month events:', err); return []; }),
109 + GoingsOn.api.tasks.listDueBetween(gridStart.toISOString(), gridEnd.toISOString())
110 + .catch(err => { console.error('Failed to load month tasks:', err); return []; }),
111 + ]);
112 + monthEvents = events;
113 + monthTasks = tasks;
94 114
95 115 renderMonthGrid(first, gridStart, gridEnd);
96 116 const label = document.getElementById('month-calendar-label');
@@ -101,6 +121,7 @@
101 121 const container = document.getElementById('month-calendar-grid');
102 122 if (!container) return;
103 123 const eventsByDate = groupByDate(monthEvents);
124 + const tasksByDate = groupTasksByDueDate(monthTasks);
104 125 const today = toDateKey(new Date());
105 126 const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
106 127
@@ -117,6 +138,7 @@
117 138 const isCurrentMonth = cursor.getMonth() === firstOfMonth.getMonth();
118 139 const isToday = dateKey === today;
119 140 const dayEvents = eventsByDate.get(dateKey) || [];
141 + const dayTasks = tasksByDate.get(dateKey) || [];
120 142
121 143 const classes = ['cal-month-cell'];
122 144 if (!isCurrentMonth) classes.push('other-month');
@@ -125,13 +147,25 @@
125 147 html += `<div class="${classes.join(' ')}" data-date="${escAttr(dateKey)}" data-act="eventsCalendar.toggleDayDetail" data-a1="${escAttr(dateKey)}">`;
126 148 html += `<div class="cal-month-cell-header"><span class="cal-day-number">${cursor.getDate()}</span></div>`;
127 149
150 + // Events first, then what is due: the day's fixed points, then the
151 + // work hanging off it. The cap is over both, so a busy day does not
152 + // grow a cell twice as tall as its neighbours.
128 153 const maxShow = 3;
154 + let shown = 0;
129 155 dayEvents.slice(0, maxShow).forEach(e => {
130 156 const blockClass = e.blockType ? `block-${e.blockType}` : '';
131 157 html += `<div class="cal-event-chip ${blockClass}" data-act="events.open" data-a1="${escAttr(e.id)}" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 18))}</div>`;
158 + shown++;
132 159 });
133 - if (dayEvents.length > maxShow) {
134 - html += `<div class="cal-event-more">+${dayEvents.length - maxShow} more</div>`;
160 + dayTasks.slice(0, Math.max(0, maxShow - shown)).forEach(t => {
161 + const overdue = t.isOverdue ? ' overdue' : '';
162 + const done = t.status === 'Completed' ? ' done' : '';
163 + html += `<div class="cal-task-chip${overdue}${done}" data-act="taskOverview.open" data-a1="${escAttr(t.id)}" title="Due: ${escAttrVal(t.title)}">${esc(truncate(t.title, 18))}</div>`;
164 + shown++;
165 + });
166 + const hidden = dayEvents.length + dayTasks.length - shown;
167 + if (hidden > 0) {
168 + html += `<div class="cal-event-more">+${hidden} more</div>`;
135 169 }
136 170
137 171 html += '</div>';
@@ -157,12 +191,13 @@
157 191
158 192 const eventsByDate = groupByDate(monthEvents);
159 193 const dayEvents = eventsByDate.get(dateKey) || [];
194 + const dayTasks = groupTasksByDueDate(monthTasks).get(dateKey) || [];
160 195 const dateObj = new Date(dateKey + 'T12:00:00');
161 196 const dayLabel = dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
162 197
163 198 let html = `<h3>${esc(dayLabel)}</h3>`;
164 - if (dayEvents.length === 0) {
165 - html += '<p class="no-events-day">No events this day.</p>';
199 + if (dayEvents.length === 0 && dayTasks.length === 0) {
200 + html += '<p class="no-events-day">Nothing on this day.</p>';
166 201 } else {
167 202 dayEvents.forEach(e => {
168 203 const blockClass = e.blockType ? `block-${e.blockType}` : '';
@@ -172,6 +207,15 @@
172 207 ${e.location ? `<span class="cal-detail-location">${esc(e.location)}</span>` : ''}
173 208 </div>`;
174 209 });
210 + dayTasks.forEach(t => {
211 + const overdue = t.isOverdue ? ' overdue' : '';
212 + const done = t.status === 'Completed' ? ' done' : '';
213 + html += `<div class="cal-day-detail-task${overdue}${done}" data-act="taskOverview.open" data-a1="${escAttr(t.id)}">
214 + <span class="cal-detail-time">Due</span>
215 + <span class="cal-detail-title">${esc(t.title)}</span>
216 + ${t.projectName ? `<span class="cal-detail-location">${esc(t.projectName)}</span>` : ''}
217 + </div>`;
218 + });
175 219 }
176 220
177 221 detail.dataset.date = dateKey;
@@ -11,13 +11,18 @@
11 11
12 12 use chrono::Datelike;
13 13 use goingson_core::{
14 - Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts,
15 - expand_recurrence_in_tz,
14 + Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, clip_to_day,
15 + detect_conflicts, expand_recurrence_in_tz,
16 16 };
17 17
18 18 use super::{ApiError, OptionNotFound, task::TaskResponse};
19 19 use crate::state::{AppState, DESKTOP_USER_ID};
20 20
21 + /// How long an item with no end time is taken to be, shared by the day-window
22 + /// clip and `detect_conflicts` so a row without an end is the same length to
23 + /// both. Matches `schedule_task`'s own default.
24 + const DEFAULT_ITEM_MINUTES: i32 = 30;
25 +
21 26 // Types
22 27
23 28 #[derive(Debug, Serialize)]
@@ -165,6 +170,17 @@
165 170 } else {
166 171 ("event".to_string(), None)
167 172 };
173 + // What of this event lands on the day being drawn. An event that runs
174 + // past midnight is one row and two bars, and each day's bar is clipped
175 + // to its own window; `day_end_excl` is the half-open bound, so an
176 + // event ending exactly at midnight belongs to the day it started.
177 + let span = clip_to_day(
178 + event.start_time,
179 + event.end_time,
180 + day_start,
181 + day_end_excl,
182 + DEFAULT_ITEM_MINUTES,
183 + );
168 184 TimelineItem {
169 185 id: event.id.into(),
170 186 item_type,
@@ -177,6 +193,11 @@
177 193 priority: None,
178 194 status: None,
179 195 block_type,
196 + is_all_day: span.is_all_day,
197 + day_offset_minutes: span.day_offset_minutes,
198 + visible_duration_minutes: span.visible_duration_minutes,
199 + continues_before: span.continues_before,
200 + continues_after: span.continues_after,
180 201 }
181 202 })
182 203 .collect();
@@ -327,6 +327,33 @@
327 327 })
328 328 }
329 329
330 + /// Lists tasks due inside a time window, for the calendar's month grid.
331 + ///
332 + /// The month grid is a calendar of what is due, so it wants tasks alongside
333 + /// events. The window is the whole grid, leading and trailing days included, so
334 + /// a task due on a neighbouring month's visible day still shows.
335 + ///
336 + /// # Arguments
337 + ///
338 + /// * `start` / `end` - Half-open UTC window `[start, end)`.
339 + ///
340 + /// # Errors
341 + ///
342 + /// Returns `DATABASE_ERROR` if the query fails.
343 + #[tauri::command]
344 + #[instrument(skip_all)]
345 + pub async fn list_tasks_due_between(
346 + state: State<'_, Arc<AppState>>,
347 + start: DateTime<Utc>,
348 + end: DateTime<Utc>,
349 + ) -> Result<Vec<TaskResponse>, ApiError> {
350 + let tasks = state
351 + .tasks
352 + .list_due_between(DESKTOP_USER_ID, start, end)
353 + .await?;
354 + Ok(tasks.into_iter().map(TaskResponse::from).collect())
355 + }
356 +
330 357 /// Retrieves a single task by ID.
331 358 ///
332 359 /// # Errors