max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 |