# 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`.

[AGENTS.md](AGENTS.md) is the long-form reference (invariants, past incidents, security notes). This file is the quick map — when the two disagree, AGENTS.md is more detailed but may also be staler; trust the code.

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 = '<PAT>'
.\release.ps1 v2.12.0
```

Env vars (all optional):

| Var | Effect |
| --- | --- |
| `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` | Skips the background holiday-sync thread in `init_database()`. **Required for any Qt test** — the thread segfaults offscreen runs. Already set in `tests/conftest.py` and the three `_*_test.py` scripts. |
| `CLOCKOUT_HOLIDAY_API_KEY` | 공공데이터포털 특일정보 key for [holiday_api.py](utils/holiday_api.py) (externalized in v2.12.0). |
| `CLOCKOUT_DEBUG` / `CLOCKOUT_DEBUG_DIR` | Enables `dlog()` output + its target dir. |
| `CLOCKOUT_RELEASES_API` / `CLOCKOUT_ASSET_NAME` | Override the Gitea endpoint / asset name for update checks. |

## 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.
- **[achievements.py](core/achievements.py)** — ~357 achievements as `Achievement` dataclass + per-item evaluator `(db) -> (progress, target)`. Evaluated on a 5-min throttle, unearned only; new unlocks emit `notify_achievement_unlocked`. Names/descriptions live in i18n as `achieve.{code}.name/desc`, not in the dataclass.
- **[recurring_leaves.py](core/recurring_leaves.py)** — Pattern parser (`weekly:friday`, `weekly:mon,wed,fri`, `biweekly:friday`, `monthly:15`). Instances are **not** persisted — `expand_for_range()` computes them on call, and callers must reconcile against concrete `leave_records` rows for the same date.
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. UI string keying is **complete as of v2.12.0** (all 22 `ui/` files + achievement metadata + chart labels) — add new user-facing strings as keys, never literals.
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants (60 keys). Modules import these instead of raw strings.
- **[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.
- **[achievements_view.py](ui/achievements_view.py)** — 4 tabs (all / in-progress / done / secret). Tier-gradient cards.
- **[schedule_view.py](ui/schedule_view.py)** — Unified holiday + leave + recurring-pattern calendar. Recurring patterns managed via `recurring_leave_dialog.py`.
- **[dark_components.py](ui/dark_components.py)** — Shared card/header/tab widgets for the dark dialogs. Every sub-label sets explicit `transparent` + `border:none` to escape the global QSS cascade — keep that when adding components.
- **[icons.py](ui/icons.py)** — `get_icon(name, color=None)`: QtSvg 24x24 line icons cached by `(name, color, size)`, tinted with the current theme color. Replaced emoji in v2.11.0.
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.

### 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)** — `LIGHT_COLORS`/`DARK_COLORS` token dicts → `generate_theme(colors, is_dark)` QSS. **Dark is the default** for new installs (v2.11.0). Read colors at paint time via `ThemeColors.get('key')` — never hardcode hex in a widget, or it will ignore the theme (that was the v2.11.1 bug: stats/help/achievements stayed dark in light mode). `apply_dark_titlebar(widget)` for the Win32 titlebar; dialogs call it in `__init__`. Theme choice is the `THEME` setting key.

### 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.
- **[holiday_api.py](utils/holiday_api.py)** — 한국천문연구원 특일정보 `getRestDeInfo` client, backfilling the temporary holidays the `holidays` package misses. Network failure returns `None` silently — callers must fall back.
- **[font_loader.py](utils/font_loader.py)** — Registers bundled NanumSquare TTFs from `font/` into `QFontDatabase` (frozen + dev paths). Falls back to Malgun Gothic via the QSS font chain if registration fails.

### 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 <main_pid> --new <new_exe> --target <target_exe>`. 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. Full UI keying landed in v2.12.0 — new strings go in `_DICT['ko']` **and** `['en']`. Watch apostrophes in English values (`Children's`) — they broke `i18n.py` with a SyntaxError in v2.12.0.

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).
- matplotlib in frozen builds: import `backend_qtagg` (with a `backend_qt5agg` fallback) and keep the `numpy.core._multiarray_tests` hiddenimport in [main.spec](main.spec). Both were silent "matplotlib 필요" fallbacks in v2.11.0–2.11.1 that only reproduced in main.exe, never in `python main.py` — chart changes need a real build to verify.

## 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`, `test_holiday_api`, `test_overtime_accrual_guard`, `test_recurring_leaves`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`); [tests/conftest.py](tests/conftest.py) sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC` at import time.

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).
