22 KiB
Clock-out Time Calculator — Agent Guide
Last verified against the working tree at version 2.11.2 (
core/version.py). This file is written for AI coding agents who need to understand, modify, build, or release the project. When in doubt, prefer the facts in this file over older documentation; this guide was produced by exploring the actual codebase, running the tests, and reading the build scripts.
1. Project Overview
Clock-out Time Calculator (Korean: 퇴근시간 계산기) is a Windows desktop productivity application written in Python with PyQt5. It tracks a user's workday, automatically detects clock-in time from Windows Event Log / boot time, counts down to the expected clock-out time in real time, banks overtime in configurable units, manages annual leave, and provides statistics, notifications, Discord integration, and automatic self-updates.
- Primary language: Python 3.9+
- GUI framework: PyQt5
- Database: SQLite (
database.db) with WAL mode and a 5-second busy timeout - Packaging: PyInstaller (
main.exe+updater.exe) - Distribution: Gitea Releases on a self-hosted instance
- Current version:
2.11.2(single source of truth:core/version.py) - Repository:
kindnick/Clock_out_Time_Calculator
The project is single-file deployable: main.exe embeds updater.exe and extracts it on first launch, so end users only need main.exe.
2. Technology Stack
| Layer | Technology |
|---|---|
| Language | Python 3.9+ |
| GUI | PyQt5 ≥ 5.15 |
| Charts | matplotlib (QtAgg backend) |
| Windows integration | pywin32 (event log), ctypes (screen-lock detection) |
| Date / recurrence | python-dateutil |
| Notifications | plyer (system toast) + PyQt signals |
| Holidays | optional holidays package; government API + fixed-date fallback |
| Packaging | PyInstaller 2-step build |
| Testing | pytest + standalone integration/GUI smoke scripts |
| Fonts | Bundled NanumSquare TTF/OTF files; Malgun Gothic fallback |
Dependencies are declared in requirements.txt:
PyQt5>=5.15.0
pywin32>=305
python-dateutil>=2.8.0
matplotlib>=3.4.0
plyer>=2.0.0
holidays>=0.40
Install with:
pip install -r requirements.txt
pywin32 is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms.
3. Directory Structure and Module Map
Clock-out Time Calculator/
├── main.py # Application entry point / bootstrap
├── updater.py # Standalone update helper process (stdlib only)
├── main.spec # PyInstaller spec for main.exe
├── updater.spec # PyInstaller spec for updater.exe
├── release.ps1 # One-shot release script (PowerShell)
├── requirements.txt # Python dependencies
├── pytest.ini # pytest configuration
├── run_as_admin.bat # Convenience launcher
├── core/ # Business logic and data access
│ ├── database.py # SQLite schema, migrations, CRUD
│ ├── time_calculator.py # Pure time-math engine
│ ├── event_monitor.py # Windows Event Log clock-in detection
│ ├── notifier.py # Notification rule engine
│ ├── salary.py # Optional pay estimation
│ ├── i18n.py # Korean/English translation dictionaries
│ ├── settings_keys.py # Setting key constants
│ ├── achievements.py # 357 achievement definitions + evaluator
│ ├── recurring_leaves.py # Recurring leave pattern expansion
│ └── version.py # __version__ single source of truth
├── ui/ # PyQt5 views and widgets
│ ├── main_window.py # Central 1 Hz main window
│ ├── styles.py # Theme colors and QSS
│ ├── dark_components.py # Reusable dark-styled widgets
│ ├── icons.py # Icon resource helpers
│ ├── i18n_runtime.py # Runtime retranslation registry
│ ├── settings_view.py # Settings dialog
│ ├── stats_view.py # Weekly/monthly/pattern statistics
│ ├── chart_widget.py # matplotlib QtAgg wrapper + fallback
│ ├── calendar_view.py # Work-record calendar
│ ├── leave_calendar_view.py # Color-coded leave calendar
│ ├── break_view.py # Break history dialog
│ ├── overtime_view.py # Overtime bank/usage dialog
│ ├── leave_view.py # Leave management dialog
│ ├── recurring_leave_dialog.py
│ ├── schedule_view.py # Schedule view
│ ├── clock_in_dialog.py # Manual clock-in dialog
│ ├── meal_time_dialog.py # Lunch/dinner start-end input
│ ├── past_record_dialog.py # Add past record dialog
│ ├── achievements_view.py # Achievements browser
│ ├── onboarding_view.py # 5-step first-run wizard
│ ├── today_summary.py # Post-clock-out card
│ ├── goal_widget.py # Monthly goal progress bars
│ ├── mini_widget.py # Always-on-top frameless widget
│ ├── help_view.py # 6-tab help dialog
│ ├── accessibility.py # Font scale / high-contrast helpers
│ └── controllers/ # Thin controllers split from MainWindow
│ ├── lock_monitor.py # Screen-lock auto-break / unlock clock-in
│ ├── auto_lunch.py # Auto-lunch after 4 hours
│ ├── notification_orchestrator.py # 5-min-tick orchestrator
│ └── meal_controller.py # Lunch/dinner toggle handling
├── utils/ # Helpers and integrations
│ ├── backup.py # Daily SQLite backup with 7-rotation
│ ├── lock_detector.py # Win32 screen-lock detection
│ ├── discord_webhook.py # Discord embed pushes
│ ├── csv_importer.py # CSV import (standard format)
│ ├── csv_exporter.py # CSV export
│ ├── crash_handler.py # Global excepthook + optional Gitea report
│ ├── updater_client.py # Gitea Releases API client
│ ├── system_tray.py # System tray menu
│ ├── time_format.py # format_hours_minutes helper
│ ├── font_loader.py # NanumSquare font loading
│ ├── resource_manager.py # PyInstaller _MEIPASS path resolver
│ ├── debug_log.py # CLOCKOUT_DEBUG gated logging
│ └── holiday_api.py # Korean holiday API client
├── tests/ # pytest unit tests (13 files)
├── font/ # Bundled NanumSquare fonts
├── resources/ # Icons and resource links
├── analysis/ # Currently empty (only __init__.py)
├── build/ # Build staging directory
└── dist/ # Built EXEs and release ZIPs
Note:
utils/http_api.pyis referenced in older documentation but is not present in the current working tree.
4. How to Build, Run, and Smoke-Test
Development run
python main.py
main.py inserts the project root into sys.path, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches MainWindow.
A convenience batch file is provided:
run_as_admin.bat
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
Module-level smoke tests
python core/event_monitor.py
python core/time_calculator.py
These run lightweight self-tests when invoked as scripts.
Production build (manual two-step)
python -m PyInstaller --clean updater.spec
mkdir -p build/staging && cp dist/updater.exe build/staging/
python -m PyInstaller --clean main.spec
updater.specbuildsdist/updater.exe(~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).main.specbuildsdist/main.exe(~78 MB) and embedsupdater.exefrombuild/staging/updater.exe(falling back todist/updater.exe).- The staging copy is critical:
main.spec --cleanwipesdist/, so withoutbuild/staging/updater.exethe updater would be deleted mid-build.
Production build (one-shot)
.\release.ps1 v2.11.2
The version argument must match ^v\d+\.\d+\.\d+$ and will be written into core/version.py.
Build gotchas
- Kill any running
main.exebefore building or PyInstaller will fail withPermissionError. - The
holidayspackage is only baked into the EXE if it is installed in the build environment. - Optional code signing is supported via
$env:CODE_SIGN_CERT(.pfx path) and$env:CODE_SIGN_PASS. main.speclists severalhiddenimportsthat are easy to break:holidays,holidays.countries.south_korea,win32evtlog,win32evtlogutil,matplotlib.backends.backend_qtagg,matplotlib.backends.backend_qt5agg,PyQt5.QtSvg,PyQt5.sip, andnumpy.core._multiarray_tests.
5. Testing Strategy
The project uses three layers of tests.
5.1 Unit tests (pytest)
Configuration: pytest.ini
python -m pytest tests/ -v --tb=short
There are 13 test files under tests/:
test_time_calculator.py— clock-out, overtime truncation, holiday overtime, day-type detectiontest_database.py— settings, migrations, leave calculations, consecutive OT daystest_csv_importer.py— CSV parsing, validation, conflict handlingtest_salary.py— pay estimation and won formattingtest_recurring_leaves.py— pattern parsing and expansiontest_crash_handler.py— crash log insertion and Gitea reporting (mocked)test_discord_webhook.py— URL validation, payload shape, network errorstest_holiday_api.py— API response parsing and error handlingtest_i18n.py— language switching, missing-key fallback, interpolationtest_i18n_runtime.py— runtime retranslation of Qt widgetstest_overtime_accrual_guard.py— auto-overtime rollover guardtest_updater.py— version parsing, Gitea API URL, update logic
tests/conftest.py sets CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 so the background holiday-sync thread does not hold open temporary DB files during test cleanup.
Current status: 194 passed.
5.2 Integration scenarios
python _integration_test.py
A standalone script with a custom @case decorator. It runs 53 scenarios (S1–S52, S52A–E) covering fresh-install migrations, work-pattern calculations, overtime banking, leave, weekends/holidays, notifications, backup, settings sync, i18n, CSV import/export, salary, crash log, updater semver, Discord guards, goals, and accessibility keys.
Current status: PASS: 53 FAIL: 0 WARN: 0.
5.3 GUI smoke tests
python _i18n_gui_test.py # Korean/English label switching
python _gui_smoke_test.py # Widget instantiation
Both use QT_QPA_PLATFORM=offscreen so no real windows appear.
_i18n_gui_test.py: 5 cases, all passing._gui_smoke_test.py: 8 cases, all passing.
5.4 Pre-release test command summary
python -m pytest tests/ -q
python _integration_test.py
python _i18n_gui_test.py
python _gui_smoke_test.py
release.ps1 runs the first two by default (skippable with -SkipTests).
6. Code Style and Conventions
Identifiers
- Classes:
PascalCase - Functions / variables:
snake_case - Module-level constants (especially setting keys):
UPPER_CASE
Settings keys
All setting keys are defined as constants in core/settings_keys.py. Import and use the constants; never use raw strings for new logic. When adding a new key, also add a default value in Database.init_default_settings().
Imports
Order is typically: standard library → third-party → project modules. Several newer modules use from __future__ import annotations.
Comments and docstrings
Code identifiers are English, but inline comments and docstrings are predominantly Korean. New code should follow the existing bilingual style: English identifiers, Korean explanatory comments.
UI construction
- Build widgets in
init_ui(). - Use helper methods such as
create_*_group()for readability. - Set
objectNamefor QSS styling. - Always verify
self.setLayout(main_layout)is at the end ofinit_ui(), not accidentally indented into a method body.
Type hints
Use type hints on public DB and helper methods (-> int, -> Optional[Dict], -> List[Dict], etc.).
String formatting
Use f-strings for internal messages. For user-visible text, use the i18n API:
from core.i18n import tr
tr('key', name=value)
No enforced linter
There is no pyproject.toml, .flake8, or setup.cfg. Formatting is informal; keep line lengths reasonable and match the surrounding style.
7. Critical Invariants (MUST PRESERVE)
7.1 Time-off subtraction order in MainWindow.update_display()
Pass the actual break_minutes to TimeCalculator.calculate_remaining_time(), then subtract total_time_off = overtime_used + leave_used from the resulting timedelta after the call. Never mutate break_minutes to break_minutes - overtime_used before calling. Doing so previously caused a "+29h remaining time" bug.
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)
7.2 Hot-path caching
update_display() runs at 1 Hz. DB reads inside it must be cached or throttled:
cached_time_formatinMainWindowAutoLunchManagercaches enabled/non-working stateNotificationOrchestratorgates periodic checks to 5-minute buckets- Use
_set_text_if_changed()to avoid useless repaints
7.3 24-hour internal time
All calculation uses 24-hour datetime. 12-hour conversion (Korean "오전"/"오후") happens only in MainWindow.format_time().
7.4 Workday boundary
workday_boundary_hour defaults to 6. Overnight work stays on the previous day's record until that hour. Do not naively use date.today() in time logic.
7.5 Database invariants
work_records.dateisUNIQUE.overtime_bank.work_record_idandovertime_usage.work_record_idare NULLable for manual entries. Never filterWHERE work_record_id IS NOT NULL. Render NULL rows as "수동 추가" / "Manual".leave_records.daysisREAL(1.0 / 0.5 / 0.25 / hourly).- Canonical time unit is minutes (
work_minutes).work_hoursis a derived/floor-synced property. Useint(minutes) // 60for hours, notround(). - Overtime balance =
SUM(bank.earned_minutes) - SUM(usage.used_minutes)plus anyINITIAL_OVERTIME_MINUTES. - WAL mode + 5-second busy timeout is enabled for cloud-sync friendliness (OneDrive/Dropbox).
7.6 Migration idempotency
Every migrate_*() method must early-return if already applied. Use sentinel settings or IF NOT EXISTS. Examples: balance_adjustment_migrated_v2, annual_leave_keys_migrated.
7.7 Settings auto-sync pairs
Database.save_settings() automatically keeps these pairs in sync:
WORK_MINUTES ↔ WORK_HOURS(floor division)ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL
7.8 Single-file deployment and updater handoff
main.exeembedsupdater.exeviamain.specdata files._ensure_updater_extracted()inmain.pyextracts the embedded updater on first launch fromsys._MEIPASS.- Protect the staging copy at
build/staging/updater.exe. updater.pyis standalone (no Qt/network deps). It accepts--pid,--new, and--target, waits for the PID, swaps files with.bakrollback, and relaunches.
8. Security Considerations
8.1 HTTP API
utils/http_api.py is not present in the current tree. If it is reintroduced, it must bind to 127.0.0.1 only and remain read-only. Never expose it externally.
8.2 Discord webhook
- URL is validated against official Discord webhook domains (
discord.com,discordapp.com, canary/ptb). - A browser User-Agent is mandatory; Cloudflare blocks the default Python UA.
- Push failures are silent so they do not break the app.
8.3 Auto-update
- Update check polls a self-hosted Gitea API:
https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest. - Downloads
main_new.exenext to the running executable;updater.exeperforms the swap with.bakrollback. - Update apply only works in frozen builds; development
.pyruns are notified but not modified.
8.4 DB path handling and cloud sync
main.pyandMainWindowboth bootstrap with the default DB first, readDB_PATH_OVERRIDE, then reopen with the override path. Do not break this order.- WAL mode + busy timeout tolerates OneDrive/Dropbox sync.
8.5 Crash reporting
- Gitea issue reporting is opt-in via
GITEA_FEEDBACK_ENABLED+GITEA_FEEDBACK_TOKEN. - Tokens are stored as plain strings in the SQLite
settingstable. The UI usesQLineEdit.Passwordecho mode.
8.6 Single instance
Multiple copies are prevented via QLocalServer named ClockOutCalculatorInstance.
8.7 Debug logging
utils/debug_log.py only emits output when CLOCKOUT_DEBUG=1 (or true/yes).
9. External Integrations
- Auto-update (
utils/updater_client.py): polls the Gitea Releases API. User-Agent:ClockOutCalculator-Updater/1.0. - Discord webhook (
utils/discord_webhook.py): optional one-direction push. Browser User-Agent required. - Gitea Issues (
utils/crash_handler.py): optional crash reporting; opt-in only. - Cloud sync via
DB_PATH_OVERRIDE: settings stores the DB path; the app reopens the override path after reading it. holidayspackage:Database.add_korean_holidays_auto()returns-1if the package is missing, and the UI falls back toadd_korean_holidays()(8 fixed dates).- Korean holiday API (
utils/holiday_api.py): 공공데이터포털 특일정보 API 키는CLOCKOUT_HOLIDAY_API_KEY환경변수에서 읽음. 소스코드/바이너리에 키를 하드코딩하지 않음.
10. i18n Conventions
- API:
tr(key, **kwargs)andtr_html(key)fromcore/i18n.py. - Translations live in
_DICT['ko']and_DICT['en']. Both languages must receive any new key. - Sentence interpolation uses Python
str.format(**kwargs). - HelpView HTML content is in
_HELP_HTML. - Runtime retranslation is supported via
ui/i18n_runtime.pyusing a weakref registry. Register widgets withregister(widget, key, setter='setText', kwargs={}, post=None)and callset_language_and_retranslate(lang)to update them. - UI files (
ui/) and achievement metadata (core/achievements.py) are fully key-based. - Remaining P4 internal-data hardcoding (not user-facing labels) includes DB-stored
leave_typevalues and Korean holiday names incore/database.py. - A language change may still prompt a restart for widgets not registered for runtime retranslate.
11. Release Flow
release.ps1 vX.Y.Z performs the full release locally:
- Pre-checks: verify
$env:GITEA_TOKEN, no runningmain.exe, no existing tag, no uncommitted changes (unless-DryRun). - Bump version: rewrite
core/version.py. - Tests: run
pytest tests/andpython _integration_test.py(skippable with-SkipTests). - Build:
updater.spec→build/staging/→main.spec. - Code signing (optional): sign both EXEs if
$env:CODE_SIGN_CERTis set. - ZIP packaging:
dist/ClockOutCalculator-vX.Y.Z.zipcontainingmain.exeandupdater.exe. - Git commit + tag + push: commit
core/version.pyandCHANGELOG.md. - Gitea Release POST: read
CHANGELOG.mdwith UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section. - Asset upload:
main.exe,updater.exe, and the ZIP.
Use -DryRun to preview without git push or API calls.
12. Past Incidents (Do Not Re-introduce)
| Incident | Root cause / fix |
|---|---|
| +29h remaining time | Mutating break_minutes -= overtime_used before calculation. Fixed by subtracting total_time_off after calculate_remaining_time. |
| Manual overtime invisible | Filtering work_record_id IS NOT NULL. Now show all rows and label NULL as "수동 추가" / "Manual". |
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 because Python rounds half to even. Use int(minutes) // 60. |
| FK enforcement rollback | PRAGMA foreign_keys=ON conflicted with existing manual overtime records. Kept WAL + timeout, no FK enforcement. |
| Discord 403 / Cloudflare 1010 | Default Python UA blocked. Fixed with browser UA. |
| Help dialog blank | self.setLayout(main_layout) indented into _reopen_onboarding. Verify setLayout() is at the end of init_ui(). |
dist/updater.exe wiped by --clean |
Solved by staging copy at build/staging/updater.exe. |
| Onboarding auto-skipped | Existing users with work_records were auto-completed. Added "Re-run Onboarding" button to Help. |
| PowerShell 5.1 ANSI | Get-Content/Set-Content default to ANSI and mangle Korean in CHANGELOG.md and core/version.py. Use [System.IO.File]::ReadAllText/WriteAllText(path, UTF8). |
| PowerShell NativeCommandError | Use Invoke-Native helper with $ErrorActionPreference = 'Continue' and explicit $LASTEXITCODE. |
| Frozen chart numpy failure | Added numpy.core._multiarray_tests to main.spec hiddenimports. |
13. Additional References
README.md— end-user feature overview (Korean/English mixed).CLAUDE.md— additional Korean-language guidance for this project.INSTALL.md— installation and troubleshooting instructions.CHANGELOG.md— release notes in Korean.resources/resource_links.md— links to external resources.
When modifying code, update this AGENTS.md if you change any conventions, build steps, module map, or external integrations described here.