KINDNICK ff71886fd7 v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키
- 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용
- MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출
- 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등)
- pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122)
- README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
2026-04-30 19:30:47 +09:00

12 KiB
Raw Permalink Blame History

Project Conventions and Operational Gotchas

🛠️ Setup & Execution

  • Dependencies: pip install -r requirements.txt (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
  • Run: python main.py
  • Module-level smoke:
    • Event monitoring: python core/event_monitor.py
    • Time calculation: python core/time_calculator.py
  • Integration tests (all should be green before release):
    • python _integration_test.py — business-logic scenarios (35+ for v2.02.2 + 15+ for v2.3+)
    • python _i18n_gui_test.py — ko/en switch on real widgets
    • python _gui_smoke_test.py — widget instantiation
  • Production build: python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec (or just release.ps1 vX.Y.Z).

🗄️ Architecture Notes (Core Business Logic)

Database (10+ tables in database.db)

work_records, overtime_bank, overtime_usage, leave_records, break_records, settings, achievements, holidays, notification_log (dedupe), crash_log (auto crash report). Migrations chained from init_database() via sentinel-gated migrate_* methods.

Invariants

  • work_records.date UNIQUE — one row per workday.
  • Overtime bank vs usage: separate tables, both with NULLable work_record_id for manual entries — never filter WHERE work_record_id IS NOT NULL. Render NULL rows as "수동 추가" / "Manual".
  • Time representation: TimeCalculator.work_minutes is canonical (int). work_hours is a read-only property. UI/DB sync WORK_MINUTES ↔ WORK_HOURS via floor (int(min) // 60).
  • Leave days: leave_records.days is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
  • Overtime balance: SUM(bank.earned_minutes) - SUM(usage.used_minutes) via get_total_overtime_balance().
  • WAL mode + 5s busy timeout enabled in init_database() for cloud-sync friendliness (OneDrive/Dropbox).

Settings system

  • Keys: core/settings_keys.py (35+ constants). Import constants — never use raw strings.
  • get_setting() returns string; use get_setting_int/float/bool() helpers or read from get_settings() dict (already typed).
  • Auto-sync pairs: WORK_MINUTES ↔ WORK_HOURS, ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL.
  • Migration sentinels (balance_adjustment_migrated_v2, annual_leave_keys_migrated) prevent re-running.

i18n

  • tr('key', **kwargs) and tr_html('help.html.X') from core/i18n.py. ko/en _DICT (30+ categories).
  • Sentence formatting via Python str.format(**kwargs).
  • Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.

⚠️ Critical Invariants (MUST PRESERVE)

1. Time-off subtraction order in update_display()

Pass actual break_minutes to calculate_remaining_time. Subtract total_time_off = overtime_used + leave_used from the resulting timedelta AFTER the call. NEVER mutate break_minutes to break_minutes - overtime_used — this caused a +29h display bug previously and was the original Phase 1 fix.

2. Hot-path caching

update_display() runs at 1Hz. Any DB call inside must be cached (_auto_lunch_enabled_cache, _today_non_working_cache, cached_time_format). Periodic checks (health/weekly/long-work notifications) are gated by now.minute % 5 == 0.

3. Time format separation

24-hour datetime for ALL internal calculation. 12-hour conversion happens only in MainWindow.format_time() (adds Korean "오전"/"오후" markers when applicable).

4. Workday boundary

workday_boundary_hour (default 6). Overnight work stays on the previous day's record until that hour. start_new_workday() only triggers when crossing this boundary. Don't naively use date.today() in time logic.

5. Migration idempotency

All migrate_* methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.

6. Single-file deployment

main.exe embeds updater.exe via main.spec data files. _ensure_updater_extracted() in main.py extracts on first launch from sys._MEIPASS. Never break the staging copy at build/staging/updater.exemain.spec --clean would otherwise wipe dist/updater.exe mid-build.

7. Updater handoff

updater.py is standalone (no PyQt). Args: --pid <main_pid> --new <new_main.exe> --target <current_main.exe>. Waits for PID exit, swaps file with .bak rollback, relaunches. Don't add Qt deps to updater.

🧩 Module Map

core/

  • database.py — SQLite schema + migrations + helpers (get_setting_*, get_consecutive_overtime_days, add_korean_holidays_auto, log_notification, add_meal_record).
  • time_calculator.pywork_minutes canonical, calculate_overtime(unit_minutes=30) (user-selectable unit).
  • event_monitor.py — Win Event IDs 6005/4624/6006.
  • notifier.py — 7 notifications, _enabled() reads NOTIF_* keys, notification_before_minutes configurable.
  • i18n.py_DICT ko/en + _HELP_HTML (6 tabs).
  • salary.pyestimate_pay(records, hourly_wage, overtime_rate=1.5).
  • settings_keys.py — All setting keys as constants.
  • version.py__version__ single source of truth.

ui/

  • main_window.py — 1Hz update_display(), single-instance QLocalServer, 7 keyboard shortcuts.
  • settings_view.py — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
  • stats_view.py — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
  • mini_widget.py — always-on-top frameless.
  • help_view.py — 6 tabs from _HELP_HTML. Has "🚀 온보딩 다시 보기" button.
  • onboarding_view.py — 5-step QWizard (forced for new users; ONBOARDING_COMPLETED sentinel).
  • today_summary.py — post-clockout card.
  • goal_widget.py — monthly progress bars (overtime cap, daily avg).
  • meal_time_dialog.py — lunch/dinner real start-end input.
  • past_record_dialog.py — calendar right-click "add past record".
  • leave_calendar_view.py — color-coded leave (green/yellow/purple).
  • accessibility.pyapply_font_scale(scale), apply_high_contrast(enabled), HIGH_CONTRAST_QSS.
  • chart_widget.py — matplotlib QtAgg helpers, _Fallback widget if matplotlib missing.
  • Other dialogs: calendar_view, break_view, overtime_view, leave_view, clock_in_dialog.

ui/controllers/

  • lock_monitor.py — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
  • auto_lunch.py — toggles lunch after 4 hours since clock-in.
  • notification_orchestrator.py — 5-min-tick orchestrator + maybe_send_weekly_report() for Mondays.

utils/

  • backup.py — once/day, ~/.clockout_backups/, 7-rotation, sqlite3.Connection.backup API.
  • lock_detector.pyOpenInputDesktop + GetUserObjectInformation for screen lock.
  • http_api.py — stdlib http.server on 127.0.0.1:17389, daemon thread. Endpoints: /status, /today, /balance, /weekly. NEVER expose externally.
  • discord_webhook.py — browser User-Agent (Mozilla/5.0 ... ClockOutCalculator/2.3) for Cloudflare bypass. send_test/clock_in/clock_out/health_warning.
  • csv_importer.py — standard format date,clock_in,clock_out,lunch_minutes,memo. parse_csv() + import_records(on_conflict).
  • csv_exporter.py — same format.
  • crash_handler.pyinstall_global_handler() registers sys.excepthook, dialog with copy/Gitea-report.
  • updater_client.py — Returns (info, reason) tuple. Reasons: OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET.
  • system_tray.py — tray menu with i18n labels.
  • time_format.pyformat_hours_minutes(minutes).
  • debug_log.pydlog(...) env-gated by CLOCKOUT_DEBUG.
  • resource_manager.py — PyInstaller _MEIPASS aware path resolver.

Top-level

  • main.py — Bootstraps DB, _ensure_updater_extracted(), crash handler, onboarding gate, MainWindow.
  • updater.py — Standalone PID wait + file replace + relaunch.
  • main.spec — Conditional updater embedding from build/staging/updater.exe.
  • updater.spec — Standalone updater build.
  • release.ps1 — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.

⚙️ Build Process

# Manual two-step
python -m PyInstaller --clean updater.spec   # → dist/updater.exe (~6MB)
mkdir -p build/staging && cp dist/updater.exe build/staging/
python -m PyInstaller --clean main.spec      # → dist/main.exe (~78MB, embeds updater)

# Or one-shot
.\release.ps1 v2.7.0
  • dist/main.exe running → PermissionError. Kill it first.
  • holidays package only baked in if installed in build env.
  • Code signing optional via $env:CODE_SIGN_CERT (.pfx path) + $env:CODE_SIGN_PASS.

🚦 External Integrations

  • Auto-update (utils/updater_client.py): polls https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest. UA: ClockOutCalculator/<version>. Repo must be public.
  • Discord webhook (utils/discord_webhook.py): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
  • Gitea Issues for crash reports (utils/crash_handler.py): user opt-in via GITEA_FEEDBACK_ENABLED + GITEA_FEEDBACK_TOKEN.
  • HTTP API (utils/http_api.py): bound to 127.0.0.1 only — never expose externally. Read-only.
  • Cloud sync via db_path_override: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
  • holidays package: add_korean_holidays_auto() returns -1 if package missing → UI falls back to add_korean_holidays() (8 fixed dates).

🐞 Past Incidents (do NOT re-introduce)

  • +29h remaining time bug (Phase 1): caused by break_minutes -= overtime_used. Fixed by subtracting total_time_off AFTER calculate_remaining_time call.
  • Manual overtime invisible: previously filtered work_record_id IS NOT NULL. Now show all rows; label NULL as "수동 추가".
  • annual_leave_total vs annual_leave_days: two keys for the same value. Auto-synced in save_settings().
  • Banker's rounding: round(450/60) = 8 in Python (round-half-even). Use int(value) // 60 (floor).
  • PRAGMA foreign_keys=ON conflict with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
  • Discord 403 / Cloudflare 1010: default Python-urllib/3.x User-Agent blocked. Fixed with browser UA in discord_webhook.py.
  • Help dialog blank (v2.3.1): self.setLayout(main_layout) accidentally indented into _reopen_onboarding method body. Same regression hit leave_view.py later. Always verify setLayout is at the END of init_ui() after method-body refactors.
  • dist/updater.exe wiped by main.spec --clean: solved by staging copy at build/staging/updater.exe.
  • Onboarding wizard auto-skipped for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
  • PowerShell 5.1 ANSI default: Get-Content -Raw reads CHANGELOG.md as cp949, mangling Korean. Use [System.IO.File]::ReadAllText(path, UTF8).
  • PowerShell 5.1 NativeCommandError: native commands' stderr triggers $ErrorActionPreference='Stop'. Use Invoke-Native helper with Continue and explicit $LASTEXITCODE.

🌐 i18n Coverage Status

  • Fully translated: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
  • Partially translated: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
  • Roadmap: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).

Adding new translations: add key to _DICT['ko'] AND _DICT['en'], replace literal with tr('key'). For sentence interpolation use tr('key', name=value).

🚢 Release Flow (release.ps1)

0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
1. Bump core/version.py
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
3. PyInstaller (updater.spec → staging copy → main.spec)
4. ZIP packaging (main.exe + updater.exe)
5. Git commit (version.py + CHANGELOG.md) + tag + push
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
7. Asset upload (main.exe, updater.exe, ZIP)

--DryRun previews without git push or API calls.