22 KiB
Raw Blame History

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.py is 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.spec builds dist/updater.exe (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
  • main.spec builds dist/main.exe (~78 MB) and embeds updater.exe from build/staging/updater.exe (falling back to dist/updater.exe).
  • The staging copy is critical: main.spec --clean wipes dist/, so without build/staging/updater.exe the 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.exe before building or PyInstaller will fail with PermissionError.
  • The holidays package 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.spec lists several hiddenimports that are easy to break: holidays, holidays.countries.south_korea, win32evtlog, win32evtlogutil, matplotlib.backends.backend_qtagg, matplotlib.backends.backend_qt5agg, PyQt5.QtSvg, PyQt5.sip, and numpy.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 detection
  • test_database.py — settings, migrations, leave calculations, consecutive OT days
  • test_csv_importer.py — CSV parsing, validation, conflict handling
  • test_salary.py — pay estimation and won formatting
  • test_recurring_leaves.py — pattern parsing and expansion
  • test_crash_handler.py — crash log insertion and Gitea reporting (mocked)
  • test_discord_webhook.py — URL validation, payload shape, network errors
  • test_holiday_api.py — API response parsing and error handling
  • test_i18n.py — language switching, missing-key fallback, interpolation
  • test_i18n_runtime.py — runtime retranslation of Qt widgets
  • test_overtime_accrual_guard.py — auto-overtime rollover guard
  • test_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 (S1S52, S52AE) 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 objectName for QSS styling.
  • Always verify self.setLayout(main_layout) is at the end of init_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_format in MainWindow
  • AutoLunchManager caches enabled/non-working state
  • NotificationOrchestrator gates 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.date is UNIQUE.
  • overtime_bank.work_record_id and overtime_usage.work_record_id are NULLable for manual entries. Never filter WHERE work_record_id IS NOT NULL. Render NULL rows as "수동 추가" / "Manual".
  • leave_records.days is REAL (1.0 / 0.5 / 0.25 / hourly).
  • Canonical time unit is minutes (work_minutes). work_hours is a derived/floor-synced property. Use int(minutes) // 60 for hours, not round().
  • Overtime balance = SUM(bank.earned_minutes) - SUM(usage.used_minutes) plus any INITIAL_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.exe embeds updater.exe via main.spec data files.
  • _ensure_updater_extracted() in main.py extracts the embedded updater on first launch from sys._MEIPASS.
  • Protect the staging copy at build/staging/updater.exe.
  • updater.py is standalone (no Qt/network deps). It accepts --pid, --new, and --target, waits for the PID, swaps files with .bak rollback, 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.exe next to the running executable; updater.exe performs the swap with .bak rollback.
  • Update apply only works in frozen builds; development .py runs are notified but not modified.

8.4 DB path handling and cloud sync

  • main.py and MainWindow both bootstrap with the default DB first, read DB_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 settings table. The UI uses QLineEdit.Password echo 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.
  • holidays package: Database.add_korean_holidays_auto() returns -1 if the package is missing, and the UI falls back to add_korean_holidays() (8 fixed dates).
  • Korean holiday API (utils/holiday_api.py): 공공데이터포털 특일정보 API 키는 CLOCKOUT_HOLIDAY_API_KEY 환경변수에서 읽음. 소스코드/바이너리에 키를 하드코딩하지 않음.

10. i18n Conventions

  • API: tr(key, **kwargs) and tr_html(key) from core/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.py using a weakref registry. Register widgets with register(widget, key, setter='setText', kwargs={}, post=None) and call set_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_type values and Korean holiday names in core/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:

  1. Pre-checks: verify $env:GITEA_TOKEN, no running main.exe, no existing tag, no uncommitted changes (unless -DryRun).
  2. Bump version: rewrite core/version.py.
  3. Tests: run pytest tests/ and python _integration_test.py (skippable with -SkipTests).
  4. Build: updater.specbuild/staging/main.spec.
  5. Code signing (optional): sign both EXEs if $env:CODE_SIGN_CERT is set.
  6. ZIP packaging: dist/ClockOutCalculator-vX.Y.Z.zip containing main.exe and updater.exe.
  7. Git commit + tag + push: commit core/version.py and CHANGELOG.md.
  8. Gitea Release POST: read CHANGELOG.md with UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section.
  9. 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 -Raw mangled Korean. Use [System.IO.File]::ReadAllText(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.