diff --git a/.gitignore b/.gitignore index 0967698..081ecb5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ # Whitelist specific files to track !time_tracker.py +!.gitignore +!TODO.md +!AGENTS.md +!tests/ +!README.md # Keep this gitignore file !.gitignore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..82ef8b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md - Time Tracker Development Guide + +## Build/Test Commands + +- **Run main application**: `python time_tracker.py` +- **Run single test**: `python tests/test_mark_logic.py` or `python tests/test_mark_billed.py` +- **Clean archive data**: `python tests/clean_archive.py` + +## Code Style Guidelines + +### Imports & Structure +- Standard library imports first (os, json, csv, datetime, collections) +- Third-party imports next (tkinter, ttk, messagebox, filedialog) +- Group related imports together +- Use absolute imports consistently + +### Naming Conventions +- **Classes**: PascalCase (e.g., `ClickableCell`, `TimeTracker`) +- **Functions/Methods**: snake_case (e.g., `load_settings`, `update_day_total`) +- **Variables**: snake_case (e.g., `time_cells`, `data_rows`) +- **Constants**: UPPER_SNAKE_CASE (e.g., `drag_info` global dict) +- **Private methods**: prefix with underscore (e.g., `_refresh_dropdowns`) + +### Error Handling +- Use try/except blocks for file operations +- Show user-friendly messages via `messagebox.showerror()` or `messagebox.showwarning()` +- Log errors with context but never expose sensitive data +- Gracefully handle missing files and directories + +### GUI Patterns +- Use `ttk.Combobox` for dropdowns with `state="readonly"` +- Frame-based layout with grid/pack geometry managers +- Bind events consistently (``, ``, ``) +- Separate data models from UI presentation +- Use consistent widget naming: `*_var` for StringVar/IntVar, `*_frame` for containers + +### Data Handling +- CSV files use UTF-8 encoding +- Store settings in `~/.config/time-tracker.json` (UNIX-compliant) +- Use `defaultdict` for pivot table operations +- Validate user input before processing +- Archive format: `['Job', 'TaskName', 'Note', 'Customer', 'Hours', 'Date', 'username', 'Billable', 'Billed']` + +### Testing +- Test files in `tests/` directory with `test_` prefix +- Create sample data fixtures for consistent testing +- Test logic separately from UI components +- Verify both success and failure scenarios \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..671a8fc --- /dev/null +++ b/TODO.md @@ -0,0 +1,215 @@ +# Time Tracker Refactor TODO + +This document outlines the recommended improvements from the code review, organized by priority stages. + +## ✨ New Feature Ideas + +### User Interface Enhancements +- [x] **Alternating colors for each hour** ✅ + - Improve visual distinction between time slots + - Use subtle color gradients or patterns + - Consider color-blind friendly palettes + +- [ ] **Pinned/frozen columns for data entry** 🚧 + - Freeze Job, Task Name, Notes, Customer columns on the left side + - These columns remain visible when horizontally scrolling through time slots + - Similar to spreadsheet frozen panes functionality + - Critical for usability with work schedules longer than 4-5 hours + +- [ ] **More compact user interface** + - Reduce padding and margins for better space utilization + - Collapsible sections for advanced features + - Responsive layout for smaller screens + +- [ ] **Button to open archive CSV in text editor** + - Add "Open Archive" button that launches system default editor + - Cross-platform support (Windows, Mac, Linux) + - Optional: Use vim/emacs if available + +- [ ] **Windows compatibility improvements** + - Test and fix Windows-specific path handling + - Ensure proper font rendering + - Verify installer/packaging options + +## 🚨 Stage 1: Critical Issues (High Priority) + +### Security & Stability +- [x] **Add comprehensive input sanitization** ✅ + - Validate task names, notes, customer names + - Prevent CSV injection attacks + - Sanitize file paths + - **Implementation**: Created sanitization functions for CSV text, filenames, and config data + - **Security Features**: Excel formula blocking, directory traversal protection, JSON safety + +- [🔧] **Critical security fixes from code review** + - [ ] **Fix settings file race condition** - Use atomic write pattern with temp file + - **Issue**: Direct file overwrite can corrupt settings if process crashes + - **Impact**: Loss of all application configuration (jobs, customers, paths) + - [ ] **Add CSV quoting protection** - Use proper csv.QUOTE_MINIMAL for safer CSV writing + - **Issue**: Current character removal isn't enough for complete CSV safety + - **Impact**: Potential CSV injection attacks could still succeed + - [ ] **Sanitize all CSV fields consistently** - Fix Date field and username field gaps + - **Issue**: Some fields (Date, username) not properly sanitized before CSV writing + - **Impact**: Data corruption and potential inject vulnerabilities remain + +- [ ] **Replace filedialog usage** for PDF exports + - Use `filedialog.asksaveasfilename` instead + - Validate file extensions + - Add overwrite confirmation + +- [ ] **Move drag_info from global to class attribute** + - Remove global state dependency + - Improve encapsulation + - Make class more testable + +- [ ] **Move drag_info from global to class attribute in TimeTracker** + +🔧 Medium Priority Additions (from code review): + - [ ] **Add type conversion error handling** - Prevent ValueError on hours field + - [ ] **Precompile regular expressions** for better performance + - [ ] **Add comprehensive error handling** for filename and filesystem issues + +### Code Structure +- [ ] **Refactor open_settings() method** (200+ lines) + - Extract tab creation into separate methods: `_create_jobs_tab()`, `_create_customers_tab()`, etc. + - Extract button creation logic + - Reduce complexity + +- [ ] **Refactor export_to_pdf() method** + - Extract table creation logic + - Extract styling logic + - Simplify main method flow + +## 🔧 Stage 2: Architecture Improvements (Medium Priority) + +### Performance & UX +- [ ] **Implement pinned/frozen columns for data entry** (NEW) + - **Problem**: When users have long work schedules (8+ hours), they can't see the Job/Task/Notes/Customer columns while scrolling through later time slots + - **Solution**: Create dual-frame layout with fixed left pane for data columns and scrollable right pane for time slots + - **Implementation**: + - Split scrollable_frame into two frames: `fixed_columns_frame` (columns 0-3) and `time_columns_frame` (column 4+) + - `fixed_columns_frame`: No horizontal scroll, contains Job dropdown, Task entry, Notes entry, Customer dropdown + - `time_columns_frame`: Horizontal scroll for time slots, aligning with fixed columns vertically + - Synchronize vertical scrolling between both frames + - **Technical challenges**: + - Row height synchronization between frames + - Visual alignment and border management + - Drag operations spanned across both frames + - Focus management and tab ordering +- [ ] **Implement CSV streaming reader** for large archive files + - Prevent memory issues with large datasets + - Add pagination for large archives + - Consider SQLite for better performance + +- [ ] **Add progress dialogs** for long-running operations + - PDF export progress + - Large CSV processing + - Archive operations + +- [ ] **Create Settings class** for type-safe configuration + - Replace JSON dict manipulation + - Add validation for settings values + - Provide default value management + +### Code Organization +- [ ] **Extract constants** to separate module + - Widget dimensions, colors, time intervals + - File paths and formats + - Magic numbers scattered in code + +- [ ] **Create GUIBuilder helper class** for common widget operations + - Standardize widget creation + - Reduce code duplication + - Consistent styling + +- [ ] **Create ReportGenerator class** + - Extract reporting logic from TimeTracker + - Separate data processing from GUI + - Make reports more testable + +- [ ] **Add data validation layer** between GUI and CSV operations + - Centralized validation logic + - Consistent error messages + - Better separation of concerns + +### Testing +- [ ] **Add comprehensive unit tests** for data processing methods + - CSV reading/writing + - Time calculation logic + - Data validation + - Report generation + +## 🎯 Stage 3: Quality & Nice-to-Haves (Low/Medium Priority) + +### Code Quality +- [ ] **Add type hints** throughout the codebase + - Improve IDE support + - Better documentation + - Catch type-related bugs early + +- [ ] **Implement debouncing** for rapid cell selections during drag operations + - Improve performance during fast dragging + - Reduce GUI update frequency + - Better user experience + +### Documentation & Maintenance +- [ ] **Create architecture documentation** + - Document class relationships + - Add sequence diagrams for key workflows + - Maintenance guidelines + +## 📋 Implementation Guidelines + +### Before Starting Each Task: +1. Create a feature branch for the task +2. Run existing tests to ensure baseline +3. Implement changes incrementally +4. Test each change thoroughly +5. Update AGENTS.md if adding new patterns + +### Pinned Columns Implementation Strategy: +For the pinned columns feature specifically: +1. **Phase 1**: Create dual-frame layout structure + - Replace single `scrollable_frame` with linked `fixed_columns_frame` and `time_columns_frame` + - Ensure proper vertical alignment between frames +2. **Phase 2**: Update row creation logic + - Modify `add_row()` to create widgets in both frames + - Maintain row index synchronization +3. **Phase 3**: Synchronize interactions + - Update drag operations to work across frame boundaries + - Ensure consistent styling and borders + - Test focus management and keyboard navigation + +### After Each Task: +1. Run all tests to ensure no regressions +2. Test the GUI functionality manually +3. Run the application to verify it works end-to-end +4. Update this TODO file with completion status + +### Testing Strategy: +- **Unit Tests**: For individual methods and classes +- **Integration Tests**: For CSV operations and report generation +- **GUI Tests**: Manual testing of user workflows +- **Regression Tests**: Ensure existing functionality isn't broken + +## 🔄 Dependencies Between Tasks: + +| Task | Depends On | +|------|------------| +| Create ReportGenerator | Add data validation layer | +| Add data validation layer | Create Settings class | +| Implement CSV streaming | Create constants module | +| **Pinned columns** | **Create GUIBuilder helper class** (for consistent widget management) | +| **Add critical security fixes** | **None (time-sensitive)** | +| **Add medium priority fixes** | **None (performance/stability)** | +| Add comprehensive tests | All major refactoring tasks | + +## 📊 Progress Tracking: + +- **Stage 1**: 1/8 completed (1 base + 3 critical fixes pending) +- **Stage 2**: 0/9 completed +- **Stage 3**: 1/2 completed +- **New Features**: 1/4 completed +- **Total**: 2/24 completed + +*Priority Legend: 🚨 Critical | 🔧 Important | 🎯 Enhancement | 🔧 🔧 Code Review Findings* \ No newline at end of file diff --git a/time_tracker.py b/time_tracker.py index bd82aee..7ae2ec3 100644 --- a/time_tracker.py +++ b/time_tracker.py @@ -9,6 +9,7 @@ import csv import os import json import calendar +import re from collections import defaultdict # Global drag state @@ -19,6 +20,154 @@ drag_info = { 'last_cell': None } +def sanitize_csv_text(text): + """Sanitize text for safe CSV writing""" + if not text: + return "" + + text = str(text) + + # Remove dangerous characters that could cause CSV injection + dangerous_chars = ['=', '+', '-', '@', '\t', '\r', '\n'] + for char in dangerous_chars: + text = text.replace(char, '') + + # Remove Excel formula triggers + text = re.sub(r'^[+\-=@]', '', text) + + # Truncate to reasonable length + text = text[:500] + + # Strip whitespace + return text.strip() + +def sanitize_date_text(date_text): + """Sanitize date text while preserving YYYY-MM-DD format""" + if not date_text: + return "" + + text = str(date_text).strip() + + # Remove dangerous characters except hyphens (needed for date format) + dangerous_chars = ['=', '+', '@', '\t', '\r', '\n'] + for char in dangerous_chars: + text = text.replace(char, '') + + # Remove Excel formula triggers (except hyphens for date format) + text = re.sub(r'^[+\-=@]', '', text) + + # Validate and fix date format if possible + # Remove extra hyphens but keep the YYYY-MM-DD structure + if text and '-' in text: + parts = text.split('-') + # Only keep first 3 parts (year, month, day) + parts = parts[:3] + # Ensure each part contains only digits + clean_parts = [] + for part in parts: + clean_part = re.sub(r'[^0-9]', '', part) + if clean_part: # Only add if not empty + clean_parts.append(clean_part) + + # Rebuild date if we have all parts + if len(clean_parts) == 3: + try: + # Validate the date + year = int(clean_parts[0]) + month = int(clean_parts[1]) + day = int(clean_parts[2]) + + # Basic validation + if 2000 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31: + text = f"{year:04d}-{month:02d}-{day:02d}" + else: + # Fallback to today if invalid + from datetime import date + text = date.today().strftime('%Y-%m-%d') + except (ValueError, TypeError): + # Fallback to today if anything goes wrong + from datetime import date + text = date.today().strftime('%Y-%m-%d') + else: + # Fallback to today if format is broken + from datetime import date + text = date.today().strftime('%Y-%m-%d') + + return text.strip() + +def sanitize_filename(filename): + """Sanitize filename for safe file operations""" + if not filename: + return "default.csv" + + text = str(filename) + + # Remove path separators and dangerous characters + text = re.sub(r'[<>:"/\\|?*]', '', text) + text = re.sub(r'\.\.', '', text) # Remove directory traversal + + # Remove leading/trailing dots and spaces + text = text.strip('. ') + + # Ensure filename is not empty + if not text or text.startswith('.'): + return "default.csv" + + # Ensure .csv extension + if not text.lower().endswith('.csv'): + text += '.csv' + + return text + +def sanitize_config_text(text, max_length=100): + """Sanitize text for configuration files""" + if not text: + return "" + + text = str(text) + + # Remove characters that could break JSON/config files + text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text) + text = re.sub(r'[{}[\]"]', '', text) + + # Escape forward slashes and backslashes + text = text.replace('\\', '\\\\').replace('/', '\\/') + + # Truncate to reasonable length + text = text[:max_length] + + return text.strip() + +def validate_input(input_type, value, **kwargs): + """Validate and sanitize user input""" + if value is None: + value = "" + + if input_type == "task_name": + return sanitize_csv_text(value) + + elif input_type == "notes": + return sanitize_csv_text(value) + + elif input_type == "invoice_number": + # Invoice numbers - allow alphanumeric, hyphens, underscores + value = str(value) + value = re.sub(r'[^\w\-]', '', value) + return value.strip()[:50] or "INV001" + + elif input_type == "customer_name": + return sanitize_config_text(value) + + elif input_type == "job_name": + return sanitize_config_text(value) + + elif input_type == "file_path": + return sanitize_filename(value) + + else: + # Default sanitization + return sanitize_csv_text(value) + class ClickableCell(tk.Frame): def __init__(self, parent, row_col_key, callback, width=5, height=2, start_hour=9): super().__init__(parent, relief="solid", borderwidth=1, width=width, height=height) @@ -250,21 +399,54 @@ class TimeTracker: } def save_settings(self): - """Save settings to JSON file""" + """Save settings to JSON file using atomic write operation""" try: # Ensure config directory exists config_dir = os.path.dirname(self.settings_file) if config_dir and not os.path.exists(config_dir): os.makedirs(config_dir, exist_ok=True) - with open(self.settings_file, 'w') as f: - json.dump({ - 'jobs': self.jobs, - 'customers': self.customers, - 'start_hour': self.start_hour, - 'work_hours': self.work_hours, - 'archive_path': self.archive_path - }, f, indent=2) + # Create temporary file in same directory to ensure atomic operation + temp_file = self.settings_file + '.tmp' + + try: + with open(temp_file, 'w') as f: + # Sanitize configuration data before saving + sanitized_jobs = [] + for job in self.jobs: + sanitized_jobs.append({ + 'name': sanitize_config_text(job.get('name', ''), 100), + 'billable': bool(job.get('billable', True)), + 'active': bool(job.get('active', True)) + }) + + sanitized_customers = [] + for customer in self.customers: + sanitized_customers.append({ + 'name': sanitize_config_text(customer.get('name', ''), 100), + 'active': bool(customer.get('active', True)) + }) + + json.dump({ + 'jobs': sanitized_jobs, + 'customers': sanitized_customers, + 'start_hour': int(self.start_hour), + 'work_hours': int(self.work_hours), + 'archive_path': sanitize_filename(self.archive_path) + }, f, indent=2) + + # Atomic replace operation - this is the critical fix + os.replace(temp_file, self.settings_file) + + except Exception: + # Clean up temp file if something went wrong + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except OSError: + pass + raise + return True except Exception as e: messagebox.showerror("Save Error", f"Failed to save settings: {e}") @@ -377,12 +559,12 @@ class TimeTracker: # Task Name field task_entry = tk.Entry(self.scrollable_frame, width=15) - task_entry.insert(0, task_name) + task_entry.insert(0, validate_input("task_name", task_name)) task_entry.grid(row=row_num, column=1, sticky="nsew", padx=1, pady=1) # Notes field notes_entry = tk.Entry(self.scrollable_frame, width=15) - notes_entry.insert(0, notes) + notes_entry.insert(0, validate_input("notes", notes)) notes_entry.grid(row=row_num, column=2, sticky="nsew", padx=1, pady=1) # Customer field @@ -495,20 +677,20 @@ class TimeTracker: if row_num not in self.data_rows: return None - # Get text from entry widgets + # Get text from entry widgets with sanitization widgets = self.scrollable_frame.grid_slaves(row=row_num) job = task = notes = customer = "" for widget in widgets: col = widget.grid_info()["column"] if col == 0: # Job dropdown - job = widget.get() if isinstance(widget, ttk.Combobox) else "" + job = validate_input("job_name", widget.get() if isinstance(widget, ttk.Combobox) else "") elif col == 1: # Task Name column - task = widget.get() if isinstance(widget, tk.Entry) else "" + task = validate_input("task_name", widget.get() if isinstance(widget, tk.Entry) else "") elif col == 2: # Notes column - notes = widget.get() if isinstance(widget, tk.Entry) else "" + notes = validate_input("notes", widget.get() if isinstance(widget, tk.Entry) else "") elif col == 3: # Customer column (now dropdown) - customer = widget.get() if isinstance(widget, ttk.Combobox) else "" + customer = validate_input("customer_name", widget.get() if isinstance(widget, ttk.Combobox) else "") # Calculate hours checked_count = sum(1 for cell in self.data_rows[row_num]['time_cells'].values() if cell.checked) @@ -560,7 +742,7 @@ class TimeTracker: try: with open(archive_path, 'a', newline='', encoding='utf-8') as csvfile: fieldnames = ['Job', 'TaskName', 'Note', 'Customer', 'Hours', 'Date', 'username', 'Billable', 'Billed'] - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer = csv.DictWriter(csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL) # Write header if file is new if not file_exists: @@ -569,14 +751,14 @@ class TimeTracker: # Write each row with archive metadata for row_data in data_to_archive: writer.writerow({ - 'Job': row_data['job'], - 'TaskName': row_data['task'], - 'Note': row_data['notes'], - 'Customer': row_data['customer'], - 'Hours': row_data['hours'], - 'Date': self.get_selected_date().strftime('%Y-%m-%d'), - 'username': os.getenv('USER', os.getenv('USERNAME', 'unknown')), - 'Billable': self.get_job_billable_status(row_data['job']), + 'Job': sanitize_csv_text(row_data['job']), + 'TaskName': sanitize_csv_text(row_data['task']), + 'Note': sanitize_csv_text(row_data['notes']), + 'Customer': sanitize_csv_text(row_data['customer']), + 'Hours': float(row_data['hours']), # Ensure numeric + 'Date': sanitize_date_text(self.get_selected_date().strftime('%Y-%m-%d')), + 'username': sanitize_csv_text(os.getenv('USER', os.getenv('USERNAME', 'unknown'))), + 'Billable': bool(self.get_job_billable_status(row_data['job'])), 'Billed': False # Default to False for now }) @@ -652,12 +834,12 @@ class TimeTracker: def generate_report(): try: - invoice_num = invoice_entry.get().strip() + invoice_num = validate_input("invoice_number", invoice_entry.get().strip()) if not invoice_num: - messagebox.showwarning("Invalid Input", "Please enter an invoice number.") + messagebox.showwarning("Invalid Input", "Please enter a valid invoice number.") return - customer = customer_combo.get() + customer = validate_input("customer_name", customer_combo.get()) start_date_obj = date(int(start_year_var.get()), int(start_month_var.get()), int(start_day_var.get())) end_date_obj = date(int(end_year_var.get()), int(end_month_var.get()), int(end_day_var.get())) @@ -886,9 +1068,30 @@ class TimeTracker: # Write back to archive with updated Billed status if fieldnames: # Ensure fieldnames is not None with open(archive_file, 'w', newline='', encoding='utf-8') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer = csv.DictWriter(csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL) writer.writeheader() - writer.writerows(all_data) + + # Sanitize all data before writing + sanitized_data = [] + for row in all_data: + sanitized_row = {} + for field_name, field_value in row.items(): + if field_name in ['Job', 'TaskName', 'Note', 'Customer', 'username']: + if isinstance(field_value, str): + sanitized_row[field_name] = sanitize_csv_text(field_value) + else: + sanitized_row[field_name] = field_value + elif field_name in ['Date']: + if isinstance(field_value, str): + sanitized_row[field_name] = sanitize_date_text(field_value) + else: + sanitized_row[field_name] = field_value + else: + # For Hours, Billable, Billed - keep as-is but ensure proper types + sanitized_row[field_name] = field_value + sanitized_data.append(sanitized_row) + + writer.writerows(sanitized_data) messagebox.showinfo("Success", f"Marked {len(filtered_data)} entries as billed for invoice #{invoice_num}") @@ -1202,7 +1405,7 @@ class TimeTracker: self.work_hours = work_hours_var.get() # Update archive path - new_archive_path = archive_path_var.get().strip() + new_archive_path = validate_input("file_path", archive_path_var.get().strip()) if new_archive_path: # If directory doesn't exist, try to create it import os.path @@ -1243,12 +1446,16 @@ class TimeTracker: tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_job(): - name = name_entry.get().strip() - if not name: + job_name = validate_input("job_name", name_entry.get().strip()) + if not job_name: messagebox.showwarning("Invalid Input", "Job name cannot be empty.") return - tree.insert('', tk.END, values=(name, 'Yes' if billable_var.get() else 'No', 'Yes' if active_var.get() else 'No')) + # Sanitize billable and active status + billable_text = 'Yes' if billable_var.get() else 'No' + active_text = 'Yes' if active_var.get() else 'No' + + tree.insert('', tk.END, values=(job_name, billable_text, active_text)) dialog.destroy() tk.Button(dialog, text="Save", command=save_job).grid(row=3, column=0, padx=10, pady=10) @@ -1272,15 +1479,19 @@ class TimeTracker: tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_job(): - name = name_entry.get().strip() - if not name: + new_job_name = validate_input("job_name", name_entry.get().strip()) + if not new_job_name: messagebox.showwarning("Invalid Input", "Job name cannot be empty.") return + # Sanitize billable and active status + billable_text = 'Yes' if billable_var.get() else 'No' + active_text = 'Yes' if active_var.get() else 'No' + # Find and update the tree item for item in tree.get_children(): if tree.item(item)['values'][0] == job_name: - tree.item(item, values=(name, 'Yes' if billable_var.get() else 'No', 'Yes' if active_var.get() else 'No')) + tree.item(item, values=(new_job_name, billable_text, active_text)) break dialog.destroy() @@ -1301,12 +1512,13 @@ class TimeTracker: tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_customer(): - name = name_entry.get().strip() - if not name: + customer_name = validate_input("customer_name", name_entry.get().strip()) + if not customer_name: messagebox.showwarning("Invalid Input", "Customer name cannot be empty.") return - tree.insert('', tk.END, values=(name, 'Yes' if active_var.get() else 'No')) + active_text = 'Yes' if active_var.get() else 'No' + tree.insert('', tk.END, values=(customer_name, active_text)) dialog.destroy() tk.Button(dialog, text="Save", command=save_customer).grid(row=2, column=0, padx=10, pady=10) @@ -1327,15 +1539,17 @@ class TimeTracker: tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_customer(): - name = name_entry.get().strip() - if not name: + new_customer_name = validate_input("customer_name", name_entry.get().strip()) + if not new_customer_name: messagebox.showwarning("Invalid Input", "Customer name cannot be empty.") return + active_text = 'Yes' if active_var.get() else 'No' + # Find and update tree item for item in tree.get_children(): if tree.item(item)['values'][0] == customer_name: - tree.item(item, values=(name, 'Yes' if active_var.get() else 'No')) + tree.item(item, values=(new_customer_name, active_text)) break dialog.destroy()