Skip to main content

eui_core/host/
calendar.rs

1//! eApps U3 (VAPP-232, spec §17 U3): the CALENDAR half of the records
2//! query, the Rust twin of `packages/exponential-ui/src/host/calendar.ts`
3//! (fixtures/records-query.json locks both): the month view's weeks with
4//! their lanes, the week view and the timeline. Pure day maths over day
5//! numbers (`crate::dates`). `host::query` filters and sorts the rows, then
6//! hands each placed row here as a [`CalendarSpan`].
7
8use serde_json::{json, Map, Value};
9
10use super::utf16_cmp;
11use crate::dates::{day_from_number, day_number, format_day, parse_day, CalendarDay};
12
13pub const CALENDAR_LANES_DEFAULT: u32 = 3;
14pub const CALENDAR_LANES_MAX: u32 = 8;
15pub const TIMELINE_COLUMNS_DEFAULT: u32 = 12;
16pub const TIMELINE_COLUMNS_MAX: u32 = 52;
17pub const TIMELINE_SCALES: [&str; 3] = ["week", "month", "quarter"];
18pub const CALENDAR_TONES: [&str; 8] = ["chart1", "chart2", "chart3", "chart4", "chart5", "chart6", "chart7", "chart8"];
19/// Monday first (the calendar's week; `RECORDS_WEEKDAYS` stays Sunday first).
20pub const CALENDAR_WEEKDAYS_LONG: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
21const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
22const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
23
24/// One placed row: its first and last day (inclusive), its tone, its value
25/// row, its place in the query's order and (timeline) its row key.
26#[derive(Debug, Clone)]
27pub struct CalendarSpan {
28    pub id: String,
29    pub start: i64,
30    pub end: i64,
31    pub tone: String,
32    pub value: Value,
33    pub order: usize,
34    pub row_key: Option<String>,
35}
36
37/// A real day's number; `None` = not a day.
38pub fn day_of(text: &str) -> Option<i64> {
39    parse_day(text).map(|d| day_number(d.y, d.m, d.d))
40}
41
42/// `start`..`end` as a row's span: `end` missing, not a day or before the
43/// start = the start day only; `None` = the start is no day.
44pub fn span_of(start_text: &str, end_text: Option<&str>) -> Option<(i64, i64)> {
45    let start = day_of(start_text)?;
46    let end = end_text.and_then(day_of);
47    Some((start, end.filter(|e| *e >= start).unwrap_or(start)))
48}
49
50pub fn day_text(n: i64) -> String {
51    format_day(day_from_number(n))
52}
53
54/// 0 = Monday (1970-01-01, day 0, was a Thursday).
55fn weekday_index(n: i64) -> usize {
56    (n + 3).rem_euclid(7) as usize
57}
58
59fn monday_of(n: i64) -> i64 {
60    n - weekday_index(n) as i64
61}
62
63fn is_tone_digit(v: &str) -> bool {
64    v.len() == 1 && matches!(v.as_bytes()[0], b'1'..=b'8')
65}
66
67/// The tones of `values` (each placed row's tone field as text): an integer
68/// 1–8 is that chart tone; any other non-empty text the tone of its rank
69/// among the distinct such texts (code units); empty = chart1.
70pub fn tones_of(values: &[String]) -> Vec<String> {
71    let mut named: Vec<&str> = values.iter().map(String::as_str).filter(|v| !v.is_empty() && !is_tone_digit(v)).collect();
72    named.sort_by(|a, b| utf16_cmp(a, b));
73    named.dedup();
74    values
75        .iter()
76        .map(|v| {
77            if is_tone_digit(v) {
78                format!("chart{v}")
79            } else if v.is_empty() {
80                "chart1".to_string()
81            } else {
82                let rank = named.iter().position(|n| *n == v.as_str()).unwrap_or(0);
83                CALENDAR_TONES[rank % 8].to_string()
84            }
85        })
86        .collect()
87}
88
89/// Spans clipped to `[from, to]` in lane order: by first day, then the
90/// longer first, then the query's order.
91fn lane_order(spans: &[CalendarSpan], from: i64, to: i64) -> Vec<&CalendarSpan> {
92    let mut out: Vec<&CalendarSpan> = spans.iter().filter(|s| s.end >= from && s.start <= to).collect();
93    out.sort_by(|a, b| {
94        let (af, bf) = (a.start.max(from), b.start.max(from));
95        let (al, bl) = (a.end.min(to) - af, b.end.min(to) - bf);
96        af.cmp(&bf).then(bl.cmp(&al)).then(a.order.cmp(&b.order))
97    });
98    out
99}
100
101/// The lowest lane whose cells `first..=last` are all free (`max` lanes;
102/// `None` = none), marking them taken.
103fn take_lane(lanes: &mut Vec<Vec<bool>>, first: usize, last: usize, max: usize) -> Option<usize> {
104    for lane in 0..max {
105        if lanes.len() <= lane {
106            lanes.push(Vec::new());
107        }
108        let row = &mut lanes[lane];
109        if row.len() <= last {
110            row.resize(last + 1, false);
111        }
112        if row[first..=last].iter().any(|c| *c) {
113            continue;
114        }
115        row[first..=last].iter_mut().for_each(|c| *c = true);
116        return Some(lane);
117    }
118    None
119}
120
121pub struct WeekOptions {
122    pub lanes: u32,
123    pub today: Option<i64>,
124    pub selected: Option<i64>,
125    pub month: Option<i64>,
126}
127
128/// One Monday-first week (`calendarWeek` in calendar.ts): {key, days, segments}.
129pub fn calendar_week(monday: i64, spans: &[CalendarSpan], o: &WeekOptions) -> Map<String, Value> {
130    let sunday = monday + 6;
131    let placed = lane_order(spans, monday, sunday);
132    let mut lanes: Vec<Vec<bool>> = Vec::new();
133    let mut hidden = [0u64; 7];
134    let mut segments: Vec<Value> = Vec::new();
135    for s in &placed {
136        let first = (s.start.max(monday) - monday) as usize;
137        let last = (s.end.min(sunday) - monday) as usize;
138        match take_lane(&mut lanes, first, last, o.lanes as usize) {
139            None => hidden[first..=last].iter_mut().for_each(|h| *h += 1),
140            Some(lane) => segments.push(json!({
141                "id": s.id, "col": first + 1, "span": last - first + 1, "lane": lane,
142                "contL": s.start < monday, "contR": s.end > sunday, "tone": s.tone, "more": 0, "row": s.value,
143            })),
144        }
145    }
146    for (c, n) in hidden.iter().enumerate() {
147        if *n > 0 {
148            segments.push(json!({
149                "id": format!("more:{}", day_text(monday + c as i64)), "col": c + 1, "span": 1, "lane": o.lanes,
150                "contL": false, "contR": false, "tone": null, "more": n, "row": null,
151            }));
152        }
153    }
154    let days: Vec<Value> = (0..7)
155        .map(|c| {
156            let n = monday + c as i64;
157            let d = day_from_number(n);
158            let covering: Vec<&&CalendarSpan> = placed.iter().filter(|s| s.start <= n && s.end >= n).collect();
159            let dots: Vec<Value> = covering.iter().take(3).map(|s| json!({"tone": s.tone})).collect();
160            json!({
161                "date": format_day(d), "day": d.d, "weekday": WEEKDAYS[c], "initial": &WEEKDAYS[c][..1],
162                "inMonth": o.month.map_or(true, |m| d.m == m), "weekend": c >= 5, "today": Some(n) == o.today,
163                "selected": Some(n) == o.selected, "total": covering.len(), "dots": dots,
164            })
165        })
166        .collect();
167    let mut out = Map::new();
168    out.insert("key".into(), json!(day_text(monday)));
169    out.insert("days".into(), Value::Array(days));
170    out.insert("segments".into(), Value::Array(segments));
171    out
172}
173
174/// `2 – 8 November 2026`, `30 November – 6 December 2026`, `29 December 2025 – 4 January 2026`.
175pub fn range_label(from: i64, to: i64) -> String {
176    let a = day_from_number(from);
177    let b = day_from_number(to);
178    let tail = format!("{} {} {}", b.d, MONTHS[(b.m - 1) as usize], b.y);
179    if a.y != b.y {
180        format!("{} {} {} – {tail}", a.d, MONTHS[(a.m - 1) as usize], a.y)
181    } else if a.m != b.m {
182        format!("{} {} – {tail}", a.d, MONTHS[(a.m - 1) as usize])
183    } else {
184        format!("{} – {tail}", a.d)
185    }
186}
187
188/// `Tuesday, 3 November`.
189pub fn day_label(n: i64) -> String {
190    let d = day_from_number(n);
191    format!("{}, {} {}", CALENDAR_WEEKDAYS_LONG[weekday_index(n)], d.d, MONTHS[(d.m - 1) as usize])
192}
193
194/// The month view's calendar half: {weeks, day, agenda} (`calendarMonth`).
195pub fn calendar_month(y: i64, m: i64, last_day: i64, spans: &[CalendarSpan], lanes: u32, today: i64, day: Option<&str>) -> Map<String, Value> {
196    let first = day_number(y, m, 1);
197    let last = day_number(y, m, last_day);
198    let asked = day.and_then(day_of);
199    let selected = match asked {
200        Some(a) if a >= first && a <= last => a,
201        _ if today >= first && today <= last => today,
202        _ => first,
203    };
204    let mut weeks = Vec::new();
205    let mut monday = monday_of(first);
206    while monday <= last {
207        weeks.push(Value::Object(calendar_week(monday, spans, &WeekOptions { lanes, today: Some(today), selected: Some(selected), month: Some(m) })));
208        monday += 7;
209    }
210    let items: Vec<Value> = lane_order(spans, selected, selected)
211        .iter()
212        .map(|s| json!({"id": s.id, "tone": s.tone, "start": day_text(s.start), "end": day_text(s.end), "days": s.end - s.start + 1, "row": s.value}))
213        .collect();
214    let mut out = Map::new();
215    out.insert("weeks".into(), Value::Array(weeks));
216    out.insert("day".into(), json!(day_text(selected)));
217    out.insert("agenda".into(), json!({"date": day_text(selected), "label": day_label(selected), "count": items.len(), "items": items}));
218    out
219}
220
221/// The week view: the Monday-first week holding `key`.
222pub fn calendar_week_view(key: i64, spans: &[CalendarSpan], lanes: u32, today: i64) -> Value {
223    let monday = monday_of(key);
224    let week = calendar_week(monday, spans, &WeekOptions { lanes, today: Some(today), selected: None, month: None });
225    json!({
226        "key": day_text(monday), "label": range_label(monday, monday + 6), "prev": day_text(monday - 7), "next": day_text(monday + 7),
227        "today": day_text(today), "days": week["days"], "segments": week["segments"],
228    })
229}
230
231/// The first day of the scale period holding day `n`.
232pub fn period_start(n: i64, scale: &str) -> i64 {
233    if scale == "week" {
234        return monday_of(n);
235    }
236    let d = day_from_number(n);
237    day_number(d.y, if scale == "month" { d.m } else { (d.m - 1) / 3 * 3 + 1 }, 1)
238}
239
240/// `start` (a period's first day) moved by `k` periods.
241pub fn shift_period(start: i64, scale: &str, k: i64) -> i64 {
242    if scale == "week" {
243        return start + 7 * k;
244    }
245    let d = day_from_number(start);
246    let index = d.y * 12 + (d.m - 1) + k * if scale == "month" { 1 } else { 3 };
247    day_number(index.div_euclid(12), index.rem_euclid(12) + 1, 1)
248}
249
250fn column_label(d: CalendarDay, scale: &str) -> String {
251    let short = &MONTHS[(d.m - 1) as usize][..3];
252    match scale {
253        "week" => format!("{} {short}", d.d),
254        "month" => short.to_string(),
255        _ => format!("Q{}", (d.m - 1) / 3 + 1),
256    }
257}
258
259/// The timeline (`calendarTimeline` in calendar.ts).
260pub fn calendar_timeline(start: i64, spans: &[CalendarSpan], scale: &str, columns: u32, today: i64) -> Value {
261    let cols = columns as usize;
262    let p0 = period_start(start, scale);
263    let starts: Vec<i64> = (0..=cols as i64).map(|i| shift_period(p0, scale, i)).collect();
264    let axis_end = starts[cols] - 1;
265    let col_of = |n: i64| -> usize {
266        let mut c = 0;
267        while c + 1 < cols && starts[c + 1] <= n {
268            c += 1;
269        }
270        c
271    };
272    let axis_year = day_from_number(p0).y;
273    let columns_json: Vec<Value> = (0..cols)
274        .map(|i| json!({"key": day_text(starts[i]), "label": column_label(day_from_number(starts[i]), scale), "end": day_text(starts[i + 1] - 1), "today": today >= starts[i] && today < starts[i + 1]}))
275        .collect();
276    let mut bands: Vec<(String, usize, usize)> = Vec::new();
277    for (i, s) in starts.iter().take(cols).enumerate() {
278        let d = day_from_number(*s);
279        let label = if scale == "week" {
280            if d.y == axis_year { MONTHS[(d.m - 1) as usize].to_string() } else { format!("{} {}", MONTHS[(d.m - 1) as usize], d.y) }
281        } else {
282            d.y.to_string()
283        };
284        match bands.last_mut() {
285            Some(last) if last.0 == label => last.2 += 1,
286            _ => bands.push((label, i + 1, 1)),
287        }
288    }
289    let mut ordered: Vec<&CalendarSpan> = spans.iter().collect();
290    ordered.sort_by_key(|s| s.order);
291    let mut groups: Vec<(String, Vec<&CalendarSpan>)> = Vec::new();
292    for s in ordered {
293        if s.end < p0 || s.start > axis_end {
294            continue;
295        }
296        let key = s.row_key.clone().unwrap_or_else(|| s.id.clone());
297        match groups.iter_mut().find(|g| g.0 == key) {
298            Some(g) => g.1.push(s),
299            None => groups.push((key, vec![s])),
300        }
301    }
302    let rows: Vec<Value> = groups
303        .into_iter()
304        .enumerate()
305        .map(|(index, (key, list))| {
306            let mut lanes: Vec<Vec<bool>> = Vec::new();
307            let mut cells: Vec<(&CalendarSpan, usize, usize)> = list.iter().map(|s| (*s, col_of(s.start.max(p0)), col_of(s.end.min(axis_end)))).collect();
308            cells.sort_by(|a, b| a.1.cmp(&b.1).then((b.2 - b.1).cmp(&(a.2 - a.1))).then(a.0.order.cmp(&b.0.order)));
309            let items: Vec<Value> = cells
310                .iter()
311                .map(|(s, first, last)| {
312                    let lane = take_lane(&mut lanes, *first, *last, usize::MAX).unwrap_or(0);
313                    json!({
314                        "id": s.id, "rowIndex": index, "col": first + 1, "span": last - first + 1, "lane": lane,
315                        "contL": s.start < p0, "contR": s.end > axis_end, "tone": s.tone, "row": s.value,
316                    })
317                })
318                .collect();
319            json!({"key": key, "index": index, "lanes": lanes.len().max(1), "count": items.len(), "items": items})
320        })
321        .collect();
322    let now = if today >= p0 && today <= axis_end {
323        let c = col_of(today);
324        let frac = (today - starts[c]) as f64 / (starts[c + 1] - starts[c]) as f64;
325        let percent = (((c as f64 + frac) / f64::from(columns)) * 10000.0).round() / 100.0;
326        json!({"col": c + 1, "percent": crate::json::number(percent)})
327    } else {
328        Value::Null
329    };
330    json!({
331        "scale": scale, "key": day_text(p0), "label": range_label(p0, axis_end),
332        "prev": day_text(shift_period(p0, scale, -i64::from(columns))), "next": day_text(starts[cols]), "today": day_text(today),
333        "columns": columns_json,
334        "bands": bands.into_iter().map(|(label, col, span)| json!({"label": label, "col": col, "span": span})).collect::<Vec<_>>(),
335        "rows": rows, "now": now,
336    })
337}