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
35pub fn render(frame: &mut Frame, app: &AppState) {
36 let main_constraints = if app.active_timer.is_some() {
38 vec![
39 Constraint::Length(3), Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ]
44 } else {
45 vec![
46 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ]
50 };
51
52 let chunks = Layout::default()
53 .direction(Direction::Vertical)
54 .constraints(main_constraints)
55 .split(frame.size());
56
57 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 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 if matches!(app.mode, crate::ui::AppMode::CommandPalette) {
105 render_command_palette(frame, app);
106 }
107
108 if matches!(app.mode, crate::ui::AppMode::Calendar) {
110 render_calendar(frame, app);
111 }
112
113 if matches!(app.mode, crate::ui::AppMode::TaskPicker) {
115 render_task_picker(frame, app);
116 }
117
118 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 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 let available_height = area.height.saturating_sub(5) as usize;
182
183 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 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 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 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 let icon = if has_active_timer {
235 "⏱ " } else if record.name.to_lowercase().contains("break") {
237 "☕"
238 } else if record.name.to_lowercase().contains("meeting") {
239 "👥"
240 } else if record.name.to_lowercase().contains("code")
241 || record.name.to_lowercase().contains("dev")
242 {
243 "💻"
244 } else {
245 "📋"
246 };
247
248 let (name_display, start_display, end_display, description_display) = if is_editing {
250 match app.edit_field {
251 crate::ui::EditField::Name => {
252 let text_with_cursor = format!("{}▏", app.input_buffer);
254
255 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 let description_with_cursor = format!("{}▏", app.input_buffer);
277
278 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 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 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 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 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 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 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 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 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 frame.render_widget(Clear, modal_area);
634
635 let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
637 frame.render_widget(bg_block, modal_area);
638
639 let chunks = Layout::default()
641 .direction(Direction::Vertical)
642 .constraints([Constraint::Length(3), Constraint::Min(5)])
643 .split(modal_area);
644
645 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 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 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 frame.render_widget(Clear, modal_area);
761
762 let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
764 frame.render_widget(bg_block, modal_area);
765
766 let chunks = Layout::default()
768 .direction(Direction::Vertical)
769 .constraints([
770 Constraint::Length(3), Constraint::Min(15), ])
773 .split(modal_area);
774
775 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 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 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 let mut rows = Vec::new();
832
833 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 let mut current_day = 1;
874 let mut week_row = Vec::new();
875
876 for _ in 0..offset {
878 week_row.push(Cell::from(" "));
879 }
880
881 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 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 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 frame.render_widget(Clear, modal_area);
1046
1047 let bg_block = Block::default().style(Style::default().bg(app.theme.row_alternate_bg));
1049 frame.render_widget(bg_block, modal_area);
1050
1051 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 let chunks = Layout::default()
1060 .direction(Direction::Vertical)
1061 .constraints([Constraint::Min(5), Constraint::Length(3)])
1062 .split(modal_area);
1063
1064 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 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 let area = frame.size();
1103 let width = area.width.min(60);
1104 let height = (filtered_tasks.len() as u16 + 8).clamp(12, 20); 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 frame.render_widget(Clear, modal_area);
1117
1118 let bg_block = Block::default().style(Style::default().bg(app.theme.selected_inactive_bg));
1120 frame.render_widget(bg_block, modal_area);
1121
1122 let chunks = Layout::default()
1124 .direction(Direction::Vertical)
1125 .constraints([
1126 Constraint::Length(3), Constraint::Length(4), Constraint::Min(5), ])
1130 .split(modal_area);
1131
1132 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 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 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 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
1284fn 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 let elapsed = calculate_timer_elapsed(timer);
1291
1292 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}