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            // Determine display text and styles for each field
249            let (name_display, start_display, end_display, description_display) = if is_editing {
250                match app.edit_field {
251                    crate::ui::EditField::Name => {
252                        // Add cursor indicator to show user is in edit mode
253                        let text_with_cursor = format!("{}▏", app.input_buffer);
254
255                        // Extract and display ticket badge if present and config exists
256                        let display = if app.config.has_integrations() {
257                            if crate::integrations::extract_ticket_from_name(&app.input_buffer)
258                                .is_some()
259                            {
260                                format!("🎫 {} {}", icon, text_with_cursor)
261                            } else {
262                                format!("{} {}", icon, text_with_cursor)
263                            }
264                        } else {
265                            format!("{} {}", icon, text_with_cursor)
266                        };
267                        (
268                            display,
269                            record.start.to_string(),
270                            record.end.to_string(),
271                            record.description.clone(),
272                        )
273                    }
274                    crate::ui::EditField::Description => {
275                        // Add cursor indicator to show user is in edit mode
276                        let description_with_cursor = format!("{}▏", app.input_buffer);
277
278                        // Extract and display ticket badge if present and config exists
279                        let display = if app.config.has_integrations() {
280                            if crate::integrations::extract_ticket_from_name(&record.name).is_some()
281                            {
282                                format!("🎫 {} {}", icon, record.name)
283                            } else {
284                                format!("{} {}", icon, record.name)
285                            }
286                        } else {
287                            format!("{} {}", icon, record.name)
288                        };
289                        (
290                            display,
291                            record.start.to_string(),
292                            record.end.to_string(),
293                            description_with_cursor,
294                        )
295                    }
296                    crate::ui::EditField::Start | crate::ui::EditField::End => {
297                        // Add cursor position indicator for time fields
298                        let time_str = &app.input_buffer;
299                        let positions = [0, 1, 3, 4];
300                        let cursor_pos = if app.time_cursor < positions.len() {
301                            positions[app.time_cursor]
302                        } else {
303                            positions[positions.len() - 1]
304                        };
305
306                        let mut display = String::new();
307                        for (i, ch) in time_str.chars().enumerate() {
308                            if i == cursor_pos {
309                                display.push('[');
310                                display.push(ch);
311                                display.push(']');
312                            } else {
313                                display.push(ch);
314                            }
315                        }
316
317                        // Extract and display ticket badge if present and config exists
318                        let name_with_badge = if app.config.has_integrations() {
319                            if crate::integrations::extract_ticket_from_name(&record.name).is_some()
320                            {
321                                format!("🎫 {} {}", icon, record.name)
322                            } else {
323                                format!("{} {}", icon, record.name)
324                            }
325                        } else {
326                            format!("{} {}", icon, record.name)
327                        };
328
329                        match app.edit_field {
330                            crate::ui::EditField::Start => (
331                                name_with_badge,
332                                display,
333                                record.end.to_string(),
334                                record.description.clone(),
335                            ),
336                            crate::ui::EditField::End => (
337                                name_with_badge,
338                                record.start.to_string(),
339                                display,
340                                record.description.clone(),
341                            ),
342                            _ => unreachable!(),
343                        }
344                    }
345                }
346            } else {
347                // Extract and display ticket badge if present and config exists (non-editing mode)
348                let name_with_badge = if app.config.has_integrations() {
349                    if crate::integrations::extract_ticket_from_name(&record.name).is_some() {
350                        format!("🎫 {} {}", icon, record.name)
351                    } else {
352                        format!("{} {}", icon, record.name)
353                    }
354                } else {
355                    format!("{} {}", icon, record.name)
356                };
357                (
358                    name_with_badge,
359                    record.start.to_string(),
360                    record.end.to_string(),
361                    record.description.clone(),
362                )
363            };
364
365            // Apply styles based on focus and edit state
366            let name_style = if is_editing && matches!(app.edit_field, crate::ui::EditField::Name) {
367                Style::default()
368                    .bg(app.theme.edit_bg)
369                    .fg(app.theme.primary_text)
370                    .add_modifier(Modifier::BOLD)
371            } else if is_selected && matches!(app.edit_field, crate::ui::EditField::Name) {
372                Style::default()
373                    .bg(app.theme.focus_bg)
374                    .fg(app.theme.primary_text)
375                    .add_modifier(Modifier::BOLD)
376            } else {
377                Style::default()
378            };
379
380            let start_style = if is_editing && matches!(app.edit_field, crate::ui::EditField::Start)
381            {
382                Style::default()
383                    .bg(app.theme.edit_bg)
384                    .fg(app.theme.primary_text)
385                    .add_modifier(Modifier::BOLD)
386            } else if is_selected && matches!(app.edit_field, crate::ui::EditField::Start) {
387                Style::default()
388                    .bg(app.theme.focus_bg)
389                    .fg(app.theme.primary_text)
390                    .add_modifier(Modifier::BOLD)
391            } else {
392                Style::default().fg(app.theme.success)
393            };
394
395            let end_style = if is_editing && matches!(app.edit_field, crate::ui::EditField::End) {
396                Style::default()
397                    .bg(app.theme.edit_bg)
398                    .fg(app.theme.primary_text)
399                    .add_modifier(Modifier::BOLD)
400            } else if is_selected && matches!(app.edit_field, crate::ui::EditField::End) {
401                Style::default()
402                    .bg(app.theme.focus_bg)
403                    .fg(app.theme.primary_text)
404                    .add_modifier(Modifier::BOLD)
405            } else {
406                Style::default().fg(app.theme.error)
407            };
408
409            let description_style = if is_editing
410                && matches!(app.edit_field, crate::ui::EditField::Description)
411            {
412                Style::default()
413                    .bg(app.theme.edit_bg)
414                    .fg(app.theme.primary_text)
415                    .add_modifier(Modifier::BOLD)
416            } else if is_selected && matches!(app.edit_field, crate::ui::EditField::Description) {
417                Style::default()
418                    .bg(app.theme.focus_bg)
419                    .fg(app.theme.primary_text)
420                    .add_modifier(Modifier::BOLD)
421            } else {
422                Style::default().fg(app.theme.primary_text)
423            };
424
425            Row::new(vec![
426                Cell::from(name_display).style(name_style),
427                Cell::from(start_display).style(start_style),
428                Cell::from(end_display).style(end_style),
429                Cell::from(record.format_duration()).style(Style::default().fg(app.theme.badge)),
430                Cell::from(description_display).style(description_style),
431            ])
432            .style(style)
433        })
434        .collect();
435
436    let table = Table::new(
437        rows,
438        [
439            Constraint::Percentage(25),
440            Constraint::Length(10),
441            Constraint::Length(10),
442            Constraint::Length(12),
443            Constraint::Percentage(30),
444        ],
445    )
446    .header(
447        Row::new(vec![
448            Cell::from("📝 Task Name"),
449            Cell::from("🕐 Start"),
450            Cell::from("🕐 End"),
451            Cell::from("⏱  Duration"),
452            Cell::from("📄 Description"),
453        ])
454        .style(
455            Style::default()
456                .fg(app.theme.warning)
457                .add_modifier(Modifier::BOLD),
458        )
459        .bottom_margin(1),
460    )
461    .block(
462        Block::default()
463            .borders(Borders::ALL)
464            .border_type(BorderType::Rounded)
465            .border_style(Style::default().fg(app.theme.active_border))
466            .title("📊 Work Records")
467            .title_style(
468                Style::default()
469                    .fg(app.theme.highlight_text)
470                    .add_modifier(Modifier::BOLD),
471            ),
472    );
473
474    // Use stateful rendering to handle scrolling
475    let mut table_state = TableState::default()
476        .with_selected(Some(app.selected_index))
477        .with_offset(scroll_offset);
478
479    frame.render_stateful_widget(table, area, &mut table_state);
480}
481
482fn render_grouped_totals(frame: &mut Frame, area: Rect, app: &AppState) {
483    let grouped = app.day_data.get_grouped_totals();
484
485    if grouped.is_empty() {
486        let paragraph = Paragraph::new("No records yet")
487            .style(Style::default().fg(app.theme.secondary_text))
488            .alignment(Alignment::Center)
489            .block(
490                Block::default()
491                    .borders(Borders::ALL)
492                    .border_type(BorderType::Rounded)
493                    .border_style(Style::default().fg(app.theme.warning))
494                    .title("📈 Summary")
495                    .title_style(
496                        Style::default()
497                            .fg(app.theme.warning)
498                            .add_modifier(Modifier::BOLD),
499                    ),
500            );
501        frame.render_widget(paragraph, area);
502        return;
503    }
504
505    let rows: Vec<Row> = grouped
506        .iter()
507        .map(|(name, minutes)| {
508            let hours = minutes / 60;
509            let mins = minutes % 60;
510
511            // Choose icon based on task type
512            let icon = if name.to_lowercase().contains("break") {
513                "☕"
514            } else if name.to_lowercase().contains("meeting") {
515                "👥"
516            } else if name.to_lowercase().contains("code") || name.to_lowercase().contains("dev") {
517                "💻"
518            } else {
519                "📋"
520            };
521
522            Row::new(vec![
523                Cell::from(format!("{} {}", icon, name)),
524                Cell::from(format!("{}h {:02}m", hours, mins)).style(
525                    Style::default()
526                        .fg(app.theme.badge)
527                        .add_modifier(Modifier::BOLD),
528                ),
529            ])
530        })
531        .collect();
532
533    let table = Table::new(
534        rows,
535        [Constraint::Percentage(65), Constraint::Percentage(35)],
536    )
537    .header(
538        Row::new(vec![Cell::from("Task"), Cell::from("Total")])
539            .style(
540                Style::default()
541                    .fg(app.theme.warning)
542                    .add_modifier(Modifier::BOLD),
543            )
544            .bottom_margin(1),
545    )
546    .block(
547        Block::default()
548            .borders(Borders::ALL)
549            .border_type(BorderType::Rounded)
550            .border_style(Style::default().fg(app.theme.warning))
551            .title("📈 Summary")
552            .title_style(
553                Style::default()
554                    .fg(app.theme.warning)
555                    .add_modifier(Modifier::BOLD),
556            ),
557    );
558
559    frame.render_widget(table, area);
560}
561
562fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) {
563    // Build help text for Browse mode conditionally
564    let browse_help = if app.config.has_integrations() {
565        "↑/↓: 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"
566    } else {
567        "↑/↓: 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"
568    };
569
570    let (help_text, mode_color, mode_label) = match app.mode {
571        crate::ui::AppMode::Browse => (browse_help, app.theme.info, "BROWSE"),
572        crate::ui::AppMode::Edit => (
573            "Tab: Next field | Enter: Save | Esc: Cancel",
574            app.theme.warning,
575            "EDIT",
576        ),
577        crate::ui::AppMode::Visual => (
578            "↑/↓: Extend selection | d: Delete | Esc: Exit visual",
579            app.theme.badge,
580            "VISUAL",
581        ),
582        crate::ui::AppMode::CommandPalette => (
583            "↑/↓: Navigate | Enter: Execute | Esc: Cancel",
584            app.theme.success,
585            "COMMAND PALETTE",
586        ),
587        crate::ui::AppMode::Calendar => (
588            "hjkl/arrows: Navigate | </>: Month | Enter: Select | Esc: Cancel",
589            app.theme.badge,
590            "CALENDAR",
591        ),
592        crate::ui::AppMode::TaskPicker => (
593            "Type: Filter/Create | ↑/↓: Navigate | Enter: Select | Esc: Cancel",
594            app.theme.info,
595            "TASK PICKER",
596        ),
597    };
598
599    let footer = Paragraph::new(help_text)
600        .style(Style::default().fg(app.theme.secondary_text))
601        .alignment(Alignment::Center)
602        .block(
603            Block::default()
604                .borders(Borders::ALL)
605                .border_type(BorderType::Rounded)
606                .border_style(Style::default().fg(mode_color))
607                .title(format!("⌨  {} MODE", mode_label))
608                .title_style(Style::default().fg(mode_color).add_modifier(Modifier::BOLD))
609                .padding(Padding::horizontal(1)),
610        );
611
612    frame.render_widget(footer, area);
613}
614
615fn render_command_palette(frame: &mut Frame, app: &AppState) {
616    use ratatui::widgets::Clear;
617
618    // Create a centered modal
619    let area = frame.size();
620    let width = area.width.min(80);
621    let height = area.height.min(20);
622    let x = (area.width.saturating_sub(width)) / 2;
623    let y = (area.height.saturating_sub(height)) / 2;
624
625    let modal_area = Rect {
626        x,
627        y,
628        width,
629        height,
630    };
631
632    // Clear the background
633    frame.render_widget(Clear, modal_area);
634
635    // Add a background block for the entire modal
636    let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
637    frame.render_widget(bg_block, modal_area);
638
639    // Split modal into input and results
640    let chunks = Layout::default()
641        .direction(Direction::Vertical)
642        .constraints([Constraint::Length(3), Constraint::Min(5)])
643        .split(modal_area);
644
645    // Render search input
646    let input_text = if app.command_palette_input.is_empty() {
647        "Type to search commands...".to_string()
648    } else {
649        app.command_palette_input.clone()
650    };
651
652    let input_style = if app.command_palette_input.is_empty() {
653        Style::default()
654            .fg(app.theme.secondary_text)
655            .bg(app.theme.edit_bg)
656    } else {
657        Style::default()
658            .fg(app.theme.primary_text)
659            .bg(app.theme.edit_bg)
660    };
661
662    let input = Paragraph::new(input_text).style(input_style).block(
663        Block::default()
664            .borders(Borders::ALL)
665            .border_type(BorderType::Rounded)
666            .border_style(Style::default().fg(app.theme.active_border))
667            .title("🔍 Search Commands")
668            .title_style(
669                Style::default()
670                    .fg(app.theme.active_border)
671                    .add_modifier(Modifier::BOLD),
672            )
673            .style(Style::default().bg(app.theme.edit_bg)),
674    );
675
676    frame.render_widget(input, chunks[0]);
677
678    // Render filtered commands
679    let filtered = app.get_filtered_commands();
680
681    let rows: Vec<Row> = filtered
682        .iter()
683        .enumerate()
684        .map(|(i, (_, score, cmd))| {
685            let is_selected = i == app.command_palette_selected;
686
687            let style = if is_selected {
688                Style::default()
689                    .bg(app.theme.selected_bg)
690                    .fg(app.theme.primary_text)
691                    .add_modifier(Modifier::BOLD)
692            } else {
693                Style::default().bg(app.theme.row_alternate_bg)
694            };
695
696            let key_display = format!("  {}  ", cmd.key);
697            let score_display = if *score > 0 {
698                format!(" ({})", score)
699            } else {
700                String::new()
701            };
702
703            Row::new(vec![
704                Cell::from(key_display).style(
705                    Style::default()
706                        .fg(app.theme.success)
707                        .add_modifier(Modifier::BOLD),
708                ),
709                Cell::from(cmd.description).style(Style::default().fg(app.theme.primary_text)),
710                Cell::from(score_display).style(Style::default().fg(app.theme.secondary_text)),
711            ])
712            .style(style)
713        })
714        .collect();
715
716    let results_table = Table::new(
717        rows,
718        [
719            Constraint::Length(15),
720            Constraint::Min(30),
721            Constraint::Length(10),
722        ],
723    )
724    .block(
725        Block::default()
726            .borders(Borders::ALL)
727            .border_type(BorderType::Rounded)
728            .border_style(Style::default().fg(app.theme.active_border))
729            .title(format!("📋 Commands ({} found)", filtered.len()))
730            .title_style(
731                Style::default()
732                    .fg(app.theme.active_border)
733                    .add_modifier(Modifier::BOLD),
734            )
735            .style(Style::default().bg(app.theme.row_alternate_bg)),
736    );
737
738    frame.render_widget(results_table, chunks[1]);
739}
740
741fn render_calendar(frame: &mut Frame, app: &AppState) {
742    use ratatui::widgets::Clear;
743    use time::{Date, Month, Weekday};
744
745    // Create a centered modal
746    let area = frame.size();
747    let width = area.width.min(60);
748    let height = area.height.min(25);
749    let x = (area.width.saturating_sub(width)) / 2;
750    let y = (area.height.saturating_sub(height)) / 2;
751
752    let modal_area = Rect {
753        x,
754        y,
755        width,
756        height,
757    };
758
759    // Clear the background
760    frame.render_widget(Clear, modal_area);
761
762    // Add a background block for the entire modal
763    let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
764    frame.render_widget(bg_block, modal_area);
765
766    // Create the calendar layout
767    let chunks = Layout::default()
768        .direction(Direction::Vertical)
769        .constraints([
770            Constraint::Length(3), // Month/Year header
771            Constraint::Min(15),   // Calendar grid
772        ])
773        .split(modal_area);
774
775    // Render month/year header
776    let month_name = match app.calendar_view_month {
777        Month::January => "January",
778        Month::February => "February",
779        Month::March => "March",
780        Month::April => "April",
781        Month::May => "May",
782        Month::June => "June",
783        Month::July => "July",
784        Month::August => "August",
785        Month::September => "September",
786        Month::October => "October",
787        Month::November => "November",
788        Month::December => "December",
789    };
790
791    let header_text = format!(
792        "📅  {} {}  [< prev] [next >]",
793        month_name, app.calendar_view_year
794    );
795    let header = Paragraph::new(header_text)
796        .style(
797            Style::default()
798                .fg(app.theme.info)
799                .add_modifier(Modifier::BOLD),
800        )
801        .alignment(Alignment::Center)
802        .block(
803            Block::default()
804                .borders(Borders::ALL)
805                .border_type(BorderType::Rounded)
806                .border_style(Style::default().fg(app.theme.info))
807                .style(Style::default().bg(app.theme.edit_bg)),
808        );
809
810    frame.render_widget(header, chunks[0]);
811
812    // Build calendar grid
813    let first_day =
814        Date::from_calendar_date(app.calendar_view_year, app.calendar_view_month, 1).unwrap();
815
816    let days_in_month = get_days_in_month(app.calendar_view_month, app.calendar_view_year);
817    let first_weekday = first_day.weekday();
818
819    // Calculate starting offset (0 = Monday, 6 = Sunday)
820    let offset = match first_weekday {
821        Weekday::Monday => 0,
822        Weekday::Tuesday => 1,
823        Weekday::Wednesday => 2,
824        Weekday::Thursday => 3,
825        Weekday::Friday => 4,
826        Weekday::Saturday => 5,
827        Weekday::Sunday => 6,
828    };
829
830    // Create calendar rows
831    let mut rows = Vec::new();
832
833    // Header row with weekday names
834    rows.push(Row::new(vec![
835        Cell::from("Mon").style(
836            Style::default()
837                .fg(app.theme.warning)
838                .add_modifier(Modifier::BOLD),
839        ),
840        Cell::from("Tue").style(
841            Style::default()
842                .fg(app.theme.warning)
843                .add_modifier(Modifier::BOLD),
844        ),
845        Cell::from("Wed").style(
846            Style::default()
847                .fg(app.theme.warning)
848                .add_modifier(Modifier::BOLD),
849        ),
850        Cell::from("Thu").style(
851            Style::default()
852                .fg(app.theme.warning)
853                .add_modifier(Modifier::BOLD),
854        ),
855        Cell::from("Fri").style(
856            Style::default()
857                .fg(app.theme.warning)
858                .add_modifier(Modifier::BOLD),
859        ),
860        Cell::from("Sat").style(
861            Style::default()
862                .fg(app.theme.info)
863                .add_modifier(Modifier::BOLD),
864        ),
865        Cell::from("Sun").style(
866            Style::default()
867                .fg(app.theme.info)
868                .add_modifier(Modifier::BOLD),
869        ),
870    ]));
871
872    // Calendar days
873    let mut current_day = 1;
874    let mut week_row = Vec::new();
875
876    // Fill in the offset days with empty cells
877    for _ in 0..offset {
878        week_row.push(Cell::from("  "));
879    }
880
881    // Fill in the actual days
882    for day_of_week in offset..7 {
883        if current_day <= days_in_month {
884            let date = Date::from_calendar_date(
885                app.calendar_view_year,
886                app.calendar_view_month,
887                current_day,
888            )
889            .unwrap();
890
891            let is_selected = date == app.calendar_selected_date;
892            let is_today = date == time::OffsetDateTime::now_utc().date();
893            let is_current_view = date == app.current_date;
894
895            let day_str = format!("{:2}", current_day);
896
897            let style = if is_selected {
898                Style::default()
899                    .bg(app.theme.visual_bg)
900                    .fg(app.theme.primary_text)
901                    .add_modifier(Modifier::BOLD)
902            } else if is_current_view {
903                Style::default()
904                    .bg(app.theme.selected_bg)
905                    .fg(app.theme.primary_text)
906                    .add_modifier(Modifier::BOLD)
907            } else if is_today {
908                Style::default()
909                    .fg(app.theme.success)
910                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
911            } else {
912                Style::default().fg(app.theme.primary_text)
913            };
914
915            week_row.push(Cell::from(day_str).style(style));
916            current_day += 1;
917        } else {
918            week_row.push(Cell::from("  "));
919        }
920
921        if day_of_week == 6 {
922            rows.push(Row::new(week_row.clone()));
923            week_row.clear();
924        }
925    }
926
927    // Continue filling remaining weeks
928    while current_day <= days_in_month {
929        for _ in 0..7 {
930            if current_day <= days_in_month {
931                let date = Date::from_calendar_date(
932                    app.calendar_view_year,
933                    app.calendar_view_month,
934                    current_day,
935                )
936                .unwrap();
937
938                let is_selected = date == app.calendar_selected_date;
939                let is_today = date == time::OffsetDateTime::now_utc().date();
940                let is_current_view = date == app.current_date;
941
942                let day_str = format!("{:2}", current_day);
943
944                let style = if is_selected {
945                    Style::default()
946                        .bg(app.theme.visual_bg)
947                        .fg(app.theme.primary_text)
948                        .add_modifier(Modifier::BOLD)
949                } else if is_current_view {
950                    Style::default()
951                        .bg(app.theme.selected_bg)
952                        .fg(app.theme.primary_text)
953                        .add_modifier(Modifier::BOLD)
954                } else if is_today {
955                    Style::default()
956                        .fg(app.theme.success)
957                        .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
958                } else {
959                    Style::default().fg(app.theme.primary_text)
960                };
961
962                week_row.push(Cell::from(day_str).style(style));
963                current_day += 1;
964            } else {
965                week_row.push(Cell::from("  "));
966            }
967        }
968        rows.push(Row::new(week_row.clone()));
969        week_row.clear();
970    }
971
972    let calendar_table = Table::new(
973        rows,
974        [
975            Constraint::Length(6),
976            Constraint::Length(6),
977            Constraint::Length(6),
978            Constraint::Length(6),
979            Constraint::Length(6),
980            Constraint::Length(6),
981            Constraint::Length(6),
982        ],
983    )
984    .block(
985        Block::default()
986            .borders(Borders::ALL)
987            .border_type(BorderType::Rounded)
988            .border_style(Style::default().fg(app.theme.info))
989            .title("📆 Select Date")
990            .title_style(
991                Style::default()
992                    .fg(app.theme.info)
993                    .add_modifier(Modifier::BOLD),
994            )
995            .style(Style::default().bg(app.theme.row_alternate_bg)),
996    );
997
998    frame.render_widget(calendar_table, chunks[1]);
999}
1000
1001fn get_days_in_month(month: time::Month, year: i32) -> u8 {
1002    use time::Month;
1003    match month {
1004        Month::January
1005        | Month::March
1006        | Month::May
1007        | Month::July
1008        | Month::August
1009        | Month::October
1010        | Month::December => 31,
1011        Month::April | Month::June | Month::September | Month::November => 30,
1012        Month::February => {
1013            if is_leap_year_render(year) {
1014                29
1015            } else {
1016                28
1017            }
1018        }
1019    }
1020}
1021
1022fn is_leap_year_render(year: i32) -> bool {
1023    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1024}
1025
1026fn render_error_modal(frame: &mut Frame, app: &AppState) {
1027    use ratatui::text::Line;
1028    use ratatui::widgets::Clear;
1029
1030    // Create a centered modal
1031    let area = frame.size();
1032    let width = area.width.min(70);
1033    let height = 10;
1034    let x = (area.width.saturating_sub(width)) / 2;
1035    let y = (area.height.saturating_sub(height)) / 2;
1036
1037    let modal_area = Rect {
1038        x,
1039        y,
1040        width,
1041        height,
1042    };
1043
1044    // Clear the background
1045    frame.render_widget(Clear, modal_area);
1046
1047    // Add a background block for the entire modal
1048    let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
1049    frame.render_widget(bg_block, modal_area);
1050
1051    // Get error message
1052    let error_text = if let Some(ref error_msg) = app.last_error_message {
1053        error_msg.clone()
1054    } else {
1055        "Unknown error".to_string()
1056    };
1057
1058    // Split modal into message and footer
1059    let chunks = Layout::default()
1060        .direction(Direction::Vertical)
1061        .constraints([Constraint::Min(5), Constraint::Length(3)])
1062        .split(modal_area);
1063
1064    // Render error message
1065    let lines = vec![
1066        Line::from(""),
1067        Line::from(format!("  {}", error_text)).style(Style::default().fg(app.theme.primary_text)),
1068        Line::from(""),
1069    ];
1070
1071    let error_msg = Paragraph::new(lines).alignment(Alignment::Left).block(
1072        Block::default()
1073            .borders(Borders::ALL)
1074            .border_type(BorderType::Rounded)
1075            .border_style(Style::default().fg(app.theme.error))
1076            .title("❌ ERROR")
1077            .title_style(
1078                Style::default()
1079                    .fg(app.theme.error)
1080                    .add_modifier(Modifier::BOLD),
1081            )
1082            .style(Style::default().bg(app.theme.row_alternate_bg)),
1083    );
1084
1085    frame.render_widget(error_msg, chunks[0]);
1086
1087    // Render help text
1088    let help = Paragraph::new("Press any key to dismiss")
1089        .alignment(Alignment::Center)
1090        .style(Style::default().fg(app.theme.secondary_text));
1091
1092    frame.render_widget(help, chunks[1]);
1093}
1094
1095fn render_task_picker(frame: &mut Frame, app: &AppState) {
1096    use ratatui::widgets::Clear;
1097
1098    let filtered_tasks = app.get_filtered_task_names();
1099    let all_tasks = app.get_unique_task_names();
1100
1101    // Create a smaller centered modal (mini-picker style)
1102    let area = frame.size();
1103    let width = area.width.min(60);
1104    let height = (filtered_tasks.len() as u16 + 8).clamp(12, 20); // Ensure minimum height for visibility
1105    let x = (area.width.saturating_sub(width)) / 2;
1106    let y = (area.height.saturating_sub(height)) / 2;
1107
1108    let modal_area = Rect {
1109        x,
1110        y,
1111        width,
1112        height,
1113    };
1114
1115    // Clear the background
1116    frame.render_widget(Clear, modal_area);
1117
1118    // Add a background block for the entire modal
1119    let bg_block = Block::default().style(Style::default().bg(app.theme.selected_inactive_bg));
1120    frame.render_widget(bg_block, modal_area);
1121
1122    // Split modal into header, input, and list
1123    let chunks = Layout::default()
1124        .direction(Direction::Vertical)
1125        .constraints([
1126            Constraint::Length(3), // Header
1127            Constraint::Length(4), // Input field (increased for better visibility)
1128            Constraint::Min(5),    // List
1129        ])
1130        .split(modal_area);
1131
1132    // Render header with help text
1133    let header_text = if app.input_buffer.is_empty() {
1134        "Select existing task or type new name"
1135    } else {
1136        "Type to filter, or create new task"
1137    };
1138
1139    let header = Paragraph::new(header_text)
1140        .style(Style::default().fg(app.theme.primary_text))
1141        .alignment(Alignment::Center)
1142        .block(
1143            Block::default()
1144                .borders(Borders::ALL)
1145                .border_type(BorderType::Rounded)
1146                .border_style(Style::default().fg(app.theme.info))
1147                .title("📋 Task Picker")
1148                .title_style(
1149                    Style::default()
1150                        .fg(app.theme.info)
1151                        .add_modifier(Modifier::BOLD),
1152                )
1153                .style(Style::default().bg(app.theme.selected_inactive_bg)),
1154        );
1155
1156    frame.render_widget(header, chunks[0]);
1157
1158    // Render input field
1159    let input_display = if app.input_buffer.is_empty() {
1160        "Start typing...".to_string()
1161    } else {
1162        app.input_buffer.clone()
1163    };
1164
1165    let input = Paragraph::new(input_display)
1166        .style(if app.input_buffer.is_empty() {
1167            Style::default().fg(app.theme.secondary_text)
1168        } else {
1169            Style::default()
1170                .fg(app.theme.primary_text)
1171                .add_modifier(Modifier::BOLD)
1172        })
1173        .block(
1174            Block::default()
1175                .borders(Borders::ALL)
1176                .border_type(BorderType::Rounded)
1177                .border_style(Style::default().fg(app.theme.warning))
1178                .title("Filter / New Task")
1179                .title_style(Style::default().fg(app.theme.warning))
1180                .style(Style::default().bg(app.theme.selected_inactive_bg))
1181                .padding(ratatui::widgets::Padding::horizontal(1)),
1182        );
1183
1184    frame.render_widget(input, chunks[1]);
1185
1186    // Render task list
1187    if all_tasks.is_empty() {
1188        let empty_msg = Paragraph::new("No existing tasks. Type to create new one.")
1189            .style(Style::default().fg(app.theme.secondary_text))
1190            .alignment(Alignment::Center)
1191            .block(
1192                Block::default()
1193                    .borders(Borders::ALL)
1194                    .border_type(BorderType::Rounded)
1195                    .border_style(Style::default().fg(app.theme.info))
1196                    .style(Style::default().bg(app.theme.selected_inactive_bg)),
1197            );
1198        frame.render_widget(empty_msg, chunks[2]);
1199    } else if filtered_tasks.is_empty() && !app.input_buffer.is_empty() {
1200        let new_task_msg =
1201            Paragraph::new(format!("Press Enter to create: \"{}\"", app.input_buffer))
1202                .style(
1203                    Style::default()
1204                        .fg(app.theme.success)
1205                        .add_modifier(Modifier::BOLD),
1206                )
1207                .alignment(Alignment::Center)
1208                .block(
1209                    Block::default()
1210                        .borders(Borders::ALL)
1211                        .border_type(BorderType::Rounded)
1212                        .border_style(Style::default().fg(app.theme.success))
1213                        .title("New Task")
1214                        .title_style(
1215                            Style::default()
1216                                .fg(app.theme.success)
1217                                .add_modifier(Modifier::BOLD),
1218                        )
1219                        .style(Style::default().bg(app.theme.selected_inactive_bg)),
1220                );
1221        frame.render_widget(new_task_msg, chunks[2]);
1222    } else {
1223        let rows: Vec<Row> = filtered_tasks
1224            .iter()
1225            .enumerate()
1226            .map(|(i, name)| {
1227                let is_selected = i == app.task_picker_selected;
1228
1229                let style = if is_selected {
1230                    Style::default()
1231                        .bg(app.theme.selected_bg)
1232                        .fg(app.theme.primary_text)
1233                        .add_modifier(Modifier::BOLD)
1234                } else {
1235                    Style::default().bg(app.theme.selected_inactive_bg)
1236                };
1237
1238                // Add icon based on task type
1239                let icon = if name.to_lowercase().contains("break") {
1240                    "☕"
1241                } else if name.to_lowercase().contains("meeting") {
1242                    "👥"
1243                } else if name.to_lowercase().contains("code")
1244                    || name.to_lowercase().contains("dev")
1245                {
1246                    "💻"
1247                } else {
1248                    "📋"
1249                };
1250
1251                let display_name = format!("{} {}", icon, name);
1252
1253                Row::new(vec![
1254                    Cell::from(display_name).style(Style::default().fg(app.theme.primary_text)),
1255                ])
1256                .style(style)
1257            })
1258            .collect();
1259
1260        let title = if app.input_buffer.is_empty() {
1261            format!("Tasks ({} available)", filtered_tasks.len())
1262        } else {
1263            format!("Filtered ({}/{})", filtered_tasks.len(), all_tasks.len())
1264        };
1265
1266        let task_table = Table::new(rows, [Constraint::Percentage(100)]).block(
1267            Block::default()
1268                .borders(Borders::ALL)
1269                .border_type(BorderType::Rounded)
1270                .border_style(Style::default().fg(app.theme.info))
1271                .title(title)
1272                .title_style(
1273                    Style::default()
1274                        .fg(app.theme.info)
1275                        .add_modifier(Modifier::BOLD),
1276                )
1277                .style(Style::default().bg(app.theme.selected_inactive_bg)),
1278        );
1279
1280        frame.render_widget(task_table, chunks[2]);
1281    }
1282}
1283
1284/// Render timer bar showing active timer status at the top of the screen
1285fn render_timer_bar(frame: &mut Frame, area: Rect, app: &AppState) {
1286    use crate::timer::TimerStatus;
1287
1288    if let Some(timer) = &app.active_timer {
1289        // Calculate elapsed time directly without needing storage
1290        let elapsed = calculate_timer_elapsed(timer);
1291
1292        // Format elapsed time
1293        let secs = elapsed.as_secs();
1294        let hours = secs / 3600;
1295        let mins = (secs % 3600) / 60;
1296        let seconds = secs % 60;
1297
1298        let status_icon = match timer.status {
1299            TimerStatus::Running => "▶",
1300            TimerStatus::Paused => "⏸",
1301            TimerStatus::Stopped => "⏹",
1302        };
1303
1304        let timer_text = if hours > 0 {
1305            format!(
1306                "{} {} - {}:{}:{}",
1307                status_icon, timer.task_name, hours, mins, seconds
1308            )
1309        } else {
1310            format!(
1311                "{} {} - {:02}:{:02}",
1312                status_icon, timer.task_name, mins, seconds
1313            )
1314        };
1315
1316        let timer_color = match timer.status {
1317            TimerStatus::Running => app.theme.success,
1318            TimerStatus::Paused => app.theme.warning,
1319            TimerStatus::Stopped => app.theme.error,
1320        };
1321
1322        let timer_paragraph = Paragraph::new(timer_text)
1323            .style(
1324                Style::default()
1325                    .fg(timer_color)
1326                    .add_modifier(Modifier::BOLD),
1327            )
1328            .alignment(Alignment::Center)
1329            .block(
1330                Block::default()
1331                    .borders(Borders::ALL)
1332                    .border_type(BorderType::Rounded)
1333                    .border_style(Style::default().fg(timer_color)),
1334            );
1335
1336        frame.render_widget(timer_paragraph, area);
1337    }
1338}