477 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`:
```text
PyQt5>=5.15.0
pywin32>=305
python-dateutil>=2.8.0
matplotlib>=3.4.0
plyer>=2.0.0
holidays>=0.40
```
Install with:
```bash
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
```text
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
```bash
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:
```bash
run_as_admin.bat
```
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
### Module-level smoke tests
```bash
python core/event_monitor.py
python core/time_calculator.py
```
These run lightweight self-tests when invoked as scripts.
### Production build (manual two-step)
```bash
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)
```bash
.\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`
```bash
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
```bash
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
```bash
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
```bash
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:
```python
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.
```python
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.spec``build/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.