# Project Conventions and Operational Gotchas ## πŸ› οΈ Setup & Execution - **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays). - **Run:** `python main.py` - **Module-level smoke:** - Event monitoring: `python core/event_monitor.py` - Time calculation: `python core/time_calculator.py` - **Integration tests** (all should be green before release): - `python _integration_test.py` β€” business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+) - `python _i18n_gui_test.py` β€” ko/en switch on real widgets - `python _gui_smoke_test.py` β€” widget instantiation - **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`). ## πŸ—„οΈ Architecture Notes (Core Business Logic) ### Database (10+ tables in `database.db`) `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods. ### Invariants - **`work_records.date` UNIQUE** β€” one row per workday. - **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries β€” never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "μˆ˜λ™ μΆ”κ°€" / "Manual". - **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`). - **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth. - **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`. - **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox). ### Settings system - Keys: `core/settings_keys.py` (35+ constants). Import constants β€” never use raw strings. - `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed). - Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`. - Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running. ### i18n - `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories). - Sentence formatting via Python `str.format(**kwargs)`. - Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap. ## ⚠️ Critical Invariants (MUST PRESERVE) ### 1. Time-off subtraction order in `update_display()` Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` β€” this caused a +29h display bug previously and was the original Phase 1 fix. ### 2. Hot-path caching `update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`. ### 3. Time format separation 24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "μ˜€μ „"/"μ˜€ν›„" markers when applicable). ### 4. Workday boundary `workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic. ### 5. Migration idempotency All `migrate_*` methods must early-return if already applied. Use sentinel keys β€” without them, every startup re-runs the migration query. ### 6. Single-file deployment `main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` β€” `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build. ### 7. Updater handoff `updater.py` is standalone (no PyQt). Args: `--pid --new --target `. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater. ## 🧩 Module Map ### `core/` - `database.py` β€” SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`). - `time_calculator.py` β€” `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit). - `event_monitor.py` β€” Win Event IDs 6005/4624/6006. - `notifier.py` β€” 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable. - `i18n.py` β€” `_DICT` ko/en + `_HELP_HTML` (6 tabs). - `salary.py` β€” `estimate_pay(records, hourly_wage, overtime_rate=1.5)`. - `settings_keys.py` β€” All setting keys as constants. - `version.py` β€” `__version__` single source of truth. ### `ui/` - `main_window.py` β€” 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts. - `settings_view.py` β€” work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals. - `stats_view.py` β€” 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget. - `mini_widget.py` β€” always-on-top frameless. - `help_view.py` β€” 6 tabs from `_HELP_HTML`. Has "πŸš€ μ˜¨λ³΄λ”© λ‹€μ‹œ 보기" button. - `onboarding_view.py` β€” 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel). - `today_summary.py` β€” post-clockout card. - `goal_widget.py` β€” monthly progress bars (overtime cap, daily avg). - `meal_time_dialog.py` β€” lunch/dinner real start-end input. - `past_record_dialog.py` β€” calendar right-click "add past record". - `leave_calendar_view.py` β€” color-coded leave (green/yellow/purple). - `accessibility.py` β€” `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`. - `chart_widget.py` β€” matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing. - Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. ### `ui/controllers/` - `lock_monitor.py` β€” Win32 OpenInputDesktop polling 5s for screen-lock auto-break. - `auto_lunch.py` β€” toggles lunch after 4 hours since clock-in. - `notification_orchestrator.py` β€” 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays. ### `utils/` - `backup.py` β€” once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API. - `lock_detector.py` β€” `OpenInputDesktop` + `GetUserObjectInformation` for screen lock. - `http_api.py` β€” stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally. - `discord_webhook.py` β€” browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`. - `csv_importer.py` β€” standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`. - `csv_exporter.py` β€” same format. - `crash_handler.py` β€” `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report. - `updater_client.py` β€” Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`. - `system_tray.py` β€” tray menu with i18n labels. - `time_format.py` β€” `format_hours_minutes(minutes)`. - `debug_log.py` β€” `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. - `resource_manager.py` β€” PyInstaller `_MEIPASS` aware path resolver. ### Top-level - `main.py` β€” Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow. - `updater.py` β€” Standalone PID wait + file replace + relaunch. - `main.spec` β€” Conditional updater embedding from `build/staging/updater.exe`. - `updater.spec` β€” Standalone updater build. - `release.ps1` β€” One-shot release: bump β†’ tests β†’ build β†’ tag push β†’ Gitea Release + assets, optional code signing. ## βš™οΈ Build Process ```bash # Manual two-step python -m PyInstaller --clean updater.spec # β†’ dist/updater.exe (~6MB) mkdir -p build/staging && cp dist/updater.exe build/staging/ python -m PyInstaller --clean main.spec # β†’ dist/main.exe (~78MB, embeds updater) # Or one-shot .\release.ps1 v2.7.0 ``` - `dist/main.exe` running β†’ `PermissionError`. Kill it first. - `holidays` package only baked in if installed in build env. - Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`. ## 🚦 External Integrations - **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/`. Repo must be public. - **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA). - **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`. - **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only β€” never expose externally. Read-only. - **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order. - **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing β†’ UI falls back to `add_korean_holidays()` (8 fixed dates). ## 🐞 Past Incidents (do NOT re-introduce) - **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. - **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "μˆ˜λ™ μΆ”κ°€". - **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`. - **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor). - **PRAGMA foreign_keys=ON conflict** with existing manual overtime records β†’ IntegrityError. Rolled back FK enforcement, kept WAL+timeout. - **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`. - **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors. - **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`. - **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog. - **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`. - **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`. ## 🌐 i18n Coverage Status - **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings. - **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog. - **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart). Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`. ## 🚒 Release Flow ([release.ps1](release.ps1)) ``` 0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes) 1. Bump core/version.py 2. Tests (pytest tests/ + python _integration_test.py) β€” skippable with --SkipTests 3. PyInstaller (updater.spec β†’ staging copy β†’ main.spec) 4. ZIP packaging (main.exe + updater.exe) 5. Git commit (version.py + CHANGELOG.md) + tag + push 6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section) 7. Asset upload (main.exe, updater.exe, ZIP) ``` `--DryRun` previews without git push or API calls.