Skip to main content

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