//! The calendar section's main area: the month grid, the agenda, the open //! invitations and the details of one event. //! //! The server expands recurrences and resolves time zones. This view only //! places the instances it gets on local days. use std::collections::{HashMap, HashSet}; use leptos::prelude::*; use wasm_bindgen::JsValue; use wasm_bindgen_futures::spawn_local; use crate::api::{ self, PimCollectionInfo, PimEventDetail, PimInstance, PimInvitation, PimObjectDetail, PimReply, }; use crate::components::icon::icon_svg; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show, show_error}; use crate::i18n::{self, k}; use crate::icons::IconName; use crate::router::{self, Location, Section, navigate}; use crate::util::{intl, owned_window_listener}; use crate::views::pim::{browser_tz, display_name, safe_color}; // --------------------------------------------------------------------------- // Days and the month grid (pure, so it is testable off the browser) // --------------------------------------------------------------------------- /// Days since 1970-01-01 of a proleptic Gregorian date. pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { let y = i64::from(if m <= 2 { y - 1 } else { y }); let era = y.div_euclid(400); let yoe = y - era * 400; let m = i64::from(m); let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(d) - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468 } pub fn civil_from_days(z: i64) -> (i32, u32, u32) { let z = z + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = (yoe + era * 400 + i64::from(m <= 2)) as i32; (y, m, d) } /// 0 is Sunday. pub fn weekday(day: i64) -> u32 { (day + 4).rem_euclid(7) as u32 } /// The first day the month grid shows: the week start on or before the 1st. pub fn grid_start(y: i32, m: u32, week_start: u32) -> i64 { let first = days_from_civil(y, m, 1); first - i64::from((weekday(first) + 7 - week_start) % 7) } /// The grid always shows six weeks, so its height never jumps. pub const GRID_DAYS: i64 = 42; /// Cancelled, or declined by the calendar's owner: shown struck through. fn struck(i: &api::PimInstance) -> bool { i.status.as_deref() == Some("CANCELLED") || i.partstat.as_deref() == Some("DECLINED") } fn add_months(y: i32, m: u32, n: i32) -> (i32, u32) { let i = y * 12 + m as i32 - 1 + n; (i.div_euclid(12), i.rem_euclid(12) as u32 + 1) } /// Where an instance lies in local time: its first and last day, and the /// minute of the day it starts on the first and ends on the last. An end at /// midnight counts as minute 1440 of the day before. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Span { pub first: i64, pub last: i64, pub all_day: bool, pub start_min: u32, pub end_min: u32, } impl Span { /// A bar across days, not a line with a time. fn is_bar(&self) -> bool { self.all_day || self.first != self.last } fn covers(&self, day: i64) -> bool { self.first <= day && day <= self.last } } /// One piece of an instance in a week row. An instance hidden on some of /// its days by the "+N more" line becomes several pieces. #[derive(Clone, Debug, PartialEq)] pub struct Piece { /// Index into the spans given to [`layout_week`]. pub item: usize, pub col: usize, pub len: usize, pub line: usize, /// The instance starts in this piece, rather than continuing into it. pub starts: bool, pub ends: bool, } #[derive(Clone, Debug, PartialEq)] pub struct WeekLayout { pub pieces: Vec, /// Per day: how many instances the "+N more" line stands for. pub more: [usize; 7], } /// Place a week's instances on `lines` lines. Bars come first, longer ones /// on top, then timed instances by start. A day with more instances than /// lines gives its last line to "+N more". pub fn layout_week(spans: &[Span], week: i64, lines: usize) -> WeekLayout { let mut items: Vec<(usize, usize, usize)> = spans .iter() .enumerate() .filter(|(_, s)| s.first <= week + 6 && s.last >= week) .map(|(i, s)| { let c0 = (s.first.max(week) - week) as usize; let c1 = (s.last.min(week + 6) - week) as usize; (i, c0, c1) }) .collect(); items.sort_by_key(|&(i, c0, c1)| { let s = &spans[i]; (!s.is_bar(), c0, usize::MAX - (c1 - c0), s.start_min, i) }); let mut used: Vec<[bool; 7]> = Vec::new(); let mut placed = Vec::with_capacity(items.len()); let mut count = [0usize; 7]; for &(i, c0, c1) in &items { let line = (0..) .find(|&l| { used.get(l) .is_none_or(|row| !row[c0..=c1].iter().any(|&u| u)) }) .unwrap_or(0); if used.len() <= line { used.resize(line + 1, [false; 7]); } for c in c0..=c1 { used[line][c] = true; count[c] += 1; } placed.push((i, c0, c1, line)); } let limit = |c: usize| { if count[c] <= lines { lines } else { lines.saturating_sub(1) } }; let mut pieces = Vec::new(); for &(i, c0, c1, line) in &placed { let mut run: Option = None; for c in c0..=c1 + 1 { if c <= c1 && line < limit(c) { run.get_or_insert(c); } else if let Some(start) = run.take() { let s = &spans[i]; pieces.push(Piece { item: i, col: start, len: c - start, line, starts: start == c0 && s.first >= week, ends: c - 1 == c1 && s.last <= week + 6, }); } } } let more = std::array::from_fn(|c| { placed .iter() .filter(|&&(_, c0, c1, line)| c0 <= c && c <= c1 && line >= limit(c)) .count() }); WeekLayout { pieces, more } } /// What an instance's time looks like on one of its days. #[derive(Clone, Copy, Debug, PartialEq)] pub enum When { AllDay, At(u32), Range(u32, u32), From(u32), Until(u32), } pub fn when_on(s: &Span, day: i64) -> When { if s.all_day { When::AllDay } else if s.first == s.last && s.start_min == s.end_min { When::At(s.start_min) } else if s.first == s.last { When::Range(s.start_min, s.end_min) } else if day == s.first { When::From(s.start_min) } else if day == s.last { When::Until(s.end_min) } else { When::AllDay } } /// Split plain text into runs, `true` for an `http(s)://` link. A trailing /// period or closing bracket belongs to the sentence, not the link. pub fn split_links(text: &str) -> Vec<(String, bool)> { let mut out = Vec::new(); let mut rest = text; while let Some(at) = ["https://", "http://"] .iter() .filter_map(|p| rest.find(p)) .min() { let end = rest[at..] .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"')) .map_or(rest.len(), |e| at + e); let link = rest[at..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']); let link_end = at + link.len(); if at > 0 { out.push((rest[..at].to_string(), false)); } out.push((link.to_string(), true)); rest = &rest[link_end..]; } if !rest.is_empty() { out.push((rest.to_string(), false)); } out } // --------------------------------------------------------------------------- // Browser dates // --------------------------------------------------------------------------- fn js_day(d: &js_sys::Date) -> i64 { days_from_civil(d.get_full_year() as i32, d.get_month() + 1, d.get_date()) } fn day_date(day: i64) -> js_sys::Date { let (y, m, d) = civil_from_days(day); js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, d as i32) } fn today() -> i64 { js_day(&js_sys::Date::new_0()) } /// Local midnight of `day` as RFC 3339 UTC, for the instances endpoint. fn day_rfc3339(day: i64) -> String { day_date(day).to_iso_string().into() } fn span_of(i: &PimInstance) -> Span { let s = js_sys::Date::new(&i.start.as_str().into()); let e = js_sys::Date::new(&i.end.as_str().into()); let first = js_day(&s); let start_min = s.get_hours() * 60 + s.get_minutes(); let (last, end_min) = if e.get_time() > s.get_time() { let last = js_day(&js_sys::Date::new(&JsValue::from_f64(e.get_time() - 1.0))); let end_min = if js_day(&e) > last { 1440 } else { e.get_hours() * 60 + e.get_minutes() }; (last, end_min) } else { (first, start_min) }; Span { first, last: last.max(first), all_day: i.all_day, start_min, end_min, } } fn fmt_min(day: i64, min: u32) -> String { let (y, m, d) = civil_from_days(day); let t = js_sys::Date::new_with_year_month_day_hr_min( y as u32, m as i32 - 1, d as i32, (min / 60) as i32, (min % 60) as i32, ); intl(&t, &[("hour", "2-digit"), ("minute", "2-digit")]) } fn fmt_when(w: When, day: i64) -> String { match w { When::AllDay => i18n::t(k::PIM_ALL_DAY).to_string(), When::At(m) => fmt_min(day, m), When::Range(a, b) => format!("{} – {}", fmt_min(day, a), fmt_min(day, b)), When::From(m) => i18n::t_fmt(k::PIM_FROM_TIME, &fmt_min(day, m)), When::Until(m) => i18n::t_fmt(k::PIM_UNTIL_TIME, &fmt_min(day, m)), } } fn fmt_day_long(day: i64) -> String { intl( &day_date(day), &[ ("weekday", "long"), ("day", "numeric"), ("month", "long"), ("year", "numeric"), ], ) } fn fmt_day_short(day: i64) -> String { intl( &day_date(day), &[ ("weekday", "short"), ("day", "numeric"), ("month", "short"), ("year", "numeric"), ], ) } /// The full time of an instance, for its details. fn fmt_span(s: &Span) -> String { match (s.all_day, s.first == s.last) { (true, true) => fmt_day_short(s.first), (true, false) => format!("{} – {}", fmt_day_short(s.first), fmt_day_short(s.last)), (false, true) => format!( "{} · {}", fmt_day_short(s.first), fmt_when(when_on(s, s.first), s.first) ), (false, false) => format!( "{} {} – {} {}", fmt_day_short(s.first), fmt_min(s.first, s.start_min), fmt_day_short(s.last), fmt_min(s.last, s.end_min) ), } } // --------------------------------------------------------------------------- // The view // --------------------------------------------------------------------------- /// Lines per day cell in the month grid, the last one shared with /// "+N more". const LINES: usize = 4; #[derive(Clone, Copy, Debug, PartialEq)] enum CalView { Month(i32, u32), Agenda, Invitations, } impl CalView { fn from_path(path: &[String]) -> Self { match path.first().map(String::as_str) { Some("agenda") => CalView::Agenda, Some("invitations") => CalView::Invitations, _ => path .get(1) .and_then(|ym| { let (y, m) = ym.split_once('-')?; let (y, m) = (y.parse().ok()?, m.parse().ok()?); (1..=12).contains(&m).then_some(CalView::Month(y, m)) }) .unwrap_or_else(|| { let (y, m, _) = civil_from_days(today()); CalView::Month(y, m) }), } } fn path(self) -> Vec { match self { CalView::Month(y, m) => vec!["month".into(), format!("{y:04}-{m:02}")], CalView::Agenda => vec!["agenda".into()], CalView::Invitations => vec!["invitations".into()], } } } fn go(view: CalView) { navigate(&Location { section: Section::Calendar, path: view.path(), ..Location::root() }); } /// The event whose details are open, as the URL names it: /// `{collection}/{recurrence id or -}/{name}`. #[derive(Clone, Debug, PartialEq)] struct Opened { collection_id: i64, recurrence_id: Option, name: String, } impl Opened { fn of(i: &PimInstance) -> Self { Opened { collection_id: i.collection_id, recurrence_id: i.recurrence_id.clone(), name: i.name.clone(), } } fn param(&self) -> String { format!( "{}/{}/{}", self.collection_id, self.recurrence_id.as_deref().unwrap_or("-"), self.name ) } fn parse(s: &str) -> Option { let mut it = s.splitn(3, '/'); let collection_id = it.next()?.parse().ok()?; let rid = it.next()?; let name = it.next()?.to_string(); Some(Opened { collection_id, recurrence_id: (rid != "-").then(|| rid.to_string()), name, }) } fn open(&self) { router::set_open(&self.param()); } } /// An invitation as the instance it names, for the day and time helpers. fn inv_instance(v: &PimInvitation) -> PimInstance { PimInstance { collection_id: v.collection_id, name: v.name.clone(), uid: v.uid.clone(), recurrence_id: v.recurrence_id.clone(), start: v.start.clone(), end: v.end.clone(), all_day: v.all_day, component: "VEVENT".into(), summary: v.summary.clone(), location: v.location.clone(), status: None, transparent: false, has_attendees: true, partstat: None, organizer: None, } } /// An instance with what the views derive from it. #[derive(Clone)] struct Shown { inst: PimInstance, span: Span, color: String, } #[component] pub fn CalendarMain( collections: ReadSignal>>, hidden: RwSignal>, loc: ReadSignal, /// 0 is Sunday. From the profile. week_start: Signal, ) -> impl IntoView { let view = Memo::new(move |_| loc.with(|l| CalView::from_path(&l.path))); let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(Opened::parse))); // Bumped after an answer or an import: every list refetches. let tick = RwSignal::new(0u32); let month = RwSignal::new(Option::>::None); let month_seq = StoredValue::new(0u32); let month_key = StoredValue::new(None::<(i32, u32)>); Effect::new(move |_| { tick.track(); collections.track(); let CalView::Month(y, m) = view.get() else { return; }; // Another month's instances would land on the wrong days. if month_key.get_value() != Some((y, m)) { month_key.set_value(Some((y, m))); month.set(None); } let start = grid_start(y, m, week_start.get()); let seq = month_seq.get_value() + 1; month_seq.set_value(seq); spawn_local(async move { let r = api::pim_instances( &day_rfc3339(start), &day_rfc3339(start + GRID_DAYS), &browser_tz(), ) .await; if month_seq.get_value() == seq { month.set(Some(r.map_err(|e| e.to_string()))); } }); }); let agenda = RwSignal::new(Option::>::None); let agenda_seq = StoredValue::new(0u32); Effect::new(move |_| { tick.track(); collections.track(); if view.get() != CalView::Agenda { return; } let from = today(); let seq = agenda_seq.get_value() + 1; agenda_seq.set_value(seq); spawn_local(async move { let r = api::pim_instances( &day_rfc3339(from), &day_rfc3339(from + AGENDA_DAYS), &browser_tz(), ) .await; if agenda_seq.get_value() == seq { agenda.set(Some(r.map_err(|e| e.to_string()))); } }); }); let invitations = RwSignal::new(Option::, String>>::None); Effect::new(move |_| { tick.track(); spawn_local(async move { let r = api::pim_invitations(&browser_tz()).await; let _ = invitations.try_set(Some(r.map_err(|e| e.to_string()))); }); }); let refresh = Callback::new(move |_| tick.update(|t| *t += 1)); let colors = Memo::new(move |_| { collections.with(|c| { c.iter() .flatten() .map(|c| (c.id, safe_color(c.color.as_deref()))) .collect::>() }) }); let shown = move |list: &api::PimInstances| -> Vec { let colors = colors.get(); hidden.with(|h| { list.instances .iter() .filter(|i| !h.contains(&i.collection_id)) .map(|i| Shown { span: span_of(i), color: colors .get(&i.collection_id) .cloned() .unwrap_or_else(|| safe_color(None)), inst: i.clone(), }) .collect() }) }; // PageUp / PageDown turn the month, unless typing or a dialog is open. owned_window_listener(leptos::ev::keydown, move |ev| { let step = match ev.key().as_str() { "PageUp" => -1, "PageDown" => 1, _ => return, }; let busy = web_sys::window() .and_then(|w| w.document()) .and_then(|d| { d.query_selector("dialog[open], input:focus, textarea:focus") .ok() }) .flatten() .is_some(); if let (CalView::Month(y, m), false) = (view.get_untracked(), busy) { ev.prevent_default(); let (y, m) = add_months(y, m, step); go(CalView::Month(y, m)); } }); let pending = move || { invitations.with(|i| match i { Some(Ok(list)) => list.len(), _ => 0, }) }; let toolbar = move || { let v = view.get(); let title = match v { CalView::Month(y, m) => intl( &js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, 1), &[("month", "long"), ("year", "numeric")], ), CalView::Agenda => i18n::t(k::PIM_UPCOMING).to_string(), CalView::Invitations => i18n::t(k::PIM_INVITATIONS).to_string(), }; let nav = match v { CalView::Month(y, m) => Some(view! {
}), _ => None, }; let this_month = match v { CalView::Month(..) => v, _ => { let (y, m, _) = civil_from_days(today()); CalView::Month(y, m) } }; view! {
{nav}

{title}

} }; let body = move || match view.get() { CalView::Month(y, m) => view! { } .into_any(), CalView::Agenda => view! { } .into_any(), CalView::Invitations => view! { } .into_any(), }; // The times of the open event come from whichever list holds it. let times = move |o: &Opened| -> Option { let find = |list: &Option>| { list.as_ref().and_then(|r| r.as_ref().ok()).and_then(|l| { l.instances .iter() .find(|i| Opened::of(i) == *o) .map(span_of) }) }; month .with_untracked(find) .or_else(|| agenda.with_untracked(find)) .or_else(|| { invitations.with_untracked(|i| { i.as_ref() .and_then(|r| r.as_ref().ok()) .and_then(|l| { l.iter().find(|v| { v.collection_id == o.collection_id && v.name == o.name && v.recurrence_id == o.recurrence_id }) }) .map(|v| span_of(&inv_instance(v))) }) }) }; view! {
{toolbar} {body}
{move || opened.get().map(|o| { let span = times(&o); view! { } })} } } fn truncated_note(list: &api::PimInstances) -> Option> { list.truncated.then(|| { view! {

{i18n::t(k::PIM_TRUNCATED)}

} }) } fn loading_or_error( data: &Option>, ) -> Result { match data { None => Err(view! {

{i18n::t(k::LOADING)}

}.into_any()), Some(Err(e)) => Err(view! {
{e.clone()}
}.into_any()), Some(Ok(list)) => Ok(list.clone()), } } /// One line of a day list: time, color and title. fn day_row(s: &Shown, day: i64) -> impl IntoView + use<> { let o = Opened::of(&s.inst); let when = fmt_when(when_on(&s.span, day), day); let title = s .inst .summary .clone() .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string()); let cancelled = struck(&s.inst); let location = s.inst.location.clone(); view! { } } #[component] fn MonthView( y: i32, m: u32, week_start: u32, data: RwSignal>>, shown: impl Fn(&api::PimInstances) -> Vec + Copy + Send + Sync + 'static, ) -> impl IntoView { let start = grid_start(y, m, week_start); let now = today(); let in_month = move |d: i64| { let (yy, mm, _) = civil_from_days(d); yy == y && mm == m }; let selected = RwSignal::new(if in_month(now) { now } else { days_from_civil(y, m, 1) }); let list_ref: NodeRef = NodeRef::new(); let pick = move |day: i64| { selected.set(day); if let Some(el) = list_ref.get_untracked() { let opts = web_sys::ScrollIntoViewOptions::new(); opts.set_block(web_sys::ScrollLogicalPosition::Nearest); el.scroll_into_view_with_scroll_into_view_options(&opts); } }; let head = (0..7) .map(|i| { view! {
{intl(&day_date(start + i), &[("weekday", "short")])}
} }) .collect::>(); view! {
{head}
{move || data.with(|d| match loading_or_error(d) { Err(v) => v, Ok(list) => { let items = shown(&list); let spans: Vec = items.iter().map(|s| s.span).collect(); let weeks = (0..6) .map(|w| { let week = start + w * 7; let layout = layout_week(&spans, week, LINES); let cells = (0..7usize) .map(|c| { let day = week + c as i64; let (_, _, dnum) = civil_from_days(day); let dots: Vec = items .iter() .filter(|s| s.span.covers(day)) .take(3) .map(|s| s.color.clone()) .collect(); let label = fmt_day_long(day); view! { } }) .collect::>(); let pieces = layout .pieces .iter() .map(|p| { let s = &items[p.item]; let o = Opened::of(&s.inst); let bar = s.span.is_bar(); let title = s .inst .summary .clone() .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string()); let time = (!bar).then(|| fmt_min(s.span.first, s.span.start_min)); let cancelled = struck(&s.inst); let tip = match &time { Some(t) => format!("{t} {title}"), None => title.clone(), }; view! { } }) .collect::>(); let more = layout .more .iter() .enumerate() .filter(|&(_, &n)| n > 0) .map(|(c, &n)| { let day = week + c as i64; view! { } }) .collect::>(); view! {
{cells} {pieces} {more}
} }) .collect::>(); let day_list = move || { let day = selected.get(); let rows: Vec<_> = items .iter() .filter(|s| s.span.covers(day)) .map(|s| day_row(s, day)) .collect(); view! {

{fmt_day_long(day)}

{if rows.is_empty() { view! {

{i18n::t(k::PIM_NO_EVENTS_DAY)}

}.into_any() } else { view! {
{rows}
}.into_any() }} } }; view! { {truncated_note(&list)}
{weeks}
{day_list}
} .into_any() } })}
} } /// The agenda shows today and the days after it. The month view is for /// browsing further. const AGENDA_DAYS: i64 = 7; #[component] fn AgendaView( data: RwSignal>>, shown: impl Fn(&api::PimInstances) -> Vec + Copy + Send + Sync + 'static, ) -> impl IntoView { view! {
{move || data.with(|d| match loading_or_error(d) { Err(v) => v, Ok(list) => { let items = shown(&list); let from = today(); let groups: Vec<_> = (from..from + AGENDA_DAYS) .filter_map(|day| { let rows: Vec<_> = items .iter() .filter(|s| s.span.covers(day)) .map(|s| day_row(s, day)) .collect(); (!rows.is_empty()).then(|| view! {

{fmt_day_long(day)}

{rows}
}) }) .collect(); let empty = groups.is_empty().then(|| view! {
{icon_svg(IconName::Calendar, "empty-glyph")}

{i18n::t(k::PIM_NO_EVENTS_AGENDA)}

}); view! { {truncated_note(&list)} {empty} {groups} } .into_any() } })}
} } /// Answer an invitation, then refresh the lists. fn reply( toast: ToastMsg, collection_id: i64, name: String, recurrence_id: Option, partstat: &'static str, done: Callback<()>, ) { spawn_local(async move { let body = PimReply { collection_id, name, recurrence_id, partstat: partstat.to_string(), tz: Some(browser_tz()), }; match api::pim_reply(body).await { Ok(_) => { show(toast, i18n::t(k::PIM_REPLIED)); done.run(()); } Err(e) => show_error(toast, e.to_string()), } }); } /// Accept, maybe, decline. `current` marks the answer already given. fn reply_buttons( current: Option, on: impl Fn(&'static str) + Clone + 'static, ) -> impl IntoView { [ ("ACCEPTED", k::PIM_ACCEPT), ("TENTATIVE", k::PIM_TENTATIVE), ("DECLINED", k::PIM_DECLINE), ] .map(|(ps, label)| { let on = on.clone(); let active = current.as_deref() == Some(ps); view! { } }) .into_iter() .collect::>() } #[component] fn InvitationsView( data: RwSignal, String>>>, collections: ReadSignal>>, on_replied: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); view! {
{move || match data.get() { None => view! {

{i18n::t(k::LOADING)}

}.into_any(), Some(Err(e)) => view! {
{e}
}.into_any(), Some(Ok(list)) if list.is_empty() => view! {
{icon_svg(IconName::Calendar, "empty-glyph")}

{i18n::t(k::PIM_NO_INVITATIONS)}

} .into_any(), Some(Ok(list)) => { let names: HashMap = collections.with_untracked(|c| { c.iter().flatten().map(|c| (c.id, display_name(c))).collect() }); list.into_iter() .map(|inv| { let o = Opened { collection_id: inv.collection_id, recurrence_id: inv.recurrence_id.clone(), name: inv.name.clone(), }; let span = span_of(&inv_instance(&inv)); let series = inv.recurring && inv.recurrence_id.is_none(); let mut meta = vec![fmt_span(&span)]; match (&inv.rrule, &inv.recurrence_id) { (Some(r), None) => meta.push(rrule_text(&with_start_day(r, span.first))), (None, Some(_)) => meta.push(i18n::t(k::PIM_THIS_EVENT).to_string()), _ => {} } if let Some(n) = names.get(&inv.collection_id) { meta.push(n.clone()); } let organizer = inv.organizer.as_ref().map(|p| { p.name.clone().unwrap_or_else(|| { p.address.trim_start_matches("mailto:").to_string() }) }); let title = inv .summary .clone() .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string()); let (id, name, rid) = (inv.collection_id, inv.name.clone(), inv.recurrence_id.clone()); let open = o.clone(); view! {
{reply_buttons(None, move |ps| { reply(toast, id, name.clone(), rid.clone(), ps, on_replied) })}
} }) .collect::>() .into_any() } }}
} } fn partstat_label(ps: Option<&str>) -> (&'static str, &'static str) { match ps { Some("ACCEPTED") => (k::PIM_PS_ACCEPTED, "ps-accepted"), Some("DECLINED") => (k::PIM_PS_DECLINED, "ps-declined"), Some("TENTATIVE") => (k::PIM_PS_TENTATIVE, "ps-tentative"), Some("DELEGATED") => (k::PIM_PS_DELEGATED, "ps-muted"), _ => (k::PIM_PS_NEEDS_ACTION, "ps-muted"), } } /// `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` as words; an unknown FREQ stays raw. fn rrule_text(rule: &str) -> String { let part = |key: &str| { rule.split(';') .find_map(|p| p.strip_prefix(key)?.strip_prefix('=')) }; let n = part("INTERVAL") .and_then(|n| n.parse::().ok()) .unwrap_or(1); let keys = match part("FREQ") { Some("DAILY") => (k::PIM_RRULE_DAILY, k::PIM_RRULE_DAILY_N), Some("WEEKLY") => (k::PIM_RRULE_WEEKLY, k::PIM_RRULE_WEEKLY_N), Some("MONTHLY") => (k::PIM_RRULE_MONTHLY, k::PIM_RRULE_MONTHLY_N), Some("YEARLY") => (k::PIM_RRULE_YEARLY, k::PIM_RRULE_YEARLY_N), _ => return rule.to_string(), }; let base = match n { 1 => i18n::t(keys.0).to_string(), n => i18n::t_fmt(keys.1, &n.to_string()), }; // Plain weekdays of a weekly rule, e.g. "Every week: Tue, Thu". let days: Vec = match part("FREQ") { Some("WEEKLY") => part("BYDAY") .unwrap_or_default() .split(',') .filter_map(weekday_name) .collect(), _ => Vec::new(), }; match days.is_empty() { true => base, false => format!("{base}: {}", days.join(", ")), } } /// A weekly rule without BYDAY repeats on its start's weekday. Naming it /// makes "Every week" read "Every week: Tue". fn with_start_day(rule: &str, first: i64) -> String { let weekly = rule.split(';').any(|p| p == "FREQ=WEEKLY"); if !weekly || rule.split(';').any(|p| p.starts_with("BYDAY=")) { return rule.to_string(); } let code = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][weekday(first) as usize]; format!("{rule};BYDAY={code}") } /// `TU` as the locale's short weekday name. Numbered days (`2TU`) are skipped. fn weekday_name(code: &str) -> Option { let offset = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] .iter() .position(|&d| d == code)?; // 2024-01-01 was a Monday. let date = js_sys::Date::new_with_year_month_day(2024, 0, 1 + offset as i32); Some(intl(&date, &[("weekday", "short")])) } fn person_name(p: &api_types::PimPerson) -> String { p.name .clone() .unwrap_or_else(|| p.address.trim_start_matches("mailto:").to_string()) } /// Text with its web links clickable, for a `white-space: pre-wrap` block. pub fn linkified(text: &str) -> impl IntoView + use<> { split_links(text) .into_iter() .map(|(t, link)| match link { true => { let href = t.clone(); view! { {t} }.into_any() } false => t.into_any(), }) .collect::>() } #[component] fn EventDialog( target: Opened, span: Option, collections: ReadSignal>>, on_replied: Callback<()>, on_close: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let detail = RwSignal::new(Option::>::None); let load = { let t = target.clone(); move || { let t = t.clone(); spawn_local(async move { let r = api::pim_object( t.collection_id, &t.name, t.recurrence_id.as_deref(), &browser_tz(), ) .await; let r = match r { Ok(PimObjectDetail::Event(e)) => Ok(e), Ok(PimObjectDetail::Contact(_)) => Err(i18n::t(k::PIM_NOT_EVENT).to_string()), Err(e) => Err(e.to_string()), }; let _ = detail.try_set(Some(r)); }); } }; load(); // For one instance of a series: answer it alone, or the whole series. let only_this = RwSignal::new(true); let collection = collections.with_untracked(|c| { c.iter() .flatten() .find(|c| c.id == target.collection_id) .map(|c| (display_name(c), safe_color(c.color.as_deref()))) }); let body = move || match detail.get() { None => view! {

{i18n::t(k::LOADING)}

}.into_any(), Some(Err(e)) => view! {

{e}

}.into_any(), Some(Ok(d)) => { let title = d .summary .clone() .unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string()); let mine = d .attendees .iter() .find(|a| a.is_owner) .and_then(|a| a.partstat.clone()); let series = target.recurrence_id.is_some() && d.rrule.is_some(); let rid = target.recurrence_id.clone(); let (id, name) = (target.collection_id, target.name.clone()); let load = load.clone(); let answer = move |ps: &'static str| { let rid = if only_this.get_untracked() { rid.clone() } else { None }; let load = load.clone(); reply( toast, id, name.clone(), rid, ps, Callback::new(move |_| { load(); on_replied.run(()); }), ); }; let url = d .url .clone() .filter(|u| u.starts_with("https://") || u.starts_with("http://")); view! { {(d.status.as_deref() == Some("CANCELLED")).then(|| view! {

{i18n::t(k::PIM_CANCELLED)}

})}
{span.map(|s| view! {
{i18n::t(k::PIM_WHEN)}
{fmt_span(&s)}
})} {d.rrule.clone().map(|r| view! {
{i18n::t(k::PIM_REPEATS)}
{rrule_text(&r)}
})} {d.location.clone().map(|l| view! {
{i18n::t(k::PIM_LOCATION)}
{linkified(&l)}
})} {collection.clone().map(|(n, c)| view! {
{i18n::t(k::PIM_CALENDAR)}
" "{n}
})} {d.organizer.as_ref().map(|p| view! {
{i18n::t(k::PIM_ORGANIZER)}
{person_name(p)}
})} {url.map(|u| { let href = u.clone(); view! {
{i18n::t(k::PIM_LINK)}
{u}
} })} {(!d.categories.is_empty()).then(|| view! {
{i18n::t(k::PIM_CATEGORIES)}
{d.categories.join(", ")}
})}
{(!d.attendees.is_empty()).then(|| view! {

{i18n::t(k::PIM_ATTENDEES)}

    {d.attendees .iter() .map(|a| { let (label, class) = partstat_label(a.partstat.as_deref()); let optional = a.role.as_deref() == Some("OPT-PARTICIPANT"); view! {
  • {person_name(&a.person)} {optional.then(|| view! { {i18n::t(k::PIM_OPTIONAL)} })} {i18n::t(label)}
  • } }) .collect::>()}
})} {d.description.clone().filter(|t| !t.trim().is_empty()).map(|t| view! {
{linkified(&t)}
})} {d.can_reply.then(|| view! {

{i18n::t(k::PIM_YOUR_ANSWER)}

{series.then(|| view! {
})}
{reply_buttons(mine, answer)}
})} } .into_any() } }; view! { {body} } } #[cfg(test)] mod tests { use super::*; fn day(y: i32, m: u32, d: u32) -> i64 { days_from_civil(y, m, d) } fn timed(first: i64, start_min: u32) -> Span { Span { first, last: first, all_day: false, start_min, end_min: start_min + 60, } } fn all_day(first: i64, last: i64) -> Span { Span { first, last, all_day: true, start_min: 0, end_min: 1440, } } #[test] fn civil_days_round_trip() { assert_eq!(day(1970, 1, 1), 0); assert_eq!(day(2000, 3, 1), 11017); for z in [-800_000, -1, 0, 59, 60, 11016, 20_000, 2_000_000] { let (y, m, d) = civil_from_days(z); assert_eq!(day(y, m, d), z); } assert_eq!(civil_from_days(day(2024, 2, 29)), (2024, 2, 29)); // 2026-10-01 is a Thursday. assert_eq!(weekday(day(2026, 10, 1)), 4); } #[test] fn grid_starts_on_the_week_start() { // October 2026 starts on a Thursday. let sunday = grid_start(2026, 10, 0); let monday = grid_start(2026, 10, 1); assert_eq!(civil_from_days(sunday), (2026, 9, 27)); assert_eq!(civil_from_days(monday), (2026, 9, 28)); assert_eq!(weekday(monday), 1); assert_eq!(civil_from_days(grid_start(2026, 10, 6)), (2026, 9, 26)); // A month starting on the week start begins the grid itself, and // six weeks always cover the whole month. assert_eq!(grid_start(2026, 6, 1), day(2026, 6, 1)); for (y, m) in [(2026, 2), (2026, 8), (2027, 1)] { for ws in [0, 1, 6] { let s = grid_start(y, m, ws); let (ny, nm) = add_months(y, m, 1); assert!(s <= day(y, m, 1) && day(ny, nm, 1) <= s + GRID_DAYS); } } assert_eq!(add_months(2026, 12, 1), (2027, 1)); assert_eq!(add_months(2026, 1, -1), (2025, 12)); } #[test] fn bars_split_across_weeks() { // Saturday to Tuesday, in weeks starting on Monday. let week1 = grid_start(2026, 10, 1); let span = all_day(day(2026, 10, 3), day(2026, 10, 6)); let a = layout_week(&[span], week1, 4); assert_eq!( a.pieces, [Piece { item: 0, col: 5, len: 2, line: 0, starts: true, ends: false }] ); let b = layout_week(&[span], week1 + 7, 4); assert_eq!( b.pieces, [Piece { item: 0, col: 0, len: 2, line: 0, starts: false, ends: true }] ); assert_eq!(a.more, [0; 7]); } #[test] fn busy_days_share_the_last_line_with_more() { let week = grid_start(2026, 10, 1); let mon = week; // Five timed events on Monday; a bar Monday to Wednesday. let mut spans: Vec = (0..5).map(|i| timed(mon, 600 + i * 30)).collect(); spans.push(all_day(mon, mon + 2)); let l = layout_week(&spans, week, 3); // Monday has six instances on three lines: two shown, four behind // "+4 more". The bar comes first, so it stays visible. assert_eq!(l.more, [4, 0, 0, 0, 0, 0, 0]); let bar = l.pieces.iter().find(|p| p.item == 5).unwrap(); assert_eq!((bar.col, bar.len, bar.line), (0, 3, 0)); assert_eq!(l.pieces.iter().filter(|p| p.col == 0).count(), 2); // Timed events keep their order. let first_chip = l.pieces.iter().find(|p| p.item != 5).unwrap(); assert_eq!(first_chip.item, 0); } #[test] fn a_weekly_rule_names_its_start_day() { let friday = days_from_civil(2026, 10, 2); assert_eq!( with_start_day("FREQ=WEEKLY", friday), "FREQ=WEEKLY;BYDAY=FR" ); assert_eq!( with_start_day("FREQ=WEEKLY;BYDAY=MO", friday), "FREQ=WEEKLY;BYDAY=MO" ); assert_eq!(with_start_day("FREQ=DAILY", friday), "FREQ=DAILY"); } #[test] fn a_bar_hidden_on_a_busy_day_splits() { let week = grid_start(2026, 10, 1); // Two bars over Mon–Wed; three timed events on Tuesday only. let mut spans = vec![all_day(week, week + 2), all_day(week, week + 2)]; spans.extend((0..3).map(|i| timed(week + 1, 600 + i * 60))); let l = layout_week(&spans, week, 2); // Tuesday overflows, so its last line (the second bar) shows "+4". assert_eq!(l.more, [0, 4, 0, 0, 0, 0, 0]); let second: Vec<_> = l.pieces.iter().filter(|p| p.item == 1).collect(); assert_eq!(second.len(), 2); assert_eq!( (second[0].col, second[0].len, second[0].ends), (0, 1, false) ); assert_eq!( (second[1].col, second[1].len, second[1].starts), (2, 1, false) ); } #[test] fn times_on_each_day() { let s = Span { first: 10, last: 12, all_day: false, start_min: 22 * 60, end_min: 9 * 60, }; assert_eq!(when_on(&s, 10), When::From(1320)); assert_eq!(when_on(&s, 11), When::AllDay); assert_eq!(when_on(&s, 12), When::Until(540)); assert_eq!(when_on(&timed(5, 600), 5), When::Range(600, 660)); assert_eq!(when_on(&all_day(5, 5), 5), When::AllDay); } #[test] fn links_are_found_in_text() { assert_eq!( split_links("See https://example.com/a?b=1. Or http://x.org)"), [ ("See ".to_string(), false), ("https://example.com/a?b=1".to_string(), true), (". Or ".to_string(), false), ("http://x.org".to_string(), true), (")".to_string(), false), ] ); assert_eq!( split_links("no links, javascript:alert(1)"), [("no links, javascript:alert(1)".to_string(), false)] ); } #[test] fn opened_round_trips() { let o = Opened { collection_id: 7, recurrence_id: Some("2026-10-05T08:00:00Z".into()), name: "a/b.ics".into(), }; assert_eq!(Opened::parse(&o.param()), Some(o)); let series = Opened { collection_id: -1, recurrence_id: None, name: "x.ics".into(), }; assert_eq!(Opened::parse(&series.param()), Some(series)); } }