tui_breath 0.4.0

Terminal breathing guide built with Rust + Ratatui. 6 patterns, breath hold, workout mode, smooth animations, JSON session tracking.
use ratatui::prelude::*;
use ratatui::symbols;
use ratatui::widgets::{Axis, Block, Borders, Cell, Chart, Dataset, GraphType, Paragraph, Row, Table};

use crate::app::{App, AppState, HistoryView, TimeFrame};
use crate::ui::trend;

pub fn draw(f: &mut Frame, app: &App) {
    let AppState::History(history_state) = &app.state else {
        return;
    };

    let area = f.size();
    let title_block = Block::default()
        .title("Session History")
        .title_alignment(Alignment::Center)
        .borders(Borders::ALL);

    f.render_widget(title_block, area);

    let inner = Rect {
        x: area.x + 1,
        y: area.y + 2,
        width: area.width.saturating_sub(2),
        height: area.height.saturating_sub(4),
    };

    match history_state.view {
        HistoryView::Table => draw_table(f, history_state, inner),
        HistoryView::Trend => draw_trend(f, history_state, inner),
    }

    draw_footer(f, area, history_state);
}

fn draw_table(f: &mut Frame, history_state: &crate::app::HistoryState, inner: Rect) {
    if history_state.sessions.is_empty() {
        let empty_msg = Paragraph::new("No sessions recorded yet.")
            .alignment(Alignment::Center)
            .style(Style::default().dim());
        f.render_widget(empty_msg, inner);
    } else {
        let header = Row::new(vec![
            Cell::from("Date & Time").style(Style::default().bold()),
            Cell::from("Pattern").style(Style::default().bold()),
            Cell::from("Duration").style(Style::default().bold()),
            Cell::from("Hold").style(Style::default().bold()),
            Cell::from("Completion").style(Style::default().bold()),
        ]);

        let viewport_height = inner.height as usize;
        let start_idx = history_state.scroll_offset;
        let end_idx = (start_idx + viewport_height).min(history_state.sessions.len());

        let rows: Vec<Row> = history_state.sessions[start_idx..end_idx]
            .iter()
            .enumerate()
            .map(|(display_idx, entry)| {
                let actual_idx = start_idx + display_idx;
                let style = if actual_idx == history_state.selected_idx {
                    Style::default().bg(Color::DarkGray)
                } else {
                    Style::default()
                };

                let date_str = entry.start_time.format("%Y-%m-%d %H:%M").to_string();
                let duration_secs = entry.duration_target;
                let mins = duration_secs / 60;
                let secs = duration_secs % 60;
                let hold_text = entry
                    .best_breath_hold_seconds
                    .map(|secs| {
                        let count = entry.breath_hold_attempt_count;
                        format!("best {secs:.1}s / {count}")
                    })
                    .unwrap_or_else(|| "--".to_string());

                Row::new(vec![
                    Cell::from(date_str),
                    Cell::from(entry.pattern_id.clone()),
                    Cell::from(format!("{}:{:02}", mins, secs)),
                    Cell::from(hold_text),
                    Cell::from(format!("{:.0}%", entry.completion_pct)),
                ])
                .style(style)
            })
            .collect();

        let widths = [
            Constraint::Percentage(30),
            Constraint::Percentage(22),
            Constraint::Percentage(14),
            Constraint::Percentage(14),
            Constraint::Percentage(20),
        ];
        let table = Table::new(rows, &widths)
            .header(header)
            .block(Block::default().borders(Borders::ALL));

        f.render_widget(table, inner);
    }
}

fn draw_trend(f: &mut Frame, history_state: &crate::app::HistoryState, inner: Rect) {
    let hold_data = trend::hold_series(&history_state.sessions, history_state.time_frame);
    let spd_data = trend::sessions_per_day(&history_state.sessions, history_state.time_frame);

    if hold_data.points.len() < 2 && spd_data.points.len() < 2 {
        let empty_msg = Paragraph::new("Not enough sessions yet — keep practicing.")
            .alignment(Alignment::Center)
            .style(Style::default().dim());
        f.render_widget(empty_msg, inner);
    } else {
        let chunks = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
            .split(inner);

        if !hold_data.points.is_empty() {
            let (min_hold, max_hold) = hold_data
                .points
                .iter()
                .fold((f64::INFINITY, 0.0_f64), |(min, max), (_, val)| {
                    (min.min(*val), max.max(*val))
                });
            let hold_padding = (max_hold - min_hold).max(1.0) * 0.1;

            let dataset = Dataset::default()
                .name("Breath Hold (s)")
                .data(&hold_data.points)
                .marker(symbols::Marker::Braille)
                .graph_type(GraphType::Line)
                .style(Style::default().fg(Color::Yellow));

            let x_bounds = (hold_data.points.len() - 1).max(1) as f64;
            let x_labels: Vec<_> = hold_data
                .x_labels
                .iter()
                .map(|(_, label)| label.clone().into())
                .collect();
            let chart = Chart::new(vec![dataset])
                .block(Block::default().title("Breath Hold").borders(Borders::ALL))
                .x_axis(
                    Axis::default()
                        .bounds([0.0, x_bounds])
                        .labels(x_labels),
                )
                .y_axis(
                    Axis::default()
                        .bounds([
                            (min_hold - hold_padding).max(0.0),
                            max_hold + hold_padding,
                        ])
                        .labels(vec!["0".into(), format!("{:.0}", max_hold).into()]),
                );

            f.render_widget(chart, chunks[0]);
        }

        if !spd_data.points.is_empty() {
            let max_spd = spd_data
                .points
                .iter()
                .map(|(_, val)| *val)
                .fold(0.0, f64::max);
            let spd_padding = max_spd.max(1.0) * 0.1;

            let dataset = Dataset::default()
                .name("Sessions/Day")
                .data(&spd_data.points)
                .marker(symbols::Marker::Braille)
                .graph_type(GraphType::Line)
                .style(Style::default().fg(Color::Cyan));

            let x_bounds = (spd_data.points.len() - 1).max(1) as f64;
            let x_labels: Vec<_> = spd_data
                .x_labels
                .iter()
                .map(|(_, label)| label.clone().into())
                .collect();
            let chart = Chart::new(vec![dataset])
                .block(Block::default().title("Sessions Per Day").borders(Borders::ALL))
                .x_axis(
                    Axis::default()
                        .bounds([0.0, x_bounds])
                        .labels(x_labels),
                )
                .y_axis(
                    Axis::default()
                        .bounds([0.0, max_spd + spd_padding])
                        .labels(vec!["0".into(), format!("{:.0}", max_spd).into()]),
                );

            f.render_widget(chart, chunks[1]);
        }
    }
}

fn draw_footer(f: &mut Frame, area: Rect, history_state: &crate::app::HistoryState) {
    let frame_str = match history_state.time_frame {
        TimeFrame::SevenDays => "7d",
        TimeFrame::ThirtyDays => "30d",
        TimeFrame::All => "all",
    };

    let footer_text = match history_state.view {
        HistoryView::Table => "[k/↑↓/j] Navigate  [g] Trend view  [Esc] Back".to_string(),
        HistoryView::Trend => {
            format!("[g] Table view  [t] Time frame: {}  [Esc] Back", frame_str)
        }
    };

    let footer = Paragraph::new(footer_text)
        .alignment(Alignment::Center)
        .style(Style::default().dim());

    let footer_area = Rect {
        x: area.x + 1,
        y: area.bottom().saturating_sub(2),
        width: area.width.saturating_sub(2),
        height: 1,
    };
    f.render_widget(footer, footer_area);
}