calendar.rs
⎇
Raw
1//! The calendar section's main area: the month grid, the agenda, the open
2//! invitations and the details of one event.
3//!
4//! The server expands recurrences and resolves time zones. This view only
5//! places the instances it gets on local days.
6
7use std::collections::{HashMap, HashSet};
8
9use leptos::prelude::*;
10use wasm_bindgen::{JsCast, JsValue};
11use wasm_bindgen_futures::spawn_local;
12
13use crate::api::{
14 self, PimCollectionInfo, PimEventDetail, PimInstance, PimInvitation, PimObjectDetail, PimReply,
15};
16use crate::components::icon::icon_svg;
17use crate::components::modal::Modal;
18use crate::components::toast::{ToastMsg, show, show_error};
19use crate::i18n::{self, k};
20use crate::icons::IconName;
21use crate::router::{self, Location, Section, navigate};
22use crate::util::{intl, owned_window_listener};
23use crate::views::pim::{browser_tz, display_name, safe_color};
24
25// ---------------------------------------------------------------------------
26// Days and the month grid (pure, so it is testable off the browser)
27// ---------------------------------------------------------------------------
28
29/// Days since 1970-01-01 of a proleptic Gregorian date.
30pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
31 let y = i64::from(if m <= 2 { y - 1 } else { y });
32 let era = y.div_euclid(400);
33 let yoe = y - era * 400;
34 let m = i64::from(m);
35 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(d) - 1;
36 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
37 era * 146_097 + doe - 719_468
38}
39
40pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
41 let z = z + 719_468;
42 let era = z.div_euclid(146_097);
43 let doe = z - era * 146_097;
44 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
45 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
46 let mp = (5 * doy + 2) / 153;
47 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
48 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
49 let y = (yoe + era * 400 + i64::from(m <= 2)) as i32;
50 (y, m, d)
51}
52
53/// 0 is Sunday.
54pub fn weekday(day: i64) -> u32 {
55 (day + 4).rem_euclid(7) as u32
56}
57
58/// The first day the month grid shows: the week start on or before the 1st.
59pub fn grid_start(y: i32, m: u32, week_start: u32) -> i64 {
60 let first = days_from_civil(y, m, 1);
61 first - i64::from((weekday(first) + 7 - week_start) % 7)
62}
63
64/// The grid always shows six weeks, so its height never jumps.
65pub const GRID_DAYS: i64 = 42;
66
67/// Cancelled, or declined by the calendar's owner: shown struck through.
68fn struck(i: &api::PimInstance) -> bool {
69 i.status.as_deref() == Some("CANCELLED") || i.partstat.as_deref() == Some("DECLINED")
70}
71
72fn add_months(y: i32, m: u32, n: i32) -> (i32, u32) {
73 let i = y * 12 + m as i32 - 1 + n;
74 (i.div_euclid(12), i.rem_euclid(12) as u32 + 1)
75}
76
77/// Where an instance lies in local time: its first and last day, and the
78/// minute of the day it starts on the first and ends on the last. An end at
79/// midnight counts as minute 1440 of the day before.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct Span {
82 pub first: i64,
83 pub last: i64,
84 pub all_day: bool,
85 pub start_min: u32,
86 pub end_min: u32,
87}
88
89impl Span {
90 /// The day and minute the instance ends, midnight as 00:00 of the next day.
91 fn end_at(&self) -> (i64, u32) {
92 match self.end_min {
93 1440 => (self.last + 1, 0),
94 m => (self.last, m),
95 }
96 }
97
98 /// A bar across days, not a line with a time.
99 fn is_bar(&self) -> bool {
100 self.all_day || self.first != self.last
101 }
102
103 fn covers(&self, day: i64) -> bool {
104 self.first <= day && day <= self.last
105 }
106}
107
108/// One piece of an instance in a week row. An instance hidden on some of
109/// its days by the "+N more" line becomes several pieces.
110#[derive(Clone, Debug, PartialEq)]
111pub struct Piece {
112 /// Index into the spans given to [`layout_week`].
113 pub item: usize,
114 pub col: usize,
115 pub len: usize,
116 pub line: usize,
117 /// The instance starts in this piece, rather than continuing into it.
118 pub starts: bool,
119 pub ends: bool,
120}
121
122#[derive(Clone, Debug, PartialEq)]
123pub struct WeekLayout {
124 pub pieces: Vec<Piece>,
125 /// Per day: how many instances the "+N more" line stands for.
126 pub more: [usize; 7],
127}
128
129/// Place a week's instances on `lines` lines. Bars come first, longer ones
130/// on top, then timed instances by start. A day with more instances than
131/// lines gives its last line to "+N more".
132pub fn layout_week(spans: &[Span], week: i64, lines: usize) -> WeekLayout {
133 let mut items: Vec<(usize, usize, usize)> = spans
134 .iter()
135 .enumerate()
136 .filter(|(_, s)| s.first <= week + 6 && s.last >= week)
137 .map(|(i, s)| {
138 let c0 = (s.first.max(week) - week) as usize;
139 let c1 = (s.last.min(week + 6) - week) as usize;
140 (i, c0, c1)
141 })
142 .collect();
143 items.sort_by_key(|&(i, c0, c1)| {
144 let s = &spans[i];
145 (!s.is_bar(), c0, usize::MAX - (c1 - c0), s.start_min, i)
146 });
147 let mut used: Vec<[bool; 7]> = Vec::new();
148 let mut placed = Vec::with_capacity(items.len());
149 let mut count = [0usize; 7];
150 for &(i, c0, c1) in &items {
151 let line = (0..)
152 .find(|&l| {
153 used.get(l)
154 .is_none_or(|row| !row[c0..=c1].iter().any(|&u| u))
155 })
156 .unwrap_or(0);
157 if used.len() <= line {
158 used.resize(line + 1, [false; 7]);
159 }
160 for c in c0..=c1 {
161 used[line][c] = true;
162 count[c] = count[c].max(line + 1);
163 }
164 placed.push((i, c0, c1, line));
165 }
166 let limit = |c: usize| {
167 if count[c] <= lines {
168 lines
169 } else {
170 lines.saturating_sub(1)
171 }
172 };
173 let mut pieces = Vec::new();
174 for &(i, c0, c1, line) in &placed {
175 let mut run: Option<usize> = None;
176 for c in c0..=c1 + 1 {
177 if c <= c1 && line < limit(c) {
178 run.get_or_insert(c);
179 } else if let Some(start) = run.take() {
180 let s = &spans[i];
181 pieces.push(Piece {
182 item: i,
183 col: start,
184 len: c - start,
185 line,
186 starts: start == c0 && s.first >= week,
187 ends: c - 1 == c1 && s.last <= week + 6,
188 });
189 }
190 }
191 }
192 let more = std::array::from_fn(|c| {
193 placed
194 .iter()
195 .filter(|&&(_, c0, c1, line)| c0 <= c && c <= c1 && line >= limit(c))
196 .count()
197 });
198 WeekLayout { pieces, more }
199}
200
201/// What an instance's time looks like on one of its days.
202#[derive(Clone, Copy, Debug, PartialEq)]
203pub enum When {
204 AllDay,
205 At(u32),
206 Range(u32, u32),
207 From(u32),
208 Until(u32),
209}
210
211pub fn when_on(s: &Span, day: i64) -> When {
212 if s.all_day {
213 When::AllDay
214 } else if s.first == s.last && s.start_min == s.end_min {
215 When::At(s.start_min)
216 } else if s.first == s.last {
217 When::Range(s.start_min, s.end_min)
218 } else if day == s.first {
219 When::From(s.start_min)
220 } else if day == s.last && s.end_min < 1440 {
221 When::Until(s.end_min)
222 } else {
223 When::AllDay
224 }
225}
226
227/// Split plain text into runs, `true` for an `http(s)://` link. A trailing
228/// period or closing bracket belongs to the sentence, not the link.
229pub fn split_links(text: &str) -> Vec<(String, bool)> {
230 let mut out = Vec::new();
231 let mut rest = text;
232 let mut from = 0;
233 while let Some((at, scheme)) = ["https://", "http://"]
234 .iter()
235 .filter_map(|p| rest[from..].find(p).map(|i| (from + i, p.len())))
236 .min()
237 {
238 let end = rest[at..]
239 .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
240 .map_or(rest.len(), |e| at + e);
241 let mut link = &rest[at..end];
242 loop {
243 let t = link.trim_end_matches(['.', ',', ';', ':', '!', '?']);
244 let t = match t.strip_suffix(')') {
245 Some(u) if t.matches(')').count() > t.matches('(').count() => u,
246 _ => t,
247 };
248 if t.len() == link.len() {
249 break;
250 }
251 link = t;
252 }
253 if link.len() <= scheme {
254 from = at + scheme;
255 continue;
256 }
257 if at > 0 {
258 out.push((rest[..at].to_string(), false));
259 }
260 out.push((link.to_string(), true));
261 rest = &rest[at + link.len()..];
262 from = 0;
263 }
264 if !rest.is_empty() {
265 out.push((rest.to_string(), false));
266 }
267 out
268}
269
270// ---------------------------------------------------------------------------
271// Browser dates
272// ---------------------------------------------------------------------------
273
274fn js_day(d: &js_sys::Date) -> i64 {
275 days_from_civil(d.get_full_year() as i32, d.get_month() + 1, d.get_date())
276}
277
278fn day_date(day: i64) -> js_sys::Date {
279 let (y, m, d) = civil_from_days(day);
280 js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, d as i32)
281}
282
283fn today() -> i64 {
284 js_day(&js_sys::Date::new_0())
285}
286
287/// Local midnight of `day` as RFC 3339 UTC, for the instances endpoint.
288fn day_rfc3339(day: i64) -> String {
289 day_date(day).to_iso_string().into()
290}
291
292fn span_of(i: &PimInstance) -> Span {
293 span_at(&i.start, &i.end, i.all_day)
294}
295
296fn span_at(start: &str, end: &str, all_day: bool) -> Span {
297 let s = js_sys::Date::new(&start.into());
298 let e = js_sys::Date::new(&end.into());
299 let first = js_day(&s);
300 let start_min = s.get_hours() * 60 + s.get_minutes();
301 let (last, end_min) = if e.get_time() > s.get_time() {
302 let last = js_day(&js_sys::Date::new(&JsValue::from_f64(e.get_time() - 1.0)));
303 let end_min = if js_day(&e) > last {
304 1440
305 } else {
306 e.get_hours() * 60 + e.get_minutes()
307 };
308 (last, end_min)
309 } else {
310 (first, start_min)
311 };
312 Span {
313 first,
314 last: last.max(first),
315 all_day,
316 start_min,
317 end_min,
318 }
319}
320
321fn fmt_min(day: i64, min: u32) -> String {
322 let (y, m, d) = civil_from_days(day);
323 let t = js_sys::Date::new_with_year_month_day_hr_min(
324 y as u32,
325 m as i32 - 1,
326 d as i32,
327 (min / 60) as i32,
328 (min % 60) as i32,
329 );
330 intl(&t, &[("hour", "2-digit"), ("minute", "2-digit")])
331}
332
333fn fmt_when(w: When, day: i64) -> String {
334 match w {
335 When::AllDay => i18n::t(k::PIM_ALL_DAY).to_string(),
336 When::At(m) => fmt_min(day, m),
337 When::Range(a, b) => format!("{} – {}", fmt_min(day, a), fmt_min(day, b)),
338 When::From(m) => i18n::t_fmt(k::PIM_FROM_TIME, &fmt_min(day, m)),
339 When::Until(m) => i18n::t_fmt(k::PIM_UNTIL_TIME, &fmt_min(day, m)),
340 }
341}
342
343fn fmt_day_long(day: i64) -> String {
344 intl(
345 &day_date(day),
346 &[
347 ("weekday", "long"),
348 ("day", "numeric"),
349 ("month", "long"),
350 ("year", "numeric"),
351 ],
352 )
353}
354
355fn fmt_day_short(day: i64) -> String {
356 intl(
357 &day_date(day),
358 &[
359 ("weekday", "short"),
360 ("day", "numeric"),
361 ("month", "short"),
362 ("year", "numeric"),
363 ],
364 )
365}
366
367/// The full time of an instance, for its details.
368fn fmt_span(s: &Span) -> String {
369 match (s.all_day, s.first == s.last) {
370 (true, true) => fmt_day_short(s.first),
371 (true, false) => format!("{} – {}", fmt_day_short(s.first), fmt_day_short(s.last)),
372 (false, true) => format!(
373 "{} · {}",
374 fmt_day_short(s.first),
375 fmt_when(when_on(s, s.first), s.first)
376 ),
377 (false, false) => {
378 let (day, min) = s.end_at();
379 format!(
380 "{} {} – {} {}",
381 fmt_day_short(s.first),
382 fmt_min(s.first, s.start_min),
383 fmt_day_short(day),
384 fmt_min(day, min)
385 )
386 }
387 }
388}
389
390// ---------------------------------------------------------------------------
391// The view
392// ---------------------------------------------------------------------------
393
394const TAB_IDS: [&str; 3] = ["cal-tab-month", "cal-tab-agenda", "cal-tab-invitations"];
395
396/// Lines per day cell in the month grid, the last one shared with
397/// "+N more".
398const LINES: usize = 4;
399
400#[derive(Clone, Copy, Debug, PartialEq)]
401enum CalView {
402 Month(i32, u32),
403 Agenda,
404 Invitations,
405}
406
407/// `YYYY-MM` from the URL. Outside 1000–9999 JS dates and the grid disagree.
408fn parse_month(ym: &str) -> Option<(i32, u32)> {
409 let (y, m) = ym.split_once('-')?;
410 let (y, m) = (y.parse().ok()?, m.parse().ok()?);
411 ((1000..=9999).contains(&y) && (1..=12).contains(&m)).then_some((y, m))
412}
413
414impl CalView {
415 fn from_path(path: &[String]) -> Self {
416 match path.first().map(String::as_str) {
417 Some("agenda") => CalView::Agenda,
418 Some("invitations") => CalView::Invitations,
419 _ => path
420 .get(1)
421 .and_then(|ym| parse_month(ym))
422 .map(|(y, m)| CalView::Month(y, m))
423 .unwrap_or_else(|| {
424 let (y, m, _) = civil_from_days(today());
425 CalView::Month(y, m)
426 }),
427 }
428 }
429
430 fn path(self) -> Vec<String> {
431 match self {
432 CalView::Month(y, m) => vec!["month".into(), format!("{y:04}-{m:02}")],
433 CalView::Agenda => vec!["agenda".into()],
434 CalView::Invitations => vec!["invitations".into()],
435 }
436 }
437}
438
439fn location(view: CalView) -> Location {
440 Location {
441 section: Section::Calendar,
442 path: view.path(),
443 ..Location::root()
444 }
445}
446
447fn go(view: CalView) {
448 navigate(&location(view));
449}
450
451/// The event whose details are open, as the URL names it:
452/// `{collection}/{recurrence id or -}/{name}`.
453#[derive(Clone, Debug, PartialEq)]
454struct Opened {
455 collection_id: i64,
456 recurrence_id: Option<String>,
457 name: String,
458}
459
460impl Opened {
461 fn of(i: &PimInstance) -> Self {
462 Opened {
463 collection_id: i.collection_id,
464 recurrence_id: i.recurrence_id.clone(),
465 name: i.name.clone(),
466 }
467 }
468
469 fn param(&self) -> String {
470 format!(
471 "{}/{}/{}",
472 self.collection_id,
473 self.recurrence_id.as_deref().unwrap_or("-"),
474 self.name
475 )
476 }
477
478 fn parse(s: &str) -> Option<Self> {
479 let mut it = s.splitn(3, '/');
480 let collection_id = it.next()?.parse().ok()?;
481 let rid = it.next()?;
482 let name = it.next()?.to_string();
483 Some(Opened {
484 collection_id,
485 recurrence_id: (rid != "-").then(|| rid.to_string()),
486 name,
487 })
488 }
489
490 fn open(&self) {
491 router::set_open(&self.param());
492 }
493}
494
495/// An invitation as the instance it names, for the day and time helpers.
496fn inv_instance(v: &PimInvitation) -> PimInstance {
497 PimInstance {
498 collection_id: v.collection_id,
499 name: v.name.clone(),
500 uid: v.uid.clone(),
501 recurrence_id: v.recurrence_id.clone(),
502 start: v.start.clone(),
503 end: v.end.clone(),
504 all_day: v.all_day,
505 component: "VEVENT".into(),
506 summary: v.summary.clone(),
507 location: v.location.clone(),
508 status: None,
509 transparent: false,
510 has_attendees: true,
511 partstat: None,
512 organizer: None,
513 }
514}
515
516/// An instance with what the views derive from it.
517#[derive(Clone)]
518struct Shown {
519 inst: PimInstance,
520 span: Span,
521 color: String,
522}
523
524#[component]
525pub fn CalendarMain(
526 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
527 hidden: RwSignal<HashSet<i64>>,
528 loc: ReadSignal<Location>,
529 /// 0 is Sunday. From the profile.
530 week_start: Signal<u32>,
531) -> impl IntoView {
532 let view = Memo::new(move |_| loc.with(|l| CalView::from_path(&l.path)));
533 let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(Opened::parse)));
534 // Bumped after an answer or an import: every list refetches.
535 let tick = RwSignal::new(0u32);
536
537 let month = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
538 let month_seq = StoredValue::new(0u32);
539 let month_key = StoredValue::new(None::<(i32, u32)>);
540 // The new month's grid appears only after its fetch, without focus.
541 let refocus = StoredValue::new(false);
542 Effect::new(move |_| {
543 tick.track();
544 // The first answer would come before the collections and go to waste.
545 if collections.with(Option::is_none) {
546 return;
547 }
548 let CalView::Month(y, m) = view.get() else {
549 return;
550 };
551 // Another month's instances would land on the wrong days.
552 if month_key.get_value() != Some((y, m)) {
553 month_key.set_value(Some((y, m)));
554 month.set(None);
555 }
556 let start = grid_start(y, m, week_start.get());
557 let seq = month_seq.get_value() + 1;
558 month_seq.set_value(seq);
559 spawn_local(async move {
560 let r = api::pim_instances(
561 &day_rfc3339(start),
562 &day_rfc3339(start + GRID_DAYS),
563 &browser_tz(),
564 )
565 .await;
566 if month_seq.try_get_value() == Some(seq) {
567 let _ = month.try_set(Some(r.map_err(|e| e.to_string())));
568 if refocus.try_get_value() == Some(true) {
569 refocus.set_value(false);
570 request_animation_frame(|| {
571 let cell = web_sys::window()
572 .and_then(|w| w.document())
573 .and_then(|d| {
574 d.query_selector(".cal-weeks .cal-cell:not(.cal-out)")
575 .ok()
576 .flatten()
577 })
578 .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
579 if let Some(cell) = cell {
580 let _ = cell.focus();
581 }
582 });
583 }
584 }
585 });
586 });
587
588 let agenda = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
589 let agenda_seq = StoredValue::new(0u32);
590 Effect::new(move |_| {
591 tick.track();
592 if collections.with(Option::is_none) {
593 return;
594 }
595 if view.get() != CalView::Agenda {
596 return;
597 }
598 let from = today();
599 let seq = agenda_seq.get_value() + 1;
600 agenda_seq.set_value(seq);
601 spawn_local(async move {
602 let r = api::pim_instances(
603 &day_rfc3339(from),
604 &day_rfc3339(from + AGENDA_DAYS),
605 &browser_tz(),
606 )
607 .await;
608 if agenda_seq.try_get_value() == Some(seq) {
609 let _ = agenda.try_set(Some(r.map_err(|e| e.to_string())));
610 }
611 });
612 });
613
614 let invitations = RwSignal::new(Option::<Result<Vec<PimInvitation>, String>>::None);
615 let invitations_seq = StoredValue::new(0u32);
616 Effect::new(move |_| {
617 tick.track();
618 if collections.with(Option::is_none) {
619 return;
620 }
621 let seq = invitations_seq.get_value() + 1;
622 invitations_seq.set_value(seq);
623 spawn_local(async move {
624 let r = api::pim_invitations(&browser_tz()).await;
625 if invitations_seq.try_get_value() == Some(seq) {
626 let _ = invitations.try_set(Some(r.map_err(|e| e.to_string())));
627 }
628 });
629 });
630 let refresh = Callback::new(move |_| tick.update(|t| *t += 1));
631
632 let colors = Memo::new(move |_| {
633 collections.with(|c| {
634 c.iter()
635 .flatten()
636 .map(|c| (c.id, safe_color(c.color.as_deref())))
637 .collect::<HashMap<_, _>>()
638 })
639 });
640 let shown = move |list: &api::PimInstances| -> Vec<Shown> {
641 let colors = colors.get();
642 hidden.with(|h| {
643 list.instances
644 .iter()
645 .filter(|i| !h.contains(&i.collection_id))
646 .map(|i| Shown {
647 span: span_of(i),
648 color: colors
649 .get(&i.collection_id)
650 .cloned()
651 .unwrap_or_else(|| safe_color(None)),
652 inst: i.clone(),
653 })
654 .collect()
655 })
656 };
657
658 // PageUp / PageDown turn the month while the grid has focus. Elsewhere
659 // they scroll the page to the day list.
660 owned_window_listener(leptos::ev::keydown, move |ev| {
661 let step = match ev.key().as_str() {
662 "PageUp" => -1,
663 "PageDown" => 1,
664 _ => return,
665 };
666 if ev.alt_key() || ev.ctrl_key() || ev.meta_key() || ev.shift_key() {
667 return;
668 }
669 let in_grid = web_sys::window()
670 .and_then(|w| w.document())
671 .and_then(|d| d.active_element())
672 .and_then(|e| e.closest(".cal-weeks").ok().flatten())
673 .is_some();
674 if let (CalView::Month(y, m), true) = (view.get_untracked(), in_grid) {
675 ev.prevent_default();
676 refocus.set_value(true);
677 let (y, m) = add_months(y, m, step);
678 go(CalView::Month(y, m));
679 }
680 });
681
682 let pending = move || {
683 invitations.with(|i| match i {
684 Some(Ok(list)) => list.len(),
685 _ => 0,
686 })
687 };
688 let is_month = Memo::new(move |_| matches!(view.get(), CalView::Month(..)));
689 // The month view selects today again even when the month stays.
690 let today_clicks = RwSignal::new(0u32);
691 // The month shown, or this month from another tab.
692 let this_month = move || match view.get_untracked() {
693 v @ CalView::Month(..) => v,
694 _ => {
695 let (y, m, _) = civil_from_days(today());
696 CalView::Month(y, m)
697 }
698 };
699 let step_month = move |step: i32| {
700 if let CalView::Month(y, m) = view.get_untracked() {
701 let (y, m) = add_months(y, m, step);
702 go(CalView::Month(y, m));
703 }
704 };
705 let title = move || match view.get() {
706 CalView::Month(y, m) => intl(
707 &js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, 1),
708 &[("month", "long"), ("year", "numeric")],
709 ),
710 CalView::Agenda => i18n::t(k::PIM_UPCOMING).to_string(),
711 CalView::Invitations => i18n::t(k::PIM_INVITATIONS).to_string(),
712 };
713 let tab_of = |v: CalView| match v {
714 CalView::Month(..) => 0,
715 CalView::Agenda => 1,
716 CalView::Invitations => 2,
717 };
718 let selected = move |i: usize| tab_of(view.get()) == i;
719 let tab_at = move |i: usize| [this_month(), CalView::Agenda, CalView::Invitations][i];
720 // The buttons stay mounted across views, so a keyboard user keeps focus.
721 let toolbar = view! {
722 <div class="cal-toolbar">
723 {move || is_month.get().then(|| view! {
724 <div class="cal-nav">
725 <button
726 class="icon-btn icon-btn-sm"
727 title=i18n::t(k::PIM_PREV_MONTH)
728 aria-label=i18n::t(k::PIM_PREV_MONTH)
729 on:click=move |_| step_month(-1)
730 >
731 {icon_svg(IconName::ChevronRight, "ic-btn flip")}
732 </button>
733 <button
734 class="btn btn-sm"
735 on:click=move |_| {
736 let (y, m, _) = civil_from_days(today());
737 go(CalView::Month(y, m));
738 today_clicks.update(|n| *n += 1);
739 }
740 >
741 {i18n::t(k::PIM_TODAY)}
742 </button>
743 <button
744 class="icon-btn icon-btn-sm"
745 title=i18n::t(k::PIM_NEXT_MONTH)
746 aria-label=i18n::t(k::PIM_NEXT_MONTH)
747 on:click=move |_| step_month(1)
748 >
749 {icon_svg(IconName::ChevronRight, "ic-btn")}
750 </button>
751 </div>
752 })}
753 <h2 class="cal-title">{title}</h2>
754 <div class="cal-views" role="tablist" on:keydown=move |ev| {
755 if ev.alt_key() || ev.ctrl_key() || ev.meta_key() || ev.shift_key() {
756 return;
757 }
758 let at = tab_of(view.get_untracked());
759 let to = match ev.key().as_str() {
760 "ArrowRight" => (at + 1) % 3,
761 "ArrowLeft" => (at + 2) % 3,
762 "Home" => 0,
763 "End" => 2,
764 _ => return,
765 };
766 ev.prevent_default();
767 // Moving between tabs with keys adds no history entries.
768 router::replace(&location(tab_at(to)));
769 let tab = web_sys::window()
770 .and_then(|w| w.document())
771 .and_then(|d| d.get_element_by_id(TAB_IDS[to]))
772 .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok());
773 if let Some(tab) = tab {
774 let _ = tab.focus();
775 }
776 }>
777 {(0..3).map(|i| view! {
778 <button
779 role="tab"
780 id=TAB_IDS[i]
781 aria-controls="cal-panel"
782 tabindex=move || if selected(i) { "0" } else { "-1" }
783 class="cal-view-btn"
784 class:active=move || selected(i)
785 aria-selected=move || selected(i).to_string()
786 on:click=move |_| go(tab_at(i))
787 >
788 {i18n::t([k::PIM_MONTH, k::PIM_AGENDA, k::PIM_INVITATIONS][i])}
789 {(i == 2).then_some(move || (pending() > 0).then(|| view! {
790 <span class="cal-badge">{pending()}</span>
791 }))}
792 </button>
793 }).collect_view()}
794 </div>
795 </div>
796 };
797
798 let body = move || match view.get() {
799 CalView::Month(y, m) => view! {
800 <MonthView
801 y=y
802 m=m
803 week_start=week_start.get()
804 data=month
805 on_retry=refresh
806 shown=shown
807 today_clicks=today_clicks
808 />
809 }
810 .into_any(),
811 CalView::Agenda => view! {
812 <AgendaView data=agenda on_retry=refresh shown=shown/>
813 }
814 .into_any(),
815 CalView::Invitations => view! {
816 <InvitationsView data=invitations collections=collections on_replied=refresh/>
817 }
818 .into_any(),
819 };
820
821 view! {
822 <div class="cal">
823 {toolbar}
824 <div
825 id="cal-panel"
826 role="tabpanel"
827 aria-labelledby=move || TAB_IDS[tab_of(view.get())]
828 >
829 {body}
830 </div>
831 </div>
832 {move || opened.get().map(|o| view! {
833 <EventDialog
834 target=o
835 collections=collections
836 on_replied=refresh
837 on_close=Callback::new(move |_| router::close_open())
838 />
839 })}
840 }
841}
842
843fn truncated_note(list: &api::PimInstances) -> Option<impl IntoView + use<>> {
844 list.truncated.then(|| {
845 view! { <p class="cal-truncated">{i18n::t(k::PIM_TRUNCATED)}</p> }
846 })
847}
848
849fn loading_or_error(
850 data: &Option<Result<api::PimInstances, String>>,
851 retry: Callback<()>,
852) -> Result<api::PimInstances, AnyView> {
853 match data {
854 None => Err(view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any()),
855 Some(Err(e)) => Err(view! {
856 <div class="card error-card">
857 <span>{e.clone()}</span>
858 <button class="btn" on:click=move |_| retry.run(())>{i18n::t(k::RETRY)}</button>
859 </div>
860 }
861 .into_any()),
862 Some(Ok(list)) => Ok(list.clone()),
863 }
864}
865
866/// One line of a day list: time, color and title.
867fn day_row(s: &Shown, day: i64) -> impl IntoView + use<> {
868 let o = Opened::of(&s.inst);
869 let when = fmt_when(when_on(&s.span, day), day);
870 let title = s
871 .inst
872 .summary
873 .clone()
874 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
875 let cancelled = struck(&s.inst);
876 let location = s.inst.location.clone();
877 view! {
878 <button class="cal-row" class:cal-cancelled=cancelled on:click=move |_| o.open()>
879 <span class="cal-row-time">{when}</span>
880 <span class="cal-dot" style=format!("--c: {}", s.color)></span>
881 <span class="cal-row-main">
882 <span class="cal-row-title">{title}</span>
883 {location.map(|l| view! { <span class="cal-row-meta">{l}</span> })}
884 </span>
885 </button>
886 }
887}
888
889#[component]
890fn MonthView(
891 y: i32,
892 m: u32,
893 week_start: u32,
894 data: RwSignal<Option<Result<api::PimInstances, String>>>,
895 on_retry: Callback<()>,
896 shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
897 today_clicks: RwSignal<u32>,
898) -> impl IntoView {
899 let start = grid_start(y, m, week_start);
900 let now = today();
901 let in_month = move |d: i64| {
902 let (yy, mm, _) = civil_from_days(d);
903 yy == y && mm == m
904 };
905 let selected = RwSignal::new(if in_month(now) {
906 now
907 } else {
908 days_from_civil(y, m, 1)
909 });
910 Effect::new(move |seen: Option<()>| {
911 today_clicks.track();
912 if seen.is_some() && in_month(today()) {
913 selected.set(today());
914 }
915 });
916 let list_ref: NodeRef<leptos::html::Div> = NodeRef::new();
917 let pick = move |day: i64| {
918 selected.set(day);
919 if let Some(el) = list_ref.get_untracked() {
920 let opts = web_sys::ScrollIntoViewOptions::new();
921 opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
922 el.scroll_into_view_with_scroll_into_view_options(&opts);
923 }
924 };
925 let head = (0..7)
926 .map(|i| {
927 view! {
928 <div class="cal-head-day">{intl(&day_date(start + i), &[("weekday", "short")])}</div>
929 }
930 })
931 .collect::<Vec<_>>();
932 view! {
933 <div class="cal-month">
934 <div class="cal-head">{head}</div>
935 {move || data.with(|d| match loading_or_error(d, on_retry) {
936 Err(v) => v,
937 Ok(list) => {
938 let items = shown(&list);
939 let spans: Vec<Span> = items.iter().map(|s| s.span).collect();
940 let weeks = (0..6)
941 .map(|w| {
942 let week = start + w * 7;
943 let layout = layout_week(&spans, week, LINES);
944 let cells = (0..7usize)
945 .map(|c| {
946 let day = week + c as i64;
947 let (_, _, dnum) = civil_from_days(day);
948 let on_day = items.iter().filter(|s| s.span.covers(day));
949 let n = on_day.clone().count();
950 let dots: Vec<String> =
951 on_day.take(3).map(|s| s.color.clone()).collect();
952 let label = match n {
953 0 => fmt_day_long(day),
954 1 => format!("{}, {}", fmt_day_long(day), i18n::t(k::PIM_EVENTS_ONE)),
955 n => format!(
956 "{}, {}",
957 fmt_day_long(day),
958 i18n::t_fmt(k::PIM_EVENTS_N, &n.to_string())
959 ),
960 };
961 view! {
962 <button
963 class="cal-cell"
964 class:cal-out=!in_month(day)
965 class:cal-today=day == now
966 class:cal-sel=move || selected.get() == day
967 style=format!("grid-column: {}", c + 1)
968 aria-label=label
969 on:click=move |_| pick(day)
970 >
971 <span class="cal-num">{dnum}</span>
972 <span class="cal-dots">
973 {dots
974 .into_iter()
975 .map(|c| view! { <span class="cal-dot" style=format!("--c: {c}")></span> })
976 .collect::<Vec<_>>()}
977 </span>
978 </button>
979 }
980 })
981 .collect::<Vec<_>>();
982 let pieces = layout
983 .pieces
984 .iter()
985 .map(|p| {
986 let s = &items[p.item];
987 let o = Opened::of(&s.inst);
988 let bar = s.span.is_bar();
989 let title = s
990 .inst
991 .summary
992 .clone()
993 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
994 let time = (!bar).then(|| fmt_min(s.span.first, s.span.start_min));
995 let cancelled = struck(&s.inst);
996 let tip = match &time {
997 Some(t) => format!("{t} {title}"),
998 None => title.clone(),
999 };
1000 view! {
1001 <button
1002 class="cal-item"
1003 class:cal-bar=bar
1004 class:cal-chip=!bar
1005 class:cal-cont-l=!p.starts
1006 class:cal-cont-r=!p.ends
1007 class:cal-cancelled=cancelled
1008 style=format!(
1009 "grid-column: {} / span {}; grid-row: {}; --c: {}",
1010 p.col + 1,
1011 p.len,
1012 p.line + 2,
1013 s.color
1014 )
1015 title=tip
1016 on:click=move |_| o.open()
1017 >
1018 {(!bar).then(|| view! { <span class="cal-dot"></span> })}
1019 {time.map(|t| view! { <span class="cal-time">{t}</span> })}
1020 <span class="cal-item-title">{title}</span>
1021 </button>
1022 }
1023 })
1024 .collect::<Vec<_>>();
1025 let more = layout
1026 .more
1027 .iter()
1028 .enumerate()
1029 .filter(|&(_, &n)| n > 0)
1030 .map(|(c, &n)| {
1031 let day = week + c as i64;
1032 view! {
1033 <button
1034 class="cal-more"
1035 style=format!("grid-column: {}; grid-row: {}", c + 1, LINES + 1)
1036 on:click=move |_| pick(day)
1037 >
1038 {i18n::t_fmt(k::PIM_MORE_N, &n.to_string())}
1039 </button>
1040 }
1041 })
1042 .collect::<Vec<_>>();
1043 view! {
1044 <div class="cal-week" style=format!("--lines: {LINES}")>
1045 {cells}
1046 {pieces}
1047 {more}
1048 </div>
1049 }
1050 })
1051 .collect::<Vec<_>>();
1052 let day_list = move || {
1053 let day = selected.get();
1054 let rows: Vec<_> = items
1055 .iter()
1056 .filter(|s| s.span.covers(day))
1057 .map(|s| day_row(s, day))
1058 .collect();
1059 view! {
1060 <h3 class="cal-day-title">{fmt_day_long(day)}</h3>
1061 {if rows.is_empty() {
1062 view! { <p class="muted cal-pad">{i18n::t(k::PIM_NO_EVENTS_DAY)}</p> }.into_any()
1063 } else {
1064 view! { <div class="cal-rows">{rows}</div> }.into_any()
1065 }}
1066 }
1067 };
1068 view! {
1069 {truncated_note(&list)}
1070 <div class="cal-weeks">{weeks}</div>
1071 <div class="cal-day" node_ref=list_ref>{day_list}</div>
1072 }
1073 .into_any()
1074 }
1075 })}
1076 </div>
1077 }
1078}
1079
1080/// The agenda shows today and the days after it. The month view is for
1081/// browsing further.
1082const AGENDA_DAYS: i64 = 7;
1083
1084#[component]
1085fn AgendaView(
1086 data: RwSignal<Option<Result<api::PimInstances, String>>>,
1087 on_retry: Callback<()>,
1088 shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
1089) -> impl IntoView {
1090 view! {
1091 <div class="cal-agenda">
1092 {move || data.with(|d| match loading_or_error(d, on_retry) {
1093 Err(v) => v,
1094 Ok(list) => {
1095 let items = shown(&list);
1096 let from = today();
1097 let groups: Vec<_> = (from..from + AGENDA_DAYS)
1098 .filter_map(|day| {
1099 let rows: Vec<_> = items
1100 .iter()
1101 .filter(|s| s.span.covers(day))
1102 .map(|s| day_row(s, day))
1103 .collect();
1104 (!rows.is_empty()).then(|| view! {
1105 <section class="cal-agenda-day">
1106 <h3 class="cal-day-title" class:cal-day-today=day == from>
1107 {fmt_day_long(day)}
1108 </h3>
1109 <div class="cal-rows">{rows}</div>
1110 </section>
1111 })
1112 })
1113 .collect();
1114 let empty = groups.is_empty().then(|| view! {
1115 <div class="empty-state">
1116 {icon_svg(IconName::Calendar, "empty-glyph")}
1117 <p class="muted">{i18n::t(k::PIM_NO_EVENTS_AGENDA)}</p>
1118 </div>
1119 });
1120 view! {
1121 {truncated_note(&list)}
1122 {empty}
1123 {groups}
1124 }
1125 .into_any()
1126 }
1127 })}
1128 </div>
1129 }
1130}
1131
1132/// Answer an invitation, then refresh the lists, also after a failure: the
1133/// invitation may have changed elsewhere. `busy` stays set on success; `done`
1134/// ends it once the refresh has landed.
1135fn reply(
1136 toast: ToastMsg,
1137 busy: RwSignal<bool>,
1138 collection_id: i64,
1139 name: String,
1140 recurrence_id: Option<String>,
1141 partstat: &'static str,
1142 done: Callback<()>,
1143) {
1144 busy.set(true);
1145 spawn_local(async move {
1146 let body = PimReply {
1147 collection_id,
1148 name,
1149 recurrence_id,
1150 partstat: partstat.to_string(),
1151 tz: Some(browser_tz()),
1152 };
1153 match api::pim_reply(body).await {
1154 Ok(_) => {
1155 show(toast, i18n::t(k::PIM_REPLIED));
1156 // The dialog or view may be gone by now.
1157 let _ = done.try_run(());
1158 }
1159 Err(e) => {
1160 let _ = busy.try_set(false);
1161 show_error(toast, e.to_string());
1162 let _ = done.try_run(());
1163 }
1164 }
1165 });
1166}
1167
1168/// Accept, maybe, decline. `current` marks the answer already given.
1169fn reply_buttons(
1170 current: Option<String>,
1171 busy: RwSignal<bool>,
1172 on: impl Fn(&'static str) + Clone + 'static,
1173) -> impl IntoView {
1174 [
1175 ("ACCEPTED", k::PIM_ACCEPT),
1176 ("TENTATIVE", k::PIM_TENTATIVE),
1177 ("DECLINED", k::PIM_DECLINE),
1178 ]
1179 .map(|(ps, label)| {
1180 let on = on.clone();
1181 let active = current.as_deref() == Some(ps);
1182 view! {
1183 <button
1184 class="btn btn-sm"
1185 class:btn-primary=active
1186 aria-pressed=active.to_string()
1187 disabled=move || busy.get()
1188 on:click=move |_| on(ps)
1189 >
1190 {i18n::t(label)}
1191 </button>
1192 }
1193 })
1194 .into_iter()
1195 .collect::<Vec<_>>()
1196}
1197
1198#[component]
1199fn InvitationsView(
1200 data: RwSignal<Option<Result<Vec<PimInvitation>, String>>>,
1201 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
1202 on_replied: Callback<()>,
1203) -> impl IntoView {
1204 let toast = use_context::<ToastMsg>().expect("toast context");
1205 view! {
1206 <div class="cal-invitations">
1207 {move || match data.get() {
1208 None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
1209 Some(Err(e)) => view! {
1210 <div class="card error-card">
1211 <span>{e}</span>
1212 <button class="btn" on:click=move |_| on_replied.run(())>{i18n::t(k::RETRY)}</button>
1213 </div>
1214 }
1215 .into_any(),
1216 Some(Ok(list)) if list.is_empty() => view! {
1217 <div class="empty-state">
1218 {icon_svg(IconName::Calendar, "empty-glyph")}
1219 <p class="muted">{i18n::t(k::PIM_NO_INVITATIONS)}</p>
1220 </div>
1221 }
1222 .into_any(),
1223 Some(Ok(list)) => {
1224 let names: HashMap<i64, String> = collections.with(|c| {
1225 c.iter().flatten().map(|c| (c.id, display_name(c))).collect()
1226 });
1227 list.into_iter()
1228 .map(|inv| {
1229 // A series opens at its next instance, the one listed.
1230 let o = Opened {
1231 collection_id: inv.collection_id,
1232 recurrence_id: inv
1233 .recurrence_id
1234 .clone()
1235 .or_else(|| inv.recurring.then(|| inv.start.clone())),
1236 name: inv.name.clone(),
1237 };
1238 let span = span_of(&inv_instance(&inv));
1239 let series = inv.recurring && inv.recurrence_id.is_none();
1240 let mut meta = vec![fmt_span(&span)];
1241 match (&inv.rrule, &inv.recurrence_id) {
1242 (Some(r), None) => meta.push(rrule_text(r, Some(span.first))),
1243 (None, Some(_)) => meta.push(i18n::t(k::PIM_THIS_EVENT).to_string()),
1244 _ => {}
1245 }
1246 if let Some(n) = names.get(&inv.collection_id) {
1247 meta.push(n.clone());
1248 }
1249 let organizer = inv.organizer.as_ref().map(|p| {
1250 p.name.clone().unwrap_or_else(|| {
1251 p.address.trim_start_matches("mailto:").to_string()
1252 })
1253 });
1254 let title = inv
1255 .summary
1256 .clone()
1257 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
1258 let (id, name, rid) =
1259 (inv.collection_id, inv.name.clone(), inv.recurrence_id.clone());
1260 let open = o.clone();
1261 let busy = RwSignal::new(false);
1262 view! {
1263 <div class="cal-inv">
1264 <button class="cal-inv-main" on:click=move |_| open.open()>
1265 <span class="cal-inv-title">{title}</span>
1266 <span class="cal-row-meta">{meta.join(" · ")}</span>
1267 {inv.location.clone().map(|l| view! { <span class="cal-row-meta">{l}</span> })}
1268 {organizer.map(|n| view! {
1269 <span class="cal-row-meta">{i18n::t_fmt(k::PIM_FROM_ORGANIZER, &n)}</span>
1270 })}
1271 {series.then(|| view! {
1272 <span class="cal-row-meta">{i18n::t(k::PIM_INV_SERIES_HINT)}</span>
1273 })}
1274 </button>
1275 <div class="cal-inv-actions">
1276 {reply_buttons(None, busy, move |ps| {
1277 reply(toast, busy, id, name.clone(), rid.clone(), ps, on_replied)
1278 })}
1279 </div>
1280 </div>
1281 }
1282 })
1283 .collect::<Vec<_>>()
1284 .into_any()
1285 }
1286 }}
1287 </div>
1288 }
1289}
1290
1291fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) {
1292 match ps {
1293 Some("ACCEPTED") => (k::PIM_PS_ACCEPTED, "ps-accepted"),
1294 Some("DECLINED") => (k::PIM_PS_DECLINED, "ps-declined"),
1295 Some("TENTATIVE") => (k::PIM_PS_TENTATIVE, "ps-tentative"),
1296 Some("DELEGATED") => (k::PIM_PS_DELEGATED, "ps-muted"),
1297 _ => (k::PIM_PS_NEEDS_ACTION, "ps-muted"),
1298 }
1299}
1300
1301/// `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` as words; a rule it cannot put into
1302/// words stays raw.
1303/// `rule` in words, with the weekday of `first` when it names none, or
1304/// else `rule` itself.
1305fn rrule_text(rule: &str, first: Option<i64>) -> String {
1306 let full = first.map_or_else(|| rule.to_string(), |f| with_start_day(rule, f));
1307 rrule_words(&full, weekday_name, month_name, until_date).unwrap_or_else(|| rule.to_string())
1308}
1309
1310fn rrule_words(
1311 rule: &str,
1312 day: fn(&str) -> Option<String>,
1313 month: fn(u32) -> Option<String>,
1314 date: fn(&str) -> Option<String>,
1315) -> Option<String> {
1316 let part = |key: &str| {
1317 rule.split(';')
1318 .find_map(|p| p.strip_prefix(key)?.strip_prefix('='))
1319 };
1320 let list = |key: &str| part(key).into_iter().flat_map(|v| v.split(','));
1321 let n = match part("INTERVAL") {
1322 Some(n) => n.parse::<u32>().ok().filter(|&n| n > 0)?,
1323 None => 1,
1324 };
1325 let freq = part("FREQ");
1326 let keys = match freq {
1327 Some("DAILY") => (k::PIM_RRULE_DAILY, k::PIM_RRULE_DAILY_N),
1328 Some("WEEKLY") => (k::PIM_RRULE_WEEKLY, k::PIM_RRULE_WEEKLY_N),
1329 Some("MONTHLY") => (k::PIM_RRULE_MONTHLY, k::PIM_RRULE_MONTHLY_N),
1330 Some("YEARLY") => (k::PIM_RRULE_YEARLY, k::PIM_RRULE_YEARLY_N),
1331 _ => return None,
1332 };
1333 let mut text = match n {
1334 1 => i18n::t(keys.0).to_string(),
1335 n => i18n::t_fmt(keys.1, &n.to_string()),
1336 };
1337 let numbered = matches!(freq, Some("MONTHLY" | "YEARLY"));
1338 let worded = |key: &str| match key {
1339 "FREQ" | "INTERVAL" | "UNTIL" | "COUNT" | "WKST" | "BYDAY" => true,
1340 "BYMONTH" => freq == Some("YEARLY"),
1341 "BYMONTHDAY" => numbered,
1342 _ => false,
1343 };
1344 let all_worded = rule
1345 .split(';')
1346 .all(|p| p.is_empty() || p.split_once('=').is_some_and(|(key, _)| worded(key)));
1347 // Without BYMONTH, a yearly BYDAY or BYMONTHDAY counts through the whole year.
1348 let yearwide = freq == Some("YEARLY")
1349 && part("BYMONTH").is_none()
1350 && (part("BYDAY").is_some() || part("BYMONTHDAY").is_some());
1351 // BYDAY with BYMONTHDAY is an intersection; listing both reads as a union.
1352 let both = part("BYDAY").is_some() && part("BYMONTHDAY").is_some();
1353 if !all_worded || yearwide || both {
1354 return None;
1355 }
1356 let mut on = Vec::new();
1357 for m in list("BYMONTH") {
1358 on.push(month(m.parse().ok()?)?);
1359 }
1360 for d in list("BYDAY") {
1361 let split = d.len().checked_sub(2)?;
1362 let (nth, code) = d.split_at_checked(split)?;
1363 let name = day(code)?;
1364 on.push(match nth {
1365 // A plain weekday outside WEEKLY means every such day of the
1366 // period, which "Every month: Mon" does not say.
1367 "" if freq == Some("WEEKLY") => name,
1368 "" => return None,
1369 _ if !numbered => return None,
1370 nth => i18n::t_fmt2(k::PIM_RRULE_NTH, ordinal(nth)?, &name),
1371 });
1372 }
1373 for d in list("BYMONTHDAY") {
1374 on.push(match d {
1375 "-1" => i18n::t(k::PIM_RRULE_LAST_DAY).to_string(),
1376 d => i18n::t_fmt(k::PIM_RRULE_MONTH_DAY, &d.parse::<u8>().ok()?.to_string()),
1377 });
1378 }
1379 if !on.is_empty() {
1380 text = format!("{text}: {}", on.join(", "));
1381 }
1382 if let Some(u) = part("UNTIL") {
1383 text = format!("{text}; {}", i18n::t_fmt(k::PIM_RRULE_UNTIL, &date(u)?));
1384 } else if let Some(c) = part("COUNT") {
1385 text = format!(
1386 "{text}; {}",
1387 i18n::t_fmt(k::PIM_RRULE_COUNT, &c.parse::<u32>().ok()?.to_string())
1388 );
1389 }
1390 Some(text)
1391}
1392
1393/// The ordinal of `2TU` or `-1FR`. Others are rare enough to stay raw.
1394fn ordinal(nth: &str) -> Option<&'static str> {
1395 Some(i18n::t(match nth {
1396 "1" | "+1" => k::PIM_RRULE_ORD_1,
1397 "2" | "+2" => k::PIM_RRULE_ORD_2,
1398 "3" | "+3" => k::PIM_RRULE_ORD_3,
1399 "4" | "+4" => k::PIM_RRULE_ORD_4,
1400 "5" | "+5" => k::PIM_RRULE_ORD_5,
1401 "-1" => k::PIM_RRULE_ORD_LAST,
1402 _ => return None,
1403 }))
1404}
1405
1406fn month_name(m: u32) -> Option<String> {
1407 let m = (1..=12).contains(&m).then_some(m)?;
1408 let date = js_sys::Date::new_with_year_month_day(2024, m as i32 - 1, 1);
1409 Some(intl(&date, &[("month", "long")]))
1410}
1411
1412/// UNTIL (`20261231` or `20261231T225959Z`) as a local date.
1413fn until_date(u: &str) -> Option<String> {
1414 let num = |r: std::ops::Range<usize>| u.get(r)?.parse::<u32>().ok();
1415 let (y, m, d) = (num(0..4)?, num(4..6)?, num(6..8)?);
1416 let date = match u.get(8..)? {
1417 t if t.len() == 8 && t.is_ascii() && t.ends_with('Z') => {
1418 let iso = format!(
1419 "{y:04}-{m:02}-{d:02}T{}:{}:{}Z",
1420 &t[1..3],
1421 &t[3..5],
1422 &t[5..7]
1423 );
1424 js_sys::Date::new(&iso.into())
1425 }
1426 // A DATE, or floating: the wall-clock date.
1427 _ => js_sys::Date::new_with_year_month_day(y, m as i32 - 1, d as i32),
1428 };
1429 (!date.get_time().is_nan()).then(|| intl(&date, &[("dateStyle", "medium")]))
1430}
1431
1432/// A weekly rule without BYDAY repeats on its start's weekday. Naming it
1433/// makes "Every week" read "Every week: Tue".
1434fn with_start_day(rule: &str, first: i64) -> String {
1435 let weekly = rule.split(';').any(|p| p == "FREQ=WEEKLY");
1436 if !weekly || rule.split(';').any(|p| p.starts_with("BYDAY=")) {
1437 return rule.to_string();
1438 }
1439 let code = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][weekday(first) as usize];
1440 format!("{rule};BYDAY={code}")
1441}
1442
1443/// `TU` as the locale's short weekday name.
1444fn weekday_name(code: &str) -> Option<String> {
1445 let offset = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
1446 .iter()
1447 .position(|&d| d == code)?;
1448 // 2024-01-01 was a Monday.
1449 let date = js_sys::Date::new_with_year_month_day(2024, 0, 1 + offset as i32);
1450 Some(intl(&date, &[("weekday", "short")]))
1451}
1452
1453fn person_name(p: &api_types::PimPerson) -> String {
1454 p.name
1455 .clone()
1456 .unwrap_or_else(|| p.address.trim_start_matches("mailto:").to_string())
1457}
1458
1459/// Text with its web links clickable, for a `white-space: pre-wrap` block.
1460pub fn linkified(text: &str) -> impl IntoView + use<> {
1461 split_links(text)
1462 .into_iter()
1463 .map(|(t, link)| match link {
1464 true => {
1465 let href = t.clone();
1466 view! { <a href=href target="_blank" rel="noopener noreferrer">{t}</a> }.into_any()
1467 }
1468 false => t.into_any(),
1469 })
1470 .collect::<Vec<_>>()
1471}
1472
1473#[component]
1474fn EventDialog(
1475 target: Opened,
1476 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
1477 on_replied: Callback<()>,
1478 on_close: Callback<()>,
1479) -> impl IntoView {
1480 let toast = use_context::<ToastMsg>().expect("toast context");
1481 let detail = RwSignal::new(Option::<Result<PimEventDetail, String>>::None);
1482 let busy = RwSignal::new(false);
1483 let load_seq = StoredValue::new(0u32);
1484 // For one instance of a series: answer it alone (`true`), or the whole
1485 // series. Set on the first load, so a reply does not flip it.
1486 let chosen = RwSignal::new(None::<bool>);
1487 let load = {
1488 let t = target.clone();
1489 move || {
1490 let t = t.clone();
1491 // Also runs after an await, when the view may be gone.
1492 let Some(seq) = load_seq.try_get_value().map(|s| s + 1) else {
1493 return;
1494 };
1495 load_seq.set_value(seq);
1496 spawn_local(async move {
1497 let r = api::pim_object(
1498 t.collection_id,
1499 &t.name,
1500 t.recurrence_id.as_deref(),
1501 &browser_tz(),
1502 )
1503 .await;
1504 let r = match r {
1505 Ok(PimObjectDetail::Event(e)) => Ok(e),
1506 Ok(PimObjectDetail::Contact(_)) => Err(i18n::t(k::PIM_NOT_EVENT).to_string()),
1507 Err(e) => Err(e.to_string()),
1508 };
1509 if load_seq.try_get_value() == Some(seq) {
1510 // A reload after a reply keeps the event shown.
1511 match r {
1512 Err(e) if matches!(detail.try_get_untracked(), Some(Some(Ok(_)))) => {
1513 show_error(toast, e)
1514 }
1515 r => {
1516 // An unanswered series came as one invitation for all of it.
1517 if let (Ok(e), Some(None)) = (&r, chosen.try_get_untracked()) {
1518 let whole = e.series_partstat.as_deref() == Some("NEEDS-ACTION");
1519 let _ = chosen.try_set(Some(!whole));
1520 }
1521 let _ = detail.try_set(Some(r));
1522 }
1523 }
1524 let _ = busy.try_set(false);
1525 }
1526 });
1527 }
1528 };
1529 load();
1530 let collection_id = target.collection_id;
1531 let collection = Memo::new(move |_| {
1532 collections.with(|c| {
1533 c.iter()
1534 .flatten()
1535 .find(|c| c.id == collection_id)
1536 .map(|c| (display_name(c), safe_color(c.color.as_deref())))
1537 })
1538 });
1539 // Every state carries the element that names the dialog.
1540 let body = move || match detail.get() {
1541 None => {
1542 view! { <p id="cal-detail-title" class="muted">{i18n::t(k::LOADING)}</p> }.into_any()
1543 }
1544 Some(Err(e)) => view! { <p id="cal-detail-title" class="form-error">{e}</p> }.into_any(),
1545 Some(Ok(d)) => {
1546 let title = d
1547 .summary
1548 .clone()
1549 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
1550 let mine = d
1551 .attendees
1552 .iter()
1553 .find(|a| a.is_owner)
1554 .and_then(|a| a.partstat.clone());
1555 // An override is one event of its own, whatever the series does.
1556 let series = target.recurrence_id.is_some() && d.rrule.is_some() && !d.is_override;
1557 let only_this = move || chosen.get().unwrap_or(true);
1558 let rid = target.recurrence_id.clone();
1559 let (id, name) = (target.collection_id, target.name.clone());
1560 let load = load.clone();
1561 let done = Callback::new(move |_| {
1562 load();
1563 let _ = on_replied.try_run(());
1564 });
1565 let answer = move |ps: &'static str| {
1566 // Without the choice, the instance alone.
1567 let rid = if !series || chosen.get_untracked().unwrap_or(true) {
1568 rid.clone()
1569 } else {
1570 None
1571 };
1572 reply(toast, busy, id, name.clone(), rid, ps, done);
1573 };
1574 let url = d
1575 .url
1576 .clone()
1577 .filter(|u| u.starts_with("https://") || u.starts_with("http://"));
1578 view! {
1579 <h2 id="cal-detail-title" class="modal-title cal-detail-title" class:cal-cancelled=d.status.as_deref() == Some("CANCELLED")>
1580 {title}
1581 </h2>
1582 {(d.status.as_deref() == Some("CANCELLED")).then(|| view! {
1583 <p class="cal-flag">{i18n::t(k::PIM_CANCELLED)}</p>
1584 })}
1585 <dl class="cal-facts">
1586 {d.start.as_deref().zip(d.end.as_deref()).map(|(s, e)| view! {
1587 <dt>{i18n::t(k::PIM_WHEN)}</dt>
1588 <dd>{fmt_span(&span_at(s, e, d.all_day))}</dd>
1589 })}
1590 {d.rrule.clone().map(|r| {
1591 // An override may sit on another day than the series.
1592 let text = match d.start.as_deref().zip(d.end.as_deref()) {
1593 Some((s, e)) if !d.is_override => {
1594 rrule_text(&r, Some(span_at(s, e, d.all_day).first))
1595 }
1596 _ => rrule_text(&r, None),
1597 };
1598 view! {
1599 <dt>{i18n::t(k::PIM_REPEATS)}</dt>
1600 <dd title=r.clone()>{text}</dd>
1601 }
1602 })}
1603 {d.location.clone().map(|l| view! {
1604 <dt>{i18n::t(k::PIM_LOCATION)}</dt>
1605 <dd class="cal-pre">{linkified(&l)}</dd>
1606 })}
1607 {collection.get().map(|(n, c)| view! {
1608 <dt>{i18n::t(k::PIM_CALENDAR)}</dt>
1609 <dd><span class="cal-dot" style=format!("--c: {c}")></span>" "{n}</dd>
1610 })}
1611 {d.organizer.as_ref().map(|p| view! {
1612 <dt>{i18n::t(k::PIM_ORGANIZER)}</dt>
1613 <dd>{person_name(p)}</dd>
1614 })}
1615 {url.map(|u| {
1616 let href = u.clone();
1617 view! {
1618 <dt>{i18n::t(k::PIM_LINK)}</dt>
1619 <dd><a href=href target="_blank" rel="noopener noreferrer">{u}</a></dd>
1620 }
1621 })}
1622 {(!d.categories.is_empty()).then(|| view! {
1623 <dt>{i18n::t(k::PIM_CATEGORIES)}</dt>
1624 <dd>{d.categories.join(", ")}</dd>
1625 })}
1626 </dl>
1627 {(!d.attendees.is_empty()).then(|| view! {
1628 <h3 class="pim-section-title">{i18n::t(k::PIM_ATTENDEES)}</h3>
1629 <ul class="cal-attendees">
1630 {d.attendees
1631 .iter()
1632 .map(|a| {
1633 let (label, class) = partstat_label(a.partstat.as_deref());
1634 let optional = a.role.as_deref() == Some("OPT-PARTICIPANT");
1635 view! {
1636 <li>
1637 <span class="cal-att-name" title=a.person.address.clone()>
1638 {person_name(&a.person)}
1639 </span>
1640 {optional.then(|| view! { <span class="muted">{i18n::t(k::PIM_OPTIONAL)}</span> })}
1641 <span class=format!("ps-badge {class}")>{i18n::t(label)}</span>
1642 </li>
1643 }
1644 })
1645 .collect::<Vec<_>>()}
1646 </ul>
1647 })}
1648 {d.description.clone().filter(|t| !t.trim().is_empty()).map(|t| view! {
1649 <div class="cal-desc cal-pre">{linkified(&t)}</div>
1650 })}
1651 {d.can_reply.then(|| view! {
1652 <section class="cal-reply">
1653 <h3 class="pim-section-title">{i18n::t(k::PIM_YOUR_ANSWER)}</h3>
1654 {series.then(|| view! {
1655 <div class="cal-scope" role="radiogroup">
1656 <label class="check-row">
1657 <input
1658 type="radio"
1659 name="cal-scope"
1660 prop:checked=only_this
1661 on:change=move |_| chosen.set(Some(true))
1662 />
1663 {i18n::t(k::PIM_THIS_EVENT)}
1664 </label>
1665 <label class="check-row">
1666 <input
1667 type="radio"
1668 name="cal-scope"
1669 prop:checked=move || !only_this()
1670 on:change=move |_| chosen.set(Some(false))
1671 />
1672 {i18n::t(k::PIM_ALL_EVENTS)}
1673 </label>
1674 </div>
1675 })}
1676 <div class="cal-inv-actions">{reply_buttons(mine, busy, answer)}</div>
1677 </section>
1678 })}
1679 }
1680 .into_any()
1681 }
1682 };
1683 view! {
1684 <Modal class="pim-modal cal-detail" labelled_by="cal-detail-title" on_close=on_close>
1685 {body}
1686 <div class="modal-actions">
1687 <button class="btn" on:click=move |_| on_close.run(())>{i18n::t(k::CLOSE)}</button>
1688 </div>
1689 </Modal>
1690 }
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::*;
1696
1697 #[test]
1698 fn url_years_are_clamped() {
1699 assert_eq!(parse_month("2026-10"), Some((2026, 10)));
1700 assert_eq!(parse_month("9999-12"), Some((9999, 12)));
1701 for bad in ["0050-01", "-3-01", "10000-01", "2026-13", "x-01"] {
1702 assert_eq!(parse_month(bad), None, "{bad}");
1703 }
1704 }
1705
1706 fn day(y: i32, m: u32, d: u32) -> i64 {
1707 days_from_civil(y, m, d)
1708 }
1709
1710 fn timed(first: i64, start_min: u32) -> Span {
1711 Span {
1712 first,
1713 last: first,
1714 all_day: false,
1715 start_min,
1716 end_min: start_min + 60,
1717 }
1718 }
1719
1720 fn all_day(first: i64, last: i64) -> Span {
1721 Span {
1722 first,
1723 last,
1724 all_day: true,
1725 start_min: 0,
1726 end_min: 1440,
1727 }
1728 }
1729
1730 #[test]
1731 fn civil_days_round_trip() {
1732 assert_eq!(day(1970, 1, 1), 0);
1733 assert_eq!(day(2000, 3, 1), 11017);
1734 for z in [-800_000, -1, 0, 59, 60, 11016, 20_000, 2_000_000] {
1735 let (y, m, d) = civil_from_days(z);
1736 assert_eq!(day(y, m, d), z);
1737 }
1738 assert_eq!(civil_from_days(day(2024, 2, 29)), (2024, 2, 29));
1739 // 2026-10-01 is a Thursday.
1740 assert_eq!(weekday(day(2026, 10, 1)), 4);
1741 }
1742
1743 #[test]
1744 fn grid_starts_on_the_week_start() {
1745 // October 2026 starts on a Thursday.
1746 let sunday = grid_start(2026, 10, 0);
1747 let monday = grid_start(2026, 10, 1);
1748 assert_eq!(civil_from_days(sunday), (2026, 9, 27));
1749 assert_eq!(civil_from_days(monday), (2026, 9, 28));
1750 assert_eq!(weekday(monday), 1);
1751 assert_eq!(civil_from_days(grid_start(2026, 10, 6)), (2026, 9, 26));
1752 // A month starting on the week start begins the grid itself, and
1753 // six weeks always cover the whole month.
1754 assert_eq!(grid_start(2026, 6, 1), day(2026, 6, 1));
1755 for (y, m) in [(2026, 2), (2026, 8), (2027, 1)] {
1756 for ws in [0, 1, 6] {
1757 let s = grid_start(y, m, ws);
1758 let (ny, nm) = add_months(y, m, 1);
1759 assert!(s <= day(y, m, 1) && day(ny, nm, 1) <= s + GRID_DAYS);
1760 }
1761 }
1762 assert_eq!(add_months(2026, 12, 1), (2027, 1));
1763 assert_eq!(add_months(2026, 1, -1), (2025, 12));
1764 }
1765
1766 #[test]
1767 fn bars_split_across_weeks() {
1768 // Saturday to Tuesday, in weeks starting on Monday.
1769 let week1 = grid_start(2026, 10, 1);
1770 let span = all_day(day(2026, 10, 3), day(2026, 10, 6));
1771 let a = layout_week(&[span], week1, 4);
1772 assert_eq!(
1773 a.pieces,
1774 [Piece {
1775 item: 0,
1776 col: 5,
1777 len: 2,
1778 line: 0,
1779 starts: true,
1780 ends: false
1781 }]
1782 );
1783 let b = layout_week(&[span], week1 + 7, 4);
1784 assert_eq!(
1785 b.pieces,
1786 [Piece {
1787 item: 0,
1788 col: 0,
1789 len: 2,
1790 line: 0,
1791 starts: false,
1792 ends: true
1793 }]
1794 );
1795 assert_eq!(a.more, [0; 7]);
1796 }
1797
1798 #[test]
1799 fn busy_days_share_the_last_line_with_more() {
1800 let week = grid_start(2026, 10, 1);
1801 let mon = week;
1802 // Five timed events on Monday; a bar Monday to Wednesday.
1803 let mut spans: Vec<Span> = (0..5).map(|i| timed(mon, 600 + i * 30)).collect();
1804 spans.push(all_day(mon, mon + 2));
1805 let l = layout_week(&spans, week, 3);
1806 // Monday has six instances on three lines: two shown, four behind
1807 // "+4 more". The bar comes first, so it stays visible.
1808 assert_eq!(l.more, [4, 0, 0, 0, 0, 0, 0]);
1809 let bar = l.pieces.iter().find(|p| p.item == 5).unwrap();
1810 assert_eq!((bar.col, bar.len, bar.line), (0, 3, 0));
1811 assert_eq!(l.pieces.iter().filter(|p| p.col == 0).count(), 2);
1812 // Timed events keep their order.
1813 let first_chip = l.pieces.iter().find(|p| p.item != 5).unwrap();
1814 assert_eq!(first_chip.item, 0);
1815 }
1816
1817 #[test]
1818 fn a_weekly_rule_names_its_start_day() {
1819 let friday = days_from_civil(2026, 10, 2);
1820 assert_eq!(
1821 with_start_day("FREQ=WEEKLY", friday),
1822 "FREQ=WEEKLY;BYDAY=FR"
1823 );
1824 assert_eq!(
1825 with_start_day("FREQ=WEEKLY;BYDAY=MO", friday),
1826 "FREQ=WEEKLY;BYDAY=MO"
1827 );
1828 assert_eq!(with_start_day("FREQ=DAILY", friday), "FREQ=DAILY");
1829 }
1830
1831 #[test]
1832 fn a_rule_reads_as_words() {
1833 let words = |r: &str| {
1834 rrule_words(
1835 r,
1836 |d| Some(d.to_string()),
1837 |m| Some(format!("M{m}")),
1838 |u| Some(u[..8].to_string()),
1839 )
1840 };
1841 assert_eq!(
1842 words("FREQ=WEEKLY;BYDAY=TU,TH").as_deref(),
1843 Some("Every week: TU, TH")
1844 );
1845 assert_eq!(
1846 words("FREQ=MONTHLY;BYDAY=2TU;COUNT=3").as_deref(),
1847 Some("Every month: 2nd TU; 3 times")
1848 );
1849 assert_eq!(
1850 words("FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=15,-1;UNTIL=20261231T225959Z").as_deref(),
1851 Some("Every 2 months: day 15, last day; until 20261231")
1852 );
1853 assert_eq!(
1854 words("FREQ=YEARLY;BYMONTH=5;BYDAY=-1SU").as_deref(),
1855 Some("Every year: M5, last SU")
1856 );
1857 assert_eq!(words("FREQ=MONTHLY;BYDAY=-2FR"), None);
1858 assert_eq!(words("FREQ=WEEKLY;BYDAY=2TU"), None);
1859 assert_eq!(words("FREQ=HOURLY"), None);
1860 for raw in [
1861 "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1",
1862 "FREQ=MONTHLY;BYMONTH=6;BYMONTHDAY=1",
1863 "FREQ=WEEKLY;BYMONTHDAY=15",
1864 "FREQ=DAILY;BYMONTHDAY=1",
1865 "FREQ=YEARLY;BYMONTHDAY=15",
1866 "FREQ=YEARLY;BYDAY=MO",
1867 "FREQ=YEARLY;BYYEARDAY=100",
1868 "FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO",
1869 "FREQ=DAILY;BYHOUR=9,17",
1870 "FREQ=WEEKLY;X-FOO=1",
1871 "FREQ=MONTHLY;BYDAY=MO",
1872 "FREQ=WEEKLY;INTERVAL=0",
1873 "FREQ=WEEKLY;INTERVAL=abc",
1874 "FREQ=YEARLY;BYMONTH=3;BYDAY=SU",
1875 "FREQ=DAILY;BYDAY=MO,TU",
1876 "FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13",
1877 "FREQ=MONTHLY;BYDAY=1FR;BYMONTHDAY=1,2,3",
1878 ] {
1879 assert_eq!(words(raw), None, "{raw}");
1880 }
1881 assert_eq!(
1882 words("FREQ=WEEKLY;WKST=SU;BYDAY=MO;").as_deref(),
1883 Some("Every week: MO")
1884 );
1885 }
1886
1887 #[test]
1888 fn a_bar_hidden_on_a_busy_day_splits() {
1889 let week = grid_start(2026, 10, 1);
1890 // Two bars over Mon–Wed; three timed events on Tuesday only.
1891 let mut spans = vec![all_day(week, week + 2), all_day(week, week + 2)];
1892 spans.extend((0..3).map(|i| timed(week + 1, 600 + i * 60)));
1893 let l = layout_week(&spans, week, 2);
1894 // Tuesday overflows, so its last line (the second bar) shows "+4".
1895 assert_eq!(l.more, [0, 4, 0, 0, 0, 0, 0]);
1896 let second: Vec<_> = l.pieces.iter().filter(|p| p.item == 1).collect();
1897 assert_eq!(second.len(), 2);
1898 assert_eq!(
1899 (second[0].col, second[0].len, second[0].ends),
1900 (0, 1, false)
1901 );
1902 assert_eq!(
1903 (second[1].col, second[1].len, second[1].starts),
1904 (2, 1, false)
1905 );
1906 }
1907
1908 #[test]
1909 fn times_on_each_day() {
1910 let s = Span {
1911 first: 10,
1912 last: 12,
1913 all_day: false,
1914 start_min: 22 * 60,
1915 end_min: 9 * 60,
1916 };
1917 assert_eq!(when_on(&s, 10), When::From(1320));
1918 assert_eq!(when_on(&s, 11), When::AllDay);
1919 assert_eq!(when_on(&s, 12), When::Until(540));
1920 // Ending at midnight, the last day runs to its end.
1921 let s = Span { end_min: 1440, ..s };
1922 assert_eq!(when_on(&s, 12), When::AllDay);
1923 assert_eq!(when_on(&timed(5, 600), 5), When::Range(600, 660));
1924 assert_eq!(when_on(&all_day(5, 5), 5), When::AllDay);
1925 }
1926
1927 #[test]
1928 fn links_are_found_in_text() {
1929 assert_eq!(
1930 split_links("See https://example.com/a?b=1. Or http://x.org)"),
1931 [
1932 ("See ".to_string(), false),
1933 ("https://example.com/a?b=1".to_string(), true),
1934 (". Or ".to_string(), false),
1935 ("http://x.org".to_string(), true),
1936 (")".to_string(), false),
1937 ]
1938 );
1939 assert_eq!(
1940 split_links("no links, javascript:alert(1)"),
1941 [("no links, javascript:alert(1)".to_string(), false)]
1942 );
1943 }
1944
1945 #[test]
1946 fn links_need_a_host_and_keep_balanced_brackets() {
1947 assert_eq!(
1948 split_links("a https:// b"),
1949 [("a https:// b".to_string(), false)]
1950 );
1951 assert_eq!(
1952 split_links("(https://en.wikipedia.org/wiki/Foo_(bar))"),
1953 [
1954 ("(".to_string(), false),
1955 ("https://en.wikipedia.org/wiki/Foo_(bar)".to_string(), true),
1956 (")".to_string(), false),
1957 ]
1958 );
1959 }
1960
1961 #[test]
1962 fn more_counts_the_hidden_items_of_a_column() {
1963 let week = grid_start(2026, 10, 1);
1964 let d = |c: i64| week + c;
1965 let five: Vec<Span> = (0..5).map(|n| timed(d(1), 60 * n)).collect();
1966 let l = layout_week(&five, week, 3);
1967 assert_eq!(l.more, [0, 3, 0, 0, 0, 0, 0]);
1968 }
1969
1970 #[test]
1971 fn a_lone_item_on_a_low_line_gets_no_more() {
1972 let week = grid_start(2026, 10, 1);
1973 let d = |c: i64| week + c;
1974 let spans = [all_day(d(0), d(4)), timed(d(6), 600)];
1975 let l = layout_week(&spans, week, 3);
1976 assert_eq!(l.more, [0; 7]);
1977 }
1978
1979 #[test]
1980 fn a_bar_pushed_below_the_lines_is_counted_where_it_hides() {
1981 let week = grid_start(2026, 10, 1);
1982 let d = |c: i64| week + c;
1983 // Three bars fill the lines of days 0 to 4, so the fourth lands on line 3.
1984 let mut spans = vec![all_day(d(0), d(4)); 3];
1985 spans.push(all_day(d(4), d(6)));
1986 let l = layout_week(&spans, week, 3);
1987 assert_eq!(l.more, [0, 0, 0, 0, 2, 1, 1]);
1988 }
1989
1990 #[test]
1991 fn midnight_end_is_the_next_day() {
1992 let mut s = timed(5, 600);
1993 s.last = 6;
1994 s.end_min = 1440;
1995 assert_eq!(s.end_at(), (7, 0));
1996 s.end_min = 540;
1997 assert_eq!(s.end_at(), (6, 540));
1998 }
1999
2000 #[test]
2001 fn opened_round_trips() {
2002 let o = Opened {
2003 collection_id: 7,
2004 recurrence_id: Some("2026-10-05T08:00:00Z".into()),
2005 name: "a/b.ics".into(),
2006 };
2007 assert_eq!(Opened::parse(&o.param()), Some(o));
2008 let series = Opened {
2009 collection_id: -1,
2010 recurrence_id: None,
2011 name: "x.ics".into(),
2012 };
2013 assert_eq!(Opened::parse(&series.param()), Some(series));
2014 }
2015}
2016