generated from eblume/project-template
`s` flips the task list between two orders: - default: attention (red→orange→white→blue) → most-overdue (desc) → project name → created_at (FIFO) - project: project first, with dimmed ──── Name ──── separators riding atop each group's first task (the cursor only lands on real tasks) The view filter still runs before the sort. Pure comparator (`cmp_tasks`/ `sort_tasks`, today injected) with unit tests for both modes + a navigation test for the toggle. `skip` moved from `s` to `S` to free `s`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
4.4 KiB
Rust
121 lines
4.4 KiB
Rust
//! Small display helpers (compact human dates for task rows). The UI layer may
|
||
//! read the wall clock (unlike `heph-core`, which is clock-injected).
|
||
|
||
use chrono::{DateTime, Datelike, Local, NaiveDate};
|
||
use ratatui::style::Color;
|
||
|
||
/// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`,
|
||
/// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`.
|
||
pub fn fmt_date(ms: i64, today: NaiveDate) -> String {
|
||
let Some(dt) = DateTime::from_timestamp_millis(ms) else {
|
||
return "?".into();
|
||
};
|
||
let date = dt.with_timezone(&Local).date_naive();
|
||
match (date - today).num_days() {
|
||
0 => "today".into(),
|
||
1 => "tomorrow".into(),
|
||
-1 => "yesterday".into(),
|
||
_ if date.year() == today.year() => format!("{:02}-{:02}", date.month(), date.day()),
|
||
_ => date.format("%Y-%m-%d").to_string(),
|
||
}
|
||
}
|
||
|
||
/// Today in the local timezone (the reference for [`fmt_date`]).
|
||
pub fn today_local() -> NaiveDate {
|
||
Local::now().date_naive()
|
||
}
|
||
|
||
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
|
||
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
|
||
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
||
match do_date.and_then(DateTime::from_timestamp_millis) {
|
||
Some(dt) => (today - dt.with_timezone(&Local).date_naive())
|
||
.num_days()
|
||
.max(0),
|
||
None => 0,
|
||
}
|
||
}
|
||
|
||
/// A stable display color for a project, derived from its node id (§8.1) so the
|
||
/// task list's bullets read as project identity. Hashing the id (rather than a
|
||
/// position-based palette) keeps each project's color **stable as others are
|
||
/// added or removed**, trading perfect spread for occasional near-collisions —
|
||
/// acceptable per the design. `None` (no project) is a neutral gray. A future
|
||
/// per-project override stored on the model would take precedence over this.
|
||
pub fn project_color(project_id: Option<&str>) -> Color {
|
||
let Some(id) = project_id else {
|
||
return Color::DarkGray;
|
||
};
|
||
// FNV-1a over the id → a hue in [0,360); fixed saturation/lightness tuned to
|
||
// stay legible on a dark terminal background.
|
||
let mut h: u32 = 0x811c_9dc5;
|
||
for b in id.bytes() {
|
||
h ^= b as u32;
|
||
h = h.wrapping_mul(0x0100_0193);
|
||
}
|
||
let hue = (h as f64 / u32::MAX as f64) * 360.0;
|
||
let (r, g, b) = hsl_to_rgb(hue, 0.55, 0.65);
|
||
Color::Rgb(r, g, b)
|
||
}
|
||
|
||
/// HSL (hue 0–360, saturation/lightness 0–1) → 8-bit RGB.
|
||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||
let hp = h / 60.0;
|
||
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
|
||
let (r, g, b) = match hp as u32 {
|
||
0 => (c, x, 0.0),
|
||
1 => (x, c, 0.0),
|
||
2 => (0.0, c, x),
|
||
3 => (0.0, x, c),
|
||
4 => (x, 0.0, c),
|
||
_ => (c, 0.0, x),
|
||
};
|
||
let m = l - c / 2.0;
|
||
let to = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||
(to(r), to(g), to(b))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
|
||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||
}
|
||
|
||
fn ms(date: NaiveDate) -> i64 {
|
||
date.and_hms_opt(12, 0, 0)
|
||
.unwrap()
|
||
.and_local_timezone(Local)
|
||
.unwrap()
|
||
.timestamp_millis()
|
||
}
|
||
|
||
#[test]
|
||
fn relative_and_absolute_dates() {
|
||
let today = day(2026, 6, 3);
|
||
assert_eq!(fmt_date(ms(today), today), "today");
|
||
assert_eq!(fmt_date(ms(day(2026, 6, 4)), today), "tomorrow");
|
||
assert_eq!(fmt_date(ms(day(2026, 6, 2)), today), "yesterday");
|
||
assert_eq!(fmt_date(ms(day(2026, 12, 25)), today), "12-25");
|
||
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
||
}
|
||
|
||
#[test]
|
||
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
||
assert_eq!(project_color(None), Color::DarkGray);
|
||
// Deterministic: the same id always maps to the same color.
|
||
assert_eq!(
|
||
project_color(Some("01J_chores")),
|
||
project_color(Some("01J_chores"))
|
||
);
|
||
// Distinct ids generally differ (these two do).
|
||
assert_ne!(
|
||
project_color(Some("01J_chores")),
|
||
project_color(Some("01J_garden"))
|
||
);
|
||
// A project id resolves to a concrete RGB (not a named palette slot).
|
||
assert!(matches!(project_color(Some("01J_work")), Color::Rgb(..)));
|
||
}
|
||
}
|