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>
15 KiB
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 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, INSTALL.md, README.md, CHANGELOG.md.
Build and Run
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 (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 — 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 frominit_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 — Internal
work_minutes: int.calculate_overtime(unit_minutes=30)truncates to user-selectable unit (15/30/60).work_hoursis read-only property. - event_monitor.py — Windows Event IDs 6005/4624/6006.
- notifier.py — 7 notifications, each gated by
NOTIF_*setting + db.has_notification_today guard for daily dedupe. Readsnotification_before_minutesfor clock-out alert threshold. - salary.py —
estimate_pay(records, hourly_wage, overtime_rate=1.5)simple month estimator. - achievements.py — ~357 achievements as
Achievementdataclass + per-item evaluator(db) -> (progress, target). Evaluated on a 5-min throttle, unearned only; new unlocks emitnotify_achievement_unlocked. Names/descriptions live in i18n asachieve.{code}.name/desc, not in the dataclass. - 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 concreteleave_recordsrows for the same date. - 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 22ui/files + achievement metadata + chart labels) — add new user-facing strings as keys, never literals. - settings_keys.py — All setting keys as constants (60 keys). Modules import these instead of raw strings.
- version.py —
__version__single source of truth.
ui/
- main_window.py —
update_display()ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance viaQLocalServer "ClockOutCalculatorInstance". Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller_MEIPASSon first run. - 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 — 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 — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via
chart_widget.py. - today_summary.py — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
- goal_widget.py — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
- meal_time_dialog.py — Lunch/dinner real start-end input.
- past_record_dialog.py — Manual past-day entry (calendar right-click).
- leave_calendar_view.py — Color-coded leave usage calendar.
- mini_widget.py — Always-on-top frameless time display.
- help_view.py — 6 tabs from
_HELP_HTML. Bottom-left "Re-run Onboarding" button. - chart_widget.py — matplotlib QtAgg helpers:
draw_daily_hours(with hover annotation),draw_weekday_avg,draw_clock_in_distribution. - accessibility.py — Font scale + high-contrast QSS overlay.
- achievements_view.py — 4 tabs (all / in-progress / done / secret). Tier-gradient cards.
- schedule_view.py — Unified holiday + leave + recurring-pattern calendar. Recurring patterns managed via
recurring_leave_dialog.py. - dark_components.py — Shared card/header/tab widgets for the dark dialogs. Every sub-label sets explicit
transparent+border:noneto escape the global QSS cascade — keep that when adding components. - 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 — 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 — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
- notification_orchestrator.py — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
- meal_controller.py — Lunch/dinner toggle + label refresh, extracted from
main_window.pyin v2.7.0. Same controller pattern as Lock/AutoLunch/Notification.
ui/ (cross-cutting)
- i18n_runtime.py — Runtime retranslate plumbing.
register(widget, key)keeps a weakref;set_language_and_retranslate(lang)re-fetches all live widgets viatr(). Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally. - styles.py —
LIGHT_COLORS/DARK_COLORStoken dicts →generate_theme(colors, is_dark)QSS. Dark is the default for new installs (v2.11.0). Read colors at paint time viaThemeColors.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 theTHEMEsetting key.
utils/
- backup.py —
backup_db_if_needed(). Daily, 7-file rotation,sqlite3.Connection.backupAPI. - lock_detector.py —
is_screen_locked()via Win32OpenInputDesktop+GetUserObjectInformation. - discord_webhook.py —
send_test/clock_in/clock_out/health_warning. Browser User-Agent (Cloudflare bypass). - 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 —
parse_csv()+import_records(on_conflict='skip'|'overwrite'). Standard format:date,clock_in,clock_out,lunch_minutes,memo. - csv_exporter.py — Same standard format as importer. Round-trips with
csv_importer. - resource_manager.py — PyInstaller
_MEIPASS-aware path resolver for icons / assets. - crash_handler.py —
install_global_handler(db, version)registerssys.excepthook. Logs to crash_log + shows dialog with copy/Gitea-report buttons. - debug_log.py —
dlog()env-gated byCLOCKOUT_DEBUG. - time_format.py —
format_hours_minutes(minutes)shared helper. - system_tray.py — Tray menu, tooltips i18n.
- holiday_api.py — 한국천문연구원 특일정보
getRestDeInfoclient, backfilling the temporary holidays theholidayspackage misses. Network failure returnsNonesilently — callers must fall back. - font_loader.py — Registers bundled NanumSquare TTFs from
font/intoQFontDatabase(frozen + dev paths). Falls back to Malgun Gothic via the QSS font chain if registration fails.
Top-level
- main.py — Entry point. Bootstraps DB, reads
db_path_override, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow. - updater.py — Standalone helper.
--pid <main_pid> --new <new_exe> --target <target_exe>. Waits for main exit, replaces, relaunches. Backup.bakfor rollback. - updater.spec — PyInstaller spec (~6MB, no PyQt deps).
- main.spec — Embeds
build/staging/updater.exeas data (release.ps1 stages it). - 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:
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.dateUNIQUE.lunch_break,dinner_breakare BOOLEAN flags; durations from settings; ACTUAL meal times viabreak_records.break_type='lunch'/'dinner'.overtime_bank.work_record_idandovertime_usage.work_record_idare NULLable. Don't filterNOT NULL— those are manual additions.leave_records.daysis FLOAT (1.0/0.5/0.25).- Balance:
SUM(bank.earned) - SUM(usage.used). notification_logfor daily dedupe (channel+event_type+date).crash_logfor unhandled exceptions.
Settings system
Stored as string key-value in settings table. Always import keys from 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()orget_settings()dict. - 24h
datetimeinternal. 12h conversion only informat_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:
QLocalServerblocks secondpython main.py. UseQT_QPA_PLATFORM=offscreenfor GUI smoke tests. - PyInstaller frozen:
getattr(sys, 'frozen', False)+sys._MEIPASSfor 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 abackend_qt5aggfallback) and keep thenumpy.core._multiarray_testshiddenimport in main.spec. Both were silent "matplotlib 필요" fallbacks in v2.11.0–2.11.1 that only reproduced in main.exe, never inpython main.py— chart changes need a real build to verify.
Tests
- _integration_test.py — Business-logic scenarios (no Qt).
- _gui_smoke_test.py — Widget instantiation via
QT_QPA_PLATFORM=offscreen. - _i18n_gui_test.py — ko/en switching on real widgets.
- 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 (testpaths = tests); tests/conftest.py setsCLOCKOUT_DISABLE_HOLIDAY_SYNCat 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
# 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).