KINDNICK f28f0619a4 v2.13.0: 익일 퇴근 처리 수정 + 날짜 지정 일일보고 + 연차 직접입력
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>
2026-08-12 14:06:18 +09:00

182 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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