diff --git a/AGENTS.md b/AGENTS.md index 31b01ad..1f9c16e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,176 +1,476 @@ -# Project Conventions and Operational Gotchas +# Clock-out Time Calculator โ€” Agent Guide -## ๐Ÿ› ๏ธ Setup & Execution -- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays). -- **Run:** `python main.py` -- **Module-level smoke:** - - Event monitoring: `python core/event_monitor.py` - - Time calculation: `python core/time_calculator.py` -- **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`). +> Last verified against the working tree at version **2.11.2** (`core/version.py`). +> This file is written for AI coding agents who need to understand, modify, build, or release the project. When in doubt, prefer the facts in this file over older documentation; this guide was produced by exploring the actual codebase, running the tests, and reading the build scripts. -## ๐Ÿ—„๏ธ Architecture Notes (Core Business Logic) +--- -### 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. +## 1. Project Overview -### Invariants -- **`work_records.date` UNIQUE** โ€” one row per workday. -- **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). +**Clock-out Time Calculator** (Korean: ํ‡ด๊ทผ์‹œ๊ฐ„ ๊ณ„์‚ฐ๊ธฐ) is a Windows desktop productivity application written in Python with PyQt5. It tracks a user's workday, automatically detects clock-in time from Windows Event Log / boot time, counts down to the expected clock-out time in real time, banks overtime in configurable units, manages annual leave, and provides statistics, notifications, Discord integration, and automatic self-updates. -### 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. +- **Primary language:** Python 3.9+ +- **GUI framework:** PyQt5 +- **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout +- **Packaging:** PyInstaller (`main.exe` + `updater.exe`) +- **Distribution:** Gitea Releases on a self-hosted instance +- **Current version:** `2.11.2` (single source of truth: `core/version.py`) +- **Repository:** `kindnick/Clock_out_Time_Calculator` -### 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. +The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`. -## โš ๏ธ Critical Invariants (MUST PRESERVE) +--- -### 1. Time-off subtraction order in `update_display()` -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. Technology Stack -### 2. Hot-path caching -`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`. +| Layer | Technology | +|-------|------------| +| Language | Python 3.9+ | +| GUI | PyQt5 โ‰ฅ 5.15 | +| Charts | matplotlib (QtAgg backend) | +| Windows integration | pywin32 (event log), ctypes (screen-lock detection) | +| Date / recurrence | python-dateutil | +| Notifications | plyer (system toast) + PyQt signals | +| Holidays | optional `holidays` package; government API + fixed-date fallback | +| Packaging | PyInstaller 2-step build | +| Testing | pytest + standalone integration/GUI smoke scripts | +| Fonts | Bundled NanumSquare TTF/OTF files; Malgun Gothic fallback | -### 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). +Dependencies are declared in `requirements.txt`: -### 4. Workday boundary -`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. +```text +PyQt5>=5.15.0 +pywin32>=305 +python-dateutil>=2.8.0 +matplotlib>=3.4.0 +plyer>=2.0.0 +holidays>=0.40 +``` -### 5. Migration idempotency -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 +Install with: ```bash -# Manual two-step -python -m PyInstaller --clean updater.spec # โ†’ dist/updater.exe (~6MB) +pip install -r requirements.txt +``` + +`pywin32` is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms. + +--- + +## 3. Directory Structure and Module Map + +```text +Clock-out Time Calculator/ +โ”œโ”€โ”€ main.py # Application entry point / bootstrap +โ”œโ”€โ”€ updater.py # Standalone update helper process (stdlib only) +โ”œโ”€โ”€ main.spec # PyInstaller spec for main.exe +โ”œโ”€โ”€ updater.spec # PyInstaller spec for updater.exe +โ”œโ”€โ”€ release.ps1 # One-shot release script (PowerShell) +โ”œโ”€โ”€ requirements.txt # Python dependencies +โ”œโ”€โ”€ pytest.ini # pytest configuration +โ”œโ”€โ”€ run_as_admin.bat # Convenience launcher +โ”œโ”€โ”€ core/ # Business logic and data access +โ”‚ โ”œโ”€โ”€ database.py # SQLite schema, migrations, CRUD +โ”‚ โ”œโ”€โ”€ time_calculator.py # Pure time-math engine +โ”‚ โ”œโ”€โ”€ event_monitor.py # Windows Event Log clock-in detection +โ”‚ โ”œโ”€โ”€ notifier.py # Notification rule engine +โ”‚ โ”œโ”€โ”€ salary.py # Optional pay estimation +โ”‚ โ”œโ”€โ”€ i18n.py # Korean/English translation dictionaries +โ”‚ โ”œโ”€โ”€ settings_keys.py # Setting key constants +โ”‚ โ”œโ”€โ”€ achievements.py # 357 achievement definitions + evaluator +โ”‚ โ”œโ”€โ”€ recurring_leaves.py # Recurring leave pattern expansion +โ”‚ โ””โ”€โ”€ version.py # __version__ single source of truth +โ”œโ”€โ”€ ui/ # PyQt5 views and widgets +โ”‚ โ”œโ”€โ”€ main_window.py # Central 1 Hz main window +โ”‚ โ”œโ”€โ”€ styles.py # Theme colors and QSS +โ”‚ โ”œโ”€โ”€ dark_components.py # Reusable dark-styled widgets +โ”‚ โ”œโ”€โ”€ icons.py # Icon resource helpers +โ”‚ โ”œโ”€โ”€ i18n_runtime.py # Runtime retranslation registry +โ”‚ โ”œโ”€โ”€ settings_view.py # Settings dialog +โ”‚ โ”œโ”€โ”€ stats_view.py # Weekly/monthly/pattern statistics +โ”‚ โ”œโ”€โ”€ chart_widget.py # matplotlib QtAgg wrapper + fallback +โ”‚ โ”œโ”€โ”€ calendar_view.py # Work-record calendar +โ”‚ โ”œโ”€โ”€ leave_calendar_view.py # Color-coded leave calendar +โ”‚ โ”œโ”€โ”€ break_view.py # Break history dialog +โ”‚ โ”œโ”€โ”€ overtime_view.py # Overtime bank/usage dialog +โ”‚ โ”œโ”€โ”€ leave_view.py # Leave management dialog +โ”‚ โ”œโ”€โ”€ recurring_leave_dialog.py +โ”‚ โ”œโ”€โ”€ schedule_view.py # Schedule view +โ”‚ โ”œโ”€โ”€ clock_in_dialog.py # Manual clock-in dialog +โ”‚ โ”œโ”€โ”€ meal_time_dialog.py # Lunch/dinner start-end input +โ”‚ โ”œโ”€โ”€ past_record_dialog.py # Add past record dialog +โ”‚ โ”œโ”€โ”€ achievements_view.py # Achievements browser +โ”‚ โ”œโ”€โ”€ onboarding_view.py # 5-step first-run wizard +โ”‚ โ”œโ”€โ”€ today_summary.py # Post-clock-out card +โ”‚ โ”œโ”€โ”€ goal_widget.py # Monthly goal progress bars +โ”‚ โ”œโ”€โ”€ mini_widget.py # Always-on-top frameless widget +โ”‚ โ”œโ”€โ”€ help_view.py # 6-tab help dialog +โ”‚ โ”œโ”€โ”€ accessibility.py # Font scale / high-contrast helpers +โ”‚ โ””โ”€โ”€ controllers/ # Thin controllers split from MainWindow +โ”‚ โ”œโ”€โ”€ lock_monitor.py # Screen-lock auto-break / unlock clock-in +โ”‚ โ”œโ”€โ”€ auto_lunch.py # Auto-lunch after 4 hours +โ”‚ โ”œโ”€โ”€ notification_orchestrator.py # 5-min-tick orchestrator +โ”‚ โ””โ”€โ”€ meal_controller.py # Lunch/dinner toggle handling +โ”œโ”€โ”€ utils/ # Helpers and integrations +โ”‚ โ”œโ”€โ”€ backup.py # Daily SQLite backup with 7-rotation +โ”‚ โ”œโ”€โ”€ lock_detector.py # Win32 screen-lock detection +โ”‚ โ”œโ”€โ”€ discord_webhook.py # Discord embed pushes +โ”‚ โ”œโ”€โ”€ csv_importer.py # CSV import (standard format) +โ”‚ โ”œโ”€โ”€ csv_exporter.py # CSV export +โ”‚ โ”œโ”€โ”€ crash_handler.py # Global excepthook + optional Gitea report +โ”‚ โ”œโ”€โ”€ updater_client.py # Gitea Releases API client +โ”‚ โ”œโ”€โ”€ system_tray.py # System tray menu +โ”‚ โ”œโ”€โ”€ time_format.py # format_hours_minutes helper +โ”‚ โ”œโ”€โ”€ font_loader.py # NanumSquare font loading +โ”‚ โ”œโ”€โ”€ resource_manager.py # PyInstaller _MEIPASS path resolver +โ”‚ โ”œโ”€โ”€ debug_log.py # CLOCKOUT_DEBUG gated logging +โ”‚ โ””โ”€โ”€ holiday_api.py # Korean holiday API client +โ”œโ”€โ”€ tests/ # pytest unit tests (13 files) +โ”œโ”€โ”€ font/ # Bundled NanumSquare fonts +โ”œโ”€โ”€ resources/ # Icons and resource links +โ”œโ”€โ”€ analysis/ # Currently empty (only __init__.py) +โ”œโ”€โ”€ build/ # Build staging directory +โ””โ”€โ”€ dist/ # Built EXEs and release ZIPs +``` + +> **Note:** `utils/http_api.py` is referenced in older documentation but is **not present** in the current working tree. + +--- + +## 4. How to Build, Run, and Smoke-Test + +### Development run + +```bash +python main.py +``` + +`main.py` inserts the project root into `sys.path`, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches `MainWindow`. + +A convenience batch file is provided: + +```bash +run_as_admin.bat +``` + +Running as administrator is recommended because Windows Event Log access may be restricted for standard users. + +### Module-level smoke tests + +```bash +python core/event_monitor.py +python core/time_calculator.py +``` + +These run lightweight self-tests when invoked as scripts. + +### Production build (manual two-step) + +```bash +python -m PyInstaller --clean updater.spec mkdir -p build/staging && cp dist/updater.exe build/staging/ -python -m PyInstaller --clean main.spec # โ†’ dist/main.exe (~78MB, embeds updater) - -# Or one-shot -.\release.ps1 v2.7.0 +python -m PyInstaller --clean main.spec ``` -- `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`. +- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays). +- `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`). +- The staging copy is critical: `main.spec --clean` wipes `dist/`, so without `build/staging/updater.exe` the updater would be deleted mid-build. -## ๐Ÿšฆ External Integrations +### Production build (one-shot) -- **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 (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. -- **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 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')`. 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) +```bash +.\release.ps1 v2.11.2 ``` -`--DryRun` previews without git push or API calls. +The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`. + +### Build gotchas + +- Kill any running `main.exe` before building or PyInstaller will fail with `PermissionError`. +- The `holidays` package is only baked into the EXE if it is installed in the build environment. +- Optional code signing is supported via `$env:CODE_SIGN_CERT` (.pfx path) and `$env:CODE_SIGN_PASS`. +- `main.spec` lists several `hiddenimports` that are easy to break: `holidays`, `holidays.countries.south_korea`, `win32evtlog`, `win32evtlogutil`, `matplotlib.backends.backend_qtagg`, `matplotlib.backends.backend_qt5agg`, `PyQt5.QtSvg`, `PyQt5.sip`, and `numpy.core._multiarray_tests`. + +--- + +## 5. Testing Strategy + +The project uses three layers of tests. + +### 5.1 Unit tests (pytest) + +Configuration: `pytest.ini` + +```bash +python -m pytest tests/ -v --tb=short +``` + +There are **13 test files** under `tests/`: + +- `test_time_calculator.py` โ€” clock-out, overtime truncation, holiday overtime, day-type detection +- `test_database.py` โ€” settings, migrations, leave calculations, consecutive OT days +- `test_csv_importer.py` โ€” CSV parsing, validation, conflict handling +- `test_salary.py` โ€” pay estimation and won formatting +- `test_recurring_leaves.py` โ€” pattern parsing and expansion +- `test_crash_handler.py` โ€” crash log insertion and Gitea reporting (mocked) +- `test_discord_webhook.py` โ€” URL validation, payload shape, network errors +- `test_holiday_api.py` โ€” API response parsing and error handling +- `test_i18n.py` โ€” language switching, missing-key fallback, interpolation +- `test_i18n_runtime.py` โ€” runtime retranslation of Qt widgets +- `test_overtime_accrual_guard.py` โ€” auto-overtime rollover guard +- `test_updater.py` โ€” version parsing, Gitea API URL, update logic + +`tests/conftest.py` sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` so the background holiday-sync thread does not hold open temporary DB files during test cleanup. + +**Current status:** `194 passed`. + +### 5.2 Integration scenarios + +```bash +python _integration_test.py +``` + +A standalone script with a custom `@case` decorator. It runs **53 scenarios** (S1โ€“S52, S52Aโ€“E) covering fresh-install migrations, work-pattern calculations, overtime banking, leave, weekends/holidays, notifications, backup, settings sync, i18n, CSV import/export, salary, crash log, updater semver, Discord guards, goals, and accessibility keys. + +**Current status:** `PASS: 53 FAIL: 0 WARN: 0`. + +### 5.3 GUI smoke tests + +```bash +python _i18n_gui_test.py # Korean/English label switching +python _gui_smoke_test.py # Widget instantiation +``` + +Both use `QT_QPA_PLATFORM=offscreen` so no real windows appear. + +- `_i18n_gui_test.py`: 5 cases, **all passing**. +- `_gui_smoke_test.py`: 8 cases, **all passing**. + +### 5.4 Pre-release test command summary + +```bash +python -m pytest tests/ -q +python _integration_test.py +python _i18n_gui_test.py +python _gui_smoke_test.py +``` + +`release.ps1` runs the first two by default (skippable with `-SkipTests`). + +--- + +## 6. Code Style and Conventions + +### Identifiers + +- Classes: `PascalCase` +- Functions / variables: `snake_case` +- Module-level constants (especially setting keys): `UPPER_CASE` + +### Settings keys + +All setting keys are defined as constants in `core/settings_keys.py`. Import and use the constants; **never use raw strings** for new logic. When adding a new key, also add a default value in `Database.init_default_settings()`. + +### Imports + +Order is typically: standard library โ†’ third-party โ†’ project modules. Several newer modules use `from __future__ import annotations`. + +### Comments and docstrings + +Code identifiers are English, but inline comments and docstrings are predominantly Korean. New code should follow the existing bilingual style: English identifiers, Korean explanatory comments. + +### UI construction + +- Build widgets in `init_ui()`. +- Use helper methods such as `create_*_group()` for readability. +- Set `objectName` for QSS styling. +- Always verify `self.setLayout(main_layout)` is at the **end** of `init_ui()`, not accidentally indented into a method body. + +### Type hints + +Use type hints on public DB and helper methods (`-> int`, `-> Optional[Dict]`, `-> List[Dict]`, etc.). + +### String formatting + +Use f-strings for internal messages. For user-visible text, use the i18n API: + +```python +from core.i18n import tr +tr('key', name=value) +``` + +### No enforced linter + +There is no `pyproject.toml`, `.flake8`, or `setup.cfg`. Formatting is informal; keep line lengths reasonable and match the surrounding style. + +--- + +## 7. Critical Invariants (MUST PRESERVE) + +### 7.1 Time-off subtraction order in `MainWindow.update_display()` + +Pass the actual `break_minutes` to `TimeCalculator.calculate_remaining_time()`, then subtract `total_time_off = overtime_used + leave_used` from the resulting `timedelta` **after** the call. **Never** mutate `break_minutes` to `break_minutes - overtime_used` before calling. Doing so previously caused a "+29h remaining time" bug. + +```python +break_minutes = self.db.get_total_break_minutes_today() +overtime_used_today = self.db.get_today_overtime_usage() +leave_used_today = self.db.get_today_leave_minutes() +total_time_off = overtime_used_today + leave_used_today +remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_minutes) +remaining -= timedelta(minutes=total_time_off) +``` + +### 7.2 Hot-path caching + +`update_display()` runs at **1 Hz**. DB reads inside it must be cached or throttled: + +- `cached_time_format` in `MainWindow` +- `AutoLunchManager` caches enabled/non-working state +- `NotificationOrchestrator` gates periodic checks to 5-minute buckets +- Use `_set_text_if_changed()` to avoid useless repaints + +### 7.3 24-hour internal time + +All calculation uses 24-hour `datetime`. 12-hour conversion (Korean "์˜ค์ „"/"์˜คํ›„") happens only in `MainWindow.format_time()`. + +### 7.4 Workday boundary + +`workday_boundary_hour` defaults to 6. Overnight work stays on the previous day's record until that hour. Do not naively use `date.today()` in time logic. + +### 7.5 Database invariants + +- `work_records.date` is `UNIQUE`. +- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are **NULLable** for manual entries. Never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "์ˆ˜๋™ ์ถ”๊ฐ€" / "Manual". +- `leave_records.days` is `REAL` (1.0 / 0.5 / 0.25 / hourly). +- Canonical time unit is **minutes** (`work_minutes`). `work_hours` is a derived/floor-synced property. Use `int(minutes) // 60` for hours, not `round()`. +- Overtime balance = `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` plus any `INITIAL_OVERTIME_MINUTES`. +- WAL mode + 5-second busy timeout is enabled for cloud-sync friendliness (OneDrive/Dropbox). + +### 7.6 Migration idempotency + +Every `migrate_*()` method must early-return if already applied. Use sentinel settings or `IF NOT EXISTS`. Examples: `balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`. + +### 7.7 Settings auto-sync pairs + +`Database.save_settings()` automatically keeps these pairs in sync: + +- `WORK_MINUTES โ†” WORK_HOURS` (floor division) +- `ANNUAL_LEAVE_DAYS โ†” ANNUAL_LEAVE_TOTAL` + +### 7.8 Single-file deployment and updater handoff + +- `main.exe` embeds `updater.exe` via `main.spec` data files. +- `_ensure_updater_extracted()` in `main.py` extracts the embedded updater on first launch from `sys._MEIPASS`. +- Protect the staging copy at `build/staging/updater.exe`. +- `updater.py` is **standalone** (no Qt/network deps). It accepts `--pid`, `--new`, and `--target`, waits for the PID, swaps files with `.bak` rollback, and relaunches. + +--- + +## 8. Security Considerations + +### 8.1 HTTP API + +`utils/http_api.py` is **not present** in the current tree. If it is reintroduced, it must bind to `127.0.0.1` only and remain read-only. Never expose it externally. + +### 8.2 Discord webhook + +- URL is validated against official Discord webhook domains (`discord.com`, `discordapp.com`, canary/ptb). +- A browser User-Agent is mandatory; Cloudflare blocks the default Python UA. +- Push failures are silent so they do not break the app. + +### 8.3 Auto-update + +- Update check polls a self-hosted Gitea API: `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. +- Downloads `main_new.exe` next to the running executable; `updater.exe` performs the swap with `.bak` rollback. +- Update apply only works in frozen builds; development `.py` runs are notified but not modified. + +### 8.4 DB path handling and cloud sync + +- `main.py` and `MainWindow` both bootstrap with the default DB first, read `DB_PATH_OVERRIDE`, then reopen with the override path. Do not break this order. +- WAL mode + busy timeout tolerates OneDrive/Dropbox sync. + +### 8.5 Crash reporting + +- Gitea issue reporting is **opt-in** via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`. +- Tokens are stored as plain strings in the SQLite `settings` table. The UI uses `QLineEdit.Password` echo mode. + +### 8.6 Single instance + +Multiple copies are prevented via `QLocalServer` named `ClockOutCalculatorInstance`. + +### 8.7 Debug logging + +`utils/debug_log.py` only emits output when `CLOCKOUT_DEBUG=1` (or `true`/`yes`). + +--- + +## 9. External Integrations + +- **Auto-update** (`utils/updater_client.py`): polls the Gitea Releases API. User-Agent: `ClockOutCalculator-Updater/1.0`. +- **Discord webhook** (`utils/discord_webhook.py`): optional one-direction push. Browser User-Agent required. +- **Gitea Issues** (`utils/crash_handler.py`): optional crash reporting; opt-in only. +- **Cloud sync via `DB_PATH_OVERRIDE`**: settings stores the DB path; the app reopens the override path after reading it. +- **`holidays` package**: `Database.add_korean_holidays_auto()` returns `-1` if the package is missing, and the UI falls back to `add_korean_holidays()` (8 fixed dates). +- **Korean holiday API** (`utils/holiday_api.py`): ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ํŠน์ผ์ •๋ณด API ํ‚ค๋Š” `CLOCKOUT_HOLIDAY_API_KEY` ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ์ฝ์Œ. ์†Œ์Šค์ฝ”๋“œ/๋ฐ”์ด๋„ˆ๋ฆฌ์— ํ‚ค๋ฅผ ํ•˜๋“œ์ฝ”๋”ฉํ•˜์ง€ ์•Š์Œ. + +--- + +## 10. i18n Conventions + +- API: `tr(key, **kwargs)` and `tr_html(key)` from `core/i18n.py`. +- Translations live in `_DICT['ko']` and `_DICT['en']`. **Both languages must receive any new key.** +- Sentence interpolation uses Python `str.format(**kwargs)`. +- HelpView HTML content is in `_HELP_HTML`. +- Runtime retranslation is supported via `ui/i18n_runtime.py` using a weakref registry. Register widgets with `register(widget, key, setter='setText', kwargs={}, post=None)` and call `set_language_and_retranslate(lang)` to update them. +- UI files (`ui/`) and achievement metadata (`core/achievements.py`) are fully key-based. +- Remaining P4 internal-data hardcoding (not user-facing labels) includes DB-stored `leave_type` values and Korean holiday names in `core/database.py`. +- A language change may still prompt a restart for widgets not registered for runtime retranslate. + +--- + +## 11. Release Flow + +`release.ps1 vX.Y.Z` performs the full release locally: + +1. **Pre-checks**: verify `$env:GITEA_TOKEN`, no running `main.exe`, no existing tag, no uncommitted changes (unless `-DryRun`). +2. **Bump version**: rewrite `core/version.py`. +3. **Tests**: run `pytest tests/` and `python _integration_test.py` (skippable with `-SkipTests`). +4. **Build**: `updater.spec` โ†’ `build/staging/` โ†’ `main.spec`. +5. **Code signing** (optional): sign both EXEs if `$env:CODE_SIGN_CERT` is set. +6. **ZIP packaging**: `dist/ClockOutCalculator-vX.Y.Z.zip` containing `main.exe` and `updater.exe`. +7. **Git commit + tag + push**: commit `core/version.py` and `CHANGELOG.md`. +8. **Gitea Release POST**: read `CHANGELOG.md` with UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section. +9. **Asset upload**: `main.exe`, `updater.exe`, and the ZIP. + +Use `-DryRun` to preview without git push or API calls. + +--- + +## 12. Past Incidents (Do Not Re-introduce) + +| Incident | Root cause / fix | +|----------|------------------| +| +29h remaining time | Mutating `break_minutes -= overtime_used` before calculation. Fixed by subtracting `total_time_off` after `calculate_remaining_time`. | +| Manual overtime invisible | Filtering `work_record_id IS NOT NULL`. Now show all rows and label NULL as "์ˆ˜๋™ ์ถ”๊ฐ€" / "Manual". | +| `annual_leave_total` vs `annual_leave_days` | Two keys for the same value; auto-synced in `save_settings()`. | +| Banker's rounding | `round(450/60) = 8` because Python rounds half to even. Use `int(minutes) // 60`. | +| FK enforcement rollback | `PRAGMA foreign_keys=ON` conflicted with existing manual overtime records. Kept WAL + timeout, no FK enforcement. | +| Discord 403 / Cloudflare 1010 | Default Python UA blocked. Fixed with browser UA. | +| Help dialog blank | `self.setLayout(main_layout)` indented into `_reopen_onboarding`. Verify `setLayout()` is at the end of `init_ui()`. | +| `dist/updater.exe` wiped by `--clean` | Solved by staging copy at `build/staging/updater.exe`. | +| Onboarding auto-skipped | Existing users with `work_records` were auto-completed. Added "Re-run Onboarding" button to Help. | +| PowerShell 5.1 ANSI | `Get-Content -Raw` mangled Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`. | +| PowerShell NativeCommandError | Use `Invoke-Native` helper with `$ErrorActionPreference = 'Continue'` and explicit `$LASTEXITCODE`. | +| Frozen chart numpy failure | Added `numpy.core._multiarray_tests` to `main.spec` hiddenimports. | + +--- + +## 13. Additional References + +- `README.md` โ€” end-user feature overview (Korean/English mixed). +- `CLAUDE.md` โ€” additional Korean-language guidance for this project. +- `INSTALL.md` โ€” installation and troubleshooting instructions. +- `CHANGELOG.md` โ€” release notes in Korean. +- `resources/resource_links.md` โ€” links to external resources. + +When modifying code, update this `AGENTS.md` if you change any conventions, build steps, module map, or external integrations described here. diff --git a/CHANGELOG.md b/CHANGELOG.md index 440bec2..22de0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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.12.0] โ€” 2026-06-16 + +### Added โ€” ์ „์ฒด i18n ํ‚คํ™” ์™„๋ฃŒ +- **UI ์ „์ฒด ์‚ฌ์šฉ์ž ๋Œ€๋ฉด ๋ฌธ์ž์—ด i18n ํ‚คํ™”** โ€” `ui/` ๋””๋ ‰ํ„ฐ๋ฆฌ 22๊ฐœ ํŒŒ์ผ์˜ ๋ฒ„ํŠผ/๋ผ๋ฒจ/๋ฉ”์‹œ์ง€๋ฐ•์Šค/์ฐจํŠธ/์˜จ๋ณผ๋”ฉ/์„ค์ •/ํ†ต๊ณ„ ๋“ฑ์„ `tr()` ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜, `core/i18n.py`์— ko/en ๋ฒˆ์—ญ ์ถ”๊ฐ€. +- **๋„์ „๊ณผ์ œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ i18n** โ€” `core/achievements.py`์˜ ๋ชจ๋“  ๋„์ „๊ณผ์ œ ์ด๋ฆ„/์„ค๋ช…์„ `achieve.{code}.name/desc` ํ‚ค๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  ์˜๋ฌธ ๋ฒˆ์—ญ ์ถ”๊ฐ€. +- **์ฐจํŠธ ์œ„์ ฏ ๋ผ๋ฒจ ํ‚คํ™”** โ€” matplotlib ์ฐจํŠธ์˜ ์ถ•/ํˆดํŒ/๋ฒ”๋ก€/๋นˆ ๊ธฐ๋ก ๋ฉ”์‹œ์ง€ ๋“ฑ์„ ์–ธ์–ด๋ณ„๋กœ ํ‘œ์‹œ. +- **๋ฐ˜๋ณต ์—ฐ์ฐจ ํŒจํ„ด ์„ค๋ช… ํ‚คํ™”** โ€” `core/recurring_leaves.describe_pattern()`์ด ์š”์ผ/์ฃผ๊ธฐ ์ ‘๋‘์‚ฌ๋ฅผ i18n ํ‚ค๋กœ ์กฐํ•ฉ. + +### Fixed +- `ui/help_view.py`์˜ "์˜จ๋ณดํŒ… ๋‹ค์‹œ ๋ณด๊ธฐ" ๋ฒ„ํŠผ์ด `tr()` ํ˜ธ์ถœ์ด ์•„๋‹Œ ๋ฆฌํ„ฐ๋Ÿด ๋ฌธ์ž์—ด๋กœ ์ž˜๋ชป ๋“ค์–ด๊ฐ€๋˜ ๋ฒ„๊ทธ ์ˆ˜์ •. +- `core/i18n.py` ์˜๋ฌธ achievement ๋ฒˆ์—ญ ์ค‘ `Children's`/`Teachers'` ์ž‘์€๋”ฐ์˜ดํ‘œ๋กœ ์ธํ•œ SyntaxError ์ˆ˜์ •. +- `_i18n_gui_test.py`์— `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` ์ถ”๊ฐ€ํ•˜์—ฌ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํœด์ผ ๋™๊ธฐํ™” ์Šค๋ ˆ๋“œ๋กœ ์ธํ•œ ์„ธ๊ทธ๋ฉ˜ํ…Œ์ด์…˜ ํดํŠธ ๋ฐฉ์ง€. + +### Changed +- **๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ๊ณตํœด์ผ API ํ‚ค ์™ธ๋ถ€ํ™”** โ€” `utils/holiday_api.py`๊ฐ€ `CLOCKOUT_HOLIDAY_API_KEY` ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ๋ณ€๊ฒฝ. +- `ui/main_window.py` 1Hz ํ•ซํŒจ์Šค DB ํ˜ธ์ถœ ์บ์‹ฑ ์ถ”๊ฐ€. +- `utils/csv_importer.py` overwrite ์‹œ `overtime_usage`๋„ ํ•จ๊ป˜ ์‚ญ์ œ. +- `ui/controllers/lock_monitor.py` ์ปจํ…์ŠคํŠธ ๋งค๋‹ˆ์ € ์ ์šฉ ๋ฐ race condition ์ฒ˜๋ฆฌ ๊ฐœ์„ . + ## [2.11.2] โ€” 2026-06-04 ### Fixed diff --git a/_gui_smoke_test.py b/_gui_smoke_test.py index 5fa0407..ef06be0 100644 --- a/_gui_smoke_test.py +++ b/_gui_smoke_test.py @@ -11,6 +11,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Qt platform plugin: offscreen์œผ๋กœ ์‹ค์ œ ์ฐฝ ์•ˆ ๋œจ๊ฒŒ os.environ['QT_QPA_PLATFORM'] = 'offscreen' +# ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํœด์ผ ๋™๊ธฐํ™” ์Šค๋ ˆ๋“œ ๋น„ํ™œ์„ฑํ™” (DB lock / segfault ๋ฐฉ์ง€) +os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1' + from PyQt5.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv) @@ -87,7 +90,7 @@ def test_stats_view(): from ui.stats_view import StatsView dlg = StatsView(db=db) # ๋ฐ์ดํ„ฐ ์—†์–ด๋„ ์ •์ƒ ๋กœ๋“œ - assert dlg.weekly_total_hours.text() is not None + assert dlg.weekly_total_card is not None dlg.deleteLater() @@ -101,12 +104,25 @@ def test_main_window_init(): """MainWindow ์ดˆ๊ธฐํ™” โ€” ๊ฐ€์žฅ ๋ฌด๊ฑฐ์šด ์ผ€์ด์Šค""" # QLocalServer ์ถฉ๋Œ ๋ฐฉ์ง€: ํ”„๋กœ์„ธ์Šค ID ๊ธฐ๋ฐ˜ ์ด๋ฆ„ ๋ณ€๊ฒฝ ์–ด๋ ค์›€ โ†’ init๋งŒ ํ™•์ธ from ui.main_window import MainWindow - w = MainWindow() + from datetime import date as _date + # MainWindow load_today_data์—์„œ QMessageBox๋ฅผ ๋„์šฐ์ง€ ์•Š๋„๋ก ์˜ค๋Š˜ ์ถœ๊ทผ ๊ธฐ๋ก์„ ๋ฏธ๋ฆฌ ์‚ฝ์ž… + today = _date.today().isoformat() + conn = db.get_connection() + try: + conn.execute("DELETE FROM work_records WHERE date = ?", (today,)) + conn.execute( + "INSERT INTO work_records (date, clock_in, clock_out, lunch_break, dinner_break) VALUES (?, ?, ?, ?, ?)", + (today, '09:00:00', '18:00:00', 0, 0) + ) + conn.commit() + finally: + conn.close() + w = MainWindow(db=db) # ๊ธฐ๋ณธ ์ƒํƒœ - assert w.is_clocked_in == False + assert w.is_clocked_in == False # ํ‡ด๊ทผ ์™„๋ฃŒ ๊ธฐ๋ก์ด๋ฏ€๋กœ False assert w.lunch_break_enabled == False - # auto_lunch ์บ์‹œ ์ดˆ๊ธฐ None - assert w._auto_lunch_enabled_cache is None + # auto_lunch ์บ์‹œ ์ดˆ๊ธฐ None (AutoLunchManager ๋‚ถ) + assert w._auto_lunch._enabled_cache is None # ๋‹จ์ถ•ํ‚ค 7๊ฐœ ๋“ฑ๋ก๋˜์—ˆ๋Š”์ง€ from PyQt5.QtWidgets import QShortcut shortcuts = w.findChildren(QShortcut) diff --git a/_i18n_gui_test.py b/_i18n_gui_test.py index 001f8b8..dca2103 100644 --- a/_i18n_gui_test.py +++ b/_i18n_gui_test.py @@ -9,6 +9,7 @@ import sys import tempfile os.environ['QT_QPA_PLATFORM'] = 'offscreen' +os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1' sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox diff --git a/core/achievements.py b/core/achievements.py index 1074af5..9b3e09a 100644 --- a/core/achievements.py +++ b/core/achievements.py @@ -17,6 +17,7 @@ - notification_log: ํœด์‹ ๊ถŒ๊ณ  ์นด์šดํŠธ """ from __future__ import annotations +from core.i18n import tr from dataclasses import dataclass, field from datetime import date, datetime, timedelta from typing import Callable, Optional, List, Tuple @@ -426,40 +427,40 @@ def _bool_eval(condition_fn): # ---- 1. ์ถœ๊ทผ streak (24๊ฐœ โ€” 22๋ฒˆ ๊ฑฐ๋ถ์ด ์ œ๊ฑฐ) ---- _STREAK_DEFS = [ # (code, name, desc, target, evaluator, tier, icon) - ('streak_first', '์ฒซ๊ฑธ์Œ', '์ฒซ ์ถœ๊ทผ ๊ธฐ๋ก', 1, + ('streak_first', tr('achieve.streak_first.name'), tr('achieve.streak_first.desc'), 1, _bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '๐Ÿ‘‹'), - ('streak_3', '๋ฟŒ๋ฆฌ๋‚ด๋ฆผ', '3์ผ ์—ฐ์† ์˜์—…์ผ ์ถœ๊ทผ', 3, + ('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3, _make_streak_eval(3), TIER_BRONZE, '๐ŸŒฑ'), - ('streak_5', '์ฒซ ์ฃผ ์™„์ฃผ', '5 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', 5, + ('streak_5', tr('achieve.streak_5.name'), tr('achieve.streak_5.desc'), 5, _make_streak_eval(5), TIER_SILVER, '๐Ÿ“…'), - ('streak_7_cal', '7์ผ ์—ฐ์†', '์ฃผ๋ง ํฌํ•จ 7์ผ ์—ฐ์† ์ถœ๊ทผ', 7, + ('streak_7_cal', tr('achieve.streak_7_cal.name'), tr('achieve.streak_7_cal.desc'), 7, _make_streak_eval(7, business_only=False), TIER_SILVER, '๐Ÿ”ฅ'), - ('streak_10', '2์ฃผ ์—ฐ์†', '10 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', 10, + ('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10, _make_streak_eval(10), TIER_SILVER, '๐Ÿ’ช'), - ('streak_22', 'ํ•œ ๋‹ฌ ๊ฐœ๊ทผ', 'ํ•œ ๋‹ฌ ์˜์—…์ผ 100% ์ถœ๊ทผ (22์ผ)', 22, + ('streak_22', tr('achieve.streak_22.name'), tr('achieve.streak_22.desc'), 22, _make_streak_eval(22), TIER_GOLD, '๐Ÿ”๏ธ'), - ('streak_50', '50์ผ ์—ฐ์†', '50 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', 50, + ('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50, _make_streak_eval(50), TIER_GOLD, '๐ŸŽฏ'), - ('streak_100', '100์ผ ์—ฐ์†', '100 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', 100, + ('streak_100', tr('achieve.streak_100.name'), tr('achieve.streak_100.desc'), 100, _make_streak_eval(100), TIER_PLATINUM, '๐Ÿ’Ž'), - ('streak_quarter', '๋ถ„๊ธฐ ์™„์ฃผ', '์•ฝ 65 ์˜์—…์ผ (3๊ฐœ์›”)', 65, + ('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65, _make_streak_eval(65), TIER_PLATINUM, '๐Ÿ†'), - ('streak_half_year', '๋ฐ˜๋…„ ๋งˆ๋ผํ†ค', '์•ฝ 130 ์˜์—…์ผ (6๊ฐœ์›”)', 130, + ('streak_half_year', tr('achieve.streak_half_year.name'), tr('achieve.streak_half_year.desc'), 130, _make_streak_eval(130), TIER_PLATINUM, '๐Ÿ‘‘'), - ('streak_year', '1๋…„ ํ’€ ์‹œ์ฆŒ', '์•ฝ 260 ์˜์—…์ผ (1๋…„)', 260, + ('streak_year', tr('achieve.streak_year.name'), tr('achieve.streak_year.desc'), 260, _make_streak_eval(260), TIER_LEGEND, '๐ŸŒŸ'), - ('streak_200', '์‚ฌ์ด์–ธ์Šค', '200 ์˜์—…์ผ ์—ฐ์†', 200, + ('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200, _make_streak_eval(200), TIER_LEGEND, '๐ŸŒŒ'), - ('streak_365_cal', '๋ถˆ์‚ฌ์‹ ', '365์ผ ๋‹ฌ๋ ฅ ์—ฐ์†', 365, + ('streak_365_cal', tr('achieve.streak_365_cal.name'), tr('achieve.streak_365_cal.desc'), 365, _make_streak_eval(365, business_only=False), TIER_LEGEND, '๐Ÿ›ก๏ธ'), - ('streak_resilience', 'ํšŒ๋ณต๋ ฅ', '๊ฒฐ๊ทผ ํ›„ ๋‹ค์Œ๋‚  ์ฆ‰์‹œ ์ถœ๊ทผ (์ž๋™: ๋‹ฌ๋ ฅ streak ๊นจ์ง„ ํ›„ ์žฌ์‹œ์ž‘)', 1, + ('streak_resilience', tr('achieve.streak_resilience.name'), tr('achieve.streak_resilience.desc'), 1, _bool_eval(lambda db: _consecutive_workdays(db) >= 1 and _count_work_records(db) >= 5), TIER_BRONZE, 'โšก'), - ('streak_total_100', '๋ˆ„์  100ํšŒ', '๋ˆ„์  ์ถœ๊ทผ 100ํšŒ', 100, + ('streak_total_100', tr('achieve.streak_total_100.name'), tr('achieve.streak_total_100.desc'), 100, _make_count_eval(_count_work_records, 100), TIER_GOLD, '๐Ÿ’ผ'), - ('streak_total_500', '๋ˆ„์  500ํšŒ', '๋ˆ„์  ์ถœ๊ทผ 500ํšŒ', 500, + ('streak_total_500', tr('achieve.streak_total_500.name'), tr('achieve.streak_total_500.desc'), 500, _make_count_eval(_count_work_records, 500), TIER_PLATINUM, '๐Ÿ›๏ธ'), - ('streak_total_1000', '๋ˆ„์  1000ํšŒ', '๋ˆ„์  ์ถœ๊ทผ 1000ํšŒ', 1000, + ('streak_total_1000', tr('achieve.streak_total_1000.name'), tr('achieve.streak_total_1000.desc'), 1000, _make_count_eval(_count_work_records, 1000), TIER_LEGEND, '๐ŸŽ–๏ธ'), ] @@ -476,37 +477,37 @@ def _count_weekday_clockins(db, weekday: int) -> int: _STREAK_DEFS.extend([ - ('streak_monday_10', '์›”์š”์ผ ์ •๋ณต', '์›”์š”์ผ 10์ฃผ ์—ฐ์† ์ถœ๊ทผ', 10, + ('streak_monday_10', tr('achieve.streak_monday_10.name'), tr('achieve.streak_monday_10.desc'), 10, _make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '๐ŸŒ…'), - ('streak_friday_10', '๊ธˆ์š”์ผ ๋ฌด๊ฒฐ', '๊ธˆ์š”์ผ 10์ฃผ ์—ฐ์† ์ถœ๊ทผ', 10, + ('streak_friday_10', tr('achieve.streak_friday_10.name'), tr('achieve.streak_friday_10.desc'), 10, _make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '๐ŸŒ’'), ]) # ---- 2. ์‹œ๊ฐ„ ์—„์ˆ˜ (19๊ฐœ - 34/46 ์ œ๊ฑฐ) ---- _PUNCTUAL_DEFS = [ - ('punc_before_8_1', '์–ผ๋ฆฌ๋ฒ„๋“œ', '08:00 ์ด์ „ ์ถœ๊ทผ 1ํšŒ', 1, + ('punc_before_8_1', tr('achieve.punc_before_8_1.name'), tr('achieve.punc_before_8_1.desc'), 1, _make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '๐ŸŒ„'), - ('punc_before_8_10', '์ฐธ์ƒˆ์กฑ', '08:00 ์ด์ „ 10ํšŒ', 10, + ('punc_before_8_10', tr('achieve.punc_before_8_10.name'), tr('achieve.punc_before_8_10.desc'), 10, _make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '๐Ÿฆ'), - ('punc_before_8_30', '์ผ์ฐ ์ž๊ณ  ์ผ์ฐ', '08:00 ์ด์ „ 30ํšŒ', 30, + ('punc_before_8_30', tr('achieve.punc_before_8_30.name'), tr('achieve.punc_before_8_30.desc'), 30, _make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '๐ŸŒž'), - ('punc_before_6_1', '์ƒˆ๋ฒฝ์ž  ์—†์Œ', '06:00 ์ด์ „ 1ํšŒ', 1, + ('punc_before_6_1', tr('achieve.punc_before_6_1.name'), tr('achieve.punc_before_6_1.desc'), 1, _make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '๐Ÿฅฑ'), - ('punc_before_6_10', '์–ด๋‘ ์„ ๊ฐ€๋ฅด๋Š” ์ž', '06:00 ์ด์ „ 10ํšŒ', 10, + ('punc_before_6_10', tr('achieve.punc_before_6_10.name'), tr('achieve.punc_before_6_10.desc'), 10, _make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '๐ŸŒ‘'), - ('punc_before_5', '์ƒˆ๋ฒฝ ์ฑ”ํ”ผ์–ธ', '05:00 ์ด์ „ ์ถœ๊ทผ', 1, + ('punc_before_5', tr('achieve.punc_before_5.name'), tr('achieve.punc_before_5.desc'), 1, _make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '๐ŸŒŒ'), - ('punc_at_9', '9์‹œ ์ •๊ฐ', '09:00 ์ •๊ฐ(ยฑ1๋ถ„) ์ถœ๊ทผ 1ํšŒ', 1, + ('punc_at_9', tr('achieve.punc_at_9.name'), tr('achieve.punc_at_9.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1), TIER_BRONZE, '๐ŸŽฏ'), - ('punc_at_9_5', '์™„๋ฒฝํ•œ 9์‹œ', '09:00 ์ •๊ฐ(ยฑ1๋ถ„) 5ํšŒ', 5, + ('punc_at_9_5', tr('achieve.punc_at_9_5.name'), tr('achieve.punc_at_9_5.desc'), 5, _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5), TIER_SILVER, '๐Ÿน'), - ('punc_late_5min', '5๋ถ„ ๋Šฆ์Œ', '09:00~09:05 ์ถœ๊ทผ 1ํšŒ (์ž์กฐ)', 1, + ('punc_late_5min', tr('achieve.punc_late_5min.name'), tr('achieve.punc_late_5min.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1), TIER_BRONZE, '๐Ÿ›Œ'), - ('punc_at_909', '์šด๋ช…์˜ ์‹œ๊ฐ', '09:09 ์ถœ๊ทผ (์‹œํฌ๋ฆฟ)', 1, + ('punc_at_909', tr('achieve.punc_at_909.name'), tr('achieve.punc_at_909.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1), TIER_GOLD, '๐ŸŽฐ'), ] @@ -525,76 +526,76 @@ def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> i # ---- 3. ์›Œ๋ผ๋ฐธยท์ •์‹œ ํ‡ด๊ทผ (8๊ฐœ ์ฝ”์–ด) ---- _BALANCE_DEFS = [ - ('bal_first_punct', '์ฒซ ์นผํ‡ด', '์ •์‹œ ํ‡ด๊ทผ ์ฒซ ๋‹ฌ์„ฑ', 1, + ('bal_first_punct', tr('achieve.bal_first_punct.name'), tr('achieve.bal_first_punct.desc'), 1, _make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '๐Ÿšช'), - ('bal_punct_10', '์นผํ‡ด๋Ÿฌ', '์ •์‹œ ํ‡ด๊ทผ 10ํšŒ', 10, + ('bal_punct_10', tr('achieve.bal_punct_10.name'), tr('achieve.bal_punct_10.desc'), 10, _make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '๐ŸŽ‰'), - ('bal_punct_30', '์นผํ‡ด ์ฑ”ํ”„', '์ •์‹œ ํ‡ด๊ทผ 30ํšŒ', 30, + ('bal_punct_30', tr('achieve.bal_punct_30.name'), tr('achieve.bal_punct_30.desc'), 30, _make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '๐Ÿƒ'), - ('bal_punct_100', '์ง„์ •ํ•œ ์ž์œ ', '์ •์‹œ ํ‡ด๊ทผ 100ํšŒ', 100, + ('bal_punct_100', tr('achieve.bal_punct_100.name'), tr('achieve.bal_punct_100.desc'), 100, _make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '๐Ÿ–๏ธ'), - ('bal_punct_300', '์›Œ๋ผ๋ฐธ ๋งˆ์Šคํ„ฐ', '์ •์‹œ ํ‡ด๊ทผ 300ํšŒ', 300, + ('bal_punct_300', tr('achieve.bal_punct_300.name'), tr('achieve.bal_punct_300.desc'), 300, _make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '๐Ÿช'), ] # ---- 4. ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ---- _OT_BANK_DEFS = [ - ('ot_first_30m', '์ฒซ 30๋ถ„', '์ฒซ ์—ฐ์žฅ ์ ๋ฆฝ', 30, + ('ot_first_30m', tr('achieve.ot_first_30m.name'), tr('achieve.ot_first_30m.desc'), 30, _make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '๐Ÿ’ฐ'), - ('ot_total_60m', '1์‹œ๊ฐ„ ์ ๊ธˆ', '๋ˆ„์  1์‹œ๊ฐ„ ์ ๋ฆฝ', 60, + ('ot_total_60m', tr('achieve.ot_total_60m.name'), tr('achieve.ot_total_60m.desc'), 60, _make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '๐Ÿ’ต'), - ('ot_total_5h', '5์‹œ๊ฐ„ ์ ๋ฆฝ', '๋ˆ„์  5์‹œ๊ฐ„', 300, + ('ot_total_5h', tr('achieve.ot_total_5h.name'), tr('achieve.ot_total_5h.desc'), 300, _make_count_eval(_ot_total_earned, 300), TIER_SILVER, '๐Ÿฆ'), - ('ot_total_10h', '10์‹œ๊ฐ„ ์ ๋ฆฝ', '๋ˆ„์  10์‹œ๊ฐ„', 600, + ('ot_total_10h', tr('achieve.ot_total_10h.name'), tr('achieve.ot_total_10h.desc'), 600, _make_count_eval(_ot_total_earned, 600), TIER_SILVER, '๐Ÿ’Ž'), - ('ot_total_25h', '25์‹œ๊ฐ„ ์ ๋ฆฝ', '๋ˆ„์  25์‹œ๊ฐ„', 1500, + ('ot_total_25h', tr('achieve.ot_total_25h.name'), tr('achieve.ot_total_25h.desc'), 1500, _make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '๐Ÿ†'), - ('ot_total_50h', '50์‹œ๊ฐ„ ์ ๋ฆฝ', '๋ˆ„์  50์‹œ๊ฐ„', 3000, + ('ot_total_50h', tr('achieve.ot_total_50h.name'), tr('achieve.ot_total_50h.desc'), 3000, _make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '๐ŸŽฏ'), - ('ot_total_100h', '๋งˆ๋ผํ† ๋„ˆ', '๋ˆ„์  100์‹œ๊ฐ„ (๊ฑฑ์ • ๋ฉ”์‹œ์ง€)', 6000, + ('ot_total_100h', tr('achieve.ot_total_100h.name'), tr('achieve.ot_total_100h.desc'), 6000, _make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '๐Ÿ”๏ธ'), - ('ot_total_200h', '์›Œํฌํ™€๋ฆญ ๊ฒฝ๊ณ ', '๋ˆ„์  200์‹œ๊ฐ„ (๊ฒฝ๊ณ )', 12000, + ('ot_total_200h', tr('achieve.ot_total_200h.name'), tr('achieve.ot_total_200h.desc'), 12000, _make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '๐ŸŒ‘'), - ('ot_total_300h', '์œ„ํ—˜ ์‹ ํ˜ธ', '๋ˆ„์  300์‹œ๊ฐ„ (๊ฐ•ํ•œ ๊ฒฝ๊ณ )', 18000, + ('ot_total_300h', tr('achieve.ot_total_300h.name'), tr('achieve.ot_total_300h.desc'), 18000, _make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, 'โš ๏ธ'), - ('ot_total_500h', '์‘๊ธ‰์‹ค ๋‹จ๊ณจ', '๋ˆ„์  500์‹œ๊ฐ„ (์ž์กฐ)', 30000, + ('ot_total_500h', tr('achieve.ot_total_500h.name'), tr('achieve.ot_total_500h.desc'), 30000, _make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '๐Ÿš‘'), ] # ---- 5. ์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ ---- _OT_USE_DEFS = [ - ('use_first', '์ฒซ ํœด์‹', '์ ๋ฆฝ ์ฒซ ์‚ฌ์šฉ', 1, + ('use_first', tr('achieve.use_first.name'), tr('achieve.use_first.desc'), 1, _bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '๐Ÿ›Œ'), - ('use_total_5h', '์„ ๋ฌผ ์‚ฌ์šฉ', '๋ˆ„์  5์‹œ๊ฐ„ ์‚ฌ์šฉ', 300, + ('use_total_5h', tr('achieve.use_total_5h.name'), tr('achieve.use_total_5h.desc'), 300, _make_count_eval(_ot_total_used, 300), TIER_SILVER, '๐ŸŽ'), - ('use_total_25h', 'ํœด์‹์˜ ๊ฐ€์น˜', '๋ˆ„์  25์‹œ๊ฐ„ ์‚ฌ์šฉ', 1500, + ('use_total_25h', tr('achieve.use_total_25h.name'), tr('achieve.use_total_25h.desc'), 1500, _make_count_eval(_ot_total_used, 1500), TIER_GOLD, '๐Ÿ›€'), - ('use_total_50h', 'ํšŒ๋ณต ๋งˆ์Šคํ„ฐ', '๋ˆ„์  50์‹œ๊ฐ„ ์‚ฌ์šฉ', 3000, + ('use_total_50h', tr('achieve.use_total_50h.name'), tr('achieve.use_total_50h.desc'), 3000, _make_count_eval(_ot_total_used, 3000), TIER_GOLD, '๐Ÿ–๏ธ'), - ('use_total_100h', '๋งˆ์‚ฌ์ง€', '๋ˆ„์  100์‹œ๊ฐ„ ์‚ฌ์šฉ', 6000, + ('use_total_100h', tr('achieve.use_total_100h.name'), tr('achieve.use_total_100h.desc'), 6000, _make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '๐Ÿ’†'), ] # ---- 6. ์—ฐ์ฐจ ---- _LEAVE_DEFS = [ - ('leave_first', '์ฒซ ์—ฐ์ฐจ', '์ฒซ ์—ฐ์ฐจ ์‚ฌ์šฉ', 1, + ('leave_first', tr('achieve.leave_first.name'), tr('achieve.leave_first.desc'), 1, _make_count_eval(_count_leave_records, 1), TIER_BRONZE, '๐ŸŒด'), - ('leave_half', '์ฒซ ๋ฐ˜์ฐจ', '0.5์ผ ์—ฐ์ฐจ ์‚ฌ์šฉ', 1, + ('leave_half', tr('achieve.leave_half.name'), tr('achieve.leave_half.desc'), 1, _bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '๐Ÿƒ'), - ('leave_quarter', '์‹œ๊ฐ„ ์—ฐ์ฐจ', '0.25์ผ ์—ฐ์ฐจ ์‚ฌ์šฉ', 1, + ('leave_quarter', tr('achieve.leave_quarter.name'), tr('achieve.leave_quarter.desc'), 1, _bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, 'โฑ๏ธ'), - ('leave_streak_3', '๋ฏธ๋‹ˆ ํœด๊ฐ€', '์—ฐ์† 3์ผ ์—ฐ์ฐจ', 3, + ('leave_streak_3', tr('achieve.leave_streak_3.name'), tr('achieve.leave_streak_3.desc'), 3, _make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '๐Ÿ๏ธ'), - ('leave_streak_5', '๋ณธ๊ฒฉ ํœด๊ฐ€', '์—ฐ์† 5์ผ ์—ฐ์ฐจ', 5, + ('leave_streak_5', tr('achieve.leave_streak_5.name'), tr('achieve.leave_streak_5.desc'), 5, _make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '๐ŸŒ…'), - ('leave_streak_7', '์žฅ๊ฑฐ๋ฆฌ ํœด๊ฐ€', '์—ฐ์† 7์ผ ์ด์ƒ ์—ฐ์ฐจ', 7, + ('leave_streak_7', tr('achieve.leave_streak_7.name'), tr('achieve.leave_streak_7.desc'), 7, _make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '๐Ÿ›ฌ'), - ('leave_total_10', '์—ฐ์ฐจ 10ํšŒ', '์—ฐ์ฐจ ๊ธฐ๋ก 10๊ฑด', 10, + ('leave_total_10', tr('achieve.leave_total_10.name'), tr('achieve.leave_total_10.desc'), 10, _make_count_eval(_count_leave_records, 10), TIER_SILVER, '๐ŸŒŠ'), - ('leave_sick', '๋ณ‘๊ฐ€', 'sick ํƒ€์ž… ์—ฐ์ฐจ ์‚ฌ์šฉ', 1, + ('leave_sick', tr('achieve.leave_sick.name'), tr('achieve.leave_sick.desc'), 1, _make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1), TIER_BRONZE, '๐Ÿฅ'), ] @@ -602,22 +603,22 @@ _LEAVE_DEFS = [ # ---- 7. ์‹์‚ฌ (์ ์‹ฌ/์ €๋…) ---- _MEAL_DEFS = [ - ('meal_lunch_first', '์ฒซ ์ ์‹ฌ ๋“ฑ๋ก', '์ ์‹ฌ ์ฒซ ํ† ๊ธ€', 1, + ('meal_lunch_first', tr('achieve.meal_lunch_first.name'), tr('achieve.meal_lunch_first.desc'), 1, _make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '๐Ÿฑ'), - ('meal_lunch_30', '์ ์‹ฌ ๋งˆ์Šคํ„ฐ', '์ ์‹ฌ ๋“ฑ๋ก 30ํšŒ', 30, + ('meal_lunch_30', tr('achieve.meal_lunch_30.name'), tr('achieve.meal_lunch_30.desc'), 30, _make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '๐Ÿฅข'), - ('meal_lunch_100', '์ ์‹ฌ ์ฑ”ํ”„', '์ ์‹ฌ ๋“ฑ๋ก 100ํšŒ', 100, + ('meal_lunch_100', tr('achieve.meal_lunch_100.name'), tr('achieve.meal_lunch_100.desc'), 100, _make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '๐Ÿœ'), - ('meal_dinner_first', '์ฒซ ์ €๋… ๋“ฑ๋ก', '์ €๋… ์ฒซ ํ† ๊ธ€', 1, + ('meal_dinner_first', tr('achieve.meal_dinner_first.name'), tr('achieve.meal_dinner_first.desc'), 1, _make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '๐Ÿฝ๏ธ'), - ('meal_dinner_10', '์ €๋… ๋‹จ๊ณจ', '์ €๋… ๋“ฑ๋ก 10ํšŒ (๊ฒฝ๊ณ )', 10, + ('meal_dinner_10', tr('achieve.meal_dinner_10.name'), tr('achieve.meal_dinner_10.desc'), 10, _make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '๐Ÿ›'), - ('meal_dinner_30', '์•ผ์‹ ๋‹จ๊ณจ', '์ €๋… ๋“ฑ๋ก 30ํšŒ (๊ฒฝ๊ณ )', 30, + ('meal_dinner_30', tr('achieve.meal_dinner_30.name'), tr('achieve.meal_dinner_30.desc'), 30, _make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '๐ŸŒƒ'), - ('meal_lunch_actual', '์‹ค์ธก ์ ์‹ฌ', '์‹ค์ œ ์ ์‹ฌ ์‹œ๊ฐ ์ž…๋ ฅ', 1, + ('meal_lunch_actual', tr('achieve.meal_lunch_actual.name'), tr('achieve.meal_lunch_actual.desc'), 1, _make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1), TIER_BRONZE, 'โฑ๏ธ'), - ('meal_dinner_actual', '์‹ค์ธก ์ €๋…', '์‹ค์ œ ์ €๋… ์‹œ๊ฐ ์ž…๋ ฅ', 1, + ('meal_dinner_actual', tr('achieve.meal_dinner_actual.name'), tr('achieve.meal_dinner_actual.desc'), 1, _make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1), TIER_BRONZE, 'โฐ'), ] @@ -625,13 +626,13 @@ _MEAL_DEFS = [ # ---- 8. ์™ธ์ถœ ---- _BREAK_DEFS = [ - ('break_first', '์ฒซ ์™ธ์ถœ', '์ฒซ ์™ธ์ถœ ์‹œ์ž‘', 1, + ('break_first', tr('achieve.break_first.name'), tr('achieve.break_first.desc'), 1, _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1), TIER_BRONZE, '๐Ÿšถ'), - ('break_10', '์™ธ์ถœ ์ฑ”ํ”„', '์™ธ์ถœ 10ํšŒ', 10, + ('break_10', tr('achieve.break_10.name'), tr('achieve.break_10.desc'), 10, _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10), TIER_SILVER, '๐Ÿšช'), - ('break_50', '์‚ฐ์ฑ…๋Ÿฌ', '์™ธ์ถœ 50ํšŒ', 50, + ('break_50', tr('achieve.break_50.name'), tr('achieve.break_50.desc'), 50, _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50), TIER_GOLD, '๐Ÿšถโ€โ™‚๏ธ'), ] @@ -639,39 +640,39 @@ _BREAK_DEFS = [ # ---- 9. ์‹œ๊ฐ„๋Œ€๋ณ„ ---- _TIME_SLOT_DEFS = [ - ('slot_in_06', '06์‹œ๋Œ€ ์ถœ๊ทผ', '06:00-06:59 ์ถœ๊ทผ 1ํšŒ', 1, + ('slot_in_06', tr('achieve.slot_in_06.name'), tr('achieve.slot_in_06.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1), TIER_BRONZE, '๐ŸŒ…'), - ('slot_in_07', '07์‹œ๋Œ€ ์ถœ๊ทผ', '07:00-07:59 ์ถœ๊ทผ 1ํšŒ', 1, + ('slot_in_07', tr('achieve.slot_in_07.name'), tr('achieve.slot_in_07.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1), TIER_BRONZE, '๐ŸŒ„'), - ('slot_in_08', '08์‹œ๋Œ€ ์ถœ๊ทผ', '08:00-08:59 ์ถœ๊ทผ 1ํšŒ', 1, + ('slot_in_08', tr('achieve.slot_in_08.name'), tr('achieve.slot_in_08.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1), TIER_BRONZE, 'โ˜€๏ธ'), - ('slot_in_10', '10์‹œ๋Œ€ ์ถœ๊ทผ', '10์‹œ๋Œ€ ์ถœ๊ทผ (์ง€๊ฐ/์œ ์—ฐ๊ทผ๋ฌด)', 1, + ('slot_in_10', tr('achieve.slot_in_10.name'), tr('achieve.slot_in_10.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1), TIER_BRONZE, '๐Ÿ•™'), - ('slot_in_11', '11์‹œ๋Œ€ ์ถœ๊ทผ', '11์‹œ๋Œ€ ์ถœ๊ทผ (์ž์กฐ)', 1, + ('slot_in_11', tr('achieve.slot_in_11.name'), tr('achieve.slot_in_11.desc'), 1, _make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1), TIER_SILVER, '๐Ÿ•ฆ'), - ('slot_out_19', '19์‹œ๋Œ€ ํ‡ด๊ทผ', '19์‹œ๋Œ€ ํ‡ด๊ทผ 10ํšŒ (๊ฒฝ๊ณ )', 10, + ('slot_out_19', tr('achieve.slot_out_19.name'), tr('achieve.slot_out_19.desc'), 10, _make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10), TIER_SILVER, '๐ŸŒ†'), - ('slot_out_20', '20์‹œ๋Œ€ ํ‡ด๊ทผ', '20์‹œ๋Œ€ ํ‡ด๊ทผ 10ํšŒ (๊ฒฝ๊ณ )', 10, + ('slot_out_20', tr('achieve.slot_out_20.name'), tr('achieve.slot_out_20.desc'), 10, _make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10), TIER_GOLD, '๐ŸŒŒ'), - ('slot_out_21', '21์‹œ๋Œ€ ํ‡ด๊ทผ', '21์‹œ๋Œ€ ํ‡ด๊ทผ 5ํšŒ (๊ฒฝ๊ณ )', 5, + ('slot_out_21', tr('achieve.slot_out_21.name'), tr('achieve.slot_out_21.desc'), 5, _make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5), TIER_GOLD, '๐ŸŒ‘'), - ('slot_out_22', '22์‹œ๋Œ€ ํ‡ด๊ทผ', '22์‹œ๋Œ€ ํ‡ด๊ทผ 1ํšŒ (๊ฒฝ๊ณ )', 1, + ('slot_out_22', tr('achieve.slot_out_22.name'), tr('achieve.slot_out_22.desc'), 1, _make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1), TIER_PLATINUM, '๐Ÿฆ‰'), - ('slot_out_23', '23์‹œ๋Œ€ ํ‡ด๊ทผ', '23์‹œ๋Œ€ ํ‡ด๊ทผ 1ํšŒ (๊ฒฝ๊ณ )', 1, + ('slot_out_23', tr('achieve.slot_out_23.name'), tr('achieve.slot_out_23.desc'), 1, _make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1), TIER_PLATINUM, '๐Ÿฆ‡'), - ('slot_midnight', '์ž์ • ํ‡ด๊ทผ', '์ž์ • ์ดํ›„ ํ‡ด๊ทผ (๊ฒฝ๊ณ )', 1, + ('slot_midnight', tr('achieve.slot_midnight.name'), tr('achieve.slot_midnight.desc'), 1, _make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '๐ŸŒš'), - ('slot_midnight_3', '์˜ฌ๋นผ๋ฏธ ํŠธ๋ฆฌ์˜ค', '์ž์ • ์ดํ›„ ํ‡ด๊ทผ 3ํšŒ (๊ฒฝ๊ณ )', 3, + ('slot_midnight_3', tr('achieve.slot_midnight_3.name'), tr('achieve.slot_midnight_3.desc'), 3, _make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '๐ŸŒŒ'), ] @@ -692,50 +693,50 @@ def _count_clockouts_in_hour(db, hour: int) -> int: # ---- 10. ๊ณตํœด์ผยท์ฃผ๋ง ---- _SPECIAL_DAY_DEFS = [ - ('weekend_1', '์ฃผ๋ง ์ถœ๊ทผ 1ํšŒ', 'ํ† /์ผ ์ถœ๊ทผ 1ํšŒ', 1, + ('weekend_1', tr('achieve.weekend_1.name'), tr('achieve.weekend_1.desc'), 1, _make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '๐ŸŒƒ'), - ('weekend_5', '์ฃผ๋ง ์›Œ์ปค', '์ฃผ๋ง ์ถœ๊ทผ 5ํšŒ (๊ฒฝ๊ณ )', 5, + ('weekend_5', tr('achieve.weekend_5.name'), tr('achieve.weekend_5.desc'), 5, _make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '๐ŸŒ‘'), - ('weekend_20', '์ง„์งœ ์›Œํฌํ™€๋ฆญ', '์ฃผ๋ง ์ถœ๊ทผ 20ํšŒ (๊ฐ•ํ•œ ์ž์กฐ)', 20, + ('weekend_20', tr('achieve.weekend_20.name'), tr('achieve.weekend_20.desc'), 20, _make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '๐Ÿ’€'), - ('holiday_1', '๊ณตํœด์ผ ์ถœ๊ทผ', 'ํ•œ๊ตญ ๊ณตํœด์ผ ์ถœ๊ทผ 1ํšŒ', 1, + ('holiday_1', tr('achieve.holiday_1.name'), tr('achieve.holiday_1.desc'), 1, _make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '๐Ÿ“†'), - ('holiday_5', '๊ณตํœด์ผ ์›Œ์ปคํ™€๋ฆญ', 'ํ•œ๊ตญ ๊ณตํœด์ผ ์ถœ๊ทผ 5ํšŒ (๊ฒฝ๊ณ )', 5, + ('holiday_5', tr('achieve.holiday_5.name'), tr('achieve.holiday_5.desc'), 5, _make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, 'โš ๏ธ'), - ('day_christmas', 'ํฌ๋ฆฌ์Šค๋งˆ์Šค ์ถœ๊ทผ', '12/25 ์ถœ๊ทผ (์ž์กฐ)', 1, + ('day_christmas', tr('achieve.day_christmas.name'), tr('achieve.day_christmas.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '๐ŸŽ„'), - ('day_newyear', '์‹ ์ • ์ถœ๊ทผ', '1/1 ์ถœ๊ทผ (์ž์กฐ)', 1, + ('day_newyear', tr('achieve.day_newyear.name'), tr('achieve.day_newyear.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '๐ŸŽŠ'), - ('day_liberation', '๊ด‘๋ณต์ ˆ ์ถœ๊ทผ', '8/15 ์ถœ๊ทผ', 1, + ('day_liberation', tr('achieve.day_liberation.name'), tr('achieve.day_liberation.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '๐ŸŽ†'), - ('day_children', '์–ด๋ฆฐ์ด๋‚  ์ถœ๊ทผ', '5/5 ์ถœ๊ทผ (์ž์กฐ)', 1, + ('day_children', tr('achieve.day_children.name'), tr('achieve.day_children.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '๐ŸŽ€'), - ('day_hangul', 'ํ•œ๊ธ€๋‚  ์ถœ๊ทผ', '10/9 ์ถœ๊ทผ', 1, + ('day_hangul', tr('achieve.day_hangul.name'), tr('achieve.day_hangul.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '๐ŸŽค'), - ('day_valentine', '๋ฐœ๋ Œํƒ€์ธ๋ฐ์ด ์ถœ๊ทผ', '2/14 ์ถœ๊ทผ', 1, + ('day_valentine', tr('achieve.day_valentine.name'), tr('achieve.day_valentine.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '๐Ÿ’'), - ('day_white', 'ํ™”์ดํŠธ๋ฐ์ด ์ถœ๊ทผ', '3/14 ์ถœ๊ทผ', 1, + ('day_white', tr('achieve.day_white.name'), tr('achieve.day_white.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '๐ŸŒน'), - ('day_pepero', '๋นผ๋นผ๋กœ๋ฐ์ด', '11/11 ์ถœ๊ทผ', 1, + ('day_pepero', tr('achieve.day_pepero.name'), tr('achieve.day_pepero.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '๐Ÿซ'), - ('day_halloween', 'ํ•ผ๋Ÿฌ์œˆ ์ถœ๊ทผ', '10/31 ์ถœ๊ทผ', 1, + ('day_halloween', tr('achieve.day_halloween.name'), tr('achieve.day_halloween.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '๐ŸŽƒ'), - ('day_aprilfools', '๋งŒ์šฐ์ ˆ ์ถœ๊ทผ', '4/1 ์ถœ๊ทผ', 1, + ('day_aprilfools', tr('achieve.day_aprilfools.name'), tr('achieve.day_aprilfools.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '๐Ÿƒ'), - ('day_77', '์น ์›”์น ์„', '7/7 ์ถœ๊ทผ', 1, + ('day_77', tr('achieve.day_77.name'), tr('achieve.day_77.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '๐ŸŽ‹'), - ('day_dongji', '๋™์ง€ ์ถœ๊ทผ', '12/22 ์ถœ๊ทผ', 1, + ('day_dongji', tr('achieve.day_dongji.name'), tr('achieve.day_dongji.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '๐ŸŽ‡'), - ('day_parents', '์–ด๋ฒ„์ด๋‚  ์ •์‹œ ํ‡ด๊ทผ', '5/8 ์ •์‹œ ํ‡ด๊ทผ', 1, + ('day_parents', tr('achieve.day_parents.name'), tr('achieve.day_parents.desc'), 1, _bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')), TIER_SILVER, '๐Ÿช…'), - ('day_teacher', '์Šค์Šน์˜ ๋‚  ์ •์‹œ ํ‡ด๊ทผ', '5/15 ์ •์‹œ ํ‡ด๊ทผ', 1, + ('day_teacher', tr('achieve.day_teacher.name'), tr('achieve.day_teacher.desc'), 1, _bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')), TIER_BRONZE, '๐ŸŽ‚'), - ('day_xmas_eve', 'ํฌ๋ฆฌ์Šค๋งˆ์Šค์ด๋ธŒ ์ •์‹œ ํ‡ด๊ทผ', '12/24 ์ •์‹œ ํ‡ด๊ทผ', 1, + ('day_xmas_eve', tr('achieve.day_xmas_eve.name'), tr('achieve.day_xmas_eve.desc'), 1, _bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')), TIER_SILVER, '๐ŸŽ'), - ('day_earth', '์ง€๊ตฌ์˜ ๋‚ ', '4/22 ์ถœ๊ทผ (์‹œํฌ๋ฆฟ)', 1, + ('day_earth', tr('achieve.day_earth.name'), tr('achieve.day_earth.desc'), 1, _bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '๐ŸŒ'), ] @@ -760,79 +761,79 @@ def _make_month_first_eval(month: int): _SEASON_DEFS = [ - ('season_jan', '1์›” ์ •์ฐฉ', '1์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', 1, + ('season_jan', tr('achieve.season_jan.name'), tr('achieve.season_jan.desc'), 1, _make_month_first_eval(1), TIER_BRONZE, 'โ›„'), - ('season_feb', '2์›” ์ •์ฐฉ', '2์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', 1, + ('season_feb', tr('achieve.season_feb.name'), tr('achieve.season_feb.desc'), 1, _make_month_full_attendance_eval(2), TIER_SILVER, '๐ŸŒจ๏ธ'), - ('season_mar', '๋ด„์„ ๋งž์ด', '3์›” ์ฒซ ์ถœ๊ทผ', 1, + ('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1, _make_month_first_eval(3), TIER_BRONZE, '๐ŸŒธ'), - ('season_apr', '4์›” ์ •์ฐฉ', '4์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', 1, + ('season_apr', tr('achieve.season_apr.name'), tr('achieve.season_apr.desc'), 1, _make_month_full_attendance_eval(4), TIER_BRONZE, '๐ŸŒท'), - ('season_may', '5์›” ์ •์ฐฉ', '5์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', 1, + ('season_may', tr('achieve.season_may.name'), tr('achieve.season_may.desc'), 1, _make_month_full_attendance_eval(5), TIER_SILVER, '๐ŸŒบ'), - ('season_jun', '์—ฌ๋ฆ„์˜ ์‹œ์ž‘', '6์›” ์ฒซ ์ถœ๊ทผ', 1, + ('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1, _make_month_first_eval(6), TIER_BRONZE, 'โ˜€๏ธ'), - ('season_jul', '7์›” ์ •์ฐฉ', '7์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', 1, + ('season_jul', tr('achieve.season_jul.name'), tr('achieve.season_jul.desc'), 1, _make_month_full_attendance_eval(7), TIER_BRONZE, '๐ŸŒป'), - ('season_aug', '8์›” ์ •์ฐฉ', '8์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', 1, + ('season_aug', tr('achieve.season_aug.name'), tr('achieve.season_aug.desc'), 1, _make_month_full_attendance_eval(8), TIER_SILVER, '๐Ÿฆ'), - ('season_sep', '๊ฐ€์„์˜ ์‹œ์ž‘', '9์›” ์ฒซ ์ถœ๊ทผ', 1, + ('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1, _make_month_first_eval(9), TIER_BRONZE, '๐Ÿ‚'), - ('season_oct', '10์›” ์ •์ฐฉ', '10์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', 1, + ('season_oct', tr('achieve.season_oct.name'), tr('achieve.season_oct.desc'), 1, _make_month_full_attendance_eval(10), TIER_BRONZE, '๐ŸŒพ'), - ('season_nov', '11์›” ๋‹จํ’', '11์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', 1, + ('season_nov', tr('achieve.season_nov.name'), tr('achieve.season_nov.desc'), 1, _make_month_full_attendance_eval(11), TIER_SILVER, '๐Ÿ'), - ('season_dec', '๊ฒจ์šธ์˜ ์‹œ์ž‘', '12์›” ์ฒซ ์ถœ๊ทผ', 1, + ('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1, _make_month_first_eval(12), TIER_BRONZE, 'โ„๏ธ'), ] # ---- 12. ์•ฑ ์‚ฌ์šฉ ๋งˆ์ผ์Šคํ†ค ---- _MILESTONE_DEFS = [ - ('mile_first', 'Hello, World!', '์•ฑ ์ฒซ ์‹คํ–‰', 1, + ('mile_first', tr('achieve.mile_first.name'), tr('achieve.mile_first.desc'), 1, _bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0), TIER_BRONZE, '๐Ÿ‘‹'), - ('mile_7days', '์ผ์ฃผ์ผ ์‚ฌ์šฉ', '7์ผ ์‚ฌ์šฉ', 7, + ('mile_7days', tr('achieve.mile_7days.name'), tr('achieve.mile_7days.desc'), 7, _make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '๐Ÿ—“๏ธ'), - ('mile_30days', 'ํ•œ ๋‹ฌ ์‚ฌ์šฉ', '30์ผ ์‚ฌ์šฉ', 30, + ('mile_30days', tr('achieve.mile_30days.name'), tr('achieve.mile_30days.desc'), 30, _make_count_eval(_days_since_first_work, 30), TIER_SILVER, '๐Ÿ“š'), - ('mile_365days', '1์ฃผ๋…„', '365์ผ ์‚ฌ์šฉ', 365, + ('mile_365days', tr('achieve.mile_365days.name'), tr('achieve.mile_365days.desc'), 365, _make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '๐Ÿ’Ž'), - ('mile_730days', '2์ฃผ๋…„', '730์ผ ์‚ฌ์šฉ', 730, + ('mile_730days', tr('achieve.mile_730days.name'), tr('achieve.mile_730days.desc'), 730, _make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '๐ŸŒŸ'), - ('mile_1095days', '3์ฃผ๋…„', '3๋…„ ์‚ฌ์šฉ', 1095, + ('mile_1095days', tr('achieve.mile_1095days.name'), tr('achieve.mile_1095days.desc'), 1095, _make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '๐ŸŽ–๏ธ'), - ('mile_5years', '5๋…„ ์‚ฌ์šฉ์ž', '5๋…„ ์‚ฌ์šฉ', 1825, + ('mile_5years', tr('achieve.mile_5years.name'), tr('achieve.mile_5years.desc'), 1825, _make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '๐Ÿ†'), - ('mile_10years', '10๋…„ ์‚ฌ์šฉ์ž', '10๋…„ ์‚ฌ์šฉ', 3650, + ('mile_10years', tr('achieve.mile_10years.name'), tr('achieve.mile_10years.desc'), 3650, _make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '๐ŸŽ–๏ธ'), ] # ---- 13. ํ†ต๊ณ„ยท๋ถ„์„ (view counter ๊ธฐ๋ฐ˜) ---- _STATS_DEFS = [ - ('stat_weekly_10', '์ฃผ๊ฐ„ ํ†ต๊ณ„๋Ÿฌ', '์ฃผ๊ฐ„ ํƒญ 10ํšŒ ์กฐํšŒ', 10, + ('stat_weekly_10', tr('achieve.stat_weekly_10.name'), tr('achieve.stat_weekly_10.desc'), 10, _make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10), TIER_BRONZE, '๐Ÿ“Š'), - ('stat_monthly_10', '์›”๊ฐ„ ํ†ต๊ณ„๋Ÿฌ', '์›”๊ฐ„ ํƒญ 10ํšŒ', 10, + ('stat_monthly_10', tr('achieve.stat_monthly_10.name'), tr('achieve.stat_monthly_10.desc'), 10, _make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10), TIER_BRONZE, '๐Ÿ“ˆ'), - ('stat_pattern_10', 'ํŒจํ„ด ๋ถ„์„๊ฐ€', 'ํŒจํ„ด ํƒญ 10ํšŒ', 10, + ('stat_pattern_10', tr('achieve.stat_pattern_10.name'), tr('achieve.stat_pattern_10.desc'), 10, _make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10), TIER_SILVER, '๐Ÿ”'), - ('stat_calendar_30', '์บ˜๋ฆฐ๋” ์ฑ”ํ”„', '์บ˜๋ฆฐ๋” 30ํšŒ ์กฐํšŒ', 30, + ('stat_calendar_30', tr('achieve.stat_calendar_30.name'), tr('achieve.stat_calendar_30.desc'), 30, _make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30), TIER_SILVER, '๐Ÿ“…'), - ('stat_report_first', '์ผ์ผ ๋ณด๊ณ ์„œ ์ฒซ ์ƒ์„ฑ', '์ผ์ผ ๋ณด๊ณ  1ํšŒ', 1, + ('stat_report_first', tr('achieve.stat_report_first.name'), tr('achieve.stat_report_first.desc'), 1, _make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1), TIER_BRONZE, '๐Ÿ“‹'), - ('stat_report_30', '๋ณด๊ณ ์„œ ์ฑ”ํ”„', '์ผ์ผ ๋ณด๊ณ  30ํšŒ', 30, + ('stat_report_30', tr('achieve.stat_report_30.name'), tr('achieve.stat_report_30.desc'), 30, _make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30), TIER_SILVER, '๐Ÿ“ฐ'), - ('stat_chart_hover', '์ฐจํŠธ ํ˜ธ๋ฒ„ ๋ฐœ๊ฒฌ', '์ฐจํŠธ hover ์ฒซ ๋ฐœ๊ฒฌ', 1, + ('stat_chart_hover', tr('achieve.stat_chart_hover.name'), tr('achieve.stat_chart_hover.desc'), 1, _bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'), TIER_BRONZE, '๐ŸŽจ'), - ('stat_achievements_open', '๋„์ „๊ณผ์ œ ๋ฐ•๋ฌผ๊ด€', '๋„์ „๊ณผ์ œ ๋ทฐ 50ํšŒ', 50, + ('stat_achievements_open', tr('achieve.stat_achievements_open.name'), tr('achieve.stat_achievements_open.desc'), 50, _make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50), TIER_BRONZE, '๐Ÿฆ„'), ] @@ -957,23 +958,23 @@ def _has_500_anniv_clockin(db) -> bool: _SECRET_DEFS = [ - ('secret_palindrome', 'ํšŒ๋ฌธ ์‹œ๊ฐ', '์ถœ๊ทผ ์‹œ๊ฐ์ด ํšŒ๋ฌธ', 1, + ('secret_palindrome', tr('achieve.secret_palindrome.name'), tr('achieve.secret_palindrome.desc'), 1, _bool_eval(_has_clock_in_palindrome), TIER_GOLD, '๐Ÿชž'), - ('secret_jackpot', '์žญํŒŸ ์‹œ๊ฐ', '์ถœ๊ทผ ์‹œ๊ฐ ๋ชจ๋“  ์ž๋ฆฟ์ˆ˜ ๋™์ผ', 1, + ('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1, _bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '๐ŸŽฐ'), - ('secret_fri13', '13์ผ ๊ธˆ์š”์ผ', '13์ผ ๊ธˆ์š”์ผ ์ถœ๊ทผ', 1, + ('secret_fri13', tr('achieve.secret_fri13.name'), tr('achieve.secret_fri13.desc'), 1, _bool_eval(_has_friday_13th_clockin), TIER_GOLD, '๐ŸŒ‘'), - ('secret_777', '7-7-7', '7์›” 7์ผ 7์‹œ 7๋ถ„ ์ถœ๊ทผ', 1, + ('secret_777', tr('achieve.secret_777.name'), tr('achieve.secret_777.desc'), 1, _bool_eval(_has_777), TIER_LEGEND, '๐Ÿ”ฎ'), - ('secret_exact_8h', '์ •ํ™• 8์‹œ๊ฐ„', '์ •ํ™•ํžˆ 8h 0m ๊ทผ๋ฌด', 1, + ('secret_exact_8h', tr('achieve.secret_exact_8h.name'), tr('achieve.secret_exact_8h.desc'), 1, _bool_eval(_has_exact_8h), TIER_PLATINUM, '๐ŸŽฏ'), - ('secret_pi_day', 'ํŒŒ์ด ๋ฐ์ด', '3/14 01:59 ์ถœ๊ทผ', 1, + ('secret_pi_day', tr('achieve.secret_pi_day.name'), tr('achieve.secret_pi_day.desc'), 1, _bool_eval(_has_pi_day), TIER_LEGEND, '๐Ÿฅง'), - ('secret_fibonacci', 'ํ”ผ๋ณด๋‚˜์น˜', '์ถœ๊ทผ ๋ถ„์ด ํ”ผ๋ณด๋‚˜์น˜ ์ˆ˜', 1, + ('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1, _bool_eval(_has_fibonacci_minute), TIER_SILVER, '๐Ÿ”ข'), - ('secret_double_six', '๋”๋ธ” ์‹์Šค', '6/6 18:06 ์ถœ๊ทผ', 1, + ('secret_double_six', tr('achieve.secret_double_six.name'), tr('achieve.secret_double_six.desc'), 1, _bool_eval(_has_double_six), TIER_LEGEND, '๐ŸŽฒ'), - ('secret_anniversary', '๋งˆ๋ฒ•์‚ฌ', '๊ฐ€์ž… ํ›„ ์ •ํ™•ํžˆ 365์ผ ํ›„ ์ถœ๊ทผ', 1, + ('secret_anniversary', tr('achieve.secret_anniversary.name'), tr('achieve.secret_anniversary.desc'), 1, _bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '๐Ÿง™'), ] @@ -984,30 +985,30 @@ def _setting_changed_from_default(db, key: str, default_value: str) -> bool: _SETTINGS_DEFS = [ - ('set_dark', '๋‹คํฌ ์‚ฌ์ด๋“œ', '๋‹คํฌ ํ…Œ๋งˆ 1ํšŒ ์‚ฌ์šฉ', 1, + ('set_dark', tr('achieve.set_dark.name'), tr('achieve.set_dark.desc'), 1, _bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')), TIER_BRONZE, '๐ŸŒ—'), - ('set_lang', '์ด์ค‘์–ธ์–ด', '์–ธ์–ด ๋ณ€๊ฒฝ (en ์‚ฌ์šฉ)', 1, + ('set_lang', tr('achieve.set_lang.name'), tr('achieve.set_lang.desc'), 1, _bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'), TIER_BRONZE, '๐ŸŒ'), - ('set_a11y', '์ ‘๊ทผ์„ฑ ํ™œ์šฉ', '๊ธ€๊ผด ํฌ๊ธฐโ‰ 100% ๋˜๋Š” ๊ณ ๋Œ€๋น„ ON', 1, + ('set_a11y', tr('achieve.set_a11y.name'), tr('achieve.set_a11y.desc'), 1, _bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0' or db.get_setting('high_contrast', 'false').lower() == 'true'), TIER_BRONZE, 'โ™ฟ'), - ('set_overtime_unit', '๋‹จ์œ„ ๋ณ€๊ฒฝ', 'overtime_unit ๋ณ€๊ฒฝ', 1, + ('set_overtime_unit', tr('achieve.set_overtime_unit.name'), tr('achieve.set_overtime_unit.desc'), 1, _bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'), TIER_BRONZE, 'โฑ๏ธ'), - ('set_goal_full', '๋ชฉํ‘œ ๋งˆ์Šคํ„ฐ', '์›” ์—ฐ์žฅ+์ผํ‰๊ท  ๋‘˜ ๋‹ค ์„ค์ •', 1, + ('set_goal_full', tr('achieve.set_goal_full.name'), tr('achieve.set_goal_full.desc'), 1, _bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0 and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0), TIER_SILVER, '๐ŸŽฏ'), - ('set_discord_full', 'ํ’€ ์…‹์—…', 'Discord URL + ๋ชจ๋“  ์•Œ๋ฆผ ON', 1, + ('set_discord_full', tr('achieve.set_discord_full.name'), tr('achieve.set_discord_full.desc'), 1, _bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '') and all(db.get_setting(k, 'true').lower() == 'true' for k in ('notification_clock_out', 'notification_lunch', 'notification_overtime', 'notification_health'))), TIER_SILVER, '๐Ÿ””'), - ('set_cloud', 'ํด๋ผ์šฐ๋“œ ๋™๊ธฐํ™”', 'DB ๊ฒฝ๋กœ ๋ณ€๊ฒฝ', 1, + ('set_cloud', tr('achieve.set_cloud.name'), tr('achieve.set_cloud.desc'), 1, _bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')), TIER_SILVER, 'โ˜๏ธ'), ] @@ -1032,21 +1033,21 @@ def _earned_secret_count(db) -> int: _META_DEFS = [ - ('meta_first', '์ฒซ ๋„์ „๊ณผ์ œ', '์ฒซ ๋„์ „๊ณผ์ œ ํš๋“', 1, + ('meta_first', tr('achieve.meta_first.name'), tr('achieve.meta_first.desc'), 1, _make_count_eval(_earned_count, 1), TIER_BRONZE, '๐Ÿ†'), - ('meta_10', '10๊ฐœ ๋‹ฌ์„ฑ', '10๊ฐœ ๋ณด์œ ', 10, + ('meta_10', tr('achieve.meta_10.name'), tr('achieve.meta_10.desc'), 10, _make_count_eval(_earned_count, 10), TIER_BRONZE, '๐ŸŽ–๏ธ'), - ('meta_25', '25๊ฐœ ๋‹ฌ์„ฑ', '25๊ฐœ ๋ณด์œ ', 25, + ('meta_25', tr('achieve.meta_25.name'), tr('achieve.meta_25.desc'), 25, _make_count_eval(_earned_count, 25), TIER_SILVER, '๐Ÿฅˆ'), - ('meta_50', '50๊ฐœ ๋‹ฌ์„ฑ', '50๊ฐœ ๋ณด์œ ', 50, + ('meta_50', tr('achieve.meta_50.name'), tr('achieve.meta_50.desc'), 50, _make_count_eval(_earned_count, 50), TIER_GOLD, '๐Ÿฅ‡'), - ('meta_75', '75๊ฐœ ๋‹ฌ์„ฑ', '75๊ฐœ ๋ณด์œ ', 75, + ('meta_75', tr('achieve.meta_75.name'), tr('achieve.meta_75.desc'), 75, _make_count_eval(_earned_count, 75), TIER_PLATINUM, '๐Ÿ’Ž'), - ('meta_100', '100๊ฐœ ๋‹ฌ์„ฑ', '100๊ฐœ ๋ณด์œ ', 100, + ('meta_100', tr('achieve.meta_100.name'), tr('achieve.meta_100.desc'), 100, _make_count_eval(_earned_count, 100), TIER_LEGEND, '๐ŸŒŸ'), - ('meta_secret_1', '์‹œํฌ๋ฆฟ ๋ฐœ๊ฒฌ', '์ฒซ ์‹œํฌ๋ฆฟ ๋ฐœ๊ฒฌ', 1, + ('meta_secret_1', tr('achieve.meta_secret_1.name'), tr('achieve.meta_secret_1.desc'), 1, _make_count_eval(_earned_secret_count, 1), TIER_SILVER, '๐Ÿ”'), - ('meta_secret_5', '์‹œํฌ๋ฆฟ ํ—Œํ„ฐ', '์‹œํฌ๋ฆฟ 5๊ฐœ ๋ฐœ๊ฒฌ', 5, + ('meta_secret_5', tr('achieve.meta_secret_5.name'), tr('achieve.meta_secret_5.desc'), 5, _make_count_eval(_earned_secret_count, 5), TIER_GOLD, '๐ŸŒ‘'), ] diff --git a/core/database.py b/core/database.py index d08e5ae..42badc8 100644 --- a/core/database.py +++ b/core/database.py @@ -12,6 +12,7 @@ from core.settings_keys import ( WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, ) +from utils.debug_log import dlog class Database: @@ -53,8 +54,8 @@ class Database: finally: try: conn.close() - except Exception: - pass + except Exception as e: + dlog(f"connection close failed: {e}") def _enable_concurrency(self): """WAL ๋ชจ๋“œ ํ™œ์„ฑํ™” โ€” ๋™์‹œ ์ฝ๊ธฐ + ์“ฐ๊ธฐ ๊ฐ€๋Šฅ, ํด๋ผ์šฐ๋“œ ๋™๊ธฐํ™” ์นœํ™”.""" @@ -118,7 +119,8 @@ class Database: sentinel = self.get_setting('holidays_synced_date', '') if sentinel == today: return - except Exception: + except Exception as e: + dlog(f"holiday sync precheck failed: {e}") return cur_year = _dt.now().year @@ -131,8 +133,8 @@ class Database: added = db.add_korean_holidays_auto(cur_year, include_next_year=True) if added >= 0: db.set_setting('holidays_synced_date', today) - except Exception: - pass + except Exception as e: + dlog(f"holiday sync worker failed: {e}") t = threading.Thread(target=_worker, daemon=True, name='holiday-sync') t.start() @@ -1070,7 +1072,8 @@ class Database: target = _dt.strptime(date_str, '%Y-%m-%d').date() recs = self.get_recurring_leaves(active_on=date_str) recurring_days = sum(o.days for o in expand_for_date(recs, target)) - except Exception: + except Exception as e: + dlog(f"recurring leave expansion failed: {e}") recurring_days = 0.0 return concrete_days + recurring_days @@ -1356,17 +1359,21 @@ class Database: for row in rows: key = row['key'] value = row['value'] - # ํƒ€์ž… ๋ณ€ํ™˜ - if value.lower() in ['true', 'false']: - settings[key] = value.lower() == 'true' - else: + # ํƒ€์ž… ๋ณ€ํ™˜ (bool ์šฐ์„ , ์ˆซ์ž, ๋ฌธ์ž์—ด ์ˆœ) + lower = (value or '').lower() + if lower in ('1', 'true', 'yes', 'on'): + settings[key] = True + continue + if lower in ('0', 'false', 'no', 'off', ''): + settings[key] = False + continue + try: + settings[key] = int(value) + except ValueError: try: - settings[key] = int(value) + settings[key] = float(value) except ValueError: - try: - settings[key] = float(value) - except ValueError: - settings[key] = value + settings[key] = value return settings def save_settings(self, settings: Dict): @@ -1388,7 +1395,8 @@ class Database: pass elif 'work_hours' in synced and 'work_minutes' not in synced: try: - synced['work_minutes'] = int(round(float(synced['work_hours']) * 60)) + # ์€ํ–‰ ๋ฐ˜์˜ฌ๋ฆผ ํšŒํ”ผ: ๋ช…์‹œ์  ๋ฐ˜์˜ฌ๋ฆผ + synced['work_minutes'] = int(float(synced['work_hours']) * 60 + 0.5) except (ValueError, TypeError): pass @@ -1674,7 +1682,8 @@ class Database: ''', (date, leave_type, days, memo)) conn.commit() # ์ž”์—ฌ ๊ฐœ์ˆ˜ ์ฐจ๊ฐ (๋ณ„๋„ ํŠธ๋žœ์žญ์…˜ โ€” set_leave_balance ๋‚ด๋ถ€ commit) - self.set_leave_balance(current_balance - days) + # get_leave_balance()๊ฐ€ leave_records๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ณ„์‚ฐํ•˜๋ฏ€๋กœ + # ๋ณ„๋„๋กœ leave_balance ์„ค์ •๊ฐ’์„ ๊ฐฑ์‹ ํ•  ํ•„์š”๊ฐ€ ์—†์Œ. # ===== ๊ณตํœด์ผ ๊ด€๋ จ ๋ฉ”์„œ๋“œ ===== @@ -1838,7 +1847,8 @@ class Database: try: import holidays as _holidays kr = _holidays.country_holidays('KR', years=y) - except Exception: + except Exception as e: + dlog(f"holidays package fallback failed for {y}: {e}") continue # ๋‘˜ ๋‹ค ์‹คํŒจ๋ฉด ํ•ด๋‹น ์—ฐ๋„๋งŒ ์Šคํ‚ต for d, name in kr.items(): date_str = d.isoformat() diff --git a/core/i18n.py b/core/i18n.py index 96d9d3a..26a5dda 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -54,7 +54,8 @@ _DICT = { 'label.remaining': '๋‚จ์€ ์‹œ๊ฐ„', 'label.overtime_progress': '์ถ”๊ฐ€ ๊ทผ๋ฌด ์ค‘', 'label.expected_clock_out': '์˜ˆ์ƒ ํ‡ด๊ทผ', - 'label.clock_in_time': '์ถœ๊ทผ ์‹œ๊ฐ„', + 'label.clock_in_time': '์ถœ๊ทผ', + 'label.current_time': 'ํ˜„์žฌ', 'label.clock_out_time': 'ํ‡ด๊ทผ ์‹œ๊ฐ„', 'label.work_hours_label': 'ํ•˜๋ฃจ ๊ธฐ๋ณธ ๊ทผ๋ฌด:', 'label.lunch_default': '์ ์‹ฌ์‹œ๊ฐ„ ๊ธฐ๋ณธ:', @@ -76,6 +77,7 @@ _DICT = { 'label.weekday_sun': '์ผ', 'label.am': '์˜ค์ „', 'label.pm': '์˜คํ›„', + 'label.recurring_yearly': ' (๋งค๋…„)', # === ์•Œ๋ฆผ === 'notif.clock_out_soon.title': 'โฐ ํ‡ด๊ทผ ์‹œ๊ฐ„ ์ž„๋ฐ•', @@ -108,6 +110,16 @@ _DICT = { 'notif.health.body': '{days}์ผ ์—ฐ์† ์—ฐ์žฅ๊ทผ๋ฌด ์ค‘์ž…๋‹ˆ๋‹ค.\n๊ฑด๊ฐ•์„ ์ฑ™๊ธฐ์„ธ์š”!', 'notif.weekly_52.title': '๐Ÿšจ ์ฃผ 52์‹œ๊ฐ„ ์ดˆ๊ณผ', 'notif.weekly_52.body': '์ด๋ฒˆ ์ฃผ ์ด ๊ทผ๋ฌด์‹œ๊ฐ„์ด {hours:.1f}์‹œ๊ฐ„์ž…๋‹ˆ๋‹ค.\n๋ฒ•์ • ๊ทผ๋กœ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค!', + 'notif.weekly_report.title': '๐Ÿ“Š ์ง€๋‚œ์ฃผ ์š”์•ฝ', + 'notif.weekly_report.body': '๊ธฐ๊ฐ„: {start} ~ {end}\n์ด ๊ทผ๋ฌด: {total_h:.1f}์‹œ๊ฐ„ ({days}์ผ)\n์ผ ํ‰๊ท : {avg_h:.1f}์‹œ๊ฐ„\n์—ฐ์žฅ๊ทผ๋ฌด: {ot_h}์‹œ๊ฐ„ {ot_m}๋ถ„\n๊ฐ€์žฅ ๊ธด ๋‚ : {longest}', + 'field.total_work': '์ด ๊ทผ๋ฌด', + 'field.avg_daily': '์ผ ํ‰๊ท ', + 'field.overtime': '์—ฐ์žฅ๊ทผ๋ฌด', + 'field.longest_day': '๊ฐ€์žฅ ๊ธด ๋‚ ', + 'notif.achievement.title': '{icon} ๋„์ „๊ณผ์ œ ๋‹ฌ์„ฑ!', + 'notif.achievement.body': '{name}\n{description}', + 'discord.achievement.title': '๐Ÿ† ๋„์ „๊ณผ์ œ {count}๊ฐœ ๋‹ฌ์„ฑ!', + 'discord.achievement.body': '์ƒˆ๋กœ ์ž ๊ธˆ ํ•ด์ œ๋œ ๋„์ „๊ณผ์ œ ์ž…๋‹ˆ๋‹ค.{extra}', # === ๋ฉ”์‹œ์ง€๋ฐ•์Šค === 'msg.save_success.title': '์ €์žฅ ์™„๋ฃŒ', @@ -154,6 +166,49 @@ _DICT = { 'stats.pattern_insights': '๊ทผ๋ฌด ํŒจํ„ด ์ธ์‚ฌ์ดํŠธ', 'stats.analyzing': '๋ฐ์ดํ„ฐ๋ฅผ ๋ถ„์„ ์ค‘์ž…๋‹ˆ๋‹ค...', 'stats.no_data': '์•„์ง ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', + 'stats.total_work_hours': '์ด ๊ทผ๋ฌด ์‹œ๊ฐ„', + 'stats.card_work_days': '๊ทผ๋ฌด ์ผ์ˆ˜', + 'stats.card_avg_hours': '์ผํ‰๊ท ', + 'stats.card_overtime': '์—ฐ์žฅ๊ทผ๋ฌด', + 'stats.this_week': '์ด๋ฒˆ ์ฃผ', + 'stats.this_month': '์ด๋ฒˆ ๋‹ฌ', + 'stats.daily_work_hours': '์ผ๋ณ„ ๊ทผ๋ฌด ์‹œ๊ฐ„', + 'stats.weekday_avg': '์š”์ผ๋ณ„ ํ‰๊ท ', + 'stats.clock_in_distribution': '์ถœ๊ทผ ์‹œ๊ฐ ๋ถ„ํฌ', + 'stats.no_pattern_data': 'ํŒจํ„ด์„ ๋ถ„์„ํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.', + 'stats.value_hours': '{hours}์‹œ๊ฐ„', + 'stats.value_hours_minutes': '{hours}์‹œ๊ฐ„ {minutes}๋ถ„', + 'stats.value_days': '{days}์ผ', + 'stats.avg_clock_in': '๐Ÿ“Œ ํ‰๊ท  ์ถœ๊ทผ์‹œ๊ฐ„: {time}', + 'stats.overtime_frequency': '๐Ÿ“Œ ์—ฐ์žฅ๊ทผ๋ฌด ๋นˆ๋„: {rate}% ({days}/{total}์ผ)', + 'stats.longest_work': '๐Ÿ“Œ ์ตœ์žฅ ๊ทผ๋ฌด: {date} ({hours}์‹œ๊ฐ„)', + 'stats.consecutive_ot_warning': 'โš ๏ธ ์ตœ๊ทผ {days}์ผ ์—ฐ์† ์—ฐ์žฅ๊ทผ๋ฌด ๋ฐœ์ƒ!', + 'stats.weekly_52_exceeded': '๐Ÿšจ ์ฃผ 52์‹œ๊ฐ„ ์ดˆ๊ณผ: {hours}์‹œ๊ฐ„', + 'stats.salary_estimate': '๐Ÿ’ฐ ์ด๋ฒˆ ๋‹ฌ ์ถ”์ • ๊ธ‰์—ฌ: {total} (๊ธฐ๋ณธ {base} + ์—ฐ์žฅ {overtime})', + + # === ChartWidget === + 'chart.need_matplotlib': '์ฐจํŠธ ํ‘œ์‹œ์—๋Š” matplotlib๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.\npip install matplotlib', + 'chart.no_records': '๊ธฐ๋ก ์—†์Œ', + 'chart.label_normal': '์ •์ƒ', + 'chart.label_overtime': '์—ฐ์žฅ', + 'chart.ylabel_hours': '์‹œ๊ฐ„', + 'chart.ylabel_days': '์ผ์ˆ˜', + 'chart.ylabel_avg_hours': 'ํ‰๊ท  ์‹œ๊ฐ„', + 'chart.hover_text': 'โ–ผ {date}\n๊ทผ๋ฌด {hours}h', + 'chart.hover_overtime': '์—ฐ์žฅ +{hours}h', + 'chart.avg_line': 'ํ‰๊ท  {time}', + + # === ChartWidget === + 'chart.need_matplotlib': 'matplotlib is required to display charts.\npip install matplotlib', + 'chart.no_records': 'No records', + 'chart.label_normal': 'Normal', + 'chart.label_overtime': 'Overtime', + 'chart.ylabel_hours': 'Hours', + 'chart.ylabel_days': 'Days', + 'chart.ylabel_avg_hours': 'Avg hours', + 'chart.hover_text': 'โ–ผ {date}\nWork {hours}h', + 'chart.hover_overtime': 'OT +{hours}h', + 'chart.avg_line': 'Avg {time}', # === CalendarView === 'cal.title': '์›”๊ฐ„ ๊ทผ๋ฌด ์บ˜๋ฆฐ๋”', @@ -270,7 +325,877 @@ _DICT = { 'view.leave.added_body': '{days}์ผ ({hours}์‹œ๊ฐ„)์˜ ์—ฐ์ฐจ ์‚ฌ์šฉ์ด ๊ธฐ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'view.leave.error_title': '์˜ค๋ฅ˜', 'view.leave.error_body': '์—ฐ์ฐจ ๊ธฐ๋ก ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{err}', - }, + + # === ๋ฉ”์ธ ์œˆ๋„์šฐ ์ถ”๊ฐ€ === + 'app.title': 'ํ‡ด๊ทผ์‹œ๊ฐ„ ๊ณ„์‚ฐ๊ธฐ', + 'group.today_work': '์˜ค๋Š˜์˜ ๊ทผ๋ฌด', + 'group.remaining_time': '๋‚จ์€ ์‹œ๊ฐ„', + 'group.overtime_leave': '์—ฐ์žฅ๊ทผ๋ฌด ๋ฐ ์—ฐ์ฐจ ํ˜„ํ™ฉ', + 'tooltip.meal_click': '์ขŒํด๋ฆญ: ํ† ๊ธ€ / ์šฐํด๋ฆญ: ์‹ค์ œ ์‹œ๊ฐ„ ์ž…๋ ฅ', + 'tooltip.clock_in_edit': 'ํด๋ฆญํ•˜์—ฌ ์ถœ๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •', + 'btn.achievements': '๋„์ „๊ณผ์ œ', + 'btn.break_manage': '์™ธ์ถœ ๊ด€๋ฆฌ', + 'section.overtime_earned': '์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ', + 'section.leave': '์—ฐ์ฐจ', + 'section.total_time': '์ด ๋ณด์œ  ์‹œ๊ฐ„', + 'btn.use_30min': '30๋ถ„', + 'btn.use_1hour': '1์‹œ๊ฐ„', + 'btn.use_2hour': '2์‹œ๊ฐ„', + 'btn.custom_input': '์ง์ ‘์ž…๋ ฅ', + 'btn.detail': '์ƒ์„ธ', + 'btn.half_leave': '๋ฐ˜์ฐจ', + 'btn.full_leave': '์—ฐ์ฐจ', + 'msg.auto_clock_in.title': '์ž๋™ ์ถœ๊ทผ ๊ฐ์ง€', + 'msg.auto_clock_in.body': '์ถœ๊ทผ ์‹œ๊ฐ„์ด ์ž๋™์œผ๋กœ ๊ฐ์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์ถœ๊ทผ: {time}\n\n์ž˜๋ชป๋œ ๊ฒฝ์šฐ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + 'msg.manual_clock_in.title': '์ถœ๊ทผ ์‹œ๊ฐ„ ์ž…๋ ฅ', + 'msg.manual_clock_in.body': '์ถœ๊ทผ ์‹œ๊ฐ„์„ ์ž๋™์œผ๋กœ ๊ฐ์ง€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.\n\n์ˆ˜๋™์œผ๋กœ ์ž…๋ ฅํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n(๊ด€๋ฆฌ์ž ๊ถŒํ•œ์œผ๋กœ ์‹คํ–‰ํ•˜๋ฉด ์ž๋™ ๊ฐ์ง€๋ฉ๋‹ˆ๋‹ค)', + 'msg.full_day_leave.title': '์ข…์ผ ์—ฐ์ฐจ ๋“ฑ๋ก๋จ', + 'msg.full_day_leave.body': '์˜ค๋Š˜์€ ์ข…์ผ ์—ฐ์ฐจ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.\n๊ทธ๋ž˜๋„ ์ถœ๊ทผํ•˜์‹œ๊ฒ ์–ด์š”?\n\n(์ถœ๊ทผ ์‹œ ๋ชจ๋“  ์‹œ๊ฐ„์ด ์—ฐ์žฅ๊ทผ๋ฌด๋กœ ์ ๋ฆฝ๋ฉ๋‹ˆ๋‹ค.)', + 'label.full_day_leave_override': '์—ฐ์ฐจ override (์ „์ฒด ์ ๋ฆฝ)', + 'label.weekend_work': '์ฃผ๋ง ๊ทผ๋ฌด (์ „์ฒด ์ ๋ฆฝ)', + 'label.holiday_work': '๊ณตํœด์ผ ๊ทผ๋ฌด (์ „์ฒด ์ ๋ฆฝ)', + 'label.holiday_work_no_clock_out': 'ํœด์ผ ๊ทผ๋ฌด (์ •ํ•ด์ง„ ํ‡ด๊ทผ์‹œ๊ฐ ์—†์Œ)', + 'label.holiday_default': '๊ณตํœด์ผ', + 'label.expected_clock_out_prefix': '์˜ˆ์ƒ ํ‡ด๊ทผ: ', + 'label.total_work_hours': '์ด ๊ทผ๋ฌด์‹œ๊ฐ„: {hours:.1f}์‹œ๊ฐ„', + 'label.weekend_work_tag': '[์ฃผ๋ง ๊ทผ๋ฌด]', + 'label.holiday_work_tag': '[๊ณตํœด์ผ ๊ทผ๋ฌด - {name}]', + 'label.overtime_earned_msg': '์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ: {time} (๐Ÿ•ร—{tokens})', + 'label.full_earned_msg': '์ „์ฒด ์ ๋ฆฝ: {time} (๐Ÿ•ร—{tokens})', + 'label.full_day_leave_today': '์˜ค๋Š˜์€ ํœด๊ฐ€', + 'label.full_day_leave_in_use': '์—ฐ์ฐจ ์‚ฌ์šฉ ์ค‘', + 'label.full_day_leave_format': '{type} โ€” {memo}', + 'label.vacation': '๐ŸŒด ํœด๊ฐ€', + 'label.time_hours_minutes': '{hours}์‹œ๊ฐ„ {minutes}๋ถ„', + 'time_format.12h': '{period} {hour}:{minute}', + 'break.status_in_progress': '์™ธ์ถœ ์ค‘ ({time}๋ถ€ํ„ฐ)', + 'break.status_total_hours_minutes': '์˜ค๋Š˜ ์ด ์™ธ์ถœ: {hours}์‹œ๊ฐ„ {minutes}๋ถ„', + 'break.status_total_minutes': '์˜ค๋Š˜ ์ด ์™ธ์ถœ: {minutes}๋ถ„', + 'break.reason.lock': 'ํ™”๋ฉด ์ž ๊ธˆ', + 'break.cannot_no_clock_in.title': '์™ธ์ถœ ๋ถˆ๊ฐ€', + 'break.cannot_no_clock_in.body': '์ถœ๊ทผํ•˜์ง€ ์•Š์€ ์ƒํƒœ์ž…๋‹ˆ๋‹ค.', + 'break.cannot_already_on_break.body': '์ด๋ฏธ ์™ธ์ถœ ์ค‘์ž…๋‹ˆ๋‹ค.', + 'break.cannot_no_record.body': '์ถœ๊ทผ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + 'break.started.title': '์™ธ์ถœ', + 'break.started.body': '์™ธ์ถœ ์‹œ๊ฐ„: {time}', + 'break.cannot_return_no_active.body': '์ง„ํ–‰ ์ค‘์ธ ์™ธ์ถœ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + 'break.cannot_return_corrupt.body': '์™ธ์ถœ ์‹œ๊ฐ„ ๊ธฐ๋ก์ด ์†์ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'break.cannot_return_unusable.body': '์™ธ์ถœ ์‹œ๊ฐ„ ๊ธฐ๋ก์ด ์†์ƒ๋˜์–ด ๋ณต๊ท€ ์ฒ˜๋ฆฌ๋ฅผ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + 'break.return.title': '๋ณต๊ท€', + 'break.return.body': '๋ณต๊ท€ ์‹œ๊ฐ„: {time}\n์™ธ์ถœ ์‹œ๊ฐ„: {minutes}๋ถ„', + 'mini.open_main': '๋ฉ”์ธ ์ฐฝ ์—ด๊ธฐ', + 'mini.close': '๋ฏธ๋‹ˆ ์œ„์ ฏ ๋‹ซ๊ธฐ', + 'msg.clock_out_confirm.title': 'ํ‡ด๊ทผ ํ™•์ธ', + 'msg.clock_out_confirm.body': 'ํ‡ด๊ทผ ์ฒ˜๋ฆฌํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nํ‡ด๊ทผ ์‹œ๊ฐ„: {time}', + 'msg.auto_overtime_confirm.title': '์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ํ™•์ธ', + 'msg.auto_overtime_confirm.body': '์—ฐ์žฅ๊ทผ๋ฌด {actual} ๋ฐœ์ƒ, {earned} ์ ๋ฆฝ ๋Œ€์ƒ์ž…๋‹ˆ๋‹ค.\n\n์ ๋ฆฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n(์•„๋‹ˆ์˜ค ์„ ํƒ ์‹œ ์ด๋ฒˆ ํ‡ด๊ทผ๋ถ„์€ ์ ๋ฆฝ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค)', + 'msg.clock_out_done.title': 'ํ‡ด๊ทผ ์™„๋ฃŒ', + 'msg.clock_out_done.body': 'ํ‡ด๊ทผ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!\n\n{type_info}{total_work}\n{overtime_info}', + 'msg.cancel_clock_out_confirm.body': 'ํ‡ด๊ทผ์„ ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nํ‡ด๊ทผ ์‹œ๊ฐ„๊ณผ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ๋‚ด์—ญ์ด ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.', + 'msg.cancel_clock_out_done.title': 'ํ‡ด๊ทผ ์ทจ์†Œ ์™„๋ฃŒ', + 'msg.cancel_clock_out_done.body': 'ํ‡ด๊ทผ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n๋‹ค์‹œ ๊ทผ๋ฌด ์ค‘ ์ƒํƒœ๋กœ ์ „ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'msg.cancel_clock_out_fail.title': '์ทจ์†Œ ์‹คํŒจ', + 'msg.cancel_clock_out_fail.body': 'ํ‡ด๊ทผ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + 'msg.error.title': '์˜ค๋ฅ˜', + 'msg.error.body': '{action} ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{error}', + 'msg.input.title': '์‹œ๊ฐ„ ์ž…๋ ฅ', + 'msg.input_error.date_format': '๋‚ ์งœ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์˜ฌ๋ฐ”๋ฅธ ํ˜•์‹: YYYY-MM-DD (์˜ˆ: 2024-01-15)', + 'msg.input_error.overtime_unit': '30๋ถ„ ๋‹จ์œ„๋กœ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.\n์˜ˆ) 0.5์‹œ๊ฐ„, 1์‹œ๊ฐ„, 1.5์‹œ๊ฐ„', + 'msg.overtime_use.title': '์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ', + 'msg.overtime_use_minus.title': '์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ (๋งˆ์ด๋„ˆ์Šค ์ „ํ™˜)', + 'msg.overtime_use.body': '{minutes}๋ถ„์˜ ์—ฐ์žฅ๊ทผ๋ฌด๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nํ˜„์žฌ ์ž”์•ก: {balance}๋ถ„\n์‚ฌ์šฉ ํ›„ ์ž”์•ก: {new_balance}๋ถ„', + 'msg.overtime_use_minus.body': '{minutes}๋ถ„์˜ ์—ฐ์žฅ๊ทผ๋ฌด๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nํ˜„์žฌ ์ž”์•ก: {balance}๋ถ„\n์‚ฌ์šฉ ํ›„ ์ž”์•ก: {new_balance}๋ถ„ (๋งˆ์ด๋„ˆ์Šค)\n\nโš ๏ธ ์ž”์•ก์ด ๋งˆ์ด๋„ˆ์Šค๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.\n๋‚˜์ค‘์— ์ดˆ๊ณผ๊ทผ๋ฌด๋กœ ๊ฐš์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค.', + 'msg.overtime_use_done.title': '์‚ฌ์šฉ ์™„๋ฃŒ', + 'msg.overtime_use_done.body': '{minutes}๋ถ„์ด ์‚ฌ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'msg.overtime_use_fail.title': '์‚ฌ์šฉ ์‹คํŒจ', + 'msg.overtime_input.body': '์‚ฌ์šฉํ•  ์‹œ๊ฐ„์„ ์ž…๋ ฅํ•˜์„ธ์š” (0.5์‹œ๊ฐ„ ๋‹จ์œ„):\n์˜ˆ) 0.5, 1, 1.5, 2, 3, 4', + 'msg.leave_use.title': '์—ฐ์ฐจ ์‚ฌ์šฉ', + 'msg.leave_use_date.title': '์—ฐ์ฐจ ์‚ฌ์šฉ ๋‚ ์งœ', + 'msg.leave_use_date.body': '์‚ฌ์šฉ ๋‚ ์งœ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (YYYY-MM-DD):', + 'msg.leave_use_reason.title': '์—ฐ์ฐจ ์‚ฌ์œ ', + 'msg.leave_use_reason.body': '์‚ฌ์œ ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (์„ ํƒ):', + 'msg.leave_use_confirm.body': '{date}์— {type} {days}๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n์‚ฌ์šฉ ํ›„ ์ž”์•ก: {balance_after}์ผ', + 'msg.leave_use_done.title': '์‚ฌ์šฉ ์™„๋ฃŒ', + 'msg.leave_use_done.body': '{type}๊ฐ€ ์‚ฌ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'msg.leave_use_impossible.title': '์‚ฌ์šฉ ๋ถˆ๊ฐ€', + 'msg.leave_short.title': '์ž”์•ก ๋ถ€์กฑ', + 'msg.leave_short.body': '์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—ฐ์ฐจ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.\nํ˜„์žฌ ์ž”์•ก: {balance}์ผ\n์š”์ฒญ: {days}์ผ', + 'msg.leave_short_hours.body': '์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—ฐ์ฐจ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.\nํ˜„์žฌ ์ž”์•ก: {balance}์ผ ({balance_hours}์‹œ๊ฐ„)\n์š”์ฒญ: {days}์ผ ({hours}์‹œ๊ฐ„)', + 'msg.settings_updated.title': '์„ค์ • ์—…๋ฐ์ดํŠธ', + 'msg.settings_updated.body': '๋ณ€๊ฒฝ๋œ ์„ค์ •์ด ์ฆ‰์‹œ ๋ฐ˜์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'msg.meal_need_clock_in.title': '์ถœ๊ทผ ํ•„์š”', + 'msg.meal_need_clock_in.body': '์ถœ๊ทผ ํ›„์—๋งŒ ์‹์‚ฌ ์‹œ๊ฐ„์„ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + 'msg.meal_recorded.title': '๊ธฐ๋ก ์™„๋ฃŒ', + 'msg.meal_recorded.body': '{meal} {minutes}๋ถ„ ๊ธฐ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n({start} ~ {end})', + 'msg.meal_actual_input': '{meal} ์‹ค์ œ ์‹œ๊ฐ„ ์ž…๋ ฅ...', + 'msg.workday_boundary.title': '๊ทผ๋ฌด์ผ ๊ฒฝ๊ณ„ ๊ฒฝ๊ณผ', + 'msg.workday_boundary.body': '๊ทผ๋ฌด์ผ ๊ฒฝ๊ณ„ ์‹œ๊ฐ„({hour}์‹œ)์ด ์ง€๋‚˜ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n์ „๋‚  ๊ทผ๋ฌด: {before} ํ‡ด๊ทผ ์ฒ˜๋ฆฌ\n๊ธˆ์ผ ๊ทผ๋ฌด: {boundary} ์ถœ๊ทผ ์ฒ˜๋ฆฌ\n\n์ž์ •~{hour}์‹œ ์ „๊นŒ์ง€์˜ ์•ผ๊ทผ์€ ์ „๋‚  ์ดˆ๊ณผ๊ทผ๋ฌด๋กœ ์ธ์ •๋ฉ๋‹ˆ๋‹ค.', + 'msg.new_workday.title': '์ƒˆ ๊ทผ๋ฌด์ผ', + 'msg.new_workday.body': '์ƒˆ๋กœ์šด ๊ทผ๋ฌด์ผ์ž…๋‹ˆ๋‹ค. ({date})\n\n์ถœ๊ทผ ์ฒ˜๋ฆฌ๋ฅผ ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', + 'msg.clock_in_set.title': '์ถœ๊ทผ ์‹œ๊ฐ„ ์„ค์ •', + 'msg.clock_in_set.body': '์ถœ๊ทผ ์‹œ๊ฐ„์ด ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n์ถœ๊ทผ: {time}', + 'leave.type.annual': '์—ฐ์ฐจ', + 'leave.type.sick': '๋ณ‘๊ฐ€', + 'leave.type.hourly_leave': '์‹œ๊ฐ„์—ฐ์ฐจ', + 'leave.use.full_day': '1์ผ', + 'leave.use.half_day': '0.5์ผ (4์‹œ๊ฐ„)', + 'leave.use.hour_1': '0.125์ผ (1์‹œ๊ฐ„)', + 'leave.use.min_30': '0.0625์ผ (30๋ถ„)', + 'leave.use.custom': '{days}์ผ ({hours}์‹œ๊ฐ„)', + 'leave.type.half_am': '์˜ค์ „ ๋ฐ˜์ฐจ', + 'leave.type.half_pm': '์˜คํ›„ ๋ฐ˜์ฐจ', + 'leave.type.time_off': '์‹œ๊ฐ„ ์—ฐ์ฐจ', + 'leave.type.half': '๋ฐ˜์ฐจ', + 'leave.type.quarter': '๋ฐ˜๋ฐ˜์ฐจ', + 'report.leave_used': '๐ŸŒด ์—ฐ์ฐจ ์‚ฌ์šฉ: {value}', + 'report.leave_used_days_hours': '{days}์ผ {hours}์‹œ๊ฐ„', + 'report.leave_used_days': '{days}์ผ', + 'report.leave_used_hours': '{hours}์‹œ๊ฐ„', + 'report.leave_detail': ' - {type}: {time}{memo}', + 'report.title': '๐Ÿ“‹ ์ผ์ผ ๊ทผ๋ฌด ๋ณด๊ณ ์„œ - {date}', + 'report.clock_in': '๐Ÿ• ์ถœ๊ทผ ์‹œ๊ฐ„: {time}', + 'report.clock_out': '๐Ÿ• ํ‡ด๊ทผ ์‹œ๊ฐ„: {time}', + 'report.not_clocked_out': '๐Ÿ• ํ‡ด๊ทผ ์‹œ๊ฐ„: ๋ฏธํ‡ด๊ทผ', + 'report.total_work': 'โฑ๏ธ ์ด ๊ทผ๋ฌด: {time}', + 'report.break_time': '๐Ÿšถ ์™ธ์ถœ ์‹œ๊ฐ„: {time}', + 'report.break_detail': ' - {start} ~ {end} ({duration}๋ถ„){reason}', + 'report.break_in_progress': ' - {start} ~ ๋ณต๊ท€์ค‘{reason}', + 'report.meal_actual': '{label}: {time} (์‹ค์ธก)', + 'report.meal_in_progress': ' - {start} ~ ์ง„ํ–‰์ค‘', + 'report.meal_default': '{label}: ํฌํ•จ ({time})', + 'report.memo': '๐Ÿ“ ๋ฉ”๋ชจ: {memo}', + 'report.copied.title': '๋ณด๊ณ ์„œ ๋ณต์‚ฌ ์™„๋ฃŒ', + 'report.copied.body': '์ผ์ผ ๊ทผ๋ฌด ๋ณด๊ณ ์„œ๊ฐ€ ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n{report}', + 'label.lunch': '๐Ÿฑ ์ ์‹ฌ์‹œ๊ฐ„', + 'label.lunch_short': '์ ์‹ฌ', + 'label.dinner': '๐Ÿฝ๏ธ ์ €๋…์‹œ๊ฐ„', + 'label.dinner_short': '์ €๋…', + 'label.overtime_balance_zero': '0๋ถ„ (ร—0)', + 'label.leave_balance_zero': '์ž”์—ฌ: 0์ผ', + 'label.today_estimate': '์˜ค๋Š˜ ์ถ”์ •: {amount}', + 'label.in_progress': '์ง„ํ–‰์ค‘', + 'label.break_returning': '๋ณต๊ท€์ค‘', + 'label.meal_actual_suffix': '์‹ค์ธก', + 'label.meal_included': 'ํฌํ•จ', + 'report.overtime_occurred': 'โฐ ์ถ”๊ฐ€ ๊ทผ๋ฌด ๋ฐœ์ƒ: {time}', + 'report.overtime_banked': ' ๐Ÿ’ฐ ์ ๋ฆฝ: {time} (30๋ถ„ ๋‹จ์œ„ ์ ˆ์‚ญ)', + 'report.overtime_used': '๐Ÿ• ์ถ”๊ฐ€ ๊ทผ๋ฌด ์‚ฌ์šฉ: {time}', + 'report.overtime_used_detail': ' - {time}{reason}', + 'update.new_version_title': '์ƒˆ ๋ฒ„์ „ ๋ฐœ๊ฒฌ', + 'update.apply_failed_title': '์—…๋ฐ์ดํŠธ ์‹คํŒจ', + 'update.check_title': '์—…๋ฐ์ดํŠธ ํ™•์ธ', + 'update.up_to_date': 'ํ˜„์žฌ ์ตœ์‹  ๋ฒ„์ „์ž…๋‹ˆ๋‹ค (v{version}).', + 'update.network_error': '์—…๋ฐ์ดํŠธ ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n๋„คํŠธ์›Œํฌ ์ƒํƒœ๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.', + 'update.no_release': '์—…๋ฐ์ดํŠธ ์ €์žฅ์†Œ์—์„œ ๋ฆด๋ฆฌ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n(์ €์žฅ์†Œ ๋น„๊ณต๊ฐœ ๋˜๋Š” ์ฒซ ๋ฆด๋ฆฌ์Šค ์ „)', + 'update.no_asset': '์ƒˆ ๋ฒ„์ „์€ ์žˆ์ง€๋งŒ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•œ main.exe ์ž์‚ฐ์ด ์—†์Šต๋‹ˆ๋‹ค.\n๊ด€๋ฆฌ์ž์—๊ฒŒ ๋ฌธ์˜ํ•˜์„ธ์š”.', + 'update.unknown': '์•Œ ์ˆ˜ ์—†๋Š” ์‘๋‹ต์ž…๋‹ˆ๋‹ค.', + 'update.new_found_dev': '์ƒˆ ๋ฒ„์ „ {version}์ด ์žˆ์Šต๋‹ˆ๋‹ค.\n(๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์ž๋™ ์ ์šฉ ๋ถˆ๊ฐ€ โ€” git pull ๋˜๋Š” ๋นŒ๋“œ ํ›„ ์‚ฌ์šฉ)', + 'update.new_found': 'ํ˜„์žฌ: v{current}\n์ƒˆ ๋ฒ„์ „: v{new}\n\n๋ฆด๋ฆฌ์Šค ๋…ธํŠธ:\n{notes}\n\n์ง€๊ธˆ ๋‹ค์šด๋กœ๋“œ ํ›„ ์—…๋ฐ์ดํŠธํ• ๊นŒ์š”?', + 'update.downloading': '๋‹ค์šด๋กœ๋“œ ์ค‘...', + 'update.download_title': '์—…๋ฐ์ดํŠธ ๋‹ค์šด๋กœ๋“œ', + 'update.download_failed': '์ƒˆ ๋ฒ„์ „ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'update.updater_failed': 'updater.exe๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†๊ฑฐ๋‚˜ ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'update.restart': '์—…๋ฐ์ดํŠธ ์ ์šฉ์„ ์œ„ํ•ด ํ”„๋กœ๊ทธ๋žจ์ด ์ข…๋ฃŒ๋ฉ๋‹ˆ๋‹ค.', + 'settings.title': '์„ค์ •', + 'settings.work_pattern': '๊ทผ๋ฌด ํŒจํ„ด:', + 'settings.preset.standard_8h': 'ํ‘œ์ค€ 8์‹œ๊ฐ„ (์ ์‹ฌ 60๋ถ„)', + 'settings.preset.short_7h30m': '๋‹จ์ถ•๊ทผ๋ฌด 7์‹œ๊ฐ„ 30๋ถ„ (์ ์‹ฌ 30๋ถ„)', + 'settings.preset.short_7h': '๋‹จ์ถ•๊ทผ๋ฌด 7์‹œ๊ฐ„ (์ ์‹ฌ 60๋ถ„)', + 'settings.preset.short_6h': '๋‹จ์ถ•๊ทผ๋ฌด 6์‹œ๊ฐ„ (์ ์‹ฌ 30๋ถ„)', + 'settings.preset.half_4h': '๋ฐ˜์ผ 4์‹œ๊ฐ„ (์ ์‹ฌ 0๋ถ„)', + 'settings.preset.custom': '์‚ฌ์šฉ์ž ์ •์˜', + 'settings.daily_work': 'ํ•˜๋ฃจ ๊ธฐ๋ณธ ๊ทผ๋ฌด:', + 'settings.lunch_default': '์ ์‹ฌ์‹œ๊ฐ„ ๊ธฐ๋ณธ:', + 'settings.dinner_default': '์ €๋…์‹œ๊ฐ„ ๊ธฐ๋ณธ:', + 'settings.auto_apply': '์ž๋™ ์ ์šฉ', + 'settings.auto_apply_tooltip': '์ถœ๊ทผ ํ›„ 4์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ž๋™ ์ ์šฉ', + 'settings.suffix_hour': ' ์‹œ๊ฐ„', + 'settings.suffix_minute': ' ๋ถ„', + 'settings.notif_clock_out': 'ํ‡ด๊ทผ 30๋ถ„ ์ „ ์•Œ๋ฆผ', + 'settings.notif_lunch': '์ ์‹ฌ์‹œ๊ฐ„ ๋“ฑ๋ก ์•Œ๋ฆผ', + 'settings.notif_dinner': '์ €๋…์‹œ๊ฐ„ ๋“ฑ๋ก ์•Œ๋ฆผ', + 'settings.notif_overtime': '์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ์•Œ๋ฆผ', + 'settings.notif_health': '๊ฑด๊ฐ• ๊ฒฝ๊ณ  ์•Œ๋ฆผ', + 'settings.notif_break': 'ํœด์‹ ๊ถŒ๊ณ  ์•Œ๋ฆผ', + 'settings.notif_break_tooltip': '์˜ค๋žœ ์‹œ๊ฐ„ ์ž๋ฆฌ์—์„œ ์ผํ•˜๋ฉด ์ŠคํŠธ๋ ˆ์นญ์„ ๊ถŒ์œ  (์—ฐ์† ๊ทผ๋ฌด N์‹œ๊ฐ„ ๊ธฐ์ค€)', + 'settings.notif_before': 'ํ‡ด๊ทผ ์•Œ๋ฆผ ์‹œ์ :', + 'settings.notif_before_spin_suffix': '๋ถ„ ์ „', + 'settings.notif_before_tooltip': 'ํ‡ด๊ทผ ์ž„๋ฐ• ์•Œ๋ฆผ์ด ํ‘œ์‹œ๋  ์‹œ์  (๋ถ„ ๋‹จ์œ„)', + 'settings.advanced_thresholds': '๊ณ ๊ธ‰ ์ž„๊ณ„๊ฐ’', + 'settings.advanced_thresholds_tooltip': 'ํšŒ์‚ฌ ์ •์ฑ…ยท๊ฐœ์ธ ์„ ํ˜ธ์— ๋งž์ถฐ ์•Œ๋ฆผ ๋ฐœ์ƒ ์‹œ์  ์กฐ์ •', + 'settings.lunch_alert_after': '์ ์‹ฌ ์•Œ๋ฆผ (์ถœ๊ทผ +):', + 'settings.lunch_alert_tooltip': '์ถœ๊ทผ ํ›„ N์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ ์‹ฌ ๋ฏธ๋“ฑ๋ก ์•Œ๋ฆผ', + 'settings.dinner_alert_after': '์ €๋… ์•Œ๋ฆผ (์ถœ๊ทผ +):', + 'settings.dinner_alert_tooltip': '์ถœ๊ทผ ํ›„ N์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ €๋… ๋ฏธ๋“ฑ๋ก ์•Œ๋ฆผ', + 'settings.overtime_alert_at': '์—ฐ์žฅ ๋ˆ„์  ์•Œ๋ฆผ:', + 'settings.overtime_alert_tooltip': '์—ฐ์žฅ๊ทผ๋ฌด ์ž”์•ก์ด N์‹œ๊ฐ„ ์ด์ƒ์ด๋ฉด ์•Œ๋ฆผ', + 'settings.weekly_limit': '์ฃผ๊ฐ„ ํ•œ๋„ ๊ฒฝ๊ณ :', + 'settings.weekly_limit_tooltip': '์ฃผ๊ฐ„ ์ด ๊ทผ๋ฌด๊ฐ€ N์‹œ๊ฐ„ ์ดˆ๊ณผ ์‹œ ๊ฒฝ๊ณ  (ํ•œ๊ตญ ๋…ธ๋™๋ฒ• ๊ธฐ๋ณธ 52)', + 'settings.consecutive_ot': '์—ฐ์† ์—ฐ์žฅ ๊ฒฝ๊ณ :', + 'settings.consecutive_ot_tooltip': 'N์ผ ์ด์ƒ ์—ฐ์† ์—ฐ์žฅ๊ทผ๋ฌด ์‹œ ๊ฑด๊ฐ• ๊ฒฝ๊ณ ', + 'settings.break_after': 'ํœด์‹ ๊ถŒ๊ณ  ์‹œ์ :', + 'settings.break_after_tooltip': '์—ฐ์† ๊ทผ๋ฌด N์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ŠคํŠธ๋ ˆ์นญ ๊ถŒ์œ ', + 'settings.time_format': '์‹œ๊ฐ„ ํ˜•์‹:', + 'settings.time_format_12': '์˜ค์ „/์˜คํ›„ (์˜คํ›„ 5:30)', + 'settings.time_format_24': '24์‹œ๊ฐ„ (17:30)', + 'settings.theme': 'ํ…Œ๋งˆ:', + 'settings.theme_light': '๋ผ์ดํŠธ', + 'settings.theme_dark': '๋‹คํฌ', + 'settings.font_scale': '๊ธ€๊ผด ํฌ๊ธฐ:', + 'settings.high_contrast': '๊ณ ๋Œ€๋น„ ๋ชจ๋“œ', + 'settings.high_contrast_tooltip': '๊ฒ€์ • ๋ฐฐ๊ฒฝ + ๋…ธ๋ž€ ํ…์ŠคํŠธ (์‹œ๊ฐ์•ฝ์ž/์•ผ๊ฐ„)', + 'settings.language_restart_tooltip': '์–ธ์–ด ๋ณ€๊ฒฝ์€ ์žฌ์‹œ์ž‘ ํ›„ ์™„์ „ํžˆ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.', + 'settings.current_balance': 'ํ˜„์žฌ ์ž”์•ก: ๊ณ„์‚ฐ ์ค‘...', + 'settings.calc_unit': '๊ณ„์‚ฐ ๋‹จ์œ„:', + 'settings.initial_overtime': '๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด:', + 'settings.auto_bank': '์ž๋™ ์ ๋ฆฝ', + 'settings.auto_bank_tooltip': 'ํ‡ด๊ทผ ์‹œ ์—ฐ์žฅ๊ทผ๋ฌด ์ž๋™ ์ ๋ฆฝ', + 'settings.initial_overtime_note': 'โ€ป ํ”„๋กœ๊ทธ๋žจ ์‚ฌ์šฉ ์ „ ์Œ“์ธ ์—ฐ์žฅ๊ทผ๋ฌด ์‹œ๊ฐ„ (์ ˆ๋Œ€๊ฐ’)', + 'settings.goal_group': '์›”๊ฐ„ ๋ชฉํ‘œ (0=๋น„ํ™œ์„ฑ)', + 'settings.monthly_ot_cap': '์›” ์—ฐ์žฅ๊ทผ๋ฌด ์ƒํ•œ:', + 'settings.daily_avg_goal': '์ผ ํ‰๊ท  ๊ทผ๋ฌด ๋ชฉํ‘œ:', + 'settings.goal_note': 'โ€ป ํ†ต๊ณ„ โ†’ ์›”๊ฐ„ ํƒญ์—์„œ ์ง„ํ–‰๋ฅ  ํ™•์ธ', + 'settings.annual_leave': '์—ฐ๊ฐ„ ์—ฐ์ฐจ:', + 'settings.remaining_leave': '๋‚จ์€ ์—ฐ์ฐจ: ๊ณ„์‚ฐ ์ค‘...', + 'settings.used_leave': '๊ธฐ์กด ์‚ฌ์šฉ:', + 'settings.used_leave_note': 'โ€ป ํ”„๋กœ๊ทธ๋žจ ์‚ฌ์šฉ ์ „ ์ด๋ฏธ ์‚ฌ์šฉํ•œ ์—ฐ์ฐจ (1์ผ=8์‹œ๊ฐ„)', + 'settings.registered': '๋“ฑ๋ก:', + 'settings.add_korean_holidays': 'ํ•œ๊ตญ ๊ณตํœด์ผ (์ž๋™)', + 'settings.add_korean_holidays_tooltip': '์Œ๋ ฅ ๋ช…์ ˆ(์„ค/์ถ”์„) + ์ž„์‹œ๊ณตํœด์ผ ํฌํ•จ ์ž๋™ ๋“ฑ๋ก', + 'settings.holiday_note': 'โ€ป ๊ณตํœด์ผ ๊ทผ๋ฌด ์‹œ ๋ชจ๋“  ์‹œ๊ฐ„์ด ์—ฐ์žฅ๊ทผ๋ฌด๋กœ ์ ๋ฆฝ๋ฉ๋‹ˆ๋‹ค', + 'settings.list': '๋ชฉ๋ก', + 'settings.korean_holidays_title': 'ํ•œ๊ตญ ๊ณตํœด์ผ ์ž๋™ ์ถ”๊ฐ€', + 'settings.korean_holidays_years_label': '{start}๋…„ + {end}๋…„', + 'settings.korean_holidays_years_label_single': '{year}๋…„', + 'settings.korean_holidays_years_label': '{start}๋…„ + {end}๋…„', + 'settings.korean_holidays_years_label_single': '{year}๋…„', + 'settings.korean_holidays_body': '{years} ํ•œ๊ตญ ๊ณตํœด์ผ์„ ์ž๋™์œผ๋กœ ๋“ฑ๋กํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nํฌํ•จ:\nโ€ข ์–‘๋ ฅ ๊ณตํœด์ผ (์‹ ์ •/์‚ผ์ผ์ ˆ/์–ด๋ฆฐ์ด๋‚ /๊ทผ๋กœ์ž์˜ ๋‚  ๋“ฑ)\nโ€ข ์Œ๋ ฅ ๋ช…์ ˆ (์„ค๋‚  ์—ฐํœด/์ถ”์„ ์—ฐํœด/์„๊ฐ€ํƒ„์‹ ์ผ)\nโ€ข ์ •๋ถ€ ์ง€์ • ๋Œ€์ฒดยท์ž„์‹œ๊ณตํœด์ผ\n\nโ€ป 1์ฐจ: ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ํŠน์ผ์ •๋ณด API (์ •๋ถ€ ๊ณต์ธ, ์ž„์‹œ๊ณตํœด์ผ ํฌํ•จ)\nโ€ป 2์ฐจ fallback: \'holidays\' ํŒจํ‚ค์ง€ (์˜คํ”„๋ผ์ธ)', + 'settings.korean_holidays_added': '{year}๋…„ ํ•œ๊ตญ ๊ณตํœด์ผ {count}๊ฐœ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'settings.korean_holidays_included': 'ํฌํ•จ:\n', + 'settings.package_not_installed': 'ํŒจํ‚ค์ง€ ๋ฏธ์„ค์น˜', + 'settings.package_fallback_body': '\'holidays\' ํŒจํ‚ค์ง€๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•„ ๊ณ ์ • ๊ณตํœด์ผ๋งŒ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.\n\n{hint} pip install holidays', + 'settings.package_install_hint': '์Œ๋ ฅ/์ž„์‹œ๊ณตํœด์ผ ์ž๋™ ๋“ฑ๋ก์„ ์›ํ•˜์‹œ๋ฉด:\n', + 'settings.add_done': '์ถ”๊ฐ€ ์™„๋ฃŒ', + 'settings.holiday_added': '๊ณตํœด์ผ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{date}: {name}', + 'settings.holiday_add_title': '๊ณตํœด์ผ ์ถ”๊ฐ€', + 'settings.holiday_date_prompt': '๊ณตํœด์ผ ๋‚ ์งœ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (YYYY-MM-DD):', + 'settings.holiday_name_prompt': '๊ณตํœด์ผ ์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”:', + 'settings.holiday_list_title': '๊ณตํœด์ผ ๋ชฉ๋ก', + 'settings.holiday_list_header': '=== {year}๋…„ ๊ณตํœด์ผ ๋ชฉ๋ก ===\n\n', + 'settings.holiday_list_item': 'โ€ข {date} ({weekday}): {name}{recurring}', + 'settings.holiday_total': '์ด {count}๊ฐœ', + 'settings.holiday_delete_confirm': '\n\n๊ณตํœด์ผ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', + 'settings.holiday_delete_title': '๊ณตํœด์ผ ์‚ญ์ œ', + 'settings.holiday_delete_prompt': '์‚ญ์ œํ•  ๊ณตํœด์ผ์„ ์„ ํƒํ•˜์„ธ์š”:', + 'settings.delete_done': '์‚ญ์ œ ์™„๋ฃŒ', + 'settings.holiday_deleted': '{item}์ด(๊ฐ€) ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'settings.export_csv': 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ', + 'settings.export_work': '๊ทผ๋ฌด๊ธฐ๋ก', + 'settings.export_overtime': '์—ฐ์žฅ๊ทผ๋ฌด', + 'settings.export_monthly': '์›”๊ฐ„ ์š”์•ฝ', + 'settings.import_csv': 'CSV ๊ฐ€์ ธ์˜ค๊ธฐ', + 'settings.import_tooltip': 'date,clock_in,clock_out,lunch_minutes,memo ํ—ค๋” ํฌ๋งท', + 'settings.import_format': '์šฐ๋ฆฌ ํ‘œ์ค€ ํฌ๋งท (ํ—ค๋”: date,clock_in,clock_out,lunch_minutes,memo)', + 'settings.db_path_label': 'DB ๊ฒฝ๋กœ:', + 'settings.change': '๋ณ€๊ฒฝ...', + 'settings.db_path_tooltip': 'ํด๋ผ์šฐ๋“œ ํด๋”(OneDrive/Dropbox ๋“ฑ) ๊ฒฝ๋กœ๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ. ์žฌ์‹œ์ž‘ ํ•„์š”.', + 'settings.auto_break_lock_tooltip': 'PC๊ฐ€ ์ž ๊ธฐ๋ฉด ์™ธ์ถœ ์‹œ์ž‘, ํ’€๋ฆฌ๋ฉด ๋ณต๊ท€๋ฅผ ์ž๋™ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.', + 'settings.gitea_feedback_label': 'Gitea ํ”ผ๋“œ๋ฐฑ:', + 'settings.gitea_token_placeholder': 'PAT (issue ์“ฐ๊ธฐ ๊ถŒํ•œ, ์˜ต์…˜)', + 'settings.clock_in_unlock_tooltip': 'PC๋ฅผ ๋„์ง€ ์•Š๊ณ  ์ถœ๊ทผํ•˜๋Š” ๊ฒฝ์šฐ โ€” ๋ถ€ํŒ… ์ด๋ฒคํŠธ๊ฐ€ ์—†์–ด๋„ ํ™”๋ฉด ์ž ๊ธˆ ํ•ด์ œ ์‹œ์ ์„ ์ถœ๊ทผ์œผ๋กœ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค.', + 'settings.auto_break_lock': 'ํ™”๋ฉด ์ž ๊ธˆ ์‹œ ์ž๋™ ์™ธ์ถœ/๋ณต๊ท€', + 'settings.gitea_feedback_tooltip': "์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ 'Gitea์— ๋ณด๊ณ ' ๋ฒ„ํŠผ ํ™œ์„ฑํ™”", + 'settings.clock_in_unlock': '์ฒซ ์ž ๊ธˆ ํ•ด์ œ ์‹œ๊ฐ์„ ์ถœ๊ทผ์‹œ๊ฐ„์œผ๋กœ ์‚ฌ์šฉ', + 'settings.version': '๋ฒ„์ „: v{version}', + 'settings.check_update': '์—…๋ฐ์ดํŠธ ํ™•์ธ (F5)', + 'settings.select_db': '๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ ์„ ํƒ', + 'settings.db_path_saved': '์ƒˆ ๊ฒฝ๋กœ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค:\n{path}\n\n๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ํ˜„์žฌ database.db ํŒŒ์ผ์„ ์ƒˆ ์œ„์น˜๋กœ ๋ณต์‚ฌํ•˜๊ณ \nํ”„๋กœ๊ทธ๋žจ์„ ์žฌ์‹œ์ž‘ํ•˜์„ธ์š”.', + 'settings.parse_failed': 'ํŒŒ์‹ฑ ์‹คํŒจ', + 'settings.empty_file': '๋นˆ ํŒŒ์ผ', + 'settings.empty_file_body': '์œ ํšจํ•œ ํ–‰์ด ์—†์Šต๋‹ˆ๋‹ค.', + 'settings.conflict_title': '์ถฉ๋Œ ์ฒ˜๋ฆฌ', + 'settings.conflict_body': '๊ธฐ์กด ์ผ์ž์™€ ์ถฉ๋Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ๊นŒ์š”?\n', + 'settings.conflict_body_detailed': '๊ธฐ์กด ์ผ์ž์™€ ์ถฉ๋Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ๊นŒ์š”?\nYes = ๋ฎ์–ด์“ฐ๊ธฐ\nNo = ๊ฑด๋„ˆ๋›ฐ๊ธฐ\nCancel = ์ทจ์†Œ', + 'settings.import_failed': '๊ฐ€์ ธ์˜ค๊ธฐ ์‹คํŒจ', + 'settings.import_result': '๊ฐ€์ ธ์˜ค๊ธฐ ๊ฒฐ๊ณผ:\nโ€ข ์ถ”๊ฐ€: {added}๊ฑด\nโ€ข ๊ฐฑ์‹ : {updated}๊ฑด\nโ€ข ๊ฑด๋„ˆ๋œ€: {skipped}๊ฑด', + 'settings.save_done': '์ €์žฅ ์™„๋ฃŒ', + 'settings.save_done_body': '์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'settings.restart_title': '์žฌ์‹œ์ž‘ / Restart', + 'settings.restart_body': '์ฃผ์š” ํ™”๋ฉด์€ ์ฆ‰์‹œ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ์ผ๋ถ€ ๋‹ค์ด์–ผ๋กœ๊ทธ๋Š” ์žฌ์‹œ์ž‘ ํ›„ ์™„์ „ํžˆ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.\n์ง€๊ธˆ ์žฌ์‹œ์ž‘ํ• ๊นŒ์š”?\n\n', + 'settings.initial_overtime_title': '๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด ์„ค์ •', + 'settings.initial_overtime_body': 'ํ˜„์žฌ ์„ค์ •: {old_hours}์‹œ๊ฐ„ {old_mins}๋ถ„\n๋ณ€๊ฒฝํ•  ๊ฐ’: {hours}์‹œ๊ฐ„ {mins}๋ถ„\n\n๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด ์‹œ๊ฐ„์„ ๋ณ€๊ฒฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', + 'settings.initial_overtime_done': '์„ค์ • ์™„๋ฃŒ', + 'settings.initial_overtime_done_body': '๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด๊ฐ€ {hours}์‹œ๊ฐ„ {mins}๋ถ„์œผ๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'settings.initial_overtime_error': '๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด ์„ค์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{error}', + 'settings.current_overtime_balance': 'ํ˜„์žฌ ์ž”์•ก: {hours}์‹œ๊ฐ„ {minutes}๋ถ„ ({balance}๋ถ„)', + 'settings.remaining_leave_fmt': '๋‚จ์€ ์—ฐ์ฐจ: {remaining:.1f}์ผ (์ด {total}์ผ ์ค‘ {used}์ผ ์‚ฌ์šฉ)', + 'settings.holiday_count': '{count}๊ฐœ ({year}๋…„)', + 'settings.error': '์˜ค๋ฅ˜', + 'settings.export_no_records': '๋‚ด๋ณด๋‚ผ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.', + 'settings.save_work_title': '๊ทผ๋ฌด ๊ธฐ๋ก ์ €์žฅ', + 'settings.export_done': '๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ', + 'settings.work_exported': '๊ทผ๋ฌด ๊ธฐ๋ก์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{path}', + 'settings.save_ot_title': '์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ ์ €์žฅ', + 'settings.ot_exported': '์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{path}', + 'settings.save_monthly_title': '์›”๊ฐ„ ์š”์•ฝ ์ €์žฅ', + 'settings.monthly_exported': '์›”๊ฐ„ ์š”์•ฝ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{path}', + 'settings.export_failed': '๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ', + 'settings.export_error': '์˜ค๋ฅ˜: {error}', + 'settings.initial_leave_title': '๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ ์„ค์ •', + 'settings.initial_leave_body': 'ํ˜„์žฌ ์„ค์ •: {old_hours}์‹œ๊ฐ„ {old_mins}๋ถ„\n๋ณ€๊ฒฝํ•  ๊ฐ’: {hours}์‹œ๊ฐ„ {mins}๋ถ„\n\n๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ๋ฅผ ๋ณ€๊ฒฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', + 'settings.initial_leave_done': '์„ค์ • ์™„๋ฃŒ', + 'settings.initial_leave_done_body': '๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ๊ฐ€ {hours}์‹œ๊ฐ„ {mins}๋ถ„์œผ๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'date_format.full': '{year}๋…„ {month}์›” {day}์ผ ({weekday})', + + 'achieve.cat_ambition': '์•ผ๋ง', + 'achieve.cat_balance': '์›Œ๋ผ๋ฐธ', + 'achieve.cat_break_use': '์™ธ์ถœ', + 'achieve.cat_health': '๊ฑด๊ฐ•', + 'achieve.cat_korea': 'ํ•œ๊ตญ ๋ฌธํ™”', + 'achieve.cat_leave': '์—ฐ์ฐจ', + 'achieve.cat_meal': '์‹์‚ฌ', + 'achieve.cat_meta': '๋ฉ”ํƒ€', + 'achieve.cat_milestone': '๋งˆ์ผ์Šคํ†ค', + 'achieve.cat_ot_bank': '์—ฐ์žฅ ์ ๋ฆฝ', + 'achieve.cat_ot_use': '์—ฐ์žฅ ์‚ฌ์šฉ', + 'achieve.cat_pattern': 'ํŒจํ„ด', + 'achieve.cat_punctual': '์‹œ๊ฐ„ ์—„์ˆ˜', + 'achieve.cat_season': '์‹œ์ฆŒ', + 'achieve.cat_secret': '์‹œํฌ๋ฆฟ', + 'achieve.cat_settings': '์„ค์ •', + 'achieve.cat_special_day': 'ํŠน๋ณ„์ผ', + 'achieve.cat_stats': 'ํ†ต๊ณ„', + 'achieve.cat_streak': '์ถœ๊ทผ streak', + 'achieve.cat_time_slot': '์‹œ๊ฐ„๋Œ€', + 'achieve.completion_rate': '๋‹ฌ์„ฑ๋ฅ ', + 'achieve.earned_date': ' โœ“ {date} ๋‹ฌ์„ฑ ', + 'achieve.empty': '(์•„์ง ์—†์Œ)', + 'achieve.secret_locked': '๐Ÿ”’ ๋‹ฌ์„ฑํ•˜๋ฉด ๊ณต๊ฐœ๋ฉ๋‹ˆ๋‹ค', + 'achieve.tab_all': '๐ŸŒ ์ „์ฒด ยท {count}', + 'achieve.tab_completed': 'โœ“ ์™„๋ฃŒ ยท {count}', + 'achieve.tab_in_progress': 'โšก ์ง„ํ–‰ ์ค‘ ยท {count}', + 'achieve.tab_secret': '๐ŸŒ‘ ์‹œํฌ๋ฆฟ ยท {earned}/{total}', + 'achieve.tier_bronze': '๋ธŒ๋ก ์ฆˆ', + 'achieve.tier_gold': '๊ณจ๋“œ', + 'achieve.tier_legend': '๋ ˆ์ „๋“œ', + 'achieve.tier_platinum': 'ํ”Œ๋ž˜ํ‹ฐ๋„˜', + 'achieve.tier_silver': '์‹ค๋ฒ„', + 'achieve.title': '๋„์ „๊ณผ์ œ', + 'cal.add_done_body': '{date} ๊ธฐ๋ก์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'cal.add_done_title': '์ถ”๊ฐ€ ์™„๋ฃŒ', + 'cal.add_error_body': '๊ธฐ๋ก ์ถ”๊ฐ€ ์‹คํŒจ: {error}', + 'cal.add_error_title': '์˜ค๋ฅ˜', + 'cal.btn_minus_30': '-30๋ถ„', + 'cal.btn_plus_30': '+30๋ถ„', + 'cal.check_dinner_1h': '์ €๋… (1์‹œ๊ฐ„)', + 'cal.check_lunch_1h': '์ ์‹ฌ (1์‹œ๊ฐ„)', + 'cal.context_add': '{date} ๊ธฐ๋ก ์ถ”๊ฐ€', + 'cal.context_delete': '{date} ์‚ญ์ œ', + 'cal.context_edit': '{date} ํŽธ์ง‘', + 'cal.delete_confirm_body': '{date} ๊ธฐ๋ก์„ ์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n(์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ๋‚ด์—ญ๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค)', + 'cal.delete_confirm_title': '์‚ญ์ œ ํ™•์ธ', + 'cal.delete_done_body': '{date} ๊ธฐ๋ก ์‚ญ์ œ๋จ', + 'cal.delete_done_title': '์‚ญ์ œ ์™„๋ฃŒ', + 'cal.delete_record': '๊ธฐ๋ก ์‚ญ์ œ', + 'cal.delete_selected_body': '{date}์˜ ์ถœ๊ทผ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nโ€ป ์—ฐ๊ด€๋œ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ/์‚ฌ์šฉ ๊ธฐ๋ก๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.\nโ€ป ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + 'cal.delete_selected_title': '์ถœ๊ทผ ๊ธฐ๋ก ์‚ญ์ œ', + 'cal.detail_clock_in': '์ถœ๊ทผ: {time}', + 'cal.detail_clock_out': 'ํ‡ด๊ทผ: {time}', + 'cal.detail_clock_out_none': 'ํ‡ด๊ทผ: ๋ฏธ๊ธฐ๋ก', + 'cal.detail_date_fmt': '{year}๋…„ {month}์›” {day}์ผ', + 'cal.detail_dinner_unused': '์ €๋…์‹œ๊ฐ„: ๋ฏธ์‚ฌ์šฉ', + 'cal.detail_dinner_used': '์ €๋…์‹œ๊ฐ„: ์‚ฌ์šฉํ•จ', + 'cal.detail_group_title': '์„ ํƒ๋œ ๋‚ ์งœ ์ •๋ณด', + 'cal.detail_lunch_unused': '์ ์‹ฌ์‹œ๊ฐ„: ๋ฏธ์‚ฌ์šฉ', + 'cal.detail_lunch_used': '์ ์‹ฌ์‹œ๊ฐ„: ์‚ฌ์šฉํ•จ', + 'cal.detail_memo': '๋ฉ”๋ชจ: {memo}', + 'cal.detail_overtime_earned': '๐Ÿ”ฅ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ: {hours}์‹œ๊ฐ„ {minutes}๋ถ„', + 'cal.detail_total_hours': '์ด ๊ทผ๋ฌด์‹œ๊ฐ„: {hours:.1f}์‹œ๊ฐ„', + 'cal.dialog_title': '์›”๊ฐ„ ๊ทผ๋ฌด ๊ธฐ๋ก', + 'cal.edit_dialog_subtitle': '{date} ์ถœํ‡ด๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •', + 'cal.edit_dialog_title': '์ถœํ‡ด๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •', + 'cal.edit_done_body': '{date}์˜ ์ถœํ‡ด๊ทผ ์‹œ๊ฐ„์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n์ถœ๊ทผ: {clock_in}\nํ‡ด๊ทผ: {clock_out}\n์ ์‹ฌ์‹œ๊ฐ„: {lunch}\n์ €๋…์‹œ๊ฐ„: {dinner}\n์™ธ์ถœ์‹œ๊ฐ„: {break_minutes}๋ถ„\n์ด ๊ทผ๋ฌด์‹œ๊ฐ„: {total_hours:.1f}์‹œ๊ฐ„\n์—ฐ์žฅ๊ทผ๋ฌด: {overtime_earned}๋ถ„ ์ ๋ฆฝ', + 'cal.edit_done_title': '์ˆ˜์ • ์™„๋ฃŒ', + 'cal.edit_error_body': '์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{error}', + 'cal.edit_error_title': '์˜ค๋ฅ˜', + 'cal.edit_note': 'โ€ป ์ˆ˜์ • ์‹œ ์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ์ด ์žฌ๊ณ„์‚ฐ๋ฉ๋‹ˆ๋‹ค.', + 'cal.edit_time': '์‹œ๊ฐ„ ์ˆ˜์ •', + 'cal.label_clock_in': '์ถœ๊ทผ:', + 'cal.label_clock_out': 'ํ‡ด๊ทผ:', + 'cal.legend_leave': 'ํœด๊ฐ€', + 'cal.legend_none': '์—†์Œ', + 'cal.legend_normal': '์ •์ƒ', + 'cal.legend_overtime': '์—ฐ์žฅ', + 'cal.memo_group': '๋ฉ”๋ชจ', + 'cal.memo_placeholder': '์ถ”๊ฐ€๊ทผ๋ฌด ์‚ฌ์œ , ํŠน์ด์‚ฌํ•ญ ๋“ฑ...', + 'cal.no_record': '๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.', + 'cal.save_memo': '๋ฉ”๋ชจ ์ €์žฅ', + 'cal.save_memo_body': '{date}์˜ ๋ฉ”๋ชจ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'cal.save_memo_title': '๋ฉ”๋ชจ ์ €์žฅ', + 'cal.time_error_body': 'ํ‡ด๊ทผ ์‹œ๊ฐ„์€ ์ถœ๊ทผ ์‹œ๊ฐ„๋ณด๋‹ค ๋Šฆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.', + 'cal.time_error_title': '์‹œ๊ฐ„ ์˜ค๋ฅ˜', + 'clock_in_dialog.cancelled': '์ทจ์†Œ๋จ', + 'clock_in_dialog.selected': '์„ ํƒ๋œ ์‹œ๊ฐ„: {time}', + 'field.avg_daily_value': '{hours:.1f}์‹œ๊ฐ„', + 'field.overtime_value': '{hours}์‹œ๊ฐ„ {minutes}๋ถ„', + 'field.total_work_value': '{hours:.1f}์‹œ๊ฐ„ ({days}์ผ)', + 'goal.avg_daily': '์ผํ‰๊ท :', + 'goal.overtime': '์—ฐ์žฅ๊ทผ๋ฌด:', + 'goal.title': '์ด๋ฒˆ ๋‹ฌ ๋ชฉํ‘œ', + 'help.onboarding_button': '์˜จ๋ณด๋”ฉ ๋‹ค์‹œ ๋ณด๊ธฐ', + 'leave_cal.detail_label': '{type} {days}์ผ', + 'leave_cal.detail_memo': '{type} {days}์ผ ({memo})', + 'leave_cal.detail_no_record': '{date} โ€” ์—ฐ์ฐจ ์‚ฌ์šฉ ์—†์Œ', + 'leave_cal.header': '์ž”์—ฌ {balance:.2f}์ผ / ์ด {total:.0f}์ผ (์‚ฌ์šฉ {used:.2f}์ผ)', + 'leave_cal.legend_full': '์ข…์ผ(1.0)', + 'leave_cal.legend_full_planned': '์ข…์ผ+์˜ˆ์ •', + 'leave_cal.legend_half': '๋ฐ˜์ฐจ(0.5)', + 'leave_cal.legend_planned': '์˜ˆ์ •', + 'leave_cal.legend_quarter': '๋ฐ˜๋ฐ˜์ฐจ(0.25)', + 'leave_cal.title': '์—ฐ์ฐจ ์บ˜๋ฆฐ๋”', + 'meal.dialog_title': '{meal} ์‹œ๊ฐ„ ์ž…๋ ฅ', + 'meal.error_after_clock_out': 'ํ‡ด๊ทผ({time}) ์ดํ›„์ž…๋‹ˆ๋‹ค', + 'meal.error_before_clock_in': '์ถœ๊ทผ({time}) ์ด์ „์ž…๋‹ˆ๋‹ค', + 'meal.error_start_after_end': '์‹œ์ž‘์ด ์ข…๋ฃŒ๋ณด๋‹ค ๋Šฆ์Šต๋‹ˆ๋‹ค', + 'meal.error_too_long': '์‹์‚ฌ ์‹œ๊ฐ„์ด 8์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค', + 'meal.info_clock_in_limit': '\n์ถœ๊ทผ {time} ์ดํ›„๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ.', + 'meal.info_text': '{meal} ์‹œ์ž‘ยท์ข…๋ฃŒ ์‹œ๊ฐ์„ ์ž…๋ ฅํ•˜์„ธ์š”.\n์ž๋™ ์ ์šฉ๋œ {minutes}๋ถ„ ๋Œ€์‹  ์ •ํ™•ํ•œ ์‹œ๊ฐ„์œผ๋กœ ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.', + 'meal.input_error_title': '์ž…๋ ฅ ์˜ค๋ฅ˜', + 'meal.label_end': '์ข…๋ฃŒ:', + 'meal.label_start': '์‹œ์ž‘:', + 'meal.preview_total': '์ด {minutes}๋ถ„', + 'mini.close': '๋ฏธ๋‹ˆ ์œ„์ ฏ ๋‹ซ๊ธฐ', + 'mini.open_main': '๋ฉ”์ธ ์ฐฝ ์—ด๊ธฐ', + 'onboarding.detection_boot': 'PC ๋ถ€ํŒ… ์‹œ๊ฐ„ (๊ธฐ๋ณธ โ€” ๋งค์ผ PC๋ฅผ ๋„๋Š” ๊ฒฝ์šฐ)', + 'onboarding.detection_info': '\nPC๋ฅผ ํ•ญ์ƒ ์ผœ๋‘” ์ฑ„ ์ถœ๊ทผํ•˜์‹œ๋Š” ๋ถ„์€ ๋‘ ๋ฒˆ์งธ ์˜ต์…˜์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.', + 'onboarding.detection_manual': '์ˆ˜๋™ ์ž…๋ ฅ๋งŒ (์ž๋™ ๊ฐ์ง€ ์•ˆ ํ•จ)', + 'onboarding.detection_subtitle': '์•ฑ์ด ์ถœ๊ทผ ์‹œ๊ฐ„์„ ์ž๋™์œผ๋กœ ์–ด๋–ป๊ฒŒ ๊ฐ์ง€ํ• ์ง€ ์„ ํƒํ•˜์„ธ์š”.', + 'onboarding.detection_title': '์ถœ๊ทผ ์‹œ๊ฐ„ ๊ฐ์ง€ ๋ฐฉ์‹', + 'onboarding.detection_unlock': 'ํ™”๋ฉด ์ž ๊ธˆ ํ•ด์ œ ์‹œ๊ฐ„ (PC๋ฅผ ์•ˆ ๋„๊ณ  ๋‹ค๋‹ˆ๋Š” ๊ฒฝ์šฐ)', + 'onboarding.discord_enable': 'Discord ์›นํ›… ์•Œ๋ฆผ ์‚ฌ์šฉ', + 'onboarding.discord_failed': '์‹คํŒจ', + 'onboarding.discord_failed_body': '์ „์†ก ์‹คํŒจ. URL์„ ๋‹ค์‹œ ํ™•์ธํ•ด์ฃผ์„ธ์š”.', + 'onboarding.discord_guide': '์…‹์—… ๋ฐฉ๋ฒ•:\n1. Discord ์„œ๋ฒ„์—์„œ ์ฑ„๋„ ์šฐํด๋ฆญ โ†’ ํŽธ์ง‘ โ†’ ์—ฐ๋™ โ†’ ์›นํ›…\n2. ์ƒˆ ์›นํ›… ๋งŒ๋“ค๊ธฐ โ†’ URL ๋ณต์‚ฌ\n3. ์œ„ ์ž…๋ ฅ๋ž€์— ๋ถ™์—ฌ๋„ฃ๊ธฐ', + 'onboarding.discord_subtitle': '์ถœํ‡ด๊ทผ ์‹œ๊ฐยทํœด์‹ ๊ถŒ๊ณ ๋ฅผ Discord๋กœ ๋ฐ›์œผ๋ ค๋ฉด ์›นํ›… URL์„ ์ž…๋ ฅํ•˜์„ธ์š”. (๋ชจ๋ฐ”์ผ์—์„œ ํ‘ธ์‹œ ์•Œ๋ฆผ)', + 'onboarding.discord_success': '์„ฑ๊ณต', + 'onboarding.discord_success_body': 'Discord ์ฑ„๋„์—์„œ ํ…Œ์ŠคํŠธ ๋ฉ”์‹œ์ง€๋ฅผ ํ™•์ธํ•˜์„ธ์š”.', + 'onboarding.discord_test': 'ํ…Œ์ŠคํŠธ ๋ฉ”์‹œ์ง€ ๋ณด๋‚ด๊ธฐ', + 'onboarding.discord_title': 'Discord ์•Œ๋ฆผ (์„ ํƒ)', + 'onboarding.discord_url_invalid_body': 'Discord ์›นํ›… URL ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.\n์˜ˆ: https://discord.com/api/webhooks/{ID}/{TOKEN}', + 'onboarding.discord_url_invalid_title': 'URL ํ˜•์‹ ์˜ค๋ฅ˜', + 'onboarding.discord_url_placeholder': 'https://discord.com/api/webhooks/...', + 'onboarding.discord_url_required_body': '์›นํ›… URL์„ ๋จผ์ € ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', + 'onboarding.discord_url_required_title': 'URL ํ•„์š”', + 'onboarding.finish_msg': '์„ค์ •ํ•œ ๋‚ด์šฉ์€ [์„ค์ •] ๋ฉ”๋‰ด์—์„œ ์–ธ์ œ๋“  ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n์˜จ๋ณด๋”ฉ์„ ๋‹ค์‹œ ๋ณด๊ณ  ์‹ถ์œผ๋ฉด [๋„์›€๋ง โ†’ ์˜จ๋ณด๋”ฉ ๋‹ค์‹œ ๋ณด๊ธฐ]๋ฅผ ๋ˆ„๋ฅด์„ธ์š”.\n\n๋‹จ์ถ•ํ‚ค:\n โ€ข Ctrl+O โ€” ์ถœํ‡ด๊ทผ ํ† ๊ธ€\n โ€ข F1 โ€” ๋„์›€๋ง\n โ€ข F5 โ€” ์—…๋ฐ์ดํŠธ ํ™•์ธ\n โ€ข Ctrl+, โ€” ์„ค์ •', + 'onboarding.finish_subtitle': '์ด์ œ ์ถœ๊ทผ๋ถ€ํ„ฐ ์ž๋™ ์ถ”์ ๋ฉ๋‹ˆ๋‹ค.', + 'onboarding.finish_title': '์ค€๋น„ ์™„๋ฃŒ!', + 'onboarding.hourly_wage': '์‹œ๊ธ‰:', + 'onboarding.input_error_title': '์ž…๋ ฅ ์˜ค๋ฅ˜', + 'onboarding.leave_group': '์—ฐ๊ฐ„ ์—ฐ์ฐจ', + 'onboarding.leave_salary_subtitle': '์—ฐ์ฐจ ์ผ์ˆ˜์™€ ๊ธ‰์—ฌ(์„ ํƒ)๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.', + 'onboarding.leave_salary_title': '์—ฐ์ฐจ + ๊ธ‰์—ฌ (์˜ต์…˜)', + 'onboarding.my_leave': '๋‚ด ์—ฐ์ฐจ:', + 'onboarding.overtime_rate': '์—ฐ์žฅ์ˆ˜๋‹น ๊ฐ€์‚ฐ๋ฅ :', + 'onboarding.rate_1_5x': '1.5๋ฐฐ (ํ•œ๊ตญ ๋…ธ๋™๋ฒ• ๊ธฐ๋ณธ)', + 'onboarding.rate_1x': '1.0๋ฐฐ (๊ฐ€์‚ฐ ์—†์Œ)', + 'onboarding.rate_2x': '2.0๋ฐฐ (์•ผ๊ทผ/ํœด์ผ ๊ฐ€์‚ฐ)', + 'onboarding.salary_enabled': '๊ธ‰์—ฌ ์ถ”์ • ํ™œ์„ฑํ™”', + 'onboarding.salary_group': '๊ธ‰์—ฌ ์ถ”์ • (์˜ต์…˜ โ€” ํฌ๊ด„์ž„๊ธˆ์ด๋ฉด ๋น„ํ™œ์„ฑ)', + 'onboarding.wage_suffix': ' ์›/์‹œ๊ฐ„', + 'onboarding.welcome_intro': '์ด ์•ฑ์€:\nโ€ข ์ปดํ“จํ„ฐ ๋ถ€ํŒ…/์ž ๊ธˆ ํ•ด์ œ๋กœ ์ถœ๊ทผ ์‹œ๊ฐ„ ์ž๋™ ๊ฐ์ง€\nโ€ข 30๋ถ„ ๋‹จ์œ„ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ\nโ€ข ์—ฐ์ฐจยท๋ฐ˜์ฐจยท์™ธ์ถœ ์‹œ๊ฐ„ ์ถ”์ \nโ€ข ๋งค์ผ ํ‡ด๊ทผ ์‹œ๊ฐ„์„ 1์ดˆ๋งˆ๋‹ค ์นด์šดํŠธ๋‹ค์šด\n\n[๋‹ค์Œ] ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์‹œ์ž‘ํ•˜์„ธ์š”.', + 'onboarding.welcome_subtitle': 'Clock-out Time Calculator๋ฅผ ์ฒ˜์Œ ์‚ฌ์šฉํ•˜์‹œ๋Š”๊ตฐ์š”. 5๋‹จ๊ณ„๋กœ ๋น ๋ฅด๊ฒŒ ์„ค์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.', + 'onboarding.welcome_title': 'ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค!', + 'onboarding.window_title': 'Clock-out Calculator โ€” ์‹œ์ž‘ ์„ค์ •', + 'onboarding.work_min_too_small': 'ํ•˜๋ฃจ ๊ทผ๋ฌด๋Š” ์ตœ์†Œ 30๋ถ„ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.', + 'onboarding.work_pattern_subtitle': '๋ณธ์ธ์˜ ํ•˜๋ฃจ ๊ทผ๋ฌด ์‹œ๊ฐ„์„ ์„ ํƒํ•˜์„ธ์š”. ๋‚˜์ค‘์— ์„ค์ •์—์„œ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + 'onboarding.work_pattern_title': '๊ทผ๋ฌด ํŒจํ„ด', + 'past_record.check_clock_out': '์ž…๋ ฅ', + 'past_record.check_dinner': '์ €๋…์‹œ๊ฐ„ ํฌํ•จ', + 'past_record.check_lunch': '์ ์‹ฌ์‹œ๊ฐ„ ํฌํ•จ', + 'past_record.dialog_title': '๊ธฐ๋ก ์ถ”๊ฐ€ โ€” {date}', + 'past_record.info': '{date} ๊ทผ๋ฌด ๊ธฐ๋ก์„ ์ž…๋ ฅํ•˜์„ธ์š”.', + 'past_record.input_error_body': 'ํ‡ด๊ทผ ์‹œ๊ฐ„์ด ์ถœ๊ทผ ์‹œ๊ฐ„๋ณด๋‹ค ๋น ๋ฅด๊ฑฐ๋‚˜ ๊ฐ™์Šต๋‹ˆ๋‹ค.', + 'past_record.input_error_title': '์ž…๋ ฅ ์˜ค๋ฅ˜', + 'past_record.label_clock_in': '์ถœ๊ทผ:', + 'past_record.label_clock_out': 'ํ‡ด๊ทผ:', + 'past_record.label_memo': '๋ฉ”๋ชจ (์„ ํƒ):', + 'past_record.memo_placeholder': '์˜ˆ: ์žฌํƒ๊ทผ๋ฌด / ์™ธ๊ทผ / ํœด๊ฐ€', + 'recurring.add_done_body': '๋ฐ˜๋ณต ํŒจํ„ด์ด ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{pattern}', + 'recurring.add_done_title': '์ถ”๊ฐ€ ์™„๋ฃŒ', + 'recurring.add_group': '์‹ ๊ทœ ํŒจํ„ด ์ถ”๊ฐ€', + 'recurring.biweekly': '๊ฒฉ์ฃผ', + 'recurring.btn_add': '์ถ”๊ฐ€', + 'recurring.btn_delete_selected': '์„ ํƒ ์‚ญ์ œ', + 'recurring.day_suffix': '์ผ', + 'recurring.deduction_full': '1.0์ผ (์ข…์ผ)', + 'recurring.deduction_half': '0.5์ผ (๋ฐ˜์ฐจ)', + 'recurring.deduction_quarter': '0.25์ผ (๋ฐ˜๋ฐ˜์ฐจ)', + 'recurring.delete_confirm_body': '์ด ๋ฐ˜๋ณต ํŒจํ„ด์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n{item}', + 'recurring.delete_confirm_title': '์‚ญ์ œ ํ™•์ธ', + 'recurring.input_error_title': '์ž…๋ ฅ ์˜ค๋ฅ˜', + 'recurring.input_error_weekday': '์ตœ์†Œ ํ•œ ๊ฐœ ์š”์ผ์„ ์„ ํƒํ•˜์„ธ์š”.', + 'recurring.label_cycle': '์ฃผ๊ธฐ:', + 'recurring.label_deduction': '์ฐจ๊ฐ:', + 'recurring.label_end': '์ข…๋ฃŒ:', + 'recurring.label_memo': '๋ฉ”๋ชจ:', + 'recurring.label_monthly_day': '๋งค์›”:', + 'recurring.label_start': '์‹œ์ž‘:', + 'recurring.label_weekday': '์š”์ผ:', + 'recurring.list_group': '๋“ฑ๋ก๋œ ๋ฐ˜๋ณต ํŒจํ„ด', + 'recurring.memo_placeholder': '์˜ˆ: ์œก์•„ ๋‹จ์ถ•๊ทผ๋ฌด', + 'recurring.monthly': '๋งค์›” N์ผ', + 'recurring.no_end': '์ข…๋ฃŒ ์—†์Œ (๋ฌด๊ธฐํ•œ)', + 'recurring.title': '๋ฐ˜๋ณต ์—ฐ์ฐจ ๊ด€๋ฆฌ', + 'recurring.pattern_weekly': '{prefix} {weekdays}์š”์ผ', + 'recurring.pattern_monthly': '๋งค์›” {day}์ผ', + 'recurring.weekly': '๋งค์ฃผ', + 'schedule.btn_add_leave': '์—ฐ์ฐจ ๋“ฑ๋ก', + 'schedule.btn_recurring': '๋ฐ˜๋ณต ํŒจํ„ด ๊ด€๋ฆฌ', + 'schedule.delete': '์‚ญ์ œ', + 'schedule.delete_leave_confirm_body': '์ด ์—ฐ์ฐจ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (์ž”์•ก์ด ์ž๋™ ๋ณต๊ตฌ๋ฉ๋‹ˆ๋‹ค.)', + 'schedule.delete_leave_confirm_title': '์‚ญ์ œ ํ™•์ธ', + 'schedule.delete_recurring_confirm_body': '์ด ๋ฐ˜๋ณต ํŒจํ„ด์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (์ดํ›„ ๋ชจ๋“  ์ธ์Šคํ„ด์Šค ์ œ๊ฑฐ)', + 'schedule.delete_recurring_confirm_title': '์‚ญ์ œ ํ™•์ธ', + 'schedule.detail_placeholder': '๋‚ ์งœ๋ฅผ ์„ ํƒํ•˜์„ธ์š”', + 'schedule.header': '์›”๊ฐ„ ์Šค์ผ€์ค„ โ€” ํœด์ผ + ์—ฐ์ฐจ + ๋ฐ˜๋ณต ํŒจํ„ด', + 'schedule.holiday': '๊ณตํœด์ผ: {name}', + 'schedule.leave_label': '{type} {days}์ผ', + 'schedule.recurring_item': '{pattern} ยท {days}์ผ ({type})', + 'schedule.legend_half': '๋ฐ˜์ฐจ/๋ฐ˜๋ฐ˜์ฐจ', + 'schedule.legend_holiday': '๊ณตํœด์ผ', + 'schedule.legend_leave_planned': '์—ฐ์ฐจ ์˜ˆ์ •', + 'schedule.legend_leave_used': '์—ฐ์ฐจ ์‚ฌ์šฉ', + 'schedule.legend_recurring': '๋ฐ˜๋ณต ํŒจํ„ด', + 'schedule.no_events': '์ผ์ • ์—†์Œ', + 'schedule.title': '์Šค์ผ€์ค„', + 'schedule.weekend': '์ฃผ๋ง ({weekday})', + 'schedule.weekday_suffix': '์š”์ผ', + 'today.detail_break': '์™ธ์ถœ {minutes}๋ถ„', + 'today.detail_dinner': '์ €๋… {minutes}๋ถ„', + 'today.detail_lunch': '์ ์‹ฌ {minutes}๋ถ„', + 'today.detail_overtime': '์—ฐ์žฅ {actual}๋ถ„ โ†’ ์ ๋ฆฝ {earned}๋ถ„', + 'today.title': '์˜ค๋Š˜์˜ ์š”์•ฝ', + 'today.total_work': '์ด ๊ทผ๋ฌด: {hours}์‹œ๊ฐ„ {minutes}๋ถ„', + 'view.leave.btn_schedule': '์Šค์ผ€์ค„', + 'view.leave.duplicate_register_body': '{date}์— ์ด๋ฏธ {existing_days:.2f}์ผ์ด ๋“ฑ๋ก๋˜์–ด ์žˆ์–ด\n์ถ”๊ฐ€ {days:.2f}์ผ์„ ๋”ํ•˜๋ฉด 1์ผ์„ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค.', + 'view.leave.duplicate_register_title': '์ค‘๋ณต ๋“ฑ๋ก ์ดˆ๊ณผ', + 'view.leave.holiday_register_forbidden_body': '{date}๋Š” ์ด๋ฏธ ๊ณตํœด์ผ({name})์ž…๋‹ˆ๋‹ค.\n์—ฐ์ฐจ๋ฅผ ์ฐจ๊ฐํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', + 'view.leave.holiday_register_forbidden_title': '๊ณตํœด์ผ ๋“ฑ๋ก ๋ถˆ๊ฐ€', + 'view.leave.schedule_tooltip': 'ํœด์ผ + ์—ฐ์ฐจ + ๋ฐ˜๋ณต ํŒจํ„ด ํ†ตํ•ฉ ๋ณด๊ธฐ', + 'view.leave.used_1day': '1์ผ', + 'view.leave.used_half_day': '0.5์ผ (4์‹œ๊ฐ„)', + 'view.leave.used_hours_fmt': '{days}์ผ ({hours}์‹œ๊ฐ„)', + 'view.leave.used_days_fmt': '{days}์ผ', + 'view.leave.weekend_register_forbidden_body': '์ฃผ๋ง์—๋Š” ์—ฐ์ฐจ๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (์ด๋ฏธ ๋น„๊ทผ๋ฌด์ผ)', + 'view.leave.weekend_register_forbidden_title': '์ฃผ๋ง ๋“ฑ๋ก ๋ถˆ๊ฐ€', + # === Achievements === + 'achieve.streak_first.name': '์ฒซ๊ฑธ์Œ', + 'achieve.streak_first.desc': '์ฒซ ์ถœ๊ทผ ๊ธฐ๋ก', + 'achieve.streak_3.name': '๋ฟŒ๋ฆฌ๋‚ด๋ฆผ', + 'achieve.streak_3.desc': '3์ผ ์—ฐ์† ์˜์—…์ผ ์ถœ๊ทผ', + 'achieve.streak_5.name': '์ฒซ ์ฃผ ์™„์ฃผ', + 'achieve.streak_5.desc': '5 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', + 'achieve.streak_7_cal.name': '7์ผ ์—ฐ์†', + 'achieve.streak_7_cal.desc': '์ฃผ๋ง ํฌํ•จ 7์ผ ์—ฐ์† ์ถœ๊ทผ', + 'achieve.streak_10.name': '2์ฃผ ์—ฐ์†', + 'achieve.streak_10.desc': '10 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', + 'achieve.streak_22.name': 'ํ•œ ๋‹ฌ ๊ฐœ๊ทผ', + 'achieve.streak_22.desc': 'ํ•œ ๋‹ฌ ์˜์—…์ผ 100% ์ถœ๊ทผ (22์ผ)', + 'achieve.streak_50.name': '50์ผ ์—ฐ์†', + 'achieve.streak_50.desc': '50 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', + 'achieve.streak_100.name': '100์ผ ์—ฐ์†', + 'achieve.streak_100.desc': '100 ์˜์—…์ผ ์—ฐ์† ์ถœ๊ทผ', + 'achieve.streak_quarter.name': '๋ถ„๊ธฐ ์™„์ฃผ', + 'achieve.streak_quarter.desc': '์•ฝ 65 ์˜์—…์ผ (3๊ฐœ์›”)', + 'achieve.streak_half_year.name': '๋ฐ˜๋…„ ๋งˆ๋ผํ†ค', + 'achieve.streak_half_year.desc': '์•ฝ 130 ์˜์—…์ผ (6๊ฐœ์›”)', + 'achieve.streak_year.name': '1๋…„ ํ’€ ์‹œ์ฆŒ', + 'achieve.streak_year.desc': '์•ฝ 260 ์˜์—…์ผ (1๋…„)', + 'achieve.streak_200.name': '์‚ฌ์ด์–ธ์Šค', + 'achieve.streak_200.desc': '200 ์˜์—…์ผ ์—ฐ์†', + 'achieve.streak_365_cal.name': '๋ถˆ์‚ฌ์‹ ', + 'achieve.streak_365_cal.desc': '365์ผ ๋‹ฌ๋ ฅ ์—ฐ์†', + 'achieve.streak_resilience.name': 'ํšŒ๋ณต๋ ฅ', + 'achieve.streak_resilience.desc': '๊ฒฐ๊ทผ ํ›„ ๋‹ค์Œ๋‚  ์ฆ‰์‹œ ์ถœ๊ทผ (์ž๋™: ๋‹ฌ๋ ฅ streak ๊นจ์ง„ ํ›„ ์žฌ์‹œ์ž‘)', + 'achieve.streak_total_100.name': '๋ˆ„์  100ํšŒ', + 'achieve.streak_total_100.desc': '๋ˆ„์  ์ถœ๊ทผ 100ํšŒ', + 'achieve.streak_total_500.name': '๋ˆ„์  500ํšŒ', + 'achieve.streak_total_500.desc': '๋ˆ„์  ์ถœ๊ทผ 500ํšŒ', + 'achieve.streak_total_1000.name': '๋ˆ„์  1000ํšŒ', + 'achieve.streak_total_1000.desc': '๋ˆ„์  ์ถœ๊ทผ 1000ํšŒ', + 'achieve.punc_before_8_1.name': '์–ผ๋ฆฌ๋ฒ„๋“œ', + 'achieve.punc_before_8_1.desc': '08:00 ์ด์ „ ์ถœ๊ทผ 1ํšŒ', + 'achieve.punc_before_8_10.name': '์ฐธ์ƒˆ์กฑ', + 'achieve.punc_before_8_10.desc': '08:00 ์ด์ „ 10ํšŒ', + 'achieve.punc_before_8_30.name': '์ผ์ฐ ์ž๊ณ  ์ผ์ฐ', + 'achieve.punc_before_8_30.desc': '08:00 ์ด์ „ 30ํšŒ', + 'achieve.punc_before_6_1.name': '์ƒˆ๋ฒฝ์ž  ์—†์Œ', + 'achieve.punc_before_6_1.desc': '06:00 ์ด์ „ 1ํšŒ', + 'achieve.punc_before_6_10.name': '์–ด๋‘ ์„ ๊ฐ€๋ฅด๋Š” ์ž', + 'achieve.punc_before_6_10.desc': '06:00 ์ด์ „ 10ํšŒ', + 'achieve.punc_before_5.name': '์ƒˆ๋ฒฝ ์ฑ”ํ”ผ์–ธ', + 'achieve.punc_before_5.desc': '05:00 ์ด์ „ ์ถœ๊ทผ', + 'achieve.punc_at_9.name': '9์‹œ ์ •๊ฐ', + 'achieve.punc_at_9.desc': '09:00 ์ •๊ฐ(ยฑ1๋ถ„) ์ถœ๊ทผ 1ํšŒ', + 'achieve.punc_at_9_5.name': '์™„๋ฒฝํ•œ 9์‹œ', + 'achieve.punc_at_9_5.desc': '09:00 ์ •๊ฐ(ยฑ1๋ถ„) 5ํšŒ', + 'achieve.punc_late_5min.name': '5๋ถ„ ๋Šฆ์Œ', + 'achieve.punc_late_5min.desc': '09:00~09:05 ์ถœ๊ทผ 1ํšŒ (์ž์กฐ)', + 'achieve.punc_at_909.name': '์šด๋ช…์˜ ์‹œ๊ฐ', + 'achieve.punc_at_909.desc': '09:09 ์ถœ๊ทผ (์‹œํฌ๋ฆฟ)', + 'achieve.bal_first_punct.name': '์ฒซ ์นผํ‡ด', + 'achieve.bal_first_punct.desc': '์ •์‹œ ํ‡ด๊ทผ ์ฒซ ๋‹ฌ์„ฑ', + 'achieve.bal_punct_10.name': '์นผํ‡ด๋Ÿฌ', + 'achieve.bal_punct_10.desc': '์ •์‹œ ํ‡ด๊ทผ 10ํšŒ', + 'achieve.bal_punct_30.name': '์นผํ‡ด ์ฑ”ํ”„', + 'achieve.bal_punct_30.desc': '์ •์‹œ ํ‡ด๊ทผ 30ํšŒ', + 'achieve.bal_punct_100.name': '์ง„์ •ํ•œ ์ž์œ ', + 'achieve.bal_punct_100.desc': '์ •์‹œ ํ‡ด๊ทผ 100ํšŒ', + 'achieve.bal_punct_300.name': '์›Œ๋ผ๋ฐธ ๋งˆ์Šคํ„ฐ', + 'achieve.bal_punct_300.desc': '์ •์‹œ ํ‡ด๊ทผ 300ํšŒ', + 'achieve.ot_first_30m.name': '์ฒซ 30๋ถ„', + 'achieve.ot_first_30m.desc': '์ฒซ ์—ฐ์žฅ ์ ๋ฆฝ', + 'achieve.ot_total_60m.name': '1์‹œ๊ฐ„ ์ ๊ธˆ', + 'achieve.ot_total_60m.desc': '๋ˆ„์  1์‹œ๊ฐ„ ์ ๋ฆฝ', + 'achieve.ot_total_5h.name': '5์‹œ๊ฐ„ ์ ๋ฆฝ', + 'achieve.ot_total_5h.desc': '๋ˆ„์  5์‹œ๊ฐ„', + 'achieve.ot_total_10h.name': '10์‹œ๊ฐ„ ์ ๋ฆฝ', + 'achieve.ot_total_10h.desc': '๋ˆ„์  10์‹œ๊ฐ„', + 'achieve.ot_total_25h.name': '25์‹œ๊ฐ„ ์ ๋ฆฝ', + 'achieve.ot_total_25h.desc': '๋ˆ„์  25์‹œ๊ฐ„', + 'achieve.ot_total_50h.name': '50์‹œ๊ฐ„ ์ ๋ฆฝ', + 'achieve.ot_total_50h.desc': '๋ˆ„์  50์‹œ๊ฐ„', + 'achieve.ot_total_100h.name': '๋งˆ๋ผํ† ๋„ˆ', + 'achieve.ot_total_100h.desc': '๋ˆ„์  100์‹œ๊ฐ„ (๊ฑฑ์ • ๋ฉ”์‹œ์ง€)', + 'achieve.ot_total_200h.name': '์›Œํฌํ™€๋ฆญ ๊ฒฝ๊ณ ', + 'achieve.ot_total_200h.desc': '๋ˆ„์  200์‹œ๊ฐ„ (๊ฒฝ๊ณ )', + 'achieve.ot_total_300h.name': '์œ„ํ—˜ ์‹ ํ˜ธ', + 'achieve.ot_total_300h.desc': '๋ˆ„์  300์‹œ๊ฐ„ (๊ฐ•ํ•œ ๊ฒฝ๊ณ )', + 'achieve.ot_total_500h.name': '์‘๊ธ‰์‹ค ๋‹จ๊ณจ', + 'achieve.ot_total_500h.desc': '๋ˆ„์  500์‹œ๊ฐ„ (์ž์กฐ)', + 'achieve.use_first.name': '์ฒซ ํœด์‹', + 'achieve.use_first.desc': '์ ๋ฆฝ ์ฒซ ์‚ฌ์šฉ', + 'achieve.use_total_5h.name': '์„ ๋ฌผ ์‚ฌ์šฉ', + 'achieve.use_total_5h.desc': '๋ˆ„์  5์‹œ๊ฐ„ ์‚ฌ์šฉ', + 'achieve.use_total_25h.name': 'ํœด์‹์˜ ๊ฐ€์น˜', + 'achieve.use_total_25h.desc': '๋ˆ„์  25์‹œ๊ฐ„ ์‚ฌ์šฉ', + 'achieve.use_total_50h.name': 'ํšŒ๋ณต ๋งˆ์Šคํ„ฐ', + 'achieve.use_total_50h.desc': '๋ˆ„์  50์‹œ๊ฐ„ ์‚ฌ์šฉ', + 'achieve.use_total_100h.name': '๋งˆ์‚ฌ์ง€', + 'achieve.use_total_100h.desc': '๋ˆ„์  100์‹œ๊ฐ„ ์‚ฌ์šฉ', + 'achieve.leave_first.name': '์ฒซ ์—ฐ์ฐจ', + 'achieve.leave_first.desc': '์ฒซ ์—ฐ์ฐจ ์‚ฌ์šฉ', + 'achieve.leave_half.name': '์ฒซ ๋ฐ˜์ฐจ', + 'achieve.leave_half.desc': '0.5์ผ ์—ฐ์ฐจ ์‚ฌ์šฉ', + 'achieve.leave_quarter.name': '์‹œ๊ฐ„ ์—ฐ์ฐจ', + 'achieve.leave_quarter.desc': '0.25์ผ ์—ฐ์ฐจ ์‚ฌ์šฉ', + 'achieve.leave_streak_3.name': '๋ฏธ๋‹ˆ ํœด๊ฐ€', + 'achieve.leave_streak_3.desc': '์—ฐ์† 3์ผ ์—ฐ์ฐจ', + 'achieve.leave_streak_5.name': '๋ณธ๊ฒฉ ํœด๊ฐ€', + 'achieve.leave_streak_5.desc': '์—ฐ์† 5์ผ ์—ฐ์ฐจ', + 'achieve.leave_streak_7.name': '์žฅ๊ฑฐ๋ฆฌ ํœด๊ฐ€', + 'achieve.leave_streak_7.desc': '์—ฐ์† 7์ผ ์ด์ƒ ์—ฐ์ฐจ', + 'achieve.leave_total_10.name': '์—ฐ์ฐจ 10ํšŒ', + 'achieve.leave_total_10.desc': '์—ฐ์ฐจ ๊ธฐ๋ก 10๊ฑด', + 'achieve.leave_sick.name': '๋ณ‘๊ฐ€', + 'achieve.leave_sick.desc': 'sick ํƒ€์ž… ์—ฐ์ฐจ ์‚ฌ์šฉ', + 'achieve.meal_lunch_first.name': '์ฒซ ์ ์‹ฌ ๋“ฑ๋ก', + 'achieve.meal_lunch_first.desc': '์ ์‹ฌ ์ฒซ ํ† ๊ธ€', + 'achieve.meal_lunch_30.name': '์ ์‹ฌ ๋งˆ์Šคํ„ฐ', + 'achieve.meal_lunch_30.desc': '์ ์‹ฌ ๋“ฑ๋ก 30ํšŒ', + 'achieve.meal_lunch_100.name': '์ ์‹ฌ ์ฑ”ํ”„', + 'achieve.meal_lunch_100.desc': '์ ์‹ฌ ๋“ฑ๋ก 100ํšŒ', + 'achieve.meal_dinner_first.name': '์ฒซ ์ €๋… ๋“ฑ๋ก', + 'achieve.meal_dinner_first.desc': '์ €๋… ์ฒซ ํ† ๊ธ€', + 'achieve.meal_dinner_10.name': '์ €๋… ๋‹จ๊ณจ', + 'achieve.meal_dinner_10.desc': '์ €๋… ๋“ฑ๋ก 10ํšŒ (๊ฒฝ๊ณ )', + 'achieve.meal_dinner_30.name': '์•ผ์‹ ๋‹จ๊ณจ', + 'achieve.meal_dinner_30.desc': '์ €๋… ๋“ฑ๋ก 30ํšŒ (๊ฒฝ๊ณ )', + 'achieve.meal_lunch_actual.name': '์‹ค์ธก ์ ์‹ฌ', + 'achieve.meal_lunch_actual.desc': '์‹ค์ œ ์ ์‹ฌ ์‹œ๊ฐ ์ž…๋ ฅ', + 'achieve.meal_dinner_actual.name': '์‹ค์ธก ์ €๋…', + 'achieve.meal_dinner_actual.desc': '์‹ค์ œ ์ €๋… ์‹œ๊ฐ ์ž…๋ ฅ', + 'achieve.break_first.name': '์ฒซ ์™ธ์ถœ', + 'achieve.break_first.desc': '์ฒซ ์™ธ์ถœ ์‹œ์ž‘', + 'achieve.break_10.name': '์™ธ์ถœ ์ฑ”ํ”„', + 'achieve.break_10.desc': '์™ธ์ถœ 10ํšŒ', + 'achieve.break_50.name': '์‚ฐ์ฑ…๋Ÿฌ', + 'achieve.break_50.desc': '์™ธ์ถœ 50ํšŒ', + 'achieve.slot_in_06.name': '06์‹œ๋Œ€ ์ถœ๊ทผ', + 'achieve.slot_in_06.desc': '06:00-06:59 ์ถœ๊ทผ 1ํšŒ', + 'achieve.slot_in_07.name': '07์‹œ๋Œ€ ์ถœ๊ทผ', + 'achieve.slot_in_07.desc': '07:00-07:59 ์ถœ๊ทผ 1ํšŒ', + 'achieve.slot_in_08.name': '08์‹œ๋Œ€ ์ถœ๊ทผ', + 'achieve.slot_in_08.desc': '08:00-08:59 ์ถœ๊ทผ 1ํšŒ', + 'achieve.slot_in_10.name': '10์‹œ๋Œ€ ์ถœ๊ทผ', + 'achieve.slot_in_10.desc': '10์‹œ๋Œ€ ์ถœ๊ทผ (์ง€๊ฐ/์œ ์—ฐ๊ทผ๋ฌด)', + 'achieve.slot_in_11.name': '11์‹œ๋Œ€ ์ถœ๊ทผ', + 'achieve.slot_in_11.desc': '11์‹œ๋Œ€ ์ถœ๊ทผ (์ž์กฐ)', + 'achieve.slot_out_19.name': '19์‹œ๋Œ€ ํ‡ด๊ทผ', + 'achieve.slot_out_19.desc': '19์‹œ๋Œ€ ํ‡ด๊ทผ 10ํšŒ (๊ฒฝ๊ณ )', + 'achieve.slot_out_20.name': '20์‹œ๋Œ€ ํ‡ด๊ทผ', + 'achieve.slot_out_20.desc': '20์‹œ๋Œ€ ํ‡ด๊ทผ 10ํšŒ (๊ฒฝ๊ณ )', + 'achieve.slot_out_21.name': '21์‹œ๋Œ€ ํ‡ด๊ทผ', + 'achieve.slot_out_21.desc': '21์‹œ๋Œ€ ํ‡ด๊ทผ 5ํšŒ (๊ฒฝ๊ณ )', + 'achieve.slot_out_22.name': '22์‹œ๋Œ€ ํ‡ด๊ทผ', + 'achieve.slot_out_22.desc': '22์‹œ๋Œ€ ํ‡ด๊ทผ 1ํšŒ (๊ฒฝ๊ณ )', + 'achieve.slot_out_23.name': '23์‹œ๋Œ€ ํ‡ด๊ทผ', + 'achieve.slot_out_23.desc': '23์‹œ๋Œ€ ํ‡ด๊ทผ 1ํšŒ (๊ฒฝ๊ณ )', + 'achieve.slot_midnight.name': '์ž์ • ํ‡ด๊ทผ', + 'achieve.slot_midnight.desc': '์ž์ • ์ดํ›„ ํ‡ด๊ทผ (๊ฒฝ๊ณ )', + 'achieve.slot_midnight_3.name': '์˜ฌ๋นผ๋ฏธ ํŠธ๋ฆฌ์˜ค', + 'achieve.slot_midnight_3.desc': '์ž์ • ์ดํ›„ ํ‡ด๊ทผ 3ํšŒ (๊ฒฝ๊ณ )', + 'achieve.weekend_1.name': '์ฃผ๋ง ์ถœ๊ทผ 1ํšŒ', + 'achieve.weekend_1.desc': 'ํ† /์ผ ์ถœ๊ทผ 1ํšŒ', + 'achieve.weekend_5.name': '์ฃผ๋ง ์›Œ์ปค', + 'achieve.weekend_5.desc': '์ฃผ๋ง ์ถœ๊ทผ 5ํšŒ (๊ฒฝ๊ณ )', + 'achieve.weekend_20.name': '์ง„์งœ ์›Œํฌํ™€๋ฆญ', + 'achieve.weekend_20.desc': '์ฃผ๋ง ์ถœ๊ทผ 20ํšŒ (๊ฐ•ํ•œ ์ž์กฐ)', + 'achieve.holiday_1.name': '๊ณตํœด์ผ ์ถœ๊ทผ', + 'achieve.holiday_1.desc': 'ํ•œ๊ตญ ๊ณตํœด์ผ ์ถœ๊ทผ 1ํšŒ', + 'achieve.holiday_5.name': '๊ณตํœด์ผ ์›Œ์ปคํ™€๋ฆญ', + 'achieve.holiday_5.desc': 'ํ•œ๊ตญ ๊ณตํœด์ผ ์ถœ๊ทผ 5ํšŒ (๊ฒฝ๊ณ )', + 'achieve.day_christmas.name': 'ํฌ๋ฆฌ์Šค๋งˆ์Šค ์ถœ๊ทผ', + 'achieve.day_christmas.desc': '12/25 ์ถœ๊ทผ (์ž์กฐ)', + 'achieve.day_newyear.name': '์‹ ์ • ์ถœ๊ทผ', + 'achieve.day_newyear.desc': '1/1 ์ถœ๊ทผ (์ž์กฐ)', + 'achieve.day_liberation.name': '๊ด‘๋ณต์ ˆ ์ถœ๊ทผ', + 'achieve.day_liberation.desc': '8/15 ์ถœ๊ทผ', + 'achieve.day_children.name': '์–ด๋ฆฐ์ด๋‚  ์ถœ๊ทผ', + 'achieve.day_children.desc': '5/5 ์ถœ๊ทผ (์ž์กฐ)', + 'achieve.day_hangul.name': 'ํ•œ๊ธ€๋‚  ์ถœ๊ทผ', + 'achieve.day_hangul.desc': '10/9 ์ถœ๊ทผ', + 'achieve.day_valentine.name': '๋ฐœ๋ Œํƒ€์ธ๋ฐ์ด ์ถœ๊ทผ', + 'achieve.day_valentine.desc': '2/14 ์ถœ๊ทผ', + 'achieve.day_white.name': 'ํ™”์ดํŠธ๋ฐ์ด ์ถœ๊ทผ', + 'achieve.day_white.desc': '3/14 ์ถœ๊ทผ', + 'achieve.day_pepero.name': '๋นผ๋นผ๋กœ๋ฐ์ด', + 'achieve.day_pepero.desc': '11/11 ์ถœ๊ทผ', + 'achieve.day_halloween.name': 'ํ•ผ๋Ÿฌ์œˆ ์ถœ๊ทผ', + 'achieve.day_halloween.desc': '10/31 ์ถœ๊ทผ', + 'achieve.day_aprilfools.name': '๋งŒ์šฐ์ ˆ ์ถœ๊ทผ', + 'achieve.day_aprilfools.desc': '4/1 ์ถœ๊ทผ', + 'achieve.day_77.name': '์น ์›”์น ์„', + 'achieve.day_77.desc': '7/7 ์ถœ๊ทผ', + 'achieve.day_dongji.name': '๋™์ง€ ์ถœ๊ทผ', + 'achieve.day_dongji.desc': '12/22 ์ถœ๊ทผ', + 'achieve.day_parents.name': '์–ด๋ฒ„์ด๋‚  ์ •์‹œ ํ‡ด๊ทผ', + 'achieve.day_parents.desc': '5/8 ์ •์‹œ ํ‡ด๊ทผ', + 'achieve.day_teacher.name': '์Šค์Šน์˜ ๋‚  ์ •์‹œ ํ‡ด๊ทผ', + 'achieve.day_teacher.desc': '5/15 ์ •์‹œ ํ‡ด๊ทผ', + 'achieve.day_xmas_eve.name': 'ํฌ๋ฆฌ์Šค๋งˆ์Šค์ด๋ธŒ ์ •์‹œ ํ‡ด๊ทผ', + 'achieve.day_xmas_eve.desc': '12/24 ์ •์‹œ ํ‡ด๊ทผ', + 'achieve.day_earth.name': '์ง€๊ตฌ์˜ ๋‚ ', + 'achieve.day_earth.desc': '4/22 ์ถœ๊ทผ (์‹œํฌ๋ฆฟ)', + 'achieve.season_jan.name': '1์›” ์ •์ฐฉ', + 'achieve.season_jan.desc': '1์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', + 'achieve.season_feb.name': '2์›” ์ •์ฐฉ', + 'achieve.season_feb.desc': '2์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', + 'achieve.season_mar.name': '๋ด„์„ ๋งž์ด', + 'achieve.season_mar.desc': '3์›” ์ฒซ ์ถœ๊ทผ', + 'achieve.season_apr.name': '4์›” ์ •์ฐฉ', + 'achieve.season_apr.desc': '4์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', + 'achieve.season_may.name': '5์›” ์ •์ฐฉ', + 'achieve.season_may.desc': '5์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', + 'achieve.season_jun.name': '์—ฌ๋ฆ„์˜ ์‹œ์ž‘', + 'achieve.season_jun.desc': '6์›” ์ฒซ ์ถœ๊ทผ', + 'achieve.season_jul.name': '7์›” ์ •์ฐฉ', + 'achieve.season_jul.desc': '7์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', + 'achieve.season_aug.name': '8์›” ์ •์ฐฉ', + 'achieve.season_aug.desc': '8์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', + 'achieve.season_sep.name': '๊ฐ€์„์˜ ์‹œ์ž‘', + 'achieve.season_sep.desc': '9์›” ์ฒซ ์ถœ๊ทผ', + 'achieve.season_oct.name': '10์›” ์ •์ฐฉ', + 'achieve.season_oct.desc': '10์›” ํ•œ ๋‹ฌ ์ถœ๊ทผ', + 'achieve.season_nov.name': '11์›” ๋‹จํ’', + 'achieve.season_nov.desc': '11์›” ์˜์—…์ผ ๋ชจ๋‘ ์ถœ๊ทผ', + 'achieve.season_dec.name': '๊ฒจ์šธ์˜ ์‹œ์ž‘', + 'achieve.season_dec.desc': '12์›” ์ฒซ ์ถœ๊ทผ', + 'achieve.mile_first.name': 'Hello, World!', + 'achieve.mile_first.desc': '์•ฑ ์ฒซ ์‹คํ–‰', + 'achieve.mile_7days.name': '์ผ์ฃผ์ผ ์‚ฌ์šฉ', + 'achieve.mile_7days.desc': '7์ผ ์‚ฌ์šฉ', + 'achieve.mile_30days.name': 'ํ•œ ๋‹ฌ ์‚ฌ์šฉ', + 'achieve.mile_30days.desc': '30์ผ ์‚ฌ์šฉ', + 'achieve.mile_365days.name': '1์ฃผ๋…„', + 'achieve.mile_365days.desc': '365์ผ ์‚ฌ์šฉ', + 'achieve.mile_730days.name': '2์ฃผ๋…„', + 'achieve.mile_730days.desc': '730์ผ ์‚ฌ์šฉ', + 'achieve.mile_1095days.name': '3์ฃผ๋…„', + 'achieve.mile_1095days.desc': '3๋…„ ์‚ฌ์šฉ', + 'achieve.mile_5years.name': '5๋…„ ์‚ฌ์šฉ์ž', + 'achieve.mile_5years.desc': '5๋…„ ์‚ฌ์šฉ', + 'achieve.mile_10years.name': '10๋…„ ์‚ฌ์šฉ์ž', + 'achieve.mile_10years.desc': '10๋…„ ์‚ฌ์šฉ', + 'achieve.stat_weekly_10.name': '์ฃผ๊ฐ„ ํ†ต๊ณ„๋Ÿฌ', + 'achieve.stat_weekly_10.desc': '์ฃผ๊ฐ„ ํƒญ 10ํšŒ ์กฐํšŒ', + 'achieve.stat_monthly_10.name': '์›”๊ฐ„ ํ†ต๊ณ„๋Ÿฌ', + 'achieve.stat_monthly_10.desc': '์›”๊ฐ„ ํƒญ 10ํšŒ', + 'achieve.stat_pattern_10.name': 'ํŒจํ„ด ๋ถ„์„๊ฐ€', + 'achieve.stat_pattern_10.desc': 'ํŒจํ„ด ํƒญ 10ํšŒ', + 'achieve.stat_calendar_30.name': '์บ˜๋ฆฐ๋” ์ฑ”ํ”„', + 'achieve.stat_calendar_30.desc': '์บ˜๋ฆฐ๋” 30ํšŒ ์กฐํšŒ', + 'achieve.stat_report_first.name': '์ผ์ผ ๋ณด๊ณ ์„œ ์ฒซ ์ƒ์„ฑ', + 'achieve.stat_report_first.desc': '์ผ์ผ ๋ณด๊ณ  1ํšŒ', + 'achieve.stat_report_30.name': '๋ณด๊ณ ์„œ ์ฑ”ํ”„', + 'achieve.stat_report_30.desc': '์ผ์ผ ๋ณด๊ณ  30ํšŒ', + 'achieve.stat_chart_hover.name': '์ฐจํŠธ ํ˜ธ๋ฒ„ ๋ฐœ๊ฒฌ', + 'achieve.stat_chart_hover.desc': '์ฐจํŠธ hover ์ฒซ ๋ฐœ๊ฒฌ', + 'achieve.stat_achievements_open.name': '๋„์ „๊ณผ์ œ ๋ฐ•๋ฌผ๊ด€', + 'achieve.stat_achievements_open.desc': '๋„์ „๊ณผ์ œ ๋ทฐ 50ํšŒ', + 'achieve.secret_palindrome.name': 'ํšŒ๋ฌธ ์‹œ๊ฐ', + 'achieve.secret_palindrome.desc': '์ถœ๊ทผ ์‹œ๊ฐ์ด ํšŒ๋ฌธ', + 'achieve.secret_jackpot.name': '์žญํŒŸ ์‹œ๊ฐ', + 'achieve.secret_jackpot.desc': '์ถœ๊ทผ ์‹œ๊ฐ ๋ชจ๋“  ์ž๋ฆฟ์ˆ˜ ๋™์ผ', + 'achieve.secret_fri13.name': '13์ผ ๊ธˆ์š”์ผ', + 'achieve.secret_fri13.desc': '13์ผ ๊ธˆ์š”์ผ ์ถœ๊ทผ', + 'achieve.secret_777.name': '7-7-7', + 'achieve.secret_777.desc': '7์›” 7์ผ 7์‹œ 7๋ถ„ ์ถœ๊ทผ', + 'achieve.secret_exact_8h.name': '์ •ํ™• 8์‹œ๊ฐ„', + 'achieve.secret_exact_8h.desc': '์ •ํ™•ํžˆ 8h 0m ๊ทผ๋ฌด', + 'achieve.secret_pi_day.name': 'ํŒŒ์ด ๋ฐ์ด', + 'achieve.secret_pi_day.desc': '3/14 01:59 ์ถœ๊ทผ', + 'achieve.secret_fibonacci.name': 'ํ”ผ๋ณด๋‚˜์น˜', + 'achieve.secret_fibonacci.desc': '์ถœ๊ทผ ๋ถ„์ด ํ”ผ๋ณด๋‚˜์น˜ ์ˆ˜', + 'achieve.secret_double_six.name': '๋”๋ธ” ์‹์Šค', + 'achieve.secret_double_six.desc': '6/6 18:06 ์ถœ๊ทผ', + 'achieve.secret_anniversary.name': '๋งˆ๋ฒ•์‚ฌ', + 'achieve.secret_anniversary.desc': '๊ฐ€์ž… ํ›„ ์ •ํ™•ํžˆ 365์ผ ํ›„ ์ถœ๊ทผ', + 'achieve.set_dark.name': '๋‹คํฌ ์‚ฌ์ด๋“œ', + 'achieve.set_dark.desc': '๋‹คํฌ ํ…Œ๋งˆ 1ํšŒ ์‚ฌ์šฉ', + 'achieve.set_lang.name': '์ด์ค‘์–ธ์–ด', + 'achieve.set_lang.desc': '์–ธ์–ด ๋ณ€๊ฒฝ (en ์‚ฌ์šฉ)', + 'achieve.set_a11y.name': '์ ‘๊ทผ์„ฑ ํ™œ์šฉ', + 'achieve.set_a11y.desc': '๊ธ€๊ผด ํฌ๊ธฐโ‰ 100% ๋˜๋Š” ๊ณ ๋Œ€๋น„ ON', + 'achieve.set_overtime_unit.name': '๋‹จ์œ„ ๋ณ€๊ฒฝ', + 'achieve.set_overtime_unit.desc': 'overtime_unit ๋ณ€๊ฒฝ', + 'achieve.set_goal_full.name': '๋ชฉํ‘œ ๋งˆ์Šคํ„ฐ', + 'achieve.set_goal_full.desc': '์›” ์—ฐ์žฅ+์ผํ‰๊ท  ๋‘˜ ๋‹ค ์„ค์ •', + 'achieve.set_discord_full.name': 'ํ’€ ์…‹์—…', + 'achieve.set_discord_full.desc': 'Discord URL + ๋ชจ๋“  ์•Œ๋ฆผ ON', + 'achieve.set_cloud.name': 'ํด๋ผ์šฐ๋“œ ๋™๊ธฐํ™”', + 'achieve.set_cloud.desc': 'DB ๊ฒฝ๋กœ ๋ณ€๊ฒฝ', + 'achieve.meta_first.name': '์ฒซ ๋„์ „๊ณผ์ œ', + 'achieve.meta_first.desc': '์ฒซ ๋„์ „๊ณผ์ œ ํš๋“', + 'achieve.meta_10.name': '10๊ฐœ ๋‹ฌ์„ฑ', + 'achieve.meta_10.desc': '10๊ฐœ ๋ณด์œ ', + 'achieve.meta_25.name': '25๊ฐœ ๋‹ฌ์„ฑ', + 'achieve.meta_25.desc': '25๊ฐœ ๋ณด์œ ', + 'achieve.meta_50.name': '50๊ฐœ ๋‹ฌ์„ฑ', + 'achieve.meta_50.desc': '50๊ฐœ ๋ณด์œ ', + 'achieve.meta_75.name': '75๊ฐœ ๋‹ฌ์„ฑ', + 'achieve.meta_75.desc': '75๊ฐœ ๋ณด์œ ', + 'achieve.meta_100.name': '100๊ฐœ ๋‹ฌ์„ฑ', + 'achieve.meta_100.desc': '100๊ฐœ ๋ณด์œ ', + 'achieve.meta_secret_1.name': '์‹œํฌ๋ฆฟ ๋ฐœ๊ฒฌ', + 'achieve.meta_secret_1.desc': '์ฒซ ์‹œํฌ๋ฆฟ ๋ฐœ๊ฒฌ', + 'achieve.meta_secret_5.name': '์‹œํฌ๋ฆฟ ํ—Œํ„ฐ', + 'achieve.meta_secret_5.desc': '์‹œํฌ๋ฆฟ 5๊ฐœ ๋ฐœ๊ฒฌ', + 'achieve.streak_monday_10.name': '์›”์š”์ผ ์ •๋ณต', + 'achieve.streak_monday_10.desc': '์›”์š”์ผ 10์ฃผ ์—ฐ์† ์ถœ๊ทผ', + 'achieve.streak_friday_10.name': '๊ธˆ์š”์ผ ๋ฌด๊ฒฐ', + 'achieve.streak_friday_10.desc': '๊ธˆ์š”์ผ 10์ฃผ ์—ฐ์† ์ถœ๊ทผ', +}, 'en': { # === Menu/Buttons === 'menu.stats': 'Stats', @@ -312,7 +1237,8 @@ _DICT = { 'label.remaining': 'Remaining', 'label.overtime_progress': 'Overtime', 'label.expected_clock_out': 'Expected Clock-out', - 'label.clock_in_time': 'Clock-in Time', + 'label.clock_in_time': 'Clock-in', + 'label.current_time': 'Current', 'label.clock_out_time': 'Clock-out Time', 'label.work_hours_label': 'Daily work:', 'label.lunch_default': 'Lunch break:', @@ -334,6 +1260,7 @@ _DICT = { 'label.weekday_sun': 'Sun', 'label.am': 'AM', 'label.pm': 'PM', + 'label.recurring_yearly': ' (yearly)', # === Notifications === 'notif.clock_out_soon.title': 'โฐ Clock-out Soon', @@ -366,6 +1293,16 @@ _DICT = { 'notif.health.body': "{days} consecutive days of overtime.\nTake care of your health!", 'notif.weekly_52.title': '๐Ÿšจ Weekly 52h Exceeded', 'notif.weekly_52.body': "This week's total work hours: {hours:.1f}\nLegal limit exceeded!", + 'notif.weekly_report.title': '๐Ÿ“Š Last Week Summary', + 'notif.weekly_report.body': 'Period: {start} ~ {end}\nTotal: {total_h:.1f}h ({days} days)\nDaily Avg: {avg_h:.1f}h\nOvertime: {ot_h}h {ot_m}m\nLongest Day: {longest}', + 'field.total_work': 'Total Work', + 'field.avg_daily': 'Daily Avg', + 'field.overtime': 'Overtime', + 'field.longest_day': 'Longest Day', + 'notif.achievement.title': '{icon} Achievement Unlocked!', + 'notif.achievement.body': '{name}\n{description}', + 'discord.achievement.title': '๐Ÿ† {count} Achievements Unlocked!', + 'discord.achievement.body': 'Newly unlocked achievements.{extra}', # === Message Boxes === 'msg.save_success.title': 'Saved', @@ -412,6 +1349,25 @@ _DICT = { 'stats.pattern_insights': 'Work Pattern Insights', 'stats.analyzing': 'Analyzing...', 'stats.no_data': 'Not enough data yet.', + 'stats.total_work_hours': 'Total Work Hours', + 'stats.card_work_days': 'Work Days', + 'stats.card_avg_hours': 'Daily Avg', + 'stats.card_overtime': 'Overtime', + 'stats.this_week': 'This Week', + 'stats.this_month': 'This Month', + 'stats.daily_work_hours': 'Daily Work Hours', + 'stats.weekday_avg': 'Avg by Weekday', + 'stats.clock_in_distribution': 'Clock-in Distribution', + 'stats.no_pattern_data': 'Not enough data to analyze patterns.', + 'stats.value_hours': '{hours}h', + 'stats.value_hours_minutes': '{hours}h {minutes}m', + 'stats.value_days': '{days}d', + 'stats.avg_clock_in': '๐Ÿ“Œ Avg clock-in: {time}', + 'stats.overtime_frequency': '๐Ÿ“Œ OT frequency: {rate}% ({days}/{total} days)', + 'stats.longest_work': '๐Ÿ“Œ Longest day: {date} ({hours}h)', + 'stats.consecutive_ot_warning': 'โš ๏ธ {days} consecutive OT days!', + 'stats.weekly_52_exceeded': '๐Ÿšจ Weekly 52h exceeded: {hours}h', + 'stats.salary_estimate': '๐Ÿ’ฐ Est. salary: {total} (base {base} + OT {overtime})', # === CalendarView === 'cal.title': 'Monthly Calendar', @@ -528,7 +1484,877 @@ _DICT = { '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}', - }, + + # === Main Window additions === + 'app.title': 'Clock-out Time Calculator', + 'group.today_work': "Today's Work", + 'group.remaining_time': 'Remaining Time', + 'group.overtime_leave': 'Overtime & Leave Status', + 'tooltip.meal_click': 'Left click: toggle / Right click: enter actual time', + 'tooltip.clock_in_edit': 'Click to edit clock-in time', + 'btn.achievements': 'Achievements', + 'btn.break_manage': 'Manage Breaks', + 'section.overtime_earned': 'Overtime Banked', + 'section.leave': 'Annual Leave', + 'section.total_time': 'Total Banked Time', + 'btn.use_30min': '30m', + 'btn.use_1hour': '1h', + 'btn.use_2hour': '2h', + 'btn.custom_input': 'Custom', + 'btn.detail': 'Detail', + 'btn.half_leave': 'Half-day', + 'btn.full_leave': 'Full-day', + 'msg.auto_clock_in.title': 'Auto Clock-in Detected', + 'msg.auto_clock_in.body': 'Clock-in time was automatically detected.\nClock-in: {time}\n\nYou can edit it if incorrect.', + 'msg.manual_clock_in.title': 'Enter Clock-in Time', + 'msg.manual_clock_in.body': 'Could not detect clock-in time automatically.\n\nEnter manually?\n(Run as administrator for auto detection.)', + 'msg.full_day_leave.title': 'Full-day Leave Registered', + 'msg.full_day_leave.body': 'Today is registered as full-day leave.\nDo you still want to clock in?\n\n(All time will be banked as overtime.)', + 'label.full_day_leave_override': 'Leave Override (Full Bank)', + 'label.weekend_work': 'Weekend Work (Full Bank)', + 'label.holiday_work': 'Holiday Work (Full Bank)', + 'label.holiday_work_no_clock_out': 'Holiday Work (No fixed clock-out)', + 'label.holiday_default': 'Holiday', + 'label.expected_clock_out_prefix': 'Expected: ', + 'label.total_work_hours': 'Total work: {hours:.1f}h', + 'label.weekend_work_tag': '[Weekend Work]', + 'label.holiday_work_tag': '[Holiday Work - {name}]', + 'label.overtime_earned_msg': 'Overtime earned: {time} (๐Ÿ•ร—{tokens})', + 'label.full_earned_msg': 'Full earned: {time} (๐Ÿ•ร—{tokens})', + 'label.full_day_leave_today': 'Today is leave', + 'label.full_day_leave_in_use': 'Leave in use', + 'label.full_day_leave_format': '{type} โ€” {memo}', + 'label.vacation': '๐ŸŒด Leave', + 'label.time_hours_minutes': '{hours}h {minutes}m', + 'time_format.12h': '{hour}:{minute} {period}', + 'break.status_in_progress': 'On break (since {time})', + 'break.status_total_hours_minutes': 'Total break today: {hours}h {minutes}m', + 'break.status_total_minutes': 'Total break today: {minutes}m', + 'break.reason.lock': 'Screen lock', + 'break.cannot_no_clock_in.title': 'Cannot Start Break', + 'break.cannot_no_clock_in.body': 'Not clocked in.', + 'break.cannot_already_on_break.body': 'Already on break.', + 'break.cannot_no_record.body': 'No clock-in record found.', + 'break.started.title': 'Break Started', + 'break.started.body': 'Break started: {time}', + 'break.cannot_return_no_active.body': 'No active break record found.', + 'break.cannot_return_corrupt.body': 'Break record is corrupted.', + 'break.cannot_return_unusable.body': 'Break record too corrupted to return.', + 'break.return.title': 'Return', + 'break.return.body': 'Returned: {time}\nBreak: {minutes}m', + 'mini.open_main': 'Open main window', + 'mini.close': 'Close mini widget', + 'msg.clock_out_confirm.title': 'Clock-out Confirm', + 'msg.clock_out_confirm.body': 'Clock out now?\n\nClock-out: {time}', + 'msg.auto_overtime_confirm.title': 'Confirm Overtime Bank', + 'msg.auto_overtime_confirm.body': 'Overtime {actual} occurred, {earned} eligible for banking.\n\nBank it?\n(No = this clock-out will not be banked)', + 'msg.clock_out_done.title': 'Clock-out Complete', + 'msg.clock_out_done.body': 'Clocked out!\n\n{type_info}{total_work}\n{overtime_info}', + 'msg.cancel_clock_out_confirm.body': 'Cancel clock-out?\n\nClock-out time and overtime bank entries will be deleted.', + 'msg.cancel_clock_out_done.title': 'Clock-out Cancelled', + 'msg.cancel_clock_out_done.body': 'Clock-out cancelled.\nReturned to working state.', + 'msg.cancel_clock_out_fail.title': 'Cancel Failed', + 'msg.cancel_clock_out_fail.body': 'Clock-out record not found.', + 'msg.error.title': 'Error', + 'msg.error.body': 'Error while {action}:\n{error}', + 'msg.input.title': 'Enter Time', + 'msg.input_error.date_format': 'Invalid date format.\nCorrect format: YYYY-MM-DD (e.g. 2024-01-15)', + 'msg.input_error.overtime_unit': 'Only 30-minute units allowed.\nExamples: 0.5h, 1h, 1.5h', + 'msg.overtime_use.title': 'Use Overtime', + 'msg.overtime_use_minus.title': 'Use Overtime (Negative)', + 'msg.overtime_use.body': 'Use {minutes}m of overtime?\n\nCurrent balance: {balance}m\nNew balance: {new_balance}m', + 'msg.overtime_use_minus.body': 'Use {minutes}m of overtime?\n\nCurrent balance: {balance}m\nNew balance: {new_balance}m (negative)\n\nโš ๏ธ Balance will be negative.\nYou must earn overtime later to cover it.', + 'msg.overtime_use_done.title': 'Used', + 'msg.overtime_use_done.body': '{minutes}m used.', + 'msg.overtime_use_fail.title': 'Use Failed', + 'msg.overtime_input.body': 'Enter hours to use (0.5h units):\nEx) 0.5, 1, 1.5, 2, 3, 4', + 'msg.leave_use.title': 'Use Leave', + 'msg.leave_use_date.title': 'Leave Date', + 'msg.leave_use_date.body': 'Enter date (YYYY-MM-DD):', + 'msg.leave_use_reason.title': 'Leave Reason', + 'msg.leave_use_reason.body': 'Enter reason (optional):', + 'msg.leave_use_confirm.body': 'Use {type} {days} on {date}?\n\nBalance after: {balance_after} days', + 'msg.leave_use_done.title': 'Used', + 'msg.leave_use_done.body': '{type} used.', + 'msg.leave_use_impossible.title': 'Cannot Use', + 'msg.leave_short.title': 'Insufficient Balance', + 'msg.leave_short.body': 'Insufficient leave balance.\nCurrent: {balance} days\nRequested: {days} days', + 'msg.leave_short_hours.body': 'Insufficient leave balance.\nCurrent: {balance} days ({balance_hours}h)\nRequested: {days} days ({hours}h)', + 'msg.settings_updated.title': 'Settings Updated', + 'msg.settings_updated.body': 'Settings changes applied.', + 'msg.meal_need_clock_in.title': 'Clock-in Required', + 'msg.meal_need_clock_in.body': 'Meal times can only be recorded after clocking in.', + 'msg.meal_recorded.title': 'Recorded', + 'msg.meal_recorded.body': '{meal} {minutes}m recorded.\n({start} ~ {end})', + 'msg.meal_actual_input': 'Enter actual {meal} time...', + 'msg.workday_boundary.title': 'Workday Boundary Crossed', + 'msg.workday_boundary.body': 'Workday boundary ({hour}:00) passed; auto-processed.\n\nPrevious day: clocked out at {before}\nToday: clocked in at {boundary}\n\nWork between midnight and {hour}:00 is counted as previous day overtime.', + 'msg.new_workday.title': 'New Workday', + 'msg.new_workday.body': 'New workday ({date}).\n\nClock in?', + 'msg.clock_in_set.title': 'Set Clock-in', + 'msg.clock_in_set.body': 'Clock-in time set.\n\nClock-in: {time}', + 'leave.type.annual': 'Annual Leave', + 'leave.type.sick': 'Sick Leave', + 'leave.type.hourly_leave': 'Hourly Leave', + 'leave.use.full_day': '1 day', + 'leave.use.half_day': '0.5 day (4h)', + 'leave.use.hour_1': '0.125 day (1h)', + 'leave.use.min_30': '0.0625 day (30m)', + 'leave.use.custom': '{days} days ({hours}h)', + 'leave.type.half_am': 'AM Half-day', + 'leave.type.half_pm': 'PM Half-day', + 'leave.type.time_off': 'Time Off', + 'leave.type.half': 'Half-day', + 'leave.type.quarter': 'Quarter-day', + 'report.leave_used': '๐ŸŒด Leave used: {value}', + 'report.leave_used_days_hours': '{days}d {hours}h', + 'report.leave_used_days': '{days}d', + 'report.leave_used_hours': '{hours}h', + 'report.leave_detail': ' - {type}: {time}{memo}', + 'report.title': '๐Ÿ“‹ Daily Work Report - {date}', + 'report.clock_in': '๐Ÿ• Clock-in: {time}', + 'report.clock_out': '๐Ÿ• Clock-out: {time}', + 'report.not_clocked_out': '๐Ÿ• Clock-out: not yet', + 'report.total_work': 'โฑ๏ธ Total work: {time}', + 'report.break_time': '๐Ÿšถ Break: {time}', + 'report.break_detail': ' - {start} ~ {end} ({duration}m){reason}', + 'report.break_in_progress': ' - {start} ~ on break{reason}', + 'report.meal_actual': '{label}: {time} (actual)', + 'report.meal_in_progress': ' - {start} ~ in progress', + 'report.meal_default': '{label}: included ({time})', + 'report.memo': '๐Ÿ“ Memo: {memo}', + 'report.copied.title': 'Report Copied', + 'report.copied.body': 'Daily work report copied to clipboard.\n\n{report}', + 'label.lunch': '๐Ÿฑ Lunch', + 'label.lunch_short': 'Lunch', + 'label.dinner': '๐Ÿฝ๏ธ Dinner', + 'label.dinner_short': 'Dinner', + 'label.overtime_balance_zero': '0m (ร—0)', + 'label.leave_balance_zero': 'Balance: 0 days', + 'label.today_estimate': 'Today est.: {amount}', + 'label.in_progress': 'in progress', + 'label.break_returning': 'on break', + 'label.meal_actual_suffix': 'actual', + 'label.meal_included': 'included', + 'report.overtime_occurred': 'โฐ Overtime occurred: {time}', + 'report.overtime_banked': ' ๐Ÿ’ฐ Banked: {time} (30-min trunc)', + 'report.overtime_used': '๐Ÿ• Overtime used: {time}', + 'report.overtime_used_detail': ' - {time}{reason}', + 'update.new_version_title': 'New Version Found', + 'update.apply_failed_title': 'Update Failed', + 'update.check_title': 'Update Check', + 'update.up_to_date': 'You are on the latest version (v{version}).', + 'update.network_error': 'Cannot connect to update server.\nPlease check your network.', + 'update.no_release': 'No release found in update repository.\n(Private repo or before first release)', + 'update.no_asset': 'New version exists but no downloadable main.exe asset.\nContact administrator.', + 'update.unknown': 'Unknown response.', + 'update.new_found_dev': 'New version {version} available.\n(Auto update not available in development โ€” git pull or build)', + 'update.new_found': 'Current: v{current}\nNew: v{new}\n\nRelease notes:\n{notes}\n\nDownload and update now?', + 'update.downloading': 'Downloading...', + 'update.download_title': 'Update Download', + 'update.download_failed': 'Failed to download new version.', + 'update.updater_failed': 'updater.exe not found or failed to run.', + 'update.restart': 'Program will restart to apply update.', + 'settings.title': 'Settings', + 'settings.work_pattern': 'Work pattern:', + 'settings.preset.standard_8h': 'Standard 8h (Lunch 60min)', + 'settings.preset.short_7h30m': 'Reduced 7h 30m (Lunch 30min)', + 'settings.preset.short_7h': 'Reduced 7h (Lunch 60min)', + 'settings.preset.short_6h': 'Reduced 6h (Lunch 30min)', + 'settings.preset.half_4h': 'Half-day 4h (No lunch)', + 'settings.preset.custom': 'Custom', + 'settings.daily_work': 'Daily work:', + 'settings.lunch_default': 'Lunch break:', + 'settings.dinner_default': 'Dinner break:', + 'settings.auto_apply': 'Auto apply', + 'settings.auto_apply_tooltip': 'Auto-apply 4 hours after clock-in', + 'settings.suffix_hour': ' h', + 'settings.suffix_minute': ' min', + 'settings.notif_clock_out': 'Clock-out 30-min reminder', + 'settings.notif_lunch': 'Lunch reminder', + 'settings.notif_dinner': 'Dinner reminder', + 'settings.notif_overtime': 'Overtime bank reminder', + 'settings.notif_health': 'Health warning', + 'settings.notif_break': 'Break reminder', + 'settings.notif_break_tooltip': 'Suggest stretching after long continuous work', + 'settings.notif_before': 'Clock-out alert:', + 'settings.notif_before_spin_suffix': 'min before', + 'settings.notif_before_tooltip': 'Minutes before clock-out to show alert', + 'settings.advanced_thresholds': 'Advanced thresholds', + 'settings.advanced_thresholds_tooltip': 'Adjust alert thresholds', + 'settings.lunch_alert_after': 'Lunch alert (clock-in +):', + 'settings.lunch_alert_tooltip': 'Hours after clock-in to remind lunch', + 'settings.dinner_alert_after': 'Dinner alert (clock-in +):', + 'settings.dinner_alert_tooltip': 'Hours after clock-in to remind dinner', + 'settings.overtime_alert_at': 'Overtime balance alert:', + 'settings.overtime_alert_tooltip': 'Alert when overtime balance reaches N hours', + 'settings.weekly_limit': 'Weekly limit warning:', + 'settings.weekly_limit_tooltip': 'Warn when weekly work exceeds N hours', + 'settings.consecutive_ot': 'Consecutive OT warning:', + 'settings.consecutive_ot_tooltip': 'Health warning after N consecutive OT days', + 'settings.break_after': 'Break suggestion:', + 'settings.break_after_tooltip': 'Suggest break after N hours', + 'settings.time_format': 'Time format:', + 'settings.time_format_12': 'AM/PM (5:30 PM)', + 'settings.time_format_24': '24-hour (17:30)', + 'settings.theme': 'Theme:', + 'settings.theme_light': 'Light', + 'settings.theme_dark': 'Dark', + 'settings.font_scale': 'Font scale:', + 'settings.high_contrast': 'High contrast mode', + 'settings.high_contrast_tooltip': 'Black background + yellow text', + 'settings.language_restart_tooltip': 'Language changes fully apply after restart.', + 'settings.current_balance': 'Current balance: calculating...', + 'settings.calc_unit': 'Calculation unit:', + 'settings.initial_overtime': 'Previous overtime:', + 'settings.auto_bank': 'Auto bank', + 'settings.auto_bank_tooltip': 'Bank overtime on clock-out', + 'settings.initial_overtime_note': 'โ€ป Overtime accumulated before using this program (absolute value)', + 'settings.goal_group': 'Monthly goals (0=disabled)', + 'settings.monthly_ot_cap': 'Monthly OT cap:', + 'settings.daily_avg_goal': 'Daily avg goal:', + 'settings.goal_note': 'โ€ป Check progress in Stats โ†’ Monthly tab', + 'settings.annual_leave': 'Annual leave:', + 'settings.remaining_leave': 'Remaining leave: calculating...', + 'settings.used_leave': 'Previously used:', + 'settings.used_leave_note': 'โ€ป Leave already used before this program (1 day = 8h)', + 'settings.registered': 'Registered:', + 'settings.add_korean_holidays': 'Korean holidays (auto)', + 'settings.add_korean_holidays_tooltip': 'Auto-register lunar holidays + temporary holidays', + 'settings.holiday_note': 'โ€ป All work on holidays is banked as overtime', + 'settings.list': 'List', + 'settings.korean_holidays_title': 'Korean holidays added', + 'settings.korean_holidays_years_label': '{start} + {end}', + 'settings.korean_holidays_years_label_single': '{year}', + 'settings.korean_holidays_body': 'Auto-register Korean holidays for {years}?\n\nIncludes:\nโ€ข Solar holidays (New Year, Independence, Children\'s, Labor Day, etc.)\nโ€ข Lunar holidays (Seollal, Chuseok, Buddha\'s Birthday)\nโ€ข Government-designated substitute/temporary holidays\n\nโ€ป Primary: Public Data Portal special-day API\nโ€ป Fallback: \'holidays\' package (offline)', + 'settings.korean_holidays_added': '{count} Korean holidays added for {year}.', + 'settings.korean_holidays_included': 'Includes:\n', + 'settings.package_not_installed': 'Package not installed', + 'settings.package_fallback_body': '\'holidays\' package not installed; only fixed holidays were added.\n\n{hint} pip install holidays', + 'settings.package_install_hint': 'For lunar/temp holidays auto-register:\n', + 'settings.add_done': 'Added', + 'settings.holiday_added': 'Holiday added.\n{date}: {name}', + 'settings.holiday_add_title': 'Add Holiday', + 'settings.holiday_date_prompt': 'Enter holiday date (YYYY-MM-DD):', + 'settings.holiday_name_prompt': 'Enter holiday name:', + 'settings.holiday_list_title': 'Holiday List', + 'settings.holiday_list_header': '=== Holidays for {year} ===\n\n', + 'settings.holiday_list_item': 'โ€ข {date} ({weekday}): {name}{recurring}', + 'settings.holiday_total': 'Total: {count}', + 'settings.holiday_delete_confirm': '\n\nDelete a holiday?', + 'settings.holiday_delete_title': 'Delete Holiday', + 'settings.holiday_delete_prompt': 'Select holiday to delete:', + 'settings.delete_done': 'Deleted', + 'settings.holiday_deleted': '{item} deleted.', + 'settings.export_csv': 'CSV Export', + 'settings.export_work': 'Work records', + 'settings.export_overtime': 'Overtime', + 'settings.export_monthly': 'Monthly summary', + 'settings.import_csv': 'CSV Import', + 'settings.import_tooltip': 'Header format: date,clock_in,clock_out,lunch_minutes,memo', + 'settings.import_format': 'Standard format (header: date,clock_in,clock_out,lunch_minutes,memo)', + 'settings.db_path_label': 'DB path:', + 'settings.change': 'Change...', + 'settings.db_path_tooltip': 'Change to cloud folder path. Restart required.', + 'settings.auto_break_lock_tooltip': 'Auto start/end break on PC lock/unlock.', + 'settings.gitea_feedback_label': 'Gitea feedback:', + 'settings.gitea_token_placeholder': 'PAT (issue write permission, optional)', + 'settings.clock_in_unlock_tooltip': 'For users who do not shut down PC โ€” record screen unlock as clock-in.', + 'settings.auto_break_lock': 'Auto break on screen lock', + 'settings.gitea_feedback_tooltip': "Enable 'Report on Gitea' button on errors", + 'settings.clock_in_unlock': 'Use first unlock as clock-in', + 'settings.version': 'Version: v{version}', + 'settings.check_update': 'Check update (F5)', + 'settings.select_db': 'Select database file', + 'settings.db_path_saved': 'New path saved:\n{path}\n\nCopy current database.db to new location and restart.', + 'settings.parse_failed': 'Parse failed', + 'settings.empty_file': 'Empty file', + 'settings.empty_file_body': 'No valid rows.', + 'settings.conflict_title': 'Conflict handling', + 'settings.conflict_body': 'How to handle conflicts with existing dates?\n', + 'settings.conflict_body_detailed': 'How to handle conflicts with existing dates?\nYes = Overwrite\nNo = Skip\nCancel = Cancel', + 'settings.import_rows_intro': '{count} rows will be imported.\n\n', + 'settings.import_failed': 'Import failed', + 'settings.import_result': 'Import result:\nโ€ข Added: {added}\nโ€ข Updated: {updated}\nโ€ข Skipped: {skipped}', + 'settings.import_complete': 'Complete', + 'settings.save_done': 'Saved', + 'settings.save_done_body': 'Settings saved.', + 'settings.restart_title': 'Restart', + 'settings.restart_body': 'Main screen applies immediately. Some dialogs require restart.\nRestart now?\n\n', + 'settings.initial_overtime_title': 'Set previous overtime', + 'settings.initial_overtime_body': 'Current: {old_hours}h {old_mins}m\nNew: {hours}h {mins}m\n\nChange previous overtime?', + 'settings.initial_overtime_done': 'Set', + 'settings.initial_overtime_done_body': 'Previous overtime set to {hours}h {mins}m.', + 'settings.initial_overtime_error': 'Error setting previous overtime:\n{error}', + 'settings.current_overtime_balance': 'Current balance: {hours}h {minutes}m ({balance}m)', + 'settings.remaining_leave_fmt': 'Remaining: {remaining:.1f} days ({used} of {total} used)', + 'settings.holiday_count': '{count} ({year})', + 'settings.error': 'Error', + 'settings.export_no_records': 'No records to export.', + 'settings.save_work_title': 'Save work records', + 'settings.export_done': 'Export complete', + 'settings.work_exported': 'Work records saved.\n{path}', + 'settings.save_ot_title': 'Save overtime records', + 'settings.ot_exported': 'Overtime records saved.\n{path}', + 'settings.save_monthly_title': 'Save monthly summary', + 'settings.monthly_exported': 'Monthly summary saved.\n{path}', + 'settings.export_failed': 'Export failed', + 'settings.export_error': 'Error: {error}', + 'settings.initial_leave_title': 'Set previous leave', + 'settings.initial_leave_body': 'Current: {old_hours}h {old_mins}m\nNew: {hours}h {mins}m\n\nChange previous leave?', + 'settings.initial_leave_done': 'Saved', + 'settings.initial_leave_done_body': 'Previous leave set to {hours}h {mins}m.', + 'date_format.full': '{year}-{month}-{day} ({weekday})', + + 'achieve.cat_ambition': 'Ambition', + 'achieve.cat_balance': 'Work-Life Balance', + 'achieve.cat_break_use': 'Break', + 'achieve.cat_health': 'Health', + 'achieve.cat_korea': 'Korean Culture', + 'achieve.cat_leave': 'Leave', + 'achieve.cat_meal': 'Meal', + 'achieve.cat_meta': 'Meta', + 'achieve.cat_milestone': 'Milestone', + 'achieve.cat_ot_bank': 'Overtime Bank', + 'achieve.cat_ot_use': 'Overtime Use', + 'achieve.cat_pattern': 'Pattern', + 'achieve.cat_punctual': 'Punctual', + 'achieve.cat_season': 'Season', + 'achieve.cat_secret': 'Secret', + 'achieve.cat_settings': 'Settings', + 'achieve.cat_special_day': 'Special Day', + 'achieve.cat_stats': 'Stats', + 'achieve.cat_streak': 'Streak', + 'achieve.cat_time_slot': 'Time Slot', + 'achieve.completion_rate': 'Completion', + 'achieve.earned_date': ' โœ“ Earned {date} ', + 'achieve.empty': '(None yet)', + 'achieve.secret_locked': '๐Ÿ”’ Revealed when achieved', + 'achieve.tab_all': '๐ŸŒ All ยท {count}', + 'achieve.tab_completed': 'โœ“ Completed ยท {count}', + 'achieve.tab_in_progress': 'โšก In Progress ยท {count}', + 'achieve.tab_secret': '๐ŸŒ‘ Secret ยท {earned}/{total}', + 'achieve.tier_bronze': 'Bronze', + 'achieve.tier_gold': 'Gold', + 'achieve.tier_legend': 'Legend', + 'achieve.tier_platinum': 'Platinum', + 'achieve.tier_silver': 'Silver', + 'achieve.title': 'Achievements', + 'cal.add_done_body': 'Record added for {date}.', + 'cal.add_done_title': 'Added', + 'cal.add_error_body': 'Failed to add record: {error}', + 'cal.add_error_title': 'Error', + 'cal.btn_minus_30': '-30m', + 'cal.btn_plus_30': '+30m', + 'cal.check_dinner_1h': 'Dinner (1h)', + 'cal.check_lunch_1h': 'Lunch (1h)', + 'cal.context_add': 'Add record {date}', + 'cal.context_delete': 'Delete {date}', + 'cal.context_edit': 'Edit {date}', + 'cal.delete_confirm_body': 'Really delete {date} record?\n(Overtime bank entries will also be deleted)', + 'cal.delete_confirm_title': 'Confirm Delete', + 'cal.delete_done_body': '{date} record deleted.', + 'cal.delete_done_title': 'Deleted', + 'cal.delete_record': 'Delete Record', + 'cal.delete_selected_body': 'Delete clock-in record for {date}?\n\nโ€ป Related overtime bank/usage entries will also be deleted.\nโ€ป This cannot be undone.', + 'cal.delete_selected_title': 'Delete Clock-in Record', + 'cal.detail_clock_in': 'Clock-in: {time}', + 'cal.detail_clock_out': 'Clock-out: {time}', + 'cal.detail_clock_out_none': 'Clock-out: not recorded', + 'cal.detail_date_fmt': '{year}-{month}-{day}', + 'cal.detail_dinner_unused': 'Dinner: not used', + 'cal.detail_dinner_used': 'Dinner: used', + 'cal.detail_group_title': 'Selected Date Info', + 'cal.detail_lunch_unused': 'Lunch: not used', + 'cal.detail_lunch_used': 'Lunch: used', + 'cal.detail_memo': 'Memo: {memo}', + 'cal.detail_overtime_earned': '๐Ÿ”ฅ Overtime earned: {hours}h {minutes}m', + 'cal.detail_total_hours': 'Total work: {hours:.1f}h', + 'cal.dialog_title': 'Monthly Work Records', + 'cal.edit_dialog_subtitle': 'Edit clock-in/out for {date}', + 'cal.edit_dialog_title': 'Edit Clock-in/out Time', + 'cal.edit_done_body': 'Clock-in/out updated for {date}.\n\nClock-in: {clock_in}\nClock-out: {clock_out}\nLunch: {lunch}\nDinner: {dinner}\nBreak: {break_minutes}m\nTotal work: {total_hours:.1f}h\nOvertime: {overtime_earned}m banked', + 'cal.edit_done_title': 'Updated', + 'cal.edit_error_body': 'Error while updating:\n{error}', + 'cal.edit_error_title': 'Error', + 'cal.edit_note': 'โ€ป Overtime will be recalculated.', + 'cal.edit_time': 'Edit Time', + 'cal.label_clock_in': 'Clock-in:', + 'cal.label_clock_out': 'Clock-out:', + 'cal.legend_leave': 'Leave', + 'cal.legend_none': 'None', + 'cal.legend_normal': 'Normal', + 'cal.legend_overtime': 'Overtime', + 'cal.memo_group': 'Memo', + 'cal.memo_placeholder': 'Overtime reason, notes...', + 'cal.no_record': 'No record.', + 'cal.save_memo': 'Save Memo', + 'cal.save_memo_body': 'Memo saved for {date}.', + 'cal.save_memo_title': 'Save Memo', + 'cal.time_error_body': 'Clock-out must be later than clock-in.', + 'cal.time_error_title': 'Time Error', + 'clock_in_dialog.cancelled': 'Cancelled', + 'clock_in_dialog.selected': 'Selected time: {time}', + 'field.avg_daily_value': '{hours:.1f}h', + 'field.overtime_value': '{hours}h {minutes}m', + 'field.total_work_value': '{hours:.1f}h ({days} days)', + 'goal.avg_daily': 'Daily Avg:', + 'goal.overtime': 'Overtime:', + 'goal.title': 'Monthly Goals', + 'help.onboarding_button': 'Re-run Onboarding', + 'leave_cal.detail_label': '{type} {days} days', + 'leave_cal.detail_memo': '{type} {days} days ({memo})', + 'leave_cal.detail_no_record': '{date} โ€” No leave usage', + 'leave_cal.header': 'Remaining {balance:.2f}d / Total {total:.0f}d (Used {used:.2f}d)', + 'leave_cal.legend_full': 'Full (1.0)', + 'leave_cal.legend_full_planned': 'Full+Planned', + 'leave_cal.legend_half': 'Half (0.5)', + 'leave_cal.legend_planned': 'Planned', + 'leave_cal.legend_quarter': 'Quarter (0.25)', + 'leave_cal.title': 'Leave Calendar', + 'meal.dialog_title': 'Enter {meal} Time', + 'meal.error_after_clock_out': 'After clock-out ({time})', + 'meal.error_before_clock_in': 'Before clock-in ({time})', + 'meal.error_start_after_end': 'Start is later than end', + 'meal.error_too_long': 'Meal time exceeds 8 hours', + 'meal.info_clock_in_limit': '\nMust be after clock-in ({time}).', + 'meal.info_text': 'Enter {meal} start and end times.\nThis records the exact time instead of the default {minutes} minutes.', + 'meal.input_error_title': 'Input Error', + 'meal.label_end': 'End:', + 'meal.label_start': 'Start:', + 'meal.preview_total': 'Total {minutes} min', + 'mini.close': 'Close mini widget', + 'mini.open_main': 'Open main window', + 'onboarding.detection_boot': 'PC boot time (default โ€” if you shut down daily)', + 'onboarding.detection_info': '\nRecommended for users who leave their PC running.', + 'onboarding.detection_manual': 'Manual only (no auto detection)', + 'onboarding.detection_subtitle': 'Choose how the app detects your clock-in time.', + 'onboarding.detection_title': 'Clock-in Detection', + 'onboarding.detection_unlock': 'First screen unlock (if you leave PC on)', + 'onboarding.discord_enable': 'Use Discord webhook notifications', + 'onboarding.discord_failed': 'Failed', + 'onboarding.discord_failed_body': 'Send failed. Please check the URL.', + 'onboarding.discord_guide': 'Setup:\n1. In Discord, right-click channel โ†’ Integrations โ†’ Webhooks\n2. New Webhook โ†’ Copy URL\n3. Paste it above', + 'onboarding.discord_subtitle': 'Enter a webhook URL to receive clock-in/out and break reminders on Discord. (Mobile push)', + 'onboarding.discord_success': 'Success', + 'onboarding.discord_success_body': 'Check the Discord channel for the test message.', + 'onboarding.discord_test': 'Send test message', + 'onboarding.discord_title': 'Discord Notifications (Optional)', + 'onboarding.discord_url_invalid_body': 'Not a valid Discord webhook URL.\nExample: https://discord.com/api/webhooks/{ID}/{TOKEN}', + 'onboarding.discord_url_invalid_title': 'Invalid URL', + 'onboarding.discord_url_placeholder': 'https://discord.com/api/webhooks/...', + 'onboarding.discord_url_required_body': 'Please enter a webhook URL first.', + 'onboarding.discord_url_required_title': 'URL Required', + 'onboarding.finish_msg': 'You can change these settings anytime in [Settings].\nTo re-run onboarding, use [Help โ†’ Re-run Onboarding].\n\nShortcuts:\n โ€ข Ctrl+O โ€” Toggle clock-in/out\n โ€ข F1 โ€” Help\n โ€ข F5 โ€” Check update\n โ€ข Ctrl+, โ€” Settings', + 'onboarding.finish_subtitle': 'Your clock-in will now be tracked automatically.', + 'onboarding.finish_title': 'Ready!', + 'onboarding.hourly_wage': 'Hourly wage:', + 'onboarding.input_error_title': 'Input Error', + 'onboarding.leave_group': 'Annual Leave', + 'onboarding.leave_salary_subtitle': 'Enter annual leave days and optional salary info.', + 'onboarding.leave_salary_title': 'Leave + Salary (Optional)', + 'onboarding.my_leave': 'My leave:', + 'onboarding.overtime_rate': 'Overtime rate:', + 'onboarding.rate_1_5x': '1.5x (Korean labor law default)', + 'onboarding.rate_1x': '1.0x (no premium)', + 'onboarding.rate_2x': '2.0x (night/holiday premium)', + 'onboarding.salary_enabled': 'Enable salary estimate', + 'onboarding.salary_group': 'Salary Estimate (Optional โ€” disable if flat rate)', + 'onboarding.wage_suffix': ' KRW/h', + 'onboarding.welcome_intro': 'This app:\nโ€ข Auto-detects clock-in from boot/unlock\nโ€ข Banks overtime in 30-min units\nโ€ข Tracks annual/half-day leave and breaks\nโ€ข Counts down to clock-out every second\n\nPress [Next] to start.', + 'onboarding.welcome_subtitle': "First time using Clock-out Time Calculator? Let's set it up in 5 steps.", + 'onboarding.welcome_title': 'Welcome!', + 'onboarding.window_title': 'Clock-out Calculator โ€” Setup', + 'onboarding.work_min_too_small': 'Daily work must be at least 30 minutes.', + 'onboarding.work_pattern_subtitle': 'Choose your daily work hours. You can change this later in Settings.', + 'onboarding.work_pattern_title': 'Work Pattern', + 'past_record.check_clock_out': 'Enter', + 'past_record.check_dinner': 'Include dinner', + 'past_record.check_lunch': 'Include lunch', + 'past_record.dialog_title': 'Add Record โ€” {date}', + 'past_record.info': 'Enter work record for {date}.', + 'past_record.input_error_body': 'Clock-out must be later than clock-in.', + 'past_record.input_error_title': 'Input Error', + 'past_record.label_clock_in': 'Clock-in:', + 'past_record.label_clock_out': 'Clock-out:', + 'past_record.label_memo': 'Memo (optional):', + 'past_record.memo_placeholder': 'e.g. remote work / business trip / leave', + 'recurring.add_done_body': 'Recurring pattern registered.\n{pattern}', + 'recurring.add_done_title': 'Added', + 'recurring.add_group': 'Add New Pattern', + 'recurring.biweekly': 'Biweekly', + 'recurring.btn_add': 'Add', + 'recurring.btn_delete_selected': 'Delete Selected', + 'recurring.day_suffix': '', + 'recurring.deduction_full': '1.0 day (full)', + 'recurring.deduction_half': '0.5 day (half)', + 'recurring.deduction_quarter': '0.25 day (quarter)', + 'recurring.delete_confirm_body': 'Delete this recurring pattern?\n\n{item}', + 'recurring.delete_confirm_title': 'Confirm Delete', + 'recurring.input_error_title': 'Input Error', + 'recurring.input_error_weekday': 'Select at least one weekday.', + 'recurring.label_cycle': 'Cycle:', + 'recurring.label_deduction': 'Deduct:', + 'recurring.label_end': 'End:', + 'recurring.label_memo': 'Memo:', + 'recurring.label_monthly_day': 'Day:', + 'recurring.label_start': 'Start:', + 'recurring.label_weekday': 'Weekday:', + 'recurring.list_group': 'Registered Recurring Patterns', + 'recurring.memo_placeholder': 'e.g. childcare reduced hours', + 'recurring.monthly': 'Monthly Nth day', + 'recurring.no_end': 'No end (indefinite)', + 'recurring.title': 'Recurring Leave Management', + 'recurring.pattern_weekly': '{prefix} {weekdays}', + 'recurring.pattern_monthly': 'Monthly {day}', + 'recurring.weekly': 'Weekly', + 'schedule.btn_add_leave': 'Register Leave', + 'schedule.btn_recurring': 'Manage Recurring Patterns', + 'schedule.delete': 'Delete', + 'schedule.delete_leave_confirm_body': 'Delete this leave record? (Balance will be restored automatically.)', + 'schedule.delete_leave_confirm_title': 'Confirm Delete', + 'schedule.delete_recurring_confirm_body': 'Delete this recurring pattern? (Removes all future instances)', + 'schedule.delete_recurring_confirm_title': 'Confirm Delete', + 'schedule.detail_placeholder': 'Select a date', + 'schedule.header': 'Monthly Schedule โ€” Holidays + Leave + Recurring Patterns', + 'schedule.holiday': 'Holiday: {name}', + 'schedule.leave_label': '{type} {days} days', + 'schedule.recurring_item': '{pattern} ยท {days}d ({type})', + 'schedule.legend_half': 'Half/Quarter Day', + 'schedule.legend_holiday': 'Holiday', + 'schedule.legend_leave_planned': 'Leave Planned', + 'schedule.legend_leave_used': 'Leave Used', + 'schedule.legend_recurring': 'Recurring Pattern', + 'schedule.no_events': 'No events', + 'schedule.title': 'Schedule', + 'schedule.weekend': 'Weekend ({weekday})', + 'schedule.weekday_suffix': '', + 'today.detail_break': 'Break {minutes}m', + 'today.detail_dinner': 'Dinner {minutes}m', + 'today.detail_lunch': 'Lunch {minutes}m', + 'today.detail_overtime': 'Overtime {actual}m โ†’ banked {earned}m', + 'today.title': "Today's Summary", + 'today.total_work': 'Total work: {hours}h {minutes}m', + 'view.leave.btn_schedule': 'Schedule', + 'view.leave.duplicate_register_body': '{date} already has {existing_days:.2f} days registered.\nAdding {days:.2f} more days would exceed 1 day.', + 'view.leave.duplicate_register_title': 'Duplicate Exceeds Limit', + 'view.leave.holiday_register_forbidden_body': '{date} is already a holiday ({name}).\nNo need to deduct leave.', + 'view.leave.holiday_register_forbidden_title': 'Cannot Register on Holiday', + 'view.leave.schedule_tooltip': 'Unified view of holidays + leave + recurring patterns', + 'view.leave.used_1day': '1 day', + 'view.leave.used_half_day': '0.5 day (4h)', + 'view.leave.used_hours_fmt': '{days} days ({hours}h)', + 'view.leave.used_days_fmt': '{days} days', + 'view.leave.weekend_register_forbidden_body': 'Leave cannot be registered on weekends. (Already non-working day)', + 'view.leave.weekend_register_forbidden_title': 'Cannot Register on Weekend', + # === Achievements === + 'achieve.streak_first.name': 'First Step', + 'achieve.streak_first.desc': 'First clock-in record.', + 'achieve.streak_3.name': 'Taking Root', + 'achieve.streak_3.desc': '3 consecutive business days clocked in.', + 'achieve.streak_5.name': 'First Week Clear', + 'achieve.streak_5.desc': '5 consecutive business days clocked in.', + 'achieve.streak_7_cal.name': '7 Days Straight', + 'achieve.streak_7_cal.desc': '7 consecutive calendar days clocked in, weekends included.', + 'achieve.streak_10.name': 'Two Weeks Running', + 'achieve.streak_10.desc': '10 consecutive business days clocked in.', + 'achieve.streak_22.name': 'Monthly Perfect Attendance', + 'achieve.streak_22.desc': '100% business-day attendance for one month (22 days).', + 'achieve.streak_50.name': '50-Day Streak', + 'achieve.streak_50.desc': '50 consecutive business days clocked in.', + 'achieve.streak_100.name': '100-Day Streak', + 'achieve.streak_100.desc': '100 consecutive business days clocked in.', + 'achieve.streak_quarter.name': 'Quarter Clear', + 'achieve.streak_quarter.desc': 'About 65 business days (3 months).', + 'achieve.streak_half_year.name': 'Half-Year Marathon', + 'achieve.streak_half_year.desc': 'About 130 business days (6 months).', + 'achieve.streak_year.name': 'Full-Year Season', + 'achieve.streak_year.desc': 'About 260 business days (1 year).', + 'achieve.streak_200.name': 'Science', + 'achieve.streak_200.desc': '200 consecutive business days clocked in.', + 'achieve.streak_365_cal.name': 'Immortal', + 'achieve.streak_365_cal.desc': '365 consecutive calendar days clocked in.', + 'achieve.streak_resilience.name': 'Bounce Back', + 'achieve.streak_resilience.desc': 'Clocked in the day immediately after absence (auto: restart after calendar streak breaks).', + 'achieve.streak_total_100.name': '100 Total Clock-ins', + 'achieve.streak_total_100.desc': '100 total clock-ins.', + 'achieve.streak_total_500.name': '500 Total Clock-ins', + 'achieve.streak_total_500.desc': '500 total clock-ins.', + 'achieve.streak_total_1000.name': '1000 Total Clock-ins', + 'achieve.streak_total_1000.desc': '1000 total clock-ins.', + 'achieve.punc_before_8_1.name': 'Early Bird', + 'achieve.punc_before_8_1.desc': 'Clocked in before 08:00 once.', + 'achieve.punc_before_8_10.name': 'Early Bird Flock', + 'achieve.punc_before_8_10.desc': 'Clocked in before 08:00 10 times.', + 'achieve.punc_before_8_30.name': 'Early to Bed, Early to Rise', + 'achieve.punc_before_8_30.desc': 'Clocked in before 08:00 30 times.', + 'achieve.punc_before_6_1.name': 'No Dawn Sleep', + 'achieve.punc_before_6_1.desc': 'Clocked in before 06:00 once.', + 'achieve.punc_before_6_10.name': 'Cutter of Darkness', + 'achieve.punc_before_6_10.desc': 'Clocked in before 06:00 10 times.', + 'achieve.punc_before_5.name': 'Dawn Champion', + 'achieve.punc_before_5.desc': 'Clocked in before 05:00.', + 'achieve.punc_at_9.name': 'Exactly Nine', + 'achieve.punc_at_9.desc': 'Clocked in exactly at 09:00 (ยฑ1 min) once.', + 'achieve.punc_at_9_5.name': 'Perfect Nine', + 'achieve.punc_at_9_5.desc': 'Clocked in exactly at 09:00 (ยฑ1 min) 5 times.', + 'achieve.punc_late_5min.name': '5 Minutes Late', + 'achieve.punc_late_5min.desc': 'Clocked in at 09:00โ€“09:05 once (self-deprecating).', + 'achieve.punc_at_909.name': 'Fateful Moment', + 'achieve.punc_at_909.desc': 'Clocked in at 09:09 (secret).', + 'achieve.bal_first_punct.name': 'First On-Time Leave', + 'achieve.bal_first_punct.desc': 'First on-time clock-out.', + 'achieve.bal_punct_10.name': 'Clock-outer', + 'achieve.bal_punct_10.desc': 'On-time clock-out 10 times.', + 'achieve.bal_punct_30.name': 'On-Time Champ', + 'achieve.bal_punct_30.desc': 'On-time clock-out 30 times.', + 'achieve.bal_punct_100.name': 'True Freedom', + 'achieve.bal_punct_100.desc': 'On-time clock-out 100 times.', + 'achieve.bal_punct_300.name': 'Work-Life Master', + 'achieve.bal_punct_300.desc': 'On-time clock-out 300 times.', + 'achieve.ot_first_30m.name': 'First 30 Minutes', + 'achieve.ot_first_30m.desc': 'First overtime banked.', + 'achieve.ot_total_60m.name': '1-Hour Savings', + 'achieve.ot_total_60m.desc': '1 hour banked in total.', + 'achieve.ot_total_5h.name': '5 Hours Banked', + 'achieve.ot_total_5h.desc': '5 hours banked in total.', + 'achieve.ot_total_10h.name': '10 Hours Banked', + 'achieve.ot_total_10h.desc': '10 hours banked in total.', + 'achieve.ot_total_25h.name': '25 Hours Banked', + 'achieve.ot_total_25h.desc': '25 hours banked in total.', + 'achieve.ot_total_50h.name': '50 Hours Banked', + 'achieve.ot_total_50h.desc': '50 hours banked in total.', + 'achieve.ot_total_100h.name': 'Marathoner', + 'achieve.ot_total_100h.desc': '100 hours banked in total (concerned message).', + 'achieve.ot_total_200h.name': 'Workaholic Warning', + 'achieve.ot_total_200h.desc': '200 hours banked in total (warning).', + 'achieve.ot_total_300h.name': 'Danger Signal', + 'achieve.ot_total_300h.desc': '300 hours banked in total (strong warning).', + 'achieve.ot_total_500h.name': 'ER Regular', + 'achieve.ot_total_500h.desc': '500 hours banked in total (self-deprecating).', + 'achieve.use_first.name': 'First Break', + 'achieve.use_first.desc': 'First time using banked time.', + 'achieve.use_total_5h.name': 'Using the Gift', + 'achieve.use_total_5h.desc': '5 hours used in total.', + 'achieve.use_total_25h.name': 'Value of Rest', + 'achieve.use_total_25h.desc': '25 hours used in total.', + 'achieve.use_total_50h.name': 'Recovery Master', + 'achieve.use_total_50h.desc': '50 hours used in total.', + 'achieve.use_total_100h.name': 'Massage', + 'achieve.use_total_100h.desc': '100 hours used in total.', + 'achieve.leave_first.name': 'First Leave', + 'achieve.leave_first.desc': 'First leave usage.', + 'achieve.leave_half.name': 'First Half-Day', + 'achieve.leave_half.desc': '0.5-day leave used.', + 'achieve.leave_quarter.name': 'Hourly Leave', + 'achieve.leave_quarter.desc': '0.25-day leave used.', + 'achieve.leave_streak_3.name': 'Mini Vacation', + 'achieve.leave_streak_3.desc': '3 consecutive days of leave.', + 'achieve.leave_streak_5.name': 'Serious Vacation', + 'achieve.leave_streak_5.desc': '5 consecutive days of leave.', + 'achieve.leave_streak_7.name': 'Long-Distance Vacation', + 'achieve.leave_streak_7.desc': '7 or more consecutive days of leave.', + 'achieve.leave_total_10.name': 'Leave x10', + 'achieve.leave_total_10.desc': '10 leave records.', + 'achieve.leave_sick.name': 'Sick Leave', + 'achieve.leave_sick.desc': 'Sick-type leave used.', + 'achieve.meal_lunch_first.name': 'First Lunch Entry', + 'achieve.meal_lunch_first.desc': 'First lunch toggle.', + 'achieve.meal_lunch_30.name': 'Lunch Master', + 'achieve.meal_lunch_30.desc': 'Lunch toggled 30 times.', + 'achieve.meal_lunch_100.name': 'Lunch Champ', + 'achieve.meal_lunch_100.desc': 'Lunch toggled 100 times.', + 'achieve.meal_dinner_first.name': 'First Dinner Entry', + 'achieve.meal_dinner_first.desc': 'First dinner toggle.', + 'achieve.meal_dinner_10.name': 'Dinner Regular', + 'achieve.meal_dinner_10.desc': 'Dinner toggled 10 times (warning).', + 'achieve.meal_dinner_30.name': 'Late-Night Regular', + 'achieve.meal_dinner_30.desc': 'Dinner toggled 30 times (warning).', + 'achieve.meal_lunch_actual.name': 'Measured Lunch', + 'achieve.meal_lunch_actual.desc': 'Entered actual lunch time.', + 'achieve.meal_dinner_actual.name': 'Measured Dinner', + 'achieve.meal_dinner_actual.desc': 'Entered actual dinner time.', + 'achieve.break_first.name': 'First Break', + 'achieve.break_first.desc': 'First break started.', + 'achieve.break_10.name': 'Break Champ', + 'achieve.break_10.desc': '10 breaks.', + 'achieve.break_50.name': 'Walker', + 'achieve.break_50.desc': '50 breaks.', + 'achieve.slot_in_06.name': '06:00 Clock-in', + 'achieve.slot_in_06.desc': 'Clocked in during 06:00โ€“06:59 once.', + 'achieve.slot_in_07.name': '07:00 Clock-in', + 'achieve.slot_in_07.desc': 'Clocked in during 07:00โ€“07:59 once.', + 'achieve.slot_in_08.name': '08:00 Clock-in', + 'achieve.slot_in_08.desc': 'Clocked in during 08:00โ€“08:59 once.', + 'achieve.slot_in_10.name': '10:00 Clock-in', + 'achieve.slot_in_10.desc': 'Clocked in during 10:00โ€“10:59 (late / flexible).', + 'achieve.slot_in_11.name': '11:00 Clock-in', + 'achieve.slot_in_11.desc': 'Clocked in during 11:00โ€“11:59 (self-deprecating).', + 'achieve.slot_out_19.name': '19:00 Clock-out', + 'achieve.slot_out_19.desc': 'Clocked out during 19:00โ€“19:59 10 times (warning).', + 'achieve.slot_out_20.name': '20:00 Clock-out', + 'achieve.slot_out_20.desc': 'Clocked out during 20:00โ€“20:59 10 times (warning).', + 'achieve.slot_out_21.name': '21:00 Clock-out', + 'achieve.slot_out_21.desc': 'Clocked out during 21:00โ€“21:59 5 times (warning).', + 'achieve.slot_out_22.name': '22:00 Clock-out', + 'achieve.slot_out_22.desc': 'Clocked out during 22:00โ€“22:59 once (warning).', + 'achieve.slot_out_23.name': '23:00 Clock-out', + 'achieve.slot_out_23.desc': 'Clocked out during 23:00โ€“23:59 once (warning).', + 'achieve.slot_midnight.name': 'Midnight Clock-out', + 'achieve.slot_midnight.desc': 'Clocked out after midnight (warning).', + 'achieve.slot_midnight_3.name': 'Owl Trio', + 'achieve.slot_midnight_3.desc': 'Clocked out after midnight 3 times (warning).', + 'achieve.weekend_1.name': 'Weekend Work Once', + 'achieve.weekend_1.desc': 'Clocked in on a Saturday/Sunday once.', + 'achieve.weekend_5.name': 'Weekend Worker', + 'achieve.weekend_5.desc': 'Clocked in on weekends 5 times (warning).', + 'achieve.weekend_20.name': 'True Workaholic', + 'achieve.weekend_20.desc': 'Clocked in on weekends 20 times (strong self-deprecating).', + 'achieve.holiday_1.name': 'Holiday Work', + 'achieve.holiday_1.desc': 'Clocked in on a Korean public holiday once.', + 'achieve.holiday_5.name': 'Holiday Workaholic', + 'achieve.holiday_5.desc': 'Clocked in on Korean public holidays 5 times (warning).', + 'achieve.day_christmas.name': 'Christmas at Work', + 'achieve.day_christmas.desc': 'Clocked in on 12/25 (self-deprecating).', + 'achieve.day_newyear.name': 'New Year at Work', + 'achieve.day_newyear.desc': 'Clocked in on 1/1 (self-deprecating).', + 'achieve.day_liberation.name': 'Liberation Day at Work', + 'achieve.day_liberation.desc': 'Clocked in on 8/15 (Liberation Day).', + 'achieve.day_children.name': "Children's Day at Work", + 'achieve.day_children.desc': "Clocked in on 5/5 (Children's Day, self-deprecating).", + 'achieve.day_hangul.name': 'Hangeul Day at Work', + 'achieve.day_hangul.desc': 'Clocked in on 10/9 (Hangeul Day).', + 'achieve.day_valentine.name': 'Valentine at Work', + 'achieve.day_valentine.desc': 'Clocked in on 2/14.', + 'achieve.day_white.name': 'White Day at Work', + 'achieve.day_white.desc': 'Clocked in on 3/14.', + 'achieve.day_pepero.name': 'Pepero Day', + 'achieve.day_pepero.desc': 'Clocked in on 11/11 (Pepero Day).', + 'achieve.day_halloween.name': 'Halloween at Work', + 'achieve.day_halloween.desc': 'Clocked in on 10/31.', + 'achieve.day_aprilfools.name': 'April Fools at Work', + 'achieve.day_aprilfools.desc': 'Clocked in on 4/1.', + 'achieve.day_77.name': 'Chilseok (July 7)', + 'achieve.day_77.desc': 'Clocked in on 7/7 (Chilseok).', + 'achieve.day_dongji.name': 'Dongji at Work', + 'achieve.day_dongji.desc': 'Clocked in on 12/22 (Dongji, winter solstice).', + 'achieve.day_parents.name': 'Parents Day On-Time Leave', + 'achieve.day_parents.desc': 'On-time clock-out on 5/8 (Parents Day).', + 'achieve.day_teacher.name': "Teachers' Day On-Time Leave", + 'achieve.day_teacher.desc': "On-time clock-out on 5/15 (Teachers' Day).", + 'achieve.day_xmas_eve.name': 'Christmas Eve On-Time Leave', + 'achieve.day_xmas_eve.desc': 'On-time clock-out on 12/24.', + 'achieve.day_earth.name': 'Earth Day', + 'achieve.day_earth.desc': 'Clocked in on 4/22 (secret).', + 'achieve.season_jan.name': 'January Settled', + 'achieve.season_jan.desc': 'Clocked in during January.', + 'achieve.season_feb.name': 'February Perfect', + 'achieve.season_feb.desc': 'Attended all business days in February.', + 'achieve.season_mar.name': 'Spring Greeting', + 'achieve.season_mar.desc': 'First clock-in of March.', + 'achieve.season_apr.name': 'April Settled', + 'achieve.season_apr.desc': 'Attended all business days in April.', + 'achieve.season_may.name': 'May Perfect', + 'achieve.season_may.desc': 'Attended all business days in May.', + 'achieve.season_jun.name': 'Start of Summer', + 'achieve.season_jun.desc': 'First clock-in of June.', + 'achieve.season_jul.name': 'July Settled', + 'achieve.season_jul.desc': 'Attended all business days in July.', + 'achieve.season_aug.name': 'August Perfect', + 'achieve.season_aug.desc': 'Attended all business days in August.', + 'achieve.season_sep.name': 'Start of Autumn', + 'achieve.season_sep.desc': 'First clock-in of September.', + 'achieve.season_oct.name': 'October Settled', + 'achieve.season_oct.desc': 'Attended all business days in October.', + 'achieve.season_nov.name': 'November Maple', + 'achieve.season_nov.desc': 'Attended all business days in November.', + 'achieve.season_dec.name': 'Start of Winter', + 'achieve.season_dec.desc': 'First clock-in of December.', + 'achieve.mile_first.name': 'Hello, World!', + 'achieve.mile_first.desc': 'First app run.', + 'achieve.mile_7days.name': 'One Week User', + 'achieve.mile_7days.desc': 'Used the app for 7 days.', + 'achieve.mile_30days.name': 'One Month User', + 'achieve.mile_30days.desc': 'Used the app for 30 days.', + 'achieve.mile_365days.name': '1st Anniversary', + 'achieve.mile_365days.desc': 'Used the app for 365 days.', + 'achieve.mile_730days.name': '2nd Anniversary', + 'achieve.mile_730days.desc': 'Used the app for 730 days.', + 'achieve.mile_1095days.name': '3rd Anniversary', + 'achieve.mile_1095days.desc': 'Used the app for 3 years.', + 'achieve.mile_5years.name': '5-Year User', + 'achieve.mile_5years.desc': 'Used the app for 5 years.', + 'achieve.mile_10years.name': '10-Year User', + 'achieve.mile_10years.desc': 'Used the app for 10 years.', + 'achieve.stat_weekly_10.name': 'Weekly Stats Viewer', + 'achieve.stat_weekly_10.desc': 'Viewed the Weekly tab 10 times.', + 'achieve.stat_monthly_10.name': 'Monthly Stats Viewer', + 'achieve.stat_monthly_10.desc': 'Viewed the Monthly tab 10 times.', + 'achieve.stat_pattern_10.name': 'Pattern Analyst', + 'achieve.stat_pattern_10.desc': 'Viewed the Pattern tab 10 times.', + 'achieve.stat_calendar_30.name': 'Calendar Champ', + 'achieve.stat_calendar_30.desc': 'Viewed the Calendar 30 times.', + 'achieve.stat_report_first.name': 'First Daily Report', + 'achieve.stat_report_first.desc': 'Generated a daily report once.', + 'achieve.stat_report_30.name': 'Report Champ', + 'achieve.stat_report_30.desc': 'Generated daily reports 30 times.', + 'achieve.stat_chart_hover.name': 'Chart Hover Discovery', + 'achieve.stat_chart_hover.desc': 'Discovered chart hover for the first time.', + 'achieve.stat_achievements_open.name': 'Achievement Museum', + 'achieve.stat_achievements_open.desc': 'Opened the Achievements view 50 times.', + 'achieve.secret_palindrome.name': 'Palindrome Time', + 'achieve.secret_palindrome.desc': 'Clock-in time is a palindrome.', + 'achieve.secret_jackpot.name': 'Jackpot Time', + 'achieve.secret_jackpot.desc': 'Clock-in time has all identical digits.', + 'achieve.secret_fri13.name': 'Friday the 13th', + 'achieve.secret_fri13.desc': 'Clocked in on Friday the 13th.', + 'achieve.secret_777.name': '7-7-7', + 'achieve.secret_777.desc': 'Clocked in at 07:07 on July 7.', + 'achieve.secret_exact_8h.name': 'Exactly 8 Hours', + 'achieve.secret_exact_8h.desc': 'Worked exactly 8h 0m.', + 'achieve.secret_pi_day.name': 'Pi Day', + 'achieve.secret_pi_day.desc': 'Clocked in at 01:59 on 3/14.', + 'achieve.secret_fibonacci.name': 'Fibonacci', + 'achieve.secret_fibonacci.desc': 'Clock-in minute is a Fibonacci number.', + 'achieve.secret_double_six.name': 'Double Six', + 'achieve.secret_double_six.desc': 'Clocked in at 18:06 on 6/6.', + 'achieve.secret_anniversary.name': 'Wizard', + 'achieve.secret_anniversary.desc': 'Clocked in exactly 365 days after joining.', + 'achieve.set_dark.name': 'Dark Side', + 'achieve.set_dark.desc': 'Used dark theme once.', + 'achieve.set_lang.name': 'Bilingual', + 'achieve.set_lang.desc': 'Changed language (used en).', + 'achieve.set_a11y.name': 'Accessibility User', + 'achieve.set_a11y.desc': 'Font scale โ‰  100% or high contrast ON.', + 'achieve.set_overtime_unit.name': 'Unit Changer', + 'achieve.set_overtime_unit.desc': 'Changed overtime_unit.', + 'achieve.set_goal_full.name': 'Goal Master', + 'achieve.set_goal_full.desc': 'Set both monthly OT cap and daily average goal.', + 'achieve.set_discord_full.name': 'Full Setup', + 'achieve.set_discord_full.desc': 'Discord URL + all notifications ON.', + 'achieve.set_cloud.name': 'Cloud Sync', + 'achieve.set_cloud.desc': 'Changed DB path.', + 'achieve.meta_first.name': 'First Achievement', + 'achieve.meta_first.desc': 'Earned first achievement.', + 'achieve.meta_10.name': '10 Achievements', + 'achieve.meta_10.desc': 'Hold 10 achievements.', + 'achieve.meta_25.name': '25 Achievements', + 'achieve.meta_25.desc': 'Hold 25 achievements.', + 'achieve.meta_50.name': '50 Achievements', + 'achieve.meta_50.desc': 'Hold 50 achievements.', + 'achieve.meta_75.name': '75 Achievements', + 'achieve.meta_75.desc': 'Hold 75 achievements.', + 'achieve.meta_100.name': '100 Achievements', + 'achieve.meta_100.desc': 'Hold 100 achievements.', + 'achieve.meta_secret_1.name': 'Secret Found', + 'achieve.meta_secret_1.desc': 'Discovered first secret.', + 'achieve.meta_secret_5.name': 'Secret Hunter', + 'achieve.meta_secret_5.desc': 'Discovered 5 secrets.', + 'achieve.streak_monday_10.name': 'Monday Conqueror', + 'achieve.streak_monday_10.desc': 'Clocked in 10 Mondays in a row.', + 'achieve.streak_friday_10.name': 'Friday Flawless', + 'achieve.streak_friday_10.desc': 'Clocked in 10 Fridays in a row.', +}, } diff --git a/core/recurring_leaves.py b/core/recurring_leaves.py index 64f2856..f9dbae4 100644 --- a/core/recurring_leaves.py +++ b/core/recurring_leaves.py @@ -15,6 +15,8 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta from typing import List, Dict, Optional +from core.i18n import tr + _WEEKDAY_MAP = { 'mon': 0, 'monday': 0, @@ -25,6 +27,7 @@ _WEEKDAY_MAP = { 'sat': 5, 'saturday': 5, 'sun': 6, 'sunday': 6, } +_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] @dataclass @@ -135,19 +138,16 @@ def _parse_date(s: Optional[str]) -> Optional[date]: return None -_KO_WEEKDAY_NAMES = ['์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ', '์ผ'] - - def describe_pattern(pattern: str) -> str: - """์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ค„ ํŒจํ„ด ์„ค๋ช…. ko.""" + """์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ค„ ํŒจํ„ด ์„ค๋ช….""" parsed = _parse_pattern(pattern) if parsed is None: return pattern kind, info = parsed if kind in ('weekly', 'biweekly'): - names = [_KO_WEEKDAY_NAMES[w] for w in info] - prefix = '๋งค์ฃผ' if kind == 'weekly' else '๊ฒฉ์ฃผ' - return f"{prefix} {','.join(names)}์š”์ผ" + names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info] + prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly') + return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names)) if kind == 'monthly': - return f"๋งค์›” {info}์ผ" + return tr('recurring.pattern_monthly', day=info) return pattern diff --git a/core/time_calculator.py b/core/time_calculator.py index cdc7627..747b6d3 100644 --- a/core/time_calculator.py +++ b/core/time_calculator.py @@ -21,7 +21,8 @@ class TimeCalculator: if work_minutes is not None: self.work_minutes = int(work_minutes) elif work_hours is not None: - self.work_minutes = int(round(float(work_hours) * 60)) + # ์€ํ–‰ ๋ฐ˜์˜ฌ๋ฆผ(banker's rounding) ํšŒํ”ผ: 6.5์‹œ๊ฐ„ โ†’ 390๋ถ„์ด ๋˜๋„๋ก ๋ช…์‹œ์  ๋ฐ˜์˜ฌ๋ฆผ + self.work_minutes = int(float(work_hours) * 60 + 0.5) else: self.work_minutes = 480 diff --git a/tests/test_csv_importer.py b/tests/test_csv_importer.py index dd810a3..c31137e 100644 --- a/tests/test_csv_importer.py +++ b/tests/test_csv_importer.py @@ -4,13 +4,15 @@ utils.csv_importer ๋‹จ์œ„ ํ…Œ์ŠคํŠธ. import os import sys import tempfile +from datetime import datetime 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 +from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records +from core.database import Database class TestNormalizeTime: @@ -125,3 +127,54 @@ class TestParseCsv: assert '์ค„ 3' in str(exc.value) finally: os.remove(path) + + +class TestImportRecords: + def _db(self): + p = tempfile.NamedTemporaryFile(delete=False, suffix='.db') + p.close() + db = Database(p.name) + db.save_settings({ + 'work_minutes': '480', + 'lunch_duration_minutes': '60', + 'dinner_duration_minutes': '60', + }) + return db, p.name + + def test_overwrite_clears_overtime_usage(self): + """CSV ๋ฎ์–ด์“ฐ๊ธฐ ์‹œ overtime_usage๋„ ์‚ญ์ œ๋˜์–ด ์ž”์•ก์ด ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•ด์•ผ ํ•จ.""" + db, path = self._db() + try: + date_str = '2026-04-01' + # ๊ธฐ์กด ๊ธฐ๋ก + ์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ ๊ธฐ๋ก ์ƒ์„ฑ + wid = db.add_work_record(date_str, '09:00:00') + db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120) + db.add_overtime_earned(wid, 120, date_str) + db.add_overtime_usage(wid, 30, date_str, 'ํ…Œ์ŠคํŠธ') + + # ๋ฎ์–ด์“ฐ๊ธฐ ์ „ ์ž”์•ก + balance_before = db.get_total_overtime_balance() + assert balance_before == 90 # 120 ์ ๋ฆฝ - 30 ์‚ฌ์šฉ + + rows = [{ + 'date': date_str, + 'clock_in': '09:00:00', + 'clock_out': '18:00:00', + 'lunch_minutes': 60, + 'dinner_minutes': 0, + 'memo': '', + }] + import_records(db, rows, on_conflict='overwrite') + + # ๋ฎ์–ด์“ฐ๊ธฐ ํ›„ ์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ ๊ธฐ๋ก์€ ์‚ญ์ œ๋˜์–ด์•ผ ํ•จ + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,)) + assert cur.fetchone()[0] == 0 + balance_after = db.get_total_overtime_balance() + assert balance_after == 0 # ์ƒˆ ๊ธฐ๋ก์€ ์—ฐ์žฅ๊ทผ๋ฌด ์—†์Œ + finally: + try: + os.remove(path) + except OSError: + pass diff --git a/tests/test_holiday_api.py b/tests/test_holiday_api.py index 8551b8b..a3d82fa 100644 --- a/tests/test_holiday_api.py +++ b/tests/test_holiday_api.py @@ -162,5 +162,12 @@ class TestFetchNetwork: class TestConfigured: - def test_key_set(self): - assert is_configured() is True + def test_key_set(self, monkeypatch): + import utils.holiday_api as _ha + monkeypatch.setattr(_ha, '_SERVICE_KEY', 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93') + assert _ha.is_configured() is True + + def test_key_empty(self, monkeypatch): + import utils.holiday_api as _ha + monkeypatch.setattr(_ha, '_SERVICE_KEY', '') + assert _ha.is_configured() is False diff --git a/ui/achievements_view.py b/ui/achievements_view.py index cafb0af..e6d32a5 100644 --- a/ui/achievements_view.py +++ b/ui/achievements_view.py @@ -16,6 +16,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont from core.achievements import get_all_with_status, get_stats +from core.i18n import tr from ui.styles import apply_dark_titlebar from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark @@ -29,7 +30,6 @@ TIER_THEMES = { 'bg_bot': '#241810', 'text': '#ffd9a8', 'label': '๐Ÿฅ‰', - 'name': '๋ธŒ๋ก ์ฆˆ', }, 'silver': { 'border': '#a8a8a8', @@ -38,7 +38,6 @@ TIER_THEMES = { 'bg_bot': '#1c1c22', 'text': '#e8e8f0', 'label': '๐Ÿฅˆ', - 'name': '์‹ค๋ฒ„', }, 'gold': { 'border': '#ffb700', @@ -47,7 +46,6 @@ TIER_THEMES = { 'bg_bot': '#241c08', 'text': '#ffe9a0', 'label': '๐Ÿฅ‡', - 'name': '๊ณจ๋“œ', }, 'platinum': { 'border': '#7fdbff', @@ -56,7 +54,6 @@ TIER_THEMES = { 'bg_bot': '#0e1f28', 'text': '#c5ecff', 'label': '๐Ÿ’Ž', - 'name': 'ํ”Œ๋ž˜ํ‹ฐ๋„˜', }, 'legend': { 'border': '#ff6b9d', @@ -65,20 +62,9 @@ TIER_THEMES = { 'bg_bot': '#26101a', 'text': '#ffc0d4', 'label': '๐ŸŒŸ', - 'name': '๋ ˆ์ „๋“œ', }, } -CATEGORY_LABELS = { - 'streak': '์ถœ๊ทผ streak', 'punctual': '์‹œ๊ฐ„ ์—„์ˆ˜', 'balance': '์›Œ๋ผ๋ฐธ', - 'ot_bank': '์—ฐ์žฅ ์ ๋ฆฝ', 'ot_use': '์—ฐ์žฅ ์‚ฌ์šฉ', 'leave': '์—ฐ์ฐจ', - 'health': '๊ฑด๊ฐ•', 'special_day': 'ํŠน๋ณ„์ผ', 'pattern': 'ํŒจํ„ด', - 'milestone': '๋งˆ์ผ์Šคํ†ค', 'season': '์‹œ์ฆŒ', 'time_slot': '์‹œ๊ฐ„๋Œ€', - 'meal': '์‹์‚ฌ', 'break_use': '์™ธ์ถœ', 'settings': '์„ค์ •', - 'stats': 'ํ†ต๊ณ„', 'secret': '์‹œํฌ๋ฆฟ', 'korea': 'ํ•œ๊ตญ ๋ฌธํ™”', - 'ambition': '์•ผ๋ง', 'meta': '๋ฉ”ํƒ€', -} - class AchievementsView(QDialog): """๋„์ „๊ณผ์ œ ๋‹ค์ด์–ผ๋กœ๊ทธ โ€” 4ํƒญ + ํ†ต๊ณ„ ํ—ค๋”.""" @@ -86,7 +72,7 @@ class AchievementsView(QDialog): def __init__(self, db, parent=None): super().__init__(parent) self.db = db - self.setWindowTitle("๋„์ „๊ณผ์ œ") + self.setWindowTitle(tr('achieve.title')) self.setMinimumSize(960, 720) self.resize(1100, 800) self._increment_view_count() @@ -121,21 +107,21 @@ class AchievementsView(QDialog): if a['earned_date'] is None and not a['is_secret']] secret_items = [a for a in all_items if a['is_secret']] - self.tabs.addTab(self._build_grid_tab(all_items), f"๐ŸŒ ์ „์ฒด ยท {len(all_items)}") + self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items))) self.tabs.addTab(self._build_grid_tab(in_progress), - f"โšก ์ง„ํ–‰ ์ค‘ ยท {len(in_progress)}") + tr('achieve.tab_in_progress', count=len(in_progress))) self.tabs.addTab(self._build_grid_tab(earned_items), - f"โœ“ ์™„๋ฃŒ ยท {len(earned_items)}") + tr('achieve.tab_completed', count=len(earned_items))) self.tabs.addTab( self._build_grid_tab(secret_items, secret_mode=True), - f"๐ŸŒ‘ ์‹œํฌ๋ฆฟ ยท {stats['secret_earned']}/{stats['secret_total']}" + tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total']) ) layout.addWidget(self.tabs, 1) # === ๋‹ซ๊ธฐ ๋ฒ„ํŠผ === btn_row = QHBoxLayout() btn_row.addStretch() - close_btn = QPushButton("๋‹ซ๊ธฐ") + close_btn = QPushButton(tr('btn.close')) close_btn.setMinimumWidth(100) close_btn.setStyleSheet(button_qss('default')) close_btn.clicked.connect(self.accept) @@ -183,7 +169,7 @@ class AchievementsView(QDialog): secret_lbl = QLabel( f"
" - f"๐ŸŒ‘ ์‹œํฌ๋ฆฟ
" + f"๐ŸŒ‘ {tr('achieve.cat_secret')}
" f"" f"{stats['secret_earned']}" f" / {stats['secret_total']}" @@ -196,7 +182,7 @@ class AchievementsView(QDialog): pct_lbl = QLabel( f"
" - f"๋‹ฌ์„ฑ๋ฅ 
" + f"{tr('achieve.completion_rate')}
" f"" f"{pct:.1f}%
" ) @@ -242,7 +228,7 @@ class AchievementsView(QDialog): grid.setContentsMargins(8, 8, 8, 8) if not items: - empty = QLabel("(์•„์ง ์—†์Œ)") + empty = QLabel(tr('achieve.empty')) empty.setAlignment(Qt.AlignCenter) empty.setStyleSheet( f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;" @@ -344,9 +330,9 @@ class AchievementsView(QDialog): name.setWordWrap(True) name_box.addWidget(name) - cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '') + cat_text = tr(f"achieve.cat_{item['category']}") if not is_locked_secret: - cat_label = QLabel(f" {theme['label']} {theme['name']} ยท {cat_text} ") + cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} ยท {cat_text} ") cat_label.setStyleSheet( f"font-size: 8.5pt; " f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; " @@ -368,7 +354,7 @@ class AchievementsView(QDialog): # 2ํ–‰: ์„ค๋ช… if is_locked_secret: - desc_text = "๐Ÿ”’ ๋‹ฌ์„ฑํ•˜๋ฉด ๊ณต๊ฐœ๋ฉ๋‹ˆ๋‹ค" + desc_text = tr('achieve.secret_locked') else: desc_text = item['description'] or '' desc = QLabel(desc_text) @@ -381,7 +367,7 @@ class AchievementsView(QDialog): # 3ํ–‰: ์ง„ํ–‰ ๊ฒŒ์ด์ง€ ๋˜๋Š” ํš๋“ ์ผ์ž if is_earned: - earned = QLabel(f" โœ“ {item['earned_date']} ๋‹ฌ์„ฑ ") + earned = QLabel(tr('achieve.earned_date', date=item['earned_date'])) earned.setStyleSheet( f"color: {theme['border_strong'] if _is_dark() else tc('text')}; " f"font-weight: bold; font-size: 9.5pt; " diff --git a/ui/calendar_view.py b/ui/calendar_view.py index 7b4d514..b345e26 100644 --- a/ui/calendar_view.py +++ b/ui/calendar_view.py @@ -12,6 +12,7 @@ import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.database import Database +from core.i18n import tr from ui.styles import ThemeColors, apply_dark_titlebar @@ -37,7 +38,7 @@ class CalendarView(QDialog): layout.setContentsMargins(12, 10, 12, 10) # ์ œ๋ชฉ - title = QLabel("์›”๊ฐ„ ๊ทผ๋ฌด ๊ธฐ๋ก") + title = QLabel(tr('cal.dialog_title')) title.setObjectName("dialog_title") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) @@ -55,8 +56,8 @@ class CalendarView(QDialog): # ๋ฒ”๋ก€ legend_layout = QHBoxLayout() legend_layout.setSpacing(12) - for _color, _txt in [('#51CF66', '์ •์ƒ'), ('#FA5252', '์—ฐ์žฅ'), - ('#FAB005', 'ํœด๊ฐ€'), ('#6C6E73', '์—†์Œ')]: + for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')), + ('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]: _item = QLabel(f"โ— {_txt}") _item.setTextFormat(Qt.RichText) legend_layout.addWidget(_item) @@ -64,7 +65,7 @@ class CalendarView(QDialog): layout.addLayout(legend_layout) # ์„ ํƒ๋œ ๋‚ ์งœ ์ƒ์„ธ ์ •๋ณด - detail_group = QGroupBox("์„ ํƒ๋œ ๋‚ ์งœ ์ •๋ณด") + detail_group = QGroupBox(tr('cal.detail_group_title')) detail_layout = QVBoxLayout() detail_layout.setSpacing(6) detail_layout.setContentsMargins(10, 20, 10, 8) @@ -78,13 +79,13 @@ class CalendarView(QDialog): button_layout = QHBoxLayout() button_layout.setSpacing(6) - self.edit_time_button = QPushButton("์‹œ๊ฐ„ ์ˆ˜์ •") + self.edit_time_button = QPushButton(tr('cal.edit_time')) self.edit_time_button.setObjectName("btn_primary") self.edit_time_button.setEnabled(False) self.edit_time_button.clicked.connect(self.edit_work_time) button_layout.addWidget(self.edit_time_button) - self.delete_record_button = QPushButton("๊ธฐ๋ก ์‚ญ์ œ") + self.delete_record_button = QPushButton(tr('cal.delete_record')) self.delete_record_button.setObjectName("btn_danger") self.delete_record_button.setEnabled(False) self.delete_record_button.clicked.connect(self.delete_selected_record) @@ -95,17 +96,17 @@ class CalendarView(QDialog): layout.addWidget(detail_group) # ๋ฉ”๋ชจ ๊ทธ๋ฃน - memo_group = QGroupBox("๋ฉ”๋ชจ") + memo_group = QGroupBox(tr('cal.memo_group')) memo_layout = QVBoxLayout() memo_layout.setSpacing(6) memo_layout.setContentsMargins(10, 20, 10, 8) self.memo_edit = QTextEdit() self.memo_edit.setMaximumHeight(70) - self.memo_edit.setPlaceholderText("์ถ”๊ฐ€๊ทผ๋ฌด ์‚ฌ์œ , ํŠน์ด์‚ฌํ•ญ ๋“ฑ...") + self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder')) memo_layout.addWidget(self.memo_edit) - self.save_memo_button = QPushButton("๋ฉ”๋ชจ ์ €์žฅ") + self.save_memo_button = QPushButton(tr('cal.save_memo')) self.save_memo_button.setObjectName("btn_primary") self.save_memo_button.setEnabled(False) self.save_memo_button.clicked.connect(self.save_memo) @@ -166,10 +167,10 @@ class CalendarView(QDialog): menu = QMenu(self) edit_action = delete_action = add_action = None if existing: - edit_action = menu.addAction(f"{date_str} ํŽธ์ง‘") - delete_action = menu.addAction(f"{date_str} ์‚ญ์ œ") + edit_action = menu.addAction(tr('cal.context_edit', date=date_str)) + delete_action = menu.addAction(tr('cal.context_delete', date=date_str)) else: - add_action = menu.addAction(f"{date_str} ๊ธฐ๋ก ์ถ”๊ฐ€") + add_action = menu.addAction(tr('cal.context_add', date=date_str)) action = menu.exec_(self.calendar.mapToGlobal(pos)) if action is None: @@ -217,9 +218,9 @@ class CalendarView(QDialog): if ot_earned > 0: self.db.add_overtime_earned(wid, ot_earned, date_str) self._refresh_calendar() - QMessageBox.information(self, "์ถ”๊ฐ€ ์™„๋ฃŒ", f"{date_str} ๊ธฐ๋ก์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + QMessageBox.information(self, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str)) except Exception as e: - QMessageBox.critical(self, "์˜ค๋ฅ˜", f"๊ธฐ๋ก ์ถ”๊ฐ€ ์‹คํŒจ: {e}") + QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e)) def _open_edit_dialog(self, date_str: str): """๊ธฐ์กด ์ผ์ž ํŽธ์ง‘ โ€” date_selected๋กœ ์šฐํšŒ (์ด๋ฏธ EditTimeDialog ์žˆ์Œ).""" @@ -231,8 +232,8 @@ class CalendarView(QDialog): def _delete_record(self, date_str: str): reply = QMessageBox.question( - self, "์‚ญ์ œ ํ™•์ธ", - f"{date_str} ๊ธฐ๋ก์„ ์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n(์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ๋‚ด์—ญ๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค)", + tr('cal.delete_confirm_title'), + tr('cal.delete_confirm_body', date=date_str), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: @@ -245,10 +246,10 @@ class CalendarView(QDialog): cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,)) conn.commit() self._refresh_calendar() - QMessageBox.information(self, "์‚ญ์ œ ์™„๋ฃŒ", f"{date_str} ๊ธฐ๋ก ์‚ญ์ œ๋จ") + QMessageBox.information(self, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str)) except Exception as e: conn.rollback() - QMessageBox.critical(self, "์˜ค๋ฅ˜", str(e)) + QMessageBox.critical(self, tr('cal.edit_error_title'), str(e)) finally: conn.close() @@ -270,33 +271,33 @@ class CalendarView(QDialog): if record: # ์ƒ์„ธ ์ •๋ณด ํ‘œ์‹œ - detail = f"{selected_date.strftime('%Y๋…„ %m์›” %d์ผ')}\n\n" - detail += f"์ถœ๊ทผ: {record['clock_in']}\n" + detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n' if record.get('clock_out'): - detail += f"ํ‡ด๊ทผ: {record['clock_out']}\n" - detail += f"์ด ๊ทผ๋ฌด์‹œ๊ฐ„: {record.get('total_hours', 0):.1f}์‹œ๊ฐ„\n" + detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n' + detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n' if record.get('lunch_break'): - detail += f"์ ์‹ฌ์‹œ๊ฐ„: ์‚ฌ์šฉํ•จ\n" + detail += tr('cal.detail_lunch_used') + '\n' else: - detail += f"์ ์‹ฌ์‹œ๊ฐ„: ๋ฏธ์‚ฌ์šฉ\n" + detail += tr('cal.detail_lunch_unused') + '\n' if record.get('dinner_break'): - detail += f"์ €๋…์‹œ๊ฐ„: ์‚ฌ์šฉํ•จ\n" + detail += tr('cal.detail_dinner_used') + '\n' else: - detail += f"์ €๋…์‹œ๊ฐ„: ๋ฏธ์‚ฌ์šฉ\n" + detail += tr('cal.detail_dinner_unused') + '\n' if record.get('overtime_earned', 0) > 0: earned_min = record['overtime_earned'] earned_hours = earned_min // 60 earned_mins = earned_min % 60 - detail += f"\n๐Ÿ”ฅ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ: {earned_hours}์‹œ๊ฐ„ {earned_mins}๋ถ„\n" + detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n' else: - detail += f"ํ‡ด๊ทผ: ๋ฏธ๊ธฐ๋ก\n" + detail += tr('cal.detail_clock_out_none') + '\n' if record.get('memo'): - detail += f"\n๋ฉ”๋ชจ: {record['memo']}\n" + detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n' self.detail_text.setText(detail) self.edit_time_button.setEnabled(True) @@ -306,7 +307,7 @@ class CalendarView(QDialog): self.memo_edit.setPlainText(record.get('memo', '')) self.save_memo_button.setEnabled(True) else: - self.detail_text.setText(f"{selected_date.strftime('%Y๋…„ %m์›” %d์ผ')}\n\n๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.") + self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record')) self.edit_time_button.setEnabled(False) self.delete_record_button.setEnabled(False) self.memo_edit.setPlainText('') @@ -319,10 +320,8 @@ class CalendarView(QDialog): reply = QMessageBox.question( self, - "์ถœ๊ทผ ๊ธฐ๋ก ์‚ญ์ œ", - f"{self.selected_date_str}์˜ ์ถœ๊ทผ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"โ€ป ์—ฐ๊ด€๋œ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ/์‚ฌ์šฉ ๊ธฐ๋ก๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.\n" - f"โ€ป ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + tr('cal.delete_selected_title'), + tr('cal.delete_selected_body', date=self.selected_date_str), QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) @@ -332,8 +331,8 @@ class CalendarView(QDialog): QMessageBox.information( self, - "์‚ญ์ œ ์™„๋ฃŒ", - f"{self.selected_date_str}์˜ ์ถœ๊ทผ ๊ธฐ๋ก์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('cal.delete_done_title'), + tr('cal.delete_done_body', date=self.selected_date_str) ) # ์บ˜๋ฆฐ๋” ์ƒˆ๋กœ๊ณ ์นจ @@ -353,8 +352,8 @@ class CalendarView(QDialog): QMessageBox.information( self, - "๋ฉ”๋ชจ ์ €์žฅ", - f"{self.selected_date_str}์˜ ๋ฉ”๋ชจ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('cal.save_memo_title'), + tr('cal.save_memo_body', date=self.selected_date_str) ) # ์ƒ์„ธ ์ •๋ณด ์ƒˆ๋กœ๊ณ ์นจ @@ -400,7 +399,7 @@ class EditWorkTimeDialog(QDialog): from PyQt5.QtWidgets import QTimeEdit from PyQt5.QtCore import QTime - self.setWindowTitle("์ถœํ‡ด๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •") + self.setWindowTitle(tr('cal.edit_dialog_title')) self.setModal(True) self.setMinimumWidth(420) @@ -409,19 +408,19 @@ class EditWorkTimeDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # ์ œ๋ชฉ - title = QLabel(f"{self.date_str} ์ถœํ‡ด๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •") + title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str)) title.setObjectName("dialog_subtitle") layout.addWidget(title) # ์ถœ๊ทผ ์‹œ๊ฐ„ clock_in_layout = QHBoxLayout() clock_in_layout.setSpacing(4) - clock_in_label = QLabel("์ถœ๊ทผ:") + clock_in_label = QLabel(tr('cal.label_clock_in')) clock_in_label.setObjectName("field_label") clock_in_label.setFixedWidth(40) clock_in_layout.addWidget(clock_in_label) - clock_in_minus_btn = QPushButton("-30๋ถ„") + clock_in_minus_btn = QPushButton(tr('cal.btn_minus_30')) clock_in_minus_btn.setFixedWidth(55) clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30)) clock_in_layout.addWidget(clock_in_minus_btn) @@ -432,7 +431,7 @@ class EditWorkTimeDialog(QDialog): self.clock_in_edit.setTime(clock_in_time) clock_in_layout.addWidget(self.clock_in_edit) - clock_in_plus_btn = QPushButton("+30๋ถ„") + clock_in_plus_btn = QPushButton(tr('cal.btn_plus_30')) clock_in_plus_btn.setFixedWidth(55) clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30)) clock_in_layout.addWidget(clock_in_plus_btn) @@ -441,12 +440,12 @@ class EditWorkTimeDialog(QDialog): # ํ‡ด๊ทผ ์‹œ๊ฐ„ clock_out_layout = QHBoxLayout() clock_out_layout.setSpacing(4) - clock_out_label = QLabel("ํ‡ด๊ทผ:") + clock_out_label = QLabel(tr('cal.label_clock_out')) clock_out_label.setObjectName("field_label") clock_out_label.setFixedWidth(40) clock_out_layout.addWidget(clock_out_label) - clock_out_minus_btn = QPushButton("-30๋ถ„") + clock_out_minus_btn = QPushButton(tr('cal.btn_minus_30')) clock_out_minus_btn.setFixedWidth(55) clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30)) clock_out_layout.addWidget(clock_out_minus_btn) @@ -458,7 +457,7 @@ class EditWorkTimeDialog(QDialog): self.clock_out_edit.setTime(clock_out_time) clock_out_layout.addWidget(self.clock_out_edit) - clock_out_plus_btn = QPushButton("+30๋ถ„") + clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30')) clock_out_plus_btn.setFixedWidth(55) clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30)) clock_out_layout.addWidget(clock_out_plus_btn) @@ -467,27 +466,27 @@ class EditWorkTimeDialog(QDialog): # ์ ์‹ฌ/์ €๋… ์ฒดํฌ๋ฐ•์Šค - ํ•œ ์ค„์— from PyQt5.QtWidgets import QCheckBox check_layout = QHBoxLayout() - self.lunch_check = QCheckBox("์ ์‹ฌ (1์‹œ๊ฐ„)") + self.lunch_check = QCheckBox(tr('cal.check_lunch_1h')) self.lunch_check.setChecked(bool(self.record.get('lunch_break', False))) check_layout.addWidget(self.lunch_check) - self.dinner_check = QCheckBox("์ €๋… (1์‹œ๊ฐ„)") + self.dinner_check = QCheckBox(tr('cal.check_dinner_1h')) self.dinner_check.setChecked(bool(self.record.get('dinner_break', False))) check_layout.addWidget(self.dinner_check) layout.addLayout(check_layout) # ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ - note = QLabel("โ€ป ์ˆ˜์ • ์‹œ ์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ์ด ์žฌ๊ณ„์‚ฐ๋ฉ๋‹ˆ๋‹ค.") + note = QLabel(tr('cal.edit_note')) note.setObjectName("note_text") layout.addWidget(note) # ๋ฒ„ํŠผ button_layout = QHBoxLayout() - save_button = QPushButton("์ €์žฅ") + save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_success") save_button.clicked.connect(self.save_changes) - cancel_button = QPushButton("์ทจ์†Œ") + cancel_button = QPushButton(tr('btn.cancel')) cancel_button.clicked.connect(self.reject) button_layout.addWidget(save_button) @@ -513,8 +512,8 @@ class EditWorkTimeDialog(QDialog): if clock_out <= clock_in: QMessageBox.warning( self, - "์‹œ๊ฐ„ ์˜ค๋ฅ˜", - "ํ‡ด๊ทผ ์‹œ๊ฐ„์€ ์ถœ๊ทผ ์‹œ๊ฐ„๋ณด๋‹ค ๋Šฆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + tr('cal.time_error_title'), + tr('cal.time_error_body') ) return @@ -597,15 +596,12 @@ class EditWorkTimeDialog(QDialog): QMessageBox.information( self, - "์ˆ˜์ • ์™„๋ฃŒ", - f"{self.date_str}์˜ ์ถœํ‡ด๊ทผ ์‹œ๊ฐ„์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n" - f"์ถœ๊ทผ: {clock_in}\n" - f"ํ‡ด๊ทผ: {clock_out}\n" - f"์ ์‹ฌ์‹œ๊ฐ„: {'์‚ฌ์šฉ' if lunch_break else '๋ฏธ์‚ฌ์šฉ'}\n" - f"์ €๋…์‹œ๊ฐ„: {'์‚ฌ์šฉ' if dinner_break else '๋ฏธ์‚ฌ์šฉ'}\n" - f"์™ธ์ถœ์‹œ๊ฐ„: {break_minutes}๋ถ„\n" - f"์ด ๊ทผ๋ฌด์‹œ๊ฐ„: {total_hours:.1f}์‹œ๊ฐ„\n" - f"์—ฐ์žฅ๊ทผ๋ฌด: {overtime_earned}๋ถ„ ์ ๋ฆฝ" + tr('cal.edit_done_title'), + tr('cal.edit_done_body', + date=self.date_str, clock_in=clock_in, clock_out=clock_out, + lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'), + dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'), + break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned) ) self.accept() @@ -613,8 +609,8 @@ class EditWorkTimeDialog(QDialog): except Exception as e: QMessageBox.critical( self, - "์˜ค๋ฅ˜", - f"์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{str(e)}" + tr('cal.edit_error_title'), + tr('cal.edit_error_body', error=str(e)) ) finally: if conn: diff --git a/ui/chart_widget.py b/ui/chart_widget.py index fa21710..4779a2c 100644 --- a/ui/chart_widget.py +++ b/ui/chart_widget.py @@ -9,6 +9,8 @@ from typing import List, Tuple from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from PyQt5.QtCore import Qt +from core.i18n import tr + try: import matplotlib from matplotlib.figure import Figure @@ -89,7 +91,7 @@ class _Fallback(QWidget): def make_chart_widget(parent=None) -> QWidget: """์ฐจํŠธ๊ฐ€ ๊ทธ๋ ค์งˆ ๋นˆ ์บ”๋ฒ„์Šค ์œ„์ ฏ. matplotlib ์—†์œผ๋ฉด fallback.""" if not _MPL: - return _Fallback("์ฐจํŠธ ํ‘œ์‹œ์—๋Š” matplotlib๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.\npip install matplotlib") + return _Fallback(tr('chart.need_matplotlib')) _refresh_chart_colors() widget = QWidget(parent) widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;") @@ -115,7 +117,7 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: if not records: ax = fig.add_subplot(111) _apply_dark_axes(ax) - ax.text(0.5, 0.5, '๊ธฐ๋ก ์—†์Œ', ha='center', va='center', + ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center', transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) widget._canvas.draw() return @@ -127,10 +129,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: base = [max(h - o, 0) for h, o in zip(hours, overtimes)] ax = fig.add_subplot(111) - bars_base = ax.bar(dates, base, label='์ •์ƒ', color=_CHART_BAR_NORMAL) - bars_ot = ax.bar(dates, overtimes, bottom=base, label='์—ฐ์žฅ', + bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL) + bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'), color=_CHART_BAR_OVERTIME) - ax.set_ylabel('์‹œ๊ฐ„') + ax.set_ylabel(tr('chart.ylabel_hours')) legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG, edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT) ax.tick_params(axis='x', labelrotation=45, labelsize=8) @@ -157,9 +159,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: for i, bar in enumerate(bars): if bar.contains(event)[0]: h = hours[i]; ot = overtimes[i] - text = f"โ–ผ {full_dates[i]}\n๊ทผ๋ฌด {h:.1f}h" + text = tr('chart.hover_text', + date=full_dates[i], hours=f"{h:.1f}") if ot > 0: - text += f"\n์—ฐ์žฅ +{ot:.1f}h" + text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}") annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y()) annot.set_text(text) annot.set_visible(True) @@ -191,7 +194,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None: if not records: ax = fig.add_subplot(111) _apply_dark_axes(ax) - ax.text(0.5, 0.5, '๊ธฐ๋ก ์—†์Œ', ha='center', va='center', + ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center', transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) widget._canvas.draw() return @@ -211,7 +214,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None: if not minutes_list: ax = fig.add_subplot(111) _apply_dark_axes(ax) - ax.text(0.5, 0.5, '๊ธฐ๋ก ์—†์Œ', ha='center', va='center', + ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center', transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) widget._canvas.draw() return @@ -225,12 +228,13 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None: ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL, edgecolor=_CHART_BG, linewidth=1) avg = sum(minutes_list) / len(minutes_list) + avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}" ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2, - label=f'ํ‰๊ท  {int(avg//60):02d}:{int(avg%60):02d}') + label=tr('chart.avg_line', time=avg_time)) ax.set_xticks([m for m in bins if m % 60 == 0]) ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0], rotation=45, fontsize=8) - ax.set_ylabel('์ผ์ˆ˜') + ax.set_ylabel(tr('chart.ylabel_days')) legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG, edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT) _apply_dark_axes(ax) @@ -257,11 +261,13 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None: weekday_counts[d.weekday()] += 1 avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)] - labels = ['์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ', '์ผ'] + labels = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'), + tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), + tr('label.weekday_sun')] ax = fig.add_subplot(111) colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # ์ฃผ๋ง ๊ณจ๋“œ ๊ฐ•์กฐ ax.bar(labels, avg, color=colors) - ax.set_ylabel('ํ‰๊ท  ์‹œ๊ฐ„') + ax.set_ylabel(tr('chart.ylabel_avg_hours')) _apply_dark_axes(ax) widget._canvas.draw() diff --git a/ui/clock_in_dialog.py b/ui/clock_in_dialog.py index 76e5d4a..9c3bb22 100644 --- a/ui/clock_in_dialog.py +++ b/ui/clock_in_dialog.py @@ -134,8 +134,8 @@ if __name__ == "__main__": dialog = ClockInDialog() if dialog.exec_() == QDialog.Accepted: selected_time = dialog.get_time() - print(f"์„ ํƒ๋œ ์‹œ๊ฐ„: {selected_time.strftime('%H:%M:%S')}") + print(tr('clock_in_dialog.selected', time=selected_time.strftime('%H:%M:%S'))) else: - print("์ทจ์†Œ๋จ") + print(tr('clock_in_dialog.cancelled')) sys.exit() diff --git a/ui/controllers/lock_monitor.py b/ui/controllers/lock_monitor.py index b0812ef..97a855f 100644 --- a/ui/controllers/lock_monitor.py +++ b/ui/controllers/lock_monitor.py @@ -8,6 +8,7 @@ from __future__ import annotations from datetime import datetime from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK +import sqlite3 class LockMonitor: @@ -61,14 +62,23 @@ class LockMonitor: clock_in_str = when.strftime("%H:%M:%S") existing = self.db.get_today_record() if existing: - conn = self.db.get_connection() - cursor = conn.cursor() - cursor.execute( - "UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?", - (clock_in_str, today), - ) - conn.commit() - conn.close() + with self.db._conn() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?", + (clock_in_str, today), + ) + conn.commit() else: - self.db.add_work_record(today, clock_in_str) + try: + self.db.add_work_record(today, clock_in_str) + except sqlite3.IntegrityError: + # get_today_record()์™€ add_work_record() ์‚ฌ์ด ๊ฒฝ์Ÿ ์กฐ๊ฑด + with self.db._conn() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?", + (clock_in_str, today), + ) + conn.commit() w.update_display() diff --git a/ui/controllers/notification_orchestrator.py b/ui/controllers/notification_orchestrator.py index e75f2e0..17b71c0 100644 --- a/ui/controllers/notification_orchestrator.py +++ b/ui/controllers/notification_orchestrator.py @@ -10,6 +10,7 @@ from datetime import datetime from core.settings_keys import ( HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS, ) +from core.i18n import tr from utils.debug_log import dlog @@ -54,12 +55,12 @@ class NotificationOrchestrator: longest = max(closed, key=lambda r: r.get('total_hours') or 0) longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)" - title = "๐Ÿ“Š ์ง€๋‚œ์ฃผ ์š”์•ฝ" - body = (f"๊ธฐ๊ฐ„: {last_mon} ~ {last_sun}\n" - f"์ด ๊ทผ๋ฌด: {total_h:.1f}์‹œ๊ฐ„ ({len(closed)}์ผ)\n" - f"์ผ ํ‰๊ท : {avg_h:.1f}์‹œ๊ฐ„\n" - f"์—ฐ์žฅ๊ทผ๋ฌด: {ot_h}์‹œ๊ฐ„ {ot_m}๋ถ„\n" - f"๊ฐ€์žฅ ๊ธด ๋‚ : {longest_str}") + title = tr('notif.weekly_report.title') + body = tr('notif.weekly_report.body', + start=last_mon, end=last_sun, + total_h=total_h, days=len(closed), + avg_h=avg_h, ot_h=ot_h, ot_m=ot_m, + longest=longest_str) self.notifier.notification_signal.emit(title, body) self.db.log_notification('system', 'weekly_report') @@ -70,13 +71,17 @@ class NotificationOrchestrator: try: from utils.discord_webhook import send, COLOR_BLUE fields = [ - {"name": "์ด ๊ทผ๋ฌด", "value": f"{total_h:.1f}์‹œ๊ฐ„ ({len(closed)}์ผ)", "inline": True}, - {"name": "์ผ ํ‰๊ท ", "value": f"{avg_h:.1f}์‹œ๊ฐ„", "inline": True}, - {"name": "์—ฐ์žฅ๊ทผ๋ฌด", "value": f"{ot_h}์‹œ๊ฐ„ {ot_m}๋ถ„", "inline": True}, - {"name": "๊ฐ€์žฅ ๊ธด ๋‚ ", "value": longest_str, "inline": False}, + {"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True}, + {"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True}, + {"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True}, + {"name": tr('field.longest_day'), "value": longest_str, "inline": False}, ] - ok = send(url, "๐Ÿ“Š ์ง€๋‚œ์ฃผ ์š”์•ฝ", - f"๊ธฐ๊ฐ„: {last_mon} ~ {last_sun}", + ok = send(url, tr('notif.weekly_report.title'), + tr('notif.weekly_report.body', + start=last_mon, end=last_sun, + total_h=total_h, days=len(closed), + avg_h=avg_h, ot_h=ot_h, ot_m=ot_m, + longest=longest_str), color=COLOR_BLUE, fields=fields) self.db.log_notification('discord', 'weekly_report', success=ok) except Exception as e: @@ -147,8 +152,8 @@ class NotificationOrchestrator: for a in unlocked: self.db.log_notification('system', f'achievement:{a.code}') if notif_on: - title = f"{a.badge_icon} ๋„์ „๊ณผ์ œ ๋‹ฌ์„ฑ!" - body = f"{a.name}\n{a.description}" + title = tr('notif.achievement.title', icon=a.badge_icon) + body = tr('notif.achievement.body', name=a.name, description=a.description) self.notifier.notification_signal.emit(title, body) # Discord ํ†ตํ•ฉ push (์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ๋ฌถ์–ด์„œ) self._discord_achievements(unlocked) @@ -167,8 +172,8 @@ class NotificationOrchestrator: extra = (f"\n... ์™ธ {len(unlocked) - 10}๊ฐœ" if len(unlocked) > 10 else '') ok = discord_webhook.send( url, - f"๐Ÿ† ๋„์ „๊ณผ์ œ {len(unlocked)}๊ฐœ ๋‹ฌ์„ฑ!", - f"์ƒˆ๋กœ ์ž ๊ธˆ ํ•ด์ œ๋œ ๋„์ „๊ณผ์ œ ์ž…๋‹ˆ๋‹ค.{extra}", + tr('discord.achievement.title', count=len(unlocked)), + tr('discord.achievement.body', extra=extra), color=discord_webhook.COLOR_YELLOW, fields=fields, ) diff --git a/ui/goal_widget.py b/ui/goal_widget.py index 8e120d0..36ee643 100644 --- a/ui/goal_widget.py +++ b/ui/goal_widget.py @@ -9,6 +9,8 @@ from datetime import datetime, date from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar from PyQt5.QtCore import Qt +from core.i18n import tr + class GoalWidget(QWidget): """์›”๊ฐ„ ๋ชฉํ‘œ ์ง„ํ–‰๋ฅ  ํ‘œ์‹œ.""" @@ -20,13 +22,13 @@ class GoalWidget(QWidget): layout.setContentsMargins(8, 6, 8, 6) layout.setSpacing(4) - title = QLabel("์ด๋ฒˆ ๋‹ฌ ๋ชฉํ‘œ") + title = QLabel(tr('goal.title')) title.setStyleSheet("font-weight: bold;") layout.addWidget(title) # ์—ฐ์žฅ๊ทผ๋ฌด ์ƒํ•œ ot_row = QHBoxLayout() - self.ot_label = QLabel("์—ฐ์žฅ๊ทผ๋ฌด:") + self.ot_label = QLabel(tr('goal.overtime')) self.ot_label.setFixedWidth(100) self.ot_bar = QProgressBar() self.ot_bar.setTextVisible(True) @@ -37,7 +39,7 @@ class GoalWidget(QWidget): # ์ผํ‰๊ท  avg_row = QHBoxLayout() - self.avg_label = QLabel("์ผํ‰๊ท :") + self.avg_label = QLabel(tr('goal.avg_daily')) self.avg_label.setFixedWidth(100) self.avg_bar = QProgressBar() self.avg_bar.setTextVisible(True) diff --git a/ui/help_view.py b/ui/help_view.py index 6de5e91..4ee3618 100644 --- a/ui/help_view.py +++ b/ui/help_view.py @@ -59,7 +59,7 @@ class HelpView(QDialog): button_layout.setContentsMargins(0, 6, 0, 0) # ์˜จ๋ณด๋”ฉ ๋‹ค์‹œ ๋ณด๊ธฐ (์™ผ์ชฝ, ghost ์Šคํƒ€์ผ) - onboarding_button = QPushButton("์˜จ๋ณด๋”ฉ ๋‹ค์‹œ ๋ณด๊ธฐ") + onboarding_button = QPushButton(tr('help.onboarding_button')) onboarding_button.setMinimumHeight(36) onboarding_button.setStyleSheet(button_qss('ghost')) onboarding_button.clicked.connect(self._reopen_onboarding) diff --git a/ui/leave_calendar_view.py b/ui/leave_calendar_view.py index 262f99a..3ed4815 100644 --- a/ui/leave_calendar_view.py +++ b/ui/leave_calendar_view.py @@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, from PyQt5.QtCore import Qt, QDate from PyQt5.QtGui import QTextCharFormat, QColor, QBrush +from core.i18n import tr from ui.styles import apply_dark_titlebar @@ -22,7 +23,7 @@ class LeaveCalendarView(QDialog): def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db - self.setWindowTitle("์—ฐ์ฐจ ์บ˜๋ฆฐ๋”") + self.setWindowTitle(tr('leave_cal.title')) self.setModal(True) self.setMinimumSize(540, 480) self._build_ui() @@ -37,7 +38,7 @@ class LeaveCalendarView(QDialog): balance = float(self.db.get_setting('leave_balance', '0') or 0) total = float(self.db.get_setting('annual_leave_total', '15') or 15) used = total - balance - title = QLabel(f"์ž”์—ฌ {balance:.2f}์ผ / ์ด {total:.0f}์ผ (์‚ฌ์šฉ {used:.2f}์ผ)") + title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used)) title.setStyleSheet("font-weight: bold; font-size: 13px;") header.addWidget(title) header.addStretch() @@ -45,9 +46,9 @@ class LeaveCalendarView(QDialog): # ๋ฒ”๋ก€ (์‚ฌ์šฉ ์™„๋ฃŒ + ์˜ˆ์ • ๋ถ„๋ฆฌ) legend = QHBoxLayout() - for _color, _txt in [('#51CF66', '์ข…์ผ(1.0)'), ('#FAB005', '๋ฐ˜์ฐจ(0.5)'), - ('#B197FC', '๋ฐ˜๋ฐ˜์ฐจ(0.25)'), ('#4DABF7', '์˜ˆ์ •'), - ('#748FFC', '์ข…์ผ+์˜ˆ์ •')]: + for _color, _txt in [('#51CF66', tr('leave_cal.legend_full')), ('#FAB005', tr('leave_cal.legend_half')), + ('#B197FC', tr('leave_cal.legend_quarter')), ('#4DABF7', tr('leave_cal.legend_planned')), + ('#748FFC', tr('leave_cal.legend_full_planned'))]: l = QLabel(f"โ— {_txt}") l.setStyleSheet("padding: 2px 6px;") legend.addWidget(l) @@ -68,7 +69,7 @@ class LeaveCalendarView(QDialog): # ๋‹ซ๊ธฐ ๋ฒ„ํŠผ btn_row = QHBoxLayout() btn_row.addStretch() - close_btn = QPushButton("๋‹ซ๊ธฐ") + close_btn = QPushButton(tr('btn.close')) close_btn.clicked.connect(self.close) btn_row.addWidget(close_btn) layout.addLayout(btn_row) @@ -109,12 +110,12 @@ class LeaveCalendarView(QDialog): records = self.db.get_all_leave_records(limit=365) match = [r for r in records if r['date'] == date_str] if not match: - self.detail_label.setText(f"{date_str} โ€” ์—ฐ์ฐจ ์‚ฌ์šฉ ์—†์Œ") + self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str)) return parts = [] for r in match: t = r.get('leave_type', 'annual') d = float(r.get('days') or 0) memo = r.get('memo') or '' - parts.append(f"{t} {d}์ผ" + (f" ({memo})" if memo else "")) + parts.append(tr('leave_cal.detail_memo', type=t, days=d, memo=memo) if memo else tr('leave_cal.detail_label', type=t, days=d)) self.detail_label.setText(f"{date_str}: " + ", ".join(parts)) diff --git a/ui/leave_view.py b/ui/leave_view.py index 1b336af..ce41ca5 100644 --- a/ui/leave_view.py +++ b/ui/leave_view.py @@ -90,8 +90,8 @@ class LeaveView(QDialog): cal_button.clicked.connect(self._show_calendar) button_layout.addWidget(cal_button) - schedule_button = QPushButton("์Šค์ผ€์ค„") - schedule_button.setToolTip("ํœด์ผ + ์—ฐ์ฐจ + ๋ฐ˜๋ณต ํŒจํ„ด ํ†ตํ•ฉ ๋ณด๊ธฐ") + schedule_button = QPushButton(tr('view.leave.btn_schedule')) + schedule_button.setToolTip(tr('view.leave.schedule_tooltip')) schedule_button.clicked.connect(self._show_schedule) button_layout.addWidget(schedule_button) @@ -137,13 +137,13 @@ class LeaveView(QDialog): days = record['days'] hours = days * 8 if days == 1.0: - days_str = "1์ผ" + days_str = tr('view.leave.used_1day') elif days == 0.5: - days_str = "0.5์ผ (4์‹œ๊ฐ„)" + days_str = tr('view.leave.used_half_day') elif hours < 8: - days_str = f"{days}์ผ ({hours}์‹œ๊ฐ„)" + days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours) else: - days_str = f"{days}์ผ" + days_str = tr('view.leave.used_days_fmt', days=days) days_item = QTableWidgetItem(days_str) days_item.setTextAlignment(Qt.AlignCenter) days_item.setForeground(QColor(250, 82, 82)) # ์‚ฌ์šฉ = ๋ ˆ๋“œ (#FA5252) @@ -365,17 +365,17 @@ class AddLeaveDialog(QDialog): if date_dt.weekday() in (5, 6): # ํ† /์ผ QMessageBox.warning( self, - "์ฃผ๋ง ๋“ฑ๋ก ๋ถˆ๊ฐ€", - "์ฃผ๋ง์—๋Š” ์—ฐ์ฐจ๋ฅผ ๋“ฑ๋กํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (์ด๋ฏธ ๋น„๊ทผ๋ฌด์ผ)" + tr('view.leave.weekend_register_forbidden_title'), + tr('view.leave.weekend_register_forbidden_body') ) return if self.db.is_holiday(date): holiday = self.db.get_holiday(date) - name = (holiday or {}).get('name', '๊ณตํœด์ผ') + name = (holiday or {}).get('name', tr('label.holiday_default')) QMessageBox.warning( self, - "๊ณตํœด์ผ ๋“ฑ๋ก ๋ถˆ๊ฐ€", - f"{date}๋Š” ์ด๋ฏธ ๊ณตํœด์ผ({name})์ž…๋‹ˆ๋‹ค.\n์—ฐ์ฐจ๋ฅผ ์ฐจ๊ฐํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." + tr('view.leave.holiday_register_forbidden_title'), + tr('view.leave.holiday_register_forbidden_body', date=date, name=name) ) return @@ -385,9 +385,8 @@ class AddLeaveDialog(QDialog): if existing_days + days > 1.0001: # ๋ถ€๋™์†Œ์ˆ˜์  ์—ฌ์œ  QMessageBox.warning( self, - "์ค‘๋ณต ๋“ฑ๋ก ์ดˆ๊ณผ", - f"{date}์— ์ด๋ฏธ {existing_days:.2f}์ผ์ด ๋“ฑ๋ก๋˜์–ด ์žˆ์–ด\n" - f"์ถ”๊ฐ€ {days:.2f}์ผ์„ ๋”ํ•˜๋ฉด 1์ผ์„ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค." + tr('view.leave.duplicate_register_title'), + tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days) ) return diff --git a/ui/main_window.py b/ui/main_window.py index bd3a747..8f576a7 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -36,6 +36,7 @@ from core.notifier import Notifier from utils.system_tray import SystemTrayIcon from utils.time_format import format_hours_minutes from ui.styles import get_theme, ThemeColors, apply_dark_titlebar +from utils.debug_log import dlog class MainWindow(QMainWindow): @@ -72,8 +73,8 @@ class MainWindow(QMainWindow): try: from ui.accessibility import apply_from_settings as _apply_a11y _apply_a11y(self.db) - except Exception: - pass + except Exception as e: + dlog(f"accessibility apply failed: {e}") # TimeCalculator ์ดˆ๊ธฐํ™” (์„ค์ •๊ฐ’ ๋ฐ˜์˜) settings = self.db.get_settings() @@ -137,6 +138,13 @@ class MainWindow(QMainWindow): self.is_on_break = False # ์™ธ์ถœ ์ค‘ ์—ฌ๋ถ€ self.midnight_rollover_handled = False # ์ž์ • ๋„˜๊น€ ์ฒ˜๋ฆฌ ์—ฌ๋ถ€ self.auto_lunch_applied_today = False # auto_lunch ์ค‘๋ณต ์ ์šฉ ๋ฐฉ์ง€ + + # update_display 1Hz ํ•ซํŒจ์Šค ์บ์‹œ (์„ค์ •/๋‚ ์งœ ๋ณ€๊ฒฝ ์‹œ์—๋งŒ ์žฌ๊ณ„์‚ฐ) + self._workday_boundary_hour_cache = None + self._non_working_cache_date = None + self._non_working_cache_value = None + self._full_day_leave_cache_date = None + self._full_day_leave_cache_value = None # ์ปจํŠธ๋กค๋Ÿฌ๋Š” init_ui() ์ดํ›„ ์•Œ๋ฆผ ์‹œ์Šคํ…œ ์ƒ์„ฑ ์‹œ์ ์— ํ•จ๊ป˜ ์ดˆ๊ธฐํ™” # UI ์ดˆ๊ธฐํ™” @@ -159,8 +167,8 @@ class MainWindow(QMainWindow): qapp = QApplication.instance() if qapp is not None: qapp.aboutToQuit.connect(self._on_app_quit) - except Exception: - pass + except Exception as e: + dlog(f"aboutToQuit connect failed: {e}") # ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋กœ๋“œ self.load_today_data() @@ -214,14 +222,12 @@ class MainWindow(QMainWindow): hour = dt.hour minute = dt.minute second = dt.second - period = "์˜ค์ „" if hour < 12 else "์˜คํ›„" + period = tr('label.am') if hour < 12 else tr('label.pm') display_hour = hour % 12 if display_hour == 0: display_hour = 12 - if include_seconds: - return f"{period} {display_hour}:{minute:02d}:{second:02d}" - else: - return f"{period} {display_hour}:{minute:02d}" + minute_str = f"{minute:02d}:{second:02d}" if include_seconds else f"{minute:02d}" + return tr('time_format.12h', period=period, hour=display_hour, minute=minute_str) else: # 24์‹œ๊ฐ„ ํ˜•์‹ if include_seconds: @@ -267,7 +273,7 @@ class MainWindow(QMainWindow): main_layout.setContentsMargins(24, 20, 24, 16) # 1. ํ—ค๋” - ์•ฑ ํƒ€์ดํ‹€ - title_label = QLabel("ํ‡ด๊ทผ์‹œ๊ฐ„ ๊ณ„์‚ฐ๊ธฐ") + title_label = QLabel(tr('app.title')) title_label.setObjectName("app_title") title_label.setAlignment(Qt.AlignCenter) main_layout.addWidget(title_label) @@ -302,7 +308,7 @@ class MainWindow(QMainWindow): self.lunch_button.customContextMenuRequested.connect( lambda pos: self._show_meal_context('lunch', self.lunch_button, pos) ) - self.lunch_button.setToolTip("์ขŒํด๋ฆญ: ํ† ๊ธ€ / ์šฐํด๋ฆญ: ์‹ค์ œ ์‹œ๊ฐ„ ์ž…๋ ฅ") + self.lunch_button.setToolTip(tr('tooltip.meal_click')) self.dinner_button = QPushButton(tr('btn.dinner_add')) self.dinner_button.setCheckable(True) @@ -311,7 +317,7 @@ class MainWindow(QMainWindow): self.dinner_button.customContextMenuRequested.connect( lambda pos: self._show_meal_context('dinner', self.dinner_button, pos) ) - self.dinner_button.setToolTip("์ขŒํด๋ฆญ: ํ† ๊ธ€ / ์šฐํด๋ฆญ: ์‹ค์ œ ์‹œ๊ฐ„ ์ž…๋ ฅ") + self.dinner_button.setToolTip(tr('tooltip.meal_click')) meal_button_layout.addWidget(self.lunch_button) meal_button_layout.addWidget(self.dinner_button) @@ -321,14 +327,14 @@ class MainWindow(QMainWindow): break_button_layout = QHBoxLayout() break_button_layout.setSpacing(8) - self.break_out_button = QPushButton("์™ธ์ถœ") + self.break_out_button = QPushButton(tr('btn.break_out')) self.break_out_button.clicked.connect(self.break_out) - self.break_in_button = QPushButton("๋ณต๊ท€") + self.break_in_button = QPushButton(tr('btn.break_in')) self.break_in_button.clicked.connect(self.break_in) self.break_in_button.setEnabled(False) - self.break_manage_button = QPushButton("์™ธ์ถœ ๊ด€๋ฆฌ") + self.break_manage_button = QPushButton(tr('btn.break_manage')) self.break_manage_button.clicked.connect(self.show_break_management) break_button_layout.addWidget(self.break_out_button) @@ -369,7 +375,7 @@ class MainWindow(QMainWindow): stats_button = QPushButton(tr('menu.stats')) calendar_button = QPushButton(tr('menu.calendar')) report_button = QPushButton(tr('menu.daily_report')) - achievements_button = QPushButton("๋„์ „๊ณผ์ œ") + achievements_button = QPushButton(tr('btn.achievements')) help_button = QPushButton(tr('menu.help')) settings_button = QPushButton(tr('menu.settings')) @@ -458,7 +464,7 @@ class MainWindow(QMainWindow): def create_clock_in_group(self) -> QGroupBox: """์ถœ๊ทผ ์ •๋ณด ๊ทธ๋ฃน ์ƒ์„ฑ โ€” ์ถœ๊ทผ/ํ˜„์žฌ ์‹œ๊ฐ์„ ํ•œ ์ค„์— ๋‚˜๋ž€ํžˆ""" - group = QGroupBox("์˜ค๋Š˜์˜ ๊ทผ๋ฌด") + group = QGroupBox(tr('group.today_work')) layout = QVBoxLayout() layout.setSpacing(8) @@ -473,12 +479,12 @@ class MainWindow(QMainWindow): self.clock_in_value.setObjectName("time_value") # ๋ผ๋ฒจ ์ž์ฒด๋„ ํด๋ฆญ ๊ฐ€๋Šฅ (์ธ๋ผ์ธ ํŽธ์ง‘ โ€” ์ถœ๊ทผ ์‹œ๊ฐ„ ๋น ๋ฅธ ์ˆ˜์ •) self.clock_in_value.setCursor(Qt.PointingHandCursor) - self.clock_in_value.setToolTip("ํด๋ฆญํ•˜์—ฌ ์ถœ๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •") + self.clock_in_value.setToolTip(tr('tooltip.clock_in_edit')) self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in() clock_in_col = QVBoxLayout() clock_in_col.setSpacing(2) - clock_in_label = QLabel("์ถœ๊ทผ") + clock_in_label = QLabel(tr('label.clock_in_time')) clock_in_label.setObjectName("field_label") clock_in_col.addWidget(clock_in_label) @@ -488,7 +494,7 @@ class MainWindow(QMainWindow): self.edit_clock_in_button = QPushButton("") self.edit_clock_in_button.setObjectName("btn_small") self.edit_clock_in_button.setFixedWidth(30) - self.edit_clock_in_button.setToolTip("์ถœ๊ทผ ์‹œ๊ฐ„ ์ˆ˜์ •") + self.edit_clock_in_button.setToolTip(tr('tooltip.clock_in_edit')) self.edit_clock_in_button.clicked.connect(self.manual_clock_in) clock_in_value_row.addWidget(self.clock_in_value) clock_in_value_row.addWidget(self.edit_clock_in_button) @@ -498,7 +504,7 @@ class MainWindow(QMainWindow): # ํ˜„์žฌ ์ปฌ๋Ÿผ self.current_time_value = QLabel("--:--:--") self.current_time_value.setObjectName("time_value") - current_col = self._build_time_column("ํ˜„์žฌ", self.current_time_value) + current_col = self._build_time_column(tr('label.current_time'), self.current_time_value) row.addLayout(clock_in_col, 1) row.addLayout(current_col, 1) @@ -509,7 +515,7 @@ class MainWindow(QMainWindow): def create_remaining_time_group(self) -> QGroupBox: """๋‚จ์€ ์‹œ๊ฐ„ ํžˆ์–ด๋กœ ๊ทธ๋ฃน โ€” ๋‚จ์€์‹œ๊ฐ„(๊ฐ€์žฅ ํผ) + ์ง„ํ–‰๋ฅ  + ์˜ˆ์ƒ ํ‡ด๊ทผ์‹œ๊ฐ""" - self.remaining_time_group = QGroupBox("๋‚จ์€ ์‹œ๊ฐ„") + self.remaining_time_group = QGroupBox(tr('group.remaining_time')) layout = QVBoxLayout() layout.setSpacing(12) @@ -539,7 +545,7 @@ class MainWindow(QMainWindow): def create_overtime_group(self) -> QGroupBox: """์—ฐ์žฅ๊ทผ๋ฌด ๋ฐ ์—ฐ์ฐจ ํ˜„ํ™ฉ ๊ทธ๋ฃน ์ƒ์„ฑ""" - group = QGroupBox("์—ฐ์žฅ๊ทผ๋ฌด ๋ฐ ์—ฐ์ฐจ ํ˜„ํ™ฉ") + group = QGroupBox(tr('group.overtime_leave')) layout = QVBoxLayout() layout.setSpacing(10) @@ -547,12 +553,12 @@ class MainWindow(QMainWindow): # ์—ฐ์žฅ๊ทผ๋ฌด ์„น์…˜ overtime_header = QHBoxLayout() - overtime_title = QLabel("์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ") + overtime_title = QLabel(tr('section.overtime_earned')) overtime_title.setObjectName("section_title") overtime_header.addWidget(overtime_title) overtime_header.addStretch() - self.overtime_balance_label = QLabel("0๋ถ„ (ร—0)") + self.overtime_balance_label = QLabel(tr('label.overtime_balance_zero')) self.overtime_balance_label.setObjectName("badge_overtime") overtime_header.addWidget(self.overtime_balance_label) layout.addLayout(overtime_header) @@ -560,11 +566,11 @@ class MainWindow(QMainWindow): # ์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ ๋ฒ„ํŠผ (1์ค„) overtime_button_layout = QHBoxLayout() overtime_button_layout.setSpacing(4) - use_30min_button = QPushButton("30๋ถ„") - use_1hour_button = QPushButton("1์‹œ๊ฐ„") - use_2hour_button = QPushButton("2์‹œ๊ฐ„") - use_custom_overtime_button = QPushButton("์ง์ ‘์ž…๋ ฅ") - overtime_detail_button = QPushButton("์ƒ์„ธ") + use_30min_button = QPushButton(tr('btn.use_30min')) + use_1hour_button = QPushButton(tr('btn.use_1hour')) + use_2hour_button = QPushButton(tr('btn.use_2hour')) + use_custom_overtime_button = QPushButton(tr('btn.custom_input')) + overtime_detail_button = QPushButton(tr('btn.detail')) for btn in [use_30min_button, use_1hour_button, use_2hour_button, use_custom_overtime_button, overtime_detail_button]: btn.setObjectName("btn_small") @@ -585,12 +591,12 @@ class MainWindow(QMainWindow): # ์—ฐ์ฐจ ์„น์…˜ leave_header = QHBoxLayout() - leave_title = QLabel("์—ฐ์ฐจ") + leave_title = QLabel(tr('section.leave')) leave_title.setObjectName("section_title") leave_header.addWidget(leave_title) leave_header.addStretch() - self.leave_balance_label = QLabel("์ž”์—ฌ: 0์ผ") + self.leave_balance_label = QLabel(tr('label.leave_balance_zero')) self.leave_balance_label.setObjectName("badge_leave") leave_header.addWidget(self.leave_balance_label) layout.addLayout(leave_header) @@ -598,11 +604,11 @@ class MainWindow(QMainWindow): # ์—ฐ์ฐจ ์‚ฌ์šฉ ๋ฒ„ํŠผ (1์ค„) leave_button_layout = QHBoxLayout() leave_button_layout.setSpacing(4) - use_30min_leave_button = QPushButton("30๋ถ„") - use_1hour_leave_button = QPushButton("1์‹œ๊ฐ„") - use_half_leave_button = QPushButton("๋ฐ˜์ฐจ") - use_full_leave_button = QPushButton("์—ฐ์ฐจ") - leave_detail_button = QPushButton("์ƒ์„ธ") + use_30min_leave_button = QPushButton(tr('btn.use_30min')) + use_1hour_leave_button = QPushButton(tr('btn.use_1hour')) + use_half_leave_button = QPushButton(tr('btn.half_leave')) + use_full_leave_button = QPushButton(tr('btn.full_leave')) + leave_detail_button = QPushButton(tr('btn.detail')) for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button, use_full_leave_button, leave_detail_button]: btn.setObjectName("btn_small") @@ -623,12 +629,12 @@ class MainWindow(QMainWindow): # ์ดํ•ฉ ์‹œ๊ฐ„ ํ‘œ์‹œ total_header = QHBoxLayout() - total_title = QLabel("์ด ๋ณด์œ  ์‹œ๊ฐ„") + total_title = QLabel(tr('section.total_time')) total_title.setObjectName("section_title") total_header.addWidget(total_title) total_header.addStretch() - self.total_time_label = QLabel("0์‹œ๊ฐ„ 0๋ถ„") + self.total_time_label = QLabel(tr('label.time_hours_minutes', hours=0, minutes=0)) self.total_time_label.setObjectName("badge_total") total_header.addWidget(self.total_time_label) layout.addLayout(total_header) @@ -661,14 +667,14 @@ class MainWindow(QMainWindow): if today_record.get('clock_out'): self.is_clocked_in = False self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("ํ‡ด๊ทผ ์ทจ์†Œ") + self.clock_out_button.setText(tr('btn.clock_out_cancel')) # ํ‡ด๊ทผ ์™„๋ฃŒ ์ƒํƒœ์—์„œ๋„ ์ถœํ‡ด๊ทผ ์‹œ๊ฐ„์€ ํ‘œ์‹œ self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True)) else: # ์ถœ๊ทผ ์ค‘์ด๋ฉด ํ‡ด๊ทผํ•˜๊ธฐ ๋ฒ„ํŠผ self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("ํ‡ด๊ทผํ•˜๊ธฐ") + self.clock_out_button.setText(tr('btn.clock_out')) else: # ์ถœ๊ทผ ๊ธฐ๋ก ์—†์Œ โ€” ์ข…์ผ ์—ฐ์ฐจ์ผ์ด๋ฉด ์ž๋™ ๊ฐ์ง€ยท์ˆ˜๋™ ์ž…๋ ฅ ๋ชจ๋‘ ์Šคํ‚ต @@ -702,19 +708,15 @@ class MainWindow(QMainWindow): QMessageBox.information( self, - "์ž๋™ ์ถœ๊ทผ ๊ฐ์ง€", - f"์ถœ๊ทผ ์‹œ๊ฐ„์ด ์ž๋™์œผ๋กœ ๊ฐ์ง€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n" - f"์ถœ๊ทผ: {clock_in_str}\n\n" - f"์ž˜๋ชป๋œ ๊ฒฝ์šฐ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." + tr('msg.auto_clock_in.title'), + tr('msg.auto_clock_in.body', time=clock_in_str) ) else: # ์ž๋™ ๊ฐ์ง€ ์‹คํŒจ - ์ˆ˜๋™ ์ž…๋ ฅ ์š”์ฒญ reply = QMessageBox.question( self, - "์ถœ๊ทผ ์‹œ๊ฐ„ ์ž…๋ ฅ", - "์ถœ๊ทผ ์‹œ๊ฐ„์„ ์ž๋™์œผ๋กœ ๊ฐ์ง€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.\n\n" - "์ˆ˜๋™์œผ๋กœ ์ž…๋ ฅํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n" - "(๊ด€๋ฆฌ์ž ๊ถŒํ•œ์œผ๋กœ ์‹คํ–‰ํ•˜๋ฉด ์ž๋™ ๊ฐ์ง€๋ฉ๋‹ˆ๋‹ค)", + tr('msg.manual_clock_in.title'), + tr('msg.manual_clock_in.body'), QMessageBox.Yes | QMessageBox.No ) @@ -746,8 +748,10 @@ class MainWindow(QMainWindow): # ํ˜„์žฌ ์‹œ๊ฐ„์€ ํ•ญ์ƒ ์—…๋ฐ์ดํŠธ (์ถœ๊ทผ ์ „์—๋„ ํ‘œ์‹œ) self._set_text_if_changed(self.current_time_value, self.format_time(now, include_seconds=True)) - # ๊ทผ๋ฌด์ผ ๊ฒฝ๊ณ„ ์‹œ๊ฐ„ ํ™•์ธ - workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) + # ๊ทผ๋ฌด์ผ ๊ฒฝ๊ณ„ ์‹œ๊ฐ„ ํ™•์ธ (์บ์‹œ, ์„ค์ • ๋ณ€๊ฒฝ ์‹œ reload_settings์—์„œ ๋ฌดํšจํ™”) + if self._workday_boundary_hour_cache is None: + self._workday_boundary_hour_cache = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) + workday_boundary_hour = self._workday_boundary_hour_cache # ์ƒˆ ๊ทผ๋ฌด์ผ ์ฒดํฌ: ํ‡ด๊ทผ ์™„๋ฃŒ ์ƒํƒœ์—์„œ ๋‚ ์งœ๊ฐ€ ๋ฐ”๋€Œ๊ณ  ๊ฒฝ๊ณ„ ์‹œ๊ฐ„ ์ดํ›„๋ฉด ์ƒˆ ์ถœ๊ทผ ์œ ๋„ if not self.is_clocked_in and self.clock_in_time: @@ -760,7 +764,10 @@ class MainWindow(QMainWindow): # (์ˆ˜๋™ ์ถœ๊ทผ override๋Š” handle_clock_in ๊ฒฝ๋กœ์—์„œ ๋ณ„๋„ ์ฒ˜๋ฆฌ) if not self.is_clocked_in: today_str = now.date().isoformat() - if self.db.has_full_day_leave(today_str): + if self._full_day_leave_cache_date != now.date(): + self._full_day_leave_cache_value = self.db.has_full_day_leave(today_str) + self._full_day_leave_cache_date = now.date() + if self._full_day_leave_cache_value: self._render_full_day_leave_state(today_str) return @@ -792,8 +799,14 @@ class MainWindow(QMainWindow): total_time_off = overtime_used_today + leave_used_today # ํœด์ผ/์ฃผ๋ง ๋˜๋Š” ์ข…์ผ์—ฐ์ฐจ override โ†’ ์ถœ๊ทผ ์งํ›„๋ถ€ํ„ฐ ๋ชจ๋“  ์‹œ๊ฐ„์ด ์—ฐ์žฅ๊ทผ๋ฌด๋กœ ํ๋ฆ„. - is_non_working = self.time_calc.is_non_working_day(now, self.db) - is_full_day_leave = self.db.has_full_day_leave(now.date().isoformat()) + if self._non_working_cache_date != now.date(): + self._non_working_cache_value = self.time_calc.is_non_working_day(now, self.db) + self._non_working_cache_date = now.date() + is_non_working = self._non_working_cache_value + if self._full_day_leave_cache_date != now.date(): + self._full_day_leave_cache_value = self.db.has_full_day_leave(now.date().isoformat()) + self._full_day_leave_cache_date = now.date() + is_full_day_leave = self._full_day_leave_cache_value is_holiday = is_non_working or is_full_day_leave if is_holiday: @@ -826,13 +839,13 @@ class MainWindow(QMainWindow): # ์ถ”๊ฐ€ ๊ทผ๋ฌด ์ค‘ (ํœด์ผ/์—ฐ์ฐจ override๋ฉด ์ถœ๊ทผ ์งํ›„๋ถ€ํ„ฐ ํ•ญ์ƒ ์ด ๋ถ„๊ธฐ) day_type = self.time_calc.get_day_type(now, self.db) if is_full_day_leave and not is_non_working: - self.remaining_time_group.setTitle("์—ฐ์ฐจ override (์ „์ฒด ์ ๋ฆฝ)") + self.remaining_time_group.setTitle(tr('label.full_day_leave_override')) elif day_type == 'weekend': - self.remaining_time_group.setTitle("์ฃผ๋ง ๊ทผ๋ฌด (์ „์ฒด ์ ๋ฆฝ)") + self.remaining_time_group.setTitle(tr('label.weekend_work')) elif day_type == 'holiday': - self.remaining_time_group.setTitle("๊ณตํœด์ผ ๊ทผ๋ฌด (์ „์ฒด ์ ๋ฆฝ)") + self.remaining_time_group.setTitle(tr('label.holiday_work')) else: - self.remaining_time_group.setTitle("์ถ”๊ฐ€ ๊ทผ๋ฌด ์ค‘") + self.remaining_time_group.setTitle(tr('label.overtime_progress')) # + ๊ธฐํ˜ธ๋กœ ํ‘œ์‹œ total_seconds = int(abs(remaining.total_seconds())) hours = total_seconds // 3600 @@ -844,7 +857,7 @@ class MainWindow(QMainWindow): else: # ์ •์ƒ ๊ทผ๋ฌด ์ค‘ โ€” ์•„์ง ํ‡ด๊ทผ ์ „์ด๋ฏ€๋กœ ๊ธฐ๋ณธ ํ…์ŠคํŠธ ์ƒ‰ - self.remaining_time_group.setTitle("๋‚จ์€ ์‹œ๊ฐ„") + self.remaining_time_group.setTitle(tr('group.remaining_time')) remaining_str = self.time_calc.format_time_delta(remaining) self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('text_primary')};") @@ -872,7 +885,7 @@ class MainWindow(QMainWindow): expected_clock_out = self.clock_in_time self._set_text_if_changed( self.expected_time_label, - "ํœด์ผ ๊ทผ๋ฌด (์ •ํ•ด์ง„ ํ‡ด๊ทผ์‹œ๊ฐ ์—†์Œ)" + tr('label.holiday_work_no_clock_out') ) else: expected_clock_out = self.time_calc.calculate_clock_out_time( @@ -885,7 +898,7 @@ class MainWindow(QMainWindow): expected_clock_out -= timedelta(minutes=total_time_off) self._set_text_if_changed( self.expected_time_label, - f"์˜ˆ์ƒ ํ‡ด๊ทผ: {self.format_time(expected_clock_out)}" + f"{tr('label.expected_clock_out_prefix')}{self.format_time(expected_clock_out)}" ) # ์•Œ๋ฆผ์€ NotificationOrchestrator๋กœ ์œ„์ž„ (5๋ถ„ throttle ํฌํ•จ) @@ -905,10 +918,11 @@ class MainWindow(QMainWindow): def update_date_label(self): """๋‚ ์งœ ๋ผ๋ฒจ ์—…๋ฐ์ดํŠธ""" now = datetime.now() - weekday_kr = ['์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ', '์ผ'] - weekday = weekday_kr[now.weekday()] - date_str = f"{now.year}๋…„ {now.month}์›” {now.day}์ผ {weekday}์š”์ผ" - self.date_label.setText(date_str) + weekday_keys = ['label.weekday_mon', 'label.weekday_tue', 'label.weekday_wed', + 'label.weekday_thu', 'label.weekday_fri', 'label.weekday_sat', + 'label.weekday_sun'] + weekday = tr(weekday_keys[now.weekday()]) + self.date_label.setText(tr('date_format.full', year=now.year, month=now.month, day=now.day, weekday=weekday)) def _render_full_day_leave_state(self, today_str: str) -> None: """์˜ค๋Š˜์ด ์ข…์ผ ์—ฐ์ฐจ์ด๊ณ  ์ถœ๊ทผ ์•ˆ ํ•œ ์ƒํƒœ โ†’ ์นด์šดํŠธ๋‹ค์šด ๋Œ€์‹  ํœด๊ฐ€ ์นด๋“œ ํ‘œ์‹œ.""" @@ -916,28 +930,28 @@ class MainWindow(QMainWindow): # ๊ฐ€์žฅ ํฐ ์ผ์ˆ˜์˜ leave_type์„ ๋Œ€ํ‘œ๋กœ ํ‘œ์‹œ (๋ณดํ†ต 1.0์งœ๋ฆฌ 1๊ฑด) if records: primary = max(records, key=lambda r: r.get('days') or 0) - label = primary.get('leave_type') or '์—ฐ์ฐจ' + label = primary.get('leave_type') or tr('leave.type.annual') memo = primary.get('memo') or '' else: - label = '์—ฐ์ฐจ' + label = tr('leave.type.annual') memo = '' - self.remaining_time_group.setTitle("์˜ค๋Š˜์€ ํœด๊ฐ€") - self.remaining_time_label.setText("์—ฐ์ฐจ ์‚ฌ์šฉ ์ค‘") + self.remaining_time_group.setTitle(tr('label.full_day_leave_today')) + self.remaining_time_label.setText(tr('label.full_day_leave_in_use')) self.remaining_time_label.setStyleSheet( f"color: {ThemeColors.get('status_normal')}; font-size: 18px;" ) self.progress_bar.setValue(100) if memo: self._set_text_if_changed(self.expected_time_label, - f"{label} โ€” {memo}") + tr('label.full_day_leave_format', type=label, memo=memo)) else: self._set_text_if_changed(self.expected_time_label, - f"{label}") + label) # ํŠธ๋ ˆ์ด/๋ฏธ๋‹ˆ ์œ„์ ฏ - self.tray_icon.update_time_display("๐ŸŒด ํœด๊ฐ€") + self.tray_icon.update_time_display(tr('label.vacation')) if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible(): - self._mini_widget.update_remaining("๐ŸŒด ํœด๊ฐ€") + self._mini_widget.update_remaining(tr('label.vacation')) def toggle_lunch_break(self): """์ ์‹ฌ์‹œ๊ฐ„ ํ† ๊ธ€ โ€” MealController ์œ„์ž„.""" @@ -962,7 +976,7 @@ class MainWindow(QMainWindow): hours = balance_minutes // 60 mins = balance_minutes % 60 - self.overtime_balance_label.setText(f"{hours}์‹œ๊ฐ„ {mins}๋ถ„") + self.overtime_balance_label.setText(tr('label.time_hours_minutes', hours=hours, minutes=mins)) self.update_total_time() def update_leave_balance(self): @@ -970,7 +984,7 @@ class MainWindow(QMainWindow): balance = self.db.get_leave_balance() balance_hours = int(balance * 8) balance_mins = int((balance * 8 % 1) * 60) - self.leave_balance_label.setText(f"{balance_hours}์‹œ๊ฐ„ {balance_mins}๋ถ„") + self.leave_balance_label.setText(tr('label.time_hours_minutes', hours=balance_hours, minutes=balance_mins)) self.update_total_time() def update_total_time(self): @@ -987,7 +1001,7 @@ class MainWindow(QMainWindow): total_hours = total_minutes // 60 total_mins = total_minutes % 60 - self.total_time_label.setText(f"{total_hours}์‹œ๊ฐ„ {total_mins}๋ถ„") + self.total_time_label.setText(tr('label.time_hours_minutes', hours=total_hours, minutes=total_mins)) def use_overtime(self, minutes: int): """์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ (์Œ์ˆ˜ ์ž”์•ก ํ—ˆ์šฉ - ์„ ์‚ฌ์šฉ ํ›„์ ๋ฆฝ ๊ฐ€๋Šฅ)""" @@ -998,21 +1012,15 @@ class MainWindow(QMainWindow): if new_balance < 0: reply = QMessageBox.warning( self, - "์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ (๋งˆ์ด๋„ˆ์Šค ์ „ํ™˜)", - f"{minutes}๋ถ„์˜ ์—ฐ์žฅ๊ทผ๋ฌด๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"ํ˜„์žฌ ์ž”์•ก: {balance}๋ถ„\n" - f"์‚ฌ์šฉ ํ›„ ์ž”์•ก: {new_balance}๋ถ„ (๋งˆ์ด๋„ˆ์Šค)\n\n" - f"โš ๏ธ ์ž”์•ก์ด ๋งˆ์ด๋„ˆ์Šค๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.\n" - f"๋‚˜์ค‘์— ์ดˆ๊ณผ๊ทผ๋ฌด๋กœ ๊ฐš์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + tr('msg.overtime_use_minus.title'), + tr('msg.overtime_use_minus.body', minutes=minutes, balance=balance, new_balance=new_balance), QMessageBox.Yes | QMessageBox.No ) else: reply = QMessageBox.question( self, - "์—ฐ์žฅ๊ทผ๋ฌด ์‚ฌ์šฉ", - f"{minutes}๋ถ„์˜ ์—ฐ์žฅ๊ทผ๋ฌด๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"ํ˜„์žฌ ์ž”์•ก: {balance}๋ถ„\n" - f"์‚ฌ์šฉ ํ›„ ์ž”์•ก: {new_balance}๋ถ„", + tr('msg.overtime_use.title'), + tr('msg.overtime_use.body', minutes=minutes, balance=balance, new_balance=new_balance), QMessageBox.Yes | QMessageBox.No ) @@ -1032,14 +1040,14 @@ class MainWindow(QMainWindow): QMessageBox.information( self, - "์‚ฌ์šฉ ์™„๋ฃŒ", - f"{minutes}๋ถ„์ด ์‚ฌ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('msg.overtime_use_done.title'), + tr('msg.overtime_use_done.body', minutes=minutes) ) self.update_overtime_balance() except Exception as e: QMessageBox.warning( self, - "์‚ฌ์šฉ ์‹คํŒจ", + tr('msg.overtime_use_fail.title'), str(e) ) self.update_overtime_balance() @@ -1059,10 +1067,8 @@ class MainWindow(QMainWindow): if balance < days: QMessageBox.warning( self, - "์ž”์•ก ๋ถ€์กฑ", - f"์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—ฐ์ฐจ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.\n" - f"ํ˜„์žฌ ์ž”์•ก: {balance}์ผ\n" - f"์š”์ฒญ: {days}์ผ" + tr('msg.leave_short.title'), + tr('msg.leave_short.body', balance=balance, days=days) ) return @@ -1073,8 +1079,8 @@ class MainWindow(QMainWindow): today = date.today().isoformat() date_str, ok = QInputDialog.getText( self, - "์—ฐ์ฐจ ์‚ฌ์šฉ ๋‚ ์งœ", - "์‚ฌ์šฉ ๋‚ ์งœ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (YYYY-MM-DD):", + tr('msg.leave_use_date.title'), + tr('msg.leave_use_date.body'), QLineEdit.Normal, today ) @@ -1088,16 +1094,16 @@ class MainWindow(QMainWindow): except ValueError: QMessageBox.warning( self, - "์ž…๋ ฅ ์˜ค๋ฅ˜", - "๋‚ ์งœ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์˜ฌ๋ฐ”๋ฅธ ํ˜•์‹: YYYY-MM-DD (์˜ˆ: 2024-01-15)" + tr('msg.input_error.title'), + tr('msg.input_error.date_format') ) return # ๋ฉ”๋ชจ ์ž…๋ ฅ memo, ok = QInputDialog.getText( self, - "์—ฐ์ฐจ ์‚ฌ์œ ", - "์‚ฌ์œ ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (์„ ํƒ):", + tr('msg.leave_use_reason.title'), + tr('msg.leave_use_reason.body'), QLineEdit.Normal, "" ) @@ -1107,27 +1113,26 @@ class MainWindow(QMainWindow): # ์‚ฌ์šฉ ํ™•์ธ if days == 1.0: - leave_type = "์—ฐ์ฐจ" - days_str = "1์ผ" + leave_type = tr('leave.type.annual') + days_str = tr('leave.use.full_day') elif days == 0.5: - leave_type = "๋ฐ˜์ฐจ" - days_str = "0.5์ผ (4์‹œ๊ฐ„)" + leave_type = tr('view.leave.type_half') + days_str = tr('leave.use.half_day') elif days == 0.125: - leave_type = "์‹œ๊ฐ„์—ฐ์ฐจ" - days_str = "0.125์ผ (1์‹œ๊ฐ„)" + leave_type = tr('leave.type.hourly_leave') + days_str = tr('leave.use.hour_1') elif days == 0.0625: - leave_type = "์‹œ๊ฐ„์—ฐ์ฐจ" - days_str = "0.0625์ผ (30๋ถ„)" + leave_type = tr('leave.type.hourly_leave') + days_str = tr('leave.use.min_30') else: - leave_type = "์—ฐ์ฐจ" + leave_type = tr('leave.type.annual') hours = days * 8 - days_str = f"{days}์ผ ({hours}์‹œ๊ฐ„)" + days_str = tr('leave.use.custom', days=days, hours=hours) reply = QMessageBox.question( self, - "์—ฐ์ฐจ ์‚ฌ์šฉ", - f"{date_str}์— {leave_type} {days_str}๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"์‚ฌ์šฉ ํ›„ ์ž”์•ก: {balance - days}์ผ", + tr('msg.leave_use.title'), + tr('msg.leave_use_confirm.body', date=date_str, type=leave_type, days=days_str, balance_after=balance - days), QMessageBox.Yes | QMessageBox.No ) @@ -1136,15 +1141,15 @@ class MainWindow(QMainWindow): self.db.use_leave(days, date_str, leave_type, memo or None) QMessageBox.information( self, - "์‚ฌ์šฉ ์™„๋ฃŒ", - f"{leave_type}๊ฐ€ ์‚ฌ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('msg.leave_use_done.title'), + tr('msg.leave_use_done.body', type=leave_type) ) self.update_leave_balance() except ValueError as e: # ์ž”์•ก ๋ถ€์กฑ ๋“ฑ ๊ฒ€์ฆ ์˜ค๋ฅ˜ QMessageBox.warning( self, - "์‚ฌ์šฉ ๋ถˆ๊ฐ€", + tr('msg.leave_use_impossible.title'), str(e) ) self.update_leave_balance() # ์ตœ์‹  ์ž”์•ก์œผ๋กœ ์ƒˆ๋กœ๊ณ ์นจ @@ -1152,8 +1157,8 @@ class MainWindow(QMainWindow): # ๊ธฐํƒ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์˜ค๋ฅ˜ QMessageBox.critical( self, - "์˜ค๋ฅ˜", - f"์—ฐ์ฐจ ์‚ฌ์šฉ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{str(e)}" + tr('msg.error.title'), + tr('msg.error.body', action=tr('msg.leave_use.title'), error=str(e)) ) def use_custom_overtime(self): @@ -1165,8 +1170,8 @@ class MainWindow(QMainWindow): # ์‚ฌ์šฉํ•  ์‹œ๊ฐ„ ์ž…๋ ฅ (30๋ถ„ ๋‹จ์œ„) hours, ok = QInputDialog.getDouble( self, - "์‹œ๊ฐ„ ์ž…๋ ฅ", - "์‚ฌ์šฉํ•  ์‹œ๊ฐ„์„ ์ž…๋ ฅํ•˜์„ธ์š” (0.5์‹œ๊ฐ„ ๋‹จ์œ„):\n์˜ˆ) 0.5, 1, 1.5, 2, 3, 4", + tr('msg.input.title'), + tr('msg.overtime_input.body'), 0.5, 0.5, 24.0, @@ -1183,8 +1188,8 @@ class MainWindow(QMainWindow): if minutes % 30 != 0: QMessageBox.warning( self, - "์ž…๋ ฅ ์˜ค๋ฅ˜", - "30๋ถ„ ๋‹จ์œ„๋กœ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.\n์˜ˆ) 0.5์‹œ๊ฐ„, 1์‹œ๊ฐ„, 1.5์‹œ๊ฐ„" + tr('msg.input_error.title'), + tr('msg.input_error.overtime_unit') ) return @@ -1200,8 +1205,8 @@ class MainWindow(QMainWindow): # ์‚ฌ์šฉํ•  ์‹œ๊ฐ„ ์ž…๋ ฅ (์‹œ๊ฐ„ ๋‹จ์œ„) hours, ok = QInputDialog.getDouble( self, - "์‹œ๊ฐ„ ์ž…๋ ฅ", - "์‚ฌ์šฉํ•  ์‹œ๊ฐ„์„ ์ž…๋ ฅํ•˜์„ธ์š” (0.5์‹œ๊ฐ„ ๋‹จ์œ„):\n์˜ˆ) 0.5, 1, 1.5, 2, 4, 8", + tr('msg.input.title'), + tr('msg.leave_input.body'), 0.5, 0.5, 80.0, @@ -1217,10 +1222,8 @@ class MainWindow(QMainWindow): if days > balance: QMessageBox.warning( self, - "์ž”์•ก ๋ถ€์กฑ", - f"์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์—ฐ์ฐจ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.\n" - f"ํ˜„์žฌ ์ž”์•ก: {balance}์ผ ({balance * 8}์‹œ๊ฐ„)\n" - f"์š”์ฒญ: {days}์ผ ({hours}์‹œ๊ฐ„)" + tr('msg.leave_short.title'), + tr('msg.leave_short_hours.body', balance=balance, balance_hours=int(balance * 8), days=days, hours=hours) ) return @@ -1254,9 +1257,8 @@ class MainWindow(QMainWindow): # ํ™•์ธ ๋ฉ”์‹œ์ง€ reply = QMessageBox.question( self, - "ํ‡ด๊ทผ ํ™•์ธ", - f"ํ‡ด๊ทผ ์ฒ˜๋ฆฌํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - f"ํ‡ด๊ทผ ์‹œ๊ฐ„: {now.strftime('%H:%M:%S')}", + tr('msg.clock_out_confirm.title'), + tr('msg.clock_out_confirm.body', time=now.strftime('%H:%M:%S')), QMessageBox.Yes | QMessageBox.No ) @@ -1308,10 +1310,8 @@ class MainWindow(QMainWindow): actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True) ask = QMessageBox.question( self, - "์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ํ™•์ธ", - f"์—ฐ์žฅ๊ทผ๋ฌด {actual_str} ๋ฐœ์ƒ, {time_str} ์ ๋ฆฝ ๋Œ€์ƒ์ž…๋‹ˆ๋‹ค.\n\n" - f"์ ๋ฆฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n" - f"(์•„๋‹ˆ์˜ค ์„ ํƒ ์‹œ ์ด๋ฒˆ ํ‡ด๊ทผ๋ถ„์€ ์ ๋ฆฝ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค)", + tr('msg.auto_overtime_confirm.title'), + tr('msg.auto_overtime_confirm.body', actual=actual_str, earned=time_str), QMessageBox.Yes | QMessageBox.No, ) if ask != QMessageBox.Yes: @@ -1338,26 +1338,31 @@ class MainWindow(QMainWindow): self.is_clocked_in = False self.midnight_rollover_handled = False # ๋‹ค์Œ๋‚ ์„ ์œ„ํ•ด ํ”Œ๋ž˜๊ทธ ๋ฆฌ์…‹ self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("ํ‡ด๊ทผ ์ทจ์†Œ") + self.clock_out_button.setText(tr('btn.clock_out_cancel')) # ๊ฒฐ๊ณผ ๋ฉ”์‹œ์ง€ - msg = f"ํ‡ด๊ทผ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!\n\n" if day_type == 'weekend': - msg += f"[์ฃผ๋ง ๊ทผ๋ฌด]\n" + type_info = tr('label.weekend_work_tag') + '\n' elif day_type == 'holiday': holiday_info = self.db.get_holiday(today) - holiday_name = holiday_info['name'] if holiday_info else "๊ณตํœด์ผ" - msg += f"[๊ณตํœด์ผ ๊ทผ๋ฌด - {holiday_name}]\n" - msg += f"์ด ๊ทผ๋ฌด์‹œ๊ฐ„: {total_hours:.1f}์‹œ๊ฐ„\n" + holiday_name = holiday_info['name'] if holiday_info else tr('label.holiday_default') + type_info = tr('label.holiday_work_tag', name=holiday_name) + '\n' + else: + type_info = '' + overtime_info = '' if overtime_earned > 0: tokens, time_str = self.time_calc.format_overtime_tokens(overtime_earned) if is_non_working_day: - msg += f"์ „์ฒด ์ ๋ฆฝ: {time_str} (๐Ÿ•ร—{tokens})" + overtime_info = tr('label.full_earned_msg', time=time_str, tokens=tokens) else: - msg += f"์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ: {time_str} (๐Ÿ•ร—{tokens})" + overtime_info = tr('label.overtime_earned_msg', time=time_str, tokens=tokens) - QMessageBox.information(self, "ํ‡ด๊ทผ ์™„๋ฃŒ", msg) + msg = tr('msg.clock_out_done.body', + type_info=type_info, + total_work=tr('label.total_work_hours', hours=total_hours), + overtime_info=overtime_info) + QMessageBox.information(self, tr('msg.clock_out_done.title'), msg) # ์ž”์•ก ์—…๋ฐ์ดํŠธ self.update_overtime_balance() @@ -1373,9 +1378,8 @@ class MainWindow(QMainWindow): # ํ™•์ธ ๋Œ€ํ™”์ƒ์ž reply = QMessageBox.question( self, - "ํ‡ด๊ทผ ์ทจ์†Œ", - "ํ‡ด๊ทผ์„ ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - "ํ‡ด๊ทผ ์‹œ๊ฐ„๊ณผ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ๋‚ด์—ญ์ด ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.", + tr('btn.clock_out_cancel'), + tr('msg.cancel_clock_out_confirm.body'), QMessageBox.Yes | QMessageBox.No ) @@ -1391,28 +1395,28 @@ class MainWindow(QMainWindow): # ์ƒํƒœ ๋ณต์› self.is_clocked_in = True self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("ํ‡ด๊ทผํ•˜๊ธฐ") + self.clock_out_button.setText(tr('btn.clock_out')) # ์ž”์•ก ์—…๋ฐ์ดํŠธ self.update_overtime_balance() QMessageBox.information( self, - "ํ‡ด๊ทผ ์ทจ์†Œ ์™„๋ฃŒ", - "ํ‡ด๊ทผ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n๋‹ค์‹œ ๊ทผ๋ฌด ์ค‘ ์ƒํƒœ๋กœ ์ „ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('msg.cancel_clock_out_done.title'), + tr('msg.cancel_clock_out_done.body') ) else: QMessageBox.warning( self, - "์ทจ์†Œ ์‹คํŒจ", - "ํ‡ด๊ทผ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." + tr('msg.cancel_clock_out_fail.title'), + tr('msg.cancel_clock_out_fail.body') ) except Exception as e: QMessageBox.critical( self, - "์˜ค๋ฅ˜", - f"ํ‡ด๊ทผ ์ทจ์†Œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{str(e)}" + tr('msg.error.title'), + tr('msg.error.body', action=tr('btn.clock_out_cancel'), error=str(e)) ) def _apply_auto_overtime_gate(self, overtime_earned: int) -> int: @@ -1540,11 +1544,8 @@ class MainWindow(QMainWindow): QMessageBox.information( self, - "๊ทผ๋ฌด์ผ ๊ฒฝ๊ณ„ ๊ฒฝ๊ณผ", - f"๊ทผ๋ฌด์ผ ๊ฒฝ๊ณ„ ์‹œ๊ฐ„({workday_boundary_hour}์‹œ)์ด ์ง€๋‚˜ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n" - f"์ „๋‚  ๊ทผ๋ฌด: {before_boundary_str} ํ‡ด๊ทผ ์ฒ˜๋ฆฌ\n" - f"๊ธˆ์ผ ๊ทผ๋ฌด: {boundary_time_str} ์ถœ๊ทผ ์ฒ˜๋ฆฌ\n\n" - f"์ž์ •~{workday_boundary_hour}์‹œ ์ „๊นŒ์ง€์˜ ์•ผ๊ทผ์€ ์ „๋‚  ์ดˆ๊ณผ๊ทผ๋ฌด๋กœ ์ธ์ •๋ฉ๋‹ˆ๋‹ค." + tr('msg.workday_boundary.title'), + tr('msg.workday_boundary.body', hour=workday_boundary_hour, before=before_boundary_str, boundary=boundary_time_str) ) # ํ™”๋ฉด ์—…๋ฐ์ดํŠธ @@ -1567,9 +1568,8 @@ class MainWindow(QMainWindow): # ์ƒˆ ๊ทผ๋ฌด์ผ ์•Œ๋ฆผ ๋ฐ ์ถœ๊ทผ ์ฒ˜๋ฆฌ reply = QMessageBox.question( self, - "์ƒˆ ๊ทผ๋ฌด์ผ", - f"์ƒˆ๋กœ์šด ๊ทผ๋ฌด์ผ์ž…๋‹ˆ๋‹ค. ({today_str})\n\n" - f"์ถœ๊ทผ ์ฒ˜๋ฆฌ๋ฅผ ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + tr('msg.new_workday.title'), + tr('msg.new_workday.body', date=today_str), QMessageBox.Yes | QMessageBox.No ) @@ -1589,7 +1589,22 @@ class MainWindow(QMainWindow): self.clock_in_time = None self.is_clocked_in = False self.clock_out_button.setEnabled(False) - self.clock_out_button.setText("ํ‡ด๊ทผํ•˜๊ธฐ") + self.clock_out_button.setText(tr('btn.clock_out')) + + def _get_break_minutes_for_date(self, date_str: str) -> int: + """ํŠน์ • ๋‚ ์งœ์˜ ์ด ์™ธ์ถœ ์‹œ๊ฐ„(๋ถ„)์„ ์•ˆ์ „ํ•˜๊ฒŒ ์กฐํšŒ.""" + try: + with self.db._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT COALESCE(SUM(total_minutes), 0) + FROM break_records + WHERE date = ? + ''', (date_str,)) + return int(cursor.fetchone()[0] or 0) + except Exception as e: + dlog(f"_get_break_minutes_for_date failed for {date_str}: {e}") + return 0 def auto_clock_out_previous_days(self): """์ด์ „ ํ‡ด๊ทผ ๊ธฐ๋ก๋“ค(ํ‡ด๊ทผ ์•ˆ ํ•œ)์— ๋Œ€ํ•ด ์ž๋™์œผ๋กœ ์ข…๋ฃŒ ์‹œ๊ฐ„ ๋“ฑ๋ก""" @@ -1600,10 +1615,17 @@ class MainWindow(QMainWindow): for days_ago in range(1, 31): # 1์ผ ์ „๋ถ€ํ„ฐ 30์ผ ์ „๊นŒ์ง€ ํ™•์ธ check_date = (today - timedelta(days=days_ago)).isoformat() - record = self.db.get_work_record(check_date) + try: + record = self.db.get_work_record(check_date) + except Exception as e: + dlog(f"auto_clock_out: get_work_record failed for {check_date}: {e}") + continue # ์ถœ๊ทผ์€ ํ–ˆ์ง€๋งŒ ํ‡ด๊ทผ์„ ์•ˆ ํ•œ ๊ธฐ๋ก ๋ฐœ๊ฒฌ - if record and record.get('clock_in') and not record.get('clock_out'): + if not (record and record.get('clock_in') and not record.get('clock_out')): + continue + + try: # ํ•ด๋‹น ๋‚ ์งœ์˜ ์ข…๋ฃŒ ์‹œ๊ฐ„ ๊ฐ์ง€ check_date_obj = today - timedelta(days=days_ago) shutdown_time = self.event_monitor.get_shutdown_time_by_date(check_date_obj) @@ -1621,15 +1643,7 @@ class MainWindow(QMainWindow): day_type = self.time_calc.get_day_type(clock_in_time, self.db) # ์™ธ์ถœ ์‹œ๊ฐ„ ๊ฐ€์ ธ์˜ค๊ธฐ - conn = self.db.get_connection() - cursor = conn.cursor() - cursor.execute(''' - SELECT SUM(total_minutes) - FROM break_records - WHERE date = ? - ''', (check_date,)) - break_minutes = cursor.fetchone()[0] or 0 - conn.close() + break_minutes = self._get_break_minutes_for_date(check_date) # ์ด ๊ทผ๋ฌด์‹œ๊ฐ„ ๊ณ„์‚ฐ (์›๋ณธ ์‹œ๊ฐ„) work_duration = shutdown_time - clock_in_time @@ -1696,15 +1710,7 @@ class MainWindow(QMainWindow): day_type = self.time_calc.get_day_type(clock_in_time, self.db) # ์™ธ์ถœ ์‹œ๊ฐ„ ๊ฐ€์ ธ์˜ค๊ธฐ - conn = self.db.get_connection() - cursor = conn.cursor() - cursor.execute(''' - SELECT SUM(total_minutes) - FROM break_records - WHERE date = ? - ''', (check_date,)) - break_minutes = cursor.fetchone()[0] or 0 - conn.close() + break_minutes = self._get_break_minutes_for_date(check_date) # ์ด ๊ทผ๋ฌด์‹œ๊ฐ„ ๊ณ„์‚ฐ work_duration = fallback_time - clock_in_time @@ -1750,6 +1756,9 @@ class MainWindow(QMainWindow): day_tag = " (์ฃผ๋ง)" if day_type == 'weekend' else (" (๊ณตํœด์ผ)" if day_type == 'holiday' else "") print(f"{check_date}{day_tag} ํ‡ด๊ทผ ์ž๋™ ๋“ฑ๋ก (fallback): 23:59:59 (์ด {total_hours:.1f}์‹œ๊ฐ„, ์ ๋ฆฝ {overtime_earned}๋ถ„)") + except Exception as e: + dlog(f"auto_clock_out: processing failed for {check_date}: {e}") + continue def manual_clock_in(self): """์ˆ˜๋™ ์ถœ๊ทผ ์‹œ๊ฐ„ ์ž…๋ ฅ""" @@ -1758,10 +1767,8 @@ class MainWindow(QMainWindow): if self.db.has_full_day_leave(today_str): reply = QMessageBox.question( self, - "์ข…์ผ ์—ฐ์ฐจ ๋“ฑ๋ก๋จ", - "์˜ค๋Š˜์€ ์ข…์ผ ์—ฐ์ฐจ๋กœ ๋“ฑ๋ก๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.\n" - "๊ทธ๋ž˜๋„ ์ถœ๊ทผํ•˜์‹œ๊ฒ ์–ด์š”?\n\n" - "(์ถœ๊ทผ ์‹œ ๋ชจ๋“  ์‹œ๊ฐ„์ด ์—ฐ์žฅ๊ทผ๋ฌด๋กœ ์ ๋ฆฝ๋ฉ๋‹ˆ๋‹ค.)", + tr('msg.full_day_leave.title'), + tr('msg.full_day_leave.body'), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: @@ -1807,12 +1814,12 @@ class MainWindow(QMainWindow): # UI ์—…๋ฐ์ดํŠธ self.clock_in_value.setText(clock_in_str) self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("ํ‡ด๊ทผํ•˜๊ธฐ") + self.clock_out_button.setText(tr('btn.clock_out')) QMessageBox.information( self, - "์ถœ๊ทผ ์‹œ๊ฐ„ ์„ค์ •", - f"์ถœ๊ทผ ์‹œ๊ฐ„์ด ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n์ถœ๊ทผ: {clock_in_str}" + tr('msg.clock_in_set.title'), + tr('msg.clock_in_set.body', time=clock_in_str) ) # Discord ์›นํ›… (์˜ต์…˜) @@ -1833,8 +1840,8 @@ class MainWindow(QMainWindow): try: cur = self.db.get_setting_int('calendar_view_count', 0) self.db.set_setting('calendar_view_count', str(cur + 1)) - except Exception: - pass + except Exception as e: + dlog(f"calendar view counter failed: {e}") dialog = CalendarView(self, self.db) dialog.exec_() @@ -1888,8 +1895,8 @@ class MainWindow(QMainWindow): try: from core.achievements import evaluate_all evaluate_all(self.db) - except Exception: - pass + except Exception as e: + dlog(f"achievements evaluate failed: {e}") dialog = AchievementsView(self.db, self) dialog.exec_() @@ -1898,14 +1905,14 @@ class MainWindow(QMainWindow): from PyQt5.QtWidgets import QMenu from ui.meal_time_dialog import MealTimeDialog menu = QMenu(self) - title = "์ ์‹ฌ" if meal_type == 'lunch' else "์ €๋…" - edit_action = menu.addAction(f"{title} ์‹ค์ œ ์‹œ๊ฐ„ ์ž…๋ ฅ...") + title = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short') + edit_action = menu.addAction(tr('msg.meal_actual_input', meal=title)) global_pos = button.mapToGlobal(pos) action = menu.exec_(global_pos) if action != edit_action: return if not self.is_clocked_in: - QMessageBox.warning(self, "์ถœ๊ทผ ํ•„์š”", "์ถœ๊ทผ ํ›„์—๋งŒ ์‹์‚ฌ ์‹œ๊ฐ„์„ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('msg.meal_need_clock_in.title'), tr('msg.meal_need_clock_in.body')) return default_min = (self.time_calc.lunch_duration_minutes if meal_type == 'lunch' @@ -1932,8 +1939,8 @@ class MainWindow(QMainWindow): self.dinner_button.setChecked(True) self.update_dinner_status() self.db.update_dinner_break(today, True) - QMessageBox.information(self, "๊ธฐ๋ก ์™„๋ฃŒ", - f"{title} {minutes}๋ถ„ ๊ธฐ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n({start} ~ {end})") + QMessageBox.information(self, tr('msg.meal_recorded.title'), + tr('msg.meal_recorded.body', meal=title, minutes=minutes, start=start, end=end)) def show_onboarding(self): """์˜จ๋ณด๋”ฉ ์œ„์ €๋“œ ๋‹ค์‹œ ๋ณด๊ธฐ.""" @@ -1941,7 +1948,7 @@ class MainWindow(QMainWindow): wizard = OnboardingWizard(self.db, self) if wizard.exec_(): self.reload_settings() - QMessageBox.information(self, "์„ค์ • ์—…๋ฐ์ดํŠธ", "๋ณ€๊ฒฝ๋œ ์„ค์ •์ด ์ฆ‰์‹œ ๋ฐ˜์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + QMessageBox.information(self, tr('msg.settings_updated.title'), tr('msg.settings_updated.body')) # ===== Discord ์›นํ›… push (์˜ต์…˜, ์‹คํŒจ silent) ===== def _show_today_summary(self, total_hours, overtime_actual, overtime_earned, break_minutes): @@ -1963,7 +1970,7 @@ class MainWindow(QMainWindow): from core.salary import estimate_pay, format_won fake_record = {'total_hours': total_hours, 'overtime_minutes': overtime_actual} result = estimate_pay([fake_record], wage, rate) - salary_text = f"์˜ค๋Š˜ ์ถ”์ •: {format_won(result['total'])}" + salary_text = tr('label.today_estimate', amount=format_won(result['total'])) except (ValueError, TypeError): pass @@ -2025,18 +2032,15 @@ class MainWindow(QMainWindow): return # ์‚ฌ์šฉ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ํŠธ๋ฆฌ๊ฑฐํ•œ ๊ฒฝ์šฐ๋งŒ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ messages = { - UP_TO_DATE: ("์—…๋ฐ์ดํŠธ ํ™•์ธ", f"ํ˜„์žฌ ์ตœ์‹  ๋ฒ„์ „์ž…๋‹ˆ๋‹ค (v{__version__})."), - NETWORK_ERROR: ("์—ฐ๊ฒฐ ์‹คํŒจ", - "์—…๋ฐ์ดํŠธ ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n" - "๋„คํŠธ์›Œํฌ ์ƒํƒœ๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š”."), - NO_RELEASE: ("๋ฆด๋ฆฌ์Šค ์—†์Œ", - "์—…๋ฐ์ดํŠธ ์ €์žฅ์†Œ์—์„œ ๋ฆด๋ฆฌ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n" - "(์ €์žฅ์†Œ ๋น„๊ณต๊ฐœ ๋˜๋Š” ์ฒซ ๋ฆด๋ฆฌ์Šค ์ „)"), - NO_ASSET: ("์ž์‚ฐ ๋ˆ„๋ฝ", - "์ƒˆ ๋ฒ„์ „์€ ์žˆ์ง€๋งŒ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•œ main.exe ์ž์‚ฐ์ด ์—†์Šต๋‹ˆ๋‹ค.\n" - "๊ด€๋ฆฌ์ž์—๊ฒŒ ๋ฌธ์˜ํ•˜์„ธ์š”."), + UP_TO_DATE: (tr('update.check_title'), tr('update.up_to_date', version=__version__)), + NETWORK_ERROR: (tr('msg.error.title'), + tr('update.network_error')), + NO_RELEASE: (tr('msg.no_data.title'), + tr('update.no_release')), + NO_ASSET: (tr('msg.confirm_delete.title'), + tr('update.no_asset')), } - title, body = messages.get(reason, ("์—…๋ฐ์ดํŠธ ํ™•์ธ", "์•Œ ์ˆ˜ ์—†๋Š” ์‘๋‹ต์ž…๋‹ˆ๋‹ค.")) + title, body = messages.get(reason, (tr('update.check_title'), tr('update.unknown'))) QMessageBox.information(self, title, body) return @@ -2044,17 +2048,15 @@ class MainWindow(QMainWindow): if not getattr(sys, 'frozen', False): QMessageBox.information( self, - "์ƒˆ ๋ฒ„์ „ ๋ฐœ๊ฒฌ", - f"์ƒˆ ๋ฒ„์ „ {info.version}์ด ์žˆ์Šต๋‹ˆ๋‹ค.\n" - "(๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์ž๋™ ์ ์šฉ ๋ถˆ๊ฐ€ โ€” git pull ๋˜๋Š” ๋นŒ๋“œ ํ›„ ์‚ฌ์šฉ)" + tr('update.new_version_title'), + tr('update.new_found_dev', version=info.version) ) return reply = QMessageBox.question( self, - "์ƒˆ ๋ฒ„์ „ ๋ฐœ๊ฒฌ", - f"ํ˜„์žฌ: v{__version__}\n์ƒˆ ๋ฒ„์ „: {info.version}\n\n" - f"๋ฆด๋ฆฌ์Šค ๋…ธํŠธ:\n{info.notes[:500]}\n\n์ง€๊ธˆ ๋‹ค์šด๋กœ๋“œ ํ›„ ์—…๋ฐ์ดํŠธํ• ๊นŒ์š”?", + tr('update.new_version_title'), + tr('update.new_found', current=__version__, new=info.version, notes=info.notes[:500]), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: @@ -2062,8 +2064,8 @@ class MainWindow(QMainWindow): # ๋‹ค์šด๋กœ๋“œ (๋ชจ๋‹ฌ ์ง„ํ–‰ ๋‹ค์ด์–ผ๋กœ๊ทธ) from PyQt5.QtWidgets import QProgressDialog - progress = QProgressDialog("๋‹ค์šด๋กœ๋“œ ์ค‘...", "์ทจ์†Œ", 0, 100, self) - progress.setWindowTitle("์—…๋ฐ์ดํŠธ ๋‹ค์šด๋กœ๋“œ") + progress = QProgressDialog(tr('update.downloading'), tr('btn.cancel'), 0, 100, self) + progress.setWindowTitle(tr('update.download_title')) progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) @@ -2076,18 +2078,18 @@ class MainWindow(QMainWindow): progress.close() if new_exe is None: - QMessageBox.critical(self, "๋‹ค์šด๋กœ๋“œ ์‹คํŒจ", "์ƒˆ ๋ฒ„์ „ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.") + QMessageBox.critical(self, tr('msg.error.title'), tr('update.download_failed')) return if not apply_update(new_exe): QMessageBox.critical( - self, "์—…๋ฐ์ดํŠธ ์‹คํŒจ", - "updater.exe๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†๊ฑฐ๋‚˜ ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + self, tr('update.apply_failed_title'), + tr('update.updater_failed') ) return # updater.exe๊ฐ€ ๋ฉ”์ธ ์ข…๋ฃŒ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๊ณ  ์žˆ์Œ โ†’ ์ฆ‰์‹œ ์ข…๋ฃŒ - QMessageBox.information(self, "์žฌ์‹œ์ž‘", "์—…๋ฐ์ดํŠธ ์ ์šฉ์„ ์œ„ํ•ด ํ”„๋กœ๊ทธ๋žจ์ด ์ข…๋ฃŒ๋ฉ๋‹ˆ๋‹ค.") + QMessageBox.information(self, tr('msg.error.title'), tr('update.restart')) QApplication.quit() def show_mini_widget(self): @@ -2107,12 +2109,12 @@ class MainWindow(QMainWindow): """์™ธ์ถœ ์ฒ˜๋ฆฌ. silent=True๋ฉด ๋‹ค์ด์–ผ๋กœ๊ทธ ์—†์ด (์ž ๊ธˆ ์ž๋™ ์™ธ์ถœ์šฉ).""" if not self.is_clocked_in: if not silent: - QMessageBox.warning(self, "์™ธ์ถœ ๋ถˆ๊ฐ€", "์ถœ๊ทผํ•˜์ง€ ์•Š์€ ์ƒํƒœ์ž…๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('break.cannot_no_clock_in.title'), tr('break.cannot_no_clock_in.body')) return if self.is_on_break: if not silent: - QMessageBox.warning(self, "์™ธ์ถœ ๋ถˆ๊ฐ€", "์ด๋ฏธ ์™ธ์ถœ ์ค‘์ž…๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('break.cannot_no_clock_in.title'), tr('break.cannot_already_on_break.body')) return now = datetime.now() @@ -2122,11 +2124,11 @@ class MainWindow(QMainWindow): today_record = self.db.get_today_record() if not today_record: if not silent: - QMessageBox.warning(self, "์™ธ์ถœ ๋ถˆ๊ฐ€", "์ถœ๊ทผ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('break.cannot_no_clock_in.title'), tr('break.cannot_no_record.body')) return work_record_id = today_record['id'] - reason = "ํ™”๋ฉด ์ž ๊ธˆ" if silent else None + reason = tr('break.reason.lock') if silent else None self.db.add_break_record(work_record_id, today, break_out_str, reason) self.is_on_break = True @@ -2136,7 +2138,7 @@ class MainWindow(QMainWindow): self.update_break_status() if not silent: - QMessageBox.information(self, "์™ธ์ถœ", f"์™ธ์ถœ ์‹œ๊ฐ„: {break_out_str}") + QMessageBox.information(self, tr('break.started.title'), tr('break.started.body', time=break_out_str)) def break_in(self, silent: bool = False): """๋ณต๊ท€ ์ฒ˜๋ฆฌ. silent=True๋ฉด ๋‹ค์ด์–ผ๋กœ๊ทธ ์—†์ด.""" @@ -2149,12 +2151,12 @@ class MainWindow(QMainWindow): if not active_break: if not silent: - QMessageBox.warning(self, "๋ณต๊ท€ ๋ถˆ๊ฐ€", "์ง„ํ–‰ ์ค‘์ธ ์™ธ์ถœ ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('msg.error.title'), tr('break.cannot_return_no_active.body')) return if not active_break.get('break_out'): if not silent: - QMessageBox.warning(self, "๋ณต๊ท€ ๋ถˆ๊ฐ€", "์™ธ์ถœ ์‹œ๊ฐ„ ๊ธฐ๋ก์ด ์†์ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('msg.error.title'), tr('break.cannot_return_corrupt.body')) return # ๋ณต๊ท€ ์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ @@ -2170,12 +2172,16 @@ class MainWindow(QMainWindow): # ์™ธ์ถœ ์‹œ๊ฐ„ ๊ณ„์‚ฐ (์ž์ • ๊ฒฝ๊ณ„ ์ฒ˜๋ฆฌ) # break_record์— ์ €์žฅ๋œ ๋‚ ์งœ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ž์ • ๊ฒฝ๊ณ„ ๋ฌธ์ œ ํ•ด๊ฒฐ break_date = active_break['date'] - break_date_obj = datetime.strptime(break_date, "%Y-%m-%d").date() + try: + break_date_obj = datetime.strptime(break_date, "%Y-%m-%d").date() + break_out_parsed = datetime.strptime(active_break['break_out'], "%H:%M:%S").time() + except ValueError: + if not silent: + QMessageBox.warning(self, tr('msg.error.title'), tr('break.cannot_return_unusable.body')) + dlog(f"break_in parse failed: date={break_date}, break_out={active_break.get('break_out')}") + return - break_out_time = datetime.combine( - break_date_obj, - datetime.strptime(active_break['break_out'], "%H:%M:%S").time() - ) + break_out_time = datetime.combine(break_date_obj, break_out_parsed) break_in_time = datetime.combine( break_date_obj, datetime.strptime(break_in_str, "%H:%M:%S").time() @@ -2191,8 +2197,8 @@ class MainWindow(QMainWindow): if not silent: QMessageBox.information( self, - "๋ณต๊ท€", - f"๋ณต๊ท€ ์‹œ๊ฐ„: {break_in_str}\n์™ธ์ถœ ์‹œ๊ฐ„: {duration_minutes}๋ถ„" + tr('break.return.title'), + tr('break.return.body', time=break_in_str, minutes=duration_minutes) ) def update_break_status(self): @@ -2201,7 +2207,7 @@ class MainWindow(QMainWindow): if active_break: break_out = active_break['break_out'] - self.break_status_label.setText(f"์™ธ์ถœ ์ค‘ ({break_out}๋ถ€ํ„ฐ)") + self.break_status_label.setText(tr('break.status_in_progress', time=break_out)) self.break_status_label.setStyleSheet(f"color: {ThemeColors.get('status_break_active')}; font-weight: bold;") self.is_on_break = True self.break_out_button.setEnabled(False) @@ -2212,9 +2218,9 @@ class MainWindow(QMainWindow): hours = total_minutes // 60 minutes = total_minutes % 60 if hours > 0: - self.break_status_label.setText(f"์˜ค๋Š˜ ์ด ์™ธ์ถœ: {hours}์‹œ๊ฐ„ {minutes}๋ถ„") + self.break_status_label.setText(tr('break.status_total_hours_minutes', hours=hours, minutes=minutes)) else: - self.break_status_label.setText(f"์˜ค๋Š˜ ์ด ์™ธ์ถœ: {minutes}๋ถ„") + self.break_status_label.setText(tr('break.status_total_minutes', minutes=minutes)) self.break_status_label.setStyleSheet(f"color: {ThemeColors.get('status_break_idle')};") else: self.break_status_label.setText("") @@ -2252,12 +2258,19 @@ class MainWindow(QMainWindow): # auto_lunch ์บ์‹œ ๋ฌดํšจํ™” (์„ค์ •์—์„œ ํ† ๊ธ€ ๊ฐ€๋Šฅํ•˜๋ฏ€๋กœ) self._auto_lunch.invalidate() + # update_display 1Hz ํ•ซํŒจ์Šค ์บ์‹œ ๋ฌดํšจํ™” + self._workday_boundary_hour_cache = None + self._non_working_cache_date = None + self._non_working_cache_value = None + self._full_day_leave_cache_date = None + self._full_day_leave_cache_value = None + # ์ ‘๊ทผ์„ฑ ์žฌ์ ์šฉ (๊ธ€๊ผด / ๊ณ ๋Œ€๋น„) try: from ui.accessibility import apply_from_settings as _apply_a11y _apply_a11y(self.db) - except Exception: - pass + except Exception as e: + dlog(f"accessibility reapply failed: {e}") # UI ์—…๋ฐ์ดํŠธ self.update_overtime_balance() @@ -2277,7 +2290,7 @@ class MainWindow(QMainWindow): """ actual_min = sum((r.get('total_minutes') or 0) for r in records) if records and actual_min > 0: - report_lines.append(f"{label}: {format_hours_minutes(actual_min)} (์‹ค์ธก)") + report_lines.append(tr('report.meal_actual', label=label, time=format_hours_minutes(actual_min))) for r in records: try: start_dt = datetime.fromisoformat(f"{today} {r['break_out']}") @@ -2292,12 +2305,12 @@ class MainWindow(QMainWindow): end_dt += timedelta(days=1) dur = int((end_dt - start_dt).total_seconds() / 60) report_lines.append( - f" - {self.format_time(start_dt)} ~ {self.format_time(end_dt)} ({dur}๋ถ„)" + tr('report.break_detail', start=self.format_time(start_dt), end=self.format_time(end_dt), duration=dur, reason='') ) else: - report_lines.append(f" - {self.format_time(start_dt)} ~ ์ง„ํ–‰์ค‘") + report_lines.append(tr('report.meal_in_progress', start=self.format_time(start_dt))) elif flag: - report_lines.append(f"{label}: ํฌํ•จ ({format_hours_minutes(default_min)})") + report_lines.append(tr('report.meal_default', label=label, time=format_hours_minutes(default_min))) else: return report_lines.append("") @@ -2310,8 +2323,8 @@ class MainWindow(QMainWindow): try: cur = self.db.get_setting_int('daily_report_count', 0) self.db.set_setting('daily_report_count', str(cur + 1)) - except Exception: - pass + except Exception as e: + dlog(f"daily report counter failed: {e}") today = date.today().isoformat() @@ -2321,34 +2334,34 @@ class MainWindow(QMainWindow): if not work_record: QMessageBox.warning( self, - "๊ธฐ๋ก ์—†์Œ", - "์˜ค๋Š˜ ์ถœ๊ทผ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค." + tr('msg.no_record.title'), + tr('msg.no_record.body') ) return # ๋ณด๊ณ ์„œ ์ž‘์„ฑ report_lines = [] report_lines.append("=" * 40) - report_lines.append(f"๐Ÿ“‹ ์ผ์ผ ๊ทผ๋ฌด ๋ณด๊ณ ์„œ - {today}") + report_lines.append(tr('report.title', date=today)) report_lines.append("=" * 40) report_lines.append("") # ์ถœ๊ทผ/ํ‡ด๊ทผ ์‹œ๊ฐ„ clock_in_dt = datetime.fromisoformat(f"{today} {work_record['clock_in']}") - report_lines.append(f"๐Ÿ• ์ถœ๊ทผ ์‹œ๊ฐ„: {self.format_time(clock_in_dt, include_seconds=True)}") + report_lines.append(tr('report.clock_in', time=self.format_time(clock_in_dt, include_seconds=True))) if work_record['clock_out']: clock_out_dt = datetime.fromisoformat(f"{today} {work_record['clock_out']}") - report_lines.append(f"๐Ÿ• ํ‡ด๊ทผ ์‹œ๊ฐ„: {self.format_time(clock_out_dt, include_seconds=True)}") + report_lines.append(tr('report.clock_out', time=self.format_time(clock_out_dt, include_seconds=True))) # ์ด ๊ทผ๋ฌด ์‹œ๊ฐ„ total_work_hours = work_record.get('total_hours') or work_record.get('work_hours', 0) if total_work_hours: hours = int(total_work_hours) minutes = int((total_work_hours - hours) * 60) - report_lines.append(f"โฑ๏ธ ์ด ๊ทผ๋ฌด: {hours}์‹œ๊ฐ„ {minutes}๋ถ„") + report_lines.append(tr('report.total_work', time=tr('label.time_hours_minutes', hours=hours, minutes=minutes))) else: - report_lines.append(f"๐Ÿ• ํ‡ด๊ทผ ์‹œ๊ฐ„: ๋ฏธํ‡ด๊ทผ") + report_lines.append(tr('report.not_clocked_out')) report_lines.append("") @@ -2364,7 +2377,7 @@ class MainWindow(QMainWindow): real_break_minutes = sum((b.get('total_minutes') or 0) for b in real_break_records) has_active_break = any(not b.get('break_in') for b in real_break_records) if real_break_minutes > 0 or has_active_break: - report_lines.append(f"๐Ÿšถ ์™ธ์ถœ ์‹œ๊ฐ„: {format_hours_minutes(real_break_minutes)}") + report_lines.append(tr('report.break_time', time=format_hours_minutes(real_break_minutes))) for br in real_break_records: break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}") if br['break_in']: @@ -2374,17 +2387,17 @@ class MainWindow(QMainWindow): break_in_time += timedelta(days=1) duration = int((break_in_time - break_out_time).total_seconds() / 60) reason = f" ({br['reason']})" if br.get('reason') else "" - report_lines.append(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}๋ถ„){reason}") + report_lines.append(tr('report.break_detail', start=self.format_time(break_out_time), end=self.format_time(break_in_time), duration=duration, reason=reason)) else: reason = f" ({br['reason']})" if br.get('reason') else "" - report_lines.append(f" - {self.format_time(break_out_time)} ~ ๋ณต๊ท€์ค‘{reason}") + report_lines.append(tr('report.break_in_progress', start=self.format_time(break_out_time), reason=reason)) report_lines.append("") # ์ ์‹ฌ์‹œ๊ฐ„ lunch_flag = bool(work_record.get('lunch_break', False)) if lunch_flag or lunch_records: self._append_meal_section( - report_lines, today, '๐Ÿฑ ์ ์‹ฌ์‹œ๊ฐ„', + report_lines, today, tr('label.lunch'), lunch_flag, lunch_records, self.time_calc.lunch_duration_minutes, ) @@ -2393,7 +2406,7 @@ class MainWindow(QMainWindow): dinner_flag = bool(work_record.get('dinner_break', False)) if dinner_flag or dinner_records: self._append_meal_section( - report_lines, today, '๐Ÿฝ๏ธ ์ €๋…์‹œ๊ฐ„', + report_lines, today, tr('label.dinner'), dinner_flag, dinner_records, self.time_calc.dinner_duration_minutes, ) @@ -2408,8 +2421,8 @@ class MainWindow(QMainWindow): earned_hours = overtime_earned // 60 earned_mins = overtime_earned % 60 - report_lines.append(f"โฐ ์ถ”๊ฐ€ ๊ทผ๋ฌด ๋ฐœ์ƒ: {ot_hours}์‹œ๊ฐ„ {ot_mins}๋ถ„") - report_lines.append(f" ๐Ÿ’ฐ ์ ๋ฆฝ: {earned_hours}์‹œ๊ฐ„ {earned_mins}๋ถ„ (30๋ถ„ ๋‹จ์œ„ ์ ˆ์‚ญ)") + report_lines.append(tr('report.overtime_occurred', time=tr('label.time_hours_minutes', hours=ot_hours, minutes=ot_mins))) + report_lines.append(tr('report.overtime_banked', time=tr('label.time_hours_minutes', hours=earned_hours, minutes=earned_mins))) report_lines.append("") # ์˜ค๋Š˜ ์‚ฌ์šฉํ•œ ์ถ”๊ฐ€๊ทผ๋ฌด @@ -2417,7 +2430,7 @@ class MainWindow(QMainWindow): if overtime_used_today > 0: used_hours = overtime_used_today // 60 used_mins = overtime_used_today % 60 - report_lines.append(f"๐Ÿ• ์ถ”๊ฐ€ ๊ทผ๋ฌด ์‚ฌ์šฉ: {used_hours}์‹œ๊ฐ„ {used_mins}๋ถ„") + report_lines.append(tr('report.overtime_used', time=tr('label.time_hours_minutes', hours=used_hours, minutes=used_mins))) # ์‚ฌ์šฉ ์ƒ์„ธ ๋‚ด์—ญ conn = self.db.get_connection() @@ -2437,7 +2450,7 @@ class MainWindow(QMainWindow): used_h = used_min // 60 used_m = used_min % 60 reason_text = f" - {reason}" if reason else "" - report_lines.append(f" - {used_h}์‹œ๊ฐ„ {used_m}๋ถ„{reason_text}") + report_lines.append(tr('report.overtime_used_detail', time=tr('label.time_hours_minutes', hours=used_h, minutes=used_m), reason=reason_text)) report_lines.append("") # ์˜ค๋Š˜ ์‚ฌ์šฉํ•œ ์—ฐ์ฐจ (์ผ๊ด„ ์ถ”๊ฐ€ ๋ฐ ์ˆ˜๋™ ์กฐ์ • ์ œ์™ธ) @@ -2457,25 +2470,26 @@ class MainWindow(QMainWindow): days = int(total_leave_days) hours = int((total_leave_days - days) * 8) if hours > 0: - report_lines.append(f"๐ŸŒด ์—ฐ์ฐจ ์‚ฌ์šฉ: {days}์ผ {hours}์‹œ๊ฐ„") + value = tr('report.leave_used_days_hours', days=days, hours=hours) else: - report_lines.append(f"๐ŸŒด ์—ฐ์ฐจ ์‚ฌ์šฉ: {days}์ผ") + value = tr('report.leave_used_days', days=days) else: hours = int(total_leave_days * 8) - report_lines.append(f"๐ŸŒด ์—ฐ์ฐจ ์‚ฌ์šฉ: {hours}์‹œ๊ฐ„") + value = tr('report.leave_used_hours', hours=hours) + report_lines.append(tr('report.leave_used', value=value)) for lr in filtered_leave_records: - # leave_type์„ ํ•œ๊ธ€ ์ด๋ฆ„์œผ๋กœ ๋ณ€ํ™˜ + # leave_type์„ ํ˜„์žฌ ์–ธ์–ด ์ด๋ฆ„์œผ๋กœ ๋ณ€ํ™˜ leave_type_name = { - 'annual': '์—ฐ์ฐจ', - 'sick': '๋ณ‘๊ฐ€', - 'half_am': '์˜ค์ „ ๋ฐ˜์ฐจ', - 'half_pm': '์˜คํ›„ ๋ฐ˜์ฐจ', - 'time_off': '์‹œ๊ฐ„ ์—ฐ์ฐจ', - '์—ฐ์ฐจ': '์—ฐ์ฐจ', - '๋ฐ˜์ฐจ': '๋ฐ˜์ฐจ', - '๋ฐ˜๋ฐ˜์ฐจ': '๋ฐ˜๋ฐ˜์ฐจ' - }.get(lr.get('leave_type', ''), lr.get('leave_type', '์—ฐ์ฐจ')) + 'annual': tr('leave.type.annual'), + 'sick': tr('leave.type.sick'), + 'half_am': tr('leave.type.half_am'), + 'half_pm': tr('leave.type.half_pm'), + 'time_off': tr('leave.type.time_off'), + '์—ฐ์ฐจ': tr('leave.type.annual'), + '๋ฐ˜์ฐจ': tr('leave.type.half'), + '๋ฐ˜๋ฐ˜์ฐจ': tr('leave.type.quarter') + }.get(lr.get('leave_type', ''), lr.get('leave_type', tr('leave.type.annual'))) days_used = lr['days'] @@ -2484,12 +2498,12 @@ class MainWindow(QMainWindow): d = int(days_used) h = int((days_used - d) * 8) if h > 0: - time_str = f"{d}์ผ {h}์‹œ๊ฐ„" + time_str = tr('report.leave_used_days_hours', days=d, hours=h) else: - time_str = f"{d}์ผ" + time_str = tr('report.leave_used_days', days=d) else: h = int(days_used * 8) - time_str = f"{h}์‹œ๊ฐ„" + time_str = tr('report.leave_used_hours', hours=h) memo = f" - {lr['memo']}" if lr.get('memo') else "" report_lines.append(f" - {leave_type_name}: {time_str}{memo}") @@ -2497,7 +2511,7 @@ class MainWindow(QMainWindow): # ๋ฉ”๋ชจ if work_record['memo']: - report_lines.append(f"๐Ÿ“ ๋ฉ”๋ชจ: {work_record['memo']}") + report_lines.append(tr('report.memo', memo=work_record['memo'])) report_lines.append("") report_lines.append("=" * 40) @@ -2510,8 +2524,8 @@ class MainWindow(QMainWindow): # ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ฉ”์‹œ์ง€ ๋ฐ•์Šค QMessageBox.information( self, - "๋ณด๊ณ ์„œ ๋ณต์‚ฌ ์™„๋ฃŒ", - "์ผ์ผ ๊ทผ๋ฌด ๋ณด๊ณ ์„œ๊ฐ€ ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n" + report_text + tr('report.copied.title'), + tr('report.copied.body', report=report_text) ) def show_notification(self, title: str, message: str): @@ -2535,8 +2549,8 @@ class MainWindow(QMainWindow): self.hide() if self.tray_icon.supportsMessages(): self.tray_icon.showMessage( - "Clock-out Time Calculator", - "ํ”„๋กœ๊ทธ๋žจ์ด ํŠธ๋ ˆ์ด์—์„œ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.", + tr('app.title'), + tr('tray.background'), QSystemTrayIcon.Information, 2000 ) diff --git a/ui/meal_time_dialog.py b/ui/meal_time_dialog.py index 3cdbe94..5af4051 100644 --- a/ui/meal_time_dialog.py +++ b/ui/meal_time_dialog.py @@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTimeEdit, QMessageBox) from PyQt5.QtCore import QTime +from core.i18n import tr from ui.styles import apply_dark_titlebar @@ -35,8 +36,8 @@ class MealTimeDialog(QDialog): self.meal_type = meal_type self._clock_in = clock_in_time self._clock_out = clock_out_time - title_kr = '์ ์‹ฌ' if meal_type == 'lunch' else '์ €๋…' - self.setWindowTitle(f"{title_kr} ์‹œ๊ฐ„ ์ž…๋ ฅ") + meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short') + self.setWindowTitle(tr('meal.dialog_title', meal=meal_label)) self.setModal(True) self.setFixedSize(380, 260) @@ -44,10 +45,9 @@ class MealTimeDialog(QDialog): layout.setSpacing(10) layout.setContentsMargins(20, 16, 20, 16) - info_text = (f"{title_kr} ์‹œ์ž‘ยท์ข…๋ฃŒ ์‹œ๊ฐ์„ ์ž…๋ ฅํ•˜์„ธ์š”.\n" - f"์ž๋™ ์ ์šฉ๋œ {default_minutes}๋ถ„ ๋Œ€์‹  ์ •ํ™•ํ•œ ์‹œ๊ฐ„์œผ๋กœ ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.") + info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes) if clock_in_time is not None: - info_text += f"\n์ถœ๊ทผ {clock_in_time.strftime('%H:%M')} ์ดํ›„๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ." + info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M')) info = QLabel(info_text) info.setWordWrap(True) info.setStyleSheet("color: #909296; padding-bottom: 6px;") @@ -63,7 +63,7 @@ class MealTimeDialog(QDialog): # ์‹œ์ž‘ start_row = QHBoxLayout() - start_row.addWidget(QLabel("์‹œ์ž‘:")) + start_row.addWidget(QLabel(tr('meal.label_start'))) self.start_edit = QTimeEdit() self.start_edit.setDisplayFormat("HH:mm") self.start_edit.setTime(QTime(default_start_h, 0)) @@ -73,7 +73,7 @@ class MealTimeDialog(QDialog): # ์ข…๋ฃŒ end_row = QHBoxLayout() - end_row.addWidget(QLabel("์ข…๋ฃŒ:")) + end_row.addWidget(QLabel(tr('meal.label_end'))) self.end_edit = QTimeEdit() self.end_edit.setDisplayFormat("HH:mm") self.end_edit.setTime(QTime(default_end_h, 0)) @@ -92,10 +92,10 @@ class MealTimeDialog(QDialog): # ๋ฒ„ํŠผ btn_row = QHBoxLayout() btn_row.addStretch() - ok_btn = QPushButton("์ €์žฅ") + ok_btn = QPushButton(tr('btn.save')) ok_btn.setObjectName("btn_primary") ok_btn.clicked.connect(self.accept) - cancel_btn = QPushButton("์ทจ์†Œ") + cancel_btn = QPushButton(tr('btn.cancel')) cancel_btn.clicked.connect(self.reject) btn_row.addWidget(ok_btn) btn_row.addWidget(cancel_btn) @@ -137,24 +137,24 @@ class MealTimeDialog(QDialog): start_dt, end_dt, minutes = self._resolve_meal_window() ok, reason = self._validate_window(start_dt, end_dt, minutes) if not ok: - self.preview.setText(f"{reason}") + self.preview.setText(reason) self.preview.setStyleSheet("color: #FA5252;") else: - self.preview.setText(f"์ด {minutes}๋ถ„") + self.preview.setText(tr('meal.preview_total', minutes=minutes)) self.preview.setStyleSheet("color: #51CF66; font-weight: bold;") def _validate_window(self, start_dt: datetime, end_dt: datetime, minutes: int) -> tuple[bool, str]: """์‹์‚ฌ ์‹œ๊ฐ์ด ์ถœ/ํ‡ด๊ทผ ๋ฒ”์œ„์™€ ์ •ํ•ฉ์ธ์ง€ ๊ฒ€์ฆ.""" if minutes <= 0: - return False, "์‹œ์ž‘์ด ์ข…๋ฃŒ๋ณด๋‹ค ๋Šฆ์Šต๋‹ˆ๋‹ค" + return False, tr('meal.error_start_after_end') if minutes > 8 * 60: # ์ž์ • ๊ฒฝ๊ณ„ ์ฒ˜๋ฆฌ ํ›„ 8์‹œ๊ฐ„ ์ดˆ๊ณผ๋ฉด ์‚ฌ์šฉ์ž ์‹ค์ˆ˜์ผ ๊ฐ€๋Šฅ์„ฑ ๋†’์Œ - return False, "์‹์‚ฌ ์‹œ๊ฐ„์ด 8์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค" + return False, tr('meal.error_too_long') if self._clock_in is not None and start_dt < self._clock_in: - return False, f"์ถœ๊ทผ({self._clock_in.strftime('%H:%M')}) ์ด์ „์ž…๋‹ˆ๋‹ค" + return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M')) if self._clock_out is not None and end_dt > self._clock_out: - return False, f"ํ‡ด๊ทผ({self._clock_out.strftime('%H:%M')}) ์ดํ›„์ž…๋‹ˆ๋‹ค" + return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M')) return True, "" def accept(self): @@ -162,7 +162,7 @@ class MealTimeDialog(QDialog): start_dt, end_dt, minutes = self._resolve_meal_window() ok, reason = self._validate_window(start_dt, end_dt, minutes) if not ok: - QMessageBox.warning(self, "์ž…๋ ฅ ์˜ค๋ฅ˜", reason) + QMessageBox.warning(self, tr('meal.input_error_title'), reason) return super().accept() diff --git a/ui/mini_widget.py b/ui/mini_widget.py index aa579af..7568c67 100644 --- a/ui/mini_widget.py +++ b/ui/mini_widget.py @@ -102,8 +102,8 @@ class MiniWidget(QWidget): qss = '' if qss: menu.setStyleSheet(qss) - open_main = menu.addAction("๋ฉ”์ธ ์ฐฝ ์—ด๊ธฐ") - close_mini = menu.addAction("๋ฏธ๋‹ˆ ์œ„์ ฏ ๋‹ซ๊ธฐ") + open_main = menu.addAction(tr('mini.open_main')) + close_mini = menu.addAction(tr('mini.close')) action = menu.exec_(event.globalPos()) if action == open_main and self.parent_window: self.parent_window.show() diff --git a/ui/onboarding_view.py b/ui/onboarding_view.py index a919cb5..cf0147c 100644 --- a/ui/onboarding_view.py +++ b/ui/onboarding_view.py @@ -32,17 +32,10 @@ WORK_PRESETS = [ class WelcomePage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค!") - self.setSubTitle("Clock-out Time Calculator๋ฅผ ์ฒ˜์Œ ์‚ฌ์šฉํ•˜์‹œ๋Š”๊ตฐ์š”. 5๋‹จ๊ณ„๋กœ ๋น ๋ฅด๊ฒŒ ์„ค์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.") + self.setTitle(tr('onboarding.welcome_title')) + self.setSubTitle(tr('onboarding.welcome_subtitle')) layout = QVBoxLayout() - intro = QLabel( - "์ด ์•ฑ์€:\n" - "โ€ข ์ปดํ“จํ„ฐ ๋ถ€ํŒ…/์ž ๊ธˆ ํ•ด์ œ๋กœ ์ถœ๊ทผ ์‹œ๊ฐ„ ์ž๋™ ๊ฐ์ง€\n" - "โ€ข 30๋ถ„ ๋‹จ์œ„ ์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ\n" - "โ€ข ์—ฐ์ฐจยท๋ฐ˜์ฐจยท์™ธ์ถœ ์‹œ๊ฐ„ ์ถ”์ \n" - "โ€ข ๋งค์ผ ํ‡ด๊ทผ ์‹œ๊ฐ„์„ 1์ดˆ๋งˆ๋‹ค ์นด์šดํŠธ๋‹ค์šด\n\n" - "[๋‹ค์Œ] ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์‹œ์ž‘ํ•˜์„ธ์š”." - ) + intro = QLabel(tr('onboarding.welcome_intro')) intro.setWordWrap(True) layout.addWidget(intro) self.setLayout(layout) @@ -51,8 +44,8 @@ class WelcomePage(QWizardPage): class WorkPatternPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("๊ทผ๋ฌด ํŒจํ„ด") - self.setSubTitle("๋ณธ์ธ์˜ ํ•˜๋ฃจ ๊ทผ๋ฌด ์‹œ๊ฐ„์„ ์„ ํƒํ•˜์„ธ์š”. ๋‚˜์ค‘์— ์„ค์ •์—์„œ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + self.setTitle(tr('onboarding.work_pattern_title')) + self.setSubTitle(tr('onboarding.work_pattern_subtitle')) layout = QVBoxLayout() self.button_group = QButtonGroup(self) @@ -127,20 +120,18 @@ class WorkPatternPage(QWizardPage): class ClockInDetectionPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("์ถœ๊ทผ ์‹œ๊ฐ„ ๊ฐ์ง€ ๋ฐฉ์‹") - self.setSubTitle("์•ฑ์ด ์ถœ๊ทผ ์‹œ๊ฐ„์„ ์ž๋™์œผ๋กœ ์–ด๋–ป๊ฒŒ ๊ฐ์ง€ํ• ์ง€ ์„ ํƒํ•˜์„ธ์š”.") + self.setTitle(tr('onboarding.detection_title')) + self.setSubTitle(tr('onboarding.detection_subtitle')) layout = QVBoxLayout() - self.option_boot = QRadioButton("PC ๋ถ€ํŒ… ์‹œ๊ฐ„ (๊ธฐ๋ณธ โ€” ๋งค์ผ PC๋ฅผ ๋„๋Š” ๊ฒฝ์šฐ)") - self.option_unlock = QRadioButton("ํ™”๋ฉด ์ž ๊ธˆ ํ•ด์ œ ์‹œ๊ฐ„ (PC๋ฅผ ์•ˆ ๋„๊ณ  ๋‹ค๋‹ˆ๋Š” ๊ฒฝ์šฐ)") - self.option_manual = QRadioButton("์ˆ˜๋™ ์ž…๋ ฅ๋งŒ (์ž๋™ ๊ฐ์ง€ ์•ˆ ํ•จ)") + self.option_boot = QRadioButton(tr('onboarding.detection_boot')) + self.option_unlock = QRadioButton(tr('onboarding.detection_unlock')) + self.option_manual = QRadioButton(tr('onboarding.detection_manual')) self.option_boot.setChecked(True) for opt in (self.option_boot, self.option_unlock, self.option_manual): layout.addWidget(opt) - info = QLabel( - "\nPC๋ฅผ ํ•ญ์ƒ ์ผœ๋‘” ์ฑ„ ์ถœ๊ทผํ•˜์‹œ๋Š” ๋ถ„์€ ๋‘ ๋ฒˆ์งธ ์˜ต์…˜์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค." - ) + info = QLabel(tr('onboarding.detection_info')) info.setWordWrap(True) info.setStyleSheet("color: #909296; padding: 8px;") layout.addWidget(info) @@ -159,35 +150,35 @@ class ClockInDetectionPage(QWizardPage): class LeaveSalaryPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("์—ฐ์ฐจ + ๊ธ‰์—ฌ (์˜ต์…˜)") - self.setSubTitle("์—ฐ์ฐจ ์ผ์ˆ˜์™€ ๊ธ‰์—ฌ(์„ ํƒ)๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.") + self.setTitle(tr('onboarding.leave_salary_title')) + self.setSubTitle(tr('onboarding.leave_salary_subtitle')) layout = QVBoxLayout() # ์—ฐ์ฐจ - leave_box = QGroupBox("์—ฐ๊ฐ„ ์—ฐ์ฐจ") + leave_box = QGroupBox(tr('onboarding.leave_group')) leave_layout = QHBoxLayout() self.leave_spin = QSpinBox() self.leave_spin.setRange(0, 30) self.leave_spin.setValue(15) - self.leave_spin.setSuffix(" ์ผ") - leave_layout.addWidget(QLabel("๋‚ด ์—ฐ์ฐจ:")) + self.leave_spin.setSuffix(tr('label.unit_day')) + leave_layout.addWidget(QLabel(tr('onboarding.my_leave'))) leave_layout.addWidget(self.leave_spin) leave_layout.addStretch() leave_box.setLayout(leave_layout) layout.addWidget(leave_box) # ๊ธ‰์—ฌ (์˜ต์…˜) - salary_box = QGroupBox("๊ธ‰์—ฌ ์ถ”์ • (์˜ต์…˜ โ€” ํฌ๊ด„์ž„๊ธˆ์ด๋ฉด ๋น„ํ™œ์„ฑ)") + salary_box = QGroupBox(tr('onboarding.salary_group')) salary_layout = QVBoxLayout() - self.salary_enabled = QCheckBox("๊ธ‰์—ฌ ์ถ”์ • ํ™œ์„ฑํ™”") + self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled')) salary_layout.addWidget(self.salary_enabled) wage_row = QHBoxLayout() - wage_row.addWidget(QLabel("์‹œ๊ธ‰:")) + wage_row.addWidget(QLabel(tr('onboarding.hourly_wage'))) self.wage_spin = QSpinBox() self.wage_spin.setRange(0, 1000000) self.wage_spin.setSingleStep(1000) - self.wage_spin.setSuffix(" ์›/์‹œ๊ฐ„") + self.wage_spin.setSuffix(tr('onboarding.wage_suffix')) self.wage_spin.setValue(0) self.wage_spin.setEnabled(False) wage_row.addWidget(self.wage_spin) @@ -195,11 +186,11 @@ class LeaveSalaryPage(QWizardPage): salary_layout.addLayout(wage_row) rate_row = QHBoxLayout() - rate_row.addWidget(QLabel("์—ฐ์žฅ์ˆ˜๋‹น ๊ฐ€์‚ฐ๋ฅ :")) + rate_row.addWidget(QLabel(tr('onboarding.overtime_rate'))) self.rate_combo = QComboBox() - self.rate_combo.addItem("1.0๋ฐฐ (๊ฐ€์‚ฐ ์—†์Œ)", 1.0) - self.rate_combo.addItem("1.5๋ฐฐ (ํ•œ๊ตญ ๋…ธ๋™๋ฒ• ๊ธฐ๋ณธ)", 1.5) - self.rate_combo.addItem("2.0๋ฐฐ (์•ผ๊ทผ/ํœด์ผ ๊ฐ€์‚ฐ)", 2.0) + self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0) + self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5) + self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0) self.rate_combo.setCurrentIndex(1) self.rate_combo.setEnabled(False) rate_row.addWidget(self.rate_combo) @@ -218,30 +209,25 @@ class LeaveSalaryPage(QWizardPage): class DiscordPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("Discord ์•Œ๋ฆผ (์„ ํƒ)") - self.setSubTitle("์ถœํ‡ด๊ทผ ์‹œ๊ฐยทํœด์‹ ๊ถŒ๊ณ ๋ฅผ Discord๋กœ ๋ฐ›์œผ๋ ค๋ฉด ์›นํ›… URL์„ ์ž…๋ ฅํ•˜์„ธ์š”. (๋ชจ๋ฐ”์ผ์—์„œ ํ‘ธ์‹œ ์•Œ๋ฆผ)") + self.setTitle(tr('onboarding.discord_title')) + self.setSubTitle(tr('onboarding.discord_subtitle')) layout = QVBoxLayout() - self.enable_check = QCheckBox("Discord ์›นํ›… ์•Œ๋ฆผ ์‚ฌ์šฉ") + self.enable_check = QCheckBox(tr('onboarding.discord_enable')) layout.addWidget(self.enable_check) self.url_edit = QLineEdit() - self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...") + self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder')) self.url_edit.setEnabled(False) layout.addWidget(self.url_edit) - guide = QLabel( - "์…‹์—… ๋ฐฉ๋ฒ•:\n" - "1. Discord ์„œ๋ฒ„์—์„œ ์ฑ„๋„ ์šฐํด๋ฆญ โ†’ ํŽธ์ง‘ โ†’ ์—ฐ๋™ โ†’ ์›นํ›…\n" - "2. ์ƒˆ ์›นํ›… ๋งŒ๋“ค๊ธฐ โ†’ URL ๋ณต์‚ฌ\n" - "3. ์œ„ ์ž…๋ ฅ๋ž€์— ๋ถ™์—ฌ๋„ฃ๊ธฐ" - ) + guide = QLabel(tr('onboarding.discord_guide')) guide.setStyleSheet("color: #909296; padding: 6px;") guide.setWordWrap(True) layout.addWidget(guide) test_row = QHBoxLayout() - self.test_btn = QPushButton("ํ…Œ์ŠคํŠธ ๋ฉ”์‹œ์ง€ ๋ณด๋‚ด๊ธฐ") + self.test_btn = QPushButton(tr('onboarding.discord_test')) self.test_btn.setEnabled(False) self.test_btn.clicked.connect(self._test_webhook) test_row.addWidget(self.test_btn) @@ -257,39 +243,30 @@ class DiscordPage(QWizardPage): def _test_webhook(self): url = self.url_edit.text().strip() if not url: - QMessageBox.warning(self, "URL ํ•„์š”", "์›นํ›… URL์„ ๋จผ์ € ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.") + QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body')) return from utils import discord_webhook if not discord_webhook.is_valid_webhook_url(url): QMessageBox.warning( - self, "URL ํ˜•์‹ ์˜ค๋ฅ˜", - "Discord ์›นํ›… URL ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.\n" - "์˜ˆ: https://discord.com/api/webhooks/{ID}/{TOKEN}" + self, tr('onboarding.discord_url_invalid_title'), + tr('onboarding.discord_url_invalid_body') ) return ok = discord_webhook.send_test(url) if ok: - QMessageBox.information(self, "์„ฑ๊ณต", "Discord ์ฑ„๋„์—์„œ ํ…Œ์ŠคํŠธ ๋ฉ”์‹œ์ง€๋ฅผ ํ™•์ธํ•˜์„ธ์š”.") + QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body')) else: - QMessageBox.warning(self, "์‹คํŒจ", "์ „์†ก ์‹คํŒจ. URL์„ ๋‹ค์‹œ ํ™•์ธํ•ด์ฃผ์„ธ์š”.") + QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body')) class FinishPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("์ค€๋น„ ์™„๋ฃŒ!") - self.setSubTitle("์ด์ œ ์ถœ๊ทผ๋ถ€ํ„ฐ ์ž๋™ ์ถ”์ ๋ฉ๋‹ˆ๋‹ค.") + self.setTitle(tr('onboarding.finish_title')) + self.setSubTitle(tr('onboarding.finish_subtitle')) layout = QVBoxLayout() - msg = QLabel( - "์„ค์ •ํ•œ ๋‚ด์šฉ์€ [์„ค์ •] ๋ฉ”๋‰ด์—์„œ ์–ธ์ œ๋“  ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n" - "์˜จ๋ณด๋”ฉ์„ ๋‹ค์‹œ ๋ณด๊ณ  ์‹ถ์œผ๋ฉด [๋„์›€๋ง โ†’ ์˜จ๋ณด๋”ฉ ๋‹ค์‹œ ๋ณด๊ธฐ]๋ฅผ ๋ˆ„๋ฅด์„ธ์š”.\n\n" - "๋‹จ์ถ•ํ‚ค:\n" - " โ€ข Ctrl+O โ€” ์ถœํ‡ด๊ทผ ํ† ๊ธ€\n" - " โ€ข F1 โ€” ๋„์›€๋ง\n" - " โ€ข F5 โ€” ์—…๋ฐ์ดํŠธ ํ™•์ธ\n" - " โ€ข Ctrl+, โ€” ์„ค์ •" - ) + msg = QLabel(tr('onboarding.finish_msg')) msg.setWordWrap(True) layout.addWidget(msg) self.setLayout(layout) @@ -301,7 +278,7 @@ class OnboardingWizard(QWizard): def __init__(self, db, parent=None): super().__init__(parent) self.db = db - self.setWindowTitle("Clock-out Calculator โ€” ์‹œ์ž‘ ์„ค์ •") + self.setWindowTitle(tr('onboarding.window_title')) self.setMinimumSize(600, 500) self.setWizardStyle(QWizard.ModernStyle) self.setOption(QWizard.NoBackButtonOnStartPage, True) @@ -323,7 +300,7 @@ class OnboardingWizard(QWizard): # 1. ๊ทผ๋ฌด ํŒจํ„ด wm, lm, dm = self.work_page.selected_minutes() if wm < 30: - QMessageBox.warning(self, "์ž…๋ ฅ ์˜ค๋ฅ˜", "ํ•˜๋ฃจ ๊ทผ๋ฌด๋Š” ์ตœ์†Œ 30๋ถ„ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small')) return settings = { diff --git a/ui/past_record_dialog.py b/ui/past_record_dialog.py index ce6a215..c79fdc8 100644 --- a/ui/past_record_dialog.py +++ b/ui/past_record_dialog.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QMessageBox) from PyQt5.QtCore import QTime, Qt +from core.i18n import tr from ui.styles import apply_dark_titlebar @@ -18,7 +19,7 @@ class PastRecordDialog(QDialog): def __init__(self, parent=None, date_str: str = ''): super().__init__(parent) self.date_str = date_str - self.setWindowTitle(f"๊ธฐ๋ก ์ถ”๊ฐ€ โ€” {date_str}") + self.setWindowTitle(tr('past_record.dialog_title', date=date_str)) self.setModal(True) self.setFixedSize(380, 320) @@ -26,13 +27,13 @@ class PastRecordDialog(QDialog): layout.setSpacing(8) layout.setContentsMargins(20, 16, 20, 16) - info = QLabel(f"{date_str} ๊ทผ๋ฌด ๊ธฐ๋ก์„ ์ž…๋ ฅํ•˜์„ธ์š”.") + info = QLabel(tr('past_record.info', date=date_str)) info.setStyleSheet("font-weight: bold; padding-bottom: 6px;") layout.addWidget(info) # ์ถœ๊ทผ ci_row = QHBoxLayout() - ci_row.addWidget(QLabel("์ถœ๊ทผ:")) + ci_row.addWidget(QLabel(tr('past_record.label_clock_in'))) self.clock_in_edit = QTimeEdit() self.clock_in_edit.setDisplayFormat("HH:mm") self.clock_in_edit.setTime(QTime(9, 0)) @@ -42,8 +43,8 @@ class PastRecordDialog(QDialog): # ํ‡ด๊ทผ co_row = QHBoxLayout() - co_row.addWidget(QLabel("ํ‡ด๊ทผ:")) - self.clock_out_check = QCheckBox("์ž…๋ ฅ") + co_row.addWidget(QLabel(tr('past_record.label_clock_out'))) + self.clock_out_check = QCheckBox(tr('past_record.check_clock_out')) self.clock_out_check.setChecked(True) self.clock_out_edit = QTimeEdit() self.clock_out_edit.setDisplayFormat("HH:mm") @@ -56,18 +57,18 @@ class PastRecordDialog(QDialog): # ์ ์‹ฌ/์ €๋… meal_row = QHBoxLayout() - self.lunch_check = QCheckBox("์ ์‹ฌ์‹œ๊ฐ„ ํฌํ•จ") + self.lunch_check = QCheckBox(tr('past_record.check_lunch')) self.lunch_check.setChecked(True) - self.dinner_check = QCheckBox("์ €๋…์‹œ๊ฐ„ ํฌํ•จ") + self.dinner_check = QCheckBox(tr('past_record.check_dinner')) meal_row.addWidget(self.lunch_check) meal_row.addWidget(self.dinner_check) meal_row.addStretch() layout.addLayout(meal_row) # ๋ฉ”๋ชจ - layout.addWidget(QLabel("๋ฉ”๋ชจ (์„ ํƒ):")) + layout.addWidget(QLabel(tr('past_record.label_memo'))) self.memo_edit = QLineEdit() - self.memo_edit.setPlaceholderText("์˜ˆ: ์žฌํƒ๊ทผ๋ฌด / ์™ธ๊ทผ / ํœด๊ฐ€") + self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder')) layout.addWidget(self.memo_edit) layout.addStretch() @@ -75,10 +76,10 @@ class PastRecordDialog(QDialog): # ๋ฒ„ํŠผ btn_row = QHBoxLayout() btn_row.addStretch() - ok_btn = QPushButton("์ €์žฅ") + ok_btn = QPushButton(tr('btn.save')) ok_btn.setObjectName("btn_primary") ok_btn.clicked.connect(self._validate_and_accept) - cancel_btn = QPushButton("์ทจ์†Œ") + cancel_btn = QPushButton(tr('btn.cancel')) cancel_btn.clicked.connect(self.reject) btn_row.addWidget(ok_btn) btn_row.addWidget(cancel_btn) @@ -92,8 +93,8 @@ class PastRecordDialog(QDialog): ci = self.clock_in_edit.time() co = self.clock_out_edit.time() if co <= ci: - QMessageBox.warning(self, "์ž…๋ ฅ ์˜ค๋ฅ˜", - "ํ‡ด๊ทผ ์‹œ๊ฐ„์ด ์ถœ๊ทผ ์‹œ๊ฐ„๋ณด๋‹ค ๋น ๋ฅด๊ฑฐ๋‚˜ ๊ฐ™์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('past_record.input_error_title'), + tr('past_record.input_error_body')) return self.accept() diff --git a/ui/recurring_leave_dialog.py b/ui/recurring_leave_dialog.py index 90e8f10..6d8ef99 100644 --- a/ui/recurring_leave_dialog.py +++ b/ui/recurring_leave_dialog.py @@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, from PyQt5.QtCore import QDate, Qt from core.recurring_leaves import describe_pattern +from core.i18n import tr from ui.styles import apply_dark_titlebar @@ -27,7 +28,7 @@ class RecurringLeaveDialog(QDialog): def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db - self.setWindowTitle("๋ฐ˜๋ณต ์—ฐ์ฐจ ๊ด€๋ฆฌ") + self.setWindowTitle(tr('recurring.title')) self.setMinimumSize(540, 480) self._build_ui() self._reload_list() @@ -37,29 +38,29 @@ class RecurringLeaveDialog(QDialog): layout = QVBoxLayout() # ๊ธฐ์กด ํŒจํ„ด ๋ชฉ๋ก - list_group = QGroupBox("๋“ฑ๋ก๋œ ๋ฐ˜๋ณต ํŒจํ„ด") + list_group = QGroupBox(tr('recurring.list_group')) lg = QVBoxLayout() self.list_widget = QListWidget() self.list_widget.setMinimumHeight(160) lg.addWidget(self.list_widget) - del_btn = QPushButton("์„ ํƒ ์‚ญ์ œ") + del_btn = QPushButton(tr('recurring.btn_delete_selected')) del_btn.clicked.connect(self._delete_selected) lg.addWidget(del_btn) list_group.setLayout(lg) layout.addWidget(list_group) # ์‹ ๊ทœ ๋“ฑ๋ก - add_group = QGroupBox("์‹ ๊ทœ ํŒจํ„ด ์ถ”๊ฐ€") + add_group = QGroupBox(tr('recurring.add_group')) ag = QVBoxLayout() # ํŒจํ„ด ์ข…๋ฅ˜ kind_row = QHBoxLayout() - kind_row.addWidget(QLabel("์ฃผ๊ธฐ:")) + kind_row.addWidget(QLabel(tr('recurring.label_cycle'))) self.kind_group = QButtonGroup(self) - self.rb_weekly = QRadioButton("๋งค์ฃผ") + self.rb_weekly = QRadioButton(tr('recurring.weekly')) self.rb_weekly.setChecked(True) - self.rb_biweekly = QRadioButton("๊ฒฉ์ฃผ") - self.rb_monthly = QRadioButton("๋งค์›” N์ผ") + self.rb_biweekly = QRadioButton(tr('recurring.biweekly')) + self.rb_monthly = QRadioButton(tr('recurring.monthly')) for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly): self.kind_group.addButton(rb) kind_row.addWidget(rb) @@ -68,10 +69,10 @@ class RecurringLeaveDialog(QDialog): # ์š”์ผ ์ฒดํฌ๋ฐ•์Šค (weekly/biweekly) wd_row = QHBoxLayout() - wd_row.addWidget(QLabel("์š”์ผ:")) + wd_row.addWidget(QLabel(tr('recurring.label_weekday'))) self.weekday_checks = [] for ko, en in _KO_WEEKDAYS: - cb = QCheckBox(ko) + cb = QCheckBox(tr(f'label.weekday_{en}')) self.weekday_checks.append((cb, en)) wd_row.addWidget(cb) wd_row.addStretch() @@ -79,40 +80,40 @@ class RecurringLeaveDialog(QDialog): # ๋งค์›” N์ผ month_row = QHBoxLayout() - month_row.addWidget(QLabel("๋งค์›”:")) + month_row.addWidget(QLabel(tr('recurring.label_monthly_day'))) self.day_of_month = QSpinBox() self.day_of_month.setRange(1, 31) self.day_of_month.setValue(15) - self.day_of_month.setSuffix("์ผ") + self.day_of_month.setSuffix(tr('recurring.day_suffix')) month_row.addWidget(self.day_of_month) month_row.addStretch() ag.addLayout(month_row) # ์ฐจ๊ฐ ์ผ์ˆ˜ days_row = QHBoxLayout() - days_row.addWidget(QLabel("์ฐจ๊ฐ:")) + days_row.addWidget(QLabel(tr('recurring.label_deduction'))) self.days_combo = QComboBox() - self.days_combo.addItem("1.0์ผ (์ข…์ผ)", 1.0) - self.days_combo.addItem("0.5์ผ (๋ฐ˜์ฐจ)", 0.5) - self.days_combo.addItem("0.25์ผ (๋ฐ˜๋ฐ˜์ฐจ)", 0.25) + self.days_combo.addItem(tr('recurring.deduction_full'), 1.0) + self.days_combo.addItem(tr('recurring.deduction_half'), 0.5) + self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25) days_row.addWidget(self.days_combo) days_row.addStretch() ag.addLayout(days_row) # ์‹œ์ž‘/์ข…๋ฃŒ ๋‚ ์งœ date_row = QHBoxLayout() - date_row.addWidget(QLabel("์‹œ์ž‘:")) + date_row.addWidget(QLabel(tr('recurring.label_start'))) self.start_edit = QDateEdit() self.start_edit.setDate(QDate.currentDate()) self.start_edit.setCalendarPopup(True) date_row.addWidget(self.start_edit) - date_row.addWidget(QLabel("์ข…๋ฃŒ:")) + date_row.addWidget(QLabel(tr('recurring.label_end'))) self.end_edit = QDateEdit() self.end_edit.setDate(QDate.currentDate().addMonths(6)) self.end_edit.setCalendarPopup(True) date_row.addWidget(self.end_edit) - self.no_end_check = QCheckBox("์ข…๋ฃŒ ์—†์Œ (๋ฌด๊ธฐํ•œ)") + self.no_end_check = QCheckBox(tr('recurring.no_end')) self.no_end_check.toggled.connect( lambda v: self.end_edit.setEnabled(not v) ) @@ -122,14 +123,14 @@ class RecurringLeaveDialog(QDialog): # ๋ฉ”๋ชจ memo_row = QHBoxLayout() - memo_row.addWidget(QLabel("๋ฉ”๋ชจ:")) + memo_row.addWidget(QLabel(tr('recurring.label_memo'))) self.memo_edit = QLineEdit() - self.memo_edit.setPlaceholderText("์˜ˆ: ์œก์•„ ๋‹จ์ถ•๊ทผ๋ฌด") + self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder')) memo_row.addWidget(self.memo_edit) ag.addLayout(memo_row) # ์ถ”๊ฐ€ ๋ฒ„ํŠผ - add_btn = QPushButton("์ถ”๊ฐ€") + add_btn = QPushButton(tr('recurring.btn_add')) add_btn.setObjectName("btn_primary") add_btn.clicked.connect(self._save) ag.addWidget(add_btn) @@ -138,7 +139,7 @@ class RecurringLeaveDialog(QDialog): layout.addWidget(add_group) # ๋‹ซ๊ธฐ - close_btn = QPushButton("๋‹ซ๊ธฐ") + close_btn = QPushButton(tr('btn.close')) close_btn.clicked.connect(self.close) layout.addWidget(close_btn) @@ -148,7 +149,7 @@ class RecurringLeaveDialog(QDialog): self.list_widget.clear() for r in self.db.get_recurring_leaves(): desc = describe_pattern(r['pattern']) - end = r.get('end_date') or '๋ฌด๊ธฐํ•œ' + end = r.get('end_date') or tr('recurring.no_end') text = (f"[{r['id']}] {desc} ยท {r['days']}์ผ ({r['leave_type']}) " f"ยท {r['start_date']} ~ {end}") if r.get('memo'): @@ -163,8 +164,8 @@ class RecurringLeaveDialog(QDialog): return rec_id = item.data(Qt.UserRole) reply = QMessageBox.question( - self, "์‚ญ์ œ ํ™•์ธ", - f"์ด ๋ฐ˜๋ณต ํŒจํ„ด์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n{item.text()}", + self, tr('recurring.delete_confirm_title'), + tr('recurring.delete_confirm_body', item=item.text()), QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: @@ -184,7 +185,7 @@ class RecurringLeaveDialog(QDialog): def _save(self): pattern = self._build_pattern() if not pattern: - QMessageBox.warning(self, "์ž…๋ ฅ ์˜ค๋ฅ˜", "์ตœ์†Œ ํ•œ ๊ฐœ ์š”์ผ์„ ์„ ํƒํ•˜์„ธ์š”.") + QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday')) return days = self.days_combo.currentData() leave_type = self.days_combo.currentText().split(' ')[1].strip('()') @@ -193,7 +194,7 @@ class RecurringLeaveDialog(QDialog): memo = self.memo_edit.text().strip() self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo) - QMessageBox.information(self, "์ถ”๊ฐ€ ์™„๋ฃŒ", - f"๋ฐ˜๋ณต ํŒจํ„ด์ด ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{describe_pattern(pattern)}") + QMessageBox.information(self, tr('recurring.add_done_title'), + tr('recurring.add_done_body', pattern=describe_pattern(pattern))) self.memo_edit.clear() self._reload_list() diff --git a/ui/schedule_view.py b/ui/schedule_view.py index 863ea70..18da8b8 100644 --- a/ui/schedule_view.py +++ b/ui/schedule_view.py @@ -18,6 +18,7 @@ from PyQt5.QtCore import Qt, QDate from PyQt5.QtGui import QTextCharFormat, QColor, QBrush from core.recurring_leaves import expand_for_range, describe_pattern +from core.i18n import tr from ui.styles import apply_dark_titlebar @@ -38,7 +39,7 @@ class ScheduleView(QDialog): def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db - self.setWindowTitle("์Šค์ผ€์ค„") + self.setWindowTitle(tr('schedule.title')) self.setMinimumSize(820, 560) self._build_ui() self._reload() @@ -49,16 +50,16 @@ class ScheduleView(QDialog): # ์ƒ๋‹จ ํˆด๋ฐ” bar = QHBoxLayout() - title = QLabel("์›”๊ฐ„ ์Šค์ผ€์ค„ โ€” ํœด์ผ + ์—ฐ์ฐจ + ๋ฐ˜๋ณต ํŒจํ„ด") + title = QLabel(tr('schedule.header')) title.setStyleSheet("font-weight: bold; font-size: 13px;") bar.addWidget(title) bar.addStretch() - rec_btn = QPushButton("๋ฐ˜๋ณต ํŒจํ„ด ๊ด€๋ฆฌ") + rec_btn = QPushButton(tr('schedule.btn_recurring')) rec_btn.clicked.connect(self._open_recurring_dialog) bar.addWidget(rec_btn) - add_btn = QPushButton("์—ฐ์ฐจ ๋“ฑ๋ก") + add_btn = QPushButton(tr('schedule.btn_add_leave')) add_btn.clicked.connect(self._open_add_leave_dialog) bar.addWidget(add_btn) @@ -66,11 +67,11 @@ class ScheduleView(QDialog): # ๋ฒ”๋ก€ legend = QHBoxLayout() - for label, color in [("๊ณตํœด์ผ", _C_HOLIDAY), - ("์—ฐ์ฐจ ์‚ฌ์šฉ", _C_LEAVE_FULL_PAST), - ("์—ฐ์ฐจ ์˜ˆ์ •", _C_LEAVE_FULL_PLAN), - ("๋ฐ˜์ฐจ/๋ฐ˜๋ฐ˜์ฐจ", _C_LEAVE_HALF_PAST), - ("๋ฐ˜๋ณต ํŒจํ„ด", _C_RECURRING)]: + for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY), + (tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST), + (tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN), + (tr('schedule.legend_half'), _C_LEAVE_HALF_PAST), + (tr('schedule.legend_recurring'), _C_RECURRING)]: sw = QLabel(f" {label} ") sw.setStyleSheet( f"background-color: {color.name()}; color: white; " @@ -94,7 +95,7 @@ class ScheduleView(QDialog): right = QWidget() right_layout = QVBoxLayout() - self.detail_title = QLabel("๋‚ ์งœ๋ฅผ ์„ ํƒํ•˜์„ธ์š”") + self.detail_title = QLabel(tr('schedule.detail_placeholder')) self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;") right_layout.addWidget(self.detail_title) @@ -109,7 +110,7 @@ class ScheduleView(QDialog): layout.addWidget(splitter, 1) - close_btn = QPushButton("๋‹ซ๊ธฐ") + close_btn = QPushButton(tr('btn.close')) close_btn.clicked.connect(self.close) layout.addWidget(close_btn) @@ -189,18 +190,19 @@ class ScheduleView(QDialog): def _on_date_click(self, qd: QDate): d = date(qd.year(), qd.month(), qd.day()) date_str = d.isoformat() - weekday_kr = ['์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ', '์ผ'] - self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}์š”์ผ)") + weekday_kr = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'), + tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), tr('label.weekday_sun')] + self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})") self.detail_list.clear() # ํœด์ผ holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None if holiday: - item = QListWidgetItem(f"๊ณตํœด์ผ: {holiday.get('name', '๊ณตํœด์ผ')}") + item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default')))) item.setForeground(QBrush(QColor("#e53935"))) self.detail_list.addItem(item) elif d.weekday() in (5, 6): - item = QListWidgetItem(f"์ฃผ๋ง ({weekday_kr[d.weekday()]}์š”์ผ)") + item = QListWidgetItem(tr('schedule.weekend', weekday=weekday_kr[d.weekday()])) self.detail_list.addItem(item) # ์—ฐ์ฐจ (๊ตฌ์ฒด) @@ -208,7 +210,7 @@ class ScheduleView(QDialog): days = float(r.get('days') or 0) t = r.get('leave_type', '์—ฐ์ฐจ') memo = r.get('memo') or '' - label = f"{t} {days}์ผ" + label = tr('schedule.leave_label', type=t, days=days) if memo: label += f" โ€” {memo}" label += f" [id={r['id']}]" @@ -221,13 +223,14 @@ class ScheduleView(QDialog): from core.recurring_leaves import expand_for_date for occ in expand_for_date(recurring, d): item = QListWidgetItem( - f"{describe_pattern(occ.pattern)} ยท {occ.days}์ผ ({occ.leave_type})" + tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern), + days=occ.days, type=occ.leave_type) ) item.setData(Qt.UserRole, ('recurring', occ.recurring_id)) self.detail_list.addItem(item) if self.detail_list.count() == 0: - self.detail_list.addItem("์ผ์ • ์—†์Œ") + self.detail_list.addItem(tr('schedule.no_events')) def _on_page_change(self, year: int, month: int): self._reload() @@ -241,7 +244,7 @@ class ScheduleView(QDialog): return kind, _id = data menu = QMenu(self) - del_act = menu.addAction("์‚ญ์ œ") + del_act = menu.addAction(tr('schedule.delete')) chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos)) if chosen == del_act: self._delete_record(kind, _id) @@ -249,8 +252,8 @@ class ScheduleView(QDialog): def _delete_record(self, kind: str, _id: int): if kind == 'concrete': reply = QMessageBox.question( - self, "์‚ญ์ œ ํ™•์ธ", - "์ด ์—ฐ์ฐจ ๊ธฐ๋ก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (์ž”์•ก์ด ์ž๋™ ๋ณต๊ตฌ๋ฉ๋‹ˆ๋‹ค.)", + self, tr('schedule.delete_leave_confirm_title'), + tr('schedule.delete_leave_confirm_body'), QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: @@ -261,8 +264,8 @@ class ScheduleView(QDialog): self._on_date_click(d) elif kind == 'recurring': reply = QMessageBox.question( - self, "์‚ญ์ œ ํ™•์ธ", - "์ด ๋ฐ˜๋ณต ํŒจํ„ด์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (์ดํ›„ ๋ชจ๋“  ์ธ์Šคํ„ด์Šค ์ œ๊ฑฐ)", + self, tr('schedule.delete_recurring_confirm_title'), + tr('schedule.delete_recurring_confirm_body'), QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: diff --git a/ui/settings_view.py b/ui/settings_view.py index d353a8a..cd2c7b8 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -58,7 +58,7 @@ class SettingsView(QDialog): main_layout.setSpacing(0) # ์ œ๋ชฉ - title = QLabel("์„ค์ •") + title = QLabel(tr('settings.title')) title.setObjectName("dialog_title") title.setAlignment(Qt.AlignCenter) main_layout.addWidget(title) @@ -137,16 +137,16 @@ class SettingsView(QDialog): # ๊ทผ๋ฌด ํŒจํ„ด ํ”„๋ฆฌ์…‹ preset_layout = QHBoxLayout() - preset_label = QLabel("๊ทผ๋ฌด ํŒจํ„ด:") + preset_label = QLabel(tr('settings.work_pattern')) preset_label.setFixedWidth(130) self.work_preset_combo = QComboBox() # (label, work_minutes, lunch_minutes) - self.work_preset_combo.addItem("ํ‘œ์ค€ 8์‹œ๊ฐ„ (์ ์‹ฌ 60๋ถ„)", (480, 60)) - self.work_preset_combo.addItem("๋‹จ์ถ•๊ทผ๋ฌด 7์‹œ๊ฐ„ 30๋ถ„ (์ ์‹ฌ 30๋ถ„)", (450, 30)) - self.work_preset_combo.addItem("๋‹จ์ถ•๊ทผ๋ฌด 7์‹œ๊ฐ„ (์ ์‹ฌ 60๋ถ„)", (420, 60)) - self.work_preset_combo.addItem("๋‹จ์ถ•๊ทผ๋ฌด 6์‹œ๊ฐ„ (์ ์‹ฌ 30๋ถ„)", (360, 30)) - self.work_preset_combo.addItem("๋ฐ˜์ผ 4์‹œ๊ฐ„ (์ ์‹ฌ 0๋ถ„)", (240, 0)) - self.work_preset_combo.addItem("์‚ฌ์šฉ์ž ์ •์˜", None) + self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60)) + self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30)) + self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60)) + self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30)) + self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0)) + self.work_preset_combo.addItem(tr('settings.preset.custom'), None) self.work_preset_combo.setFixedWidth(260) self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed) preset_layout.addWidget(preset_label) @@ -156,18 +156,18 @@ class SettingsView(QDialog): # ํ•˜๋ฃจ ๊ธฐ๋ณธ ๊ทผ๋ฌด ์‹œ๊ฐ„ (์‹œ + ๋ถ„ ๋ถ„๋ฆฌ ์ž…๋ ฅ) work_hours_layout = QHBoxLayout() - work_hours_label = QLabel("ํ•˜๋ฃจ ๊ธฐ๋ณธ ๊ทผ๋ฌด:") + work_hours_label = QLabel(tr('settings.daily_work')) work_hours_label.setFixedWidth(130) self.work_hours_spin = QSpinBox() self.work_hours_spin.setRange(0, 12) self.work_hours_spin.setValue(8) - self.work_hours_spin.setSuffix(" ์‹œ๊ฐ„") + self.work_hours_spin.setSuffix(tr('settings.suffix_hour')) self.work_hours_spin.setFixedWidth(100) self.work_minutes_spin = QSpinBox() self.work_minutes_spin.setRange(0, 59) self.work_minutes_spin.setValue(0) self.work_minutes_spin.setSingleStep(15) - self.work_minutes_spin.setSuffix(" ๋ถ„") + self.work_minutes_spin.setSuffix(tr('settings.suffix_minute')) self.work_minutes_spin.setFixedWidth(100) # ์‚ฌ์šฉ์ž๊ฐ€ ์‹œ๊ฐ„/๋ถ„ ์ง์ ‘ ๋ณ€๊ฒฝ ์‹œ ํ”„๋ฆฌ์…‹์„ "์‚ฌ์šฉ์ž ์ •์˜"๋กœ self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit) @@ -180,34 +180,34 @@ class SettingsView(QDialog): # ์ ์‹ฌ์‹œ๊ฐ„ ๊ธฐ๋ณธ๊ฐ’ lunch_layout = QHBoxLayout() - lunch_label = QLabel("์ ์‹ฌ์‹œ๊ฐ„ ๊ธฐ๋ณธ:") + lunch_label = QLabel(tr('settings.lunch_default')) lunch_label.setFixedWidth(130) self.lunch_spin = QSpinBox() self.lunch_spin.setRange(0, 120) self.lunch_spin.setValue(60) self.lunch_spin.setSingleStep(5) - self.lunch_spin.setSuffix(" ๋ถ„") + self.lunch_spin.setSuffix(tr('settings.suffix_minute')) self.lunch_spin.setFixedWidth(110) self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit) lunch_layout.addWidget(lunch_label) lunch_layout.addWidget(self.lunch_spin) # ์ ์‹ฌ์‹œ๊ฐ„ ์ž๋™ ์ ์šฉ - self.auto_lunch_check = QCheckBox("์ž๋™ ์ ์šฉ") - self.auto_lunch_check.setToolTip("์ถœ๊ทผ ํ›„ 4์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ž๋™ ์ ์šฉ") + self.auto_lunch_check = QCheckBox(tr('settings.auto_apply')) + self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip')) lunch_layout.addWidget(self.auto_lunch_check) lunch_layout.addStretch() layout.addLayout(lunch_layout) # ์ €๋…์‹œ๊ฐ„ ๊ธฐ๋ณธ๊ฐ’ dinner_layout = QHBoxLayout() - dinner_label = QLabel("์ €๋…์‹œ๊ฐ„ ๊ธฐ๋ณธ:") + dinner_label = QLabel(tr('settings.dinner_default')) dinner_label.setFixedWidth(130) self.dinner_spin = QSpinBox() self.dinner_spin.setRange(0, 120) self.dinner_spin.setValue(60) self.dinner_spin.setSingleStep(5) - self.dinner_spin.setSuffix(" ๋ถ„") + self.dinner_spin.setSuffix(tr('settings.suffix_minute')) self.dinner_spin.setFixedWidth(110) dinner_layout.addWidget(dinner_label) dinner_layout.addWidget(self.dinner_spin) @@ -306,30 +306,30 @@ class SettingsView(QDialog): # ์•Œ๋ฆผ ์ฒดํฌ๋ฐ•์Šค๋“ค์„ 3ํ–‰์œผ๋กœ ๋ฐฐ์น˜ (์ €๋… ์•Œ๋ฆผ ์ถ”๊ฐ€๋กœ 5๊ฐœ) check_row1 = QHBoxLayout() - self.clock_out_notification_check = QCheckBox("ํ‡ด๊ทผ 30๋ถ„ ์ „ ์•Œ๋ฆผ") + self.clock_out_notification_check = QCheckBox(tr('settings.notif_clock_out')) self.clock_out_notification_check.setChecked(True) - self.lunch_notification_check = QCheckBox("์ ์‹ฌ์‹œ๊ฐ„ ๋“ฑ๋ก ์•Œ๋ฆผ") + self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch')) self.lunch_notification_check.setChecked(True) check_row1.addWidget(self.clock_out_notification_check) check_row1.addWidget(self.lunch_notification_check) layout.addLayout(check_row1) check_row2 = QHBoxLayout() - self.dinner_notification_check = QCheckBox("์ €๋…์‹œ๊ฐ„ ๋“ฑ๋ก ์•Œ๋ฆผ") + self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner')) self.dinner_notification_check.setChecked(True) - self.overtime_notification_check = QCheckBox("์—ฐ์žฅ๊ทผ๋ฌด ์ ๋ฆฝ ์•Œ๋ฆผ") + self.overtime_notification_check = QCheckBox(tr('settings.notif_overtime')) self.overtime_notification_check.setChecked(True) check_row2.addWidget(self.dinner_notification_check) check_row2.addWidget(self.overtime_notification_check) layout.addLayout(check_row2) check_row3 = QHBoxLayout() - self.health_notification_check = QCheckBox("๊ฑด๊ฐ• ๊ฒฝ๊ณ  ์•Œ๋ฆผ") + self.health_notification_check = QCheckBox(tr('settings.notif_health')) self.health_notification_check.setChecked(True) - self.health_break_notification_check = QCheckBox("ํœด์‹ ๊ถŒ๊ณ  ์•Œ๋ฆผ") + self.health_break_notification_check = QCheckBox(tr('settings.notif_break')) self.health_break_notification_check.setChecked(True) self.health_break_notification_check.setToolTip( - "์˜ค๋žœ ์‹œ๊ฐ„ ์ž๋ฆฌ์—์„œ ์ผํ•˜๋ฉด ์ŠคํŠธ๋ ˆ์นญ์„ ๊ถŒ์œ  (์—ฐ์† ๊ทผ๋ฌด N์‹œ๊ฐ„ ๊ธฐ์ค€)" + tr('settings.notif_break_tooltip') ) check_row3.addWidget(self.health_notification_check) check_row3.addWidget(self.health_break_notification_check) @@ -337,75 +337,75 @@ class SettingsView(QDialog): # ํ‡ด๊ทผ N๋ถ„ ์ „ ์•Œ๋ฆผ ์‹œ์  ์„ค์ • before_row = QHBoxLayout() - before_label = QLabel("ํ‡ด๊ทผ ์•Œ๋ฆผ ์‹œ์ :") + before_label = QLabel(tr('settings.notif_before')) before_label.setFixedWidth(110) self.notif_before_spin = QSpinBox() self.notif_before_spin.setRange(1, 120) self.notif_before_spin.setSingleStep(5) self.notif_before_spin.setValue(30) - self.notif_before_spin.setSuffix(" ๋ถ„ ์ „") + self.notif_before_spin.setSuffix(' ' + tr('settings.notif_before_spin_suffix')) self.notif_before_spin.setFixedWidth(110) - self.notif_before_spin.setToolTip("ํ‡ด๊ทผ ์ž„๋ฐ• ์•Œ๋ฆผ์ด ํ‘œ์‹œ๋  ์‹œ์  (๋ถ„ ๋‹จ์œ„)") + self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip')) before_row.addWidget(before_label) before_row.addWidget(self.notif_before_spin) before_row.addStretch() layout.addLayout(before_row) # === ๊ณ ๊ธ‰ ์ž„๊ณ„๊ฐ’ (์ ‘์ด์‹ ๊ทธ๋ฃน๋ฐ•์Šค) === - adv_box = QGroupBox("๊ณ ๊ธ‰ ์ž„๊ณ„๊ฐ’") + adv_box = QGroupBox(tr('settings.advanced_thresholds')) adv_box.setCheckable(True) adv_box.setChecked(False) # ๊ธฐ๋ณธ ์ ‘ํž˜ - adv_box.setToolTip("ํšŒ์‚ฌ ์ •์ฑ…ยท๊ฐœ์ธ ์„ ํ˜ธ์— ๋งž์ถฐ ์•Œ๋ฆผ ๋ฐœ์ƒ ์‹œ์  ์กฐ์ •") + adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip')) adv_layout = QVBoxLayout() adv_layout.setSpacing(4) # ์ ์‹ฌ ์•Œ๋ฆผ ์ž„๊ณ„ (์ถœ๊ทผ ํ›„ N์‹œ๊ฐ„) - self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " ์‹œ๊ฐ„") - self.lunch_reminder_spin.setToolTip("์ถœ๊ทผ ํ›„ N์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ ์‹ฌ ๋ฏธ๋“ฑ๋ก ์•Œ๋ฆผ") - adv_layout.addLayout(self._labeled_row("์ ์‹ฌ ์•Œ๋ฆผ (์ถœ๊ทผ +):", self.lunch_reminder_spin)) + self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour')) + self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip')) + adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin)) # ์ €๋… ์•Œ๋ฆผ ์ž„๊ณ„ (์ถœ๊ทผ ํ›„ N์‹œ๊ฐ„) - self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " ์‹œ๊ฐ„") - self.dinner_reminder_spin.setToolTip("์ถœ๊ทผ ํ›„ N์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ €๋… ๋ฏธ๋“ฑ๋ก ์•Œ๋ฆผ") - adv_layout.addLayout(self._labeled_row("์ €๋… ์•Œ๋ฆผ (์ถœ๊ทผ +):", self.dinner_reminder_spin)) + self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour')) + self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip')) + adv_layout.addLayout(self._labeled_row(tr('settings.dinner_alert_after'), self.dinner_reminder_spin)) # ์—ฐ์žฅ๊ทผ๋ฌด ๋ˆ„์  ์ž„๊ณ„ - self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " ์‹œ๊ฐ„") - self.overtime_threshold_spin.setToolTip("์—ฐ์žฅ๊ทผ๋ฌด ์ž”์•ก์ด N์‹œ๊ฐ„ ์ด์ƒ์ด๋ฉด ์•Œ๋ฆผ") - adv_layout.addLayout(self._labeled_row("์—ฐ์žฅ ๋ˆ„์  ์•Œ๋ฆผ:", self.overtime_threshold_spin)) + self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour')) + self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip')) + adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin)) # ์ฃผ X์‹œ๊ฐ„ ์ž„๊ณ„ - self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " ์‹œ๊ฐ„") - self.weekly_hours_spin.setToolTip("์ฃผ๊ฐ„ ์ด ๊ทผ๋ฌด๊ฐ€ N์‹œ๊ฐ„ ์ดˆ๊ณผ ์‹œ ๊ฒฝ๊ณ  (ํ•œ๊ตญ ๋…ธ๋™๋ฒ• ๊ธฐ๋ณธ 52)") - adv_layout.addLayout(self._labeled_row("์ฃผ๊ฐ„ ํ•œ๋„ ๊ฒฝ๊ณ :", self.weekly_hours_spin)) + self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour')) + self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip')) + adv_layout.addLayout(self._labeled_row(tr('settings.weekly_limit'), self.weekly_hours_spin)) # ์—ฐ์† ์—ฐ์žฅ๊ทผ๋ฌด ์ผ์ˆ˜ - self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, " ์ผ") - self.health_consecutive_spin.setToolTip("N์ผ ์ด์ƒ ์—ฐ์† ์—ฐ์žฅ๊ทผ๋ฌด ์‹œ ๊ฑด๊ฐ• ๊ฒฝ๊ณ ") - adv_layout.addLayout(self._labeled_row("์—ฐ์† ์—ฐ์žฅ ๊ฒฝ๊ณ :", self.health_consecutive_spin)) + self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day')) + self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback='')) + adv_layout.addLayout(self._labeled_row(tr('settings.consecutive_ot'), self.health_consecutive_spin)) # ํœด์‹ ๊ถŒ๊ณ  (์—ฐ์† ๊ทผ๋ฌด ์‹œ๊ฐ„) - self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " ์‹œ๊ฐ„") - self.health_break_hours_spin.setToolTip("์—ฐ์† ๊ทผ๋ฌด N์‹œ๊ฐ„ ๊ฒฝ๊ณผ ์‹œ ์ŠคํŠธ๋ ˆ์นญ ๊ถŒ์œ ") - adv_layout.addLayout(self._labeled_row("ํœด์‹ ๊ถŒ๊ณ  ์‹œ์ :", self.health_break_hours_spin)) + self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour')) + self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip')) + adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin)) adv_box.setLayout(adv_layout) layout.addWidget(adv_box) # ์‹œ๊ฐ„ ํ˜•์‹ + ํ…Œ๋งˆ ํ•œ ์ค„์— format_row = QHBoxLayout() - time_format_label = QLabel("์‹œ๊ฐ„ ํ˜•์‹:") + time_format_label = QLabel(tr('settings.time_format')) time_format_label.setFixedWidth(70) self.time_format_combo = QComboBox() - self.time_format_combo.addItem("24์‹œ๊ฐ„ (17:30)", "24") - self.time_format_combo.addItem("์˜ค์ „/์˜คํ›„ (์˜คํ›„ 5:30)", "12") + self.time_format_combo.addItem(tr('settings.time_format_24'), "24") + self.time_format_combo.addItem(tr('settings.time_format_12'), "12") self.time_format_combo.setFixedWidth(180) - theme_label = QLabel("ํ…Œ๋งˆ:") + theme_label = QLabel(tr('settings.theme')) theme_label.setFixedWidth(40) self.theme_combo = QComboBox() - self.theme_combo.addItem("๋ผ์ดํŠธ", "light") - self.theme_combo.addItem("๋‹คํฌ", "dark") + self.theme_combo.addItem(tr('settings.theme_light'), "light") + self.theme_combo.addItem(tr('settings.theme_dark'), "dark") self.theme_combo.setFixedWidth(90) self.theme_combo.currentIndexChanged.connect(self.on_theme_changed) @@ -419,7 +419,7 @@ class SettingsView(QDialog): # ์ ‘๊ทผ์„ฑ: ๊ธ€๊ผด ํฌ๊ธฐ + ๊ณ ๋Œ€๋น„ a11y_row = QHBoxLayout() - a11y_row.addWidget(QLabel("๊ธ€๊ผด ํฌ๊ธฐ:")) + a11y_row.addWidget(QLabel(tr('settings.font_scale'))) self.font_scale_combo = QComboBox() self.font_scale_combo.addItem("100%", "1.0") self.font_scale_combo.addItem("125%", "1.25") @@ -427,8 +427,8 @@ class SettingsView(QDialog): self.font_scale_combo.setFixedWidth(90) a11y_row.addWidget(self.font_scale_combo) a11y_row.addSpacing(16) - self.high_contrast_check = QCheckBox("๊ณ ๋Œ€๋น„ ๋ชจ๋“œ") - self.high_contrast_check.setToolTip("๊ฒ€์ • ๋ฐฐ๊ฒฝ + ๋…ธ๋ž€ ํ…์ŠคํŠธ (์‹œ๊ฐ์•ฝ์ž/์•ผ๊ฐ„)") + self.high_contrast_check = QCheckBox(tr('settings.high_contrast')) + self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip')) a11y_row.addWidget(self.high_contrast_check) a11y_row.addStretch() layout.addLayout(a11y_row) @@ -436,13 +436,13 @@ class SettingsView(QDialog): # ์–ธ์–ด ์„ ํƒ from core.i18n import available_languages, language_label lang_row = QHBoxLayout() - lang_label = QLabel("์–ธ์–ด / Language:") + lang_label = QLabel(tr('label.language')) lang_label.setFixedWidth(120) self.language_combo = QComboBox() for code in available_languages(): self.language_combo.addItem(language_label(code), code) self.language_combo.setFixedWidth(140) - self.language_combo.setToolTip("์–ธ์–ด ๋ณ€๊ฒฝ์€ ์žฌ์‹œ์ž‘ ํ›„ ์™„์ „ํžˆ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.") + self.language_combo.setToolTip(tr('group.language_restart_tooltip', fallback='')) lang_row.addWidget(lang_label) lang_row.addWidget(self.language_combo) lang_row.addStretch() @@ -459,16 +459,16 @@ class SettingsView(QDialog): # ์ž”์•ก + ๊ณ„์‚ฐ ๋‹จ์œ„ ํ•œ ์ค„ top_row = QHBoxLayout() - self.current_overtime_label = QLabel("ํ˜„์žฌ ์ž”์•ก: ๊ณ„์‚ฐ ์ค‘...") + self.current_overtime_label = QLabel(tr('settings.current_balance')) self.current_overtime_label.setObjectName("badge_success") top_row.addWidget(self.current_overtime_label) top_row.addStretch() - unit_label = QLabel("๊ณ„์‚ฐ ๋‹จ์œ„:") + unit_label = QLabel(tr('settings.calc_unit')) self.overtime_unit_combo = QComboBox() - self.overtime_unit_combo.addItem("30๋ถ„", 30) - self.overtime_unit_combo.addItem("1์‹œ๊ฐ„", 60) - self.overtime_unit_combo.addItem("15๋ถ„", 15) + self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30) + self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60) + self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15) self.overtime_unit_combo.setFixedWidth(100) top_row.addWidget(unit_label) top_row.addWidget(self.overtime_unit_combo) @@ -476,28 +476,28 @@ class SettingsView(QDialog): # ์ดˆ๊ธฐ ์—ฐ์žฅ๊ทผ๋ฌด ์„ค์ • initial_overtime_layout = QHBoxLayout() - initial_overtime_label = QLabel("๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด:") + initial_overtime_label = QLabel(tr('settings.initial_overtime')) initial_overtime_label.setFixedWidth(100) self.initial_overtime_hours = QSpinBox() self.initial_overtime_hours.setRange(0, 200) self.initial_overtime_hours.setValue(0) - self.initial_overtime_hours.setSuffix(" ์‹œ๊ฐ„") + self.initial_overtime_hours.setSuffix(tr('settings.suffix_hour')) self.initial_overtime_hours.setFixedWidth(110) self.initial_overtime_mins = QSpinBox() self.initial_overtime_mins.setRange(0, 59) self.initial_overtime_mins.setValue(0) - self.initial_overtime_mins.setSuffix(" ๋ถ„") + self.initial_overtime_mins.setSuffix(tr('settings.suffix_minute')) self.initial_overtime_mins.setFixedWidth(100) - apply_overtime_btn = QPushButton("์ ์šฉ") + apply_overtime_btn = QPushButton(tr('btn.apply')) apply_overtime_btn.setObjectName("btn_small") apply_overtime_btn.setFixedWidth(50) apply_overtime_btn.clicked.connect(self.apply_initial_overtime) - self.auto_overtime_check = QCheckBox("์ž๋™ ์ ๋ฆฝ") + self.auto_overtime_check = QCheckBox(tr('settings.auto_bank')) self.auto_overtime_check.setChecked(True) - self.auto_overtime_check.setToolTip("ํ‡ด๊ทผ ์‹œ ์—ฐ์žฅ๊ทผ๋ฌด ์ž๋™ ์ ๋ฆฝ") + self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip')) initial_overtime_layout.addWidget(initial_overtime_label) initial_overtime_layout.addWidget(self.initial_overtime_hours) @@ -507,7 +507,7 @@ class SettingsView(QDialog): initial_overtime_layout.addWidget(self.auto_overtime_check) layout.addLayout(initial_overtime_layout) - initial_overtime_note = QLabel("โ€ป ํ”„๋กœ๊ทธ๋žจ ์‚ฌ์šฉ ์ „ ์Œ“์ธ ์—ฐ์žฅ๊ทผ๋ฌด ์‹œ๊ฐ„ (์ ˆ๋Œ€๊ฐ’)") + initial_overtime_note = QLabel(tr('settings.initial_overtime_note', fallback='')) initial_overtime_note.setObjectName("note_text") layout.addWidget(initial_overtime_note) @@ -516,22 +516,22 @@ class SettingsView(QDialog): def create_goal_group(self) -> QGroupBox: """์›”๊ฐ„ ๋ชฉํ‘œ ์„ค์ • ๊ทธ๋ฃน (0=๋น„ํ™œ์„ฑ).""" - group = QGroupBox("์›”๊ฐ„ ๋ชฉํ‘œ (0=๋น„ํ™œ์„ฑ)") + group = QGroupBox(tr('settings.goal_group')) layout = QVBoxLayout() layout.setSpacing(6) # ์—ฐ์žฅ๊ทผ๋ฌด ์ƒํ•œ ot_row = QHBoxLayout() - ot_label = QLabel("์›” ์—ฐ์žฅ๊ทผ๋ฌด ์ƒํ•œ:") + ot_label = QLabel(tr('settings.monthly_ot_cap')) ot_label.setFixedWidth(150) self.goal_ot_h = QSpinBox() self.goal_ot_h.setRange(0, 100) - self.goal_ot_h.setSuffix(" ์‹œ๊ฐ„") + self.goal_ot_h.setSuffix(tr('settings.suffix_hour')) self.goal_ot_h.setFixedWidth(100) self.goal_ot_m = QSpinBox() self.goal_ot_m.setRange(0, 59) self.goal_ot_m.setSingleStep(30) - self.goal_ot_m.setSuffix(" ๋ถ„") + self.goal_ot_m.setSuffix(tr('settings.suffix_minute')) self.goal_ot_m.setFixedWidth(90) ot_row.addWidget(ot_label) ot_row.addWidget(self.goal_ot_h) @@ -541,18 +541,18 @@ class SettingsView(QDialog): # ์ผํ‰๊ท  ๋ชฉํ‘œ avg_row = QHBoxLayout() - avg_label = QLabel("์ผ ํ‰๊ท  ๊ทผ๋ฌด ๋ชฉํ‘œ:") + avg_label = QLabel(tr('settings.daily_avg_goal')) avg_label.setFixedWidth(150) self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 ๋ฐฉ์‹ self.goal_avg.setRange(0, 24) - self.goal_avg.setSuffix(" ์‹œ๊ฐ„") + self.goal_avg.setSuffix(tr('settings.suffix_hour')) self.goal_avg.setFixedWidth(100) avg_row.addWidget(avg_label) avg_row.addWidget(self.goal_avg) avg_row.addStretch() layout.addLayout(avg_row) - note = QLabel("โ€ป ํ†ต๊ณ„ โ†’ ์›”๊ฐ„ ํƒญ์—์„œ ์ง„ํ–‰๋ฅ  ํ™•์ธ") + note = QLabel(tr('settings.goal_note', fallback='')) note.setObjectName("note_text") layout.addWidget(note) @@ -567,40 +567,40 @@ class SettingsView(QDialog): # ์—ฐ์ฐจ ๊ฐœ์ˆ˜ + ๋‚จ์€ ์—ฐ์ฐจ ํ•œ ์ค„ top_row = QHBoxLayout() - annual_leave_label = QLabel("์—ฐ๊ฐ„ ์—ฐ์ฐจ:") + annual_leave_label = QLabel(tr('settings.annual_leave')) annual_leave_label.setFixedWidth(70) self.annual_leave_days = QSpinBox() self.annual_leave_days.setRange(0, 30) self.annual_leave_days.setValue(15) - self.annual_leave_days.setSuffix(" ์ผ") + self.annual_leave_days.setSuffix(tr('label.unit_day')) self.annual_leave_days.setFixedWidth(100) top_row.addWidget(annual_leave_label) top_row.addWidget(self.annual_leave_days) top_row.addStretch() - self.remaining_leave_label = QLabel("๋‚จ์€ ์—ฐ์ฐจ: ๊ณ„์‚ฐ ์ค‘...") + self.remaining_leave_label = QLabel(tr('settings.remaining_leave')) self.remaining_leave_label.setObjectName("badge_leave") top_row.addWidget(self.remaining_leave_label) layout.addLayout(top_row) # ๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ ์„ค์ • used_leave_layout = QHBoxLayout() - used_leave_label = QLabel("๊ธฐ์กด ์‚ฌ์šฉ:") + used_leave_label = QLabel(tr('settings.used_leave')) used_leave_label.setFixedWidth(70) self.used_leave_hours = QSpinBox() self.used_leave_hours.setRange(0, 200) self.used_leave_hours.setValue(0) - self.used_leave_hours.setSuffix(" ์‹œ๊ฐ„") + self.used_leave_hours.setSuffix(tr('settings.suffix_hour')) self.used_leave_hours.setFixedWidth(110) self.used_leave_mins = QSpinBox() self.used_leave_mins.setRange(0, 59) self.used_leave_mins.setValue(0) - self.used_leave_mins.setSuffix(" ๋ถ„") + self.used_leave_mins.setSuffix(tr('settings.suffix_minute')) self.used_leave_mins.setSingleStep(30) self.used_leave_mins.setFixedWidth(100) - apply_used_leave_btn = QPushButton("์ ์šฉ") + apply_used_leave_btn = QPushButton(tr('btn.apply')) apply_used_leave_btn.setObjectName("btn_small") apply_used_leave_btn.setFixedWidth(50) apply_used_leave_btn.clicked.connect(self.apply_used_leave) @@ -612,7 +612,7 @@ class SettingsView(QDialog): used_leave_layout.addStretch() layout.addLayout(used_leave_layout) - used_leave_note = QLabel("โ€ป ํ”„๋กœ๊ทธ๋žจ ์‚ฌ์šฉ ์ „ ์ด๋ฏธ ์‚ฌ์šฉํ•œ ์—ฐ์ฐจ (1์ผ=8์‹œ๊ฐ„)") + used_leave_note = QLabel(tr('settings.used_leave_note', fallback='')) used_leave_note.setObjectName("note_text") layout.addWidget(used_leave_note) @@ -627,33 +627,33 @@ class SettingsView(QDialog): # ๊ณตํœด์ผ ๋ชฉ๋ก + ๋ฒ„ํŠผ ํ•œ ์ค„ button_layout = QHBoxLayout() - holiday_list_label = QLabel("๋“ฑ๋ก:") + holiday_list_label = QLabel(tr('settings.registered')) button_layout.addWidget(holiday_list_label) - self.holiday_count_label = QLabel("0๊ฐœ") + self.holiday_count_label = QLabel(tr('settings.holiday_count', count=0, year=datetime.now().year)) self.holiday_count_label.setObjectName("info_text") button_layout.addWidget(self.holiday_count_label) button_layout.addStretch() - add_korean_btn = QPushButton("ํ•œ๊ตญ ๊ณตํœด์ผ (์ž๋™)") + add_korean_btn = QPushButton(tr('settings.add_korean_holidays')) add_korean_btn.setObjectName("btn_small") - add_korean_btn.setToolTip("์Œ๋ ฅ ๋ช…์ ˆ(์„ค/์ถ”์„) + ์ž„์‹œ๊ณตํœด์ผ ํฌํ•จ ์ž๋™ ๋“ฑ๋ก") + add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip')) add_korean_btn.clicked.connect(self.add_korean_holidays_auto) button_layout.addWidget(add_korean_btn) - add_custom_btn = QPushButton("์ถ”๊ฐ€") + add_custom_btn = QPushButton(tr('btn.add')) add_custom_btn.setObjectName("btn_small") add_custom_btn.clicked.connect(self.add_custom_holiday) button_layout.addWidget(add_custom_btn) - view_holidays_btn = QPushButton("๋ชฉ๋ก") + view_holidays_btn = QPushButton(tr('settings.list')) view_holidays_btn.setObjectName("btn_small") view_holidays_btn.clicked.connect(self.view_holidays) button_layout.addWidget(view_holidays_btn) layout.addLayout(button_layout) - holiday_note = QLabel("โ€ป ๊ณตํœด์ผ ๊ทผ๋ฌด ์‹œ ๋ชจ๋“  ์‹œ๊ฐ„์ด ์—ฐ์žฅ๊ทผ๋ฌด๋กœ ์ ๋ฆฝ๋ฉ๋‹ˆ๋‹ค") + holiday_note = QLabel(tr('settings.holiday_note', fallback='')) holiday_note.setObjectName("note_text") layout.addWidget(holiday_note) @@ -668,7 +668,7 @@ class SettingsView(QDialog): """๊ณตํœด์ผ ๊ฐœ์ˆ˜ ํ‘œ์‹œ ์—…๋ฐ์ดํŠธ""" current_year = datetime.now().year holidays = self.db.get_holidays_by_year(current_year) - self.holiday_count_label.setText(f"{len(holidays)}๊ฐœ ({current_year}๋…„)") + self.holiday_count_label.setText(tr('settings.holiday_count', count=len(holidays), year=current_year)) def add_korean_holidays_auto(self): """holidays ํŒจํ‚ค์ง€๋กœ ์Œ๋ ฅ/์ž„์‹œ ๊ณตํœด์ผ ํฌํ•จ ์ž๋™ ์ถ”๊ฐ€. @@ -680,19 +680,13 @@ class SettingsView(QDialog): current_year = now.year # 11~12์›”์— ํ˜ธ์ถœ ์‹œ ๋‹ค์Œ ํ•ด 1์›” ์‹ ์ •ยท์„ค ์—ฐํœด ๋ฏธ๋ฆฌ ๋“ฑ๋ก (์—ฐ๋ง ์ž์ • ๊ฒฝ๊ณ„ ๋Œ€์‘) include_next = now.month >= 11 - target_label = (f"{current_year}๋…„ + {current_year + 1}๋…„" - if include_next else f"{current_year}๋…„") + target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1) + if include_next else tr('settings.korean_holidays_years_label_single', year=current_year)) reply = QMessageBox.question( self, - "ํ•œ๊ตญ ๊ณตํœด์ผ ์ž๋™ ์ถ”๊ฐ€", - f"{target_label} ํ•œ๊ตญ ๊ณตํœด์ผ์„ ์ž๋™์œผ๋กœ ๋“ฑ๋กํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n" - "ํฌํ•จ:\n" - "โ€ข ์–‘๋ ฅ ๊ณตํœด์ผ (์‹ ์ •/์‚ผ์ผ์ ˆ/์–ด๋ฆฐ์ด๋‚ /๊ทผ๋กœ์ž์˜ ๋‚  ๋“ฑ)\n" - "โ€ข ์Œ๋ ฅ ๋ช…์ ˆ (์„ค๋‚  ์—ฐํœด/์ถ”์„ ์—ฐํœด/์„๊ฐ€ํƒ„์‹ ์ผ)\n" - "โ€ข ์ •๋ถ€ ์ง€์ • ๋Œ€์ฒดยท์ž„์‹œ๊ณตํœด์ผ\n\n" - "โ€ป 1์ฐจ: ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ํŠน์ผ์ •๋ณด API (์ •๋ถ€ ๊ณต์ธ, ์ž„์‹œ๊ณตํœด์ผ ํฌํ•จ)\n" - "โ€ป 2์ฐจ fallback: 'holidays' ํŒจํ‚ค์ง€ (์˜คํ”„๋ผ์ธ)", + tr('settings.korean_holidays_title'), + tr('settings.korean_holidays_body', years=target_label), QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: @@ -707,18 +701,16 @@ class SettingsView(QDialog): self.update_holiday_count() QMessageBox.warning( self, - "ํŒจํ‚ค์ง€ ๋ฏธ์„ค์น˜", - "'holidays' ํŒจํ‚ค์ง€๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•„ ๊ณ ์ • ๊ณตํœด์ผ๋งŒ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.\n\n" - "์Œ๋ ฅ/์ž„์‹œ๊ณตํœด์ผ ์ž๋™ ๋“ฑ๋ก์„ ์›ํ•˜์‹œ๋ฉด:\n" - " pip install holidays" + tr('settings.package_not_installed'), + tr('settings.package_fallback_body', hint=tr('settings.package_install_hint')) ) return self.update_holiday_count() QMessageBox.information( self, - "์ถ”๊ฐ€ ์™„๋ฃŒ", - f"{current_year}๋…„ ํ•œ๊ตญ ๊ณตํœด์ผ {added}๊ฐœ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('settings.add_done'), + tr('settings.korean_holidays_added', year=current_year, count=added) ) def add_custom_holiday(self): @@ -729,8 +721,8 @@ class SettingsView(QDialog): today = datetime.now().date().isoformat() date_str, ok = QInputDialog.getText( self, - "๊ณตํœด์ผ ์ถ”๊ฐ€", - "๊ณตํœด์ผ ๋‚ ์งœ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (YYYY-MM-DD):", + tr('settings.holiday_add_title'), + tr('settings.holiday_date_prompt'), QLineEdit.Normal, today ) @@ -744,16 +736,16 @@ class SettingsView(QDialog): except ValueError: QMessageBox.warning( self, - "์ž…๋ ฅ ์˜ค๋ฅ˜", - "๋‚ ์งœ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์˜ฌ๋ฐ”๋ฅธ ํ˜•์‹: YYYY-MM-DD (์˜ˆ: 2024-01-01)" + tr('msg.input_error.title'), + tr('msg.input_error.date_format') ) return # ๊ณตํœด์ผ ์ด๋ฆ„ ์ž…๋ ฅ name, ok = QInputDialog.getText( self, - "๊ณตํœด์ผ ์ถ”๊ฐ€", - "๊ณตํœด์ผ ์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”:", + tr('settings.holiday_add_title'), + tr('settings.holiday_name_prompt'), QLineEdit.Normal, "" ) @@ -767,8 +759,8 @@ class SettingsView(QDialog): QMessageBox.information( self, - "์ถ”๊ฐ€ ์™„๋ฃŒ", - f"๊ณตํœด์ผ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{date_str}: {name}" + tr('settings.add_done'), + tr('settings.holiday_added', date=date_str, name=name) ) def view_holidays(self): @@ -779,26 +771,28 @@ class SettingsView(QDialog): if not holidays: QMessageBox.information( self, - "๊ณตํœด์ผ ๋ชฉ๋ก", - f"{current_year}๋…„์— ๋“ฑ๋ก๋œ ๊ณตํœด์ผ์ด ์—†์Šต๋‹ˆ๋‹ค." + tr('settings.holiday_list_title'), + tr('stats.no_data') ) return # ๋ชฉ๋ก ์ƒ์„ฑ - holiday_list = f"=== {current_year}๋…„ ๊ณตํœด์ผ ๋ชฉ๋ก ===\n\n" + holiday_list = tr('settings.holiday_list_header', year=current_year) for h in holidays: date_obj = datetime.strptime(h['date'], "%Y-%m-%d") - weekday = ['์›”', 'ํ™”', '์ˆ˜', '๋ชฉ', '๊ธˆ', 'ํ† ', '์ผ'][date_obj.weekday()] - recurring = " (๋งค๋…„)" if h['is_recurring'] else "" - holiday_list += f"โ€ข {h['date']} ({weekday}): {h['name']}{recurring}\n" + weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}") + recurring = tr('label.recurring_yearly') if h['is_recurring'] else "" + holiday_list += tr('settings.holiday_list_item', + date=h['date'], weekday=weekday, + name=h['name'], recurring=recurring) - holiday_list += f"\n์ด {len(holidays)}๊ฐœ" + holiday_list += '\n' + tr('settings.holiday_total', count=len(holidays)) # ์‚ญ์ œ ์˜ต์…˜ ์ œ๊ณต reply = QMessageBox.question( self, - "๊ณตํœด์ผ ๋ชฉ๋ก", - holiday_list + "\n\n๊ณตํœด์ผ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + tr('settings.holiday_list_title'), + holiday_list + tr('settings.holiday_delete_confirm'), QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel ) @@ -819,8 +813,8 @@ class SettingsView(QDialog): items = [f"{h['date']}: {h['name']}" for h in holidays] item, ok = QInputDialog.getItem( self, - "๊ณตํœด์ผ ์‚ญ์ œ", - "์‚ญ์ œํ•  ๊ณตํœด์ผ์„ ์„ ํƒํ•˜์„ธ์š”:", + tr('settings.holiday_delete_title'), + tr('settings.holiday_delete_prompt'), items, 0, False @@ -830,7 +824,7 @@ class SettingsView(QDialog): date_str = item.split(":")[0] self.db.delete_holiday_by_date(date_str) self.update_holiday_count() - QMessageBox.information(self, "์‚ญ์ œ ์™„๋ฃŒ", f"{item}์ด(๊ฐ€) ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + QMessageBox.information(self, tr('settings.delete_done'), tr('settings.holiday_deleted', item=item)) def create_data_group(self) -> QGroupBox: """๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ ๊ทธ๋ฃน""" @@ -841,22 +835,22 @@ class SettingsView(QDialog): # CSV ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ๋“ค ํ•œ ์ค„ export_layout = QHBoxLayout() - export_work_btn = QPushButton("๊ทผ๋ฌด๊ธฐ๋ก") + export_work_btn = QPushButton(tr('settings.export_work')) export_work_btn.setObjectName("btn_small") export_work_btn.clicked.connect(self.export_work_records) export_layout.addWidget(export_work_btn) - export_overtime_btn = QPushButton("์—ฐ์žฅ๊ทผ๋ฌด") + export_overtime_btn = QPushButton(tr('settings.export_overtime')) export_overtime_btn.setObjectName("btn_small") export_overtime_btn.clicked.connect(self.export_overtime_summary) export_layout.addWidget(export_overtime_btn) - monthly_btn = QPushButton("์›”๊ฐ„ ์š”์•ฝ") + monthly_btn = QPushButton(tr('settings.export_monthly')) monthly_btn.setObjectName("btn_small") monthly_btn.clicked.connect(self.export_monthly_summary) export_layout.addWidget(monthly_btn) - export_label = QLabel("CSV ๋‚ด๋ณด๋‚ด๊ธฐ") + export_label = QLabel(tr('settings.export_csv')) export_label.setObjectName("note_text") export_layout.addWidget(export_label) export_layout.addStretch() @@ -865,12 +859,12 @@ class SettingsView(QDialog): # CSV ๊ฐ€์ ธ์˜ค๊ธฐ import_layout = QHBoxLayout() - import_btn = QPushButton("CSV ๊ฐ€์ ธ์˜ค๊ธฐ") + import_btn = QPushButton(tr('settings.import_csv')) import_btn.setObjectName("btn_small") - import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo ํ—ค๋” ํฌ๋งท") + import_btn.setToolTip(tr('settings.import_tooltip')) import_btn.clicked.connect(self._import_csv) import_layout.addWidget(import_btn) - import_label = QLabel("์šฐ๋ฆฌ ํ‘œ์ค€ ํฌ๋งท (ํ—ค๋”: date,clock_in,clock_out,lunch_minutes,memo)") + import_label = QLabel(tr('settings.import_format')) import_label.setObjectName("note_text") import_layout.addWidget(import_label) import_layout.addStretch() @@ -878,14 +872,14 @@ class SettingsView(QDialog): # DB ๊ฒฝ๋กœ ์„ค์ • (ํด๋ผ์šฐ๋“œ ๋™๊ธฐํ™” ๊ฐ€๋Šฅ) db_path_layout = QHBoxLayout() - db_path_label = QLabel("DB ๊ฒฝ๋กœ:") + db_path_label = QLabel(tr('settings.db_path_label')) db_path_label.setFixedWidth(60) self.db_path_edit = QLineEdit() self.db_path_edit.setReadOnly(True) self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db') - db_path_btn = QPushButton("๋ณ€๊ฒฝ...") + db_path_btn = QPushButton(tr('settings.change')) db_path_btn.setObjectName("btn_small") - db_path_btn.setToolTip("ํด๋ผ์šฐ๋“œ ํด๋”(OneDrive/Dropbox ๋“ฑ) ๊ฒฝ๋กœ๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ. ์žฌ์‹œ์ž‘ ํ•„์š”.") + db_path_btn.setToolTip(tr('settings.db_path_tooltip')) db_path_btn.clicked.connect(self._change_db_path) db_path_layout.addWidget(db_path_label) db_path_layout.addWidget(self.db_path_edit, 1) @@ -893,39 +887,35 @@ class SettingsView(QDialog): layout.addLayout(db_path_layout) # ์ž๋™ ์™ธ์ถœ (ํ™”๋ฉด ์ž ๊ธˆ ์‹œ) - self.auto_break_check = QCheckBox("ํ™”๋ฉด ์ž ๊ธˆ ์‹œ ์ž๋™ ์™ธ์ถœ/๋ณต๊ท€") - self.auto_break_check.setToolTip("PC๊ฐ€ ์ž ๊ธฐ๋ฉด ์™ธ์ถœ ์‹œ์ž‘, ํ’€๋ฆฌ๋ฉด ๋ณต๊ท€๋ฅผ ์ž๋™ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.") + self.auto_break_check = QCheckBox(tr('settings.auto_break_lock')) + self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback='')) layout.addWidget(self.auto_break_check) # Gitea ํ”ผ๋“œ๋ฐฑ ํ† ํฐ (์˜ต์…˜, crash ์ž๋™ ๋ณด๊ณ ์šฉ) feedback_layout = QHBoxLayout() - feedback_label = QLabel("Gitea ํ”ผ๋“œ๋ฐฑ:") + feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback='')) feedback_label.setFixedWidth(80) self.gitea_token_edit = QLineEdit() self.gitea_token_edit.setEchoMode(QLineEdit.Password) - self.gitea_token_edit.setPlaceholderText("PAT (issue ์“ฐ๊ธฐ ๊ถŒํ•œ, ์˜ต์…˜)") + self.gitea_token_edit.setPlaceholderText(tr('settings.gitea_token_placeholder', fallback='')) feedback_layout.addWidget(feedback_label) feedback_layout.addWidget(self.gitea_token_edit, 1) layout.addLayout(feedback_layout) - self.gitea_feedback_enabled_check = QCheckBox( - "์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ 'Gitea์— ๋ณด๊ณ ' ๋ฒ„ํŠผ ํ™œ์„ฑํ™”" - ) + self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback')) layout.addWidget(self.gitea_feedback_enabled_check) # ์ฒซ ์ž ๊ธˆ ํ•ด์ œ = ์ถœ๊ทผ (PC๋ฅผ ์•ˆ ๋„๋Š” ์‚ฌ์šฉ์ž์šฉ) - self.clock_in_unlock_check = QCheckBox("์ฒซ ์ž ๊ธˆ ํ•ด์ œ ์‹œ๊ฐ์„ ์ถœ๊ทผ์‹œ๊ฐ„์œผ๋กœ ์‚ฌ์šฉ") - self.clock_in_unlock_check.setToolTip( - "PC๋ฅผ ๋„์ง€ ์•Š๊ณ  ์ถœ๊ทผํ•˜๋Š” ๊ฒฝ์šฐ โ€” ๋ถ€ํŒ… ์ด๋ฒคํŠธ๊ฐ€ ์—†์–ด๋„ ํ™”๋ฉด ์ž ๊ธˆ ํ•ด์ œ ์‹œ์ ์„ ์ถœ๊ทผ์œผ๋กœ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค." - ) + self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock')) + self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback='')) layout.addWidget(self.clock_in_unlock_check) # ์—…๋ฐ์ดํŠธ ํ™•์ธ update_layout = QHBoxLayout() from core.version import __version__ - version_label = QLabel(f"๋ฒ„์ „: v{__version__}") + version_label = QLabel(tr('settings.version', version=__version__)) version_label.setObjectName("note_text") - update_btn = QPushButton("์—…๋ฐ์ดํŠธ ํ™•์ธ (F5)") + update_btn = QPushButton(tr('settings.check_update')) update_btn.setObjectName("btn_small") update_btn.clicked.connect(self._check_updates) update_layout.addWidget(version_label) @@ -941,7 +931,7 @@ class SettingsView(QDialog): current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db' new_path, _ = QFileDialog.getSaveFileName( self, - "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ ์„ ํƒ", + tr('settings.select_db'), current, "SQLite Database (*.db)" ) @@ -952,10 +942,8 @@ class SettingsView(QDialog): self.db_path_edit.setText(new_path) QMessageBox.information( self, - "DB ๊ฒฝ๋กœ ๋ณ€๊ฒฝ", - f"์ƒˆ ๊ฒฝ๋กœ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค:\n{new_path}\n\n" - "๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ํ˜„์žฌ database.db ํŒŒ์ผ์„ ์ƒˆ ์œ„์น˜๋กœ ๋ณต์‚ฌํ•˜๊ณ \n" - "ํ”„๋กœ๊ทธ๋žจ์„ ์žฌ์‹œ์ž‘ํ•˜์„ธ์š”." + tr('settings.db_path_label')[:-1], + tr('settings.db_path_saved', path=new_path) ) def load_settings(self): @@ -1105,7 +1093,7 @@ class SettingsView(QDialog): def _import_csv(self): """CSV ํŒŒ์ผ์—์„œ ๊ทผ๋ฌด ๊ธฐ๋ก ์ผ๊ด„ ๊ฐ€์ ธ์˜ค๊ธฐ.""" path, _ = QFileDialog.getOpenFileName( - self, "CSV ๊ฐ€์ ธ์˜ค๊ธฐ", + self, tr('settings.import_csv'), os.path.expanduser("~"), "CSV files (*.csv);;All files (*.*)", ) @@ -1115,19 +1103,17 @@ class SettingsView(QDialog): from utils.csv_importer import parse_csv, import_records rows = parse_csv(path) except (FileNotFoundError, ValueError) as e: - QMessageBox.critical(self, "ํŒŒ์‹ฑ ์‹คํŒจ", str(e)) + QMessageBox.critical(self, tr('settings.parse_failed'), str(e)) return if not rows: - QMessageBox.information(self, "๋นˆ ํŒŒ์ผ", "์œ ํšจํ•œ ํ–‰์ด ์—†์Šต๋‹ˆ๋‹ค.") + QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body')) return reply = QMessageBox.question( self, - "์ถฉ๋Œ ์ฒ˜๋ฆฌ", - f"{len(rows)}๊ฑด์˜ ํ–‰์„ ๊ฐ€์ ธ์˜ค๊ฒ ์Šต๋‹ˆ๋‹ค.\n\n" - "๊ธฐ์กด ์ผ์ž์™€ ์ถฉ๋Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ๊นŒ์š”?\n" - "Yes = ๋ฎ์–ด์“ฐ๊ธฐ\nNo = ๊ฑด๋„ˆ๋›ฐ๊ธฐ\nCancel = ์ทจ์†Œ", + tr('settings.conflict_title'), + tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'), QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, ) if reply == QMessageBox.Cancel: @@ -1137,12 +1123,12 @@ class SettingsView(QDialog): try: added, updated, skipped = import_records(self.db, rows, on_conflict=policy) except Exception as e: - QMessageBox.critical(self, "๊ฐ€์ ธ์˜ค๊ธฐ ์‹คํŒจ", str(e)) + QMessageBox.critical(self, tr('settings.import_failed'), str(e)) return QMessageBox.information( - self, "์™„๋ฃŒ", - f"๊ฐ€์ ธ์˜ค๊ธฐ ๊ฒฐ๊ณผ:\nโ€ข ์ถ”๊ฐ€: {added}๊ฑด\nโ€ข ๊ฐฑ์‹ : {updated}๊ฑด\nโ€ข ๊ฑด๋„ˆ๋œ€: {skipped}๊ฑด" + self, tr('settings.import_complete'), + tr('settings.import_result', added=added, updated=updated, skipped=skipped) ) def _check_updates(self): @@ -1235,8 +1221,8 @@ class SettingsView(QDialog): QMessageBox.information( self, - "์ €์žฅ ์™„๋ฃŒ", - "์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('settings.save_done'), + tr('settings.save_done_body') ) # ๋ถ€๋ชจ ์œˆ๋„์šฐ์— ์„ค์ • ๋ณ€๊ฒฝ ์•Œ๋ฆผ @@ -1252,9 +1238,8 @@ class SettingsView(QDialog): set_language_and_retranslate(new_lang) reply = QMessageBox.question( self, - "์žฌ์‹œ์ž‘ / Restart", - "์ฃผ์š” ํ™”๋ฉด์€ ์ฆ‰์‹œ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ์ผ๋ถ€ ๋‹ค์ด์–ผ๋กœ๊ทธ๋Š” ์žฌ์‹œ์ž‘ ํ›„ ์™„์ „ํžˆ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.\n์ง€๊ธˆ ์žฌ์‹œ์ž‘ํ• ๊นŒ์š”?\n\n" - "Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?", + tr('settings.restart_title'), + tr('settings.restart_body'), QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: @@ -1286,10 +1271,8 @@ class SettingsView(QDialog): reply = QMessageBox.question( self, - "๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด ์„ค์ •", - f"ํ˜„์žฌ ์„ค์ •: {old_hours}์‹œ๊ฐ„ {old_mins}๋ถ„\n" - f"๋ณ€๊ฒฝํ•  ๊ฐ’: {hours}์‹œ๊ฐ„ {mins}๋ถ„\n\n" - f"๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด ์‹œ๊ฐ„์„ ๋ณ€๊ฒฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + tr('settings.initial_overtime_title'), + tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins), QMessageBox.Yes | QMessageBox.No ) @@ -1299,8 +1282,8 @@ class SettingsView(QDialog): QMessageBox.information( self, - "์„ค์ • ์™„๋ฃŒ", - f"๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด๊ฐ€ {hours}์‹œ๊ฐ„ {mins}๋ถ„์œผ๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('settings.initial_overtime_done'), + tr('settings.initial_overtime_done_body', hours=hours, mins=mins) ) # ๋ถ€๋ชจ ์œˆ๋„์šฐ ์ž”์•ก ์—…๋ฐ์ดํŠธ @@ -1312,8 +1295,8 @@ class SettingsView(QDialog): except Exception as e: QMessageBox.critical( self, - "์˜ค๋ฅ˜", - f"๊ธฐ์กด ์—ฐ์žฅ๊ทผ๋ฌด ์„ค์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค:\n{str(e)}" + tr('settings.error'), + tr('settings.initial_overtime_error', error=str(e)) ) def update_overtime_balance_display(self): @@ -1321,7 +1304,7 @@ class SettingsView(QDialog): balance_minutes = self.db.get_total_overtime_balance() hours = balance_minutes // 60 minutes = balance_minutes % 60 - self.current_overtime_label.setText(f"ํ˜„์žฌ ์ž”์•ก: {hours}์‹œ๊ฐ„ {minutes}๋ถ„ ({balance_minutes}๋ถ„)") + self.current_overtime_label.setText(tr('settings.current_overtime_balance', hours=hours, minutes=minutes, balance=balance_minutes)) def update_remaining_leave(self): """๋‚จ์€ ์—ฐ์ฐจ ๊ณ„์‚ฐ ๋ฐ ํ‘œ์‹œ""" @@ -1347,7 +1330,7 @@ class SettingsView(QDialog): remaining = total_annual - total_used self.remaining_leave_label.setText( - f"๋‚จ์€ ์—ฐ์ฐจ: {remaining:.1f}์ผ (์ด {total_annual}์ผ ์ค‘ {total_used:.1f}์ผ ์‚ฌ์šฉ)" + tr('settings.remaining_leave_fmt', remaining=remaining, total=total_annual, used=total_used) ) def export_work_records(self): @@ -1358,14 +1341,14 @@ class SettingsView(QDialog): records = stats.get('records', []) if not records: - QMessageBox.warning(self, "๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ", "๋‚ด๋ณด๋‚ผ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.") + QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records')) return # ํŒŒ์ผ ๊ฒฝ๋กœ ์„ ํƒ default_filename = f"work_records_{now.year}{now.month:02d}.csv" filename, _ = QFileDialog.getSaveFileName( self, - "๊ทผ๋ฌด ๊ธฐ๋ก ์ €์žฅ", + tr('settings.save_work_title'), default_filename, "CSV Files (*.csv)" ) @@ -1375,17 +1358,17 @@ class SettingsView(QDialog): saved_path = CSVExporter.export_work_records(records, filename, db=self.db) QMessageBox.information( self, - "๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ", - f"๊ทผ๋ฌด ๊ธฐ๋ก์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{saved_path}" + tr('settings.export_done'), + tr('settings.work_exported', path=saved_path) ) except Exception as e: - QMessageBox.critical(self, "๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ", f"์˜ค๋ฅ˜: {str(e)}") + QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e))) def export_overtime_summary(self): """์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ ๋‚ด๋ณด๋‚ด๊ธฐ""" filename, _ = QFileDialog.getSaveFileName( self, - "์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ ์ €์žฅ", + tr('settings.save_ot_title'), f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv", "CSV Files (*.csv)" ) @@ -1395,18 +1378,18 @@ class SettingsView(QDialog): saved_path = CSVExporter.export_overtime_summary(self.db, filename) QMessageBox.information( self, - "๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ", - f"์—ฐ์žฅ๊ทผ๋ฌด ๋‚ด์—ญ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{saved_path}" + tr('settings.export_done'), + tr('settings.ot_exported', path=saved_path) ) except Exception as e: - QMessageBox.critical(self, "๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ", f"์˜ค๋ฅ˜: {str(e)}") + QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e))) def export_monthly_summary(self): """์›”๊ฐ„ ์š”์•ฝ ๋‚ด๋ณด๋‚ด๊ธฐ""" now = datetime.now() filename, _ = QFileDialog.getSaveFileName( self, - "์›”๊ฐ„ ์š”์•ฝ ์ €์žฅ", + tr('settings.save_monthly_title'), f"monthly_summary_{now.year}{now.month:02d}.csv", "CSV Files (*.csv)" ) @@ -1416,11 +1399,11 @@ class SettingsView(QDialog): saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename) QMessageBox.information( self, - "๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ", - f"์›”๊ฐ„ ์š”์•ฝ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n{saved_path}" + tr('settings.export_done'), + tr('settings.monthly_exported', path=saved_path) ) except Exception as e: - QMessageBox.critical(self, "๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ", f"์˜ค๋ฅ˜: {str(e)}") + QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e))) def apply_used_leave(self): """๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ ์„ค์ • (ํ”„๋กœ๊ทธ๋žจ ์‚ฌ์šฉ ์ „ ์ด๋ฏธ ์‚ฌ์šฉํ•œ ์—ฐ์ฐจ - ์ ˆ๋Œ€๊ฐ’)""" @@ -1435,10 +1418,8 @@ class SettingsView(QDialog): reply = QMessageBox.question( self, - "๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ ์„ค์ •", - f"ํ˜„์žฌ ์„ค์ •: {old_hours}์‹œ๊ฐ„ {old_mins}๋ถ„\n" - f"๋ณ€๊ฒฝํ•  ๊ฐ’: {hours}์‹œ๊ฐ„ {mins}๋ถ„\n\n" - f"๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ๋ฅผ ๋ณ€๊ฒฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + tr('settings.initial_leave_title'), + tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins), QMessageBox.Yes | QMessageBox.No ) @@ -1448,8 +1429,8 @@ class SettingsView(QDialog): QMessageBox.information( self, - "์„ค์ • ์™„๋ฃŒ", - f"๊ธฐ์กด ์‚ฌ์šฉ ์—ฐ์ฐจ๊ฐ€ {hours}์‹œ๊ฐ„ {mins}๋ถ„์œผ๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + tr('settings.initial_leave_done'), + tr('settings.initial_leave_done_body', hours=hours, mins=mins) ) # ๋‚จ์€ ์—ฐ์ฐจ ์žฌ๊ณ„์‚ฐ diff --git a/ui/stats_view.py b/ui/stats_view.py index 36d67de..9d9ae5d 100644 --- a/ui/stats_view.py +++ b/ui/stats_view.py @@ -93,13 +93,13 @@ class StatsView(QDialog): # ์นด๋“œ 4๊ฐœ ๊ฐ€๋กœ ๋ฐฐ์น˜ (์ด๊ทผ๋ฌด / ์ถœ๊ทผ์ผ / ํ‰๊ท  / ์—ฐ์žฅ) cards_row = QHBoxLayout() cards_row.setSpacing(10) - self.weekly_total_card = build_stat_card("์ด ๊ทผ๋ฌด ์‹œ๊ฐ„", "0์‹œ๊ฐ„", "์ด๋ฒˆ ์ฃผ", + self.weekly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'), theme='blue', icon='clock') - self.weekly_days_card = build_stat_card("๊ทผ๋ฌด ์ผ์ˆ˜", "0์ผ", "์ด๋ฒˆ ์ฃผ", + self.weekly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_week'), theme='cyan', icon='calendar') - self.weekly_avg_card = build_stat_card("์ผํ‰๊ท ", "0์‹œ๊ฐ„", "์ด๋ฒˆ ์ฃผ", + self.weekly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'), theme='green', icon='chart') - self.weekly_ot_card = build_stat_card("์—ฐ์žฅ๊ทผ๋ฌด", "0์‹œ๊ฐ„ 0๋ถ„", "์ด๋ฒˆ ์ฃผ", + self.weekly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_week'), theme='gold', icon='flame') for c in (self.weekly_total_card, self.weekly_days_card, self.weekly_avg_card, self.weekly_ot_card): @@ -109,7 +109,7 @@ class StatsView(QDialog): # ์ฃผ๊ฐ„ ์ฐจํŠธ (์ผ๋ณ„ ๊ทผ๋ฌด์‹œ๊ฐ„) โ€” ์นด๋“œ ์•ˆ์— from ui.chart_widget import make_chart_widget self.weekly_chart = make_chart_widget(widget) - chart_card = build_section_card("์ผ๋ณ„ ๊ทผ๋ฌด ์‹œ๊ฐ„", self.weekly_chart, + chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart, theme='gray', icon='trending-up') layout.addWidget(chart_card, 1) @@ -127,13 +127,13 @@ class StatsView(QDialog): # ์นด๋“œ 4๊ฐœ cards_row = QHBoxLayout() cards_row.setSpacing(10) - self.monthly_total_card = build_stat_card("์ด ๊ทผ๋ฌด ์‹œ๊ฐ„", "0์‹œ๊ฐ„", "์ด๋ฒˆ ๋‹ฌ", + self.monthly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'), theme='blue', icon='clock') - self.monthly_days_card = build_stat_card("๊ทผ๋ฌด ์ผ์ˆ˜", "0์ผ", "์ด๋ฒˆ ๋‹ฌ", + self.monthly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_month'), theme='cyan', icon='calendar') - self.monthly_avg_card = build_stat_card("์ผํ‰๊ท ", "0์‹œ๊ฐ„", "์ด๋ฒˆ ๋‹ฌ", + self.monthly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'), theme='green', icon='chart') - self.monthly_ot_card = build_stat_card("์—ฐ์žฅ๊ทผ๋ฌด", "0์‹œ๊ฐ„ 0๋ถ„", "์ด๋ฒˆ ๋‹ฌ", + self.monthly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_month'), theme='gold', icon='flame') for c in (self.monthly_total_card, self.monthly_days_card, self.monthly_avg_card, self.monthly_ot_card): @@ -159,7 +159,7 @@ class StatsView(QDialog): # ์›”๊ฐ„ ์ฐจํŠธ from ui.chart_widget import make_chart_widget self.monthly_chart = make_chart_widget(widget) - chart_card = build_section_card("์š”์ผ๋ณ„ ํ‰๊ท ", self.monthly_chart, + chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart, theme='gray', icon='chart') layout.addWidget(chart_card, 1) @@ -182,13 +182,13 @@ class StatsView(QDialog): f"font-size: 11pt; color: {tc('text')}; " f"background: transparent; border: none; padding: 4px 0;" ) - layout.addWidget(build_section_card("ํŒจํ„ด ์ธ์‚ฌ์ดํŠธ", self.pattern_text, + layout.addWidget(build_section_card(tr('stats.pattern_insights'), self.pattern_text, theme='cyan', icon='search')) # ์ถœ๊ทผ ์‹œ๊ฐ ๋ถ„ํฌ ์ฐจํŠธ from ui.chart_widget import make_chart_widget self.clock_in_chart = make_chart_widget(widget) - layout.addWidget(build_section_card("์ถœ๊ทผ ์‹œ๊ฐ ๋ถ„ํฌ", self.clock_in_chart, + layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart, theme='gray', icon='clock'), 1) widget.setLayout(layout) @@ -225,15 +225,15 @@ class StatsView(QDialog): # ์ฃผ๊ฐ„ ํ†ต๊ณ„ weekly_stats = self.db.get_weekly_stats() total_hours = weekly_stats.get('total_hours', 0) or 0 - self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}์‹œ๊ฐ„") - self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}์ผ") + self._set_card_value(self.weekly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}")) + self._set_card_value(self.weekly_days_card, tr('stats.value_days', days=weekly_stats.get('work_days', 0))) avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0 - self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}์‹œ๊ฐ„") + self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}")) overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0 overtime_hours = overtime_minutes // 60 overtime_mins = overtime_minutes % 60 - self._set_card_value(self.weekly_ot_card, f"{overtime_hours}์‹œ๊ฐ„ {overtime_mins}๋ถ„") + self._set_card_value(self.weekly_ot_card, tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins)) # ์ฃผ๊ฐ„ ์ฐจํŠธ from ui.chart_widget import draw_daily_hours, draw_weekday_avg @@ -251,21 +251,21 @@ class StatsView(QDialog): now = datetime.now() monthly_stats = self.db.get_monthly_stats(now.year, now.month) total_hours = monthly_stats.get('total_hours', 0) or 0 - self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}์‹œ๊ฐ„") + self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}")) work_days = monthly_stats.get('work_days', 0) or 0 - self._set_card_value(self.monthly_days_card, f"{work_days}์ผ") + self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days)) if work_days > 0: avg = total_hours / work_days - self._set_card_value(self.monthly_avg_card, f"{avg:.1f}์‹œ๊ฐ„") + self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}")) else: - self._set_card_value(self.monthly_avg_card, "0์‹œ๊ฐ„") + self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0)) overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0 overtime_hours = overtime_minutes // 60 overtime_mins = overtime_minutes % 60 self._set_card_value(self.monthly_ot_card, - f"{overtime_hours}์‹œ๊ฐ„ {overtime_mins}๋ถ„") + tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins)) # ์›”๊ฐ„ ์ฐจํŠธ (์š”์ผ๋ณ„ ํ‰๊ท ) if hasattr(self, 'monthly_chart'): @@ -300,8 +300,10 @@ class StatsView(QDialog): from core.salary import estimate_pay, format_won result = estimate_pay(records, wage, rate) self.salary_label.setText( - f"๐Ÿ’ฐ ์ด๋ฒˆ ๋‹ฌ ์ถ”์ • ๊ธ‰์—ฌ: {format_won(result['total'])} " - f"(๊ธฐ๋ณธ {format_won(result['base'])} + ์—ฐ์žฅ {format_won(result['overtime'])})" + tr('stats.salary_estimate', + total=format_won(result['total']), + base=format_won(result['base']), + overtime=format_won(result['overtime'])) ) self.salary_label.setVisible(True) @@ -334,7 +336,7 @@ class StatsView(QDialog): avg_minutes = sum(clock_in_times) / len(clock_in_times) avg_hour = int(avg_minutes // 60) avg_min = int(avg_minutes % 60) - insights.append(f"๐Ÿ“Œ ํ‰๊ท  ์ถœ๊ทผ์‹œ๊ฐ„: {avg_hour:02d}:{avg_min:02d}") + insights.append(tr('stats.avg_clock_in', time=f"{avg_hour:02d}:{avg_min:02d}")) # ์—ฐ์žฅ๊ทผ๋ฌด ๋นˆ๋„ overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0]) @@ -342,14 +344,14 @@ class StatsView(QDialog): if total_days > 0: overtime_rate = (overtime_days / total_days) * 100 - insights.append(f"๐Ÿ“Œ ์—ฐ์žฅ๊ทผ๋ฌด ๋นˆ๋„: {overtime_rate:.0f}% ({overtime_days}/{total_days}์ผ)") + insights.append(tr('stats.overtime_frequency', rate=f"{overtime_rate:.0f}", days=overtime_days, total=total_days)) # ๊ฐ€์žฅ ๊ธด ๊ทผ๋ฌด์ผ records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0] if records_with_hours: longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0)) if longest_work.get('total_hours', 0) > 0: - insights.append(f"๐Ÿ“Œ ์ตœ์žฅ ๊ทผ๋ฌด: {longest_work['date']} ({longest_work['total_hours']:.1f}์‹œ๊ฐ„)") + insights.append(tr('stats.longest_work', date=longest_work['date'], hours=f"{longest_work['total_hours']:.1f}")) # ๊ฑด๊ฐ• ๊ฒฝ๊ณ  recent_records = records[-7:] # ์ตœ๊ทผ 7์ผ @@ -364,15 +366,15 @@ class StatsView(QDialog): consecutive_overtime = 0 if max_consecutive >= 3: - insights.append(f"โš ๏ธ ์ตœ๊ทผ {max_consecutive}์ผ ์—ฐ์† ์—ฐ์žฅ๊ทผ๋ฌด ๋ฐœ์ƒ!") + insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive)) # ์ฃผ 52์‹œ๊ฐ„ ์ฒดํฌ if len(recent_records) >= 7: week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:]) if week_total > 52: - insights.append(f"๐Ÿšจ ์ฃผ 52์‹œ๊ฐ„ ์ดˆ๊ณผ: {week_total:.1f}์‹œ๊ฐ„") + insights.append(tr('stats.weekly_52_exceeded', hours=f"{week_total:.1f}")) - self.pattern_text.setText("\n\n".join(insights) if insights else "ํŒจํ„ด์„ ๋ถ„์„ํ•  ๋ฐ์ดํ„ฐ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.") + self.pattern_text.setText("\n\n".join(insights) if insights else tr('stats.no_pattern_data')) # ํ…Œ์ŠคํŠธ ์ฝ”๋“œ diff --git a/ui/today_summary.py b/ui/today_summary.py index 3707dbf..36fa25e 100644 --- a/ui/today_summary.py +++ b/ui/today_summary.py @@ -7,6 +7,8 @@ from __future__ import annotations from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton from PyQt5.QtCore import Qt +from core.i18n import tr + class TodaySummaryCard(QFrame): """ํ‡ด๊ทผ ์ฒ˜๋ฆฌ ์งํ›„ ํ‘œ์‹œ๋˜๋Š” ์š”์•ฝ ์นด๋“œ.""" @@ -30,7 +32,7 @@ class TodaySummaryCard(QFrame): layout.setSpacing(2) header = QHBoxLayout() - title = QLabel("์˜ค๋Š˜์˜ ์š”์•ฝ") + title = QLabel(tr('today.title')) title.setStyleSheet("font-weight: bold; font-size: 13px;") header.addWidget(title) header.addStretch() @@ -70,17 +72,17 @@ class TodaySummaryCard(QFrame): """ h = int(total_hours) m = int((total_hours - h) * 60) - self.total_label.setText(f"์ด ๊ทผ๋ฌด: {h}์‹œ๊ฐ„ {m}๋ถ„") + self.total_label.setText(tr('today.total_work', hours=h, minutes=m)) details = [] if lunch_minutes > 0: - details.append(f"์ ์‹ฌ {lunch_minutes}๋ถ„") + details.append(tr('today.detail_lunch', minutes=lunch_minutes)) if dinner_minutes > 0: - details.append(f"์ €๋… {dinner_minutes}๋ถ„") + details.append(tr('today.detail_dinner', minutes=dinner_minutes)) if break_minutes > 0: - details.append(f"์™ธ์ถœ {break_minutes}๋ถ„") + details.append(tr('today.detail_break', minutes=break_minutes)) if overtime_actual > 0: - details.append(f"์—ฐ์žฅ {overtime_actual}๋ถ„ โ†’ ์ ๋ฆฝ {overtime_earned}๋ถ„") + details.append(tr('today.detail_overtime', actual=overtime_actual, earned=overtime_earned)) self.detail_label.setText(" ยท ".join(details) if details else "") self.detail_label.setVisible(bool(details)) diff --git a/utils/csv_importer.py b/utils/csv_importer.py index 4aa9556..ac17a9d 100644 --- a/utils/csv_importer.py +++ b/utils/csv_importer.py @@ -138,6 +138,7 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int conn = db.get_connection() cursor = conn.cursor() try: + cursor.execute("DELETE FROM overtime_usage WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],)) diff --git a/utils/discord_webhook.py b/utils/discord_webhook.py index d272484..b9ad9f3 100644 --- a/utils/discord_webhook.py +++ b/utils/discord_webhook.py @@ -13,7 +13,9 @@ from typing import Optional, List # Discord/Cloudflare๋Š” Python ๊ธฐ๋ณธ UA(Python-urllib/3.x)๋ฅผ ๋ด‡์œผ๋กœ ์ฐจ๋‹จ(error 1010). # ์ผ๋ฐ˜ ๋ธŒ๋ผ์šฐ์ € UA๋กœ ์œ„์žฅํ•ด์•ผ ํ†ต๊ณผ. -USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3' +# ๋ฒ„์ „์€ core/version.py์—์„œ ๋™๊ธฐํ™”. +from core.version import __version__ +USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/{__version__}' # Discord embed ์ƒ‰์ƒ (decimal) COLOR_GREEN = 0x57F287 diff --git a/utils/holiday_api.py b/utils/holiday_api.py index 7e6f27d..5df2afb 100644 --- a/utils/holiday_api.py +++ b/utils/holiday_api.py @@ -14,15 +14,17 @@ """ from __future__ import annotations import json +import os import urllib.parse import urllib.request import urllib.error from typing import List, Dict, Optional -# ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ dev ํ‚ค (ํŠน์ผ์ •๋ณด API ํ•œ์ •). +# ๊ณต๊ณต๋ฐ์ดํ„ฐํฌํ„ธ ํŠน์ผ์ •๋ณด API ์„œ๋น„์Šค ํ‚ค. +# ์†Œ์Šค์ฝ”๋“œ/๋ฐ”์ด๋„ˆ๋ฆฌ ๋…ธ์ถœ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ์ฝ์Šต๋‹ˆ๋‹ค. # ๋…ธ์ถœ ์‹œ data.go.kr ๋งˆ์ดํŽ˜์ด์ง€์—์„œ ์ฆ‰์‹œ ํ๊ธฐ/์žฌ๋ฐœ๊ธ‰ ๊ฐ€๋Šฅ. -_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93' +_SERVICE_KEY = os.environ.get('CLOCKOUT_HOLIDAY_API_KEY', '') _BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService' _USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'