trackWork 0.15.0

A terminal-based time tracking application for managing work sessions
use ratatui::style::Color;

/// Parse color name string to ratatui Color
pub fn string_to_color(color_str: &str) -> Color {
    match color_str {
        "Blue" => Color::Blue,
        "Cyan" => Color::Cyan,
        "Green" => Color::Green,
        "Yellow" => Color::Yellow,
        "Magenta" => Color::Magenta,
        "Red" => Color::Red,
        "LightBlue" => Color::LightBlue,
        "LightCyan" => Color::LightCyan,
        "LightGreen" => Color::LightGreen,
        "LightYellow" => Color::LightYellow,
        "LightMagenta" => Color::LightMagenta,
        "LightRed" => Color::LightRed,
        "DarkGray" => Color::DarkGray,
        "Gray" => Color::Gray,
        "White" => Color::White,
        "Black" => Color::Black,
        _ => Color::White,
    }
}

/// Calculate the best resolution (minutes per row) for a vertical timeline.
/// Picks the smallest allowed resolution (1,2,5,10,15,30,60) that fits the
/// view window into the available rows. Shared by the day timeline and the
/// weekly summary so both use the same grid logic.
pub fn calculate_minutes_per_row(total_minutes: i64, available_rows: usize) -> i64 {
    let allowed_resolutions = [1, 2, 5, 10, 15, 30, 60];

    if available_rows == 0 {
        return 10; // Fallback
    }

    let ideal = (total_minutes as f64 / available_rows as f64).ceil() as i64;

    for &resolution in &allowed_resolutions {
        if resolution >= ideal {
            return resolution;
        }
    }

    60
}

/// Slow eased "breathe" wave in `[0, 1]`: a smooth 0 → 1 → 0 cycle. Driven by
/// the wall clock, so every element that calls it pulses in sync (e.g. a
/// highlighted entry's timeline bar and its name breathe together).
pub fn breathe_t() -> f64 {
    const PERIOD_MS: f64 = 2600.0; // full breathe in/out
    let ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as f64;
    let p = (ms % PERIOD_MS) / PERIOD_MS; // 0..1 linear
    let tri = if p < 0.5 { p * 2.0 } else { 2.0 - p * 2.0 }; // 0..1..0
    tri * tri * (3.0 - 2.0 * tri) // smoothstep ease
}

/// At `t` (from [`breathe_t`]): brightness and saturation move *together* —
/// `t=0` is dimmer & muted, `t=1` is brighter & more vivid — so the pulse reads
/// as a glow. Amplitude is gentle so the hue stays clearly recognizable. HSL.
pub fn breathe_color(base: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
    let (h, s, l) = rgb_to_hsl(base);
    let l_dim = (l - 0.08).max(0.10);
    let l_bright = (l + 0.12).min(0.85);
    let s_dim = (s - 0.12).max(0.0);
    let s_bright = (s + 0.18).min(1.0);
    let cur_l = l_dim + (l_bright - l_dim) * t;
    let cur_s = s_dim + (s_bright - s_dim) * t;
    hsl_to_rgb(h, cur_s, cur_l)
}

/// The ANSI palette index for a named ratatui color, if it maps to one.
fn color_index(c: Color) -> Option<u8> {
    Some(match c {
        Color::Black => 0,
        Color::Red => 1,
        Color::Green => 2,
        Color::Yellow => 3,
        Color::Blue => 4,
        Color::Magenta => 5,
        Color::Cyan => 6,
        Color::Gray => 7,
        Color::DarkGray => 8,
        Color::LightRed => 9,
        Color::LightGreen => 10,
        Color::LightYellow => 11,
        Color::LightBlue => 12,
        Color::LightMagenta => 13,
        Color::LightCyan => 14,
        Color::White => 15,
        Color::Indexed(i) => i,
        _ => return None,
    })
}

/// Real RGB for a color, preferring the terminal's queried palette (so
/// animations match the user's actual theme) and falling back to the static
/// approximation when the terminal didn't answer the OSC query.
pub fn themed_rgb(c: Color, palette: &std::collections::HashMap<u8, (u8, u8, u8)>) -> (u8, u8, u8) {
    if let Color::Rgb(r, g, b) = c {
        return (r, g, b);
    }
    if let Some(rgb) = color_index(c).and_then(|i| palette.get(&i)) {
        return *rgb;
    }
    color_to_rgb(c)
}

/// Blend `c` toward the terminal background (palette index 0) by `t`
/// (0.0 = unchanged, 1.0 = full background). Resolves both ends through the
/// queried palette so "dimmer" tracks the user's theme — toward black on a dark
/// terminal, toward white on a light one — instead of a hardcoded gray.
pub fn dim_toward_bg(
    c: Color,
    palette: &std::collections::HashMap<u8, (u8, u8, u8)>,
    t: f64,
) -> Color {
    let (r, g, b) = themed_rgb(c, palette);
    let bg = palette.get(&0).copied().unwrap_or((0, 0, 0));
    let mix = |fg: u8, bg: u8| (fg as f64 * (1.0 - t) + bg as f64 * t).round() as u8;
    Color::Rgb(mix(r, bg.0), mix(g, bg.1), mix(b, bg.2))
}

/// Canonical RGB for ratatui's named/indexed colors (VS Code dark palette), so
/// the breathe can operate in RGB. Passes `Rgb` through unchanged. Fallback for
/// when the terminal's real palette is unavailable; see [`themed_rgb`].
pub fn color_to_rgb(c: Color) -> (u8, u8, u8) {
    match c {
        Color::Rgb(r, g, b) => (r, g, b),
        Color::Black => (0, 0, 0),
        Color::Red => (205, 49, 49),
        Color::Green => (13, 188, 121),
        Color::Yellow => (229, 229, 16),
        Color::Blue => (36, 114, 200),
        Color::Magenta => (188, 63, 188),
        Color::Cyan => (17, 168, 205),
        Color::Gray => (118, 118, 118),
        Color::DarkGray => (102, 102, 102),
        Color::LightRed => (241, 76, 76),
        Color::LightGreen => (35, 209, 139),
        Color::LightYellow => (245, 245, 67),
        Color::LightBlue => (59, 142, 234),
        Color::LightMagenta => (214, 112, 214),
        Color::LightCyan => (41, 184, 219),
        Color::White => (229, 229, 229),
        _ => (200, 200, 200),
    }
}

fn rgb_to_hsl((r, g, b): (u8, u8, u8)) -> (f64, f64, f64) {
    let (r, g, b) = (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
    let max = r.max(g).max(b);
    let min = r.min(g).min(b);
    let l = (max + min) / 2.0;
    let d = max - min;
    if d == 0.0 {
        return (0.0, 0.0, l);
    }
    let s = d / (1.0 - (2.0 * l - 1.0).abs());
    let h = if max == r {
        60.0 * (((g - b) / d).rem_euclid(6.0))
    } else if max == g {
        60.0 * ((b - r) / d + 2.0)
    } else {
        60.0 * ((r - g) / d + 4.0)
    };
    (h, s, l)
}

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 (r1, g1, b1) = match hp as i32 {
        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 f = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
    (f(r1), f(g1), f(b1))
}

/// Format duration in seconds to human-readable string
pub fn format_duration_seconds(seconds: i64) -> String {
    let hours = seconds / 3600;
    let minutes = (seconds % 3600) / 60;
    let secs = seconds % 60;

    if hours > 0 {
        format!("{}h {:02}m {:02}s", hours, minutes, secs)
    } else if minutes > 0 {
        format!("{}m {:02}s", minutes, secs)
    } else {
        format!("{}s", secs)
    }
}