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