- 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+ 아키텍처 반영
12 KiB
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
- Event monitoring:
- Integration tests (all should be green before release):
python _integration_test.py— business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+)python _i18n_gui_test.py— ko/en switch on real widgetspython _gui_smoke_test.py— widget instantiation
- Production build:
python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec(or justrelease.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.dateUNIQUE — one row per workday.- Overtime bank vs usage: separate tables, both with NULLable
work_record_idfor manual entries — never filterWHERE work_record_id IS NOT NULL. Render NULL rows as "수동 추가" / "Manual". - Time representation:
TimeCalculator.work_minutesis canonical (int).work_hoursis a read-only property. UI/DB syncWORK_MINUTES ↔ WORK_HOURSvia floor (int(min) // 60). - Leave days:
leave_records.daysis FLOAT (1.0 / 0.5 / 0.25). Single source of truth. - Overtime balance:
SUM(bank.earned_minutes) - SUM(usage.used_minutes)viaget_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; useget_setting_int/float/bool()helpers or read fromget_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)andtr_html('help.html.X')fromcore/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.exe — main.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.py—work_minutescanonical,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_minutesconfigurable.i18n.py—_DICTko/en +_HELP_HTML(6 tabs).salary.py—estimate_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— 1Hzupdate_display(), single-instanceQLocalServer, 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_COMPLETEDsentinel).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.py—apply_font_scale(scale),apply_high_contrast(enabled),HIGH_CONTRAST_QSS.chart_widget.py— matplotlib QtAgg helpers,_Fallbackwidget 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.backupAPI.lock_detector.py—OpenInputDesktop+GetUserObjectInformationfor screen lock.http_api.py— stdlibhttp.serveron127.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 formatdate,clock_in,clock_out,lunch_minutes,memo.parse_csv()+import_records(on_conflict).csv_exporter.py— same format.crash_handler.py—install_global_handler()registerssys.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.py—format_hours_minutes(minutes).debug_log.py—dlog(...)env-gated byCLOCKOUT_DEBUG.resource_manager.py— PyInstaller_MEIPASSaware 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 frombuild/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.exerunning →PermissionError. Kill it first.holidayspackage 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): pollshttps://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 viaGITEA_FEEDBACK_ENABLED+GITEA_FEEDBACK_TOKEN. - HTTP API (
utils/http_api.py): bound to127.0.0.1only — 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. holidayspackage:add_korean_holidays_auto()returns-1if package missing → UI falls back toadd_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 subtractingtotal_time_offAFTERcalculate_remaining_timecall. - Manual overtime invisible: previously filtered
work_record_id IS NOT NULL. Now show all rows; label NULL as "수동 추가". annual_leave_totalvsannual_leave_days: two keys for the same value. Auto-synced insave_settings().- Banker's rounding:
round(450/60) = 8in Python (round-half-even). Useint(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.xUser-Agent blocked. Fixed with browser UA indiscord_webhook.py. - Help dialog blank (v2.3.1):
self.setLayout(main_layout)accidentally indented into_reopen_onboardingmethod body. Same regression hitleave_view.pylater. Always verify setLayout is at the END ofinit_ui()after method-body refactors. dist/updater.exewiped bymain.spec --clean: solved by staging copy atbuild/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 -Rawreads CHANGELOG.md as cp949, mangling Korean. Use[System.IO.File]::ReadAllText(path, UTF8). - PowerShell 5.1 NativeCommandError: native commands' stderr triggers
$ErrorActionPreference='Stop'. UseInvoke-Nativehelper withContinueand 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.