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