hephaestus/crates/heph-tui/src/fmt.rs
Erich Blume 4f291ce373 feat(tui): s sort toggle — default vs project-grouped (§8.1)
`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>
2026-06-03 11:01:51 -07:00

121 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 0360, saturation/lightness 01) → 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(..)));
}
}