Skip to main content

work_tuimer/ui/
render.rs

1use crate::timer::{TimerState, TimerStatus};
2use crate::ui::AppState;
3use ratatui::{
4    Frame,
5    layout::{Alignment, Constraint, Direction, Layout, Rect},
6    style::{Modifier, Style},
7    widgets::{Block, BorderType, Borders, Cell, Padding, Paragraph, Row, Table, TableState},
8};
9use std::time::Duration as StdDuration;
10use time::OffsetDateTime;
11
12/// Calculate elapsed duration for a timer (extracted from TimerManager to avoid storage dependency)
13fn calculate_timer_elapsed(timer: &TimerState) -> StdDuration {
14    let end_point = if timer.status == TimerStatus::Paused {
15        // If paused, use when it was paused
16        timer.paused_at.unwrap_or_else(|| {
17            OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
18        })
19    } else {
20        // If running, use now
21        OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
22    };
23
24    let elapsed = end_point - timer.start_time;
25    let paused_duration_std = StdDuration::from_secs(timer.paused_duration_secs as u64);
26
27    // Convert time::Duration to std::Duration for arithmetic
28    let elapsed_std = StdDuration::from_secs(elapsed.whole_seconds() as u64)
29        + StdDuration::from_nanos(elapsed.subsec_nanoseconds() as u64);
30
31    // Subtract paused time
32    elapsed_std.saturating_sub(paused_duration_std)
33}
34
35pub fn render(frame: &mut Frame, app: &AppState) {
36    // Layout changes if timer is active: add timer bar at top
37    let main_constraints = if app.active_timer.is_some() {
38        vec![
39            Constraint::Length(3), // Timer bar (needs 3 lines for borders + content)
40            Constraint::Length(3), // Header
41            Constraint::Min(10),   // Content
42            Constraint::Length(3), // Footer
43        ]
44    } else {
45        vec![
46            Constraint::Length(3), // Header
47            Constraint::Min(10),   // Content
48            Constraint::Length(3), // Footer
49        ]
50    };
51
52    let chunks = Layout::default()
53        .direction(Direction::Vertical)
54        .constraints(main_constraints)
55        .split(frame.size());
56
57    // Render timer bar if active
58    if app.active_timer.is_some() {
59        render_timer_bar(frame, chunks[0], app);
60        let start_idx = 1;
61        let header_chunk = chunks[start_idx];
62        let content_chunk = chunks[start_idx + 1];
63        let footer_chunk = chunks[start_idx + 2];
64
65        let is_wide = frame.size().width >= 100;
66        let middle_chunks = if is_wide {
67            Layout::default()
68                .direction(Direction::Horizontal)
69                .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
70                .split(content_chunk)
71        } else {
72            Layout::default()
73                .direction(Direction::Vertical)
74                .constraints([Constraint::Min(10), Constraint::Length(15)])
75                .split(content_chunk)
76        };
77
78        render_header(frame, header_chunk, app);
79        render_records(frame, middle_chunks[0], app);
80        render_grouped_totals(frame, middle_chunks[1], app);
81        render_footer(frame, footer_chunk, app);
82    } else {
83        // Original layout without timer
84        let is_wide = frame.size().width >= 100;
85        let middle_chunks = if is_wide {
86            Layout::default()
87                .direction(Direction::Horizontal)
88                .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
89                .split(chunks[1])
90        } else {
91            Layout::default()
92                .direction(Direction::Vertical)
93                .constraints([Constraint::Min(10), Constraint::Length(15)])
94                .split(chunks[1])
95        };
96
97        render_header(frame, chunks[0], app);
98        render_records(frame, middle_chunks[0], app);
99        render_grouped_totals(frame, middle_chunks[1], app);
100        render_footer(frame, chunks[2], app);
101    }
102
103    // Render command palette overlay if active
104    if matches!(app.mode, crate::ui::AppMode::CommandPalette) {
105        render_command_palette(frame, app);
106    }
107
108    // Render calendar modal if active
109    if matches!(app.mode, crate::ui::AppMode::Calendar) {
110        render_calendar(frame, app);
111    }
112
113    // Render task picker modal if active
114    if matches!(app.mode, crate::ui::AppMode::TaskPicker) {
115        render_task_picker(frame, app);
116    }
117
118    // Render error modal if there's an error
119    if app.last_error_message.is_some() {
120        render_error_modal(frame, app);
121    }
122}
123
124fn render_header(frame: &mut Frame, area: Rect, app: &AppState) {
125    let date_str = format!("{}", app.current_date);
126
127    let total_minutes: u32 = app
128        .day_data
129        .work_records
130        .values()
131        .map(|r| r.total_minutes)
132        .sum();
133    let total_hours = total_minutes / 60;
134    let total_mins = total_minutes % 60;
135
136    // Create a more visual header with sections
137    let chunks = Layout::default()
138        .direction(Direction::Horizontal)
139        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
140        .split(area);
141
142    let title_text = format!("⏱  WorkTimer - {} [←prev] [next→]", date_str);
143    let title = Paragraph::new(title_text)
144        .style(
145            Style::default()
146                .fg(app.theme.highlight_text)
147                .add_modifier(Modifier::BOLD),
148        )
149        .alignment(Alignment::Left)
150        .block(
151            Block::default()
152                .borders(Borders::ALL)
153                .border_type(BorderType::Rounded)
154                .border_style(Style::default().fg(app.theme.active_border)),
155        );
156
157    let total_text = format!("Total: {}h {:02}m", total_hours, total_mins);
158    let total = Paragraph::new(total_text)
159        .style(
160            Style::default()
161                .fg(app.theme.success)
162                .add_modifier(Modifier::BOLD),
163        )
164        .alignment(Alignment::Right)
165        .block(
166            Block::default()
167                .borders(Borders::ALL)
168                .border_type(BorderType::Rounded)
169                .border_style(Style::default().fg(app.theme.success)),
170        );
171
172    frame.render_widget(title, chunks[0]);
173    frame.render_widget(total, chunks[1]);
174}
175
176fn render_records(frame: &mut Frame, area: Rect, app: &AppState) {
177    let records = app.day_data.get_sorted_records();
178
179    // Calculate how many rows can fit in the visible area
180    // Account for: borders (2) + header (2) + margin (1) = 5 lines
181    let available_height = area.height.saturating_sub(5) as usize;
182
183    // Calculate scroll offset to keep selected item visible
184    let scroll_offset = if records.len() > available_height {
185        if app.selected_index >= available_height {
186            app.selected_index.saturating_sub(available_height - 1)
187        } else {
188            0
189        }
190    } else {
191        0
192    };
193
194    let rows: Vec<Row> = records
195        .iter()
196        .enumerate()
197        .map(|(i, record)| {
198            let is_selected = i == app.selected_index;
199            let is_editing = matches!(app.mode, crate::ui::AppMode::Edit) && is_selected;
200            let is_in_visual =
201                matches!(app.mode, crate::ui::AppMode::Visual) && app.is_in_visual_selection(i);
202
203            // Check if this record has an active timer running
204            // Compare by source_record_id to highlight only the specific record, not all with same name
205            let has_active_timer = app
206                .active_timer
207                .as_ref()
208                .is_some_and(|timer| timer.source_record_id == Some(record.id));
209
210            // Enhanced styling with more vibrant colors
211            let style = if is_in_visual {
212                Style::default()
213                    .bg(app.theme.visual_bg)
214                    .fg(app.theme.primary_text)
215                    .add_modifier(Modifier::BOLD)
216            } else if has_active_timer {
217                // Highlight record with active timer in green/gold
218                Style::default()
219                    .bg(app.theme.timer_active_bg)
220                    .fg(app.theme.timer_text)
221                    .add_modifier(Modifier::BOLD)
222            } else if is_selected {
223                Style::default()
224                    .bg(app.theme.selected_bg)
225                    .fg(app.theme.highlight_text)
226                    .add_modifier(Modifier::BOLD)
227            } else if i % 2 == 0 {
228                Style::default().bg(app.theme.row_alternate_bg)
229            } else {
230                Style::default()
231            };
232
233            // Add icon/emoji based on task type, with timer indicator if active
234            let icon = if has_active_timer {
235                "⏱ " // Timer icon for active timers
236            } else if record.name.to_lowercase().contains("break") {
237                "☕"
238            } else if record.name.to_lowercase().contains("meeting") {
239                "👥"
240            } else if record.name.to_lowercase().contains("code")
241                || record.name.to_lowercase().contains("dev")
242            {
243                "💻"
244            } else {
245                "📋"
246            };
247
248            let format_name_with_badge = |display_name: &str, ticket_source: &str| {
249                if app.config.has_integrations()
250                    && crate::integrations::extract_ticket_from_name(ticket_source).is_some()
251                {
252                    format!("🎫 {} {}", icon, display_name)
253                } else {
254                    format!("{} {}", icon, display_name)
255                }
256            };
257
258            let mut name_display = format_name_with_badge(&record.name, &record.name);
259            let mut start_display = record.start.to_string();
260            let mut end_display = record.end.to_string();
261            let mut project_display = record.project.clone();
262            let mut customer_display = record.customer.clone();
263            let mut description_display = record.description.clone();
264
265            if is_editing {
266                match app.edit_field {
267                    crate::ui::EditField::Name => {
268                        let name_with_cursor = format!("{}▏", app.input_buffer);
269                        name_display = format_name_with_badge(&name_with_cursor, &app.input_buffer);
270                    }
271                    crate::ui::EditField::Start | crate::ui::EditField::End => {
272                        let positions = [0, 1, 3, 4];
273                        let cursor_pos = if app.time_cursor < positions.len() {
274                            positions[app.time_cursor]
275                        } else {
276                            positions[positions.len() - 1]
277                        };
278
279                        let mut display = String::new();
280                        for (index, ch) in app.input_buffer.chars().enumerate() {
281                            if index == cursor_pos {
282                                display.push('[');
283                                display.push(ch);
284                                display.push(']');
285                            } else {
286                                display.push(ch);
287                            }
288                        }
289
290                        match app.edit_field {
291                            crate::ui::EditField::Start => start_display = display,
292                            crate::ui::EditField::End => end_display = display,
293                            _ => {}
294                        }
295                    }
296                    crate::ui::EditField::Project => {
297                        project_display = format!("{}▏", app.input_buffer);
298                    }
299                    crate::ui::EditField::Customer => {
300                        customer_display = format!("{}▏", app.input_buffer);
301                    }
302                    crate::ui::EditField::Description => {
303                        description_display = format!("{}▏", app.input_buffer);
304                    }
305                }
306            }
307
308            let field_style = |field: crate::ui::EditField, default_style: Style| {
309                if is_editing && app.edit_field == field {
310                    Style::default()
311                        .bg(app.theme.edit_bg)
312                        .fg(app.theme.primary_text)
313                        .add_modifier(Modifier::BOLD)
314                } else if is_selected && app.edit_field == field {
315                    Style::default()
316                        .bg(app.theme.focus_bg)
317                        .fg(app.theme.primary_text)
318                        .add_modifier(Modifier::BOLD)
319                } else {
320                    default_style
321                }
322            };
323
324            let mut cells = vec![
325                Cell::from(name_display)
326                    .style(field_style(crate::ui::EditField::Name, Style::default())),
327                Cell::from(start_display).style(field_style(
328                    crate::ui::EditField::Start,
329                    Style::default().fg(app.theme.success),
330                )),
331                Cell::from(end_display).style(field_style(
332                    crate::ui::EditField::End,
333                    Style::default().fg(app.theme.error),
334                )),
335                Cell::from(record.format_duration()).style(Style::default().fg(app.theme.badge)),
336            ];
337
338            if app.show_project_column() {
339                cells.push(Cell::from(project_display).style(field_style(
340                    crate::ui::EditField::Project,
341                    Style::default().fg(app.theme.primary_text),
342                )));
343            }
344
345            if app.show_customer_column() {
346                cells.push(Cell::from(customer_display).style(field_style(
347                    crate::ui::EditField::Customer,
348                    Style::default().fg(app.theme.primary_text),
349                )));
350            }
351
352            if app.show_description_column() {
353                cells.push(Cell::from(description_display).style(field_style(
354                    crate::ui::EditField::Description,
355                    Style::default().fg(app.theme.primary_text),
356                )));
357            }
358
359            Row::new(cells).style(style)
360        })
361        .collect();
362
363    let mut constraints = vec![
364        Constraint::Min(18),
365        Constraint::Length(10),
366        Constraint::Length(10),
367        Constraint::Length(12),
368    ];
369    let mut header_cells = vec![
370        Cell::from("📝 Task"),
371        Cell::from("🕐 Start"),
372        Cell::from("🕐 End"),
373        Cell::from("⏱  Duration"),
374    ];
375
376    if app.show_project_column() {
377        constraints.push(Constraint::Length(16));
378        header_cells.push(Cell::from("🗂 Project"));
379    }
380
381    if app.show_customer_column() {
382        constraints.push(Constraint::Length(16));
383        header_cells.push(Cell::from("👤 Customer"));
384    }
385
386    if app.show_description_column() {
387        constraints.push(Constraint::Min(20));
388        header_cells.push(Cell::from("📄 Description"));
389    }
390
391    let table = Table::new(rows, constraints)
392        .header(
393            Row::new(header_cells)
394                .style(
395                    Style::default()
396                        .fg(app.theme.warning)
397                        .add_modifier(Modifier::BOLD),
398                )
399                .bottom_margin(1),
400        )
401        .block(
402            Block::default()
403                .borders(Borders::ALL)
404                .border_type(BorderType::Rounded)
405                .border_style(Style::default().fg(app.theme.active_border))
406                .title("📊 Work Records")
407                .title_style(
408                    Style::default()
409                        .fg(app.theme.highlight_text)
410                        .add_modifier(Modifier::BOLD),
411                ),
412        );
413
414    // Use stateful rendering to handle scrolling
415    let mut table_state = TableState::default()
416        .with_selected(Some(app.selected_index))
417        .with_offset(scroll_offset);
418
419    frame.render_stateful_widget(table, area, &mut table_state);
420}
421
422fn render_grouped_totals(frame: &mut Frame, area: Rect, app: &AppState) {
423    let grouped = app.day_data.get_grouped_totals();
424
425    if grouped.is_empty() {
426        let paragraph = Paragraph::new("No records yet")
427            .style(Style::default().fg(app.theme.secondary_text))
428            .alignment(Alignment::Center)
429            .block(
430                Block::default()
431                    .borders(Borders::ALL)
432                    .border_type(BorderType::Rounded)
433                    .border_style(Style::default().fg(app.theme.warning))
434                    .title("📈 Summary")
435                    .title_style(
436                        Style::default()
437                            .fg(app.theme.warning)
438                            .add_modifier(Modifier::BOLD),
439                    ),
440            );
441        frame.render_widget(paragraph, area);
442        return;
443    }
444
445    let rows: Vec<Row> = grouped
446        .iter()
447        .map(|(name, minutes)| {
448            let hours = minutes / 60;
449            let mins = minutes % 60;
450
451            // Choose icon based on task type
452            let icon = if name.to_lowercase().contains("break") {
453                "☕"
454            } else if name.to_lowercase().contains("meeting") {
455                "👥"
456            } else if name.to_lowercase().contains("code") || name.to_lowercase().contains("dev") {
457                "💻"
458            } else {
459                "📋"
460            };
461
462            Row::new(vec![
463                Cell::from(format!("{} {}", icon, name)),
464                Cell::from(format!("{}h {:02}m", hours, mins)).style(
465                    Style::default()
466                        .fg(app.theme.badge)
467                        .add_modifier(Modifier::BOLD),
468                ),
469            ])
470        })
471        .collect();
472
473    let table = Table::new(
474        rows,
475        [Constraint::Percentage(65), Constraint::Percentage(35)],
476    )
477    .header(
478        Row::new(vec![Cell::from("Task"), Cell::from("Total")])
479            .style(
480                Style::default()
481                    .fg(app.theme.warning)
482                    .add_modifier(Modifier::BOLD),
483            )
484            .bottom_margin(1),
485    )
486    .block(
487        Block::default()
488            .borders(Borders::ALL)
489            .border_type(BorderType::Rounded)
490            .border_style(Style::default().fg(app.theme.warning))
491            .title("📈 Summary")
492            .title_style(
493                Style::default()
494                    .fg(app.theme.warning)
495                    .add_modifier(Modifier::BOLD),
496            ),
497    );
498
499    frame.render_widget(table, area);
500}
501
502fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) {
503    // Build help text for Browse mode conditionally
504    let browse_help = if app.config.has_integrations() {
505        "↑/↓: Row | ←/→: Field | [/]: Day | C: Calendar | Enter: Edit | c: Change | n: New | b: Break | d: Delete | v: Visual | t: Now | T: Ticket | L: Worklog | S: Session Start/Stop | P: Pause | ?: Help | q: Quit"
506    } else {
507        "↑/↓: Row | ←/→: Field | [/]: Day | C: Calendar | Enter: Edit | c: Change | n: New | b: Break | d: Delete | v: Visual | t: Now | S: Session Start/Stop | P: Pause | ?: Help | q: Quit"
508    };
509
510    let (help_text, mode_color, mode_label) = match app.mode {
511        crate::ui::AppMode::Browse => (browse_help, app.theme.info, "BROWSE"),
512        crate::ui::AppMode::Edit => (
513            "Tab: Next field | Enter/Esc: Save",
514            app.theme.warning,
515            "EDIT",
516        ),
517        crate::ui::AppMode::Visual => (
518            "↑/↓: Extend selection | d: Delete | Esc: Exit visual",
519            app.theme.badge,
520            "VISUAL",
521        ),
522        crate::ui::AppMode::CommandPalette => (
523            "↑/↓: Navigate | Enter: Execute | Esc: Cancel",
524            app.theme.success,
525            "COMMAND PALETTE",
526        ),
527        crate::ui::AppMode::Calendar => (
528            "hjkl/arrows: Navigate | </>: Month | Enter: Select | Esc: Cancel",
529            app.theme.badge,
530            "CALENDAR",
531        ),
532        crate::ui::AppMode::TaskPicker => (
533            "Type: Filter/Create | ↑/↓: Navigate | Enter: Select | Esc: Cancel",
534            app.theme.info,
535            "TASK PICKER",
536        ),
537    };
538
539    let footer = Paragraph::new(help_text)
540        .style(Style::default().fg(app.theme.secondary_text))
541        .alignment(Alignment::Center)
542        .block(
543            Block::default()
544                .borders(Borders::ALL)
545                .border_type(BorderType::Rounded)
546                .border_style(Style::default().fg(mode_color))
547                .title(format!("⌨  {} MODE", mode_label))
548                .title_style(Style::default().fg(mode_color).add_modifier(Modifier::BOLD))
549                .padding(Padding::horizontal(1)),
550        );
551
552    frame.render_widget(footer, area);
553}
554
555fn render_command_palette(frame: &mut Frame, app: &AppState) {
556    use ratatui::widgets::Clear;
557
558    // Create a centered modal
559    let area = frame.size();
560    let width = area.width.min(80);
561    let height = area.height.min(20);
562    let x = (area.width.saturating_sub(width)) / 2;
563    let y = (area.height.saturating_sub(height)) / 2;
564
565    let modal_area = Rect {
566        x,
567        y,
568        width,
569        height,
570    };
571
572    // Clear the background
573    frame.render_widget(Clear, modal_area);
574
575    // Add a background block for the entire modal
576    let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
577    frame.render_widget(bg_block, modal_area);
578
579    // Split modal into input and results
580    let chunks = Layout::default()
581        .direction(Direction::Vertical)
582        .constraints([Constraint::Length(3), Constraint::Min(5)])
583        .split(modal_area);
584
585    // Render search input
586    let input_text = if app.command_palette_input.is_empty() {
587        "Type to search commands...".to_string()
588    } else {
589        app.command_palette_input.clone()
590    };
591
592    let input_style = if app.command_palette_input.is_empty() {
593        Style::default()
594            .fg(app.theme.secondary_text)
595            .bg(app.theme.edit_bg)
596    } else {
597        Style::default()
598            .fg(app.theme.primary_text)
599            .bg(app.theme.edit_bg)
600    };
601
602    let input = Paragraph::new(input_text).style(input_style).block(
603        Block::default()
604            .borders(Borders::ALL)
605            .border_type(BorderType::Rounded)
606            .border_style(Style::default().fg(app.theme.active_border))
607            .title("🔍 Search Commands")
608            .title_style(
609                Style::default()
610                    .fg(app.theme.active_border)
611                    .add_modifier(Modifier::BOLD),
612            )
613            .style(Style::default().bg(app.theme.edit_bg)),
614    );
615
616    frame.render_widget(input, chunks[0]);
617
618    // Render filtered commands
619    let filtered = app.get_filtered_commands();
620
621    let rows: Vec<Row> = filtered
622        .iter()
623        .enumerate()
624        .map(|(i, (_, score, cmd))| {
625            let is_selected = i == app.command_palette_selected;
626
627            let style = if is_selected {
628                Style::default()
629                    .bg(app.theme.selected_bg)
630                    .fg(app.theme.primary_text)
631                    .add_modifier(Modifier::BOLD)
632            } else {
633                Style::default().bg(app.theme.row_alternate_bg)
634            };
635
636            let key_display = format!("  {}  ", cmd.key);
637            let score_display = if *score > 0 {
638                format!(" ({})", score)
639            } else {
640                String::new()
641            };
642
643            Row::new(vec![
644                Cell::from(key_display).style(
645                    Style::default()
646                        .fg(app.theme.success)
647                        .add_modifier(Modifier::BOLD),
648                ),
649                Cell::from(cmd.description).style(Style::default().fg(app.theme.primary_text)),
650                Cell::from(score_display).style(Style::default().fg(app.theme.secondary_text)),
651            ])
652            .style(style)
653        })
654        .collect();
655
656    let results_table = Table::new(
657        rows,
658        [
659            Constraint::Length(15),
660            Constraint::Min(30),
661            Constraint::Length(10),
662        ],
663    )
664    .block(
665        Block::default()
666            .borders(Borders::ALL)
667            .border_type(BorderType::Rounded)
668            .border_style(Style::default().fg(app.theme.active_border))
669            .title(format!("📋 Commands ({} found)", filtered.len()))
670            .title_style(
671                Style::default()
672                    .fg(app.theme.active_border)
673                    .add_modifier(Modifier::BOLD),
674            )
675            .style(Style::default().bg(app.theme.row_alternate_bg)),
676    );
677
678    frame.render_widget(results_table, chunks[1]);
679}
680
681fn render_calendar(frame: &mut Frame, app: &AppState) {
682    use ratatui::widgets::Clear;
683    use time::{Date, Month, Weekday};
684
685    // Create a centered modal
686    let area = frame.size();
687    let width = area.width.min(60);
688    let height = area.height.min(25);
689    let x = (area.width.saturating_sub(width)) / 2;
690    let y = (area.height.saturating_sub(height)) / 2;
691
692    let modal_area = Rect {
693        x,
694        y,
695        width,
696        height,
697    };
698
699    // Clear the background
700    frame.render_widget(Clear, modal_area);
701
702    // Add a background block for the entire modal
703    let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
704    frame.render_widget(bg_block, modal_area);
705
706    // Create the calendar layout
707    let chunks = Layout::default()
708        .direction(Direction::Vertical)
709        .constraints([
710            Constraint::Length(3), // Month/Year header
711            Constraint::Min(15),   // Calendar grid
712        ])
713        .split(modal_area);
714
715    // Render month/year header
716    let month_name = match app.calendar_view_month {
717        Month::January => "January",
718        Month::February => "February",
719        Month::March => "March",
720        Month::April => "April",
721        Month::May => "May",
722        Month::June => "June",
723        Month::July => "July",
724        Month::August => "August",
725        Month::September => "September",
726        Month::October => "October",
727        Month::November => "November",
728        Month::December => "December",
729    };
730
731    let header_text = format!(
732        "📅  {} {}  [< prev] [next >]",
733        month_name, app.calendar_view_year
734    );
735    let header = Paragraph::new(header_text)
736        .style(
737            Style::default()
738                .fg(app.theme.info)
739                .add_modifier(Modifier::BOLD),
740        )
741        .alignment(Alignment::Center)
742        .block(
743            Block::default()
744                .borders(Borders::ALL)
745                .border_type(BorderType::Rounded)
746                .border_style(Style::default().fg(app.theme.info))
747                .style(Style::default().bg(app.theme.edit_bg)),
748        );
749
750    frame.render_widget(header, chunks[0]);
751
752    // Build calendar grid
753    let first_day =
754        Date::from_calendar_date(app.calendar_view_year, app.calendar_view_month, 1).unwrap();
755
756    let days_in_month = get_days_in_month(app.calendar_view_month, app.calendar_view_year);
757    let first_weekday = first_day.weekday();
758
759    // Calculate starting offset (0 = Monday, 6 = Sunday)
760    let offset = match first_weekday {
761        Weekday::Monday => 0,
762        Weekday::Tuesday => 1,
763        Weekday::Wednesday => 2,
764        Weekday::Thursday => 3,
765        Weekday::Friday => 4,
766        Weekday::Saturday => 5,
767        Weekday::Sunday => 6,
768    };
769
770    // Create calendar rows
771    let mut rows = Vec::new();
772
773    // Header row with weekday names
774    rows.push(Row::new(vec![
775        Cell::from("Mon").style(
776            Style::default()
777                .fg(app.theme.warning)
778                .add_modifier(Modifier::BOLD),
779        ),
780        Cell::from("Tue").style(
781            Style::default()
782                .fg(app.theme.warning)
783                .add_modifier(Modifier::BOLD),
784        ),
785        Cell::from("Wed").style(
786            Style::default()
787                .fg(app.theme.warning)
788                .add_modifier(Modifier::BOLD),
789        ),
790        Cell::from("Thu").style(
791            Style::default()
792                .fg(app.theme.warning)
793                .add_modifier(Modifier::BOLD),
794        ),
795        Cell::from("Fri").style(
796            Style::default()
797                .fg(app.theme.warning)
798                .add_modifier(Modifier::BOLD),
799        ),
800        Cell::from("Sat").style(
801            Style::default()
802                .fg(app.theme.info)
803                .add_modifier(Modifier::BOLD),
804        ),
805        Cell::from("Sun").style(
806            Style::default()
807                .fg(app.theme.info)
808                .add_modifier(Modifier::BOLD),
809        ),
810    ]));
811
812    // Calendar days
813    let mut current_day = 1;
814    let mut week_row = Vec::new();
815
816    // Fill in the offset days with empty cells
817    for _ in 0..offset {
818        week_row.push(Cell::from("  "));
819    }
820
821    // Fill in the actual days
822    for day_of_week in offset..7 {
823        if current_day <= days_in_month {
824            let date = Date::from_calendar_date(
825                app.calendar_view_year,
826                app.calendar_view_month,
827                current_day,
828            )
829            .unwrap();
830
831            let is_selected = date == app.calendar_selected_date;
832            let is_today = date == time::OffsetDateTime::now_utc().date();
833            let is_current_view = date == app.current_date;
834
835            let day_str = format!("{:2}", current_day);
836
837            let style = if is_selected {
838                Style::default()
839                    .bg(app.theme.visual_bg)
840                    .fg(app.theme.primary_text)
841                    .add_modifier(Modifier::BOLD)
842            } else if is_current_view {
843                Style::default()
844                    .bg(app.theme.selected_bg)
845                    .fg(app.theme.primary_text)
846                    .add_modifier(Modifier::BOLD)
847            } else if is_today {
848                Style::default()
849                    .fg(app.theme.success)
850                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
851            } else {
852                Style::default().fg(app.theme.primary_text)
853            };
854
855            week_row.push(Cell::from(day_str).style(style));
856            current_day += 1;
857        } else {
858            week_row.push(Cell::from("  "));
859        }
860
861        if day_of_week == 6 {
862            rows.push(Row::new(week_row.clone()));
863            week_row.clear();
864        }
865    }
866
867    // Continue filling remaining weeks
868    while current_day <= days_in_month {
869        for _ in 0..7 {
870            if current_day <= days_in_month {
871                let date = Date::from_calendar_date(
872                    app.calendar_view_year,
873                    app.calendar_view_month,
874                    current_day,
875                )
876                .unwrap();
877
878                let is_selected = date == app.calendar_selected_date;
879                let is_today = date == time::OffsetDateTime::now_utc().date();
880                let is_current_view = date == app.current_date;
881
882                let day_str = format!("{:2}", current_day);
883
884                let style = if is_selected {
885                    Style::default()
886                        .bg(app.theme.visual_bg)
887                        .fg(app.theme.primary_text)
888                        .add_modifier(Modifier::BOLD)
889                } else if is_current_view {
890                    Style::default()
891                        .bg(app.theme.selected_bg)
892                        .fg(app.theme.primary_text)
893                        .add_modifier(Modifier::BOLD)
894                } else if is_today {
895                    Style::default()
896                        .fg(app.theme.success)
897                        .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
898                } else {
899                    Style::default().fg(app.theme.primary_text)
900                };
901
902                week_row.push(Cell::from(day_str).style(style));
903                current_day += 1;
904            } else {
905                week_row.push(Cell::from("  "));
906            }
907        }
908        rows.push(Row::new(week_row.clone()));
909        week_row.clear();
910    }
911
912    let calendar_table = Table::new(
913        rows,
914        [
915            Constraint::Length(6),
916            Constraint::Length(6),
917            Constraint::Length(6),
918            Constraint::Length(6),
919            Constraint::Length(6),
920            Constraint::Length(6),
921            Constraint::Length(6),
922        ],
923    )
924    .block(
925        Block::default()
926            .borders(Borders::ALL)
927            .border_type(BorderType::Rounded)
928            .border_style(Style::default().fg(app.theme.info))
929            .title("📆 Select Date")
930            .title_style(
931                Style::default()
932                    .fg(app.theme.info)
933                    .add_modifier(Modifier::BOLD),
934            )
935            .style(Style::default().bg(app.theme.row_alternate_bg)),
936    );
937
938    frame.render_widget(calendar_table, chunks[1]);
939}
940
941fn get_days_in_month(month: time::Month, year: i32) -> u8 {
942    use time::Month;
943    match month {
944        Month::January
945        | Month::March
946        | Month::May
947        | Month::July
948        | Month::August
949        | Month::October
950        | Month::December => 31,
951        Month::April | Month::June | Month::September | Month::November => 30,
952        Month::February => {
953            if is_leap_year_render(year) {
954                29
955            } else {
956                28
957            }
958        }
959    }
960}
961
962fn is_leap_year_render(year: i32) -> bool {
963    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
964}
965
966fn render_error_modal(frame: &mut Frame, app: &AppState) {
967    use ratatui::text::Line;
968    use ratatui::widgets::Clear;
969
970    // Create a centered modal
971    let area = frame.size();
972    let width = area.width.min(70);
973    let height = 10;
974    let x = (area.width.saturating_sub(width)) / 2;
975    let y = (area.height.saturating_sub(height)) / 2;
976
977    let modal_area = Rect {
978        x,
979        y,
980        width,
981        height,
982    };
983
984    // Clear the background
985    frame.render_widget(Clear, modal_area);
986
987    // Add a background block for the entire modal
988    let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
989    frame.render_widget(bg_block, modal_area);
990
991    // Get error message
992    let error_text = if let Some(ref error_msg) = app.last_error_message {
993        error_msg.clone()
994    } else {
995        "Unknown error".to_string()
996    };
997
998    // Split modal into message and footer
999    let chunks = Layout::default()
1000        .direction(Direction::Vertical)
1001        .constraints([Constraint::Min(5), Constraint::Length(3)])
1002        .split(modal_area);
1003
1004    // Render error message
1005    let lines = vec![
1006        Line::from(""),
1007        Line::from(format!("  {}", error_text)).style(Style::default().fg(app.theme.primary_text)),
1008        Line::from(""),
1009    ];
1010
1011    let error_msg = Paragraph::new(lines).alignment(Alignment::Left).block(
1012        Block::default()
1013            .borders(Borders::ALL)
1014            .border_type(BorderType::Rounded)
1015            .border_style(Style::default().fg(app.theme.error))
1016            .title("❌ ERROR")
1017            .title_style(
1018                Style::default()
1019                    .fg(app.theme.error)
1020                    .add_modifier(Modifier::BOLD),
1021            )
1022            .style(Style::default().bg(app.theme.row_alternate_bg)),
1023    );
1024
1025    frame.render_widget(error_msg, chunks[0]);
1026
1027    // Render help text
1028    let help = Paragraph::new("Press any key to dismiss")
1029        .alignment(Alignment::Center)
1030        .style(Style::default().fg(app.theme.secondary_text));
1031
1032    frame.render_widget(help, chunks[1]);
1033}
1034
1035fn render_task_picker(frame: &mut Frame, app: &AppState) {
1036    use ratatui::widgets::Clear;
1037
1038    let filtered_tasks = app.get_filtered_task_names();
1039    let all_tasks = app.get_unique_task_names();
1040
1041    // Create a smaller centered modal (mini-picker style)
1042    let area = frame.size();
1043    let width = area.width.min(60);
1044    let height = (filtered_tasks.len() as u16 + 8).clamp(12, 20); // Ensure minimum height for visibility
1045    let x = (area.width.saturating_sub(width)) / 2;
1046    let y = (area.height.saturating_sub(height)) / 2;
1047
1048    let modal_area = Rect {
1049        x,
1050        y,
1051        width,
1052        height,
1053    };
1054
1055    // Clear the background
1056    frame.render_widget(Clear, modal_area);
1057
1058    // Add a background block for the entire modal
1059    let bg_block = Block::default().style(Style::default().bg(app.theme.selected_inactive_bg));
1060    frame.render_widget(bg_block, modal_area);
1061
1062    // Split modal into header, input, and list
1063    let chunks = Layout::default()
1064        .direction(Direction::Vertical)
1065        .constraints([
1066            Constraint::Length(3), // Header
1067            Constraint::Length(4), // Input field (increased for better visibility)
1068            Constraint::Min(5),    // List
1069        ])
1070        .split(modal_area);
1071
1072    // Render header with help text
1073    let header_text = if app.input_buffer.is_empty() {
1074        "Select existing task or type new name"
1075    } else {
1076        "Type to filter, or create new task"
1077    };
1078
1079    let header = Paragraph::new(header_text)
1080        .style(Style::default().fg(app.theme.primary_text))
1081        .alignment(Alignment::Center)
1082        .block(
1083            Block::default()
1084                .borders(Borders::ALL)
1085                .border_type(BorderType::Rounded)
1086                .border_style(Style::default().fg(app.theme.info))
1087                .title("📋 Task Picker")
1088                .title_style(
1089                    Style::default()
1090                        .fg(app.theme.info)
1091                        .add_modifier(Modifier::BOLD),
1092                )
1093                .style(Style::default().bg(app.theme.selected_inactive_bg)),
1094        );
1095
1096    frame.render_widget(header, chunks[0]);
1097
1098    // Render input field
1099    let input_display = if app.input_buffer.is_empty() {
1100        "Start typing...".to_string()
1101    } else {
1102        app.input_buffer.clone()
1103    };
1104
1105    let input = Paragraph::new(input_display)
1106        .style(if app.input_buffer.is_empty() {
1107            Style::default().fg(app.theme.secondary_text)
1108        } else {
1109            Style::default()
1110                .fg(app.theme.primary_text)
1111                .add_modifier(Modifier::BOLD)
1112        })
1113        .block(
1114            Block::default()
1115                .borders(Borders::ALL)
1116                .border_type(BorderType::Rounded)
1117                .border_style(Style::default().fg(app.theme.warning))
1118                .title("Filter / New Task")
1119                .title_style(Style::default().fg(app.theme.warning))
1120                .style(Style::default().bg(app.theme.selected_inactive_bg))
1121                .padding(ratatui::widgets::Padding::horizontal(1)),
1122        );
1123
1124    frame.render_widget(input, chunks[1]);
1125
1126    // Render task list
1127    if all_tasks.is_empty() {
1128        let empty_msg = Paragraph::new("No existing tasks. Type to create new one.")
1129            .style(Style::default().fg(app.theme.secondary_text))
1130            .alignment(Alignment::Center)
1131            .block(
1132                Block::default()
1133                    .borders(Borders::ALL)
1134                    .border_type(BorderType::Rounded)
1135                    .border_style(Style::default().fg(app.theme.info))
1136                    .style(Style::default().bg(app.theme.selected_inactive_bg)),
1137            );
1138        frame.render_widget(empty_msg, chunks[2]);
1139    } else if filtered_tasks.is_empty() && !app.input_buffer.is_empty() {
1140        let new_task_msg =
1141            Paragraph::new(format!("Press Enter to create: \"{}\"", app.input_buffer))
1142                .style(
1143                    Style::default()
1144                        .fg(app.theme.success)
1145                        .add_modifier(Modifier::BOLD),
1146                )
1147                .alignment(Alignment::Center)
1148                .block(
1149                    Block::default()
1150                        .borders(Borders::ALL)
1151                        .border_type(BorderType::Rounded)
1152                        .border_style(Style::default().fg(app.theme.success))
1153                        .title("New Task")
1154                        .title_style(
1155                            Style::default()
1156                                .fg(app.theme.success)
1157                                .add_modifier(Modifier::BOLD),
1158                        )
1159                        .style(Style::default().bg(app.theme.selected_inactive_bg)),
1160                );
1161        frame.render_widget(new_task_msg, chunks[2]);
1162    } else {
1163        let rows: Vec<Row> = filtered_tasks
1164            .iter()
1165            .enumerate()
1166            .map(|(i, name)| {
1167                let is_selected = i == app.task_picker_selected;
1168
1169                let style = if is_selected {
1170                    Style::default()
1171                        .bg(app.theme.selected_bg)
1172                        .fg(app.theme.primary_text)
1173                        .add_modifier(Modifier::BOLD)
1174                } else {
1175                    Style::default().bg(app.theme.selected_inactive_bg)
1176                };
1177
1178                // Add icon based on task type
1179                let icon = if name.to_lowercase().contains("break") {
1180                    "☕"
1181                } else if name.to_lowercase().contains("meeting") {
1182                    "👥"
1183                } else if name.to_lowercase().contains("code")
1184                    || name.to_lowercase().contains("dev")
1185                {
1186                    "💻"
1187                } else {
1188                    "📋"
1189                };
1190
1191                let display_name = format!("{} {}", icon, name);
1192
1193                Row::new(vec![
1194                    Cell::from(display_name).style(Style::default().fg(app.theme.primary_text)),
1195                ])
1196                .style(style)
1197            })
1198            .collect();
1199
1200        let title = if app.input_buffer.is_empty() {
1201            format!("Tasks ({} available)", filtered_tasks.len())
1202        } else {
1203            format!("Filtered ({}/{})", filtered_tasks.len(), all_tasks.len())
1204        };
1205
1206        let task_table = Table::new(rows, [Constraint::Percentage(100)]).block(
1207            Block::default()
1208                .borders(Borders::ALL)
1209                .border_type(BorderType::Rounded)
1210                .border_style(Style::default().fg(app.theme.info))
1211                .title(title)
1212                .title_style(
1213                    Style::default()
1214                        .fg(app.theme.info)
1215                        .add_modifier(Modifier::BOLD),
1216                )
1217                .style(Style::default().bg(app.theme.selected_inactive_bg)),
1218        );
1219
1220        frame.render_widget(task_table, chunks[2]);
1221    }
1222}
1223
1224/// Render timer bar showing active timer status at the top of the screen
1225fn render_timer_bar(frame: &mut Frame, area: Rect, app: &AppState) {
1226    use crate::timer::TimerStatus;
1227
1228    if let Some(timer) = &app.active_timer {
1229        // Calculate elapsed time directly without needing storage
1230        let elapsed = calculate_timer_elapsed(timer);
1231
1232        // Format elapsed time
1233        let secs = elapsed.as_secs();
1234        let hours = secs / 3600;
1235        let mins = (secs % 3600) / 60;
1236        let seconds = secs % 60;
1237
1238        let status_icon = match timer.status {
1239            TimerStatus::Running => "▶",
1240            TimerStatus::Paused => "⏸",
1241            TimerStatus::Stopped => "⏹",
1242        };
1243
1244        let timer_text = if hours > 0 {
1245            format!(
1246                "{} {} - {}:{}:{}",
1247                status_icon, timer.task_name, hours, mins, seconds
1248            )
1249        } else {
1250            format!(
1251                "{} {} - {:02}:{:02}",
1252                status_icon, timer.task_name, mins, seconds
1253            )
1254        };
1255
1256        let timer_color = match timer.status {
1257            TimerStatus::Running => app.theme.success,
1258            TimerStatus::Paused => app.theme.warning,
1259            TimerStatus::Stopped => app.theme.error,
1260        };
1261
1262        let timer_paragraph = Paragraph::new(timer_text)
1263            .style(
1264                Style::default()
1265                    .fg(timer_color)
1266                    .add_modifier(Modifier::BOLD),
1267            )
1268            .alignment(Alignment::Center)
1269            .block(
1270                Block::default()
1271                    .borders(Borders::ALL)
1272                    .border_type(BorderType::Rounded)
1273                    .border_style(Style::default().fg(timer_color)),
1274            );
1275
1276        frame.render_widget(timer_paragraph, area);
1277    }
1278}