68893236+KINDNICK@users.noreply.github.com c5df37ca57 v2.8.0: 도전과제 시스템 + 다크 디자인 리뉴얼 + 안정성 강화
Added — 도전과제 시스템 (153개 자동 평가)
- core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제
- ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿)
- 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push
- achievements 테이블 확장 (code/category/tier/is_secret/progress/target)
- hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키

Changed — 다크 테마 디자인 리뉴얼
- ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress)
- 통계/도움말/도전과제 다이얼로그 일관 다크 톤
- matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend)
- 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드)

Fixed — 안정성·일관성
- 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch)
- DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환
- DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자)
- crash_handler 다단계 폴백 (DB → 파일 → stderr)
- updater PID race: 지수 backoff 재시도 (총 ~9초)
- Discord URL 형식 검증 (snowflake regex)
- 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증
- check_dinner_reminder 신규, 알림 임계값 5개 설정화
- closeEvent timer/notifier 정리 (aboutToQuit hook)
- 마이그레이션 12개 모두 _conn() + try/finally
- DB 인덱스 5개 추가 (break/overtime/leave date)

Tests
- pytest 116/116 PASS, 통합 시나리오 48/48 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:11:13 +09:00

12 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.

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.7.0

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 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 — 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 — Windows Event IDs 6005/4624/6006.
  • 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.pyestimate_pay(records, hourly_wage, overtime_rate=1.5) simple month estimator.
  • 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 — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
  • version.py__version__ single source of truth.

ui/

  • main_window.pyupdate_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 — 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.
  • 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 — 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.py in 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 via tr(). Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
  • styles.py — Shared QSS / color tokens.

utils/

  • backup.pybackup_db_if_needed(). Daily, 7-file rotation, sqlite3.Connection.backup API.
  • lock_detector.pyis_screen_locked() via Win32 OpenInputDesktop + GetUserObjectInformation.
  • discord_webhook.pysend_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.pyparse_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.pyinstall_global_handler(db, version) registers sys.excepthook. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
  • debug_log.pydlog() env-gated by CLOCKOUT_DEBUG.
  • time_format.pyformat_hours_minutes(minutes) shared helper.
  • system_tray.py — Tray menu, tooltips i18n.

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 .bak for rollback.
  • updater.spec — PyInstaller spec (~6MB, no PyQt deps).
  • main.spec — Embeds build/staging/updater.exe as 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.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. 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 — 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. Auto-discovered via pytest.ini (testpaths = tests).

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