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