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

15 KiB
Raw Blame History

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 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.
  • 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 — 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_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 — All setting keys as constants (60 keys). Modules import these instead of raw strings.
  • 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.
  • 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:none to escape the global QSS cascade — keep that when adding components.
  • icons.pyget_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.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.pyLIGHT_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.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.
  • 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 — 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 — 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. 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. 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 — 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 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

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