핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의) - Windows 이벤트 뷰어 자동 출퇴근 감지 - 30분 단위 연장근무 적립/사용 시스템 - 1.0/0.5/0.25일 연차·반차·반반차 - 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출 - 한국 공휴일 자동 등록 (음력 포함, holidays 패키지) - matplotlib 차트 기반 주간/월간/패턴 통계 - 미니 위젯 + 시스템 트레이 통합 - 한국어/English i18n - 자가 업데이트 (updater.exe + Gitea Releases) 아키텍처: - core/ (db, time_calculator, notifier, i18n, version, settings_keys) - ui/ (main_window + 9 dialogs + 3 controllers) - utils/ (backup, lock_detector, debug_log, updater_client, time_format) - tests/ (66 pytest 단위) + 통합/i18n GUI 검증 CI/CD: - .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트 - .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.3 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 (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable).
Tech Stack: Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional holidays, optional anthropic.
Companion docs: AGENTS.md, INSTALL.md, README.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
python -m PyInstaller --clean main.spec
# Integration tests (35 + 5 + view scenarios)
python _integration_test.py
python _i18n_gui_test.py
python _gui_smoke_test.py
PyInstaller fails with PermissionError if dist/main.exe is running — kill it first. _integration_test.py and _gui_smoke_test.py are intentionally hidden behind a leading underscore so they don't ship in the build.
Architecture
Core (core/)
- database.py — SQLite. 8 tables (
work_records,overtime_bank,overtime_usage,leave_records,break_records,settings,achievements,holidays). Runtime migrations (migrate_*methods called frominit_database()) ALTER existing DBs on startup. Helpers:get_setting_int/float/bool(),get_work_minutes(),get_consecutive_overtime_days(),add_korean_holidays_auto(). - time_calculator.py — Internal representation is
work_minutes: int.work_hoursis a read-only property (compatibility shim for legacy callers / float input). 30-min truncation incalculate_overtime(). - event_monitor.py — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required.
- notifier.py — 6 notifications, each gated by setting key (
NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH). Texts come fromtr()for ko/en. - ai_analysis.py — Optional Claude API integration.
get_insights(records, api_key): with key → Claude, without →static_summary()fallback. - i18n.py —
_DICT(28 categories × 2 languages) +_HELP_HTML(6 large HTML blocks for HelpView). API:tr('key', **kwargs),tr_html('key'),set_language('ko'|'en'). - settings_keys.py — All setting keys as constants. Modules import these instead of raw strings.
UI (ui/)
- main_window.py —
update_display()ticks 1Hz. State:clock_in_time,is_clocked_in,lunch_break_enabled,dinner_break_enabled,is_on_break,auto_lunch_applied_today. Hot-path caches:_auto_lunch_enabled_cache,_today_non_working_cache. Single-instance viaQLocalServernamed"ClockOutCalculatorInstance". 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R). - settings_view.py — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle.
save_settings()sends onlyWORK_MINUTES— DB auto-syncsWORK_HOURS. - stats_view.py — 3 tabs (weekly/monthly/patterns) with matplotlib charts (
make_chart_widget,draw_daily_hours,draw_weekday_avg) and AI insight button. - mini_widget.py — Always-on-top frameless widget; updated from
update_display()when visible. - help_view.py — 6 tabs sourced from
_HELP_HTMLdict (ko/en)._TABSclass constant defines (html_key, label_key) pairs. - Other dialogs:
calendar_view,break_view,overtime_view,leave_view,clock_in_dialog. Window titles usetr(); deeper labels still mostly Korean (point of incremental i18n extension). - chart_widget.py — matplotlib QtAgg helpers. Returns
_Fallbackwidget if matplotlib missing.
Utils (utils/)
- backup.py —
backup_db_if_needed(). Once per day,~/.clockout_backups/database-YYYY-MM-DD.db, 7-file rotation. Usessqlite3.Connection.backupAPI for lock-safe copy. - lock_detector.py — Windows screen-lock detection via
OpenInputDesktop+GetUserObjectInformation(active desktop name != "default" → locked). - http_api.py — stdlib
http.serveron127.0.0.1, daemon thread. Endpoints:/status,/today,/balance,/weekly. Started fromMainWindow.__init__ifHTTP_API_ENABLED=true. - debug_log.py —
dlog(...)env-gated byCLOCKOUT_DEBUG. No-op in production. - time_format.py —
format_hours_minutes(minutes)shared helper. - system_tray.py — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n.
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
Pass actual break_minutes to calculate_remaining_time. Overtime/leave usage subtracted as a timedelta on the result. Progress bar uses overtime_used_minutes=total_time_off keyword arg of calculate_work_progress.
Workday rollover
workday_boundary_hour setting (default 6). start_new_workday() triggers when is_clocked_in=False and clock_in_time.date() != now.date() and now.hour >= boundary. Overnight work past midnight stays attributed to previous workday until that hour. auto_clock_out_previous_days() retroactively closes records using shutdown events (6006).
Database invariants
work_records.dateUNIQUE (one row/day).lunch_break,dinner_breakare BOOLEAN flags; durations live inlunch_duration_minutes/dinner_duration_minutessettings.overtime_bank.work_record_idandovertime_usage.work_record_idare NULLable (manual additions / direct usage). DO NOT filterWHERE work_record_id IS NOT NULL— those rows render with "수동 추가" / "Manual" label.leave_records.daysis FLOAT (1.0 / 0.5 / 0.25).- Balance:
SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)viaget_total_overtime_balance(). - Settings dict from
get_settings()already auto-converts numeric strings to int/float — additionalint(x)casts in callers are dead code.
Settings system
Stored as string key-value pairs in settings table. Always import keys from core/settings_keys.py — typos become ImportError. Defaults set in init_default_settings(). Auto-sync in save_settings():
WORK_MINUTES ↔ WORK_HOURS(floor: 450 min → 7 h, not 8 — settings_view sendsWORK_MINUTESonly)ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL
Migration sentinels (balance_adjustment_migrated_v2, annual_leave_keys_migrated) prevent re-running migrations every startup.
i18n
tr('key', **kwargs) reads _DICT[current_lang], falls back to ko, then to literal key. tr_html('help.html.X') reads _HELP_HTML dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal.
Language is read from LANGUAGE setting at MainWindow.__init__. Changing language requires restart for full propagation (existing widget instances keep their original-language text).
Conventions and gotchas
Database.get_setting()always returns a string (or default). Useget_setting_int/float/bool()helpers or import a key constant. Already-loadedsettings = db.get_settings()dict returns proper types.- Time format: Internal calc uses 24h
datetime. UI conversion only informat_time()with Korean "오전"/"오후" markers whentime_format=12. - QSS hover colors: Hex with alpha suffix (
#colorDD) renders translucent and can hide text. Use solid colors for hover. - Hot-path 1 Hz:
update_display()runs every second. Don't add un-cached DB calls — see_auto_lunch_enabled_cache,_today_non_working_cache,cached_time_formatpatterns. Health/weekly checks are gated bynow.minute % 5 == 0to throttle to 5 min. - Bash with spaces: Repo path contains a space. PowerShell more reliable for stderr capture.
- Single-instance during dev:
QLocalServerblocks a secondpython main.py. Use import-level test or setQT_QPA_PLATFORM=offscreenfor GUI smoke tests. - PyInstaller frozen?
getattr(sys, 'frozen', False)andsys._MEIPASSfor resource path resolution (icon).
Tests
- _integration_test.py — 35 business-logic scenarios (no Qt).
- _gui_smoke_test.py — 8 widget instantiation checks via
QT_QPA_PLATFORM=offscreen. - _i18n_gui_test.py — 5 ko/en switch verifications on real widgets.
All three should be green before any release.