use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use chrono::{
DateTime, Datelike, Duration as ChronoDuration, NaiveDate, NaiveTime, TimeZone, Timelike, Utc,
};
use hifitime::Epoch;
use GORBIE::prelude::CardCtx;
use GORBIE::themes::{blend, colorhash};
use crate::planner as planner_model;
use crate::relations::{self, Head, ProfileInput, ProfileView};
use crate::widgets::storage::{DatasetRevision, DatasetView};
use triblespace::core::id::Id;
fn color_muted(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x9a, 0x9a, 0x9a)
} else {
egui::Color32::from_rgb(0x6a, 0x6a, 0x6a)
}
}
fn color_frame(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x29, 0x32, 0x36)
} else {
egui::Color32::from_rgb(0xec, 0xec, 0xec)
}
}
fn color_faint(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x3a, 0x42, 0x46)
} else {
egui::Color32::from_rgb(0xe2, 0xe2, 0xe2)
}
}
fn color_default_event() -> egui::Color32 {
egui::Color32::from_rgb(0x3b, 0x83, 0xbd)
}
fn color_today() -> egui::Color32 {
egui::Color32::from_rgb(0xf7, 0xba, 0x0b)
}
fn person_color(id: Id) -> egui::Color32 {
colorhash::ral_categorical(id.as_ref())
}
fn epoch_to_chrono(e: Epoch) -> Option<DateTime<Utc>> {
let secs = e.to_unix_seconds();
if !secs.is_finite() {
return None;
}
let whole = secs.floor();
if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
return None;
}
let nanos = ((secs - whole) * 1e9).round().clamp(0.0, 999_999_999.0) as u32;
Utc.timestamp_opt(whole as i64, nanos).single()
}
fn current_utc() -> Option<DateTime<Utc>> {
crate::clock::now().ok().and_then(epoch_to_chrono)
}
fn week_monday(today: NaiveDate) -> NaiveDate {
today - ChronoDuration::days(today.weekday().num_days_from_monday() as i64)
}
fn is_all_day(start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
let dur = (end - start).num_seconds();
start.time() == NaiveTime::from_hms_opt(0, 0, 0).unwrap() && dur > 0 && dur % 86_400 == 0
}
fn format_day_header(date: NaiveDate) -> String {
let weekday = date.format("%a").to_string().to_uppercase();
format!("{weekday} {}", date.day())
}
fn format_day_section(date: NaiveDate) -> String {
let weekday = date.format("%a").to_string().to_uppercase();
let month = date.format("%b").to_string().to_uppercase();
format!("{weekday} {} {month} {}", date.day(), date.year())
}
fn format_time_range(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
if is_all_day(start, end) {
if (end - start).num_days() <= 1 {
"ALL DAY".to_string()
} else {
format!("ALL DAY × {}", (end - start).num_days())
}
} else {
format!(
"{:02}:{:02} — {:02}:{:02}",
start.hour(),
start.minute(),
end.hour(),
end.minute(),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EventStatus {
Confirmed,
Tentative,
Cancelled,
}
impl EventStatus {
fn parse(s: &str) -> Self {
match s.trim().to_ascii_uppercase().as_str() {
"TENTATIVE" => Self::Tentative,
"CANCELLED" => Self::Cancelled,
_ => Self::Confirmed,
}
}
fn badge(self) -> Option<&'static str> {
match self {
Self::Confirmed => None,
Self::Tentative => Some("TENTATIVE"),
Self::Cancelled => Some("CANCELLED"),
}
}
}
#[derive(Clone, Debug)]
struct EventRow {
summary: String,
start: DateTime<Utc>,
end: DateTime<Utc>,
location: Option<String>,
status: EventStatus,
rrule: Option<String>,
attendees: Vec<Id>,
organizer: Option<Id>,
notes: Vec<String>,
}
#[derive(Clone, Debug, Default)]
struct Person {
alias: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
display_name: Option<String>,
email: Option<String>,
}
impl Person {
fn from_profile(mut profile: ProfileInput) -> Self {
profile.emails.sort();
profile.emails.dedup();
Self {
alias: Some(profile.label),
first_name: profile.first_name,
last_name: profile.last_name,
display_name: profile.display_name,
email: profile.emails.into_iter().next(),
}
}
fn display(&self, id: Id) -> String {
if let Some(a) = self.alias.as_ref() {
if !a.is_empty() {
return a.clone();
}
}
match (self.first_name.as_ref(), self.last_name.as_ref()) {
(Some(f), Some(l)) if !f.is_empty() && !l.is_empty() => {
return format!("{f} {l}");
}
(Some(f), _) if !f.is_empty() => return f.clone(),
(_, Some(l)) if !l.is_empty() => return l.clone(),
_ => {}
}
if let Some(d) = self.display_name.as_ref() {
if !d.is_empty() {
return d.clone();
}
}
if let Some(e) = self.email.as_ref() {
if !e.is_empty() {
return e.clone();
}
}
format!("{id:x}")
}
}
struct PlannerLive {
cached_revision: DatasetRevision,
relations_cached_revision: Option<DatasetRevision>,
events: Vec<EventRow>,
people: HashMap<Id, Person>,
diagnostics: Vec<String>,
}
impl PlannerLive {
fn refresh(view: DatasetView<'_>, relations: Option<DatasetView<'_>>) -> Self {
let (relations_cached_revision, people, mut diagnostics) = match relations {
Some(relations) => {
let (people, diagnostics) = build_people(relations);
(Some(relations.revision), people, diagnostics)
}
None => (None, HashMap::new(), Vec::new()),
};
let (events, planner_diagnostics) = collect_events(view);
diagnostics.extend(planner_diagnostics);
PlannerLive {
cached_revision: view.revision,
relations_cached_revision,
events,
people,
diagnostics,
}
}
fn display(&self, id: Id) -> String {
self.people
.get(&id)
.map(|p| p.display(id))
.unwrap_or_else(|| format!("{id:x}"))
}
}
fn collect_events(view: DatasetView<'_>) -> (Vec<EventRow>, Vec<String>) {
let mut diagnostics = Vec::new();
let mut events = Vec::new();
let event_ids = planner_model::event_ids(view.facts)
.into_iter()
.collect::<BTreeSet<_>>();
for event_id in event_ids {
let Some(row) = planner_model::event(view.facts, event_id) else {
continue;
};
let (start, end): (Epoch, Epoch) = match row.time.try_from_inline() {
Ok(interval) => interval,
Err(error) => {
diagnostics.push(format!(
"Planner event {:x} has an invalid time interval: {error:?}",
row.id
));
continue;
}
};
let Some(start) = epoch_to_chrono(start) else {
diagnostics.push(format!(
"Planner event {:x} starts outside the displayable calendar range",
row.id
));
continue;
};
let Some(end) = epoch_to_chrono(end) else {
diagnostics.push(format!(
"Planner event {:x} ends outside the displayable calendar range",
row.id
));
continue;
};
let mut notes = planner_model::notes_for_event(view.facts, row.id);
notes.sort_by(|left, right| {
left.created_at
.raw
.cmp(&right.created_at.raw)
.then_with(|| left.id.cmp(&right.id))
});
let notes = notes
.into_iter()
.filter_map(
|note| match planner_model::read_text(view.reader, note.text) {
Ok(text) => Some(text),
Err(error) => {
diagnostics.push(format!(
"Planner note {:x} is unavailable: {error:#}",
note.id
));
None
}
},
)
.collect();
events.push(EventRow {
summary: row.summary.clone(),
start,
end,
location: row.location.clone(),
status: if planner_model::event_is_cancelled(view.facts, row.id) {
EventStatus::Cancelled
} else {
EventStatus::parse(&row.status)
},
rrule: row.rrule.clone(),
attendees: row.attendees.iter().copied().collect(),
organizer: row.organizer,
notes,
});
}
events.sort_by(|left, right| {
left.start
.cmp(&right.start)
.then_with(|| left.summary.cmp(&right.summary))
});
(events, diagnostics)
}
fn build_people(relations_view: DatasetView<'_>) -> (HashMap<Id, Person>, Vec<String>) {
let mut people = HashMap::new();
let mut diagnostics = Vec::new();
for (person, view) in
relations::person_profile_views(relations_view.reader, relations_view.facts)
{
match view {
ProfileView::Current { value, .. } => {
people.insert(person, Person::from_profile(value));
}
ProfileView::Forked(heads) => diagnostics.push(format!(
"Relations profile {person:x} is forked across {}",
heads
.iter()
.map(|id| format!("{id:x}"))
.collect::<Vec<_>>()
.join(", ")
)),
ProfileView::Invalid(error) => {
diagnostics.push(format!("Relations profile {person:x} is invalid: {error}"));
}
}
match relations::lifecycle_head(relations_view.facts, person) {
Ok(Head::Unique(snapshot)) => {
if let Err(error) = relations::lifecycle_snapshot(relations_view.facts, snapshot) {
diagnostics.push(format!(
"Relations lifecycle {person:x} is invalid: {error}"
));
}
}
Ok(Head::Forked(heads)) => diagnostics.push(format!(
"Relations lifecycle {person:x} is forked across {}",
heads
.iter()
.map(|id| format!("{id:x}"))
.collect::<Vec<_>>()
.join(", ")
)),
Ok(Head::Missing) => diagnostics.push(format!(
"Relations person {person:x} has no lifecycle snapshot"
)),
Err(error) => diagnostics.push(format!(
"Relations lifecycle {person:x} is invalid: {error}"
)),
}
}
(people, diagnostics)
}
pub struct PlannerViewer {
live: Option<PlannerLive>,
}
impl Default for PlannerViewer {
fn default() -> Self {
Self { live: None }
}
}
impl PlannerViewer {
pub fn new() -> Self {
Self::default()
}
pub fn render(
&mut self,
ctx: &mut CardCtx<'_>,
view: DatasetView<'_>,
relations: Option<DatasetView<'_>>,
) {
let revision = view.revision;
let relations_revision = relations.map(|view| view.revision);
let need_refresh = match self.live.as_ref() {
None => true,
Some(l) => {
l.cached_revision != revision || l.relations_cached_revision != relations_revision
}
};
if need_refresh {
self.live = Some(PlannerLive::refresh(view, relations));
}
ctx.section("Planner", |ctx| {
let Some(live) = self.live.as_ref() else {
return;
};
ctx.grid(|g| {
let now = current_utc();
let today = now.as_ref().map(|now| now.date_naive());
let monday = today.map(week_monday);
for diagnostic in &live.diagnostics {
g.full(|ctx| render_diagnostic(ctx.ui_mut(), diagnostic));
}
g.full(|ctx| {
let ui = ctx.ui_mut();
let count = live.events.len();
let plural = if count == 1 { "" } else { "S" };
let label = match monday {
Some(monday) => format!(
"WEEK OF {} — {count} EVENT{plural}",
monday.format("%-d %b %Y").to_string().to_uppercase(),
),
None => format!("CURRENT WEEK UNAVAILABLE — {count} EVENT{plural}"),
};
ui.label(
egui::RichText::new(label)
.monospace()
.strong()
.small()
.color(color_muted(ui)),
);
});
if let (Some(monday), Some(now)) = (monday, now.as_ref()) {
g.full(|ctx| {
render_week_grid(ctx.ui_mut(), live, monday, now);
});
}
if live.events.is_empty() {
g.full(|ctx| {
let ui = ctx.ui_mut();
ui.add_space(16.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("\u{1F4C5}") .size(28.0)
.color(color_muted(ui)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("No events.")
.monospace()
.small()
.strong()
.color(color_muted(ui)),
);
});
ui.add_space(16.0);
});
} else {
let mut by_date: BTreeMap<NaiveDate, Vec<&EventRow>> = BTreeMap::new();
for event in &live.events {
by_date
.entry(event.start.date_naive())
.or_default()
.push(event);
}
for (date, events) in by_date.iter() {
let n = events.len();
let header = format!(
"{} · {n} EVENT{}",
format_day_section(*date),
if n == 1 { "" } else { "S" },
);
let is_today = today.is_some_and(|today| *date == today);
g.full(|ctx| {
render_day_section_header(ctx.ui_mut(), &header, is_today);
});
for event in events {
g.full(|ctx| {
render_event_card(ctx.ui_mut(), event, live);
});
}
}
}
});
});
}
}
const DAY_HEADER_HEIGHT: f32 = 24.0;
const ALL_DAY_HEIGHT: f32 = 22.0;
const HOUR_START: u32 = 6;
const HOUR_END: u32 = 22;
const PX_PER_HOUR: f32 = 18.0;
const HOUR_LABEL_WIDTH: f32 = 36.0;
fn render_week_grid(ui: &mut egui::Ui, live: &PlannerLive, monday: NaiveDate, now: &DateTime<Utc>) {
let today = now.date_naive();
let width = ui.available_width();
let hours_visible = (HOUR_END - HOUR_START) as f32;
let grid_height = DAY_HEADER_HEIGHT + ALL_DAY_HEIGHT + hours_visible * PX_PER_HOUR;
let (rect, _resp) =
ui.allocate_exact_size(egui::vec2(width, grid_height), egui::Sense::hover());
let painter = ui.painter().clone();
let bubble_fill = ui.visuals().window_fill;
let stroke = egui::Stroke::new(1.0, color_frame(ui));
let faint_stroke = egui::Stroke::new(0.5, color_faint(ui));
let muted = color_muted(ui);
painter.rect_filled(rect, egui::CornerRadius::ZERO, bubble_fill);
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Inside);
let day_grid_left = rect.left() + HOUR_LABEL_WIDTH;
let day_grid_width = rect.right() - day_grid_left;
let day_col_width = day_grid_width / 7.0;
let day_header_top = rect.top();
let all_day_top = day_header_top + DAY_HEADER_HEIGHT;
let hour_grid_top = all_day_top + ALL_DAY_HEIGHT;
let hour_grid_bottom = hour_grid_top + hours_visible * PX_PER_HOUR;
for day in 0..7u32 {
let date = monday + ChronoDuration::days(day as i64);
let col_left = day_grid_left + (day as f32) * day_col_width;
let header_rect = egui::Rect::from_min_size(
egui::pos2(col_left, day_header_top),
egui::vec2(day_col_width, DAY_HEADER_HEIGHT),
);
let is_today = date == today;
painter.rect_filled(header_rect, egui::CornerRadius::ZERO, color_frame(ui));
let text_color = if is_today {
ui.visuals().text_color()
} else {
muted
};
painter.text(
header_rect.center(),
egui::Align2::CENTER_CENTER,
format_day_header(date),
egui::FontId::monospace(11.0),
text_color,
);
if is_today {
let accent_rect = egui::Rect::from_min_size(
egui::pos2(col_left, header_rect.top()),
egui::vec2(day_col_width, 3.0),
);
painter.rect_filled(accent_rect, egui::CornerRadius::ZERO, color_today());
}
}
let all_day_rect = egui::Rect::from_min_size(
egui::pos2(day_grid_left, all_day_top),
egui::vec2(day_grid_width, ALL_DAY_HEIGHT),
);
painter.rect_filled(all_day_rect, egui::CornerRadius::ZERO, color_faint(ui));
if today >= monday && today < monday + ChronoDuration::days(7) {
let day_index = (today - monday).num_days() as u32;
let col_left = day_grid_left + (day_index as f32) * day_col_width;
let tint = egui::Color32::from_rgba_unmultiplied(
color_today().r(),
color_today().g(),
color_today().b(),
22, );
let tint_rect = egui::Rect::from_min_size(
egui::pos2(col_left, all_day_top),
egui::vec2(day_col_width, hour_grid_bottom - all_day_top),
);
painter.rect_filled(tint_rect, egui::CornerRadius::ZERO, tint);
}
for h in HOUR_START..=HOUR_END {
let y = hour_grid_top + ((h - HOUR_START) as f32) * PX_PER_HOUR;
painter.text(
egui::pos2(rect.left() + HOUR_LABEL_WIDTH - 6.0, y),
egui::Align2::RIGHT_CENTER,
format!("{h:02}"),
egui::FontId::monospace(9.0),
muted,
);
let stroke = if h % 6 == 0 {
egui::Stroke::new(0.8, color_frame(ui))
} else {
faint_stroke
};
painter.line_segment(
[egui::pos2(day_grid_left, y), egui::pos2(rect.right(), y)],
stroke,
);
}
for day in 0..=7u32 {
let x = day_grid_left + (day as f32) * day_col_width;
painter.line_segment(
[egui::pos2(x, all_day_top), egui::pos2(x, hour_grid_bottom)],
faint_stroke,
);
}
if today >= monday && today < monday + ChronoDuration::days(7) {
ui.ctx()
.request_repaint_after(std::time::Duration::from_secs(60));
let now_hour_f = now.hour() as f32 + now.minute() as f32 / 60.0;
if now_hour_f >= HOUR_START as f32 && now_hour_f <= HOUR_END as f32 {
let day_index = (today - monday).num_days() as u32;
let col_left = day_grid_left + (day_index as f32) * day_col_width;
let y = hour_grid_top + (now_hour_f - HOUR_START as f32) * PX_PER_HOUR;
painter.circle_filled(egui::pos2(col_left + 3.0, y), 2.5, color_today());
painter.line_segment(
[
egui::pos2(col_left + 3.0, y),
egui::pos2(col_left + day_col_width - 1.0, y),
],
egui::Stroke::new(1.5, color_today()),
);
}
}
let week_end = monday + ChronoDuration::days(7);
for event in &live.events {
let event_date = event.start.date_naive();
if event_date < monday || event_date >= week_end {
continue;
}
let day_index = (event_date - monday).num_days() as u32;
let col_left = day_grid_left + (day_index as f32) * day_col_width;
if is_all_day(event.start, event.end) {
let block_rect = egui::Rect::from_min_size(
egui::pos2(col_left + 1.0, all_day_top + 2.0),
egui::vec2(day_col_width - 2.0, ALL_DAY_HEIGHT - 4.0),
);
paint_event_block(&painter, block_rect, event, true);
} else {
let start_hour_f = event.start.hour() as f32 + (event.start.minute() as f32) / 60.0;
let end_hour_f = event.end.hour() as f32 + (event.end.minute() as f32) / 60.0;
let top_y = hour_grid_top + (start_hour_f - HOUR_START as f32) * PX_PER_HOUR;
let bot_y = hour_grid_top + (end_hour_f - HOUR_START as f32) * PX_PER_HOUR;
let top_y = top_y.max(hour_grid_top);
let bot_y = bot_y.min(hour_grid_bottom).max(top_y + 14.0);
let block_rect = egui::Rect::from_min_max(
egui::pos2(col_left + 1.0, top_y),
egui::pos2(col_left + day_col_width - 1.0, bot_y),
);
paint_event_block(&painter, block_rect, event, false);
}
}
}
fn paint_event_block(
painter: &egui::Painter,
rect: egui::Rect,
event: &EventRow,
is_all_day_block: bool,
) {
let accent = match event.organizer {
Some(org) => person_color(org),
None => color_default_event(),
};
let (fill, stroke_color, text_color) = match event.status {
EventStatus::Confirmed => (
accent,
accent.gamma_multiply(0.7),
colorhash::text_color_on(accent),
),
EventStatus::Tentative => (
accent.gamma_multiply(0.50),
accent,
colorhash::text_color_on(accent),
),
EventStatus::Cancelled => (
egui::Color32::from_gray(120),
egui::Color32::from_gray(80),
egui::Color32::from_gray(40),
),
};
painter.rect_filled(rect, egui::CornerRadius::ZERO, fill);
if matches!(event.status, EventStatus::Tentative) {
paint_diagonal_hatch(painter, rect, accent.gamma_multiply(0.6));
}
if matches!(event.status, EventStatus::Cancelled) {
painter.line_segment(
[
egui::pos2(rect.left() + 1.0, rect.bottom() - 1.0),
egui::pos2(rect.right() - 1.0, rect.top() + 1.0),
],
egui::Stroke::new(1.5, egui::Color32::from_gray(40)),
);
}
painter.rect_stroke(
rect,
0.0,
egui::Stroke::new(1.0, stroke_color),
egui::StrokeKind::Inside,
);
let pad = 3.0;
let text_rect = rect.shrink(pad);
if text_rect.height() < 9.0 || text_rect.width() < 14.0 {
return;
}
let summary_font = egui::FontId::proportional(11.0);
let mono_font = egui::FontId::monospace(9.0);
if is_all_day_block || rect.height() < 28.0 {
let galley = ellipsized_galley(
painter,
&event.summary,
&summary_font,
text_color,
text_rect.width(),
);
painter.galley(text_rect.min, galley, text_color);
} else {
painter.text(
text_rect.min,
egui::Align2::LEFT_TOP,
format!("{:02}:{:02}", event.start.hour(), event.start.minute()),
mono_font,
text_color,
);
let galley = ellipsized_galley(
painter,
&event.summary,
&summary_font,
text_color,
text_rect.width(),
);
painter.galley(
egui::pos2(text_rect.min.x, text_rect.min.y + 11.0),
galley,
text_color,
);
}
}
fn paint_diagonal_hatch(painter: &egui::Painter, rect: egui::Rect, stripe_color: egui::Color32) {
let stripe = egui::Stroke::new(1.0, stripe_color);
let step = 5.0;
let painter = painter.with_clip_rect(rect);
let start_x = rect.left() - rect.height();
let mut x = start_x;
while x < rect.right() {
painter.line_segment(
[
egui::pos2(x, rect.bottom()),
egui::pos2(x + rect.height(), rect.top()),
],
stripe,
);
x += step;
}
}
fn ellipsized_galley(
painter: &egui::Painter,
text: &str,
font: &egui::FontId,
color: egui::Color32,
max_width: f32,
) -> Arc<egui::Galley> {
let galley = painter.layout_no_wrap(text.to_string(), font.clone(), color);
if galley.size().x <= max_width {
return galley;
}
let char_count = text.chars().count().max(1);
let approx_char_width = galley.size().x / char_count as f32;
let max_chars = ((max_width / approx_char_width) - 1.0).max(1.0) as usize;
let truncated: String = text.chars().take(max_chars).collect::<String>() + "…";
painter.layout_no_wrap(truncated, font.clone(), color)
}
fn render_event_card(ui: &mut egui::Ui, event: &EventRow, live: &PlannerLive) {
let bubble_fill = ui.visuals().window_fill;
let accent = match event.organizer {
Some(org) => person_color(org),
None => color_default_event(),
};
let text_on_accent = colorhash::text_color_on(accent);
egui::Frame::NONE
.fill(bubble_fill)
.stroke(egui::Stroke::new(1.0, color_frame(ui)))
.shadow(egui::epaint::Shadow {
offset: [2, 2],
blur: 0,
spread: 0,
color: egui::Color32::from_black_alpha(48),
})
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::ZERO)
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
egui::Frame::NONE
.fill(accent)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin {
left: 10,
right: 10,
top: 6,
bottom: 6,
})
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format_time_range(event.start, event.end))
.monospace()
.strong()
.color(text_on_accent),
);
if let Some(badge) = event.status.badge() {
ui.label(
egui::RichText::new(format!("· {badge}"))
.monospace()
.small()
.strong()
.color(text_on_accent),
);
}
if event.rrule.is_some() {
ui.label(
egui::RichText::new("· \u{21BB}") .monospace()
.small()
.color(text_on_accent),
);
}
});
let mut summary = egui::RichText::new(&event.summary)
.size(15.0)
.color(text_on_accent);
if matches!(event.status, EventStatus::Cancelled) {
summary = summary.strikethrough();
}
ui.label(summary);
});
let has_body = event.location.is_some()
|| event.rrule.is_some()
|| event.organizer.is_some()
|| !event.attendees.is_empty()
|| !event.notes.is_empty();
if has_body {
let body_text = colorhash::text_color_on(bubble_fill);
let body_muted = blend(body_text, bubble_fill, 0.22);
egui::Frame::NONE
.fill(bubble_fill)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin {
left: 10,
right: 10,
top: 6,
bottom: 8,
})
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if let Some(loc) = event.location.as_ref() {
ui.label(
egui::RichText::new(format!("\u{1F4CD} {loc}")) .monospace()
.small()
.color(body_muted),
);
}
if let Some(rrule) = event.rrule.as_ref() {
ui.label(
egui::RichText::new(format!("\u{21BB} {rrule}")) .monospace()
.small()
.color(body_muted),
);
}
if event.organizer.is_some() || !event.attendees.is_empty() {
ui.add_space(2.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
if let Some(org) = event.organizer {
render_attendee_chip(
ui,
&live.display(org),
person_color(org),
true,
);
}
for &att in &event.attendees {
if Some(att) == event.organizer {
continue;
}
render_attendee_chip(
ui,
&live.display(att),
person_color(att),
false,
);
}
});
}
for note_text in &event.notes {
ui.add_space(2.0);
ui.label(
egui::RichText::new(format!("» {note_text}"))
.size(13.0)
.color(body_text),
);
}
});
}
});
}
fn render_day_section_header(ui: &mut egui::Ui, label: &str, is_today: bool) {
let muted = color_muted(ui);
let strip_color = if is_today { color_today() } else { muted };
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let (rect, _) = ui.allocate_exact_size(egui::vec2(3.0, 14.0), egui::Sense::hover());
ui.painter()
.rect_filled(rect, egui::CornerRadius::ZERO, strip_color);
let text_color = if is_today {
ui.visuals().text_color()
} else {
muted
};
ui.label(
egui::RichText::new(label)
.monospace()
.strong()
.size(12.0)
.color(text_color),
);
});
ui.add_space(2.0);
}
fn render_attendee_chip(ui: &mut egui::Ui, label: &str, fill: egui::Color32, is_organizer: bool) {
let text = colorhash::text_color_on(fill);
let display = if is_organizer {
format!("\u{25CE} {label}") } else {
label.to_string()
};
egui::Frame::NONE
.fill(fill)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::symmetric(5, 1))
.show(ui, |ui| {
ui.label(
egui::RichText::new(display)
.monospace()
.small()
.strong()
.color(text),
);
});
}
fn render_diagnostic(ui: &mut egui::Ui, message: &str) {
let accent = egui::Color32::from_rgb(0xe6, 0x32, 0x46);
egui::Frame::NONE
.fill(accent.gamma_multiply(0.12))
.stroke(egui::Stroke::new(1.0, accent))
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
egui::RichText::new(format!("⚠ {message}"))
.monospace()
.small()
.color(ui.visuals().text_color()),
);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_relation_profile_drives_planner_display_name() {
let person = Person::from_profile(ProfileInput {
label: "Bulti".to_owned(),
emails: vec![
"z@example.invalid".to_owned(),
"a@example.invalid".to_owned(),
],
..ProfileInput::default()
});
let id = Id::new([1; 16]).unwrap();
assert_eq!(person.display(id), "Bulti");
assert_eq!(person.email.as_deref(), Some("a@example.invalid"));
}
#[test]
fn canonical_cancellation_overrides_baseline_display_status() {
assert_eq!(
EventStatus::parse(planner_model::STATUS_CONFIRMED),
EventStatus::Confirmed
);
assert_eq!(
EventStatus::parse(planner_model::STATUS_TENTATIVE),
EventStatus::Tentative
);
assert_eq!(
EventStatus::parse(planner_model::STATUS_CANCELLED),
EventStatus::Cancelled
);
}
}