# 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 <main_pid> --new <new_main.exe> --target <current_main.exe>`. 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/<version>`. 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.
