use crate::cli::parser::Commands;
use crate::config::Config;
use crate::core::logic::Core;
use crate::db::pool::DbPool;
use crate::db::queries::load_events_by_date;
use crate::errors::{AppError, AppResult};
use crate::models::day_summary::DaySummary;
use crate::models::event::Event;
use crate::models::location::Location;
use crate::ui::messages::{info, warning};
use crate::utils::date::get_day_position;
use crate::utils::table::EVENTS_TABLE_WIDTH;
use crate::utils::{colors, date, formatting, mins2readable};
use chrono::{Datelike, NaiveDate};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WeekdayMode {
None,
Short,
Medium,
Long,
}
fn weekday_mode(cfg: &Config) -> WeekdayMode {
match cfg.show_weekday.to_ascii_lowercase().as_str() {
"none" => WeekdayMode::None,
"short" => WeekdayMode::Short,
"medium" => WeekdayMode::Medium,
"long" => WeekdayMode::Long,
_ => WeekdayMode::Medium,
}
}
fn weekday_type_char(mode: WeekdayMode) -> Option<char> {
match mode {
WeekdayMode::None => None,
WeekdayMode::Short => Some('s'),
WeekdayMode::Medium => Some('m'),
WeekdayMode::Long => Some('l'),
}
}
fn effective_weekday_mode(mode: WeekdayMode, compact: bool) -> WeekdayMode {
if !compact {
return mode;
}
match mode {
WeekdayMode::None => WeekdayMode::None,
_ => WeekdayMode::Short,
}
}
fn date_col_width(mode: WeekdayMode) -> usize {
match mode {
WeekdayMode::None => 10,
WeekdayMode::Short => 15,
WeekdayMode::Medium => 16,
WeekdayMode::Long => 22,
}
}
const POS_W: usize = 16;
const TIME_W: usize = 5; const DWORK_W: usize = 7;
fn daily_table_width(mode: WeekdayMode) -> usize {
let dw = date_col_width(mode);
1 + dw + 3 + POS_W + 3 + TIME_W + 3 + TIME_W + 3 + TIME_W + 3 + TIME_W + 3 + DWORK_W + 1
}
const CPOS_W: usize = 12;
const TRIPLE_W: usize = 21; const CTGT_W: usize = 5;
const CDWORK_W: usize = 7;
fn compact_table_width(mode: WeekdayMode) -> usize {
let dw = date_col_width(mode);
dw + 3 + CPOS_W + 3 + TRIPLE_W + 3 + CTGT_W + 3 + CDWORK_W + 7
}
fn format_date_with_weekday(date: &NaiveDate, mode: WeekdayMode) -> String {
let date_str = date.to_string();
if let Some(ch) = weekday_type_char(mode) {
let wd = date::weekday_str(&date_str, ch);
format!("{} ({})", date_str, wd)
} else {
date_str
}
}
fn get_meta_string(events: &[Event], max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let joined = events
.iter()
.filter_map(|e| e.meta.as_deref())
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(", ");
let count = joined.chars().count();
if count <= max_chars {
return joined;
}
if max_chars == 1 {
return "…".to_string();
}
let mut out: String = joined.chars().take(max_chars - 1).collect();
out.push('…');
out
}
fn remaining_width(total_width: usize, plain_prefix: &str) -> usize {
total_width.saturating_sub(plain_prefix.len())
}
fn total_non_work_gap_minutes(summary: &DaySummary) -> i64 {
summary
.timeline
.gaps
.iter()
.filter(|g| !g.is_work_gap)
.map(|g| g.duration_minutes)
.sum()
}
pub fn handle(cmd: &Commands, cfg: &Config) -> AppResult<()> {
if let Commands::List {
compact,
period,
now,
details,
events: events_only,
..
} = cmd
{
if *compact && *details {
return Err(AppError::InvalidArgs(
"--compact cannot be used together with --details.".into(),
));
}
let mut pool = DbPool::new(&cfg.database)?;
let wd_mode_cfg = weekday_mode(cfg);
let wd_mode = effective_weekday_mode(wd_mode_cfg, *compact);
let dates = if *now {
vec![date::today()]
} else {
resolve_period(period)?
};
if dates.is_empty() {
warning("⚠️ No recorded sessions found");
return Ok(());
}
if !*now {
if period.is_some() {
print_header(period);
} else {
print_header(&Some("this_month".to_string()));
}
}
let mut total_surplus: i64 = 0;
let mut any_output = false;
let mut last_month: Option<(i32, u32)> = None;
let mut printed_daily_header = false;
if *events_only && Event::has_events_for_dates(&mut pool, &dates)? {
println!("EVENTS:");
println!();
println!(
" {:^17} | {:^4} | {:^12} | {:^16} | {:^6} | {:^4} | {:^8}",
"Date Time", "Type", "Lunch", "Position", "Source", "Pair", "Work Gap"
);
println!("{:-<w$}", "-", w = EVENTS_TABLE_WIDTH);
}
for day in dates {
if !*events_only {
let current_month = (day.year(), day.month());
if let Some((ly, lm)) = last_month
&& (ly, lm) != current_month
{
let twidth = if *compact {
compact_table_width(wd_mode)
} else {
daily_table_width(wd_mode)
};
println!("{:-<w$}", "-", w = twidth);
if *compact {
print_compact_header(wd_mode);
} else {
print_daily_table_header(wd_mode);
}
printed_daily_header = true;
}
last_month = Some(current_month);
}
let events = load_events_by_date(&mut pool, &day)?;
if events.is_empty() {
continue;
}
if *events_only {
print_raw_events(&events);
continue;
}
let day_summary = Core::build_daily_summary(&events, cfg);
if day_summary.timeline.pairs.is_empty() {
info(format!("No valid pairs for {}.", day));
continue;
}
if !printed_daily_header {
if *compact {
print_compact_header(wd_mode);
} else {
print_daily_table_header(wd_mode);
}
printed_daily_header = true;
}
let day_surplus = if *compact {
print_daily_row_compact(&day, &events, &day_summary, cfg, wd_mode)
} else {
print_daily_row(&day, &events, &day_summary, cfg, wd_mode)
};
if let Some(v) = day_surplus {
total_surplus += v;
}
if *details && (*now || period.as_ref().is_some_and(|p| p.len() == 10)) {
print_details(&day_summary);
}
any_output = true;
}
if any_output && !*events_only {
let twidth = if *compact {
compact_table_width(wd_mode)
} else {
daily_table_width(wd_mode)
};
println!("{:-<w$}", "-", w = twidth);
let color = colors::color_for_surplus(total_surplus);
let delta = format_delta_compact(total_surplus);
let footer_plain = format!("Σ Total ΔWORK: {}", delta);
let prefix = formatting::right_pad_prefix(
twidth.saturating_sub(if *compact { 1 } else { 3 }),
&footer_plain,
);
if *compact {
println!(
"{}Σ Total ΔWORK: {}{}{}",
prefix,
color,
delta,
colors::RESET
);
} else {
println!(
"{}{} Σ Total ΔWORK: {} {}{}{}",
prefix,
colors::SECTION_BAR, colors::RESET, color, delta, colors::RESET );
}
}
Ok(())
} else {
Ok(())
}
}
fn resolve_period(period: &Option<String>) -> AppResult<Vec<NaiveDate>> {
if let Some(p) = period {
if p == "all" {
return date::generate_all_dates().map_err(AppError::InvalidDate);
}
if p.contains(':') {
let parts: Vec<&str> = p.split(':').collect();
if parts.len() == 2 {
return date::generate_range(parts[0], parts[1]).map_err(AppError::InvalidDate);
}
}
return date::generate_from_period(p).map_err(AppError::InvalidDate);
}
date::current_month_dates().map_err(AppError::InvalidDate)
}
fn print_header(period: &Option<String>) {
if let Some(p) = period {
if p == "this_month" {
let today = date::today();
let month_name = date::month_name(&format!("{:02}", today.month()));
info(format!(
"📅 Saved sessions for {} {}\n",
month_name,
today.year()
));
return;
}
match p.len() {
4 => info(format!("📅 Saved sessions for year {}\n", p)),
7 => {
let parts: Vec<&str> = p.split('-').collect();
if parts.len() == 2 {
info(format!(
"📅 Saved sessions for {} {}\n",
date::month_name(parts[1]),
parts[0]
));
}
}
10 => info(format!("📅 Saved session for date {}\n", p)),
15 => {
let parts: Vec<&str> = p.split(':').collect();
if parts.len() == 2 {
info(format!(
"📅 Saved sessions from {} to {}\n",
parts[0], parts[1]
));
}
}
_ => {}
}
}
}
fn print_raw_events(events: &[Event]) {
let mut last_date: Option<String> = None;
for ev in events {
let lunch = colors::colorize_optional(&format!("{:>2} min", ev.lunch.unwrap_or(0)));
let pos_label = ev.location.label();
let pos_color = ev.location.color();
let pos_fmt = formatting::pad_right(pos_label, POS_W);
let (dash, date_str) = if ev.kind.is_in() {
let current_date = ev.date_str();
match &last_date {
Some(d) if d == ¤t_date => (" ", " ".repeat(10)),
_ => {
last_date = Some(current_date.clone());
("→", current_date)
}
}
} else {
(" ", " ".repeat(10))
};
println!(
"{} {:^10} {} | {:>4} | lunch {} | {}{}\x1b[0m | {:^6} | {:>3} | {:^8}",
dash,
date_str,
colors::colorize_in_out(&ev.time_str(), ev.kind.is_in()),
ev.kind.et_as_str(),
lunch,
pos_color,
pos_fmt,
ev.source,
ev.pair,
if ev.work_gap { "YES" } else { "" }
);
}
}
fn print_daily_table_header(wd_mode: WeekdayMode) {
let dw = date_col_width(wd_mode);
let twidth = daily_table_width(wd_mode);
println!(
" {:^dw$} | {:^16} | {:^5} | {:^5} | {:^5} | {:^5} | {:^7}",
"DATE",
"POSITION",
"IN",
"LNCH",
"OUT",
"TGT",
"ΔWORK",
dw = dw
);
println!("{:-<w$}", "-", w = twidth);
}
fn print_daily_row(
date: &NaiveDate,
events: &[Event],
summary: &DaySummary,
_cfg: &Config,
wd_mode: WeekdayMode,
) -> Option<i64> {
let timeline = &summary.timeline;
if timeline.pairs.is_empty() {
return None;
}
let day_position = get_day_position(timeline);
let date_str = format_date_with_weekday(date, wd_mode);
let dw = date_col_width(wd_mode);
let pos_label = day_position.label();
let pos_color = day_position.color();
let pos_fmt = formatting::pad_right(pos_label, POS_W);
let grey_time = format!("{}--:--{}", colors::GREY, colors::RESET);
let mut first_in_str = grey_time.clone();
let mut lunch_c = grey_time.clone();
let mut end_c = grey_time.clone();
let mut expected_exit_str = grey_time.clone();
let mut surplus_opt: Option<i64> = Some(0); let mut surplus_display = "-".to_string();
let mut surplus_color = colors::GREY;
let is_marker_day = matches!(
day_position,
Location::Holiday | Location::NationalHoliday | Location::SickLeave
);
if !is_marker_day {
let first_in = timeline.pairs[0].in_event.timestamp();
first_in_str = first_in.format("%H:%M").to_string();
let last_out_opt = timeline
.pairs
.iter()
.filter_map(|p| p.out_event.as_ref())
.map(|ev| ev.timestamp())
.next_back();
let mut lunch_total: i64 = timeline.pairs.iter().map(|p| p.lunch_minutes).sum();
if lunch_total == 0 {
lunch_total = events.iter().map(|ev| ev.lunch.unwrap_or(0) as i64).sum();
}
let non_work_gap_minutes = total_non_work_gap_minutes(summary);
let expected_exit = first_in
+ chrono::Duration::minutes(summary.expected)
+ chrono::Duration::minutes(non_work_gap_minutes);
expected_exit_str = expected_exit.format("%H:%M").to_string();
let lunch_str = if lunch_total > 0 {
crate::utils::time::format_minutes(lunch_total)
} else {
"--:--".to_string()
};
lunch_c = colors::colorize_optional(&lunch_str);
let end_str = last_out_opt
.map(|ts| ts.format("%H:%M").to_string())
.unwrap_or_else(|| "--:--".to_string());
end_c = colors::colorize_optional(&end_str);
surplus_opt = last_out_opt.map(|out| (out - expected_exit).num_minutes());
match surplus_opt {
None => {
surplus_display = "-".to_string();
surplus_color = colors::GREY;
}
Some(0) => {
surplus_display = "0".to_string();
surplus_color = colors::GREY;
}
Some(v) => {
let abs = mins2readable(v.abs(), false, false); let compact = abs.replace(' ', ""); surplus_display = format!("{}{}", if v < 0 { "-" } else { "+" }, compact);
surplus_color = colors::color_for_surplus(v);
}
}
}
if day_position == Location::NationalHoliday {
let twidth = daily_table_width(wd_mode);
let plain_prefix = format!(" {:<dw$} | {:<16} | ", date_str, pos_label, dw = dw);
let meta_w = remaining_width(twidth, &plain_prefix);
let meta = get_meta_string(events, meta_w);
println!(
" {:<dw$} | {}{:<16}{}\x1b[0m | {}{:<meta_w$}{}",
date_str,
pos_color,
pos_label,
colors::RESET,
pos_color,
meta,
colors::RESET,
dw = dw,
meta_w = meta_w,
);
} else {
println!(
" {:<dw$} | {}{}\x1b[0m | {:^5} | {:^5} | {:^5} | {:^5} | {}{:>7}\x1b[0m",
date_str,
pos_color,
pos_fmt,
first_in_str,
lunch_c,
end_c,
expected_exit_str,
surplus_color,
surplus_display,
dw = dw
);
}
surplus_opt
}
fn print_details(summary: &DaySummary) {
if summary.timeline.pairs.is_empty() {
return;
}
println!();
println!(" {} DETAILS {}", colors::SECTION_BAR, colors::RESET);
println!(
" {:^4} | {:^5} | {:^5} | {:^6} | {:^5} | {:^16} | {:^2}",
"PAIR", "IN", "OUT", "WORKED", "LUNCH", "POSITION", "WG"
);
println!(" {:-<72}", "-");
for (idx, p) in summary.timeline.pairs.iter().enumerate() {
let in_t = p.in_event.timestamp().format("%H:%M").to_string();
let in_c = colors::colorize_in_out(&in_t, true);
let out_t = p
.out_event
.as_ref()
.map(|ev| ev.timestamp().format("%H:%M").to_string())
.unwrap_or_else(|| "--:--".to_string());
let out_c = colors::colorize_in_out(&out_t, false);
let worked_raw = mins2readable(p.duration_minutes, false, false);
let worked_compact = worked_raw.replace(' ', "");
let worked_c = colors::colorize_optional(&worked_compact);
let lunch_compact = format!("{:>2}m", p.lunch_minutes);
let lunch_c = colors::colorize_optional(&lunch_compact);
let pos_label = p.position.label();
let pos_color = p.position.color();
let pos_fmt = formatting::pad_right(pos_label, POS_W);
let wg_str = if p.work_gap { "Y" } else { "" };
println!(
" {:>4} | {:^5} | {:^5} | {:^6} | {:^5} | {}{}\x1b[0m | {:^2}",
idx + 1,
in_c,
out_c,
worked_c,
lunch_c,
pos_color,
pos_fmt,
wg_str
);
}
println!();
}
fn print_compact_header(wd_mode: WeekdayMode) {
let dw = date_col_width(wd_mode);
let twidth = compact_table_width(wd_mode);
println!(
"{:^dw$} | {:^16} | {:^21} | {:^5} | {:^7}",
"DATE",
"POSITION",
"IN / LNCH / OUT",
"TGT",
"ΔWORK",
dw = dw
);
println!("{:-<w$}", "-", w = twidth);
}
fn format_delta_compact(minutes: i64) -> String {
let abs = mins2readable(minutes.abs(), false, true); format!("{}{}", if minutes < 0 { "-" } else { "+" }, abs)
}
fn print_daily_row_compact(
date: &NaiveDate,
events: &[Event],
summary: &DaySummary,
_cfg: &Config,
wd_mode: WeekdayMode,
) -> Option<i64> {
let timeline = &summary.timeline;
if timeline.pairs.is_empty() {
return None;
}
let dw = date_col_width(wd_mode);
let date_str = format_date_with_weekday(date, wd_mode);
let day_position = get_day_position(timeline);
let pos_label = day_position.label();
let pos_color = day_position.color();
if day_position == Location::Holiday {
println!(
"{:<dw$} | {}{:<16}{}\x1b[0m | {:<21} | {:^5} | {}Δ -{}\x1b[0m",
date_str,
pos_color,
pos_label,
colors::RESET,
format!("{}--:-- / --:-- / --:--{}", colors::GREY, colors::RESET),
format!("{}--:--{}", colors::GREY, colors::RESET),
colors::GREY,
colors::RESET,
dw = dw
);
return Some(0);
} else if day_position == Location::NationalHoliday {
let twidth = compact_table_width(wd_mode);
let plain_prefix = format!("{:<dw$} | {:<16} | ", date_str, pos_label, dw = dw);
let meta_w = remaining_width(twidth, &plain_prefix);
let meta = get_meta_string(events, meta_w);
println!(
"{:<dw$} | {}{:<16}{}\x1b[0m | {}{:<meta_w$}{}",
date_str,
pos_color,
pos_label,
colors::RESET,
pos_color,
meta,
colors::RESET,
dw = dw,
meta_w = meta_w
);
return Some(0);
}
let first_in = timeline.pairs[0].in_event.timestamp();
let first_in_str = first_in.format("%H:%M").to_string();
let last_out_opt = timeline
.pairs
.iter()
.filter_map(|p| p.out_event.as_ref())
.map(|ev| ev.timestamp())
.next_back();
let end_str = last_out_opt
.map(|ts| ts.format("%H:%M").to_string())
.unwrap_or_else(|| "--:--".to_string());
let mut lunch_total: i64 = timeline.pairs.iter().map(|p| p.lunch_minutes).sum();
if lunch_total == 0 {
lunch_total = events.iter().map(|ev| ev.lunch.unwrap_or(0) as i64).sum();
}
let lunch_str = if lunch_total > 0 {
crate::utils::time::format_minutes(lunch_total)
} else {
"--:--".to_string()
};
let non_work_gap_minutes = total_non_work_gap_minutes(summary);
let expected_exit = first_in
+ chrono::Duration::minutes(summary.expected)
+ chrono::Duration::minutes(non_work_gap_minutes);
let target_end_str = expected_exit.format("%H:%M").to_string();
let surplus_opt = last_out_opt.map(|out| (out - expected_exit).num_minutes());
let (delta_str, delta_color) = match surplus_opt {
None => ("-".to_string(), colors::GREY),
Some(0) => ("0".to_string(), colors::GREY),
Some(v) => {
let abs = mins2readable(v.abs(), false, true);
let sign = if v < 0 { "-" } else { "+" };
(format!("{}{}", sign, abs), colors::color_for_surplus(v))
}
};
let times_string = format!("{} / {} / {}", first_in_str, lunch_str, end_str);
let delta_value = format!("Δ {}", delta_str);
println!(
"{:<dw$} | {}{:<16}{}\x1b[0m | {:<21} | {:^5} | {}{}{}\x1b[0m",
date_str,
pos_color,
pos_label,
colors::RESET,
times_string,
target_end_str,
delta_color,
delta_value,
colors::RESET,
dw = dw
);
surplus_opt
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(meta: Option<&str>) -> Event {
Event::test_with_meta(meta)
}
#[test]
fn meta_string_returns_empty_when_max_is_zero() {
let events = vec![ev(Some("Epiphany"))];
assert_eq!(get_meta_string(&events, 0), "");
}
#[test]
fn meta_string_filters_empty_and_whitespace() {
let events = vec![ev(Some("")), ev(Some(" ")), ev(Some("Epiphany"))];
assert_eq!(get_meta_string(&events, 100), "Epiphany");
}
#[test]
fn meta_string_joins_multiple_meta_with_comma_space() {
let events = vec![ev(Some("Epiphany")), ev(Some("Republic Day"))];
assert_eq!(get_meta_string(&events, 100), "Epiphany, Republic Day");
}
#[test]
fn meta_string_truncates_unicode_safely_by_chars() {
let events = vec![ev(Some("caffè 漢字")), ev(Some("fine"))];
let full = get_meta_string(&events, 1_000);
assert_eq!(full, "caffè 漢字, fine");
let n = 7;
let expected = if n == 0 {
String::new()
} else if full.chars().count() <= n {
full.clone()
} else if n == 1 {
"…".to_string()
} else {
let mut s: String = full.chars().take(n - 1).collect();
s.push('…');
s
};
let got = get_meta_string(&events, n);
assert_eq!(got, expected);
}
#[test]
fn meta_string_does_not_truncate_when_within_limit() {
let events = vec![ev(Some("Epiphany"))];
assert_eq!(get_meta_string(&events, 10), "Epiphany");
}
}