diff --git a/AGENTS.md b/AGENTS.md index 6fedf4f..31b01ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,40 @@ # Project Conventions and Operational Gotchas ## ๐Ÿ› ๏ธ 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` -- **Module-level tests:** +- **Module-level smoke:** - Event monitoring: `python core/event_monitor.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.0โ€“2.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) -- **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. -- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries โ€” never filter them out. -- **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). -- **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). -- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect. +- **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 canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES โ†” WORK_HOURS` via floor (`int(min) // 60`). +- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth. +- **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) @@ -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. ### 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 24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "์˜ค์ „"/"์˜คํ›„" markers when applicable). ### 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 -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 --new --target `. 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 ```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) -- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`) -- **Stale `dist/main.exe` running** โ†’ `PermissionError`. Kill it first. -- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env. +- `dist/main.exe` running โ†’ `PermissionError`. Kill it first. +- `holidays` package only baked in if installed in build env. +- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`. ## ๐Ÿšฆ 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. -- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only โ€” never expose externally. Read-only by design. +- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/`. Repo must be public. +- **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. - **`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. -- **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". -- **`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. -- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters. +- **+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**: 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. Auto-synced in `save_settings()`. +- **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 -- **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. -- **Partially translated**: settings_view sub-labels (์ž…๋ ฅ๋ž€ placeholder ๋“ฑ), calendar_view detail labels. -- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated. +- **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, calendar_view detail labels, meal_time_dialog, past_record_dialog. +- **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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 293c860..eb95f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). +## [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 ์‹œ๋‚˜๋ฆฌ์˜ค (S36โ€“S52): ์˜จ๋ณด๋”ฉ ์‹ ๊ทœ/๊ธฐ์กด, 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.4โ€“v2.6 ์‹ ๊ธฐ๋Šฅ ์„น์…˜ ์ •๋ฆฌ, ์žฌ๋ฒˆํ˜ธ. +- `CLAUDE.md` v2.6+ ์•„ํ‚คํ…์ฒ˜ โ€” ์ปจํŠธ๋กค๋Ÿฌ ๋ชจ๋“ˆ, 35+ ์„ค์ • ํ‚ค, ๋ฆด๋ฆฌ์Šค ํ”Œ๋กœ์šฐ ๋ฐ˜์˜. +- `INSTALL.md` ์ตœ์‹  ๋‹จ์ผํŒŒ์ผ ๋ฐฐํฌ + ์˜จ๋ณด๋”ฉ + ๋””์Šค์ฝ”๋“œ + ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ •๋ฆฌ. +- `AGENTS.md` 10+ DB ํ…Œ์ด๋ธ”, ์ปจํŠธ๋กค๋Ÿฌ ๋งต, Past Incidents ๊ฐฑ์‹ . + ## [2.6.0] โ€” 2026-04-30 ### Added โ€” Phase 4 (3์ข…) diff --git a/CLAUDE.md b/CLAUDE.md index b0e6827..8129966 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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 @@ -20,46 +20,73 @@ python main.py python core/event_monitor.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 -# Integration tests (35 + 5 + view scenarios) -python _integration_test.py -python _i18n_gui_test.py -python _gui_smoke_test.py -``` +# Tests +python _integration_test.py # business-logic scenarios +python _i18n_gui_test.py # ko/en GUI verification +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 = '' +.\release.ps1 v2.7.0 +``` ## Architecture -### 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()`. -- **[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()`. -- **[event_monitor.py](core/event_monitor.py)** โ€” Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required. -- **[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. -- **[ai_analysis.py](core/ai_analysis.py)** โ€” Optional Claude API integration. `get_insights(records, api_key)`: with key โ†’ Claude, without โ†’ `static_summary()` fallback. -- **[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')`. -- **[settings_keys.py](core/settings_keys.py)** โ€” All setting keys as constants. Modules import these instead of raw strings. +### core/ +- **[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 `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)** โ€” Windows Event IDs 6005/4624/6006. +- **[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. +- **[salary.py](core/salary.py)** โ€” `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator. +- **[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. ~35 keys. +- **[version.py](core/version.py)** โ€” `__version__` single source of truth. -### 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). -- **[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`. -- **[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. -- **[mini_widget.py](ui/mini_widget.py)** โ€” Always-on-top frameless widget; updated from `update_display()` when visible. -- **[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. -- 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). -- **[chart_widget.py](ui/chart_widget.py)** โ€” matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing. +### ui/ +- **[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. +- **[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. +- **[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. +- **[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`. +- **[today_summary.py](ui/today_summary.py)** โ€” Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in. +- **[goal_widget.py](ui/goal_widget.py)** โ€” Monthly overtime cap + daily avg progress bars. Hidden when both goals=0. +- **[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/`) -- **[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_detector.py](utils/lock_detector.py)** โ€” Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" โ†’ locked). -- **[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`. -- **[debug_log.py](utils/debug_log.py)** โ€” `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production. +### ui/controllers/ +- **[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). +- **[auto_lunch.py](ui/controllers/auto_lunch.py)** โ€” 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache. +- **[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. + +### 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. -- **[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 --new --target `. 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: ```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 ``` -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 -- `work_records.date` UNIQUE (one row/day). -- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings. -- `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. -- `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()`. -- Settings dict from `get_settings()` already auto-converts numeric strings to int/float โ€” additional `int(x)` casts in callers are dead code. +- `work_records.date` UNIQUE. +- `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. Don't filter `NOT NULL` โ€” those are manual additions. +- `leave_records.days` is FLOAT (1.0/0.5/0.25). +- Balance: `SUM(bank.earned) - SUM(usage.used)`. +- `notification_log` for daily dedupe (channel+event_type+date). +- `crash_log` for unhandled exceptions. ## 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()`: -- `WORK_MINUTES โ†” WORK_HOURS` (floor: 450 min โ†’ 7 h, not 8 โ€” settings_view sends `WORK_MINUTES` only) +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) - `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 -`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. -- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "์˜ค์ „"/"์˜คํ›„" markers when `time_format=12`. -- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover. -- **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. -- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture. -- **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. -- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon). +- **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict. +- 24h `datetime` internal. 12h conversion only in `format_time()`. +- 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min. +- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. +- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths. +- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py). ## Tests -- [_integration_test.py](_integration_test.py) โ€” 35 business-logic scenarios (no Qt). -- [_gui_smoke_test.py](_gui_smoke_test.py) โ€” 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`. -- [_i18n_gui_test.py](_i18n_gui_test.py) โ€” 5 ko/en switch verifications on real widgets. +- [_integration_test.py](_integration_test.py) โ€” Business-logic scenarios (no Qt). +- [_gui_smoke_test.py](_gui_smoke_test.py) โ€” Widget instantiation via `QT_QPA_PLATFORM=offscreen`. +- [_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). diff --git a/INSTALL.md b/INSTALL.md index f9e3502..086a117 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 ์ด์ƒ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. -### Windows +#### Windows 1. https://www.python.org/downloads/ ๋ฐฉ๋ฌธ 2. "Download Python 3.x.x" ํด๋ฆญ -3. ์„ค์น˜ ์‹œ **"Add Python to PATH"** ์ฒดํฌ ํ•„์ˆ˜! +3. ์„ค์น˜ ์‹œ **"Add Python to PATH"** ์ฒดํฌ ํ•„์ˆ˜ ํ™•์ธ: ```bash 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์„ ์—ฝ๋‹ˆ๋‹ค. @@ -26,66 +47,26 @@ python --version pip install -r requirements.txt ``` -### ์„ค์น˜๋˜๋Š” ํŒจํ‚ค์ง€: -1. **PyQt5** - GUI ํ”„๋ ˆ์ž„์›Œํฌ -2. **pywin32** - Windows API ์ ‘๊ทผ -3. **python-dateutil** - ๋‚ ์งœ ๊ณ„์‚ฐ -4. **matplotlib** - ๊ทธ๋ž˜ํ”„ -5. **plyer** - ์•Œ๋ฆผ ์‹œ์Šคํ…œ -6. **holidays** - ํ•œ๊ตญ ๊ณตํœด์ผ ์ž๋™ ๋“ฑ๋ก (์Œ๋ ฅ ๋ช…์ ˆ ํฌํ•จ) +#### ์„ค์น˜๋˜๋Š” ํŒจํ‚ค์ง€ (requirements.txt) +1. **PyQt5** โ€” GUI ํ”„๋ ˆ์ž„์›Œํฌ +2. **pywin32** โ€” Windows API (์ด๋ฒคํŠธ ๋ทฐ์–ด, ํ™”๋ฉด ์ž ๊ธˆ ๊ฐ์ง€) +3. **python-dateutil** โ€” ๋‚ ์งœ ๊ณ„์‚ฐ +4. **matplotlib** โ€” ํ†ต๊ณ„ ์ฐจํŠธ +5. **plyer** โ€” ์‹œ์Šคํ…œ ์•Œ๋ฆผ +6. **holidays** โ€” ํ•œ๊ตญ ๊ณตํœด์ผ ์ž๋™ ๋“ฑ๋ก (์Œ๋ ฅ ๋ช…์ ˆ ํฌํ•จ) -## 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. ์‹คํ–‰ +### 4. ์‹คํ–‰ ```bash python main.py ``` -### ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์œผ๋กœ ์‹คํ–‰ (๊ถŒ์žฅ) +#### ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์œผ๋กœ ์‹คํ–‰ (๊ถŒ์žฅ) Windows ์ด๋ฒคํŠธ ๋ทฐ์–ด ์ ‘๊ทผ์„ ์œ„ํ•ด ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. 1. **๋ฐฉ๋ฒ• 1**: cmd๋ฅผ ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์œผ๋กœ ์‹คํ–‰ - - Windows ํ‚ค + X - - "๋ช…๋ น ํ”„๋กฌํ”„ํŠธ(๊ด€๋ฆฌ์ž)" ๋˜๋Š” "Windows PowerShell(๊ด€๋ฆฌ์ž)" ์„ ํƒ + - Windows ํ‚ค + X โ†’ "ํ„ฐ๋ฏธ๋„(๊ด€๋ฆฌ์ž)" ๋˜๋Š” "PowerShell(๊ด€๋ฆฌ์ž)" - ํ”„๋กœ์ ํŠธ ํด๋”๋กœ ์ด๋™: `cd "๊ฒฝ๋กœ"` - `python main.py` ์‹คํ–‰ @@ -93,11 +74,39 @@ Windows ์ด๋ฒคํŠธ ๋ทฐ์–ด ์ ‘๊ทผ์„ ์œ„ํ•ด ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ - `python main.py`๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๋ฐฐ์น˜ ํŒŒ์ผ(.bat) ์ƒ์„ฑ - ์šฐํด๋ฆญ โ†’ ์†์„ฑ โ†’ ๊ณ ๊ธ‰ โ†’ "๊ด€๋ฆฌ์ž ๊ถŒํ•œ์œผ๋กœ ์‹คํ–‰" ์ฒดํฌ -## 6. ์ฒซ ์‹คํ–‰ +### 5. ์ฒซ ์‹คํ–‰ -1. ํ”„๋กœ๊ทธ๋žจ์ด ์‹คํ–‰๋˜๋ฉด ์ž๋™์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค(`database.db`) ์ƒ์„ฑ -2. Windows ์ด๋ฒคํŠธ ๋ทฐ์–ด์—์„œ ์˜ค๋Š˜์˜ ๋ถ€ํŒ… ์‹œ๊ฐ„ ์ž๋™ ๊ฐ์ง€ -3. ๊ฐ์ง€๋œ ์‹œ๊ฐ„์ด ์ถœ๊ทผ์‹œ๊ฐ„์œผ๋กœ ์„ค์ •๋จ +1. ์‹คํ–‰ ์‹œ ์ž๋™์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค(`database.db`) ์ƒ์„ฑ +2. 5๋‹จ๊ณ„ ์˜จ๋ณด๋”ฉ ์œ„์ €๋“œ ํ‘œ์‹œ + - ํ™˜์˜ โ†’ ๊ทผ๋ฌดํŒจํ„ด โ†’ ์ถœ๊ทผ ์ž๋™ ๊ฐ์ง€ โ†’ ์—ฐ์ฐจ/์‹œ๊ธ‰ โ†’ 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 +git pull pip install --upgrade -r requirements.txt ``` ## ์ œ๊ฑฐ -1. ํ”„๋กœ์ ํŠธ ํด๋” ์‚ญ์ œ -2. ํŒจํ‚ค์ง€ ์ œ๊ฑฐ (์„ ํƒ): -```bash -pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer -``` +1. ํ”„๋กœ์ ํŠธ ํด๋” ์‚ญ์ œ (`main.exe` ๋˜๋Š” ์†Œ์Šค) +2. ๋ฐ์ดํ„ฐ ๋ณด์กดํ•˜๋ ค๋ฉด `database.db`๋งŒ ๋ณ„๋„ ๋ฐฑ์—… +3. ์ž๋™ ๋ฐฑ์—…: `~/.clockout_backups/` ์— 7๊ฐœ ํšŒ์ „ ๋ณด๊ด€ (ํ•„์š” ์‹œ ์‚ญ์ œ) ## ํ”„๋กœ๋•์…˜ ๋นŒ๋“œ (PyInstaller) ์†Œ์Šค ์—†์ด ์‹คํ–‰ ํŒŒ์ผ๋งŒ ๋ฐฐํฌํ•˜๋ ค๋ฉด: + ```bash -python -m PyInstaller --clean main.spec # โ†’ dist/main.exe (~73MB) -python -m PyInstaller --clean updater.spec # โ†’ dist/updater.exe (~6MB, ์ž๊ฐ€ ์—…๋ฐ์ดํ„ฐ) +# ์ž๊ฐ€ ์—…๋ฐ์ดํ„ฐ ๋จผ์ € ๋นŒ๋“œ (main.spec์ด ๋ฐ์ดํ„ฐ๋กœ ์ž„๋ฒ ๋“œ) +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๋ฅผ ํ•จ๊ป˜ ํฌํ•จ์‹œ์ผœ ๊ฐ™์€ ํด๋”์— ๋‘์„ธ์š”. -์ž๋™ ์—…๋ฐ์ดํŠธ๋Š” main.exe๊ฐ€ ๊ฐ™์€ ํด๋”์˜ updater.exe๋ฅผ ํ˜ธ์ถœํ•ด์•ผ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. +๋˜๋Š” [release.ps1](release.ps1) ํ•œ ๋ฒˆ ์‹คํ–‰์œผ๋กœ ์ „์ฒด ์ž๋™ํ™”. ๋นŒ๋“œ ์‹œ ์ฃผ์˜: - `dist/main.exe`๊ฐ€ ์‹คํ–‰ ์ค‘์ด๋ฉด `PermissionError` ๋ฐœ์ƒ โ†’ ์ข…๋ฃŒ ํ›„ ์žฌ์‹คํ–‰ - `holidays` ๋“ฑ ์˜ต์…”๋„ ํŒจํ‚ค์ง€๋Š” ์„ค์น˜๋œ ํ™˜๊ฒฝ์—์„œ ๋นŒ๋“œํ•ด์•ผ ํฌํ•จ๋จ - -## ํ™˜๊ฒฝ ๋ณ€์ˆ˜ - -- `CLOCKOUT_DEBUG=1` โ€” ๋””๋ฒ„๊ทธ ๋กœ๊ทธ๋ฅผ `~/.clockout_logs/debug.log`๋กœ ์ถœ๋ ฅ -- `CLOCKOUT_DEBUG_DIR=๊ฒฝ๋กœ` โ€” ๋กœ๊ทธ ์ €์žฅ ์œ„์น˜ ๋ณ€๊ฒฝ +- `main.exe` ๋‹จ์ผ ๋ฐฐํฌ๊ฐ€ ๊ฐ€๋Šฅ (updater.exe๋Š” ์ฒซ ์‹คํ–‰ ์‹œ ์ž๋™ ์ถ”์ถœ) ## ๋‹ค์Œ ๋‹จ๊ณ„ -์„ค์น˜๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ๋‹ค๋ฉด [README.md](README.md)๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ ํ”„๋กœ๊ทธ๋žจ์„ ์‚ฌ์šฉํ•˜์„ธ์š”! +์„ค์น˜๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ๋‹ค๋ฉด [README.md](README.md) ์™€ ๋„์›€๋ง(F1)์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. +๊ฐœ๋ฐœ์ž๋Š” [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) ๋„ ํ•จ๊ป˜ ์ฝ์œผ์„ธ์š”. diff --git a/README.md b/README.md index 43d65dc..0f6d47c 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ ### 7. ํ†ต๊ณ„ยท๋ถ„์„ - ์ฃผ๊ฐ„/์›”๊ฐ„ ์š”์•ฝ + matplotlib ์ฐจํŠธ -- ์ผ๋ณ„ ๊ทผ๋ฌด์‹œ๊ฐ„ + ์—ฐ์žฅ ๋ˆ„์  ๋ง‰๋Œ€ ๊ทธ๋ž˜ํ”„ +- ์ผ๋ณ„ ๊ทผ๋ฌด์‹œ๊ฐ„ + ์—ฐ์žฅ ๋ˆ„์  ๋ง‰๋Œ€ ๊ทธ๋ž˜ํ”„ (ํ˜ธ๋ฒ„ ์‹œ ์ •ํ™•ํ•œ ์ˆ˜์น˜ ํˆดํŒ) - ์š”์ผ๋ณ„ ํ‰๊ท  ๊ทผ๋ฌด์‹œ๊ฐ„ -- ๊ทผ๋ฌด ํŒจํ„ด ์ธ์‚ฌ์ดํŠธ (์ •์  ํ†ต๊ณ„ ์š”์•ฝ) +- ์ถœ๊ทผ ์‹œ๊ฐ ๋ถ„ํฌ ํžˆ์Šคํ† ๊ทธ๋žจ (30๋ถ„ ๋‹จ์œ„ + ํ‰๊ท ์„ ) +- ๊ทผ๋ฌด ํŒจํ„ด ์ธ์‚ฌ์ดํŠธ +- **์‹œ๊ธ‰ ์˜ต์…˜ ํ™œ์„ฑ ์‹œ ์ถ”์ • ๊ธ‰์—ฌ** (์›”๊ฐ„ + ์˜ค๋Š˜ ์š”์•ฝ) ### 8. ๊ณตํœด์ผ ๊ด€๋ฆฌ - ํ•œ๊ตญ ๊ณตํœด์ผ ์ž๋™ ๋“ฑ๋ก (`holidays` ํŒจํ‚ค์ง€) @@ -69,13 +71,35 @@ - ์ƒˆ ๋ฒ„์ „ ๋ฐœ๊ฒฌ ์‹œ ์•Œ๋ฆผ + ์‚ฌ์šฉ์ž ๋™์˜ ํ›„ ์ž๋™ ๋‹ค์šด๋กœ๋“œยท๊ต์ฒดยท์žฌ์‹œ์ž‘ - F5 ๋˜๋Š” ์„ค์ • โ†’ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ โ†’ "์—…๋ฐ์ดํŠธ ํ™•์ธ" ์œผ๋กœ ์ˆ˜๋™ ํŠธ๋ฆฌ๊ฑฐ - ์‹คํŒจ ์‹œ ์ž๋™ ๋กค๋ฐฑ +- **main.exe ๋‹จ๋… ๋ฐฐํฌ** (updater.exe ๋‚ด์žฅ, ์ฒซ ์‹คํ–‰ ์‹œ ์ž๋™ ์ถ”์ถœ) -### 12. ๋‹ค๊ตญ์–ด ์ง€์› (i18n) -- ํ•œ๊ตญ์–ด / English ์ „ํ™˜ -- ์•Œ๋ฆผ ๋ฉ”์‹œ์ง€ยทUI ๋ผ๋ฒจ 28๊ฐœ ์นดํ…Œ๊ณ ๋ฆฌ +### 12. ์ฒซ ์‹คํ–‰ ์˜จ๋ณด๋”ฉ ์œ„์ €๋“œ +- ์‹ ๊ทœ ์‚ฌ์šฉ์ž: 5๋‹จ๊ณ„ (ํ™˜์˜ โ†’ ๊ทผ๋ฌดํŒจํ„ด โ†’ ์ถœ๊ทผ ๊ฐ์ง€ โ†’ ์—ฐ์ฐจ/์‹œ๊ธ‰ โ†’ Discord) ๊ฐ•์ œ ํ‘œ์‹œ +- ๊ธฐ์กด ์‚ฌ์šฉ์ž: ์ž๋™ ์™„๋ฃŒ ์ฒ˜๋ฆฌ + ๋„์›€๋ง(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 ์ฝ˜ํ…์ธ  -### 13. ๋‹จ์ถ•ํ‚ค +### 17. ๋‹จ์ถ•ํ‚ค - `Ctrl+O` ์ถœ/ํ‡ด๊ทผ ํ† ๊ธ€ - `Ctrl+L` ์ ์‹ฌ ํ† ๊ธ€, `Ctrl+D` ์ €๋… ํ† ๊ธ€ - `Ctrl+B` ์™ธ์ถœ ๊ด€๋ฆฌ, `Ctrl+,` ์„ค์ •, `F1` ๋„์›€๋ง diff --git a/_integration_test.py b/_integration_test.py index 0756f2f..9b4e25d 100644 --- a/_integration_test.py +++ b/_integration_test.py @@ -485,6 +485,234 @@ def s35_lock(): # ์ผ๋ฐ˜์ ์œผ๋กœ 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 # ============================================================ @@ -520,6 +748,23 @@ def main(): s33_short_overtime() s34_format() 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("=" * 60) diff --git a/core/i18n.py b/core/i18n.py index 44f0896..3cb56d7 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -103,6 +103,7 @@ _DICT = { 'msg.no_record.body': '์˜ค๋Š˜ ์ถœ๊ทผ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.', 'msg.confirm_delete.title': '์‚ญ์ œ ํ™•์ธ', 'msg.no_data.title': '๋ฐ์ดํ„ฐ ์—†์Œ', + 'msg.manual_added': '์ˆ˜๋™ ์ถ”๊ฐ€', # === ํŠธ๋ ˆ์ด === 'tray.open': 'ํ”„๋กœ๊ทธ๋žจ ์—ด๊ธฐ', @@ -153,6 +154,103 @@ _DICT = { 'help.tab_leave': '๐ŸŒด ์—ฐ์ฐจ/ํœด๊ฐ€', 'help.tab_break': '๐Ÿšช ์™ธ์ถœ/์ €๋…', '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': { # === Menu/Buttons === @@ -244,6 +342,7 @@ _DICT = { 'msg.no_record.body': 'No clock-in record for today.', 'msg.confirm_delete.title': 'Confirm Delete', 'msg.no_data.title': 'No Data', + 'msg.manual_added': 'Manual', # === Tray === 'tray.open': 'Open Program', @@ -294,6 +393,103 @@ _DICT = { 'help.tab_leave': '๐ŸŒด Leave', 'help.tab_break': '๐Ÿšช Break/Dinner', '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}', }, } diff --git a/core/version.py b/core/version.py index 18b818e..0ae8f25 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ ๋ฆด๋ฆฌ์Šค ์‹œ ์ด ๊ฐ’์„ ์˜ฌ๋ฆฐ ํ›„ git tag โ†’ push. CHANGELOG.md์˜ ์ตœ์ƒ๋‹จ ํ•ญ๋ชฉ๊ณผ ์ผ์น˜์‹œํ‚ฌ ๊ฒƒ. """ -__version__ = '2.6.0' +__version__ = '2.7.0' diff --git a/tests/test_crash_handler.py b/tests/test_crash_handler.py new file mode 100644 index 0000000..2e02dc4 --- /dev/null +++ b/tests/test_crash_handler.py @@ -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' diff --git a/tests/test_csv_importer.py b/tests/test_csv_importer.py new file mode 100644 index 0000000..dd810a3 --- /dev/null +++ b/tests/test_csv_importer.py @@ -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) diff --git a/tests/test_discord_webhook.py b/tests/test_discord_webhook.py new file mode 100644 index 0000000..f79816a --- /dev/null +++ b/tests/test_discord_webhook.py @@ -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', '') diff --git a/tests/test_i18n_runtime.py b/tests/test_i18n_runtime.py new file mode 100644 index 0000000..ccee855 --- /dev/null +++ b/tests/test_i18n_runtime.py @@ -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() diff --git a/tests/test_salary.py b/tests/test_salary.py new file mode 100644 index 0000000..7524fd5 --- /dev/null +++ b/tests/test_salary.py @@ -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 diff --git a/ui/break_view.py b/ui/break_view.py index 7ef00e5..c0071c5 100644 --- a/ui/break_view.py +++ b/ui/break_view.py @@ -21,7 +21,7 @@ class BreakEditDialog(QDialog): def init_ui(self): """UI ์ดˆ๊ธฐํ™”""" - self.setWindowTitle("์™ธ์ถœ ๊ธฐ๋ก ์ˆ˜์ •") + self.setWindowTitle(tr('dlg.break.edit_title')) self.setFixedSize(380, 180) layout = QVBoxLayout() @@ -30,7 +30,7 @@ class BreakEditDialog(QDialog): # ์™ธ์ถœ ์‹œ๊ฐ„ out_layout = QHBoxLayout() - out_label = QLabel("์™ธ์ถœ ์‹œ๊ฐ„:") + out_label = QLabel(tr('dlg.break.out_label')) out_label.setFixedWidth(80) self.out_time_edit = QTimeEdit() self.out_time_edit.setDisplayFormat("HH:mm:ss") @@ -40,7 +40,7 @@ class BreakEditDialog(QDialog): # ๋ณต๊ท€ ์‹œ๊ฐ„ in_layout = QHBoxLayout() - in_label = QLabel("๋ณต๊ท€ ์‹œ๊ฐ„:") + in_label = QLabel(tr('dlg.break.in_label')) in_label.setFixedWidth(80) self.in_time_edit = QTimeEdit() self.in_time_edit.setDisplayFormat("HH:mm:ss") @@ -50,7 +50,7 @@ class BreakEditDialog(QDialog): # ์‚ฌ์œ  reason_layout = QHBoxLayout() - reason_label = QLabel("์‚ฌ์œ :") + reason_label = QLabel(tr('dlg.break.reason_label')) reason_label.setFixedWidth(80) self.reason_edit = QLineEdit() reason_layout.addWidget(reason_label) @@ -74,8 +74,8 @@ class BreakEditDialog(QDialog): # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - save_button = QPushButton("์ €์žฅ") - cancel_button = QPushButton("์ทจ์†Œ") + save_button = QPushButton(tr('btn.save')) + cancel_button = QPushButton(tr('btn.cancel')) save_button.clicked.connect(self.accept) cancel_button.clicked.connect(self.reject) @@ -128,7 +128,7 @@ class BreakView(QDialog): layout.setContentsMargins(12, 10, 12, 10) # ์ œ๋ชฉ - title = QLabel("์˜ค๋Š˜์˜ ์™ธ์ถœ ๊ธฐ๋ก") + title = QLabel(tr('view.break.today_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) @@ -136,7 +136,13 @@ class BreakView(QDialog): # ์™ธ์ถœ ๋ฆฌ์ŠคํŠธ ํ…Œ์ด๋ธ” self.table = QTableWidget() 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() @@ -152,7 +158,7 @@ class BreakView(QDialog): 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.setAlignment(Qt.AlignRight) layout.addWidget(self.total_label) @@ -160,8 +166,8 @@ class BreakView(QDialog): # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - self.refresh_button = QPushButton("์ƒˆ๋กœ๊ณ ์นจ") - close_button = QPushButton("๋‹ซ๊ธฐ") + self.refresh_button = QPushButton(tr('btn.refresh')) + close_button = QPushButton(tr('btn.close')) self.refresh_button.clicked.connect(self.load_break_records) close_button.clicked.connect(self.accept) @@ -190,7 +196,7 @@ class BreakView(QDialog): if break_in: self.table.setItem(i, 1, QTableWidgetItem(break_in)) else: - item = QTableWidgetItem("์ง„ํ–‰์ค‘") + item = QTableWidgetItem(tr('view.break.in_progress')) item.setForeground(Qt.red) self.table.setItem(i, 1, item) @@ -199,7 +205,10 @@ class BreakView(QDialog): if total_minutes: hours = 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)) else: self.table.setItem(i, 2, QTableWidgetItem("-")) @@ -214,8 +223,8 @@ class BreakView(QDialog): action_layout.setContentsMargins(0, 0, 0, 0) action_layout.setSpacing(5) - edit_button = QPushButton("์ˆ˜์ •") - delete_button = QPushButton("์‚ญ์ œ") + edit_button = QPushButton(tr('btn.edit_short')) + delete_button = QPushButton(tr('btn.delete_short')) edit_button.setFixedSize(50, 25) delete_button.setFixedSize(50, 25) @@ -237,9 +246,9 @@ class BreakView(QDialog): minutes = total_minutes % 60 if hours > 0: - self.total_label.setText(f"์ด ์™ธ์ถœ ์‹œ๊ฐ„: {hours}์‹œ๊ฐ„ {minutes}๋ถ„") + self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes)) 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): """์™ธ์ถœ ๊ธฐ๋ก ์ˆ˜์ •""" @@ -277,8 +286,8 @@ class BreakView(QDialog): """์™ธ์ถœ ๊ธฐ๋ก ์‚ญ์ œ""" reply = QMessageBox.question( self, - "์‚ญ์ œ ํ™•์ธ", - "์ด ์™ธ์ถœ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + tr('msg.confirm_delete.title'), + tr('view.break.delete_confirm'), QMessageBox.Yes | QMessageBox.No ) diff --git a/ui/clock_in_dialog.py b/ui/clock_in_dialog.py index e443416..76e5d4a 100644 --- a/ui/clock_in_dialog.py +++ b/ui/clock_in_dialog.py @@ -29,14 +29,14 @@ class ClockInDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # ์•ˆ๋‚ด ๋ฌธ๊ตฌ - info_label = QLabel("์˜ค๋Š˜์˜ ์ถœ๊ทผ์‹œ๊ฐ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”") + info_label = QLabel(tr('dlg.clock_in.prompt')) info_label.setObjectName("field_label") info_label.setAlignment(Qt.AlignCenter) layout.addWidget(info_label) # ์‹œ๊ฐ„ ์ž…๋ ฅ time_layout = QHBoxLayout() - time_label = QLabel("์ถœ๊ทผ์‹œ๊ฐ„:") + time_label = QLabel(tr('dlg.clock_in.label')) time_label.setObjectName("field_label") self.time_edit = QTimeEdit() @@ -59,13 +59,13 @@ class ClockInDialog(QDialog): # ๋น ๋ฅธ ์„ ํƒ ๋ฒ„ํŠผ quick_layout = QHBoxLayout() - quick_label = QLabel("๋น ๋ฅธ ์„ ํƒ:") + quick_label = QLabel(tr('dlg.clock_in.quick')) quick_label.setObjectName("field_label") btn_8am = QPushButton("08:00") btn_9am = QPushButton("09: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]: btn.setMinimumHeight(30) @@ -87,12 +87,12 @@ class ClockInDialog(QDialog): # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - ok_button = QPushButton("ํ™•์ธ") + ok_button = QPushButton(tr('btn.confirm')) ok_button.setObjectName("btn_primary") ok_button.setMinimumHeight(40) ok_button.clicked.connect(self.accept) - cancel_button = QPushButton("์ทจ์†Œ") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.setMinimumHeight(40) cancel_button.clicked.connect(self.reject) diff --git a/ui/controllers/meal_controller.py b/ui/controllers/meal_controller.py new file mode 100644 index 0000000..d3f041c --- /dev/null +++ b/ui/controllers/meal_controller.py @@ -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') + ) diff --git a/ui/i18n_runtime.py b/ui/i18n_runtime.py new file mode 100644 index 0000000..5d9e4fb --- /dev/null +++ b/ui/i18n_runtime.py @@ -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) diff --git a/ui/leave_view.py b/ui/leave_view.py index 1fc6916..cbaaf9b 100644 --- a/ui/leave_view.py +++ b/ui/leave_view.py @@ -40,27 +40,32 @@ class LeaveView(QDialog): # ์ œ๋ชฉ + ์ž”์•ก + ์„ค์ • ํ•œ ์ค„ header_layout = QHBoxLayout() - title = QLabel("์—ฐ์ฐจ ๊ด€๋ฆฌ") + title = QLabel(tr('view.leave.title')) title.setObjectName("dialog_title") header_layout.addWidget(title) header_layout.addStretch() - self.balance_label = QLabel("์ž”์—ฌ: 0์ผ") + self.balance_label = QLabel(tr('view.leave.balance_zero')) self.balance_label.setObjectName("badge_leave") 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) header_layout.addWidget(set_balance_button) layout.addLayout(header_layout) # ์‚ฌ์šฉ ๋‚ด์—ญ - used_group = QGroupBox("๐Ÿ“ค ์‚ฌ์šฉ ๋‚ด์—ญ") + used_group = QGroupBox(tr('view.leave.used_group')) used_layout = QVBoxLayout() used_layout.setSpacing(4) used_layout.setContentsMargins(8, 20, 8, 6) self.used_table = QTableWidget() 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(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) @@ -77,15 +82,15 @@ class LeaveView(QDialog): # ๋ฒ„ํŠผ๋“ค 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) button_layout.addWidget(add_leave_button) - cal_button = QPushButton("๐Ÿ“… ์บ˜๋ฆฐ๋” ๋ณด๊ธฐ") + cal_button = QPushButton(tr('view.leave.btn_calendar')) cal_button.clicked.connect(self._show_calendar) button_layout.addWidget(cal_button) - close_button = QPushButton("๋‹ซ๊ธฐ") + close_button = QPushButton(tr('btn.close')) close_button.clicked.connect(self.close) button_layout.addWidget(close_button) layout.addLayout(button_layout) @@ -102,7 +107,8 @@ class LeaveView(QDialog): # ์ž”์•ก ์—…๋ฐ์ดํŠธ balance = self.db.get_leave_balance() 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) @@ -144,7 +150,7 @@ class LeaveView(QDialog): return menu = QMenu(self) - delete_action = QAction("์‚ญ์ œ", self) + delete_action = QAction(tr('btn.delete_short'), self) delete_action.triggered.connect(self.delete_leave_record) menu.addAction(delete_action) menu.exec_(self.used_table.viewport().mapToGlobal(position)) @@ -164,11 +170,10 @@ class LeaveView(QDialog): reply = QMessageBox.question( self, - "์‚ญ์ œ ํ™•์ธ", - f"๋‹ค์Œ ์—ฐ์ฐจ ์‚ฌ์šฉ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"๋‚ ์งœ: {date_item.text()}\n" - f"๊ตฌ๋ถ„: {type_item.text()}\n" - f"์‚ฌ์šฉ: {days_item.text()}", + tr('msg.confirm_delete.title'), + tr('view.leave.delete_confirm_body', + date=date_item.text(), type=type_item.text(), + days=days_item.text()), QMessageBox.Yes | QMessageBox.No ) @@ -183,13 +188,12 @@ class LeaveView(QDialog): hours, ok = QInputDialog.getDouble( self, - "์—ฐ์ฐจ ์‹œ๊ฐ„ ์„ค์ •", - "์—ฐ์ฐจ ์ž”์—ฌ ์‹œ๊ฐ„์„ ์ž…๋ ฅํ•˜์„ธ์š” (0.5์‹œ๊ฐ„ ๋‹จ์œ„):\n" - "์˜ˆ) 8์‹œ๊ฐ„ = 1์ผ, 4์‹œ๊ฐ„ = 0.5์ผ(๋ฐ˜์ฐจ), 2์‹œ๊ฐ„ = 0.25์ผ, 0.5์‹œ๊ฐ„ = 30๋ถ„", + tr('view.leave.set_title'), + tr('view.leave.set_prompt'), current_hours, 0.0, 999.0, - 1 # ์†Œ์ˆ˜์  ์ฒซ์งธ์ž๋ฆฌ๊นŒ์ง€ (0.5 ๋‹จ์œ„) + 1 ) if ok: @@ -200,8 +204,8 @@ class LeaveView(QDialog): self.db.set_leave_balance(days) QMessageBox.information( self, - "์„ค์ • ์™„๋ฃŒ", - f"์—ฐ์ฐจ ์ž”์—ฌ ๊ฐœ์ˆ˜๊ฐ€ {days}์ผ ({hours}์‹œ๊ฐ„)๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('view.leave.set_done_title'), + tr('view.leave.set_done_body', days=days, hours=hours) ) self.load_data() @@ -223,7 +227,7 @@ class AddLeaveDialog(QDialog): def init_ui(self): """UI ์ดˆ๊ธฐํ™”""" - self.setWindowTitle("์—ฐ์ฐจ ์‚ฌ์šฉ ๊ธฐ๋ก ์ถ”๊ฐ€") + self.setWindowTitle(tr('view.leave.add_title')) self.setModal(True) self.setMinimumWidth(360) @@ -232,14 +236,14 @@ class AddLeaveDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # ์ œ๋ชฉ - title = QLabel("์—ฐ์ฐจ ์‚ฌ์šฉ ๊ธฐ๋ก ์ถ”๊ฐ€") + title = QLabel(tr('view.leave.add_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) # ๋‚ ์งœ + ๊ตฌ๋ถ„ ํ•œ ์ค„ row1 = QHBoxLayout() - date_label = QLabel("๋‚ ์งœ:") + date_label = QLabel(tr('view.leave.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(40) self.date_edit = QDateEdit() @@ -248,14 +252,14 @@ class AddLeaveDialog(QDialog): row1.addWidget(date_label) row1.addWidget(self.date_edit) row1.addSpacing(8) - type_label = QLabel("๊ตฌ๋ถ„:") + type_label = QLabel(tr('view.leave.field_type')) type_label.setObjectName("field_label") type_label.setFixedWidth(40) self.type_combo = QComboBox() - self.type_combo.addItem("์—ฐ์ฐจ", "annual") - self.type_combo.addItem("๋ฐ˜์ฐจ", "half") - self.type_combo.addItem("๋ฐ˜๋ฐ˜์ฐจ", "quarter") - self.type_combo.addItem("์‹œ๊ฐ„", "hourly") + self.type_combo.addItem(tr('view.leave.type_annual'), "annual") + self.type_combo.addItem(tr('view.leave.type_half'), "half") + self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter") + self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly") self.type_combo.currentIndexChanged.connect(self.on_type_changed) row1.addWidget(type_label) row1.addWidget(self.type_combo) @@ -263,14 +267,14 @@ class AddLeaveDialog(QDialog): # ์‚ฌ์šฉ ์‹œ๊ฐ„ (์‹œ๊ฐ„ ์—ฐ์ฐจ์šฉ) hours_layout = QHBoxLayout() - hours_label = QLabel("์‹œ๊ฐ„:") + hours_label = QLabel(tr('view.leave.field_hours')) hours_label.setObjectName("field_label") hours_label.setFixedWidth(40) self.hours_spin = QDoubleSpinBox() self.hours_spin.setRange(0.5, 8.0) self.hours_spin.setSingleStep(0.5) self.hours_spin.setValue(1.0) - self.hours_spin.setSuffix(" ์‹œ๊ฐ„") + self.hours_spin.setSuffix(' ' + tr('label.unit_hour')) self.hours_spin.setEnabled(False) hours_layout.addWidget(hours_label) hours_layout.addWidget(self.hours_spin) @@ -278,27 +282,27 @@ class AddLeaveDialog(QDialog): # ์‚ฌ์œ  memo_layout = QHBoxLayout() - memo_label = QLabel("์‚ฌ์œ :") + memo_label = QLabel(tr('view.leave.field_reason')) memo_label.setObjectName("field_label") memo_label.setFixedWidth(40) 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(self.memo_input) layout.addLayout(memo_layout) # ์•ˆ๋‚ด - info_label = QLabel("โ€ป ์ž”์—ฌ ์—ฐ์ฐจ๊ฐ€ ์ž๋™ ์ฐจ๊ฐ๋ฉ๋‹ˆ๋‹ค.") + info_label = QLabel(tr('view.leave.note_auto_deduct')) info_label.setObjectName("note_text") layout.addWidget(info_label) # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - save_button = QPushButton("์ €์žฅ") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save_record) button_layout.addWidget(save_button) - cancel_button = QPushButton("์ทจ์†Œ") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) @@ -336,8 +340,8 @@ class AddLeaveDialog(QDialog): if current_balance < days: QMessageBox.warning( self, - "์ž”์—ฌ ์—ฐ์ฐจ ๋ถ€์กฑ", - f"์ž”์—ฌ ์—ฐ์ฐจ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.\nํ˜„์žฌ ์ž”์—ฌ: {current_balance}์ผ\n์‚ฌ์šฉ ์š”์ฒญ: {days}์ผ" + tr('view.leave.short_title'), + tr('view.leave.short_body', balance=current_balance, req=days) ) return @@ -345,12 +349,10 @@ class AddLeaveDialog(QDialog): hours = days * 8 reply = QMessageBox.question( self, - "์—ฐ์ฐจ ์‚ฌ์šฉ ๊ธฐ๋ก ์ถ”๊ฐ€", - f"๋‚ ์งœ: {date}\n" - f"๊ตฌ๋ถ„: {leave_type_name}\n" - f"์‚ฌ์šฉ: {days}์ผ ({hours}์‹œ๊ฐ„)\n" - f"์‚ฌ์œ : {memo if memo else '(์—†์Œ)'}\n\n" - f"์ด ๊ธฐ๋ก์„ ์ถ”๊ฐ€ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + tr('view.leave.confirm_title'), + tr('view.leave.confirm_body', + date=date, type=leave_type_name, days=days, hours=hours, + reason=(memo if memo else '-')), QMessageBox.Yes | QMessageBox.No ) @@ -360,15 +362,15 @@ class AddLeaveDialog(QDialog): self.db.use_leave(days, date, leave_type_name, memo) QMessageBox.information( self, - "์ถ”๊ฐ€ ์™„๋ฃŒ", - f"{days}์ผ ({hours}์‹œ๊ฐ„)์˜ ์—ฐ์ฐจ ์‚ฌ์šฉ์ด ๊ธฐ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('view.leave.added_title'), + tr('view.leave.added_body', days=days, hours=hours) ) self.accept() except Exception as e: QMessageBox.critical( self, - "์˜ค๋ฅ˜", - f"์—ฐ์ฐจ ๊ธฐ๋ก ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{str(e)}" + tr('view.leave.error_title'), + tr('view.leave.error_body', err=str(e)) ) diff --git a/ui/main_window.py b/ui/main_window.py index f7fd7dc..8ed8f8d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -81,13 +81,15 @@ class MainWindow(QMainWindow): self.notifier = Notifier(self, db=self.db) self.notifier.notification_signal.connect(self.show_notification) - # ์ฑ…์ž„ ๋ถ„๋ฆฌ๋œ ์ปจํŠธ๋กค๋Ÿฌ๋“ค (1Hz hot path) + # ์ฑ…์ž„ ๋ถ„๋ฆฌ๋œ ์ปจํŠธ๋กค๋Ÿฌ๋“ค (1Hz hot path + ์‚ฌ์šฉ์ž ์•ก์…˜) from ui.controllers.lock_monitor import LockMonitor from ui.controllers.auto_lunch import AutoLunchManager from ui.controllers.notification_orchestrator import NotificationOrchestrator + from ui.controllers.meal_controller import MealController self._lock_monitor = LockMonitor(self) self._auto_lunch = AutoLunchManager(self) self._notif_orch = NotificationOrchestrator(self) + self._meal = MealController(self) # ์‹œ์Šคํ…œ ํŠธ๋ ˆ์ด self.tray_icon = SystemTrayIcon(self) @@ -184,7 +186,11 @@ class MainWindow(QMainWindow): def init_ui(self): """UI ์ดˆ๊ธฐํ™”""" from core.version import __version__ + from ui.i18n_runtime import register + self._app_version = __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.setMinimumSize(480, 520) @@ -326,6 +332,14 @@ class MainWindow(QMainWindow): help_button = QPushButton(tr('menu.help')) 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]: bottom_layout.addWidget(btn) @@ -750,40 +764,20 @@ class MainWindow(QMainWindow): self.date_label.setText(date_str) def toggle_lunch_break(self): - """์ ์‹ฌ์‹œ๊ฐ„ ํ† ๊ธ€""" - self.lunch_break_enabled = self.lunch_button.isChecked() - 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) + """์ ์‹ฌ์‹œ๊ฐ„ ํ† ๊ธ€ โ€” MealController ์œ„์ž„.""" + self._meal.toggle_lunch() def toggle_dinner_break(self): - """์ €๋…์‹œ๊ฐ„ ํ† ๊ธ€""" - self.dinner_break_enabled = self.dinner_button.isChecked() - 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) + """์ €๋…์‹œ๊ฐ„ ํ† ๊ธ€ โ€” MealController ์œ„์ž„.""" + self._meal.toggle_dinner() def update_lunch_status(self): - """์ ์‹ฌ์‹œ๊ฐ„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ""" - self.lunch_button.setText( - tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add') - ) + """์ ์‹ฌ์‹œ๊ฐ„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ โ€” MealController ์œ„์ž„.""" + self._meal.refresh_lunch_label() def update_dinner_status(self): - """์ €๋…์‹œ๊ฐ„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ""" - self.dinner_button.setText( - tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add') - ) + """์ €๋…์‹œ๊ฐ„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ โ€” MealController ์œ„์ž„.""" + self._meal.refresh_dinner_label() def update_overtime_balance(self): """์—ฐ์žฅ๊ทผ๋ฌด ์ž”์•ก ์—…๋ฐ์ดํŠธ""" diff --git a/ui/overtime_view.py b/ui/overtime_view.py index 51b8b86..aea2bb4 100644 --- a/ui/overtime_view.py +++ b/ui/overtime_view.py @@ -38,24 +38,28 @@ class OvertimeView(QDialog): # ์ œ๋ชฉ + ์ž”์•ก ํ•œ ์ค„ header_layout = QHBoxLayout() - title = QLabel("์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ") + title = QLabel(tr('view.overtime.title')) title.setObjectName("dialog_title") header_layout.addWidget(title) header_layout.addStretch() - self.balance_label = QLabel("์ž”์•ก: 0๋ถ„") + self.balance_label = QLabel(tr('view.overtime.balance_zero')) self.balance_label.setObjectName("badge_balance") header_layout.addWidget(self.balance_label) layout.addLayout(header_layout) # ์ ๋ฆฝ ๋‚ด์—ญ - earned_group = QGroupBox("๐Ÿ’ฐ ์ ๋ฆฝ ๋‚ด์—ญ") + earned_group = QGroupBox(tr('view.overtime.earned_group')) earned_layout = QVBoxLayout() earned_layout.setSpacing(4) earned_layout.setContentsMargins(8, 20, 8, 6) self.earned_table = QTableWidget() 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(1, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) @@ -64,7 +68,7 @@ class OvertimeView(QDialog): self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) 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) earned_layout.addWidget(add_earned_button) @@ -72,14 +76,18 @@ class OvertimeView(QDialog): layout.addWidget(earned_group) # ์‚ฌ์šฉ ๋‚ด์—ญ - used_group = QGroupBox("๐Ÿ“ค ์‚ฌ์šฉ ๋‚ด์—ญ") + used_group = QGroupBox(tr('view.overtime.used_group')) used_layout = QVBoxLayout() used_layout.setSpacing(4) used_layout.setContentsMargins(8, 20, 8, 6) self.used_table = QTableWidget() 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(1, QHeaderView.ResizeToContents) 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) 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) used_layout.addWidget(add_used_button) @@ -98,7 +106,7 @@ class OvertimeView(QDialog): layout.addWidget(used_group) # ๋‹ซ๊ธฐ ๋ฒ„ํŠผ - close_button = QPushButton("๋‹ซ๊ธฐ") + close_button = QPushButton(tr('btn.close')) close_button.clicked.connect(self.close) layout.addWidget(close_button) @@ -110,23 +118,21 @@ class OvertimeView(QDialog): balance = self.db.get_total_overtime_balance() hours = 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() cursor = conn.cursor() cursor.execute(''' - SELECT ob.date, ob.earned_minutes, - CASE - WHEN ob.work_record_id IS NULL THEN '์ˆ˜๋™ ์ถ”๊ฐ€' - ELSE COALESCE(wr.memo, '') - END as memo + SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo FROM overtime_bank ob LEFT JOIN work_records wr ON ob.work_record_id = wr.id ORDER BY ob.date DESC ''') earned_records = cursor.fetchall() + manual_label = tr('msg.manual_added') self.earned_table.setRowCount(len(earned_records)) for i, record in enumerate(earned_records): @@ -136,12 +142,17 @@ class OvertimeView(QDialog): minutes = record[1] hours = 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.setTextAlignment(Qt.AlignCenter) 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, 1, time_item) @@ -166,7 +177,10 @@ class OvertimeView(QDialog): minutes = record[2] hours = 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.setTextAlignment(Qt.AlignCenter) time_item.setForeground(QColor(231, 76, 60)) # ๋นจ๊ฐ„์ƒ‰ @@ -188,7 +202,7 @@ class OvertimeView(QDialog): # ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ์ƒ์„ฑ menu = QMenu(self) - delete_action = QAction("โŒ ์‚ญ์ œ", self) + delete_action = QAction(tr('view.overtime.menu_delete'), self) delete_action.triggered.connect(self.delete_used_record) menu.addAction(delete_action) @@ -213,11 +227,10 @@ class OvertimeView(QDialog): # ํ™•์ธ ๋ฉ”์‹œ์ง€ reply = QMessageBox.question( self, - "์‚ญ์ œ ํ™•์ธ", - f"๋‹ค์Œ ์‚ฌ์šฉ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"๋‚ ์งœ: {date_item.text()}\n" - f"์‹œ๊ฐ„: {time_item.text()}\n" - f"์‚ฌ์œ : {reason_item.text()}", + tr('msg.confirm_delete.title'), + tr('view.overtime.delete_confirm_body', + date=date_item.text(), time=time_item.text(), + reason=reason_item.text()), QMessageBox.Yes | QMessageBox.No ) @@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog): def init_ui(self): """UI ์ดˆ๊ธฐํ™”""" - self.setWindowTitle("์ถ”๊ฐ€๊ทผ๋ฌด ์ˆ˜๋™ ์ ๋ฆฝ") + self.setWindowTitle(tr('view.overtime.manual_earned_title')) self.setModal(True) self.setMinimumWidth(360) @@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # ์ œ๋ชฉ - title = QLabel("์ถ”๊ฐ€๊ทผ๋ฌด ์ˆ˜๋™ ์ ๋ฆฝ") + title = QLabel(tr('view.overtime.manual_earned_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) # ๋‚ ์งœ date_layout = QHBoxLayout() - date_label = QLabel("๋‚ ์งœ:") + date_label = QLabel(tr('view.overtime.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(60) self.date_edit = QDateEdit() @@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog): # ์‹œ๊ฐ„ (30๋ถ„ ๋‹จ์œ„) time_layout = QHBoxLayout() - time_label = QLabel("์‹œ๊ฐ„:") + time_label = QLabel(tr('view.overtime.field_time')) time_label.setObjectName("field_label") time_label.setFixedWidth(60) self.hour_spin = QSpinBox() 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.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(self.hour_spin) time_layout.addWidget(self.minute_combo) @@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog): # ๋ฉ”๋ชจ memo_layout = QHBoxLayout() - memo_label = QLabel("๋ฉ”๋ชจ:") + memo_label = QLabel(tr('view.overtime.field_memo')) memo_label.setObjectName("field_label") memo_label.setFixedWidth(60) 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(self.memo_edit) layout.addLayout(memo_layout) # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - save_button = QPushButton("์ €์žฅ") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save) - cancel_button = QPushButton("์ทจ์†Œ") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(save_button) button_layout.addWidget(cancel_button) @@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog): """์ €์žฅ""" # ์‹œ๊ฐ„ ๊ณ„์‚ฐ (30๋ถ„ ๋‹จ์œ„) 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 if total_minutes == 0: - QMessageBox.warning(self, "์ž…๋ ฅ ์˜ค๋ฅ˜", "0๋ถ„์€ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('msg.input_error.title'), + tr('view.overtime.zero_add_error')) return date = self.date_edit.date().toString("yyyy-MM-dd") @@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog): QMessageBox.information( self, - "์ €์žฅ ์™„๋ฃŒ", - f"{hours}์‹œ๊ฐ„ {minutes}๋ถ„์ด ์ ๋ฆฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('msg.save_success.title'), + tr('view.overtime.saved_earned', h=hours, m=minutes) ) self.accept() @@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog): def init_ui(self): """UI ์ดˆ๊ธฐํ™”""" - self.setWindowTitle("์ถ”๊ฐ€๊ทผ๋ฌด ์ˆ˜๋™ ์‚ฌ์šฉ") + self.setWindowTitle(tr('view.overtime.manual_used_title')) self.setModal(True) self.setMinimumWidth(360) @@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog): # ์ œ๋ชฉ + ์ž”์•ก ํ•œ ์ค„ header_layout = QHBoxLayout() - title = QLabel("์ถ”๊ฐ€๊ทผ๋ฌด ์ˆ˜๋™ ์‚ฌ์šฉ") + title = QLabel(tr('view.overtime.manual_used_title')) title.setObjectName("dialog_subtitle") header_layout.addWidget(title) header_layout.addStretch() balance = self.db.get_total_overtime_balance() hours = 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") header_layout.addWidget(balance_label) layout.addLayout(header_layout) # ๋‚ ์งœ date_layout = QHBoxLayout() - date_label = QLabel("๋‚ ์งœ:") + date_label = QLabel(tr('view.overtime.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(60) self.date_edit = QDateEdit() @@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog): # ์‹œ๊ฐ„ (30๋ถ„ ๋‹จ์œ„) time_layout = QHBoxLayout() - time_label = QLabel("์‹œ๊ฐ„:") + time_label = QLabel(tr('view.overtime.field_time')) time_label.setObjectName("field_label") time_label.setFixedWidth(60) self.hour_spin = QSpinBox() 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.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(self.hour_spin) time_layout.addWidget(self.minute_combo) @@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog): # ์‚ฌ์œ  reason_layout = QHBoxLayout() - reason_label = QLabel("์‚ฌ์œ :") + reason_label = QLabel(tr('view.overtime.field_reason')) reason_label.setObjectName("field_label") reason_label.setFixedWidth(60) 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(self.reason_edit) layout.addLayout(reason_layout) # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - save_button = QPushButton("์ €์žฅ") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save) - cancel_button = QPushButton("์ทจ์†Œ") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(save_button) button_layout.addWidget(cancel_button) @@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog): """์ €์žฅ""" # ์‹œ๊ฐ„ ๊ณ„์‚ฐ (30๋ถ„ ๋‹จ์œ„) 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 if total_minutes == 0: - QMessageBox.warning(self, "์ž…๋ ฅ ์˜ค๋ฅ˜", "0๋ถ„์€ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('msg.input_error.title'), + tr('view.overtime.zero_use_error')) return # ์ž”์•ก ํ™•์ธ @@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog): if total_minutes > balance: QMessageBox.warning( self, - "์ž”์•ก ๋ถ€์กฑ", - f"์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์‹œ๊ฐ„์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.\n\n" - f"์š”์ฒญ: {hours}์‹œ๊ฐ„ {minutes}๋ถ„\n" - f"์ž”์•ก: {balance // 60}์‹œ๊ฐ„ {balance % 60}๋ถ„" + tr('view.overtime.balance_short_title'), + tr('view.overtime.balance_short_body', + req_h=hours, req_m=minutes, + bal_h=balance // 60, bal_m=balance % 60) ) return 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์— ์ €์žฅ self.db.add_overtime_usage(None, total_minutes, date, reason) QMessageBox.information( self, - "์ €์žฅ ์™„๋ฃŒ", - f"{hours}์‹œ๊ฐ„ {minutes}๋ถ„์ด ์‚ฌ์šฉ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('msg.save_success.title'), + tr('view.overtime.saved_used', h=hours, m=minutes) ) self.accept() diff --git a/ui/settings_view.py b/ui/settings_view.py index eb8cfed..176e3bb 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -1114,16 +1114,18 @@ class SettingsView(QDialog): if self.parent_window and hasattr(self.parent_window, 'reload_settings'): self.parent_window.reload_settings() - # ์–ธ์–ด ๋ณ€๊ฒฝ ๊ฐ์ง€ โ†’ ์žฌ์‹œ์ž‘ ์ œ์•ˆ + # ์–ธ์–ด ๋ณ€๊ฒฝ ๊ฐ์ง€ โ†’ ๋“ฑ๋ก๋œ ์œ„์ ฏ ์ฆ‰์‹œ ์žฌ๋ฒˆ์—ญ, ์•„์ง ๋ฏธ๋“ฑ๋ก ์˜์—ญ์€ ์žฌ์‹œ์ž‘ ๊ถŒ์žฅ if hasattr(self, 'language_combo'): from core.i18n import get_language + from ui.i18n_runtime import set_language_and_retranslate new_lang = self.language_combo.currentData() if new_lang and new_lang != get_language(): + set_language_and_retranslate(new_lang) reply = QMessageBox.question( self, - "์žฌ์‹œ์ž‘ ํ•„์š” / Restart required", - "์–ธ์–ด ๋ณ€๊ฒฝ์„ ์™„์ „ํžˆ ์ ์šฉํ•˜๋ ค๋ฉด ์žฌ์‹œ์ž‘์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.\n์ง€๊ธˆ ์žฌ์‹œ์ž‘ํ• ๊นŒ์š”?\n\n" - "Restart now to fully apply the language change?", + "์žฌ์‹œ์ž‘ / Restart", + "์ฃผ์š” ํ™”๋ฉด์€ ์ฆ‰์‹œ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ์ผ๋ถ€ ๋‹ค์ด์–ผ๋กœ๊ทธ๋Š” ์žฌ์‹œ์ž‘ ํ›„ ์™„์ „ํžˆ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.\n์ง€๊ธˆ ์žฌ์‹œ์ž‘ํ• ๊นŒ์š”?\n\n" + "Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?", QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: