work_tuimer/ui/
app_state.rs

1use super::history::History;
2use crate::config::{Config, Theme};
3use crate::models::{DayData, WorkRecord};
4use crate::timer::TimerState;
5use time::Date;
6
7pub enum AppMode {
8    Browse,
9    Edit,
10    Visual,
11    CommandPalette,
12    Calendar,
13    TaskPicker,
14}
15
16pub enum EditField {
17    Name,
18    Start,
19    End,
20    Description,
21}
22
23pub struct Command {
24    pub key: &'static str,
25    pub description: &'static str,
26    pub action: CommandAction,
27}
28
29#[derive(Debug, Clone, Copy)]
30pub enum CommandAction {
31    MoveUp,
32    MoveDown,
33    MoveLeft,
34    MoveRight,
35    Edit,
36    Change,
37    New,
38    Break,
39    Delete,
40    Visual,
41    SetNow,
42    Undo,
43    Redo,
44    Save,
45    StartTimer,
46    PauseTimer,
47    Quit,
48}
49
50pub struct AppState {
51    pub day_data: DayData,
52    pub current_date: Date,
53    pub mode: AppMode,
54    pub selected_index: usize,
55    pub edit_field: EditField,
56    pub input_buffer: String,
57    pub time_cursor: usize,
58    pub should_quit: bool,
59    pub visual_start: usize,
60    pub visual_end: usize,
61    pub command_palette_input: String,
62    pub command_palette_selected: usize,
63    pub available_commands: Vec<Command>,
64    pub date_changed: bool,
65    pub calendar_selected_date: Date,
66    pub calendar_view_month: time::Month,
67    pub calendar_view_year: i32,
68    pub config: Config,
69    pub theme: Theme,
70    pub last_error_message: Option<String>,
71    pub task_picker_selected: usize,
72    pub active_timer: Option<TimerState>,
73    pub last_file_modified: Option<std::time::SystemTime>,
74    history: History,
75}
76
77impl AppState {
78    pub fn new(day_data: DayData) -> Self {
79        let current_date = day_data.date;
80        let available_commands = vec![
81            Command {
82                key: "↑/k",
83                description: "Move selection up",
84                action: CommandAction::MoveUp,
85            },
86            Command {
87                key: "↓/j",
88                description: "Move selection down",
89                action: CommandAction::MoveDown,
90            },
91            Command {
92                key: "←/h",
93                description: "Move field left",
94                action: CommandAction::MoveLeft,
95            },
96            Command {
97                key: "→/l",
98                description: "Move field right",
99                action: CommandAction::MoveRight,
100            },
101            Command {
102                key: "Enter/i",
103                description: "Enter edit mode",
104                action: CommandAction::Edit,
105            },
106            Command {
107                key: "c",
108                description: "Change task name",
109                action: CommandAction::Change,
110            },
111            Command {
112                key: "n",
113                description: "Add new task",
114                action: CommandAction::New,
115            },
116            Command {
117                key: "b",
118                description: "Add break",
119                action: CommandAction::Break,
120            },
121            Command {
122                key: "d",
123                description: "Delete selected record",
124                action: CommandAction::Delete,
125            },
126            Command {
127                key: "v",
128                description: "Enter visual mode",
129                action: CommandAction::Visual,
130            },
131            Command {
132                key: "t",
133                description: "Set current time on field",
134                action: CommandAction::SetNow,
135            },
136            Command {
137                key: "u",
138                description: "Undo last change",
139                action: CommandAction::Undo,
140            },
141            Command {
142                key: "r",
143                description: "Redo last change",
144                action: CommandAction::Redo,
145            },
146            Command {
147                key: "s",
148                description: "Save to file",
149                action: CommandAction::Save,
150            },
151            Command {
152                key: "S",
153                description: "Start/Stop session (toggle)",
154                action: CommandAction::StartTimer,
155            },
156            Command {
157                key: "P",
158                description: "Pause/Resume active session",
159                action: CommandAction::PauseTimer,
160            },
161            Command {
162                key: "q",
163                description: "Quit application",
164                action: CommandAction::Quit,
165            },
166        ];
167
168        let config = Config::load().unwrap_or_default();
169        let theme = config.get_theme();
170
171        AppState {
172            calendar_selected_date: current_date,
173            calendar_view_month: current_date.month(),
174            calendar_view_year: current_date.year(),
175            day_data,
176            current_date,
177            mode: AppMode::Browse,
178            selected_index: 0,
179            edit_field: EditField::Name,
180            input_buffer: String::new(),
181            time_cursor: 0,
182            should_quit: false,
183            visual_start: 0,
184            visual_end: 0,
185            command_palette_input: String::new(),
186            command_palette_selected: 0,
187            available_commands,
188            date_changed: false,
189            config,
190            theme,
191            last_error_message: None,
192            task_picker_selected: 0,
193            active_timer: None,
194            last_file_modified: None,
195            history: History::new(),
196        }
197    }
198
199    pub fn get_selected_record(&self) -> Option<&WorkRecord> {
200        let records = self.day_data.get_sorted_records();
201        records.get(self.selected_index).copied()
202    }
203
204    pub fn move_selection_up(&mut self) {
205        if self.selected_index > 0 {
206            self.selected_index -= 1;
207        }
208        if matches!(self.mode, AppMode::Visual) {
209            self.visual_end = self.selected_index;
210        }
211    }
212
213    pub fn move_selection_down(&mut self) {
214        let record_count = self.day_data.work_records.len();
215        if self.selected_index < record_count.saturating_sub(1) {
216            self.selected_index += 1;
217        }
218        if matches!(self.mode, AppMode::Visual) {
219            self.visual_end = self.selected_index;
220        }
221    }
222
223    pub fn enter_edit_mode(&mut self) {
224        if let Some(record) = self.get_selected_record() {
225            let input_value = match self.edit_field {
226                EditField::Name => record.name.clone(),
227                EditField::Start => record.start.to_string(),
228                EditField::End => record.end.to_string(),
229                EditField::Description => record.description.clone(),
230            };
231            self.mode = AppMode::Edit;
232            self.input_buffer = input_value;
233            self.time_cursor = 0;
234        }
235    }
236
237    pub fn change_task_name(&mut self) {
238        if matches!(self.edit_field, EditField::Name) && self.get_selected_record().is_some() {
239            // Check if there are any existing tasks to pick from
240            let task_names = self.get_unique_task_names();
241            if !task_names.is_empty() {
242                // Open task picker if tasks exist
243                self.input_buffer.clear();
244                self.task_picker_selected = 0;
245                self.mode = AppMode::TaskPicker;
246            } else {
247                // Go directly to edit mode if no tasks exist
248                self.mode = AppMode::Edit;
249                self.input_buffer.clear();
250                self.time_cursor = 0;
251            }
252        }
253    }
254
255    pub fn exit_edit_mode(&mut self) {
256        self.mode = AppMode::Browse;
257        self.input_buffer.clear();
258        self.edit_field = EditField::Name;
259        self.time_cursor = 0;
260    }
261
262    pub fn next_field(&mut self) {
263        if let Some(record) = self.get_selected_record() {
264            self.edit_field = match self.edit_field {
265                EditField::Name => {
266                    self.input_buffer = record.start.to_string();
267                    self.time_cursor = 0;
268                    EditField::Start
269                }
270                EditField::Start => {
271                    self.input_buffer = record.end.to_string();
272                    self.time_cursor = 0;
273                    EditField::End
274                }
275                EditField::End => {
276                    self.input_buffer = record.description.clone();
277                    self.time_cursor = 0;
278                    EditField::Description
279                }
280                EditField::Description => {
281                    self.input_buffer = record.name.clone();
282                    self.time_cursor = 0;
283                    EditField::Name
284                }
285            };
286        }
287    }
288
289    pub fn handle_char_input(&mut self, c: char) {
290        match self.edit_field {
291            EditField::Name | EditField::Description => {
292                self.input_buffer.push(c);
293            }
294            EditField::Start | EditField::End => {
295                if !c.is_ascii_digit() {
296                    return;
297                }
298
299                if self.input_buffer.len() != 5 {
300                    return;
301                }
302
303                let positions = [0, 1, 3, 4];
304                if self.time_cursor >= positions.len() {
305                    return;
306                }
307
308                let pos = positions[self.time_cursor];
309                let mut chars: Vec<char> = self.input_buffer.chars().collect();
310                chars[pos] = c;
311                self.input_buffer = chars.into_iter().collect();
312
313                self.time_cursor += 1;
314
315                if self.time_cursor >= positions.len() && self.save_current_field().is_ok() {
316                    self.exit_edit_mode();
317                }
318            }
319        }
320    }
321
322    pub fn handle_backspace(&mut self) {
323        match self.edit_field {
324            EditField::Name | EditField::Description => {
325                self.input_buffer.pop();
326            }
327            EditField::Start | EditField::End => {
328                if self.time_cursor > 0 {
329                    self.time_cursor -= 1;
330                }
331            }
332        }
333    }
334
335    fn save_current_field(&mut self) -> Result<(), String> {
336        let records = self.day_data.get_sorted_records();
337        if let Some(&record) = records.get(self.selected_index) {
338            let id = record.id;
339
340            if let Some(record_mut) = self.day_data.work_records.get_mut(&id) {
341                match self.edit_field {
342                    EditField::Name => {
343                        if self.input_buffer.trim().is_empty() {
344                            return Err("Name cannot be empty".to_string());
345                        }
346                        record_mut.name = self.input_buffer.trim().to_string();
347                    }
348                    EditField::Start => {
349                        record_mut.start = self
350                            .input_buffer
351                            .parse()
352                            .map_err(|_| "Invalid start time format (use HH:MM)".to_string())?;
353                        record_mut.update_duration();
354                    }
355                    EditField::End => {
356                        record_mut.end = self
357                            .input_buffer
358                            .parse()
359                            .map_err(|_| "Invalid end time format (use HH:MM)".to_string())?;
360                        record_mut.update_duration();
361                    }
362                    EditField::Description => {
363                        record_mut.description = self.input_buffer.trim().to_string();
364                    }
365                }
366            }
367        }
368        Ok(())
369    }
370
371    pub fn save_edit(&mut self) -> Result<(), String> {
372        self.save_snapshot();
373        self.save_current_field()?;
374        self.exit_edit_mode();
375        Ok(())
376    }
377
378    pub fn add_new_record(&mut self) {
379        use crate::models::{TimePoint, WorkRecord};
380
381        self.save_snapshot();
382
383        let id = self.day_data.next_id();
384
385        let (default_start, default_end) = if let Some(current_record) = self.get_selected_record()
386        {
387            let start_minutes = current_record.end.to_minutes_since_midnight();
388            let end_minutes = (start_minutes + 60).min(24 * 60 - 1);
389            (
390                current_record.end,
391                TimePoint::from_minutes_since_midnight(end_minutes).unwrap(),
392            )
393        } else {
394            (
395                TimePoint::new(9, 0).unwrap(),
396                TimePoint::new(10, 0).unwrap(),
397            )
398        };
399
400        let record = WorkRecord::new(id, "New Task".to_string(), default_start, default_end);
401
402        self.day_data.add_record(record);
403
404        let records = self.day_data.get_sorted_records();
405        self.selected_index = records.iter().position(|r| r.id == id).unwrap_or(0);
406    }
407
408    pub fn add_break(&mut self) {
409        use crate::models::{TimePoint, WorkRecord};
410
411        self.save_snapshot();
412
413        let id = self.day_data.next_id();
414
415        let (default_start, default_end) = if let Some(current_record) = self.get_selected_record()
416        {
417            let start_minutes = current_record.end.to_minutes_since_midnight();
418            let end_minutes = (start_minutes + 15).min(24 * 60 - 1);
419            (
420                current_record.end,
421                TimePoint::from_minutes_since_midnight(end_minutes).unwrap(),
422            )
423        } else {
424            (
425                TimePoint::new(12, 0).unwrap(),
426                TimePoint::new(12, 15).unwrap(),
427            )
428        };
429
430        let record = WorkRecord::new(id, "Break".to_string(), default_start, default_end);
431
432        self.day_data.add_record(record);
433
434        let records = self.day_data.get_sorted_records();
435        self.selected_index = records.iter().position(|r| r.id == id).unwrap_or(0);
436    }
437
438    pub fn delete_selected_record(&mut self) {
439        self.save_snapshot();
440
441        let records = self.day_data.get_sorted_records();
442        if let Some(&record) = records.get(self.selected_index) {
443            self.day_data.remove_record(record.id);
444
445            if self.selected_index >= self.day_data.work_records.len() {
446                self.selected_index = self.day_data.work_records.len().saturating_sub(1);
447            }
448        }
449    }
450
451    pub fn move_field_left(&mut self) {
452        self.edit_field = match self.edit_field {
453            EditField::Name => EditField::Description,
454            EditField::Start => EditField::Name,
455            EditField::End => EditField::Start,
456            EditField::Description => EditField::End,
457        };
458    }
459
460    pub fn move_field_right(&mut self) {
461        self.edit_field = match self.edit_field {
462            EditField::Name => EditField::Start,
463            EditField::Start => EditField::End,
464            EditField::End => EditField::Description,
465            EditField::Description => EditField::Name,
466        };
467    }
468
469    pub fn set_current_time_on_field(&mut self) {
470        use time::{OffsetDateTime, UtcOffset};
471
472        self.save_snapshot();
473
474        let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
475        let now = OffsetDateTime::now_utc().to_offset(local_offset);
476        let current_time = format!("{:02}:{:02}", now.hour(), now.minute());
477
478        let records = self.day_data.get_sorted_records();
479        if let Some(&record) = records.get(self.selected_index) {
480            let id = record.id;
481
482            if let Some(record_mut) = self.day_data.work_records.get_mut(&id) {
483                match self.edit_field {
484                    EditField::Start => {
485                        if let Ok(time_point) = current_time.parse() {
486                            record_mut.start = time_point;
487                            record_mut.update_duration();
488                        }
489                    }
490                    EditField::End => {
491                        if let Ok(time_point) = current_time.parse() {
492                            record_mut.end = time_point;
493                            record_mut.update_duration();
494                        }
495                    }
496                    _ => {}
497                }
498            }
499        }
500    }
501
502    pub fn enter_visual_mode(&mut self) {
503        self.mode = AppMode::Visual;
504        self.visual_start = self.selected_index;
505        self.visual_end = self.selected_index;
506    }
507
508    pub fn exit_visual_mode(&mut self) {
509        self.mode = AppMode::Browse;
510    }
511
512    pub fn is_in_visual_selection(&self, index: usize) -> bool {
513        let start = self.visual_start.min(self.visual_end);
514        let end = self.visual_start.max(self.visual_end);
515
516        index >= start && index <= end
517    }
518
519    pub fn delete_visual_selection(&mut self) {
520        self.save_snapshot();
521
522        let records = self.day_data.get_sorted_records();
523        let start = self.visual_start.min(self.visual_end);
524        let end = self.visual_start.max(self.visual_end);
525
526        let ids_to_delete: Vec<u32> = records
527            .iter()
528            .enumerate()
529            .filter(|(i, _)| *i >= start && *i <= end)
530            .map(|(_, record)| record.id)
531            .collect();
532
533        for id in ids_to_delete {
534            self.day_data.remove_record(id);
535        }
536
537        if self.selected_index >= self.day_data.work_records.len() {
538            self.selected_index = self.day_data.work_records.len().saturating_sub(1);
539        }
540
541        self.exit_visual_mode();
542    }
543
544    fn save_snapshot(&mut self) {
545        self.history.push(self.day_data.clone());
546    }
547
548    pub fn undo(&mut self) {
549        if let Some(previous_state) = self.history.undo(self.day_data.clone()) {
550            self.day_data = previous_state;
551
552            if self.selected_index >= self.day_data.work_records.len() {
553                self.selected_index = self.day_data.work_records.len().saturating_sub(1);
554            }
555        }
556    }
557
558    pub fn redo(&mut self) {
559        if let Some(next_state) = self.history.redo(self.day_data.clone()) {
560            self.day_data = next_state;
561
562            if self.selected_index >= self.day_data.work_records.len() {
563                self.selected_index = self.day_data.work_records.len().saturating_sub(1);
564            }
565        }
566    }
567
568    pub fn open_command_palette(&mut self) {
569        self.mode = AppMode::CommandPalette;
570        self.command_palette_input.clear();
571        self.command_palette_selected = 0;
572    }
573
574    pub fn close_command_palette(&mut self) {
575        self.mode = AppMode::Browse;
576        self.command_palette_input.clear();
577        self.command_palette_selected = 0;
578    }
579
580    pub fn handle_command_palette_char(&mut self, c: char) {
581        self.command_palette_input.push(c);
582        self.command_palette_selected = 0;
583    }
584
585    pub fn handle_command_palette_backspace(&mut self) {
586        self.command_palette_input.pop();
587        self.command_palette_selected = 0;
588    }
589
590    pub fn move_command_palette_up(&mut self) {
591        if self.command_palette_selected > 0 {
592            self.command_palette_selected -= 1;
593        }
594    }
595
596    pub fn move_command_palette_down(&mut self, filtered_count: usize) {
597        if self.command_palette_selected < filtered_count.saturating_sub(1) {
598            self.command_palette_selected += 1;
599        }
600    }
601
602    pub fn get_filtered_commands(&self) -> Vec<(usize, i64, &Command)> {
603        use fuzzy_matcher::FuzzyMatcher;
604        use fuzzy_matcher::skim::SkimMatcherV2;
605
606        let matcher = SkimMatcherV2::default();
607        let query = self.command_palette_input.as_str();
608
609        if query.is_empty() {
610            return self
611                .available_commands
612                .iter()
613                .enumerate()
614                .map(|(i, cmd)| (i, 0, cmd))
615                .collect();
616        }
617
618        let mut results: Vec<(usize, i64, &Command)> = self
619            .available_commands
620            .iter()
621            .enumerate()
622            .filter_map(|(i, cmd)| {
623                let search_text = format!("{} {}", cmd.key, cmd.description);
624                matcher
625                    .fuzzy_match(&search_text, query)
626                    .map(|score| (i, score, cmd))
627            })
628            .collect();
629
630        results.sort_by(|a, b| b.1.cmp(&a.1));
631        results
632    }
633
634    pub fn execute_selected_command(&mut self) -> Option<CommandAction> {
635        let filtered = self.get_filtered_commands();
636        let action = filtered
637            .get(self.command_palette_selected)
638            .map(|(_, _, cmd)| cmd.action);
639        self.close_command_palette();
640        action
641    }
642
643    pub fn navigate_to_previous_day(&mut self) {
644        use time::Duration;
645
646        self.current_date = self.current_date.saturating_sub(Duration::days(1));
647        self.date_changed = true;
648    }
649
650    pub fn navigate_to_next_day(&mut self) {
651        use time::Duration;
652
653        self.current_date = self.current_date.saturating_add(Duration::days(1));
654        self.date_changed = true;
655    }
656
657    pub fn load_new_day_data(&mut self, new_day_data: DayData) {
658        self.day_data = new_day_data;
659        self.selected_index = 0;
660        self.history = History::new();
661        self.date_changed = false;
662    }
663
664    pub fn open_calendar(&mut self) {
665        self.mode = AppMode::Calendar;
666        self.calendar_selected_date = self.current_date;
667        self.calendar_view_month = self.current_date.month();
668        self.calendar_view_year = self.current_date.year();
669    }
670
671    pub fn close_calendar(&mut self) {
672        self.mode = AppMode::Browse;
673    }
674
675    pub fn calendar_navigate_left(&mut self) {
676        use time::Duration;
677        self.calendar_selected_date = self
678            .calendar_selected_date
679            .saturating_sub(Duration::days(1));
680        self.calendar_view_month = self.calendar_selected_date.month();
681        self.calendar_view_year = self.calendar_selected_date.year();
682    }
683
684    pub fn calendar_navigate_right(&mut self) {
685        use time::Duration;
686        self.calendar_selected_date = self
687            .calendar_selected_date
688            .saturating_add(Duration::days(1));
689        self.calendar_view_month = self.calendar_selected_date.month();
690        self.calendar_view_year = self.calendar_selected_date.year();
691    }
692
693    pub fn calendar_navigate_up(&mut self) {
694        use time::Duration;
695        self.calendar_selected_date = self
696            .calendar_selected_date
697            .saturating_sub(Duration::days(7));
698        self.calendar_view_month = self.calendar_selected_date.month();
699        self.calendar_view_year = self.calendar_selected_date.year();
700    }
701
702    pub fn calendar_navigate_down(&mut self) {
703        use time::Duration;
704        self.calendar_selected_date = self
705            .calendar_selected_date
706            .saturating_add(Duration::days(7));
707        self.calendar_view_month = self.calendar_selected_date.month();
708        self.calendar_view_year = self.calendar_selected_date.year();
709    }
710
711    pub fn calendar_previous_month(&mut self) {
712        use time::Month;
713
714        let (new_month, new_year) = match self.calendar_view_month {
715            Month::January => (Month::December, self.calendar_view_year - 1),
716            Month::February => (Month::January, self.calendar_view_year),
717            Month::March => (Month::February, self.calendar_view_year),
718            Month::April => (Month::March, self.calendar_view_year),
719            Month::May => (Month::April, self.calendar_view_year),
720            Month::June => (Month::May, self.calendar_view_year),
721            Month::July => (Month::June, self.calendar_view_year),
722            Month::August => (Month::July, self.calendar_view_year),
723            Month::September => (Month::August, self.calendar_view_year),
724            Month::October => (Month::September, self.calendar_view_year),
725            Month::November => (Month::October, self.calendar_view_year),
726            Month::December => (Month::November, self.calendar_view_year),
727        };
728
729        self.calendar_view_month = new_month;
730        self.calendar_view_year = new_year;
731
732        // Adjust selected date if it's in a different month
733        if self.calendar_selected_date.month() != new_month
734            || self.calendar_selected_date.year() != new_year
735        {
736            // Try to keep same day of month, or use last valid day
737            let day = self
738                .calendar_selected_date
739                .day()
740                .min(days_in_month(new_month, new_year));
741            self.calendar_selected_date =
742                time::Date::from_calendar_date(new_year, new_month, day).unwrap();
743        }
744    }
745
746    pub fn calendar_next_month(&mut self) {
747        use time::Month;
748
749        let (new_month, new_year) = match self.calendar_view_month {
750            Month::January => (Month::February, self.calendar_view_year),
751            Month::February => (Month::March, self.calendar_view_year),
752            Month::March => (Month::April, self.calendar_view_year),
753            Month::April => (Month::May, self.calendar_view_year),
754            Month::May => (Month::June, self.calendar_view_year),
755            Month::June => (Month::July, self.calendar_view_year),
756            Month::July => (Month::August, self.calendar_view_year),
757            Month::August => (Month::September, self.calendar_view_year),
758            Month::September => (Month::October, self.calendar_view_year),
759            Month::October => (Month::November, self.calendar_view_year),
760            Month::November => (Month::December, self.calendar_view_year),
761            Month::December => (Month::January, self.calendar_view_year + 1),
762        };
763
764        self.calendar_view_month = new_month;
765        self.calendar_view_year = new_year;
766
767        // Adjust selected date if it's in a different month
768        if self.calendar_selected_date.month() != new_month
769            || self.calendar_selected_date.year() != new_year
770        {
771            // Try to keep same day of month, or use last valid day
772            let day = self
773                .calendar_selected_date
774                .day()
775                .min(days_in_month(new_month, new_year));
776            self.calendar_selected_date =
777                time::Date::from_calendar_date(new_year, new_month, day).unwrap();
778        }
779    }
780
781    pub fn calendar_select_date(&mut self) {
782        self.current_date = self.calendar_selected_date;
783        self.date_changed = true;
784        self.close_calendar();
785    }
786
787    pub fn open_ticket_in_browser(&mut self) {
788        use crate::integrations::{build_url, detect_tracker, extract_ticket_from_name};
789
790        if let Some(record) = self.get_selected_record() {
791            if let Some(ticket_id) = extract_ticket_from_name(&record.name) {
792                if let Some(tracker_name) = detect_tracker(&ticket_id, &self.config) {
793                    match build_url(&ticket_id, &tracker_name, &self.config, false) {
794                        Ok(url) => {
795                            if let Err(e) = open_url_in_browser(&url) {
796                                self.last_error_message =
797                                    Some(format!("Failed to open browser: {}", e));
798                            }
799                        }
800                        Err(e) => {
801                            self.last_error_message = Some(format!("Failed to build URL: {}", e));
802                        }
803                    }
804                } else {
805                    self.last_error_message =
806                        Some("Could not detect tracker for ticket".to_string());
807                }
808            } else {
809                self.last_error_message = Some("No ticket found in task name".to_string());
810            }
811        }
812    }
813
814    pub fn open_worklog_in_browser(&mut self) {
815        use crate::integrations::{build_url, detect_tracker, extract_ticket_from_name};
816
817        if let Some(record) = self.get_selected_record() {
818            if let Some(ticket_id) = extract_ticket_from_name(&record.name) {
819                if let Some(tracker_name) = detect_tracker(&ticket_id, &self.config) {
820                    match build_url(&ticket_id, &tracker_name, &self.config, true) {
821                        Ok(url) => {
822                            if let Err(e) = open_url_in_browser(&url) {
823                                self.last_error_message =
824                                    Some(format!("Failed to open browser: {}", e));
825                            }
826                        }
827                        Err(e) => {
828                            self.last_error_message = Some(format!("Failed to build URL: {}", e));
829                        }
830                    }
831                } else {
832                    self.last_error_message =
833                        Some("Could not detect tracker for ticket".to_string());
834                }
835            } else {
836                self.last_error_message = Some("No ticket found in task name".to_string());
837            }
838        }
839    }
840
841    pub fn clear_error(&mut self) {
842        self.last_error_message = None;
843    }
844
845    pub fn close_task_picker(&mut self) {
846        // Cancel and return to Browse mode
847        self.input_buffer.clear();
848        self.mode = AppMode::Browse;
849    }
850
851    pub fn get_unique_task_names(&self) -> Vec<String> {
852        use std::collections::HashSet;
853
854        let mut seen = HashSet::new();
855        let mut task_names = Vec::new();
856
857        for record in self.day_data.work_records.values() {
858            let name = record.name.trim().to_string();
859            if !name.is_empty() && seen.insert(name.clone()) {
860                task_names.push(name);
861            }
862        }
863
864        task_names.sort();
865        task_names
866    }
867
868    pub fn get_filtered_task_names(&self) -> Vec<String> {
869        let all_tasks = self.get_unique_task_names();
870        let filter = self.input_buffer.to_lowercase();
871
872        if filter.is_empty() {
873            return all_tasks;
874        }
875
876        all_tasks
877            .into_iter()
878            .filter(|task| task.to_lowercase().contains(&filter))
879            .collect()
880    }
881
882    pub fn move_task_picker_up(&mut self) {
883        if self.task_picker_selected > 0 {
884            self.task_picker_selected -= 1;
885        }
886    }
887
888    pub fn move_task_picker_down(&mut self, task_count: usize) {
889        if self.task_picker_selected < task_count.saturating_sub(1) {
890            self.task_picker_selected += 1;
891        }
892    }
893
894    pub fn select_task_from_picker(&mut self) {
895        let filtered_tasks = self.get_filtered_task_names();
896
897        if filtered_tasks.is_empty() {
898            // No matches - use the typed input as-is (creating new task)
899            // input_buffer already contains the typed text
900        } else if let Some(selected_name) = filtered_tasks.get(self.task_picker_selected) {
901            // Select from filtered list
902            self.input_buffer = selected_name.clone();
903        }
904
905        // Save the task name and return to Browse mode
906        if let Some(record) = self.get_selected_record() {
907            let record_id = record.id;
908            let new_name = self.input_buffer.trim().to_string();
909
910            self.save_snapshot();
911            if let Some(work_record) = self.day_data.work_records.get_mut(&record_id) {
912                work_record.name = new_name;
913            }
914        }
915
916        self.input_buffer.clear();
917        self.mode = AppMode::Browse;
918    }
919
920    pub fn handle_task_picker_char(&mut self, c: char) {
921        self.input_buffer.push(c);
922        // Reset selection when typing
923        self.task_picker_selected = 0;
924    }
925
926    pub fn handle_task_picker_backspace(&mut self) {
927        self.input_buffer.pop();
928        // Reset selection when deleting
929        self.task_picker_selected = 0;
930    }
931
932    /// Start a new timer with the current selected task
933    pub fn start_timer_for_selected(
934        &mut self,
935        storage: &crate::storage::StorageManager,
936    ) -> Result<(), String> {
937        if let Some(record) = self.get_selected_record() {
938            match storage.start_timer(
939                record.name.clone(),
940                Some(record.description.clone()),
941                Some(record.id),
942                Some(self.current_date),
943            ) {
944                Ok(timer) => {
945                    self.active_timer = Some(timer);
946                    Ok(())
947                }
948                Err(e) => Err(e.to_string()),
949            }
950        } else {
951            Err("No record selected".to_string())
952        }
953    }
954
955    /// Stop the active timer and convert to work record
956    pub fn stop_active_timer(
957        &mut self,
958        storage: &mut crate::storage::StorageManager,
959    ) -> Result<(), String> {
960        if self.active_timer.is_some() {
961            match storage.stop_timer() {
962                Ok(_work_record) => {
963                    self.active_timer = None;
964                    // Reload day data to reflect the new work record
965                    match storage.load_with_tracking(self.current_date) {
966                        Ok(new_day_data) => {
967                            self.day_data = new_day_data;
968                            self.selected_index = 0;
969                            Ok(())
970                        }
971                        Err(e) => Err(format!("Failed to reload day data: {}", e)),
972                    }
973                }
974                Err(e) => Err(e.to_string()),
975            }
976        } else {
977            Err("No active timer".to_string())
978        }
979    }
980
981    /// Pause the active timer
982    pub fn pause_active_timer(
983        &mut self,
984        storage: &crate::storage::StorageManager,
985    ) -> Result<(), String> {
986        if self.active_timer.is_some() {
987            match storage.pause_timer() {
988                Ok(paused_timer) => {
989                    self.active_timer = Some(paused_timer);
990                    Ok(())
991                }
992                Err(e) => Err(e.to_string()),
993            }
994        } else {
995            Err("No active timer".to_string())
996        }
997    }
998
999    /// Resume a paused timer
1000    pub fn resume_active_timer(
1001        &mut self,
1002        storage: &crate::storage::StorageManager,
1003    ) -> Result<(), String> {
1004        if self.active_timer.is_some() {
1005            match storage.resume_timer() {
1006                Ok(resumed_timer) => {
1007                    self.active_timer = Some(resumed_timer);
1008                    Ok(())
1009                }
1010                Err(e) => Err(e.to_string()),
1011            }
1012        } else {
1013            Err("No active timer".to_string())
1014        }
1015    }
1016
1017    /// Get current status of active timer or None if no timer running
1018    pub fn get_timer_status(&self) -> Option<&TimerState> {
1019        self.active_timer.as_ref()
1020    }
1021
1022    /// Check if the data file has been modified externally and reload if needed
1023    /// Returns true if the file was reloaded
1024    pub fn check_and_reload_if_modified(
1025        &mut self,
1026        storage: &mut crate::storage::StorageManager,
1027    ) -> bool {
1028        let mut changed = false;
1029
1030        // Check if day data file has been modified
1031        if let Ok(Some(new_data)) = storage.check_and_reload(self.current_date) {
1032            self.day_data = new_data;
1033            self.last_file_modified = storage.get_last_modified(&self.current_date);
1034
1035            // Adjust selected_index if it's now out of bounds
1036            let record_count = self.day_data.work_records.len();
1037            if self.selected_index >= record_count && record_count > 0 {
1038                self.selected_index = record_count - 1;
1039            }
1040
1041            changed = true;
1042        }
1043
1044        // Check if active timer has been modified externally (e.g., started/stopped from CLI)
1045        if let Ok(Some(timer)) = storage.load_active_timer() {
1046            // Timer exists - update if different from current state
1047            if self.active_timer.is_none() || self.active_timer.as_ref() != Some(&timer) {
1048                self.active_timer = Some(timer);
1049                changed = true;
1050            }
1051        } else if self.active_timer.is_some() {
1052            // Timer was cleared externally
1053            self.active_timer = None;
1054            changed = true;
1055        }
1056
1057        changed
1058    }
1059}
1060
1061/// Open a URL in the default browser using platform-specific commands.
1062///
1063/// On Windows, special care is taken to handle URLs with query parameters
1064/// containing `&` characters. The `start` command requires an empty string
1065/// as the window title argument before the URL, otherwise `&` is interpreted
1066/// as a command separator by cmd.exe.
1067fn open_url_in_browser(url: &str) -> std::io::Result<()> {
1068    #[cfg(target_os = "macos")]
1069    {
1070        std::process::Command::new("open").arg(url).spawn()?;
1071    }
1072
1073    #[cfg(target_os = "windows")]
1074    {
1075        // Windows cmd.exe treats & as a command separator.
1076        // We use raw_arg to pass the complete command string with proper quoting.
1077        // Format: cmd /C start "" "url" - empty quotes for title, quoted URL.
1078        use std::os::windows::process::CommandExt;
1079        std::process::Command::new("cmd")
1080            .raw_arg(format!("/C start \"\" \"{}\"", url))
1081            .spawn()?;
1082    }
1083
1084    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1085    {
1086        // Linux/Unix
1087        std::process::Command::new("xdg-open").arg(url).spawn()?;
1088    }
1089
1090    Ok(())
1091}
1092
1093fn days_in_month(month: time::Month, year: i32) -> u8 {
1094    use time::Month;
1095    match month {
1096        Month::January
1097        | Month::March
1098        | Month::May
1099        | Month::July
1100        | Month::August
1101        | Month::October
1102        | Month::December => 31,
1103        Month::April | Month::June | Month::September | Month::November => 30,
1104        Month::February => {
1105            if is_leap_year(year) {
1106                29
1107            } else {
1108                28
1109            }
1110        }
1111    }
1112}
1113
1114fn is_leap_year(year: i32) -> bool {
1115    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1116}