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};
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);
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)>,
) {
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 {
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);
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();
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)
};
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],
);
}
}
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);
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;
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);
}
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);
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, 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);
}
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
}