use chrono::{Local, Timelike};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use std::collections::HashMap;
use crate::app::{App, InputMode};
use crate::dashboard::utils::{
breathe_color, breathe_t, calculate_minutes_per_row, format_duration_seconds, string_to_color,
themed_rgb,
};
pub fn draw_timeline(f: &mut Frame, app: &App, area: Rect) {
if app.entries.is_empty() {
let empty = Paragraph::new("No entries\nfor this day.\n\nPress 'n'\nto create one.")
.block(Block::default().borders(Borders::ALL).title("Timeline"))
.style(Style::default().fg(Color::Gray));
f.render_widget(empty, area);
return;
}
let (editing_start, editing_end, editing_field) = match &app.input_mode {
InputMode::Editing {
start_time,
end_time,
current_field,
..
}
| InputMode::Creating {
start_time,
end_time,
current_field,
..
} => {
let start = chrono::NaiveTime::parse_from_str(start_time, "%H:%M")
.ok()
.map(|t| app.current_date.and_time(t));
let end = if !end_time.is_empty() {
chrono::NaiveTime::parse_from_str(end_time, "%H:%M")
.ok()
.map(|t| app.current_date.and_time(t))
} else {
None
};
(start, end, Some(*current_field))
}
_ => (None, None, None),
};
let earliest_start = app.get_earliest_start_time().unwrap();
let mut end_time = app.get_latest_end_time(Some(app.current_date)).unwrap_or(earliest_start);
let has_running = app.entries.iter().any(|e| e.is_running());
let start_time = earliest_start
.with_minute(0)
.unwrap()
.with_second(0)
.unwrap();
if let Some(edit_start) = editing_start {
end_time = end_time.max(edit_start);
}
if let Some(edit_end) = editing_end {
end_time = end_time.max(edit_end);
}
let today = Local::now().date_naive();
if app.current_date == today {
let now = Local::now().naive_local();
let future_time = now + chrono::Duration::minutes(10);
end_time = end_time.max(future_time);
}
let min_duration = chrono::Duration::minutes(5);
if end_time.signed_duration_since(start_time) < min_duration {
end_time = start_time + min_duration;
}
if end_time.minute() > 0 || end_time.second() > 0 {
end_time = end_time
.with_minute(0)
.unwrap()
.with_second(0)
.unwrap()
+ chrono::Duration::hours(1);
}
let total_duration = end_time.signed_duration_since(start_time);
let mut lines = vec![];
let actual_end = if has_running {
Local::now().naive_local()
} else {
app.entries.iter().filter_map(|e| e.end_time).max().unwrap_or(earliest_start)
};
let actual_total_seconds = actual_end.signed_duration_since(earliest_start).num_seconds().max(0);
lines.push(Line::from(vec![
Span::styled("Start: ", Style::default().fg(Color::Gray)),
Span::styled(
earliest_start.format("%H:%M").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(
if has_running { "Now: " } else { "End: " },
Style::default().fg(Color::Gray),
),
Span::styled(
actual_end.format("%H:%M").to_string(),
Style::default()
.fg(if has_running {
Color::Green
} else {
Color::Yellow
})
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("Total: ", Style::default().fg(Color::Gray)),
Span::styled(
format_duration_seconds(actual_total_seconds),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"━".repeat(31),
Style::default().fg(Color::DarkGray),
)));
let total_minutes = total_duration.num_minutes();
let available_rows = (area.height.saturating_sub(10)) as usize; let minutes_per_row = calculate_minutes_per_row(total_minutes, available_rows);
let bar_height = ((total_minutes + minutes_per_row - 1) / minutes_per_row) as usize;
let colors: Vec<Color> = app
.config
.colors
.iter()
.map(|s| string_to_color(s))
.collect();
let selected_entry_id = if app.at_work_selected {
None
} else {
app.selected_index
.and_then(|i| app.entries.get(i))
.map(|e| e.id)
};
let breathe = breathe_t();
let mut editing_times = vec![];
if let Some(s) = editing_start {
editing_times.push((s, true)); }
if let Some(e) = editing_end {
editing_times.push((e, false)); }
let mut entry_row_bounds = vec![];
for entry in app.entries.iter() {
if !entry.is_running() {
continue;
}
let entry_start_offset = entry
.start_time
.signed_duration_since(start_time)
.num_seconds();
let entry_end = entry.end_time.unwrap_or_else(|| Local::now().naive_local());
let entry_end_offset = entry_end.signed_duration_since(start_time).num_seconds();
let mut first_row = None;
let mut last_row = None;
for r in 0..=bar_height {
let r_seconds = r as i64 * minutes_per_row * 60;
if r_seconds >= entry_start_offset && r_seconds < entry_end_offset {
if first_row.is_none() {
first_row = Some(r);
}
last_row = Some(r);
}
}
if let (Some(first), Some(last)) = (first_row, last_row) {
entry_row_bounds.push((entry.id, first, last));
}
}
let mut row_entries: HashMap<usize, Vec<(i64, Color, bool)>> = HashMap::new();
for entry in app.entries.iter() {
let entry_start_offset = entry
.start_time
.signed_duration_since(start_time)
.num_seconds();
let entry_end = entry.end_time.unwrap_or_else(|| Local::now().naive_local());
let entry_end_offset = entry_end.signed_duration_since(start_time).num_seconds();
let entry_color = colors[entry.color as usize % colors.len()];
for r in 0..=bar_height {
let r_seconds = r as i64 * minutes_per_row * 60;
if r_seconds >= entry_start_offset && r_seconds < entry_end_offset {
row_entries.entry(r).or_insert_with(Vec::new).push((
entry.id,
entry_color,
entry.is_running(),
));
}
}
}
fn workday_edge(in_span: bool, is_start: bool, is_end: bool) -> Span<'static> {
let _ = (is_start, is_end);
if !in_span {
return Span::raw(" ");
}
let ch = "│";
Span::styled(ch, Style::default().fg(Color::Cyan))
}
let workday_offsets = app.at_work_span().map(|(s, e, _)| {
(
s.signed_duration_since(start_time).num_seconds(),
e.signed_duration_since(start_time).num_seconds(),
)
});
let in_workday = |row_secs: i64| -> bool {
workday_offsets
.map(|(s, e)| row_secs >= s && row_secs < e)
.unwrap_or(false)
};
let (workday_first_row, workday_last_row) = {
let mut first = None;
let mut last = None;
for r in 0..=bar_height {
if in_workday(r as i64 * minutes_per_row * 60) {
if first.is_none() {
first = Some(r);
}
last = Some(r);
}
}
(first, last)
};
for row in 0..=bar_height {
let row_start_time = start_time + chrono::Duration::minutes(row as i64 * minutes_per_row);
let row_start_seconds = row as i64 * minutes_per_row * 60;
let mut forced_indicator = None;
let half_row_seconds = minutes_per_row * 30; for (edit_time, is_start) in &editing_times {
let edit_offset = edit_time.signed_duration_since(start_time).num_seconds();
if row_start_seconds >= (edit_offset - half_row_seconds)
&& row_start_seconds <= (edit_offset + half_row_seconds)
{
forced_indicator = Some((*edit_time, *is_start));
break;
}
}
let mut entry_info = None;
for entry in app.entries.iter() {
let entry_start_offset = entry
.start_time
.signed_duration_since(start_time)
.num_seconds();
let entry_end = entry.end_time.unwrap_or_else(|| Local::now().naive_local());
let entry_end_offset = entry_end.signed_duration_since(start_time).num_seconds();
if row_start_seconds >= entry_start_offset && row_start_seconds < entry_end_offset {
let mut is_first_row = false;
let mut is_last_row = false;
if entry.is_running() {
for (entry_id, first, last) in &entry_row_bounds {
if *entry_id == entry.id {
is_first_row = row == *first;
is_last_row = row == *last;
break;
}
}
}
entry_info = Some((
colors[entry.color as usize % colors.len()],
entry.is_running(),
is_first_row,
is_last_row,
entry.off_work,
Some(entry.id) == selected_entry_id,
));
break;
}
}
let line = if let Some((edit_time, is_start)) = forced_indicator {
let time_label = edit_time.format("%H:%M").to_string();
if is_start {
let is_active = editing_field == Some(1);
let bg_color = if is_active { Color::White } else { Color::Gray };
let fg_color = Color::Black;
let text = format!("{} START {}", time_label, time_label);
let padding = 26usize.saturating_sub(text.len());
Line::from(vec![Span::styled(
format!("{}{}", text, " ".repeat(padding)),
Style::default()
.fg(fg_color)
.bg(bg_color)
.add_modifier(Modifier::BOLD),
)])
} else {
let is_active = editing_field == Some(2);
let bg_color = if is_active { Color::White } else { Color::Gray };
let fg_color = Color::Black;
let text = format!("{} END {}", time_label, time_label);
let padding = 26usize.saturating_sub(text.len());
Line::from(vec![Span::styled(
format!("{}{}", text, " ".repeat(padding)),
Style::default()
.fg(fg_color)
.bg(bg_color)
.add_modifier(Modifier::BOLD),
)])
}
} else {
let time_label = row_start_time.format("%H:%M").to_string();
let entries_at_row = row_entries.get(&row).map(|v| v.as_slice()).unwrap_or(&[]);
let has_overlap = entries_at_row.len() > 1;
let row_in_workday = in_workday(row_start_seconds);
let edge = workday_edge(
row_in_workday,
workday_first_row == Some(row),
workday_last_row == Some(row),
);
if let Some((color, is_running, is_first_row, is_last_row, off_work, is_selected)) =
entry_info
{
let color = if is_selected {
let base = if off_work {
(158, 158, 158) } else {
themed_rgb(color, &app.term_palette)
};
let (r, g, b) = breathe_color(base, breathe);
Color::Rgb(r, g, b)
} else if off_work {
Color::Indexed(247)
} else {
color
};
let green = if is_selected {
let (r, g, b) = breathe_color(themed_rgb(Color::Green, &app.term_palette), breathe);
Color::Rgb(r, g, b)
} else {
Color::Green
};
let mut spans = vec![
Span::styled(
format!("{} ", time_label),
Style::default().fg(Color::DarkGray),
),
edge,
];
if off_work {
spans.push(Span::styled("▒".repeat(20), Style::default().fg(color)));
} else if is_running {
if is_first_row || is_last_row {
spans.push(Span::styled(
"█".repeat(20),
Style::default()
.fg(green)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
"██",
Style::default().fg(green).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"█".repeat(16),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"██",
Style::default().fg(green).add_modifier(Modifier::BOLD),
));
}
} else {
spans.push(Span::styled("█".repeat(20), Style::default().fg(color)));
}
if has_overlap {
spans.push(Span::raw(" "));
for (idx, (_, overlap_color, overlap_running)) in
entries_at_row.iter().enumerate()
{
if idx > 0 {
if *overlap_running {
spans.push(Span::styled(
"●",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
"●",
Style::default()
.fg(*overlap_color)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled("●", Style::default().fg(*overlap_color)));
}
}
}
}
Line::from(spans)
} else {
let is_full_hour = row_start_time.minute() == 0;
let (line_char, line_color) = if is_full_hour {
("─", Color::Gray)
} else if row_start_time.minute() == 30 {
("╌", Color::Indexed(245)) } else {
("┄", Color::Indexed(238)) };
let label_style =
Style::default().fg(if is_full_hour { Color::Gray } else { Color::DarkGray });
Line::from(vec![
Span::styled(format!("{} ", time_label), label_style),
edge,
Span::styled(line_char.repeat(20), Style::default().fg(line_color)),
])
}
};
lines.push(line);
}
let timeline =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Timeline"));
f.render_widget(timeline, area);
}