# 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** (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

```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`/`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.
