trackWork 0.15.0

A terminal-based time tracking application for managing work sessions
use chrono::{Datelike, Duration, Local, NaiveDateTime, NaiveTime, Timelike};
use ratatui::{
    layout::{Alignment, Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
    Frame,
};

use super::state::{build_week, DaySummary};
use crate::app::{App, DayEditDraft, InputMode};
use crate::dashboard::utils::{calculate_minutes_per_row, dim_toward_bg, string_to_color};

/// Rows reserved at the bottom of each day column for the stats footer.
const STAT_LINES: usize = 7;

fn fmt_hm(minutes: i64) -> String {
    let h = minutes / 60;
    let m = minutes % 60;
    if h > 0 {
        format!("{}h {:02}m", h, m)
    } else {
        format!("{}m", m)
    }
}

const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

pub fn draw_week_summary(f: &mut Frame, app: &App) {
    let (anchor, selected_day, selected_field, editing) = match &app.input_mode {
        InputMode::WeekSummary {
            anchor,
            selected_day,
            selected_field,
            editing,
        } => (*anchor, *selected_day, *selected_field, editing.as_ref()),
        _ => (app.current_date, 0, 0, None),
    };
    let week = build_week(app, anchor);
    let today = Local::now().date_naive();
    let area = f.area();

    let title = format!(
        " Weekly Summary — W{} ({}{})   [←/→ day · Shift+←/→ week · Esc close] ",
        anchor.iso_week().week(),
        week[0].date.format(&app.config.date_format),
        week[6].date.format(&app.config.date_format),
    );
    let outer = Block::default()
        .borders(Borders::ALL)
        .title(title)
        .border_style(Style::default().fg(Color::Cyan));
    let inner = outer.inner(area);
    f.render_widget(outer, area);

    // Grid on top, per-day edit panel at the bottom.
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(3), Constraint::Length(8)])
        .split(inner);
    draw_grid(
        f,
        rows[0],
        &week,
        selected_day,
        today,
        &app.config.colors,
        &app.term_palette,
    );
    draw_edit_panel(f, rows[1], &week[selected_day], selected_field, editing);
}

fn draw_grid(
    f: &mut Frame,
    area: ratatui::layout::Rect,
    week: &[DaySummary],
    selected_day: usize,
    today: chrono::NaiveDate,
    palette: &[String],
    term_palette: &std::collections::HashMap<u8, (u8, u8, u8)>,
) {
    // Shared vertical window across the whole week so columns align row-for-row.
    // Compare time-of-day only — using full datetimes would stretch the window across
    // multiple calendar days and squash each day's bars into a sliver.
    let start_times: Vec<NaiveTime> = week
        .iter()
        .filter_map(|d| d.span.map(|s| s.0.time()))
        .collect();
    let end_times: Vec<NaiveTime> = week
        .iter()
        .filter_map(|d| d.span.map(|s| s.1.time()))
        .collect();
    if start_times.is_empty() {
        let empty = Paragraph::new("No entries this week.")
            .alignment(Alignment::Center)
            .style(Style::default().fg(Color::DarkGray));
        f.render_widget(empty, area);
        return;
    }

    let raw_start_time = *start_times.iter().min().unwrap();
    let raw_end_time = *end_times.iter().max().unwrap();
    let window_start_time = NaiveTime::from_hms_opt(raw_start_time.hour(), 0, 0).unwrap();
    let window_end_time = if raw_end_time.minute() > 0 || raw_end_time.second() > 0 {
        // Ceil to next hour; 23:xx ceils to 24:00 which we represent as +1 day below.
        raw_end_time.hour() + 1
    } else {
        raw_end_time.hour()
    };
    let start_min = window_start_time.hour() as i64 * 60;
    let end_min = window_end_time as i64 * 60;
    let window_min = (end_min - start_min).max(60);

    // Layout: a thin time-axis gutter + 7 equal day columns.
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(6), Constraint::Min(0)])
        .split(area);
    let gutter_area = cols[0];
    let days_area = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Ratio(1, 7); 7])
        .split(cols[1]);

    let col_inner_h = days_area[0].height.saturating_sub(2) as usize;
    let bar_rows = col_inner_h.saturating_sub(STAT_LINES).max(1);
    let mpr = calculate_minutes_per_row(window_min, bar_rows);
    let bar_height = (((window_min + mpr - 1) / mpr) as usize).min(bar_rows);
    let bar_w = days_area[0].width.saturating_sub(2).max(1) as usize;

    let colors: Vec<Color> = palette.iter().map(|s| string_to_color(s)).collect();

    // Gutter: time labels on full hours (top border only, so rows align with day columns).
    // Match timeline's themed-dim faintness so hour labels read as scaffolding, not data.
    let mut gutter_lines = vec![];
    for r in 0..bar_height {
        let minute_of_day = start_min + r as i64 * mpr;
        let label = if minute_of_day % 60 == 0 {
            format!("{:02}:00", (minute_of_day / 60) % 24)
        } else {
            String::new()
        };
        gutter_lines.push(Line::from(Span::styled(
            label,
            Style::default().fg(dim_toward_bg(Color::Gray, term_palette, 0.45)),
        )));
    }
    f.render_widget(
        Paragraph::new(gutter_lines).block(Block::default().borders(Borders::TOP)),
        gutter_area,
    );

    for (i, day) in week.iter().enumerate() {
        let is_selected = i == selected_day;
        let is_today = day.date == today;
        let title = format!("{} {}", WEEKDAYS[i], day.date.format("%m-%d"));
        let title_style = if is_selected {
            Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
        } else if is_today {
            Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Gray)
        };
        let border_style = if is_selected {
            Style::default().fg(Color::Cyan)
        } else {
            Style::default().fg(Color::DarkGray)
        };

        // Anchor the shared time-of-day window to this day's date so each day's
        // entries line up against rows that represent the same wall-clock hours.
        let day_window_start = day
            .date
            .and_hms_opt(window_start_time.hour(), 0, 0)
            .unwrap();
        let lines = render_day_lines(day, day_window_start, mpr, bar_height, bar_w, &colors, term_palette);
        f.render_widget(
            Paragraph::new(lines).block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(border_style)
                    .title(Span::styled(title, title_style)),
            ),
            days_area[i],
        );
    }
}

/// Editable section for the highlighted day: Workday Start / End / Lunch.
fn draw_edit_panel(
    f: &mut Frame,
    area: ratatui::layout::Rect,
    day: &DaySummary,
    selected_field: usize,
    editing: Option<&DayEditDraft>,
) {
    let idx = day.date.weekday().num_days_from_monday() as usize;
    let title = format!(" {} {} ", WEEKDAYS[idx], day.date.format("%m-%d"));
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(title);
    let panel = block.inner(area);
    f.render_widget(block, area);

    // Read-only values from the day rollup.
    let span_start = day
        .span
        .map(|(s, _, _)| s.format("%H:%M").to_string())
        .unwrap_or_else(|| "--".to_string());
    let span_end = day
        .span
        .map(|(_, e, _)| e.format("%H:%M").to_string())
        .unwrap_or_else(|| "--".to_string());
    let lunch = day.entries.iter().find(|e| e.off_work);

    let labels = ["Workday Start", "Workday End", "Lunch"];
    let mut lines = vec![Line::from("")];
    for (field, label) in labels.iter().enumerate() {
        let is_editing = editing.map(|d| d.kind) == Some(field);
        let is_selected = editing.is_none() && field == selected_field;

        // Value spans for this field.
        let value: Vec<Span> = if let (true, Some(d)) = (is_editing, editing) {
            edit_value_spans(d)
        } else {
            match field {
                0 => vec![Span::styled(span_start.clone(), Style::default().fg(Color::Cyan))],
                1 => vec![Span::styled(span_end.clone(), Style::default().fg(Color::Yellow))],
                _ => match lunch {
                    Some(e) => vec![Span::styled(
                        format!(
                            "{}{}",
                            e.start_time.format("%H:%M"),
                            e.end_time.map(|t| t.format("%H:%M").to_string()).unwrap_or_default()
                        ),
                        Style::default().fg(Color::Magenta),
                    )],
                    None => vec![Span::styled(
                        "⚠ none",
                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
                    )],
                },
            }
        };

        let label_style = if is_editing {
            Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD)
        } else if is_selected {
            Style::default().fg(Color::White).bg(Color::DarkGray).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Gray)
        };

        let mut spans = vec![
            Span::raw("  "),
            Span::styled(format!("{:<15}", label), label_style),
            Span::raw(" "),
        ];
        spans.extend(value);
        lines.push(Line::from(spans));
    }

    let hint = if editing.is_some() {
        "  type · ↑/↓ adjust · Tab start/end · Enter save · Esc cancel"
    } else {
        "  ↑/↓ field · e edit · Enter open day · ←/→ day · Shift+←/→ week · Esc close"
    };
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))));

    f.render_widget(Paragraph::new(lines), panel);
}

/// Render the buffer(s) of an in-progress edit, bracketing the active sub-field.
fn edit_value_spans<'a>(d: &DayEditDraft) -> Vec<Span<'a>> {
    let active = Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD);
    let idle = Style::default().fg(Color::Gray);
    if d.kind == 2 {
        let (s_style, e_style) = if d.sub_field == 0 {
            (active, idle)
        } else {
            (idle, active)
        };
        vec![
            Span::styled(format!("[{}]", d.start), s_style),
            Span::raw(""),
            Span::styled(format!("[{}]", d.end), e_style),
        ]
    } else {
        let buf = if d.kind == 1 { &d.end } else { &d.start };
        vec![Span::styled(format!("[{}]", buf), active)]
    }
}

fn render_day_lines<'a>(
    day: &DaySummary,
    window_start: NaiveDateTime,
    mpr: i64,
    bar_height: usize,
    bar_w: usize,
    colors: &[Color],
    term_palette: &std::collections::HashMap<u8, (u8, u8, u8)>,
) -> Vec<Line<'a>> {
    let now = Local::now().naive_local();
    let mut lines = Vec::with_capacity(bar_height + STAT_LINES);

    for r in 0..bar_height {
        let row_sec = r as i64 * mpr * 60;
        let row_time = window_start + Duration::minutes(r as i64 * mpr);

        // First entry covering this row's time slice.
        let mut covering: Option<Color> = None;
        for e in &day.entries {
            let start_off = e.start_time.signed_duration_since(window_start).num_seconds();
            let end_off = e
                .end_time
                .unwrap_or(now)
                .signed_duration_since(window_start)
                .num_seconds();
            if row_sec >= start_off && row_sec < end_off {
                covering = Some(if e.off_work {
                    Color::Magenta
                } else {
                    colors[e.color as usize % colors.len().max(1)]
                });
                break;
            }
        }

        let line = if let Some(color) = covering {
            Line::from(Span::styled("".repeat(bar_w), Style::default().fg(color)))
        } else {
            // Match the day timeline's themed-dim guide hierarchy so empty rows
            // read as faint scaffolding regardless of terminal theme.
            let (ch, dim) = if row_time.minute() == 0 {
                ("", 0.55)
            } else if row_time.minute() == 30 {
                ("", 0.70)
            } else {
                ("", 0.82)
            };
            Line::from(Span::styled(
                ch.repeat(bar_w),
                Style::default().fg(dim_toward_bg(Color::Gray, term_palette, dim)),
            ))
        };
        lines.push(line);
    }

    // Stats footer.
    lines.push(Line::from(Span::styled(
        "".repeat(bar_w),
        Style::default().fg(Color::DarkGray),
    )));
    let stat = |label: &str, value: String, color: Color| {
        Line::from(vec![
            Span::styled(format!("{:<6}", label), Style::default().fg(Color::Gray)),
            Span::styled(value, Style::default().fg(color).add_modifier(Modifier::BOLD)),
        ])
    };

    if let Some((s, e, manual)) = day.span {
        let mark = if manual { "*" } else { "" };
        lines.push(stat("Start", format!("{}{}", s.format("%H:%M"), mark), Color::Cyan));
        lines.push(stat("End", e.format("%H:%M").to_string(), Color::Yellow));
    } else {
        lines.push(stat("Start", "--".into(), Color::DarkGray));
        lines.push(stat("End", "--".into(), Color::DarkGray));
    }
    lines.push(stat("Logged", fmt_hm(day.logged_min), Color::Green));
    lines.push(stat("Unlog", fmt_hm(day.unlogged_min), Color::Yellow));
    lines.push(stat("Work", fmt_hm(day.workday_min), Color::White));
    if day.missing_lunch {
        lines.push(Line::from(Span::styled(
            "⚠ no lunch",
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        )));
    }

    lines
}