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