KINDNICK ff71886fd7 v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키
- 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용
- MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출
- 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등)
- pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122)
- README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
2026-04-30 19:30:47 +09:00

153 lines
11 KiB
Markdown

# 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 = '<PAT>'
.\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.
### 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`.
- **[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 <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. 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_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+).
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).