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
12fn calculate_timer_elapsed(timer: &TimerState) -> StdDuration {
14 let end_point = if timer.status == TimerStatus::Paused {
15 timer.paused_at.unwrap_or_else(|| {
17 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
18 })
19 } else {
20 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 let elapsed_std = StdDuration::from_secs(elapsed.whole_seconds() as u64)
29 + StdDuration::from_nanos(elapsed.subsec_nanoseconds() as u64);
30
31 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 let main_constraints = if app.active_timer.is_some() {
43 vec![
44 Constraint::Length(3), Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ]
49 } else {
50 vec![
51 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ]
55 };
56
57 let chunks = Layout::default()
58 .direction(Direction::Vertical)
59 .constraints(main_constraints)
60 .split(frame.size());
61
62 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 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 if matches!(app.mode, crate::ui::AppMode::CommandPalette) {
110 render_command_palette(frame, app);
111 }
112
113 if matches!(app.mode, crate::ui::AppMode::Calendar) {
115 render_calendar(frame, app);
116 }
117
118 if matches!(app.mode, crate::ui::AppMode::TaskPicker) {
120 render_task_picker(frame, app);
121 }
122
123 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 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 let available_height = area.height.saturating_sub(5) as usize;
187
188 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 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 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 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 let icon = if has_active_timer {
240 "⏱ " } 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 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 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 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 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 frame.render_widget(Clear, modal_area);
579
580 let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
582 frame.render_widget(bg_block, modal_area);
583
584 let chunks = Layout::default()
586 .direction(Direction::Vertical)
587 .constraints([Constraint::Length(3), Constraint::Min(5)])
588 .split(modal_area);
589
590 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 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 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 frame.render_widget(Clear, modal_area);
706
707 let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
709 frame.render_widget(bg_block, modal_area);
710
711 let chunks = Layout::default()
713 .direction(Direction::Vertical)
714 .constraints([
715 Constraint::Length(3), Constraint::Min(15), ])
718 .split(modal_area);
719
720 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 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 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 let mut rows = Vec::new();
777
778 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 let mut current_day = 1;
819 let mut week_row = Vec::new();
820
821 for _ in 0..offset {
823 week_row.push(Cell::from(" "));
824 }
825
826 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 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 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 frame.render_widget(Clear, modal_area);
991
992 let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
994 frame.render_widget(bg_block, modal_area);
995
996 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 let chunks = Layout::default()
1005 .direction(Direction::Vertical)
1006 .constraints([Constraint::Min(5), Constraint::Length(3)])
1007 .split(modal_area);
1008
1009 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 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 let area = frame.size();
1048 let width = area.width.min(60);
1049 let height = (filtered_tasks.len() as u16 + 8).clamp(12, 20); 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 frame.render_widget(Clear, modal_area);
1062
1063 let bg_block = Block::default().style(Style::default().bg(app.theme.selected_inactive_bg));
1065 frame.render_widget(bg_block, modal_area);
1066
1067 let chunks = Layout::default()
1069 .direction(Direction::Vertical)
1070 .constraints([
1071 Constraint::Length(3), Constraint::Length(4), Constraint::Min(5), ])
1075 .split(modal_area);
1076
1077 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 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 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 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
1229fn 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 let elapsed = calculate_timer_elapsed(timer);
1236
1237 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}