Fixed: - clock_out()이 datetime.now().date() 행에 퇴근을 써서 자정 넘긴 퇴근이 UPDATE 0건으로 유실되던 버그. 출근일(근무일) 기준으로 수정. cancel_clock_out() 및 주말/공휴일 판정·외출 집계도 동일 기준으로 통일. - 캘린더 시간 수정 / 과거기록 추가가 퇴근<=출근을 반려해 익일 퇴근을 저장할 수 없던 문제. 익일로 해석하고 동시각만 반려. 총 근무시간·연장근무 음수 계산도 수정. Added: - generate_daily_report(target_date) 날짜 파라미터화 + 캘린더 '보고서 복사' 버튼. 기본값은 진행 중인 근무일(경계시간 전이면 출근일). - 연차 직접입력 버튼(메인 창) — 미연결 상태였던 use_custom_leave()를 기존 AddLeaveDialog 재사용으로 연결. - resolve_work_datetimes() / is_overnight() 공용 헬퍼 + 유닛 테스트 6개. Changed: - 연차 빠른 사용에서 날짜·메모 팝업 제거 (연장근무와 동일하게 확인 1회). - get_today_overtime_usage() / get_total_break_minutes_today()에 선택적 date_str 파라미터 추가. 테스트: 유닛 200 / 통합 53 / GUI 8 / i18n 5 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
15 KiB
Markdown
182 lines
15 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`.
|
||
|
||
[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).
|