trackWork 0.13.0

A terminal-based time tracking application for managing work sessions
use chrono::{Datelike, Duration, Local, NaiveDateTime, 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, 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);
    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],
) {
    // Shared vertical window across the whole week so columns align row-for-row.
    let starts: Vec<NaiveDateTime> = week.iter().filter_map(|d| d.span.map(|s| s.0)).collect();
    let ends: Vec<NaiveDateTime> = week.iter().filter_map(|d| d.span.map(|s| s.1)).collect();
    if starts.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 = *starts.iter().min().unwrap();
    let raw_end = *ends.iter().max().unwrap();
    let window_start = raw_start
        .with_minute(0)
        .unwrap()
        .with_second(0)
        .unwrap()
        .with_nanosecond(0)
        .unwrap();
    let mut window_end = raw_end;
    if window_end.minute() > 0 || window_end.second() > 0 {
        window_end = window_end
            .with_minute(0)
            .unwrap()
            .with_second(0)
            .unwrap()
            .with_nanosecond(0)
            .unwrap()
            + Duration::hours(1);
    }
    if window_end <= window_start {
        window_end = window_start + Duration::hours(1);
    }
    let window_min = window_end.signed_duration_since(window_start).num_minutes();

    // 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).
    let mut gutter_lines = vec![];
    for r in 0..bar_height {
        let t = window_start + Duration::minutes(r as i64 * mpr);
        let label = if t.minute() == 0 {
            t.format("%H:%M").to_string()
        } else {
            String::new()
        };
        gutter_lines.push(Line::from(Span::styled(
            label,
            Style::default().fg(Color::Gray),
        )));
    }
    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)
        };

        let lines = render_day_lines(day, window_start, mpr, bar_height, bar_w, &colors);
        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],
) -> 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 {
            let (ch, c) = if row_time.minute() == 0 {
                ("", Color::Gray)
            } else if row_time.minute() == 30 {
                ("", Color::Indexed(245))
            } else {
                ("", Color::Indexed(238))
            };
            Line::from(Span::styled(ch.repeat(bar_w), Style::default().fg(c)))
        };
        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
}