use std::collections::HashMap;
use jiff::ToSpan;
use jiff::civil::{Date, Weekday};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use rust_i18n::t;
use super::app::App;
use crate::i18n::{self, Lang};
use crate::theme::Palette;
pub const WIDTH: u16 = 2 + 1 + 3 + 7 * 3;
fn is_weekend(wd: Weekday) -> bool {
matches!(wd, Weekday::Saturday | Weekday::Sunday)
}
fn superscript(n: usize) -> char {
const DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
if n < 10 { DIGITS[n] } else { '⁺' }
}
fn first_of_month(d: Date) -> Date {
d.first_of_month()
}
fn sunday_first(lang: Lang) -> bool {
lang == Lang::En
}
fn week_offset(d: Date, sunday_first: bool) -> usize {
if sunday_first {
d.weekday().to_sunday_zero_offset() as usize
} else {
d.weekday().to_monday_zero_offset() as usize
}
}
fn weeks_in_month(first: Date, sunday_first: bool) -> usize {
(week_offset(first, sunday_first) + first.days_in_month() as usize).div_ceil(7)
}
fn month_height(first: Date, sunday_first: bool) -> usize {
3 + weeks_in_month(first, sunday_first)
}
fn month_name(month: i8) -> String {
let key = format!("cal.month.{month}");
t!(key.as_str()).into_owned()
}
fn weekday_header(wd: Weekday) -> String {
let key = match wd {
Weekday::Monday => "cal.wd.mon",
Weekday::Tuesday => "cal.wd.tue",
Weekday::Wednesday => "cal.wd.wed",
Weekday::Thursday => "cal.wd.thu",
Weekday::Friday => "cal.wd.fri",
Weekday::Saturday => "cal.wd.sat",
Weekday::Sunday => "cal.wd.sun",
};
t!(key).into_owned()
}
pub fn draw(f: &mut Frame, app: &App, c: &Palette, area: Rect) {
let today = crate::dates::today();
let block = Block::bordered()
.border_style(Style::new().fg(c.border))
.title(Span::styled(
t!("cal.title").into_owned(),
Style::new().fg(c.title),
));
let sunday_first = sunday_first(i18n::current());
let inner = block.inner(area);
f.render_widget(block, area);
let mut due: HashMap<Date, usize> = HashMap::new();
for col in &app.board.columns[..app.board.last_index()] {
for t in &col.tasks {
if let Some(d) = t.due {
*due.entry(d).or_default() += 1;
}
}
}
let selected_due = app.selected().and_then(|t| t.due);
let current = first_of_month(today);
let prev = current.checked_sub(1.month()).unwrap_or(current);
let next = current.checked_add(1.month()).unwrap_or(current);
let height = inner.height as usize;
let months: Vec<Date> = [
vec![prev, current, next],
vec![current, next],
vec![current],
]
.into_iter()
.find(|ms| {
ms.iter()
.map(|m| month_height(*m, sunday_first))
.sum::<usize>()
<= height
})
.unwrap_or_else(|| vec![current]);
let first = months[0];
let last = months[months.len() - 1];
let range_end = last.last_of_month();
let holiday_list = app
.region
.map(|r| r.between(first, range_end))
.unwrap_or_default();
let holiday_set: HashMap<Date, &str> =
holiday_list.iter().map(|(d, n)| (*d, n.as_str())).collect();
let mut lines: Vec<Line> = vec![];
for m in &months {
month_lines(
&mut lines,
*m,
sunday_first,
*m == current,
today,
&due,
selected_due,
&holiday_set,
c,
);
}
for (d, name) in holiday_list {
if lines.len() >= height {
break;
}
let style = if d < today {
Style::new().fg(c.dim)
} else {
Style::new().fg(c.fg)
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", crate::dates::fmt_day_month(d)),
Style::new().fg(c.holiday),
),
Span::styled(name, style),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
#[allow(clippy::too_many_arguments)]
fn month_lines(
lines: &mut Vec<Line<'static>>,
first: Date,
sunday_first: bool,
is_current: bool,
today: Date,
due: &HashMap<Date, usize>,
selected_due: Option<Date>,
holidays: &HashMap<Date, &str>,
c: &Palette,
) {
let title = format!("{} {}", month_name(first.month()), first.year());
let title_style = if is_current {
Style::new().fg(c.title).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(c.dim)
};
lines.push(Line::from(Span::styled(
format!("{title:^25}"),
title_style,
)));
let mut header = vec![Span::styled(
format!("{:>3}", t!("cal.week")),
Style::new().fg(c.dim),
)];
let offset = week_offset(first, sunday_first) as i64;
let mut week_start = first.checked_sub(offset.days()).unwrap_or(first);
for i in 0..7 {
let wd = week_start
.checked_add((i as i64).days())
.unwrap_or(week_start)
.weekday();
let color = if is_weekend(wd) { c.weekend } else { c.dim };
header.push(Span::styled(
format!(" {}", weekday_header(wd)),
Style::new().fg(color),
));
}
lines.push(Line::from(header));
for _ in 0..weeks_in_month(first, sunday_first) {
let monday = week_start
.checked_add((if sunday_first { 1 } else { 0 }).days())
.unwrap_or(week_start);
let kw = monday.iso_week_date().week();
let mut spans = vec![Span::styled(format!(" {kw:>2}"), Style::new().fg(c.dim))];
for i in 0..7 {
let d = week_start
.checked_add((i as i64).days())
.unwrap_or(week_start);
if d.month() != first.month() {
spans.push(Span::raw(" "));
continue;
}
spans.push(Span::raw(if i == 0 { " " } else { "" }));
spans.push(Span::styled(
format!("{:>2}", d.day()),
day_style(d, today, selected_due, holidays, c),
));
let marker = match due.get(&d) {
Some(&n) => {
let color = if d < today { c.overdue } else { c.accent };
Span::styled(superscript(n).to_string(), Style::new().fg(color).bold())
}
None => Span::raw(" "),
};
spans.push(marker);
}
lines.push(Line::from(spans));
week_start = week_start.checked_add(7.days()).unwrap_or(week_start);
}
lines.push(Line::default());
}
fn day_style(
d: Date,
today: Date,
selected_due: Option<Date>,
holidays: &HashMap<Date, &str>,
c: &Palette,
) -> Style {
let weekend = is_weekend(d.weekday());
let mut style = if holidays.contains_key(&d) {
Style::new().fg(c.holiday).add_modifier(Modifier::BOLD)
} else if weekend {
Style::new().fg(c.weekend)
} else if d < today {
Style::new().fg(c.dim)
} else {
Style::new().fg(c.fg)
};
if d == today {
style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD);
}
if selected_due == Some(d) {
style = style
.fg(c.border_active)
.add_modifier(Modifier::UNDERLINED | Modifier::BOLD);
}
style
}
#[cfg(test)]
mod tests {
use super::*;
use jiff::civil::date;
#[test]
fn month_geometry() {
assert_eq!(weeks_in_month(date(2026, 10, 1), false), 5);
assert_eq!(weeks_in_month(date(2027, 2, 1), false), 4);
assert_eq!(weeks_in_month(date(2027, 2, 1), true), 5);
assert_eq!(weeks_in_month(date(2027, 8, 1), false), 6);
assert_eq!(weeks_in_month(date(2027, 8, 1), true), 5);
assert_eq!(month_height(date(2026, 10, 1), false), 8);
}
#[test]
fn superscripts() {
assert_eq!(superscript(1), '¹');
assert_eq!(superscript(9), '⁹');
assert_eq!(superscript(12), '⁺');
}
}