# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview **Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases. **Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`. Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md). ## Build and Run ```bash pip install -r requirements.txt python main.py # Standalone module tests python core/event_monitor.py python core/time_calculator.py # Production build → dist/main.exe (78MB, embeds updater.exe) python -m PyInstaller --clean updater.spec # build first — main.spec datas references it python -m PyInstaller --clean main.spec # Tests python _integration_test.py # business-logic scenarios python _i18n_gui_test.py # ko/en GUI verification python _gui_smoke_test.py # widget instantiation python -m pytest tests # unit tests # Release (one-shot to Gitea) $env:GITEA_TOKEN = '' .\release.ps1 v2.7.0 ``` ## Architecture ### core/ - **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness. - **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property. - **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006. - **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold. - **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator. - **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0). - **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys. - **[version.py](core/version.py)** — `__version__` single source of truth. ### ui/ - **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run. - **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog. - **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import. - **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`. - **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in. - **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0. - **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input. - **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click). - **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar. - **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display. - **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button. - **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`. - **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay. - Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n). ### ui/controllers/ - **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot). - **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache. - **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push. - **[meal_controller.py](ui/controllers/meal_controller.py)** — Lunch/dinner toggle + label refresh, extracted from `main_window.py` in v2.7.0. Same controller pattern as Lock/AutoLunch/Notification. ### ui/ (cross-cutting) - **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally. - **[styles.py](ui/styles.py)** — Shared QSS / color tokens. ### utils/ - **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API. - **[lock_detector.py](utils/lock_detector.py)** — `is_screen_locked()` via Win32 `OpenInputDesktop` + `GetUserObjectInformation`. - **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass). - **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe. - **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`. - **[csv_exporter.py](utils/csv_exporter.py)** — Same standard format as importer. Round-trips with `csv_importer`. - **[resource_manager.py](utils/resource_manager.py)** — PyInstaller `_MEIPASS`-aware path resolver for icons / assets. - **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons. - **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`. - **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper. - **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n. ### Top-level - **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow. - **[updater.py](updater.py)** — Standalone helper. `--pid --new --target `. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback. - **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps). - **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it). - **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`. ## Time-off accounting in `update_display()` Critical invariant — preserve in any change: ```python break_minutes = self.db.get_total_break_minutes_today() overtime_used_today = self.db.get_today_overtime_usage() leave_used_today = self.db.get_today_leave_minutes() total_time_off = overtime_used_today + leave_used_today remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_minutes) remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation ``` ## Database invariants - `work_records.date` UNIQUE. - `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`. - `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions. - `leave_records.days` is FLOAT (1.0/0.5/0.25). - Balance: `SUM(bank.earned) - SUM(usage.used)`. - `notification_log` for daily dedupe (channel+event_type+date). - `crash_log` for unhandled exceptions. ## Settings system Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`: - `WORK_MINUTES ↔ WORK_HOURS` (floor) - `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL` Migration sentinels prevent re-running. ## i18n `tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장. Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched. ## Conventions - **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict. - 24h `datetime` internal. 12h conversion only in `format_time()`. - 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min. - Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. - PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths. - main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py). ## Tests - [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt). - [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`. - [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets. - [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`). Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`. All should be green before any release. ## Release flow ```bash # Edit core/version.py + CHANGELOG.md git add -A && git commit -m "v2.X.Y: ..." .\release.ps1 v2.X.Y ``` Auto-handles: version bump check, pytest+integration tests, two-exe build, ZIP, git tag push, Gitea Release create, asset upload (main.exe + updater.exe + ZIP).