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