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::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, Locale, 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/// Sunday for English, Monday for German and French.
68fn week_start(locale: Locale) -> u32 {
69 match locale {
70 Locale::En => 0,
71 Locale::De | Locale::Fr => 1,
72 }
73}
74
75fn add_months(y: i32, m: u32, n: i32) -> (i32, u32) {
76 let i = y * 12 + m as i32 - 1 + n;
77 (i.div_euclid(12), i.rem_euclid(12) as u32 + 1)
78}
79
80/// Where an instance lies in local time: its first and last day, and the
81/// minute of the day it starts on the first and ends on the last. An end at
82/// midnight counts as minute 1440 of the day before.
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub struct Span {
85 pub first: i64,
86 pub last: i64,
87 pub all_day: bool,
88 pub start_min: u32,
89 pub end_min: u32,
90}
91
92impl Span {
93 /// A bar across days, not a line with a time.
94 fn is_bar(&self) -> bool {
95 self.all_day || self.first != self.last
96 }
97
98 fn covers(&self, day: i64) -> bool {
99 self.first <= day && day <= self.last
100 }
101}
102
103/// One piece of an instance in a week row. An instance hidden on some of
104/// its days by the "+N more" line becomes several pieces.
105#[derive(Clone, Debug, PartialEq)]
106pub struct Piece {
107 /// Index into the spans given to [`layout_week`].
108 pub item: usize,
109 pub col: usize,
110 pub len: usize,
111 pub line: usize,
112 /// The instance starts in this piece, rather than continuing into it.
113 pub starts: bool,
114 pub ends: bool,
115}
116
117#[derive(Clone, Debug, PartialEq)]
118pub struct WeekLayout {
119 pub pieces: Vec<Piece>,
120 /// Per day: how many instances the "+N more" line stands for.
121 pub more: [usize; 7],
122}
123
124/// Place a week's instances on `lines` lines. Bars come first, longer ones
125/// on top, then timed instances by start. A day with more instances than
126/// lines gives its last line to "+N more".
127pub fn layout_week(spans: &[Span], week: i64, lines: usize) -> WeekLayout {
128 let mut items: Vec<(usize, usize, usize)> = spans
129 .iter()
130 .enumerate()
131 .filter(|(_, s)| s.first <= week + 6 && s.last >= week)
132 .map(|(i, s)| {
133 let c0 = (s.first.max(week) - week) as usize;
134 let c1 = (s.last.min(week + 6) - week) as usize;
135 (i, c0, c1)
136 })
137 .collect();
138 items.sort_by_key(|&(i, c0, c1)| {
139 let s = &spans[i];
140 (!s.is_bar(), c0, usize::MAX - (c1 - c0), s.start_min, i)
141 });
142 let mut used: Vec<[bool; 7]> = Vec::new();
143 let mut placed = Vec::with_capacity(items.len());
144 let mut count = [0usize; 7];
145 for &(i, c0, c1) in &items {
146 let line = (0..)
147 .find(|&l| {
148 used.get(l)
149 .is_none_or(|row| !row[c0..=c1].iter().any(|&u| u))
150 })
151 .unwrap_or(0);
152 if used.len() <= line {
153 used.resize(line + 1, [false; 7]);
154 }
155 for c in c0..=c1 {
156 used[line][c] = true;
157 count[c] += 1;
158 }
159 placed.push((i, c0, c1, line));
160 }
161 let limit = |c: usize| {
162 if count[c] <= lines {
163 lines
164 } else {
165 lines.saturating_sub(1)
166 }
167 };
168 let mut pieces = Vec::new();
169 for &(i, c0, c1, line) in &placed {
170 let mut run: Option<usize> = None;
171 for c in c0..=c1 + 1 {
172 if c <= c1 && line < limit(c) {
173 run.get_or_insert(c);
174 } else if let Some(start) = run.take() {
175 let s = &spans[i];
176 pieces.push(Piece {
177 item: i,
178 col: start,
179 len: c - start,
180 line,
181 starts: start == c0 && s.first >= week,
182 ends: c - 1 == c1 && s.last <= week + 6,
183 });
184 }
185 }
186 }
187 let more = std::array::from_fn(|c| {
188 placed
189 .iter()
190 .filter(|&&(_, c0, c1, line)| c0 <= c && c <= c1 && line >= limit(c))
191 .count()
192 });
193 WeekLayout { pieces, more }
194}
195
196/// What an instance's time looks like on one of its days.
197#[derive(Clone, Copy, Debug, PartialEq)]
198pub enum When {
199 AllDay,
200 At(u32),
201 Range(u32, u32),
202 From(u32),
203 Until(u32),
204}
205
206pub fn when_on(s: &Span, day: i64) -> When {
207 if s.all_day {
208 When::AllDay
209 } else if s.first == s.last && s.start_min == s.end_min {
210 When::At(s.start_min)
211 } else if s.first == s.last {
212 When::Range(s.start_min, s.end_min)
213 } else if day == s.first {
214 When::From(s.start_min)
215 } else if day == s.last {
216 When::Until(s.end_min)
217 } else {
218 When::AllDay
219 }
220}
221
222/// Split plain text into runs, `true` for an `http(s)://` link. A trailing
223/// period or closing bracket belongs to the sentence, not the link.
224pub fn split_links(text: &str) -> Vec<(String, bool)> {
225 let mut out = Vec::new();
226 let mut rest = text;
227 while let Some(at) = ["https://", "http://"]
228 .iter()
229 .filter_map(|p| rest.find(p))
230 .min()
231 {
232 let end = rest[at..]
233 .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"'))
234 .map_or(rest.len(), |e| at + e);
235 let link = rest[at..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
236 let link_end = at + link.len();
237 if at > 0 {
238 out.push((rest[..at].to_string(), false));
239 }
240 out.push((link.to_string(), true));
241 rest = &rest[link_end..];
242 }
243 if !rest.is_empty() {
244 out.push((rest.to_string(), false));
245 }
246 out
247}
248
249// ---------------------------------------------------------------------------
250// Browser dates
251// ---------------------------------------------------------------------------
252
253fn js_day(d: &js_sys::Date) -> i64 {
254 days_from_civil(d.get_full_year() as i32, d.get_month() + 1, d.get_date())
255}
256
257fn day_date(day: i64) -> js_sys::Date {
258 let (y, m, d) = civil_from_days(day);
259 js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, d as i32)
260}
261
262fn today() -> i64 {
263 js_day(&js_sys::Date::new_0())
264}
265
266/// Local midnight of `day` as RFC 3339 UTC, for the instances endpoint.
267fn day_rfc3339(day: i64) -> String {
268 day_date(day).to_iso_string().into()
269}
270
271fn span_of(i: &PimInstance) -> Span {
272 let s = js_sys::Date::new(&i.start.as_str().into());
273 let e = js_sys::Date::new(&i.end.as_str().into());
274 let first = js_day(&s);
275 let start_min = s.get_hours() * 60 + s.get_minutes();
276 let (last, end_min) = if e.get_time() > s.get_time() {
277 let last = js_day(&js_sys::Date::new(&JsValue::from_f64(e.get_time() - 1.0)));
278 let end_min = if js_day(&e) > last {
279 1440
280 } else {
281 e.get_hours() * 60 + e.get_minutes()
282 };
283 (last, end_min)
284 } else {
285 (first, start_min)
286 };
287 Span {
288 first,
289 last: last.max(first),
290 all_day: i.all_day,
291 start_min,
292 end_min,
293 }
294}
295
296fn fmt_min(day: i64, min: u32) -> String {
297 let (y, m, d) = civil_from_days(day);
298 let t = js_sys::Date::new_with_year_month_day_hr_min(
299 y as u32,
300 m as i32 - 1,
301 d as i32,
302 (min / 60) as i32,
303 (min % 60) as i32,
304 );
305 intl(&t, &[("hour", "2-digit"), ("minute", "2-digit")])
306}
307
308fn fmt_when(w: When, day: i64) -> String {
309 match w {
310 When::AllDay => i18n::t(k::PIM_ALL_DAY).to_string(),
311 When::At(m) => fmt_min(day, m),
312 When::Range(a, b) => format!("{} – {}", fmt_min(day, a), fmt_min(day, b)),
313 When::From(m) => i18n::t_fmt(k::PIM_FROM_TIME, &fmt_min(day, m)),
314 When::Until(m) => i18n::t_fmt(k::PIM_UNTIL_TIME, &fmt_min(day, m)),
315 }
316}
317
318fn fmt_day_long(day: i64) -> String {
319 intl(
320 &day_date(day),
321 &[
322 ("weekday", "long"),
323 ("day", "numeric"),
324 ("month", "long"),
325 ("year", "numeric"),
326 ],
327 )
328}
329
330fn fmt_day_short(day: i64) -> String {
331 intl(
332 &day_date(day),
333 &[
334 ("weekday", "short"),
335 ("day", "numeric"),
336 ("month", "short"),
337 ("year", "numeric"),
338 ],
339 )
340}
341
342/// The full time of an instance, for its details.
343fn fmt_span(s: &Span) -> String {
344 match (s.all_day, s.first == s.last) {
345 (true, true) => fmt_day_short(s.first),
346 (true, false) => format!("{} – {}", fmt_day_short(s.first), fmt_day_short(s.last)),
347 (false, true) => format!(
348 "{} · {}",
349 fmt_day_short(s.first),
350 fmt_when(when_on(s, s.first), s.first)
351 ),
352 (false, false) => format!(
353 "{} {} – {} {}",
354 fmt_day_short(s.first),
355 fmt_min(s.first, s.start_min),
356 fmt_day_short(s.last),
357 fmt_min(s.last, s.end_min)
358 ),
359 }
360}
361
362// ---------------------------------------------------------------------------
363// The view
364// ---------------------------------------------------------------------------
365
366/// Lines per day cell in the month grid, the last one shared with
367/// "+N more".
368const LINES: usize = 4;
369
370#[derive(Clone, Copy, Debug, PartialEq)]
371enum CalView {
372 Month(i32, u32),
373 Agenda,
374 Invitations,
375}
376
377impl CalView {
378 fn from_path(path: &[String]) -> Self {
379 match path.first().map(String::as_str) {
380 Some("agenda") => CalView::Agenda,
381 Some("invitations") => CalView::Invitations,
382 _ => path
383 .get(1)
384 .and_then(|ym| {
385 let (y, m) = ym.split_once('-')?;
386 let (y, m) = (y.parse().ok()?, m.parse().ok()?);
387 (1..=12).contains(&m).then_some(CalView::Month(y, m))
388 })
389 .unwrap_or_else(|| {
390 let (y, m, _) = civil_from_days(today());
391 CalView::Month(y, m)
392 }),
393 }
394 }
395
396 fn path(self) -> Vec<String> {
397 match self {
398 CalView::Month(y, m) => vec!["month".into(), format!("{y:04}-{m:02}")],
399 CalView::Agenda => vec!["agenda".into()],
400 CalView::Invitations => vec!["invitations".into()],
401 }
402 }
403}
404
405fn go(view: CalView) {
406 navigate(&Location {
407 section: Section::Calendar,
408 path: view.path(),
409 ..Location::root()
410 });
411}
412
413/// The event whose details are open, as the URL names it:
414/// `{collection}/{recurrence id or -}/{name}`.
415#[derive(Clone, Debug, PartialEq)]
416struct Opened {
417 collection_id: i64,
418 recurrence_id: Option<String>,
419 name: String,
420}
421
422impl Opened {
423 fn of(i: &PimInstance) -> Self {
424 Opened {
425 collection_id: i.collection_id,
426 recurrence_id: i.recurrence_id.clone(),
427 name: i.name.clone(),
428 }
429 }
430
431 fn param(&self) -> String {
432 format!(
433 "{}/{}/{}",
434 self.collection_id,
435 self.recurrence_id.as_deref().unwrap_or("-"),
436 self.name
437 )
438 }
439
440 fn parse(s: &str) -> Option<Self> {
441 let mut it = s.splitn(3, '/');
442 let collection_id = it.next()?.parse().ok()?;
443 let rid = it.next()?;
444 let name = it.next()?.to_string();
445 Some(Opened {
446 collection_id,
447 recurrence_id: (rid != "-").then(|| rid.to_string()),
448 name,
449 })
450 }
451
452 fn open(&self) {
453 router::set_open(&self.param());
454 }
455}
456
457/// An invitation as the instance it names, for the day and time helpers.
458fn inv_instance(v: &PimInvitation) -> PimInstance {
459 PimInstance {
460 collection_id: v.collection_id,
461 name: v.name.clone(),
462 uid: v.uid.clone(),
463 recurrence_id: v.recurrence_id.clone(),
464 start: v.start.clone(),
465 end: v.end.clone(),
466 all_day: v.all_day,
467 component: "VEVENT".into(),
468 summary: v.summary.clone(),
469 location: v.location.clone(),
470 status: None,
471 transparent: false,
472 has_attendees: true,
473 partstat: None,
474 organizer: None,
475 }
476}
477
478/// An instance with what the views derive from it.
479#[derive(Clone)]
480struct Shown {
481 inst: PimInstance,
482 span: Span,
483 color: String,
484}
485
486#[component]
487pub fn CalendarMain(
488 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
489 hidden: RwSignal<HashSet<i64>>,
490 loc: ReadSignal<Location>,
491) -> impl IntoView {
492 let view = Memo::new(move |_| loc.with(|l| CalView::from_path(&l.path)));
493 let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(Opened::parse)));
494 // Bumped after an answer or an import: every list refetches.
495 let tick = RwSignal::new(0u32);
496
497 let month = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
498 let month_seq = StoredValue::new(0u32);
499 let month_key = StoredValue::new(None::<(i32, u32)>);
500 Effect::new(move |_| {
501 tick.track();
502 collections.track();
503 let CalView::Month(y, m) = view.get() else {
504 return;
505 };
506 // Another month's instances would land on the wrong days.
507 if month_key.get_value() != Some((y, m)) {
508 month_key.set_value(Some((y, m)));
509 month.set(None);
510 }
511 let start = grid_start(y, m, week_start(i18n::current_untracked()));
512 let seq = month_seq.get_value() + 1;
513 month_seq.set_value(seq);
514 spawn_local(async move {
515 let r = api::pim_instances(
516 &day_rfc3339(start),
517 &day_rfc3339(start + GRID_DAYS),
518 &browser_tz(),
519 )
520 .await;
521 if month_seq.get_value() == seq {
522 month.set(Some(r.map_err(|e| e.to_string())));
523 }
524 });
525 });
526
527 // The agenda grows by 30 days per "show more".
528 let agenda_days = RwSignal::new(30i64);
529 let agenda = RwSignal::new(Option::<Result<api::PimInstances, String>>::None);
530 let agenda_seq = StoredValue::new(0u32);
531 Effect::new(move |_| {
532 tick.track();
533 collections.track();
534 if view.get() != CalView::Agenda {
535 return;
536 }
537 let days = agenda_days.get();
538 let from = today();
539 let seq = agenda_seq.get_value() + 1;
540 agenda_seq.set_value(seq);
541 spawn_local(async move {
542 let r =
543 api::pim_instances(&day_rfc3339(from), &day_rfc3339(from + days), &browser_tz())
544 .await;
545 if agenda_seq.get_value() == seq {
546 agenda.set(Some(r.map_err(|e| e.to_string())));
547 }
548 });
549 });
550
551 let invitations = RwSignal::new(Option::<Result<Vec<PimInvitation>, String>>::None);
552 Effect::new(move |_| {
553 tick.track();
554 spawn_local(async move {
555 let r = api::pim_invitations(&browser_tz()).await;
556 let _ = invitations.try_set(Some(r.map_err(|e| e.to_string())));
557 });
558 });
559 let refresh = Callback::new(move |_| tick.update(|t| *t += 1));
560
561 let colors = Memo::new(move |_| {
562 collections.with(|c| {
563 c.iter()
564 .flatten()
565 .map(|c| (c.id, safe_color(c.color.as_deref())))
566 .collect::<HashMap<_, _>>()
567 })
568 });
569 let shown = move |list: &api::PimInstances| -> Vec<Shown> {
570 let colors = colors.get();
571 hidden.with(|h| {
572 list.instances
573 .iter()
574 .filter(|i| !h.contains(&i.collection_id))
575 .map(|i| Shown {
576 span: span_of(i),
577 color: colors
578 .get(&i.collection_id)
579 .cloned()
580 .unwrap_or_else(|| safe_color(None)),
581 inst: i.clone(),
582 })
583 .collect()
584 })
585 };
586
587 // PageUp / PageDown turn the month, unless typing or a dialog is open.
588 owned_window_listener(leptos::ev::keydown, move |ev| {
589 let step = match ev.key().as_str() {
590 "PageUp" => -1,
591 "PageDown" => 1,
592 _ => return,
593 };
594 let busy = web_sys::window()
595 .and_then(|w| w.document())
596 .and_then(|d| {
597 d.query_selector("dialog[open], input:focus, textarea:focus")
598 .ok()
599 })
600 .flatten()
601 .is_some();
602 if let (CalView::Month(y, m), false) = (view.get_untracked(), busy) {
603 ev.prevent_default();
604 let (y, m) = add_months(y, m, step);
605 go(CalView::Month(y, m));
606 }
607 });
608
609 let pending = move || {
610 invitations.with(|i| match i {
611 Some(Ok(list)) => list.len(),
612 _ => 0,
613 })
614 };
615 let toolbar = move || {
616 let v = view.get();
617 let title = match v {
618 CalView::Month(y, m) => intl(
619 &js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, 1),
620 &[("month", "long"), ("year", "numeric")],
621 ),
622 CalView::Agenda => i18n::t(k::PIM_UPCOMING).to_string(),
623 CalView::Invitations => i18n::t(k::PIM_INVITATIONS).to_string(),
624 };
625 let nav = match v {
626 CalView::Month(y, m) => Some(view! {
627 <div class="cal-nav">
628 <button
629 class="icon-btn icon-btn-sm"
630 title=i18n::t(k::PIM_PREV_MONTH)
631 aria-label=i18n::t(k::PIM_PREV_MONTH)
632 on:click=move |_| {
633 let (y, m) = add_months(y, m, -1);
634 go(CalView::Month(y, m));
635 }
636 >
637 {icon_svg(IconName::ChevronRight, "ic-btn flip")}
638 </button>
639 <button
640 class="btn btn-sm"
641 on:click=move |_| {
642 let (y, m, _) = civil_from_days(today());
643 go(CalView::Month(y, m));
644 }
645 >
646 {i18n::t(k::PIM_TODAY)}
647 </button>
648 <button
649 class="icon-btn icon-btn-sm"
650 title=i18n::t(k::PIM_NEXT_MONTH)
651 aria-label=i18n::t(k::PIM_NEXT_MONTH)
652 on:click=move |_| {
653 let (y, m) = add_months(y, m, 1);
654 go(CalView::Month(y, m));
655 }
656 >
657 {icon_svg(IconName::ChevronRight, "ic-btn")}
658 </button>
659 </div>
660 }),
661 _ => None,
662 };
663 let this_month = match v {
664 CalView::Month(..) => v,
665 _ => {
666 let (y, m, _) = civil_from_days(today());
667 CalView::Month(y, m)
668 }
669 };
670 view! {
671 <div class="cal-toolbar">
672 {nav}
673 <h2 class="cal-title">{title}</h2>
674 <div class="cal-views" role="tablist">
675 <button
676 role="tab"
677 class="cal-view-btn"
678 class:active=matches!(v, CalView::Month(..))
679 aria-selected=matches!(v, CalView::Month(..)).to_string()
680 on:click=move |_| go(this_month)
681 >
682 {i18n::t(k::PIM_MONTH)}
683 </button>
684 <button
685 role="tab"
686 class="cal-view-btn"
687 class:active=v == CalView::Agenda
688 aria-selected=(v == CalView::Agenda).to_string()
689 on:click=move |_| go(CalView::Agenda)
690 >
691 {i18n::t(k::PIM_AGENDA)}
692 </button>
693 <button
694 role="tab"
695 class="cal-view-btn"
696 class:active=v == CalView::Invitations
697 aria-selected=(v == CalView::Invitations).to_string()
698 on:click=move |_| go(CalView::Invitations)
699 >
700 {i18n::t(k::PIM_INVITATIONS)}
701 {move || (pending() > 0).then(|| view! {
702 <span class="cal-badge">{pending()}</span>
703 })}
704 </button>
705 </div>
706 </div>
707 }
708 };
709
710 let body = move || match view.get() {
711 CalView::Month(y, m) => view! {
712 <MonthView y=y m=m data=month shown=shown/>
713 }
714 .into_any(),
715 CalView::Agenda => view! {
716 <AgendaView data=agenda days=agenda_days shown=shown/>
717 }
718 .into_any(),
719 CalView::Invitations => view! {
720 <InvitationsView data=invitations collections=collections on_replied=refresh/>
721 }
722 .into_any(),
723 };
724
725 // The times of the open event come from whichever list holds it.
726 let times = move |o: &Opened| -> Option<Span> {
727 let find = |list: &Option<Result<api::PimInstances, String>>| {
728 list.as_ref().and_then(|r| r.as_ref().ok()).and_then(|l| {
729 l.instances
730 .iter()
731 .find(|i| Opened::of(i) == *o)
732 .map(span_of)
733 })
734 };
735 month
736 .with_untracked(find)
737 .or_else(|| agenda.with_untracked(find))
738 .or_else(|| {
739 invitations.with_untracked(|i| {
740 i.as_ref()
741 .and_then(|r| r.as_ref().ok())
742 .and_then(|l| {
743 l.iter().find(|v| {
744 v.collection_id == o.collection_id
745 && v.name == o.name
746 && v.recurrence_id == o.recurrence_id
747 })
748 })
749 .map(|v| span_of(&inv_instance(v)))
750 })
751 })
752 };
753 view! {
754 <div class="cal">
755 {toolbar}
756 {body}
757 </div>
758 {move || opened.get().map(|o| {
759 let span = times(&o);
760 view! {
761 <EventDialog
762 target=o
763 span=span
764 collections=collections
765 on_replied=refresh
766 on_close=Callback::new(move |_| router::close_open())
767 />
768 }
769 })}
770 }
771}
772
773fn truncated_note(list: &api::PimInstances) -> Option<impl IntoView + use<>> {
774 list.truncated.then(|| {
775 view! { <p class="cal-truncated">{i18n::t(k::PIM_TRUNCATED)}</p> }
776 })
777}
778
779fn loading_or_error(
780 data: &Option<Result<api::PimInstances, String>>,
781) -> Result<api::PimInstances, AnyView> {
782 match data {
783 None => Err(view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any()),
784 Some(Err(e)) => Err(view! { <div class="card error-card">{e.clone()}</div> }.into_any()),
785 Some(Ok(list)) => Ok(list.clone()),
786 }
787}
788
789/// One line of a day list: time, color and title.
790fn day_row(s: &Shown, day: i64) -> impl IntoView + use<> {
791 let o = Opened::of(&s.inst);
792 let when = fmt_when(when_on(&s.span, day), day);
793 let title = s
794 .inst
795 .summary
796 .clone()
797 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
798 let cancelled = s.inst.status.as_deref() == Some("CANCELLED");
799 let location = s.inst.location.clone();
800 view! {
801 <button class="cal-row" class:cal-cancelled=cancelled on:click=move |_| o.open()>
802 <span class="cal-row-time">{when}</span>
803 <span class="cal-dot" style=format!("--c: {}", s.color)></span>
804 <span class="cal-row-main">
805 <span class="cal-row-title">{title}</span>
806 {location.map(|l| view! { <span class="cal-row-meta">{l}</span> })}
807 </span>
808 </button>
809 }
810}
811
812#[component]
813fn MonthView(
814 y: i32,
815 m: u32,
816 data: RwSignal<Option<Result<api::PimInstances, String>>>,
817 shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
818) -> impl IntoView {
819 let start = grid_start(y, m, week_start(i18n::current_untracked()));
820 let now = today();
821 let in_month = move |d: i64| {
822 let (yy, mm, _) = civil_from_days(d);
823 yy == y && mm == m
824 };
825 let selected = RwSignal::new(if in_month(now) {
826 now
827 } else {
828 days_from_civil(y, m, 1)
829 });
830 let list_ref: NodeRef<leptos::html::Div> = NodeRef::new();
831 let pick = move |day: i64| {
832 selected.set(day);
833 if let Some(el) = list_ref.get_untracked() {
834 let opts = web_sys::ScrollIntoViewOptions::new();
835 opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
836 el.scroll_into_view_with_scroll_into_view_options(&opts);
837 }
838 };
839 let head = (0..7)
840 .map(|i| {
841 view! {
842 <div class="cal-head-day">{intl(&day_date(start + i), &[("weekday", "short")])}</div>
843 }
844 })
845 .collect::<Vec<_>>();
846 view! {
847 <div class="cal-month">
848 <div class="cal-head">{head}</div>
849 {move || data.with(|d| match loading_or_error(d) {
850 Err(v) => v,
851 Ok(list) => {
852 let items = shown(&list);
853 let spans: Vec<Span> = items.iter().map(|s| s.span).collect();
854 let weeks = (0..6)
855 .map(|w| {
856 let week = start + w * 7;
857 let layout = layout_week(&spans, week, LINES);
858 let cells = (0..7usize)
859 .map(|c| {
860 let day = week + c as i64;
861 let (_, _, dnum) = civil_from_days(day);
862 let dots: Vec<String> = items
863 .iter()
864 .filter(|s| s.span.covers(day))
865 .take(3)
866 .map(|s| s.color.clone())
867 .collect();
868 let label = fmt_day_long(day);
869 view! {
870 <button
871 class="cal-cell"
872 class:cal-out=!in_month(day)
873 class:cal-today=day == now
874 class:cal-sel=move || selected.get() == day
875 style=format!("grid-column: {}", c + 1)
876 aria-label=label
877 on:click=move |_| pick(day)
878 >
879 <span class="cal-num">{dnum}</span>
880 <span class="cal-dots">
881 {dots
882 .into_iter()
883 .map(|c| view! { <span class="cal-dot" style=format!("--c: {c}")></span> })
884 .collect::<Vec<_>>()}
885 </span>
886 </button>
887 }
888 })
889 .collect::<Vec<_>>();
890 let pieces = layout
891 .pieces
892 .iter()
893 .map(|p| {
894 let s = &items[p.item];
895 let o = Opened::of(&s.inst);
896 let bar = s.span.is_bar();
897 let title = s
898 .inst
899 .summary
900 .clone()
901 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
902 let time = (!bar).then(|| fmt_min(s.span.first, s.span.start_min));
903 let cancelled = s.inst.status.as_deref() == Some("CANCELLED");
904 let tip = match &time {
905 Some(t) => format!("{t} {title}"),
906 None => title.clone(),
907 };
908 view! {
909 <button
910 class="cal-item"
911 class:cal-bar=bar
912 class:cal-chip=!bar
913 class:cal-cont-l=!p.starts
914 class:cal-cont-r=!p.ends
915 class:cal-cancelled=cancelled
916 style=format!(
917 "grid-column: {} / span {}; grid-row: {}; --c: {}",
918 p.col + 1,
919 p.len,
920 p.line + 2,
921 s.color
922 )
923 title=tip
924 on:click=move |_| o.open()
925 >
926 {(!bar).then(|| view! { <span class="cal-dot"></span> })}
927 {time.map(|t| view! { <span class="cal-time">{t}</span> })}
928 <span class="cal-item-title">{title}</span>
929 </button>
930 }
931 })
932 .collect::<Vec<_>>();
933 let more = layout
934 .more
935 .iter()
936 .enumerate()
937 .filter(|&(_, &n)| n > 0)
938 .map(|(c, &n)| {
939 let day = week + c as i64;
940 view! {
941 <button
942 class="cal-more"
943 style=format!("grid-column: {}; grid-row: {}", c + 1, LINES + 1)
944 on:click=move |_| pick(day)
945 >
946 {i18n::t_fmt(k::PIM_MORE_N, &n.to_string())}
947 </button>
948 }
949 })
950 .collect::<Vec<_>>();
951 view! {
952 <div class="cal-week" style=format!("--lines: {LINES}")>
953 {cells}
954 {pieces}
955 {more}
956 </div>
957 }
958 })
959 .collect::<Vec<_>>();
960 let day_list = move || {
961 let day = selected.get();
962 let rows: Vec<_> = items
963 .iter()
964 .filter(|s| s.span.covers(day))
965 .map(|s| day_row(s, day))
966 .collect();
967 view! {
968 <h3 class="cal-day-title">{fmt_day_long(day)}</h3>
969 {if rows.is_empty() {
970 view! { <p class="muted cal-pad">{i18n::t(k::PIM_NO_EVENTS_DAY)}</p> }.into_any()
971 } else {
972 view! { <div class="cal-rows">{rows}</div> }.into_any()
973 }}
974 }
975 };
976 view! {
977 {truncated_note(&list)}
978 <div class="cal-weeks">{weeks}</div>
979 <div class="cal-day" node_ref=list_ref>{day_list}</div>
980 }
981 .into_any()
982 }
983 })}
984 </div>
985 }
986}
987
988#[component]
989fn AgendaView(
990 data: RwSignal<Option<Result<api::PimInstances, String>>>,
991 days: RwSignal<i64>,
992 shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
993) -> impl IntoView {
994 view! {
995 <div class="cal-agenda">
996 {move || data.with(|d| match loading_or_error(d) {
997 Err(v) => v,
998 Ok(list) => {
999 let items = shown(&list);
1000 let from = today();
1001 let groups: Vec<_> = (from..from + days.get_untracked())
1002 .filter_map(|day| {
1003 let rows: Vec<_> = items
1004 .iter()
1005 .filter(|s| s.span.covers(day))
1006 .map(|s| day_row(s, day))
1007 .collect();
1008 (!rows.is_empty()).then(|| view! {
1009 <section class="cal-agenda-day">
1010 <h3 class="cal-day-title" class:cal-day-today=day == from>
1011 {fmt_day_long(day)}
1012 </h3>
1013 <div class="cal-rows">{rows}</div>
1014 </section>
1015 })
1016 })
1017 .collect();
1018 let empty = groups.is_empty().then(|| view! {
1019 <div class="empty-state">
1020 {icon_svg(IconName::Calendar, "empty-glyph")}
1021 <p class="muted">{i18n::t(k::PIM_NO_EVENTS_AGENDA)}</p>
1022 </div>
1023 });
1024 // Each request covers at most 400 days.
1025 let can_grow = days.get_untracked() + 30 <= 390;
1026 view! {
1027 {truncated_note(&list)}
1028 {empty}
1029 {groups}
1030 {can_grow.then(|| view! {
1031 <div class="cal-pad">
1032 <button class="btn btn-sm" on:click=move |_| days.update(|d| *d += 30)>
1033 {i18n::t(k::PIM_LOAD_MORE)}
1034 </button>
1035 </div>
1036 })}
1037 }
1038 .into_any()
1039 }
1040 })}
1041 </div>
1042 }
1043}
1044
1045/// Answer an invitation, then refresh the lists.
1046fn reply(
1047 toast: ToastMsg,
1048 collection_id: i64,
1049 name: String,
1050 recurrence_id: Option<String>,
1051 partstat: &'static str,
1052 done: Callback<()>,
1053) {
1054 spawn_local(async move {
1055 let body = PimReply {
1056 collection_id,
1057 name,
1058 recurrence_id,
1059 partstat: partstat.to_string(),
1060 tz: Some(browser_tz()),
1061 };
1062 match api::pim_reply(body).await {
1063 Ok(_) => {
1064 show(toast, i18n::t(k::PIM_REPLIED));
1065 done.run(());
1066 }
1067 Err(e) => show_error(toast, e.to_string()),
1068 }
1069 });
1070}
1071
1072/// Accept, maybe, decline. `current` marks the answer already given.
1073fn reply_buttons(
1074 current: Option<String>,
1075 on: impl Fn(&'static str) + Clone + 'static,
1076) -> impl IntoView {
1077 [
1078 ("ACCEPTED", k::PIM_ACCEPT),
1079 ("TENTATIVE", k::PIM_TENTATIVE),
1080 ("DECLINED", k::PIM_DECLINE),
1081 ]
1082 .map(|(ps, label)| {
1083 let on = on.clone();
1084 let active = current.as_deref() == Some(ps);
1085 view! {
1086 <button
1087 class="btn btn-sm"
1088 class:btn-primary=active
1089 aria-pressed=active.to_string()
1090 on:click=move |_| on(ps)
1091 >
1092 {i18n::t(label)}
1093 </button>
1094 }
1095 })
1096 .into_iter()
1097 .collect::<Vec<_>>()
1098}
1099
1100#[component]
1101fn InvitationsView(
1102 data: RwSignal<Option<Result<Vec<PimInvitation>, String>>>,
1103 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
1104 on_replied: Callback<()>,
1105) -> impl IntoView {
1106 let toast = use_context::<ToastMsg>().expect("toast context");
1107 view! {
1108 <div class="cal-invitations">
1109 {move || match data.get() {
1110 None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
1111 Some(Err(e)) => view! { <div class="card error-card">{e}</div> }.into_any(),
1112 Some(Ok(list)) if list.is_empty() => view! {
1113 <div class="empty-state">
1114 {icon_svg(IconName::Calendar, "empty-glyph")}
1115 <p class="muted">{i18n::t(k::PIM_NO_INVITATIONS)}</p>
1116 </div>
1117 }
1118 .into_any(),
1119 Some(Ok(list)) => {
1120 let names: HashMap<i64, String> = collections.with_untracked(|c| {
1121 c.iter().flatten().map(|c| (c.id, display_name(c))).collect()
1122 });
1123 list.into_iter()
1124 .map(|inv| {
1125 let o = Opened {
1126 collection_id: inv.collection_id,
1127 recurrence_id: inv.recurrence_id.clone(),
1128 name: inv.name.clone(),
1129 };
1130 let span = span_of(&inv_instance(&inv));
1131 let mut meta = vec![fmt_span(&span)];
1132 if inv.recurring && inv.recurrence_id.is_none() {
1133 meta.push(i18n::t(k::PIM_SERIES).to_string());
1134 }
1135 if let Some(n) = names.get(&inv.collection_id) {
1136 meta.push(n.clone());
1137 }
1138 let organizer = inv.organizer.as_ref().map(|p| {
1139 p.name.clone().unwrap_or_else(|| {
1140 p.address.trim_start_matches("mailto:").to_string()
1141 })
1142 });
1143 let title = inv
1144 .summary
1145 .clone()
1146 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
1147 let (id, name, rid) =
1148 (inv.collection_id, inv.name.clone(), inv.recurrence_id.clone());
1149 let open = o.clone();
1150 view! {
1151 <div class="cal-inv">
1152 <button class="cal-inv-main" on:click=move |_| open.open()>
1153 <span class="cal-inv-title">{title}</span>
1154 <span class="cal-row-meta">{meta.join(" · ")}</span>
1155 {inv.location.clone().map(|l| view! { <span class="cal-row-meta">{l}</span> })}
1156 {organizer.map(|n| view! {
1157 <span class="cal-row-meta">{i18n::t_fmt(k::PIM_FROM_ORGANIZER, &n)}</span>
1158 })}
1159 </button>
1160 <div class="cal-inv-actions">
1161 {reply_buttons(None, move |ps| {
1162 reply(toast, id, name.clone(), rid.clone(), ps, on_replied)
1163 })}
1164 </div>
1165 </div>
1166 }
1167 })
1168 .collect::<Vec<_>>()
1169 .into_any()
1170 }
1171 }}
1172 </div>
1173 }
1174}
1175
1176fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) {
1177 match ps {
1178 Some("ACCEPTED") => (k::PIM_PS_ACCEPTED, "ps-accepted"),
1179 Some("DECLINED") => (k::PIM_PS_DECLINED, "ps-declined"),
1180 Some("TENTATIVE") => (k::PIM_PS_TENTATIVE, "ps-tentative"),
1181 Some("DELEGATED") => (k::PIM_PS_DELEGATED, "ps-muted"),
1182 _ => (k::PIM_PS_NEEDS_ACTION, "ps-muted"),
1183 }
1184}
1185
1186/// `FREQ=WEEKLY;INTERVAL=2` as words; anything else stays raw.
1187fn rrule_text(rule: &str) -> String {
1188 let part = |key: &str| {
1189 rule.split(';')
1190 .find_map(|p| p.strip_prefix(key)?.strip_prefix('='))
1191 };
1192 let n = part("INTERVAL")
1193 .and_then(|n| n.parse::<u32>().ok())
1194 .unwrap_or(1);
1195 let keys = match part("FREQ") {
1196 Some("DAILY") => (k::PIM_RRULE_DAILY, k::PIM_RRULE_DAILY_N),
1197 Some("WEEKLY") => (k::PIM_RRULE_WEEKLY, k::PIM_RRULE_WEEKLY_N),
1198 Some("MONTHLY") => (k::PIM_RRULE_MONTHLY, k::PIM_RRULE_MONTHLY_N),
1199 Some("YEARLY") => (k::PIM_RRULE_YEARLY, k::PIM_RRULE_YEARLY_N),
1200 _ => return rule.to_string(),
1201 };
1202 match n {
1203 1 => i18n::t(keys.0).to_string(),
1204 n => i18n::t_fmt(keys.1, &n.to_string()),
1205 }
1206}
1207
1208fn person_name(p: &api_types::PimPerson) -> String {
1209 p.name
1210 .clone()
1211 .unwrap_or_else(|| p.address.trim_start_matches("mailto:").to_string())
1212}
1213
1214/// Text with its web links clickable, for a `white-space: pre-wrap` block.
1215pub fn linkified(text: &str) -> impl IntoView + use<> {
1216 split_links(text)
1217 .into_iter()
1218 .map(|(t, link)| match link {
1219 true => {
1220 let href = t.clone();
1221 view! { <a href=href target="_blank" rel="noopener noreferrer">{t}</a> }.into_any()
1222 }
1223 false => t.into_any(),
1224 })
1225 .collect::<Vec<_>>()
1226}
1227
1228#[component]
1229fn EventDialog(
1230 target: Opened,
1231 span: Option<Span>,
1232 collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
1233 on_replied: Callback<()>,
1234 on_close: Callback<()>,
1235) -> impl IntoView {
1236 let toast = use_context::<ToastMsg>().expect("toast context");
1237 let detail = RwSignal::new(Option::<Result<PimEventDetail, String>>::None);
1238 let load = {
1239 let t = target.clone();
1240 move || {
1241 let t = t.clone();
1242 spawn_local(async move {
1243 let r = api::pim_object(
1244 t.collection_id,
1245 &t.name,
1246 t.recurrence_id.as_deref(),
1247 &browser_tz(),
1248 )
1249 .await;
1250 let r = match r {
1251 Ok(PimObjectDetail::Event(e)) => Ok(e),
1252 Ok(PimObjectDetail::Contact(_)) => Err(i18n::t(k::PIM_NOT_EVENT).to_string()),
1253 Err(e) => Err(e.to_string()),
1254 };
1255 let _ = detail.try_set(Some(r));
1256 });
1257 }
1258 };
1259 load();
1260 // For one instance of a series: answer it alone, or the whole series.
1261 let only_this = RwSignal::new(true);
1262 let collection = collections.with_untracked(|c| {
1263 c.iter()
1264 .flatten()
1265 .find(|c| c.id == target.collection_id)
1266 .map(|c| (display_name(c), safe_color(c.color.as_deref())))
1267 });
1268 let body = move || match detail.get() {
1269 None => view! { <p class="muted">{i18n::t(k::LOADING)}</p> }.into_any(),
1270 Some(Err(e)) => view! { <p class="form-error">{e}</p> }.into_any(),
1271 Some(Ok(d)) => {
1272 let title = d
1273 .summary
1274 .clone()
1275 .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
1276 let mine = d
1277 .attendees
1278 .iter()
1279 .find(|a| a.is_owner)
1280 .and_then(|a| a.partstat.clone());
1281 let series = target.recurrence_id.is_some() && d.rrule.is_some();
1282 let rid = target.recurrence_id.clone();
1283 let (id, name) = (target.collection_id, target.name.clone());
1284 let load = load.clone();
1285 let answer = move |ps: &'static str| {
1286 let rid = if only_this.get_untracked() {
1287 rid.clone()
1288 } else {
1289 None
1290 };
1291 let load = load.clone();
1292 reply(
1293 toast,
1294 id,
1295 name.clone(),
1296 rid,
1297 ps,
1298 Callback::new(move |_| {
1299 load();
1300 on_replied.run(());
1301 }),
1302 );
1303 };
1304 let url = d
1305 .url
1306 .clone()
1307 .filter(|u| u.starts_with("https://") || u.starts_with("http://"));
1308 view! {
1309 <h2 class="modal-title cal-detail-title" class:cal-cancelled=d.status.as_deref() == Some("CANCELLED")>
1310 {title}
1311 </h2>
1312 {(d.status.as_deref() == Some("CANCELLED")).then(|| view! {
1313 <p class="cal-flag">{i18n::t(k::PIM_CANCELLED)}</p>
1314 })}
1315 <dl class="cal-facts">
1316 {span.map(|s| view! {
1317 <dt>{i18n::t(k::PIM_WHEN)}</dt>
1318 <dd>{fmt_span(&s)}</dd>
1319 })}
1320 {d.rrule.clone().map(|r| view! {
1321 <dt>{i18n::t(k::PIM_REPEATS)}</dt>
1322 <dd title=r.clone()>{rrule_text(&r)}</dd>
1323 })}
1324 {d.location.clone().map(|l| view! {
1325 <dt>{i18n::t(k::PIM_LOCATION)}</dt>
1326 <dd class="cal-pre">{linkified(&l)}</dd>
1327 })}
1328 {collection.clone().map(|(n, c)| view! {
1329 <dt>{i18n::t(k::PIM_CALENDAR)}</dt>
1330 <dd><span class="cal-dot" style=format!("--c: {c}")></span>" "{n}</dd>
1331 })}
1332 {d.organizer.as_ref().map(|p| view! {
1333 <dt>{i18n::t(k::PIM_ORGANIZER)}</dt>
1334 <dd>{person_name(p)}</dd>
1335 })}
1336 {url.map(|u| {
1337 let href = u.clone();
1338 view! {
1339 <dt>{i18n::t(k::PIM_LINK)}</dt>
1340 <dd><a href=href target="_blank" rel="noopener noreferrer">{u}</a></dd>
1341 }
1342 })}
1343 {(!d.categories.is_empty()).then(|| view! {
1344 <dt>{i18n::t(k::PIM_CATEGORIES)}</dt>
1345 <dd>{d.categories.join(", ")}</dd>
1346 })}
1347 </dl>
1348 {(!d.attendees.is_empty()).then(|| view! {
1349 <h3 class="pim-section-title">{i18n::t(k::PIM_ATTENDEES)}</h3>
1350 <ul class="cal-attendees">
1351 {d.attendees
1352 .iter()
1353 .map(|a| {
1354 let (label, class) = partstat_label(a.partstat.as_deref());
1355 let optional = a.role.as_deref() == Some("OPT-PARTICIPANT");
1356 view! {
1357 <li>
1358 <span class="cal-att-name" title=a.person.address.clone()>
1359 {person_name(&a.person)}
1360 </span>
1361 {optional.then(|| view! { <span class="muted">{i18n::t(k::PIM_OPTIONAL)}</span> })}
1362 <span class=format!("ps-badge {class}")>{i18n::t(label)}</span>
1363 </li>
1364 }
1365 })
1366 .collect::<Vec<_>>()}
1367 </ul>
1368 })}
1369 {d.description.clone().filter(|t| !t.trim().is_empty()).map(|t| view! {
1370 <div class="cal-desc cal-pre">{linkified(&t)}</div>
1371 })}
1372 {d.can_reply.then(|| view! {
1373 <section class="cal-reply">
1374 <h3 class="pim-section-title">{i18n::t(k::PIM_YOUR_ANSWER)}</h3>
1375 {series.then(|| view! {
1376 <div class="cal-scope" role="radiogroup">
1377 <label class="check-row">
1378 <input
1379 type="radio"
1380 name="cal-scope"
1381 prop:checked=move || only_this.get()
1382 on:change=move |_| only_this.set(true)
1383 />
1384 {i18n::t(k::PIM_THIS_EVENT)}
1385 </label>
1386 <label class="check-row">
1387 <input
1388 type="radio"
1389 name="cal-scope"
1390 prop:checked=move || !only_this.get()
1391 on:change=move |_| only_this.set(false)
1392 />
1393 {i18n::t(k::PIM_ALL_EVENTS)}
1394 </label>
1395 </div>
1396 })}
1397 <div class="cal-inv-actions">{reply_buttons(mine, answer)}</div>
1398 </section>
1399 })}
1400 }
1401 .into_any()
1402 }
1403 };
1404 view! {
1405 <Modal class="pim-modal cal-detail" on_close=on_close>
1406 {body}
1407 <div class="modal-actions">
1408 <button class="btn" on:click=move |_| on_close.run(())>{i18n::t(k::CLOSE)}</button>
1409 </div>
1410 </Modal>
1411 }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416 use super::*;
1417
1418 fn day(y: i32, m: u32, d: u32) -> i64 {
1419 days_from_civil(y, m, d)
1420 }
1421
1422 fn timed(first: i64, start_min: u32) -> Span {
1423 Span {
1424 first,
1425 last: first,
1426 all_day: false,
1427 start_min,
1428 end_min: start_min + 60,
1429 }
1430 }
1431
1432 fn all_day(first: i64, last: i64) -> Span {
1433 Span {
1434 first,
1435 last,
1436 all_day: true,
1437 start_min: 0,
1438 end_min: 1440,
1439 }
1440 }
1441
1442 #[test]
1443 fn civil_days_round_trip() {
1444 assert_eq!(day(1970, 1, 1), 0);
1445 assert_eq!(day(2000, 3, 1), 11017);
1446 for z in [-800_000, -1, 0, 59, 60, 11016, 20_000, 2_000_000] {
1447 let (y, m, d) = civil_from_days(z);
1448 assert_eq!(day(y, m, d), z);
1449 }
1450 assert_eq!(civil_from_days(day(2024, 2, 29)), (2024, 2, 29));
1451 // 2026-10-01 is a Thursday.
1452 assert_eq!(weekday(day(2026, 10, 1)), 4);
1453 }
1454
1455 #[test]
1456 fn grid_starts_on_the_week_start() {
1457 // October 2026 starts on a Thursday.
1458 let sunday = grid_start(2026, 10, 0);
1459 let monday = grid_start(2026, 10, 1);
1460 assert_eq!(civil_from_days(sunday), (2026, 9, 27));
1461 assert_eq!(civil_from_days(monday), (2026, 9, 28));
1462 assert_eq!(weekday(monday), 1);
1463 // A month starting on the week start begins the grid itself, and
1464 // six weeks always cover the whole month.
1465 assert_eq!(grid_start(2026, 6, 1), day(2026, 6, 1));
1466 for (y, m) in [(2026, 2), (2026, 8), (2027, 1)] {
1467 for ws in [0, 1] {
1468 let s = grid_start(y, m, ws);
1469 let (ny, nm) = add_months(y, m, 1);
1470 assert!(s <= day(y, m, 1) && day(ny, nm, 1) <= s + GRID_DAYS);
1471 }
1472 }
1473 assert_eq!(add_months(2026, 12, 1), (2027, 1));
1474 assert_eq!(add_months(2026, 1, -1), (2025, 12));
1475 }
1476
1477 #[test]
1478 fn bars_split_across_weeks() {
1479 // Saturday to Tuesday, in weeks starting on Monday.
1480 let week1 = grid_start(2026, 10, 1);
1481 let span = all_day(day(2026, 10, 3), day(2026, 10, 6));
1482 let a = layout_week(&[span], week1, 4);
1483 assert_eq!(
1484 a.pieces,
1485 [Piece {
1486 item: 0,
1487 col: 5,
1488 len: 2,
1489 line: 0,
1490 starts: true,
1491 ends: false
1492 }]
1493 );
1494 let b = layout_week(&[span], week1 + 7, 4);
1495 assert_eq!(
1496 b.pieces,
1497 [Piece {
1498 item: 0,
1499 col: 0,
1500 len: 2,
1501 line: 0,
1502 starts: false,
1503 ends: true
1504 }]
1505 );
1506 assert_eq!(a.more, [0; 7]);
1507 }
1508
1509 #[test]
1510 fn busy_days_share_the_last_line_with_more() {
1511 let week = grid_start(2026, 10, 1);
1512 let mon = week;
1513 // Five timed events on Monday; a bar Monday to Wednesday.
1514 let mut spans: Vec<Span> = (0..5).map(|i| timed(mon, 600 + i * 30)).collect();
1515 spans.push(all_day(mon, mon + 2));
1516 let l = layout_week(&spans, week, 3);
1517 // Monday has six instances on three lines: two shown, four behind
1518 // "+4 more". The bar comes first, so it stays visible.
1519 assert_eq!(l.more, [4, 0, 0, 0, 0, 0, 0]);
1520 let bar = l.pieces.iter().find(|p| p.item == 5).unwrap();
1521 assert_eq!((bar.col, bar.len, bar.line), (0, 3, 0));
1522 assert_eq!(l.pieces.iter().filter(|p| p.col == 0).count(), 2);
1523 // Timed events keep their order.
1524 let first_chip = l.pieces.iter().find(|p| p.item != 5).unwrap();
1525 assert_eq!(first_chip.item, 0);
1526 }
1527
1528 #[test]
1529 fn a_bar_hidden_on_a_busy_day_splits() {
1530 let week = grid_start(2026, 10, 1);
1531 // Two bars over Mon–Wed; three timed events on Tuesday only.
1532 let mut spans = vec![all_day(week, week + 2), all_day(week, week + 2)];
1533 spans.extend((0..3).map(|i| timed(week + 1, 600 + i * 60)));
1534 let l = layout_week(&spans, week, 2);
1535 // Tuesday overflows, so its last line (the second bar) shows "+4".
1536 assert_eq!(l.more, [0, 4, 0, 0, 0, 0, 0]);
1537 let second: Vec<_> = l.pieces.iter().filter(|p| p.item == 1).collect();
1538 assert_eq!(second.len(), 2);
1539 assert_eq!(
1540 (second[0].col, second[0].len, second[0].ends),
1541 (0, 1, false)
1542 );
1543 assert_eq!(
1544 (second[1].col, second[1].len, second[1].starts),
1545 (2, 1, false)
1546 );
1547 }
1548
1549 #[test]
1550 fn times_on_each_day() {
1551 let s = Span {
1552 first: 10,
1553 last: 12,
1554 all_day: false,
1555 start_min: 22 * 60,
1556 end_min: 9 * 60,
1557 };
1558 assert_eq!(when_on(&s, 10), When::From(1320));
1559 assert_eq!(when_on(&s, 11), When::AllDay);
1560 assert_eq!(when_on(&s, 12), When::Until(540));
1561 assert_eq!(when_on(&timed(5, 600), 5), When::Range(600, 660));
1562 assert_eq!(when_on(&all_day(5, 5), 5), When::AllDay);
1563 }
1564
1565 #[test]
1566 fn links_are_found_in_text() {
1567 assert_eq!(
1568 split_links("See https://example.com/a?b=1. Or http://x.org)"),
1569 [
1570 ("See ".to_string(), false),
1571 ("https://example.com/a?b=1".to_string(), true),
1572 (". Or ".to_string(), false),
1573 ("http://x.org".to_string(), true),
1574 (")".to_string(), false),
1575 ]
1576 );
1577 assert_eq!(
1578 split_links("no links, javascript:alert(1)"),
1579 [("no links, javascript:alert(1)".to_string(), false)]
1580 );
1581 }
1582
1583 #[test]
1584 fn opened_round_trips() {
1585 let o = Opened {
1586 collection_id: 7,
1587 recurrence_id: Some("2026-10-05T08:00:00Z".into()),
1588 name: "a/b.ics".into(),
1589 };
1590 assert_eq!(Opened::parse(&o.param()), Some(o));
1591 let series = Opened {
1592 collection_id: -1,
1593 recurrence_id: None,
1594 name: "x.ics".into(),
1595 };
1596 assert_eq!(Opened::parse(&series.param()), Some(series));
1597 }
1598}
1599