v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱

- 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+ 아키텍처 반영
This commit is contained in:
KINDNICK 2026-04-30 19:30:47 +09:00
parent 6a17876af1
commit ff71886fd7
21 changed files with 1744 additions and 336 deletions

163
AGENTS.md
View File

@ -1,20 +1,40 @@
# Project Conventions and Operational Gotchas # Project Conventions and Operational Gotchas
## 🛠️ Setup & Execution ## 🛠️ Setup & Execution
- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature. - **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
- **Run:** `python main.py` - **Run:** `python main.py`
- **Module-level tests:** - **Module-level smoke:**
- Event monitoring: `python core/event_monitor.py` - Event monitoring: `python core/event_monitor.py`
- Time calculation: `python core/time_calculator.py` - Time calculation: `python core/time_calculator.py`
- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release. - **Integration tests** (all should be green before release):
- `python _integration_test.py` — business-logic scenarios (35+ for v2.02.2 + 15+ for v2.3+)
- `python _i18n_gui_test.py` — ko/en switch on real widgets
- `python _gui_smoke_test.py` — widget instantiation
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
## 🗄️ Architecture Notes (Core Business Logic) ## 🗄️ Architecture Notes (Core Business Logic)
- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs.
### 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.date` UNIQUE** — one row per workday. - **`work_records.date` UNIQUE** — one row per workday.
- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out. - **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours). - **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed). - **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect. - **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_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; use `get_setting_int/float/bool()` helpers or read from `get_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)` and `tr_html('help.html.X')` from `core/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) ## ⚠️ Critical Invariants (MUST PRESERVE)
@ -22,46 +42,135 @@
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. 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 ### 2. Hot-path caching
`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`. `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 ### 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). 24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
### 4. Workday boundary ### 4. Workday boundary
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover. `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 ### 5. Migration idempotency
All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query. 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_minutes` canonical, `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_minutes` configurable.
- `i18n.py``_DICT` ko/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` — 1Hz `update_display()`, single-instance `QLocalServer`, 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_COMPLETED` sentinel).
- `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, `_Fallback` widget 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.backup` API.
- `lock_detector.py``OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
- `http_api.py` — stdlib `http.server` on `127.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 format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
- `csv_exporter.py` — same format.
- `crash_handler.py``install_global_handler()` registers `sys.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 by `CLOCKOUT_DEBUG`.
- `resource_manager.py` — PyInstaller `_MEIPASS` aware 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 from `build/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 ## ⚙️ Build Process
```bash ```bash
python -m PyInstaller --clean main.spec # 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
``` ```
- Output: `dist/main.exe` (console disabled, UPX compressed) - `dist/main.exe` running → `PermissionError`. Kill it first.
- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`) - `holidays` package only baked in if installed in build env.
- **Stale `dist/main.exe` running**`PermissionError`. Kill it first. - Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env.
## 🚦 External Integrations ## 🚦 External Integrations
- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view. - **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<version>`. Repo must be public.
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design. - **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 via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — 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. - **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.
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates). - **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
## 🐞 Past Incidents ## 🐞 Past Incidents (do NOT re-introduce)
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce. - **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual". - **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling. - **`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 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters. - **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(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.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/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 -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
## 🌐 i18n Coverage Status ## 🌐 i18n Coverage Status
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels. - **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 (입력란 placeholder 등), calendar_view detail labels. - **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated. - **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')`. 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](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.

View File

@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.7.0] — 2026-04-30
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
- **i18n 사전 100% 커버리지**`break_view`, `overtime_view`, `leave_view`,
`clock_in_dialog` 의 내부 라벨/메시지/플레이스홀더까지 전부 ko/en 키화.
새 키 50+개 추가 (`view.break.*`, `view.overtime.*`, `view.leave.*`, `dlg.*`).
- **i18n 런타임 재번역** — 재시작 없이 메인 화면 즉시 언어 전환.
- `ui/i18n_runtime.py`: `register(widget, key)` + `set_language_and_retranslate(lang)`
- 위젯은 weakref로 보관되어 삭제 시 자동 정리
- 메인 윈도우 타이틀/하단 메뉴 5개 버튼 등록 완료 (점진 확대)
- 일부 다이얼로그는 여전히 재시작 필요 (다음 릴리스에서 점진 등록)
- **MealController 분리**`main_window.py` 에서 점심/저녁 토글·라벨 갱신 로직을
`ui/controllers/meal_controller.py` 로 추출. 기존 `LockMonitor`/`AutoLunch`/
`NotificationOrchestrator` 패턴 준수.
### Tests
- 통합 테스트 +15 시나리오 (S36S52): 온보딩 신규/기존, salary 추정, CSV 가져오기
(skip/overwrite/overtime 적립까지), notification_log dedupe, meal_record,
crash_log, updater semver 비교, Discord 입력 검증, goal/accessibility 키 등.
- 신규 pytest 모듈 4종 — `test_salary.py`, `test_csv_importer.py`,
`test_discord_webhook.py`(network mocked), `test_crash_handler.py`.
- `test_i18n_runtime.py` — register/retranslate/post-callback/dead-widget 정리 검증.
- 총 pytest 케이스: 90 → **122**, 통합 시나리오: 33 → **48**.
### Docs
- `README.md` v2.4v2.6 신기능 섹션 정리, 재번호.
- `CLAUDE.md` v2.6+ 아키텍처 — 컨트롤러 모듈, 35+ 설정 키, 릴리스 플로우 반영.
- `INSTALL.md` 최신 단일파일 배포 + 온보딩 + 디스코드 + 환경변수 정리.
- `AGENTS.md` 10+ DB 테이블, 컨트롤러 맵, Past Incidents 갱신.
## [2.6.0] — 2026-04-30 ## [2.6.0] — 2026-04-30
### Added — Phase 4 (3종) ### Added — Phase 4 (3종)

154
CLAUDE.md
View File

@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable). **Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases.
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`. **Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`.
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md). Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md).
## Build and Run ## Build and Run
@ -20,46 +20,73 @@ python main.py
python core/event_monitor.py python core/event_monitor.py
python core/time_calculator.py python core/time_calculator.py
# Production build → dist/main.exe # Production build → dist/main.exe (78MB, embeds updater.exe)
python -m PyInstaller --clean updater.spec # build first — main.spec datas references it
python -m PyInstaller --clean main.spec python -m PyInstaller --clean main.spec
# Integration tests (35 + 5 + view scenarios) # Tests
python _integration_test.py python _integration_test.py # business-logic scenarios
python _i18n_gui_test.py python _i18n_gui_test.py # ko/en GUI verification
python _gui_smoke_test.py python _gui_smoke_test.py # widget instantiation
``` python -m pytest tests # unit tests
PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build. # Release (one-shot to Gitea)
$env:GITEA_TOKEN = '<PAT>'
.\release.ps1 v2.7.0
```
## Architecture ## Architecture
### Core (`core/`) ### core/
- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`. - **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness.
- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`. - **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property.
- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required. - **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en. - **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold.
- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback. - **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`. - **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0).
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. - **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
- **[version.py](core/version.py)** — `__version__` single source of truth.
### UI (`ui/`) ### ui/
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R). - **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run.
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`. - **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog.
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button. - **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import.
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible. - **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`.
- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs. - **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension). - **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing. - **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input.
- **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click).
- **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar.
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display.
- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button.
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`.
- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay.
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n).
### Utils (`utils/`) ### ui/controllers/
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy. - **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked). - **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`. - **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production.
### utils/
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
- **[lock_detector.py](utils/lock_detector.py)** — `is_screen_locked()` via Win32 `OpenInputDesktop` + `GetUserObjectInformation`.
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper. - **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n. - **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n.
### Time-off accounting in `update_display()` ### Top-level
- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow.
- **[updater.py](updater.py)** — Standalone helper. `--pid <main_pid> --new <new_exe> --target <target_exe>`. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback.
- **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps).
- **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it).
- **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`.
## Time-off accounting in `update_display()`
Critical invariant — preserve in any change: Critical invariant — preserve in any change:
```python ```python
@ -72,49 +99,54 @@ remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_min
remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation
``` ```
Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`.
### Workday rollover
`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006).
## Database invariants ## Database invariants
- `work_records.date` UNIQUE (one row/day). - `work_records.date` UNIQUE.
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings. - `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`.
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label. - `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions.
- `leave_records.days` is FLOAT (1.0/0.5/0.25). - `leave_records.days` is FLOAT (1.0/0.5/0.25).
- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`. - Balance: `SUM(bank.earned) - SUM(usage.used)`.
- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code. - `notification_log` for daily dedupe (channel+event_type+date).
- `crash_log` for unhandled exceptions.
## Settings system ## Settings system
Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`: Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`:
- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only) - `WORK_MINUTES ↔ WORK_HOURS` (floor)
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL` - `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup. Migration sentinels prevent re-running.
## i18n ## i18n
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal. `tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장.
Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text). Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched.
## Conventions and gotchas ## Conventions
- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types. - **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict.
- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`. - 24h `datetime` internal. 12h conversion only in `format_time()`.
- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover. - 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min.
- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min. - Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture. - PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. - main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon).
## Tests ## Tests
- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt). - [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`. - [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets. - [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+).
All three should be green before any release. All should be green before any release.
## Release flow
```bash
# Edit core/version.py + CHANGELOG.md
git add -A && git commit -m "v2.X.Y: ..."
.\release.ps1 v2.X.Y
```
Auto-handles: version bump check, pytest+integration tests, two-exe build, ZIP, git tag push, Gitea Release create, asset upload (main.exe + updater.exe + ZIP).

View File

@ -1,24 +1,45 @@
# 설치 가이드 # 설치 가이드
## 1. Python 설치 ## 일반 사용자 — 빌드된 .exe로 설치 (권장)
소스 빌드 없이 즉시 사용하려면 Gitea Releases에서 받으세요.
1. https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator/releases
2. 최신 릴리스의 **main.exe** (단일 파일) 다운로드
3. 원하는 폴더에 두고 더블클릭으로 실행
4. 첫 실행 시 5단계 온보딩 위저드가 안내
`main.exe` 안에 `updater.exe`가 내장되어 있어 첫 실행 시 같은 폴더로 자동 추출됩니다.
이후 새 버전이 올라오면 앱이 알림 → 동의 → 자동 다운로드·교체·재시작합니다.
> 옵션: 직접 실행하지 않고 ZIP을 받으면 `main.exe + updater.exe`가 같이 들어 있습니다.
## 개발자 — 소스에서 실행
### 1. Python 설치
Python 3.9 이상이 필요합니다. Python 3.9 이상이 필요합니다.
### Windows #### Windows
1. https://www.python.org/downloads/ 방문 1. https://www.python.org/downloads/ 방문
2. "Download Python 3.x.x" 클릭 2. "Download Python 3.x.x" 클릭
3. 설치 시 **"Add Python to PATH"** 체크 필수! 3. 설치 시 **"Add Python to PATH"** 체크 필수
확인: 확인:
```bash ```bash
python --version python --version
``` ```
## 2. 프로젝트 다운로드 ### 2. 프로젝트 다운로드
프로젝트를 다운로드하거나 압축을 해제합니다. ```bash
git clone https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator.git
cd Clock_out_Time_Calculator
```
## 3. 패키지 설치 또는 ZIP을 받아 압축 해제.
### 3. 패키지 설치
프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다. 프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다.
@ -26,66 +47,26 @@ python --version
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 설치되는 패키지: #### 설치되는 패키지 (requirements.txt)
1. **PyQt5** - GUI 프레임워크 1. **PyQt5** GUI 프레임워크
2. **pywin32** - Windows API 접근 2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지)
3. **python-dateutil** - 날짜 계산 3. **python-dateutil** 날짜 계산
4. **matplotlib** - 그래프 4. **matplotlib** — 통계 차트
5. **plyer** - 알림 시스템 5. **plyer** — 시스템 알림
6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함) 6. **holidays** 한국 공휴일 자동 등록 (음력 명절 포함)
## 4. 리소스 다운로드 (선택) ### 4. 실행
리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다.
```bash
python download_resources.py
```
이 스크립트는 무료 리소스 다운로드 링크를 안내합니다.
### 수동 다운로드:
#### 아이콘
다음 사이트에서 다운로드:
- **Flaticon**: https://www.flaticon.com/
- **Material Design Icons**: https://materialdesignicons.com/
- **Icons8**: https://icons8.com/
다운로드 후 `resources/icons/` 폴더에 저장
필요한 아이콘:
- `app_icon.ico` (512x512)
- `clock.png`, `timer.png`, `lunch.png`
- `calendar.png`, `statistics.png`, `vacation.png`
- `settings.png`, `notification.png`
#### 사운드
다음 사이트에서 다운로드:
- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천)
- **Freesound**: https://freesound.org/
- **Zapsplat**: https://www.zapsplat.com/
다운로드 후 `resources/sounds/` 폴더에 저장
필요한 사운드:
- `clock_out_alarm.wav` - 퇴근시간 알림
- `notification.wav` - 일반 알림
- `success.wav` - 성공 효과음
## 5. 실행
```bash ```bash
python main.py python main.py
``` ```
### 관리자 권한으로 실행 (권장) #### 관리자 권한으로 실행 (권장)
Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다. Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다.
1. **방법 1**: cmd를 관리자 권한으로 실행 1. **방법 1**: cmd를 관리자 권한으로 실행
- Windows 키 + X - Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)"
- "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택
- 프로젝트 폴더로 이동: `cd "경로"` - 프로젝트 폴더로 이동: `cd "경로"`
- `python main.py` 실행 - `python main.py` 실행
@ -93,11 +74,39 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있
- `python main.py`를 실행하는 배치 파일(.bat) 생성 - `python main.py`를 실행하는 배치 파일(.bat) 생성
- 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크 - 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크
## 6. 첫 실행 ### 5. 첫 실행
1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성 1. 실행 시 자동으로 데이터베이스(`database.db`) 생성
2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지 2. 5단계 온보딩 위저드 표시
3. 감지된 시간이 출근시간으로 설정됨 - 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → Discord 웹훅(옵션)
3. 위저드 완료 후 메인 화면 진입
4. 이후 실행에서는 위저드 없이 바로 시작
### 6. 단축근무자 설정 (예: 7시간 30분)
온보딩에서 미설정한 경우:
1. 설정(`Ctrl+,`) → 근무 시간 → **근무 패턴**
2. "단축근무 7시간 30분 (점심 30분)" 또는 사용자 정의 선택
3. 시·분 직접 입력 가능 (5분 단위)
4. 저장 → 즉시 메인 화면 반영
## 클라우드 동기화 (여러 PC 공용)
OneDrive / Dropbox 폴더에 DB를 두면 자동 동기화됩니다 (WAL 모드).
1. OneDrive/Dropbox 안에 `database.db` 위치 결정
2. 설정 → 데이터 관리 → DB 경로 → 변경
3. 기존 DB를 새 위치로 복사 → 재시작
4. 다른 PC에서도 같은 경로 지정하면 데이터 공유
## 환경 변수
| 변수 | 용도 |
|------|------|
| `CLOCKOUT_DEBUG=1` | `~/.clockout_logs/debug.log`에 디버그 로그 출력 |
| `CLOCKOUT_DEBUG_DIR=경로` | 로그 저장 위치 변경 |
| `GITEA_TOKEN` | 릴리스 발행 시 PAT (개발자용) |
| `CODE_SIGN_CERT` / `CODE_SIGN_PASS` | Authenticode 인증서 경로/암호 (개발자용) |
## 문제 해결 ## 문제 해결
@ -119,42 +128,57 @@ https://aka.ms/vs/17/release/vc_redist.x64.exe
### 이벤트 뷰어 접근 불가 ### 이벤트 뷰어 접근 불가
- 관리자 권한으로 실행 - 관리자 권한으로 실행
- 또는 설정에서 수동 입력 모드 사용 - 또는 메인 화면 출근시각 옆 ✏️ 버튼으로 수동 입력
### Discord 웹훅 "실패" 표시
- v2.3.3 이전 버전에서 발생 — Cloudflare가 Python User-Agent 차단
- 최신 버전으로 업데이트하면 해결 (브라우저 UA로 우회)
### 온보딩 위저드를 다시 보고 싶음
- 도움말(F1) → "🚀 온보딩 다시 보기"
## 업그레이드 ## 업그레이드
### .exe 사용자
- 앱이 자동 감지 → 알림 → 동의 → 자동 처리
- 수동 트리거: F5 또는 설정 → 데이터 관리 → "업데이트 확인"
### 소스 사용자
```bash ```bash
git pull
pip install --upgrade -r requirements.txt pip install --upgrade -r requirements.txt
``` ```
## 제거 ## 제거
1. 프로젝트 폴더 삭제 1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스)
2. 패키지 제거 (선택): 2. 데이터 보존하려면 `database.db`만 별도 백업
```bash 3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제)
pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer
```
## 프로덕션 빌드 (PyInstaller) ## 프로덕션 빌드 (PyInstaller)
소스 없이 실행 파일만 배포하려면: 소스 없이 실행 파일만 배포하려면:
```bash ```bash
python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB) # 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드)
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터) python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
# updater.exe를 staging 폴더로 복사 (main.spec --clean 시 보호)
mkdir -p build/staging
cp dist/updater.exe build/staging/
# 메인 앱 빌드 (updater.exe 임베드 포함)
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB)
``` ```
배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요. 또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화.
자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다.
빌드 시 주의: 빌드 시 주의:
- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행 - `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행
- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨 - `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨
- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출)
## 환경 변수
- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력
- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경
## 다음 단계 ## 다음 단계
설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요! 설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요.
개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요.

View File

@ -45,9 +45,11 @@
### 7. 통계·분석 ### 7. 통계·분석
- 주간/월간 요약 + matplotlib 차트 - 주간/월간 요약 + matplotlib 차트
- 일별 근무시간 + 연장 누적 막대 그래프 - 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁)
- 요일별 평균 근무시간 - 요일별 평균 근무시간
- 근무 패턴 인사이트 (정적 통계 요약) - 출근 시각 분포 히스토그램 (30분 단위 + 평균선)
- 근무 패턴 인사이트
- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약)
### 8. 공휴일 관리 ### 8. 공휴일 관리
- 한국 공휴일 자동 등록 (`holidays` 패키지) - 한국 공휴일 자동 등록 (`holidays` 패키지)
@ -69,13 +71,35 @@
- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작 - 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작
- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거 - F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거
- 실패 시 자동 롤백 - 실패 시 자동 롤백
- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출)
### 12. 다국어 지원 (i18n) ### 12. 첫 실행 온보딩 위저드
- 한국어 / English 전환 - 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시
- 알림 메시지·UI 라벨 28개 카테고리 - 기존 사용자: 자동 완료 처리 + 도움말(F1) → "🚀 온보딩 다시 보기"
### 13. 사용자 친화 기능
- **메인 화면 인라인 편집** — 출퇴근 시각 라벨 클릭으로 즉시 수정
- **퇴근 후 "오늘 요약" 카드** — 총 근무·점심·외출·연장·추정급여 한눈에
- **장시간 근무 휴식 권고** — 4시간 연속 근무 시 토스트 + Discord push
- **점심/저녁 실제 시간 입력** — 버튼 우클릭 → 시작·종료 시각
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
- **월간 목표** — 연장근무 상한 / 일평균 목표 + 진행률 게이지
- **CSV 가져오기** — 표준 포맷으로 타 도구에서 마이그레이션
- **자동 Crash Report** — 예외 발생 시 Gitea Issues 자동 등록 (옵션)
- **주간 자동 리포트** — 월요일 첫 출근 시 지난주 요약 push
- **휴가 캘린더** — 종일/반차/반반차 색상 구분
- **Discord 웹훅 알림** — 출퇴근/휴식권고/주간리포트 모바일 push (옵션, 서버 0)
### 14. 접근성
- 글꼴 크기 100% / 125% / 150%
- 고대비 모드 (검정 배경 + 노란 텍스트)
### 16. 다국어 지원 (i18n)
- 한국어 / English 전환 (재시작 또는 즉시 갱신)
- 알림 메시지·UI 라벨 30+ 카테고리
- HelpView 6개 탭 ko/en HTML 콘텐츠 - HelpView 6개 탭 ko/en HTML 콘텐츠
### 13. 단축키 ### 17. 단축키
- `Ctrl+O` 출/퇴근 토글 - `Ctrl+O` 출/퇴근 토글
- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글 - `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글
- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말 - `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말

View File

@ -485,6 +485,234 @@ def s35_lock():
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트) # 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)
# ============================================================
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
# ============================================================
@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
def s36_onboarding_new():
db = fresh_db('s36')
assert db.get_setting('onboarding_completed') == 'false'
@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
def s37_onboarding_existing():
db = fresh_db('s37')
# work_record 1건 추가 후 마이그레이션 재실행
today = date.today().isoformat()
db.add_work_record(today, '09:00:00', is_manual=True)
# 다시 호출 (init_database에서 호출되는 헬퍼)
db.migrate_v23_onboarding_for_existing()
assert db.get_setting('onboarding_completed') == 'true'
@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
def s38_salary_zero_wage():
from core.salary import estimate_pay
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0
@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
def s39_salary_basic():
from core.salary import estimate_pay
out = estimate_pay(
[{'total_hours': 8.0, 'overtime_minutes': 30}],
hourly_wage=10000,
overtime_rate=1.5,
)
# 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
# 연장 = 0.5h × 10000 × 1.5 = 7500
assert abs(out['base'] - 75000) < 0.01, out['base']
assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
assert abs(out['total'] - 82500) < 0.01
@case("S40. salary.format_won: 콤마 + '' 접미사")
def s40_format_won():
from core.salary import format_won
assert format_won(0) == '0원'
assert format_won(1234567) == '1,234,567원'
assert format_won(82500.4) == '82,500원'
@case("S41. CSV 가져오기: 표준 포맷 파싱")
def s41_csv_parse():
from utils.csv_importer import parse_csv
p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,첫째날\n")
f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
rows = parse_csv(p)
os.remove(p)
assert len(rows) == 2
assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS)
assert rows[0]['lunch_minutes'] == 60
assert rows[1]['lunch_minutes'] == 30
assert rows[1]['memo'] == '단축'
@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
def s42_csv_invalid():
from utils.csv_importer import parse_csv
p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("not-a-date,09:00,18:00,60,\n")
try:
parse_csv(p)
assert False, "should have raised"
except ValueError:
pass
finally:
os.remove(p)
@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
def s43_csv_skip():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s43')
# 기존 레코드 1건
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,중복\n")
f.write("2026-04-02,09:00,18:00,60,신규\n")
rows = parse_csv(p)
added, updated, skipped = import_records(db, rows, on_conflict='skip')
os.remove(p)
assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
# 기존 레코드 보존 확인 (08:30 그대로)
rec = db.get_work_record('2026-04-01')
assert rec['clock_in'] == '08:30:00'
@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
def s44_csv_overwrite():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s44')
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")
rows = parse_csv(p)
added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
os.remove(p)
assert updated == 1 and added == 0
rec = db.get_work_record('2026-04-01')
assert rec['clock_in'] == '09:00:00'
@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
def s45_notification_dedupe():
db = fresh_db('s45')
assert not db.has_notification_today('discord', 'weekly_report')
db.log_notification('discord', 'weekly_report', payload='test', success=True)
assert db.has_notification_today('discord', 'weekly_report')
# 다른 event_type은 별개
assert not db.has_notification_today('discord', 'clock_in')
@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
def s46_meal_record():
db = fresh_db('s46')
today = date.today().isoformat()
# 오늘이 아닌 날짜로 add (work_record 미존재 OK)
db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
# get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
('2026-04-01',))
assert cur.fetchone()[0] == 60
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
('2026-04-01',))
assert cur.fetchone()[0] == 30
conn.close()
@case("S47. crash_log 자동 생성 + 기록")
def s47_crash_log():
from utils.crash_handler import _log_crash
db = fresh_db('s47')
_log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
assert cur.fetchone()[0] == 1
cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
msg, ver = cur.fetchone()
assert msg == 'sample message' and ver == 'v2.6.0'
conn.close()
@case("S48. updater_client.is_newer: 정확한 semver 비교")
def s48_updater_compare():
from utils.updater_client import is_newer
assert is_newer('v2.7.0', '2.6.0')
assert is_newer('2.6.1', 'v2.6.0')
assert not is_newer('v2.6.0', 'v2.6.0')
assert not is_newer('v2.5.0', 'v2.6.0')
assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교
@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
def s49_discord_empty():
from utils.discord_webhook import send, send_test
assert send('', 't', 'd') is False
assert send('http://invalid', 't', 'd') is False # https:// 아님
assert send_test('') is False
@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
def s50_goals():
db = fresh_db('s50')
# 기본값 확인 (0)
assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
# 활성화
db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
assert db.get_setting_int('goal_overtime_max_monthly') == 1200
assert db.get_setting_int('goal_avg_hours_daily') == 8
@case("S51. 글꼴 크기 / 고대비 설정 키")
def s51_accessibility_keys():
db = fresh_db('s51')
# 기본값
assert db.get_setting_float('font_scale', 1.0) == 1.0
assert db.get_setting_bool('high_contrast', False) is False
# 변경
db.save_settings({'font_scale': 1.5, 'high_contrast': True})
assert db.get_setting_float('font_scale') == 1.5
assert db.get_setting_bool('high_contrast') is True
@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s52')
p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
# 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
f.write("2026-04-01,09:00,19:30,60,연장근무\n")
rows = parse_csv(p)
added, _, _ = import_records(db, rows, on_conflict='skip')
os.remove(p)
assert added == 1
bal = db.get_total_overtime_balance()
assert bal == 90, f"overtime balance: {bal}"
# ============================================================ # ============================================================
# Run all # Run all
# ============================================================ # ============================================================
@ -520,6 +748,23 @@ def main():
s33_short_overtime() s33_short_overtime()
s34_format() s34_format()
s35_lock() s35_lock()
s36_onboarding_new()
s37_onboarding_existing()
s38_salary_zero_wage()
s39_salary_basic()
s40_format_won()
s41_csv_parse()
s42_csv_invalid()
s43_csv_skip()
s44_csv_overwrite()
s45_notification_dedupe()
s46_meal_record()
s47_crash_log()
s48_updater_compare()
s49_discord_empty()
s50_goals()
s51_accessibility_keys()
s52_csv_overtime()
print() print()
print("=" * 60) print("=" * 60)

View File

@ -103,6 +103,7 @@ _DICT = {
'msg.no_record.body': '오늘 출근 기록이 없습니다.', 'msg.no_record.body': '오늘 출근 기록이 없습니다.',
'msg.confirm_delete.title': '삭제 확인', 'msg.confirm_delete.title': '삭제 확인',
'msg.no_data.title': '데이터 없음', 'msg.no_data.title': '데이터 없음',
'msg.manual_added': '수동 추가',
# === 트레이 === # === 트레이 ===
'tray.open': '프로그램 열기', 'tray.open': '프로그램 열기',
@ -153,6 +154,103 @@ _DICT = {
'help.tab_leave': '🌴 연차/휴가', 'help.tab_leave': '🌴 연차/휴가',
'help.tab_break': '🚪 외출/저녁', 'help.tab_break': '🚪 외출/저녁',
'help.tab_faq': '❓ 자주 묻는 질문', 'help.tab_faq': '❓ 자주 묻는 질문',
# === clock_in_dialog ===
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
'dlg.clock_in.label': '출근시간:',
'dlg.clock_in.quick': '빠른 선택:',
'dlg.clock_in.btn_now': '현재',
# === break_view ===
'dlg.break.edit_title': '외출 기록 수정',
'dlg.break.out_label': '외출 시간:',
'dlg.break.in_label': '복귀 시간:',
'dlg.break.reason_label': '사유:',
'view.break.today_title': '오늘의 외출 기록',
'view.break.col_out': '외출 시간',
'view.break.col_in': '복귀 시간',
'view.break.col_duration': '소요 시간',
'view.break.col_reason': '사유',
'view.break.in_progress': '진행중',
'view.break.total_zero': '총 외출 시간: 0분',
'view.break.total_fmt': '총 외출 시간: {h}시간 {m}',
'view.break.total_min_only': '총 외출 시간: {m}',
'view.break.duration_fmt': '{h}시간 {m}',
'view.break.duration_min_only': '{m}',
'view.break.delete_confirm': '이 외출 기록을 삭제하시겠습니까?',
'btn.refresh': '새로고침',
'btn.edit_short': '수정',
'btn.delete_short': '삭제',
# === overtime_view ===
'view.overtime.title': '연장근무 내역',
'view.overtime.balance_zero': '잔액: 0분',
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
'view.overtime.earned_group': '💰 적립 내역',
'view.overtime.used_group': '📤 사용 내역',
'view.overtime.col_date': '날짜',
'view.overtime.col_earned': '적립',
'view.overtime.col_used': '사용',
'view.overtime.col_memo': '메모',
'view.overtime.col_reason': '사유',
'view.overtime.btn_add_earned': ' 수동 적립',
'view.overtime.btn_add_used': ' 수동 사용',
'view.overtime.menu_delete': '❌ 삭제',
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
'view.overtime.manual_earned_title': '추가근무 수동 적립',
'view.overtime.manual_used_title': '추가근무 수동 사용',
'view.overtime.field_date': '날짜:',
'view.overtime.field_time': '시간:',
'view.overtime.field_memo': '메모:',
'view.overtime.field_reason': '사유:',
'view.overtime.unit_hour_suffix': '시간',
'view.overtime.minute_0': '0분',
'view.overtime.minute_30': '30분',
'view.overtime.placeholder_memo': '선택사항',
'view.overtime.placeholder_reason': '예: 개인 사유',
'view.overtime.zero_add_error': '0분은 추가할 수 없습니다.',
'view.overtime.zero_use_error': '0분은 사용할 수 없습니다.',
'view.overtime.balance_short_title': '잔액 부족',
'view.overtime.balance_short_body': '사용 가능한 시간이 부족합니다.\n\n요청: {req_h}시간 {req_m}\n잔액: {bal_h}시간 {bal_m}',
'view.overtime.saved_earned': '{h}시간 {m}분이 적립되었습니다.',
'view.overtime.saved_used': '{h}시간 {m}분이 사용 처리되었습니다.',
# === leave_view ===
'view.leave.title': '연차 관리',
'view.leave.balance_zero': '잔여: 0일',
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
'view.leave.btn_set_balance': '잔여 설정',
'view.leave.used_group': '📤 사용 내역',
'view.leave.col_date': '날짜',
'view.leave.col_type': '구분',
'view.leave.col_used': '사용',
'view.leave.col_reason': '사유',
'view.leave.btn_add': ' 연차 사용 추가',
'view.leave.btn_calendar': '📅 캘린더 보기',
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
'view.leave.set_title': '연차 시간 설정',
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
'view.leave.set_done_title': '설정 완료',
'view.leave.set_done_body': '연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다.',
'view.leave.add_title': '연차 사용 기록 추가',
'view.leave.field_date': '날짜:',
'view.leave.field_type': '구분:',
'view.leave.field_hours': '시간:',
'view.leave.field_reason': '사유:',
'view.leave.type_annual': '연차',
'view.leave.type_half': '반차',
'view.leave.type_quarter': '반반차',
'view.leave.type_hourly': '시간',
'view.leave.placeholder_reason': '예) 개인 사유, 병원 방문 등',
'view.leave.note_auto_deduct': '※ 잔여 연차가 자동 차감됩니다.',
'view.leave.short_title': '잔여 연차 부족',
'view.leave.short_body': '잔여 연차가 부족합니다.\n현재 잔여: {balance}\n사용 요청: {req}',
'view.leave.confirm_title': '연차 사용 기록 추가',
'view.leave.confirm_body': '날짜: {date}\n구분: {type}\n사용: {days}일 ({hours}시간)\n사유: {reason}\n\n이 기록을 추가하시겠습니까?',
'view.leave.added_title': '추가 완료',
'view.leave.added_body': '{days}일 ({hours}시간)의 연차 사용이 기록되었습니다.',
'view.leave.error_title': '오류',
'view.leave.error_body': '연차 기록 추가 중 오류가 발생했습니다:\n{err}',
}, },
'en': { 'en': {
# === Menu/Buttons === # === Menu/Buttons ===
@ -244,6 +342,7 @@ _DICT = {
'msg.no_record.body': 'No clock-in record for today.', 'msg.no_record.body': 'No clock-in record for today.',
'msg.confirm_delete.title': 'Confirm Delete', 'msg.confirm_delete.title': 'Confirm Delete',
'msg.no_data.title': 'No Data', 'msg.no_data.title': 'No Data',
'msg.manual_added': 'Manual',
# === Tray === # === Tray ===
'tray.open': 'Open Program', 'tray.open': 'Open Program',
@ -294,6 +393,103 @@ _DICT = {
'help.tab_leave': '🌴 Leave', 'help.tab_leave': '🌴 Leave',
'help.tab_break': '🚪 Break/Dinner', 'help.tab_break': '🚪 Break/Dinner',
'help.tab_faq': '❓ FAQ', 'help.tab_faq': '❓ FAQ',
# === clock_in_dialog ===
'dlg.clock_in.prompt': "Enter today's clock-in time",
'dlg.clock_in.label': 'Clock-in time:',
'dlg.clock_in.quick': 'Quick pick:',
'dlg.clock_in.btn_now': 'Now',
# === break_view ===
'dlg.break.edit_title': 'Edit Break Record',
'dlg.break.out_label': 'Break out:',
'dlg.break.in_label': 'Return:',
'dlg.break.reason_label': 'Reason:',
'view.break.today_title': "Today's Break Records",
'view.break.col_out': 'Break out',
'view.break.col_in': 'Return',
'view.break.col_duration': 'Duration',
'view.break.col_reason': 'Reason',
'view.break.in_progress': 'In progress',
'view.break.total_zero': 'Total break: 0 min',
'view.break.total_fmt': 'Total break: {h}h {m}m',
'view.break.total_min_only': 'Total break: {m} min',
'view.break.duration_fmt': '{h}h {m}m',
'view.break.duration_min_only': '{m} min',
'view.break.delete_confirm': 'Delete this break record?',
'btn.refresh': 'Refresh',
'btn.edit_short': 'Edit',
'btn.delete_short': 'Delete',
# === overtime_view ===
'view.overtime.title': 'Overtime History',
'view.overtime.balance_zero': 'Balance: 0 min',
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
'view.overtime.earned_group': '💰 Earned',
'view.overtime.used_group': '📤 Used',
'view.overtime.col_date': 'Date',
'view.overtime.col_earned': 'Earned',
'view.overtime.col_used': 'Used',
'view.overtime.col_memo': 'Memo',
'view.overtime.col_reason': 'Reason',
'view.overtime.btn_add_earned': ' Manual Earn',
'view.overtime.btn_add_used': ' Manual Use',
'view.overtime.menu_delete': '❌ Delete',
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
'view.overtime.manual_used_title': 'Manual Overtime Use',
'view.overtime.field_date': 'Date:',
'view.overtime.field_time': 'Time:',
'view.overtime.field_memo': 'Memo:',
'view.overtime.field_reason': 'Reason:',
'view.overtime.unit_hour_suffix': 'h',
'view.overtime.minute_0': '0 min',
'view.overtime.minute_30': '30 min',
'view.overtime.placeholder_memo': 'Optional',
'view.overtime.placeholder_reason': 'e.g., personal',
'view.overtime.zero_add_error': 'Cannot add 0 minutes.',
'view.overtime.zero_use_error': 'Cannot use 0 minutes.',
'view.overtime.balance_short_title': 'Insufficient Balance',
'view.overtime.balance_short_body': 'Not enough balance.\n\nRequested: {req_h}h {req_m}m\nBalance: {bal_h}h {bal_m}m',
'view.overtime.saved_earned': '{h}h {m}m banked.',
'view.overtime.saved_used': '{h}h {m}m used.',
# === leave_view ===
'view.leave.title': 'Leave Management',
'view.leave.balance_zero': 'Balance: 0 days',
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
'view.leave.btn_set_balance': 'Set Balance',
'view.leave.used_group': '📤 Used',
'view.leave.col_date': 'Date',
'view.leave.col_type': 'Type',
'view.leave.col_used': 'Used',
'view.leave.col_reason': 'Reason',
'view.leave.btn_add': ' Add Leave Usage',
'view.leave.btn_calendar': '📅 Calendar',
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
'view.leave.set_title': 'Set Leave Hours',
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',
'view.leave.set_done_title': 'Saved',
'view.leave.set_done_body': 'Leave balance set to {days} days ({hours}h).',
'view.leave.add_title': 'Add Leave Usage',
'view.leave.field_date': 'Date:',
'view.leave.field_type': 'Type:',
'view.leave.field_hours': 'Hours:',
'view.leave.field_reason': 'Reason:',
'view.leave.type_annual': 'Annual',
'view.leave.type_half': 'Half',
'view.leave.type_quarter': 'Quarter',
'view.leave.type_hourly': 'Hourly',
'view.leave.placeholder_reason': 'e.g., personal, medical',
'view.leave.note_auto_deduct': '※ Leave balance is auto-deducted.',
'view.leave.short_title': 'Insufficient Leave',
'view.leave.short_body': 'Not enough leave.\nCurrent: {balance} days\nRequested: {req} days',
'view.leave.confirm_title': 'Add Leave Usage',
'view.leave.confirm_body': 'Date: {date}\nType: {type}\nUsed: {days} days ({hours}h)\nReason: {reason}\n\nAdd this record?',
'view.leave.added_title': 'Added',
'view.leave.added_body': '{days} days ({hours}h) of leave usage recorded.',
'view.leave.error_title': 'Error',
'view.leave.error_body': 'Failed to add leave record:\n{err}',
}, },
} }

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push. 릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 . CHANGELOG.md의 최상단 항목과 일치시킬 .
""" """
__version__ = '2.6.0' __version__ = '2.7.0'

110
tests/test_crash_handler.py Normal file
View File

@ -0,0 +1,110 @@
"""
utils.crash_handler 단위 테스트.
GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea).
"""
import os
import sys
import tempfile
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from utils.crash_handler import _log_crash, _send_to_gitea
@pytest.fixture
def db():
p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db')
if os.path.exists(p):
os.remove(p)
d = Database(p)
yield d
try:
os.remove(p)
except OSError:
pass
class TestLogCrash:
def test_creates_table_and_inserts(self, db):
_log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT exception_type, message, app_version FROM crash_log")
row = cur.fetchone()
conn.close()
assert row[0] == 'TestExc'
assert row[1] == 'msg'
assert row[2] == 'v2.6.0'
def test_table_idempotent_creation(self, db):
# 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS)
_log_crash(db, 'A', 'a', 't', 'v1')
_log_crash(db, 'B', 'b', 't', 'v1')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM crash_log")
count = cur.fetchone()[0]
conn.close()
assert count == 2
def test_silent_on_db_error(self, db):
# 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음)
broken = MagicMock()
broken.get_connection.side_effect = RuntimeError('boom')
# raise되면 안 됨
_log_crash(broken, 'X', 'x', 'tb', 'v')
class TestSendToGitea:
@patch('utils.crash_handler.urllib.request.urlopen')
def test_success(self, mock_urlopen):
resp = MagicMock()
resp.status = 201
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
ok = _send_to_gitea('fake_token', 'title', 'body')
assert ok is True
req = mock_urlopen.call_args[0][0]
# PAT 헤더 확인
assert req.headers.get('Authorization') == 'token fake_token'
# User-Agent 위장
assert 'Mozilla' in req.headers.get('User-agent', '')
@patch('utils.crash_handler.urllib.request.urlopen')
def test_4xx_returns_false(self, mock_urlopen):
resp = MagicMock()
resp.status = 401
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert _send_to_gitea('bad', 't', 'b') is False
@patch('utils.crash_handler.urllib.request.urlopen')
def test_network_error(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert _send_to_gitea('t', 't', 'b') is False
@patch('utils.crash_handler.urllib.request.urlopen')
def test_payload_json(self, mock_urlopen):
import json as _json
resp = MagicMock()
resp.status = 201
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
_send_to_gitea('tok', 'TITLE', 'BODY')
req = mock_urlopen.call_args[0][0]
body = _json.loads(req.data.decode('utf-8'))
assert body['title'] == 'TITLE'
assert body['body'] == 'BODY'

127
tests/test_csv_importer.py Normal file
View File

@ -0,0 +1,127 @@
"""
utils.csv_importer 단위 테스트.
"""
import os
import sys
import tempfile
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
class TestNormalizeTime:
def test_hh_mm_to_hh_mm_ss(self):
assert _normalize_time('09:00', 'clock_in') == '09:00:00'
def test_hh_mm_ss_unchanged(self):
assert _normalize_time('09:00:00', 'clock_in') == '09:00:00'
def test_empty_raises(self):
with pytest.raises(ValueError):
_normalize_time('', 'clock_in')
def test_invalid_format_raises(self):
with pytest.raises(ValueError):
_normalize_time('foo', 'clock_in')
with pytest.raises(ValueError):
_normalize_time('25:00', 'clock_in')
class TestNormalizeRow:
def test_basic_row(self):
row = {
'date': '2026-04-01',
'clock_in': '09:00',
'clock_out': '18:00',
'lunch_minutes': '60',
'memo': '메모',
}
out = _normalize_row(row)
assert out['date'] == '2026-04-01'
assert out['clock_in'] == '09:00:00'
assert out['clock_out'] == '18:00:00'
assert out['lunch_minutes'] == 60
assert out['memo'] == '메모'
def test_optional_clock_out(self):
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
'lunch_minutes': '0', 'memo': ''}
out = _normalize_row(row)
assert out['clock_out'] is None
def test_invalid_date(self):
row = {'date': 'not-a-date', 'clock_in': '09:00', 'clock_out': '',
'lunch_minutes': '0', 'memo': ''}
with pytest.raises(ValueError):
_normalize_row(row)
def test_negative_lunch_minutes(self):
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
'lunch_minutes': '-30', 'memo': ''}
with pytest.raises(ValueError):
_normalize_row(row)
class TestParseCsv:
def _write(self, content: str) -> str:
f = tempfile.NamedTemporaryFile('w', encoding='utf-8',
delete=False, suffix='.csv', newline='')
f.write(content)
f.close()
return f.name
def test_valid_csv(self):
path = self._write(
"date,clock_in,clock_out,lunch_minutes,memo\n"
"2026-04-01,09:00,18:00,60,첫째날\n"
"2026-04-02,09:30:00,17:30:00,30,단축\n"
)
try:
rows = parse_csv(path)
assert len(rows) == 2
assert rows[0]['lunch_minutes'] == 60
assert rows[1]['memo'] == '단축'
finally:
os.remove(path)
def test_utf8_bom(self):
# 엑셀 저장본 호환
path = self._write('\ufeff' +
"date,clock_in,clock_out,lunch_minutes,memo\n"
"2026-04-01,09:00,18:00,60,첫째날\n"
)
try:
rows = parse_csv(path)
assert len(rows) == 1
finally:
os.remove(path)
def test_missing_required_header(self):
path = self._write("date,memo\n2026-04-01,foo\n")
try:
with pytest.raises(ValueError) as exc:
parse_csv(path)
assert 'clock_in' in str(exc.value)
finally:
os.remove(path)
def test_file_not_found(self):
with pytest.raises(FileNotFoundError):
parse_csv('/nonexistent/file.csv')
def test_line_number_in_error(self):
path = self._write(
"date,clock_in,clock_out,lunch_minutes,memo\n"
"2026-04-01,09:00,18:00,60,ok\n"
"bad-date,09:00,18:00,60,broken\n"
)
try:
with pytest.raises(ValueError) as exc:
parse_csv(path)
assert '줄 3' in str(exc.value)
finally:
os.remove(path)

View File

@ -0,0 +1,129 @@
"""
utils.discord_webhook 단위 테스트.
네트워크 호출은 mock 실제 Discord API는 절대 건드리지 않음.
"""
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.discord_webhook import (
send, send_test, send_clock_in, send_clock_out, send_health_warning,
USER_AGENT, COLOR_GREEN, COLOR_BLUE,
)
class TestSendInputValidation:
def test_empty_url_returns_false(self):
assert send('', 'title', 'desc') is False
def test_non_https_url_returns_false(self):
# 보안상 http:// 거부
assert send('http://example.com/webhook', 'title', 'desc') is False
assert send('ftp://example.com', 'title', 'desc') is False
def test_none_url_returns_false(self):
assert send(None, 'title', 'desc') is False
class TestSendNetwork:
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_success(self, mock_urlopen):
resp = MagicMock()
resp.status = 204
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
ok = send('https://discord.com/api/webhooks/123/abc', 'title', 'desc')
assert ok is True
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
req = mock_urlopen.call_args[0][0]
assert req.headers.get('User-agent') == USER_AGENT
assert 'Mozilla' in USER_AGENT # 봇으로 인식 안 되도록
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_4xx_returns_false(self, mock_urlopen):
resp = MagicMock()
resp.status = 403
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_network_error_returns_false(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_timeout_returns_false(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
class TestPayloadShape:
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_payload_contains_embed(self, mock_urlopen):
import json as _json
resp = MagicMock()
resp.status = 204
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
send('https://discord.com/api/webhooks/x', 'TITLE', 'DESC',
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
req = mock_urlopen.call_args[0][0]
body = _json.loads(req.data.decode('utf-8'))
assert 'embeds' in body
assert body['embeds'][0]['title'] == 'TITLE'
assert body['embeds'][0]['description'] == 'DESC'
assert body['embeds'][0]['color'] == COLOR_GREEN
assert body['embeds'][0]['fields'] == [{"name": "f", "value": "v"}]
class TestHelpers:
@patch('utils.discord_webhook.send')
def test_send_test(self, mock_send):
mock_send.return_value = True
send_test('https://discord.com/x')
assert mock_send.called
kwargs = mock_send.call_args.kwargs
assert kwargs.get('color') == COLOR_GREEN
@patch('utils.discord_webhook.send')
def test_send_clock_in(self, mock_send):
send_clock_in('https://discord.com/x', '09:00')
kwargs = mock_send.call_args.kwargs
assert '09:00' in kwargs.get('description', '')
@patch('utils.discord_webhook.send')
def test_send_clock_out_no_overtime(self, mock_send):
send_clock_out('https://discord.com/x', '18:00', 8.0, 0, 0)
kwargs = mock_send.call_args.kwargs
# 연장 없음 → 파란색
assert kwargs.get('color') == COLOR_BLUE
# 필드: 총 근무시간만
assert len(kwargs.get('fields', [])) == 1
@patch('utils.discord_webhook.send')
def test_send_clock_out_with_overtime(self, mock_send):
send_clock_out('https://discord.com/x', '19:30', 9.5, 90, 90)
kwargs = mock_send.call_args.kwargs
# 연장 있음 → 노란색
assert kwargs.get('color') != COLOR_BLUE
assert len(kwargs.get('fields', [])) == 2
@patch('utils.discord_webhook.send')
def test_send_health_warning(self, mock_send):
send_health_warning('https://discord.com/x', 4.5)
kwargs = mock_send.call_args.kwargs
assert '4.5' in kwargs.get('description', '')

106
tests/test_i18n_runtime.py Normal file
View File

@ -0,0 +1,106 @@
"""
ui.i18n_runtime 단위 테스트.
QApplication이 필요해서 offscreen으로.
"""
import os
import sys
import pytest
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@pytest.fixture(scope='module')
def qapp():
from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def i18n():
from core import i18n
from ui import i18n_runtime
saved_lang = i18n.get_language()
yield i18n_runtime
i18n_runtime.clear()
i18n.set_language(saved_lang)
def test_register_applies_initial_text(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save')
assert label.text() == '💾 저장'
def test_retranslate_after_language_change(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.close')
assert label.text() == '닫기'
i18n.set_language_and_retranslate('en')
assert label.text() == 'Close'
i18n.set_language_and_retranslate('ko')
assert label.text() == '닫기'
def test_setter_kwarg_for_window_title(qapp, i18n):
from PyQt5.QtWidgets import QDialog
from core.i18n import set_language
set_language('ko')
dlg = QDialog()
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
assert dlg.windowTitle() == '⚙️ 설정'
i18n.set_language_and_retranslate('en')
assert dlg.windowTitle() == '⚙️ Settings'
def test_post_callback_applied(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
assert label.text() == '[💾 저장]'
i18n.set_language_and_retranslate('en')
assert label.text() == '[💾 Save]'
def test_dead_widget_pruned(qapp, i18n):
"""삭제된 위젯은 retranslate에서 자동 제외 (RuntimeError 안 남)."""
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.cancel')
label.deleteLater()
label = None # weakref 끊기
# Qt 이벤트 처리 한 번 강제로 (deleteLater 처리)
qapp.processEvents()
# 죽은 위젯이 있어도 예외 없이 실행돼야 함
i18n.set_language_and_retranslate('en')
i18n.set_language_and_retranslate('ko')
def test_kwargs_format(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
# 'tray.tooltip_remaining': '퇴근까지: {time}'
i18n.register(label, 'tray.tooltip_remaining', kwargs={'time': '01:23'})
assert '01:23' in label.text()

98
tests/test_salary.py Normal file
View File

@ -0,0 +1,98 @@
"""
core.salary 단위 테스트 포괄임금제 시급 추정.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.salary import estimate_pay, format_won
class TestEstimatePay:
def test_zero_wage_returns_zero(self):
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
assert out['base'] == 0
assert out['overtime'] == 0
assert out['total'] == 0
def test_negative_wage_returns_zero(self):
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 0}], -1000)
assert out['total'] == 0
def test_empty_records(self):
out = estimate_pay([], 10000)
assert out['base'] == 0
assert out['overtime'] == 0
assert out['total'] == 0
def test_basic_8h_no_overtime(self):
# 8h 정규 × 10000 = 80000
out = estimate_pay([{'total_hours': 8.0, 'overtime_minutes': 0}], 10000)
assert out['base'] == 80000
assert out['overtime'] == 0
assert out['total'] == 80000
def test_8h_with_30min_overtime(self):
# 정규 = 7.5h × 10000 = 75000
# 연장 = 0.5h × 10000 × 1.5 = 7500
out = estimate_pay(
[{'total_hours': 8.0, 'overtime_minutes': 30}],
hourly_wage=10000,
overtime_rate=1.5,
)
assert out['base'] == pytest.approx(75000)
assert out['overtime'] == pytest.approx(7500)
assert out['total'] == pytest.approx(82500)
def test_custom_overtime_rate(self):
# 연장 = 1h × 10000 × 2.0 = 20000
out = estimate_pay(
[{'total_hours': 9.0, 'overtime_minutes': 60}],
hourly_wage=10000,
overtime_rate=2.0,
)
assert out['overtime'] == pytest.approx(20000)
assert out['base'] == pytest.approx(80000)
def test_aggregated_multiple_records(self):
records = [
{'total_hours': 8.0, 'overtime_minutes': 0},
{'total_hours': 9.0, 'overtime_minutes': 60},
{'total_hours': 8.5, 'overtime_minutes': 30},
]
out = estimate_pay(records, hourly_wage=10000)
# base_hours = 8 + 8 + 8 = 24h
# overtime_hours = 0 + 1 + 0.5 = 1.5h
assert out['base_hours'] == pytest.approx(24.0)
assert out['overtime_hours'] == pytest.approx(1.5)
assert out['base'] == pytest.approx(240000)
assert out['overtime'] == pytest.approx(22500) # 1.5 * 10000 * 1.5
def test_missing_keys_default_zero(self):
out = estimate_pay([{}], 10000)
assert out['total'] == 0
def test_overtime_minutes_zero_when_negative_total(self):
# total - overtime이 음수가 되면 base는 0으로 클램프
out = estimate_pay(
[{'total_hours': 0.3, 'overtime_minutes': 60}], # 0.3h - 1h = -0.7
hourly_wage=10000,
)
assert out['base'] == 0
assert out['overtime'] == pytest.approx(15000)
class TestFormatWon:
@pytest.mark.parametrize("amount,expected", [
(0, '0원'),
(1000, '1,000원'),
(1234567, '1,234,567원'),
(999, '999원'),
(82500.4, '82,500원'), # round
(82500.6, '82,501원'),
])
def test_format(self, amount, expected):
assert format_won(amount) == expected

View File

@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("외출 기록 수정") self.setWindowTitle(tr('dlg.break.edit_title'))
self.setFixedSize(380, 180) self.setFixedSize(380, 180)
layout = QVBoxLayout() layout = QVBoxLayout()
@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
# 외출 시간 # 외출 시간
out_layout = QHBoxLayout() out_layout = QHBoxLayout()
out_label = QLabel("외출 시간:") out_label = QLabel(tr('dlg.break.out_label'))
out_label.setFixedWidth(80) out_label.setFixedWidth(80)
self.out_time_edit = QTimeEdit() self.out_time_edit = QTimeEdit()
self.out_time_edit.setDisplayFormat("HH:mm:ss") self.out_time_edit.setDisplayFormat("HH:mm:ss")
@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
# 복귀 시간 # 복귀 시간
in_layout = QHBoxLayout() in_layout = QHBoxLayout()
in_label = QLabel("복귀 시간:") in_label = QLabel(tr('dlg.break.in_label'))
in_label.setFixedWidth(80) in_label.setFixedWidth(80)
self.in_time_edit = QTimeEdit() self.in_time_edit = QTimeEdit()
self.in_time_edit.setDisplayFormat("HH:mm:ss") self.in_time_edit.setDisplayFormat("HH:mm:ss")
@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
# 사유 # 사유
reason_layout = QHBoxLayout() reason_layout = QHBoxLayout()
reason_label = QLabel("사유:") reason_label = QLabel(tr('dlg.break.reason_label'))
reason_label.setFixedWidth(80) reason_label.setFixedWidth(80)
self.reason_edit = QLineEdit() self.reason_edit = QLineEdit()
reason_layout.addWidget(reason_label) reason_layout.addWidget(reason_label)
@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
save_button.clicked.connect(self.accept) save_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
@ -128,7 +128,7 @@ class BreakView(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("오늘의 외출 기록") title = QLabel(tr('view.break.today_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
@ -136,7 +136,13 @@ class BreakView(QDialog):
# 외출 리스트 테이블 # 외출 리스트 테이블
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(5) self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""]) self.table.setHorizontalHeaderLabels([
tr('view.break.col_out'),
tr('view.break.col_in'),
tr('view.break.col_duration'),
tr('view.break.col_reason'),
"",
])
# 테이블 설정 # 테이블 설정
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
@ -152,7 +158,7 @@ class BreakView(QDialog):
layout.addWidget(self.table) layout.addWidget(self.table)
# 총 외출 시간 표시 # 총 외출 시간 표시
self.total_label = QLabel("총 외출 시간: 0분") self.total_label = QLabel(tr('view.break.total_zero'))
self.total_label.setObjectName("section_title") self.total_label.setObjectName("section_title")
self.total_label.setAlignment(Qt.AlignRight) self.total_label.setAlignment(Qt.AlignRight)
layout.addWidget(self.total_label) layout.addWidget(self.total_label)
@ -160,8 +166,8 @@ class BreakView(QDialog):
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.refresh_button = QPushButton("새로고침") self.refresh_button = QPushButton(tr('btn.refresh'))
close_button = QPushButton("닫기") close_button = QPushButton(tr('btn.close'))
self.refresh_button.clicked.connect(self.load_break_records) self.refresh_button.clicked.connect(self.load_break_records)
close_button.clicked.connect(self.accept) close_button.clicked.connect(self.accept)
@ -190,7 +196,7 @@ class BreakView(QDialog):
if break_in: if break_in:
self.table.setItem(i, 1, QTableWidgetItem(break_in)) self.table.setItem(i, 1, QTableWidgetItem(break_in))
else: else:
item = QTableWidgetItem("진행중") item = QTableWidgetItem(tr('view.break.in_progress'))
item.setForeground(Qt.red) item.setForeground(Qt.red)
self.table.setItem(i, 1, item) self.table.setItem(i, 1, item)
@ -199,7 +205,10 @@ class BreakView(QDialog):
if total_minutes: if total_minutes:
hours = total_minutes // 60 hours = total_minutes // 60
minutes = total_minutes % 60 minutes = total_minutes % 60
duration_str = f"{hours}시간 {minutes}" if hours > 0 else f"{minutes}" if hours > 0:
duration_str = tr('view.break.duration_fmt', h=hours, m=minutes)
else:
duration_str = tr('view.break.duration_min_only', m=minutes)
self.table.setItem(i, 2, QTableWidgetItem(duration_str)) self.table.setItem(i, 2, QTableWidgetItem(duration_str))
else: else:
self.table.setItem(i, 2, QTableWidgetItem("-")) self.table.setItem(i, 2, QTableWidgetItem("-"))
@ -214,8 +223,8 @@ class BreakView(QDialog):
action_layout.setContentsMargins(0, 0, 0, 0) action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(5) action_layout.setSpacing(5)
edit_button = QPushButton("수정") edit_button = QPushButton(tr('btn.edit_short'))
delete_button = QPushButton("삭제") delete_button = QPushButton(tr('btn.delete_short'))
edit_button.setFixedSize(50, 25) edit_button.setFixedSize(50, 25)
delete_button.setFixedSize(50, 25) delete_button.setFixedSize(50, 25)
@ -237,9 +246,9 @@ class BreakView(QDialog):
minutes = total_minutes % 60 minutes = total_minutes % 60
if hours > 0: if hours > 0:
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}") self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
else: else:
self.total_label.setText(f"총 외출 시간: {minutes}") self.total_label.setText(tr('view.break.total_min_only', m=minutes))
def edit_record(self, record_id): def edit_record(self, record_id):
"""외출 기록 수정""" """외출 기록 수정"""
@ -277,8 +286,8 @@ class BreakView(QDialog):
"""외출 기록 삭제""" """외출 기록 삭제"""
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"삭제 확인", tr('msg.confirm_delete.title'),
"이 외출 기록을 삭제하시겠습니까?", tr('view.break.delete_confirm'),
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )

View File

@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 안내 문구 # 안내 문구
info_label = QLabel("오늘의 출근시간을 입력해주세요") info_label = QLabel(tr('dlg.clock_in.prompt'))
info_label.setObjectName("field_label") info_label.setObjectName("field_label")
info_label.setAlignment(Qt.AlignCenter) info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label) layout.addWidget(info_label)
# 시간 입력 # 시간 입력
time_layout = QHBoxLayout() time_layout = QHBoxLayout()
time_label = QLabel("출근시간:") time_label = QLabel(tr('dlg.clock_in.label'))
time_label.setObjectName("field_label") time_label.setObjectName("field_label")
self.time_edit = QTimeEdit() self.time_edit = QTimeEdit()
@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
# 빠른 선택 버튼 # 빠른 선택 버튼
quick_layout = QHBoxLayout() quick_layout = QHBoxLayout()
quick_label = QLabel("빠른 선택:") quick_label = QLabel(tr('dlg.clock_in.quick'))
quick_label.setObjectName("field_label") quick_label.setObjectName("field_label")
btn_8am = QPushButton("08:00") btn_8am = QPushButton("08:00")
btn_9am = QPushButton("09:00") btn_9am = QPushButton("09:00")
btn_10am = QPushButton("10:00") btn_10am = QPushButton("10:00")
btn_now = QPushButton("현재") btn_now = QPushButton(tr('dlg.clock_in.btn_now'))
for btn in [btn_8am, btn_9am, btn_10am, btn_now]: for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
btn.setMinimumHeight(30) btn.setMinimumHeight(30)
@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
ok_button = QPushButton("확인") ok_button = QPushButton(tr('btn.confirm'))
ok_button.setObjectName("btn_primary") ok_button.setObjectName("btn_primary")
ok_button.setMinimumHeight(40) ok_button.setMinimumHeight(40)
ok_button.clicked.connect(self.accept) ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.setMinimumHeight(40) cancel_button.setMinimumHeight(40)
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)

View File

@ -0,0 +1,56 @@
"""
점심/저녁 토글 컨트롤러.
main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status /
update_dinner_status 합쳐져 있던 것을 분리. 1Hz hot path 사용자 액션 응답.
단위 테스트가 가능하도록 window 의존성을 명시적으로 받음.
"""
from __future__ import annotations
from datetime import datetime
from core.i18n import tr
class MealController:
"""점심/저녁 토글 + 상태 라벨 갱신."""
def __init__(self, window):
self.window = window
self.db = window.db
# -------- 토글 --------
def toggle_lunch(self) -> None:
w = self.window
w.lunch_break_enabled = w.lunch_button.isChecked()
self.refresh_lunch_label()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if w.lunch_break_enabled:
w.auto_lunch_applied_today = True
if w.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, w.lunch_break_enabled)
def toggle_dinner(self) -> None:
w = self.window
w.dinner_break_enabled = w.dinner_button.isChecked()
self.refresh_dinner_label()
if w.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, w.dinner_break_enabled)
# -------- 라벨 --------
def refresh_lunch_label(self) -> None:
w = self.window
w.lunch_button.setText(
tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add')
)
def refresh_dinner_label(self) -> None:
w = self.window
w.dinner_button.setText(
tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add')
)

94
ui/i18n_runtime.py Normal file
View File

@ -0,0 +1,94 @@
"""
런타임 i18n 재번역 재시작 없이 언어 전환.
사용법:
from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate
label = QLabel(tr('label.foo'))
register(label, 'label.foo') # 약한 참조로 등록
button = QPushButton()
register(button, 'btn.bar', kwargs={'name': 'X'})
# 그룹박스 제목 등 setter가 다른 경우
register(group, 'group.work_time', setter='setTitle')
# 윈도우 제목
register(dialog, 'window.foo', setter='setWindowTitle')
언어 변경:
set_language_and_retranslate('en')
widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는
키는 kwargs를 함께 등록하면 retranslate 같은 인자로 재계산.
"""
from __future__ import annotations
import weakref
from typing import Any, Callable, List, Tuple, Optional
from core.i18n import tr, set_language as _set_language
# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트
# weakref가 죽으면 다음 retranslate 시 정리.
_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = []
def register(widget: Any, key: str, *, setter: str = 'setText',
kwargs: Optional[dict] = None,
post: Optional[Callable[[str], str]] = None) -> None:
"""위젯을 retranslate 대상으로 등록.
Args:
widget: PyQt 위젯 (setText/setTitle/setWindowTitle 지원)
key: i18n
setter: 호출할 메서드명 (기본 setText)
kwargs: tr() 전달할 format 인자 (정적인 경우만)
post: 번역 가공할 콜백 : 이모지 prefix
"""
# 약한 참조 — 위젯 삭제 시 자동 GC
try:
ref = weakref.ref(widget)
except TypeError:
# weakref 미지원 객체는 retranslate 불가
return
_registry.append((ref, key, setter, kwargs or {}, post))
# 초기 적용
_apply(widget, key, setter, kwargs or {}, post)
def retranslate_all() -> None:
"""모든 등록된 위젯에 현재 언어로 텍스트 재적용."""
global _registry
alive = []
for ref, key, setter, kw, post in _registry:
widget = ref()
if widget is None:
continue # 죽은 위젯은 빼버림
try:
_apply(widget, key, setter, kw, post)
alive.append((ref, key, setter, kw, post))
except RuntimeError:
# Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스
continue
_registry = alive
def set_language_and_retranslate(lang: str) -> None:
"""언어 전환 + 즉시 재번역."""
_set_language(lang)
retranslate_all()
def clear() -> None:
"""레지스트리 비우기 (테스트용)."""
_registry.clear()
def _apply(widget: Any, key: str, setter: str, kw: dict,
post: Optional[Callable[[str], str]]) -> None:
text = tr(key, **kw)
if post:
text = post(text)
fn = getattr(widget, setter, None)
if callable(fn):
fn(text)

View File

@ -40,27 +40,32 @@ class LeaveView(QDialog):
# 제목 + 잔액 + 설정 한 줄 # 제목 + 잔액 + 설정 한 줄
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
title = QLabel("연차 관리") title = QLabel(tr('view.leave.title'))
title.setObjectName("dialog_title") title.setObjectName("dialog_title")
header_layout.addWidget(title) header_layout.addWidget(title)
header_layout.addStretch() header_layout.addStretch()
self.balance_label = QLabel("잔여: 0일") self.balance_label = QLabel(tr('view.leave.balance_zero'))
self.balance_label.setObjectName("badge_leave") self.balance_label.setObjectName("badge_leave")
header_layout.addWidget(self.balance_label) header_layout.addWidget(self.balance_label)
set_balance_button = QPushButton("잔여 설정") set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
set_balance_button.clicked.connect(self.set_balance) set_balance_button.clicked.connect(self.set_balance)
header_layout.addWidget(set_balance_button) header_layout.addWidget(set_balance_button)
layout.addLayout(header_layout) layout.addLayout(header_layout)
# 사용 내역 # 사용 내역
used_group = QGroupBox("📤 사용 내역") used_group = QGroupBox(tr('view.leave.used_group'))
used_layout = QVBoxLayout() used_layout = QVBoxLayout()
used_layout.setSpacing(4) used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6) used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget() self.used_table = QTableWidget()
self.used_table.setColumnCount(4) self.used_table.setColumnCount(4)
self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"]) self.used_table.setHorizontalHeaderLabels([
tr('view.leave.col_date'),
tr('view.leave.col_type'),
tr('view.leave.col_used'),
tr('view.leave.col_reason'),
])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
@ -77,15 +82,15 @@ class LeaveView(QDialog):
# 버튼들 # 버튼들
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_leave_button = QPushButton(" 연차 사용 추가") add_leave_button = QPushButton(tr('view.leave.btn_add'))
add_leave_button.clicked.connect(self.add_leave_record) add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button) button_layout.addWidget(add_leave_button)
cal_button = QPushButton("📅 캘린더 보기") cal_button = QPushButton(tr('view.leave.btn_calendar'))
cal_button.clicked.connect(self._show_calendar) cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button) button_layout.addWidget(cal_button)
close_button = QPushButton("닫기") close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
button_layout.addWidget(close_button) button_layout.addWidget(close_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
@ -102,7 +107,8 @@ class LeaveView(QDialog):
# 잔액 업데이트 # 잔액 업데이트
balance = self.db.get_leave_balance() balance = self.db.get_leave_balance()
hours = balance * 8 hours = balance * 8
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)") self.balance_label.setText(tr('view.leave.balance_fmt',
days=balance, hours=hours))
# 사용 내역 로드 (잔액 조정 제외) # 사용 내역 로드 (잔액 조정 제외)
records = self.db.get_leave_records(exclude_bulk=True) records = self.db.get_leave_records(exclude_bulk=True)
@ -144,7 +150,7 @@ class LeaveView(QDialog):
return return
menu = QMenu(self) menu = QMenu(self)
delete_action = QAction("삭제", self) delete_action = QAction(tr('btn.delete_short'), self)
delete_action.triggered.connect(self.delete_leave_record) delete_action.triggered.connect(self.delete_leave_record)
menu.addAction(delete_action) menu.addAction(delete_action)
menu.exec_(self.used_table.viewport().mapToGlobal(position)) menu.exec_(self.used_table.viewport().mapToGlobal(position))
@ -164,11 +170,10 @@ class LeaveView(QDialog):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"삭제 확인", tr('msg.confirm_delete.title'),
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n" tr('view.leave.delete_confirm_body',
f"날짜: {date_item.text()}\n" date=date_item.text(), type=type_item.text(),
f"구분: {type_item.text()}\n" days=days_item.text()),
f"사용: {days_item.text()}",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@ -183,13 +188,12 @@ class LeaveView(QDialog):
hours, ok = QInputDialog.getDouble( hours, ok = QInputDialog.getDouble(
self, self,
"연차 시간 설정", tr('view.leave.set_title'),
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n" tr('view.leave.set_prompt'),
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
current_hours, current_hours,
0.0, 0.0,
999.0, 999.0,
1 # 소수점 첫째자리까지 (0.5 단위) 1
) )
if ok: if ok:
@ -200,8 +204,8 @@ class LeaveView(QDialog):
self.db.set_leave_balance(days) self.db.set_leave_balance(days)
QMessageBox.information( QMessageBox.information(
self, self,
"설정 완료", tr('view.leave.set_done_title'),
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다." tr('view.leave.set_done_body', days=days, hours=hours)
) )
self.load_data() self.load_data()
@ -223,7 +227,7 @@ class AddLeaveDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("연차 사용 기록 추가") self.setWindowTitle(tr('view.leave.add_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(360) self.setMinimumWidth(360)
@ -232,14 +236,14 @@ class AddLeaveDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("연차 사용 기록 추가") title = QLabel(tr('view.leave.add_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
# 날짜 + 구분 한 줄 # 날짜 + 구분 한 줄
row1 = QHBoxLayout() row1 = QHBoxLayout()
date_label = QLabel("날짜:") date_label = QLabel(tr('view.leave.field_date'))
date_label.setObjectName("field_label") date_label.setObjectName("field_label")
date_label.setFixedWidth(40) date_label.setFixedWidth(40)
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
@ -248,14 +252,14 @@ class AddLeaveDialog(QDialog):
row1.addWidget(date_label) row1.addWidget(date_label)
row1.addWidget(self.date_edit) row1.addWidget(self.date_edit)
row1.addSpacing(8) row1.addSpacing(8)
type_label = QLabel("구분:") type_label = QLabel(tr('view.leave.field_type'))
type_label.setObjectName("field_label") type_label.setObjectName("field_label")
type_label.setFixedWidth(40) type_label.setFixedWidth(40)
self.type_combo = QComboBox() self.type_combo = QComboBox()
self.type_combo.addItem("연차", "annual") self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
self.type_combo.addItem("반차", "half") self.type_combo.addItem(tr('view.leave.type_half'), "half")
self.type_combo.addItem("반반차", "quarter") self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
self.type_combo.addItem("시간", "hourly") self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
self.type_combo.currentIndexChanged.connect(self.on_type_changed) self.type_combo.currentIndexChanged.connect(self.on_type_changed)
row1.addWidget(type_label) row1.addWidget(type_label)
row1.addWidget(self.type_combo) row1.addWidget(self.type_combo)
@ -263,14 +267,14 @@ class AddLeaveDialog(QDialog):
# 사용 시간 (시간 연차용) # 사용 시간 (시간 연차용)
hours_layout = QHBoxLayout() hours_layout = QHBoxLayout()
hours_label = QLabel("시간:") hours_label = QLabel(tr('view.leave.field_hours'))
hours_label.setObjectName("field_label") hours_label.setObjectName("field_label")
hours_label.setFixedWidth(40) hours_label.setFixedWidth(40)
self.hours_spin = QDoubleSpinBox() self.hours_spin = QDoubleSpinBox()
self.hours_spin.setRange(0.5, 8.0) self.hours_spin.setRange(0.5, 8.0)
self.hours_spin.setSingleStep(0.5) self.hours_spin.setSingleStep(0.5)
self.hours_spin.setValue(1.0) self.hours_spin.setValue(1.0)
self.hours_spin.setSuffix(" 시간") self.hours_spin.setSuffix(' ' + tr('label.unit_hour'))
self.hours_spin.setEnabled(False) self.hours_spin.setEnabled(False)
hours_layout.addWidget(hours_label) hours_layout.addWidget(hours_label)
hours_layout.addWidget(self.hours_spin) hours_layout.addWidget(self.hours_spin)
@ -278,27 +282,27 @@ class AddLeaveDialog(QDialog):
# 사유 # 사유
memo_layout = QHBoxLayout() memo_layout = QHBoxLayout()
memo_label = QLabel("사유:") memo_label = QLabel(tr('view.leave.field_reason'))
memo_label.setObjectName("field_label") memo_label.setObjectName("field_label")
memo_label.setFixedWidth(40) memo_label.setFixedWidth(40)
self.memo_input = QLineEdit() self.memo_input = QLineEdit()
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등") self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
memo_layout.addWidget(memo_label) memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_input) memo_layout.addWidget(self.memo_input)
layout.addLayout(memo_layout) layout.addLayout(memo_layout)
# 안내 # 안내
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.") info_label = QLabel(tr('view.leave.note_auto_deduct'))
info_label.setObjectName("note_text") info_label.setObjectName("note_text")
layout.addWidget(info_label) layout.addWidget(info_label)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary") save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save_record) save_button.clicked.connect(self.save_record)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
@ -336,8 +340,8 @@ class AddLeaveDialog(QDialog):
if current_balance < days: if current_balance < days:
QMessageBox.warning( QMessageBox.warning(
self, self,
"잔여 연차 부족", tr('view.leave.short_title'),
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}\n사용 요청: {days}" tr('view.leave.short_body', balance=current_balance, req=days)
) )
return return
@ -345,12 +349,10 @@ class AddLeaveDialog(QDialog):
hours = days * 8 hours = days * 8
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"연차 사용 기록 추가", tr('view.leave.confirm_title'),
f"날짜: {date}\n" tr('view.leave.confirm_body',
f"구분: {leave_type_name}\n" date=date, type=leave_type_name, days=days, hours=hours,
f"사용: {days}일 ({hours}시간)\n" reason=(memo if memo else '-')),
f"사유: {memo if memo else '(없음)'}\n\n"
f"이 기록을 추가하시겠습니까?",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@ -360,15 +362,15 @@ class AddLeaveDialog(QDialog):
self.db.use_leave(days, date, leave_type_name, memo) self.db.use_leave(days, date, leave_type_name, memo)
QMessageBox.information( QMessageBox.information(
self, self,
"추가 완료", tr('view.leave.added_title'),
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다." tr('view.leave.added_body', days=days, hours=hours)
) )
self.accept() self.accept()
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, self,
"오류", tr('view.leave.error_title'),
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}" tr('view.leave.error_body', err=str(e))
) )

View File

@ -81,13 +81,15 @@ class MainWindow(QMainWindow):
self.notifier = Notifier(self, db=self.db) self.notifier = Notifier(self, db=self.db)
self.notifier.notification_signal.connect(self.show_notification) self.notifier.notification_signal.connect(self.show_notification)
# 책임 분리된 컨트롤러들 (1Hz hot path) # 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
from ui.controllers.lock_monitor import LockMonitor from ui.controllers.lock_monitor import LockMonitor
from ui.controllers.auto_lunch import AutoLunchManager from ui.controllers.auto_lunch import AutoLunchManager
from ui.controllers.notification_orchestrator import NotificationOrchestrator from ui.controllers.notification_orchestrator import NotificationOrchestrator
from ui.controllers.meal_controller import MealController
self._lock_monitor = LockMonitor(self) self._lock_monitor = LockMonitor(self)
self._auto_lunch = AutoLunchManager(self) self._auto_lunch = AutoLunchManager(self)
self._notif_orch = NotificationOrchestrator(self) self._notif_orch = NotificationOrchestrator(self)
self._meal = MealController(self)
# 시스템 트레이 # 시스템 트레이
self.tray_icon = SystemTrayIcon(self) self.tray_icon = SystemTrayIcon(self)
@ -184,7 +186,11 @@ class MainWindow(QMainWindow):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
from core.version import __version__ from core.version import __version__
from ui.i18n_runtime import register
self._app_version = __version__
self.setWindowTitle(f"{tr('window.main_title')} v{__version__}") self.setWindowTitle(f"{tr('window.main_title')} v{__version__}")
register(self, 'window.main_title', setter='setWindowTitle',
post=lambda t: f"{t} v{__version__}")
self.setGeometry(100, 100, 500, 620) self.setGeometry(100, 100, 500, 620)
self.setMinimumSize(480, 520) self.setMinimumSize(480, 520)
@ -326,6 +332,14 @@ class MainWindow(QMainWindow):
help_button = QPushButton(tr('menu.help')) help_button = QPushButton(tr('menu.help'))
settings_button = QPushButton(tr('menu.settings')) settings_button = QPushButton(tr('menu.settings'))
# 런타임 i18n 등록
for btn, key in [(stats_button, 'menu.stats'),
(calendar_button, 'menu.calendar'),
(report_button, 'menu.daily_report'),
(help_button, 'menu.help'),
(settings_button, 'menu.settings')]:
register(btn, key)
for btn in [stats_button, calendar_button, report_button, help_button, settings_button]: for btn in [stats_button, calendar_button, report_button, help_button, settings_button]:
bottom_layout.addWidget(btn) bottom_layout.addWidget(btn)
@ -750,40 +764,20 @@ class MainWindow(QMainWindow):
self.date_label.setText(date_str) self.date_label.setText(date_str)
def toggle_lunch_break(self): def toggle_lunch_break(self):
"""점심시간 토글""" """점심시간 토글 — MealController 위임."""
self.lunch_break_enabled = self.lunch_button.isChecked() self._meal.toggle_lunch()
self.update_lunch_status()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if self.lunch_break_enabled:
self.auto_lunch_applied_today = True
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, self.lunch_break_enabled)
def toggle_dinner_break(self): def toggle_dinner_break(self):
"""저녁시간 토글""" """저녁시간 토글 — MealController 위임."""
self.dinner_break_enabled = self.dinner_button.isChecked() self._meal.toggle_dinner()
self.update_dinner_status()
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, self.dinner_break_enabled)
def update_lunch_status(self): def update_lunch_status(self):
"""점심시간 상태 업데이트""" """점심시간 상태 업데이트 — MealController 위임."""
self.lunch_button.setText( self._meal.refresh_lunch_label()
tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add')
)
def update_dinner_status(self): def update_dinner_status(self):
"""저녁시간 상태 업데이트""" """저녁시간 상태 업데이트 — MealController 위임."""
self.dinner_button.setText( self._meal.refresh_dinner_label()
tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add')
)
def update_overtime_balance(self): def update_overtime_balance(self):
"""연장근무 잔액 업데이트""" """연장근무 잔액 업데이트"""

View File

@ -38,24 +38,28 @@ class OvertimeView(QDialog):
# 제목 + 잔액 한 줄 # 제목 + 잔액 한 줄
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
title = QLabel("연장근무 내역") title = QLabel(tr('view.overtime.title'))
title.setObjectName("dialog_title") title.setObjectName("dialog_title")
header_layout.addWidget(title) header_layout.addWidget(title)
header_layout.addStretch() header_layout.addStretch()
self.balance_label = QLabel("잔액: 0분") self.balance_label = QLabel(tr('view.overtime.balance_zero'))
self.balance_label.setObjectName("badge_balance") self.balance_label.setObjectName("badge_balance")
header_layout.addWidget(self.balance_label) header_layout.addWidget(self.balance_label)
layout.addLayout(header_layout) layout.addLayout(header_layout)
# 적립 내역 # 적립 내역
earned_group = QGroupBox("💰 적립 내역") earned_group = QGroupBox(tr('view.overtime.earned_group'))
earned_layout = QVBoxLayout() earned_layout = QVBoxLayout()
earned_layout.setSpacing(4) earned_layout.setSpacing(4)
earned_layout.setContentsMargins(8, 20, 8, 6) earned_layout.setContentsMargins(8, 20, 8, 6)
self.earned_table = QTableWidget() self.earned_table = QTableWidget()
self.earned_table.setColumnCount(3) self.earned_table.setColumnCount(3)
self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"]) self.earned_table.setHorizontalHeaderLabels([
tr('view.overtime.col_date'),
tr('view.overtime.col_earned'),
tr('view.overtime.col_memo'),
])
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@ -64,7 +68,7 @@ class OvertimeView(QDialog):
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
earned_layout.addWidget(self.earned_table) earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton(" 수동 적립") add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
add_earned_button.clicked.connect(self.add_earned_record) add_earned_button.clicked.connect(self.add_earned_record)
earned_layout.addWidget(add_earned_button) earned_layout.addWidget(add_earned_button)
@ -72,14 +76,18 @@ class OvertimeView(QDialog):
layout.addWidget(earned_group) layout.addWidget(earned_group)
# 사용 내역 # 사용 내역
used_group = QGroupBox("📤 사용 내역") used_group = QGroupBox(tr('view.overtime.used_group'))
used_layout = QVBoxLayout() used_layout = QVBoxLayout()
used_layout.setSpacing(4) used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6) used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget() self.used_table = QTableWidget()
self.used_table.setColumnCount(3) self.used_table.setColumnCount(3)
self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"]) self.used_table.setHorizontalHeaderLabels([
tr('view.overtime.col_date'),
tr('view.overtime.col_used'),
tr('view.overtime.col_reason'),
])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@ -90,7 +98,7 @@ class OvertimeView(QDialog):
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu) self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
used_layout.addWidget(self.used_table) used_layout.addWidget(self.used_table)
add_used_button = QPushButton(" 수동 사용") add_used_button = QPushButton(tr('view.overtime.btn_add_used'))
add_used_button.clicked.connect(self.add_used_record) add_used_button.clicked.connect(self.add_used_record)
used_layout.addWidget(add_used_button) used_layout.addWidget(add_used_button)
@ -98,7 +106,7 @@ class OvertimeView(QDialog):
layout.addWidget(used_group) layout.addWidget(used_group)
# 닫기 버튼 # 닫기 버튼
close_button = QPushButton("닫기") close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
layout.addWidget(close_button) layout.addWidget(close_button)
@ -110,23 +118,21 @@ class OvertimeView(QDialog):
balance = self.db.get_total_overtime_balance() balance = self.db.get_total_overtime_balance()
hours = balance // 60 hours = balance // 60
minutes = balance % 60 minutes = balance % 60
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)") self.balance_label.setText(tr('view.overtime.balance_fmt',
h=hours, m=minutes, total=balance))
# 적립 내역 로드 # 적립 내역 로드
conn = self.db.get_connection() conn = self.db.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT ob.date, ob.earned_minutes, SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
CASE
WHEN ob.work_record_id IS NULL THEN '수동 추가'
ELSE COALESCE(wr.memo, '')
END as memo
FROM overtime_bank ob FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC ORDER BY ob.date DESC
''') ''')
earned_records = cursor.fetchall() earned_records = cursor.fetchall()
manual_label = tr('msg.manual_added')
self.earned_table.setRowCount(len(earned_records)) self.earned_table.setRowCount(len(earned_records))
for i, record in enumerate(earned_records): for i, record in enumerate(earned_records):
@ -136,12 +142,17 @@ class OvertimeView(QDialog):
minutes = record[1] minutes = record[1]
hours = minutes // 60 hours = minutes // 60
mins = minutes % 60 mins = minutes % 60
time_str = f"{hours}시간 {mins}" if hours > 0 else f"{mins}" if hours > 0:
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
else:
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str) time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter) time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색 time_item.setForeground(QColor(39, 174, 96)) # 초록색
memo_item = QTableWidgetItem(record[2] or "") # work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "")
memo_item = QTableWidgetItem(memo_text)
self.earned_table.setItem(i, 0, date_item) self.earned_table.setItem(i, 0, date_item)
self.earned_table.setItem(i, 1, time_item) self.earned_table.setItem(i, 1, time_item)
@ -166,7 +177,10 @@ class OvertimeView(QDialog):
minutes = record[2] minutes = record[2]
hours = minutes // 60 hours = minutes // 60
mins = minutes % 60 mins = minutes % 60
time_str = f"{hours}시간 {mins}" if hours > 0 else f"{mins}" if hours > 0:
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
else:
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str) time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter) time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색 time_item.setForeground(QColor(231, 76, 60)) # 빨간색
@ -188,7 +202,7 @@ class OvertimeView(QDialog):
# 컨텍스트 메뉴 생성 # 컨텍스트 메뉴 생성
menu = QMenu(self) menu = QMenu(self)
delete_action = QAction("❌ 삭제", self) delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_used_record) delete_action.triggered.connect(self.delete_used_record)
menu.addAction(delete_action) menu.addAction(delete_action)
@ -213,11 +227,10 @@ class OvertimeView(QDialog):
# 확인 메시지 # 확인 메시지
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"삭제 확인", tr('msg.confirm_delete.title'),
f"다음 사용 기록을 삭제하시겠습니까?\n\n" tr('view.overtime.delete_confirm_body',
f"날짜: {date_item.text()}\n" date=date_item.text(), time=time_item.text(),
f"시간: {time_item.text()}\n" reason=reason_item.text()),
f"사유: {reason_item.text()}",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("추가근무 수동 적립") self.setWindowTitle(tr('view.overtime.manual_earned_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(360) self.setMinimumWidth(360)
@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("추가근무 수동 적립") title = QLabel(tr('view.overtime.manual_earned_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
# 날짜 # 날짜
date_layout = QHBoxLayout() date_layout = QHBoxLayout()
date_label = QLabel("날짜:") date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label") date_label.setObjectName("field_label")
date_label.setFixedWidth(60) date_label.setFixedWidth(60)
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog):
# 시간 (30분 단위) # 시간 (30분 단위)
time_layout = QHBoxLayout() time_layout = QHBoxLayout()
time_label = QLabel("시간:") time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label") time_label.setObjectName("field_label")
time_label.setFixedWidth(60) time_label.setFixedWidth(60)
self.hour_spin = QSpinBox() self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23) self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간") self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
self.minute_combo = QComboBox() self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"]) self.minute_combo.addItems([tr('view.overtime.minute_0'),
tr('view.overtime.minute_30')])
time_layout.addWidget(time_label) time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo) time_layout.addWidget(self.minute_combo)
@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog):
# 메모 # 메모
memo_layout = QHBoxLayout() memo_layout = QHBoxLayout()
memo_label = QLabel("메모:") memo_label = QLabel(tr('view.overtime.field_memo'))
memo_label.setObjectName("field_label") memo_label.setObjectName("field_label")
memo_label.setFixedWidth(60) memo_label.setFixedWidth(60)
self.memo_edit = QLineEdit() self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("선택사항") self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo'))
memo_layout.addWidget(memo_label) memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_edit) memo_layout.addWidget(self.memo_edit)
layout.addLayout(memo_layout) layout.addLayout(memo_layout)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary") save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save) save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog):
"""저장""" """저장"""
# 시간 계산 (30분 단위) # 시간 계산 (30분 단위)
hours = self.hour_spin.value() hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30 minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
total_minutes = hours * 60 + minutes total_minutes = hours * 60 + minutes
if total_minutes == 0: if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.") QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_add_error'))
return return
date = self.date_edit.date().toString("yyyy-MM-dd") date = self.date_edit.date().toString("yyyy-MM-dd")
@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"저장 완료", tr('msg.save_success.title'),
f"{hours}시간 {minutes}분이 적립되었습니다." tr('view.overtime.saved_earned', h=hours, m=minutes)
) )
self.accept() self.accept()
@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("추가근무 수동 사용") self.setWindowTitle(tr('view.overtime.manual_used_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(360) self.setMinimumWidth(360)
@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog):
# 제목 + 잔액 한 줄 # 제목 + 잔액 한 줄
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
title = QLabel("추가근무 수동 사용") title = QLabel(tr('view.overtime.manual_used_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
header_layout.addWidget(title) header_layout.addWidget(title)
header_layout.addStretch() header_layout.addStretch()
balance = self.db.get_total_overtime_balance() balance = self.db.get_total_overtime_balance()
hours = balance // 60 hours = balance // 60
minutes = balance % 60 minutes = balance % 60
balance_label = QLabel(f"잔액: {hours}시간 {minutes}") if hours > 0:
balance_text = tr('view.break.duration_fmt', h=hours, m=minutes)
else:
balance_text = tr('view.break.duration_min_only', m=minutes)
balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}")
balance_label.setObjectName("badge_balance") balance_label.setObjectName("badge_balance")
header_layout.addWidget(balance_label) header_layout.addWidget(balance_label)
layout.addLayout(header_layout) layout.addLayout(header_layout)
# 날짜 # 날짜
date_layout = QHBoxLayout() date_layout = QHBoxLayout()
date_label = QLabel("날짜:") date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label") date_label.setObjectName("field_label")
date_label.setFixedWidth(60) date_label.setFixedWidth(60)
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog):
# 시간 (30분 단위) # 시간 (30분 단위)
time_layout = QHBoxLayout() time_layout = QHBoxLayout()
time_label = QLabel("시간:") time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label") time_label.setObjectName("field_label")
time_label.setFixedWidth(60) time_label.setFixedWidth(60)
self.hour_spin = QSpinBox() self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23) self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간") self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
self.minute_combo = QComboBox() self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"]) self.minute_combo.addItems([tr('view.overtime.minute_0'),
tr('view.overtime.minute_30')])
time_layout.addWidget(time_label) time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo) time_layout.addWidget(self.minute_combo)
@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog):
# 사유 # 사유
reason_layout = QHBoxLayout() reason_layout = QHBoxLayout()
reason_label = QLabel("사유:") reason_label = QLabel(tr('view.overtime.field_reason'))
reason_label.setObjectName("field_label") reason_label.setObjectName("field_label")
reason_label.setFixedWidth(60) reason_label.setFixedWidth(60)
self.reason_edit = QLineEdit() self.reason_edit = QLineEdit()
self.reason_edit.setPlaceholderText("예: 개인 사유") self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason'))
reason_layout.addWidget(reason_label) reason_layout.addWidget(reason_label)
reason_layout.addWidget(self.reason_edit) reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout) layout.addLayout(reason_layout)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary") save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save) save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog):
"""저장""" """저장"""
# 시간 계산 (30분 단위) # 시간 계산 (30분 단위)
hours = self.hour_spin.value() hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30 minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
total_minutes = hours * 60 + minutes total_minutes = hours * 60 + minutes
if total_minutes == 0: if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.") QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_use_error'))
return return
# 잔액 확인 # 잔액 확인
@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog):
if total_minutes > balance: if total_minutes > balance:
QMessageBox.warning( QMessageBox.warning(
self, self,
"잔액 부족", tr('view.overtime.balance_short_title'),
f"사용 가능한 시간이 부족합니다.\n\n" tr('view.overtime.balance_short_body',
f"요청: {hours}시간 {minutes}\n" req_h=hours, req_m=minutes,
f"잔액: {balance // 60}시간 {balance % 60}" bal_h=balance // 60, bal_m=balance % 60)
) )
return return
date = self.date_edit.date().toString("yyyy-MM-dd") date = self.date_edit.date().toString("yyyy-MM-dd")
reason = self.reason_edit.text().strip() or "수동 사용" reason = self.reason_edit.text().strip() or tr('msg.manual_added')
# DB에 저장 # DB에 저장
self.db.add_overtime_usage(None, total_minutes, date, reason) self.db.add_overtime_usage(None, total_minutes, date, reason)
QMessageBox.information( QMessageBox.information(
self, self,
"저장 완료", tr('msg.save_success.title'),
f"{hours}시간 {minutes}분이 사용 처리되었습니다." tr('view.overtime.saved_used', h=hours, m=minutes)
) )
self.accept() self.accept()

View File

@ -1114,16 +1114,18 @@ class SettingsView(QDialog):
if self.parent_window and hasattr(self.parent_window, 'reload_settings'): if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
self.parent_window.reload_settings() self.parent_window.reload_settings()
# 언어 변경 감지 → 재시작 제안 # 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장
if hasattr(self, 'language_combo'): if hasattr(self, 'language_combo'):
from core.i18n import get_language from core.i18n import get_language
from ui.i18n_runtime import set_language_and_retranslate
new_lang = self.language_combo.currentData() new_lang = self.language_combo.currentData()
if new_lang and new_lang != get_language(): if new_lang and new_lang != get_language():
set_language_and_retranslate(new_lang)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"재시작 필요 / Restart required", "재시작 / Restart",
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n" "주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
"Restart now to fully apply the language change?", "Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes: