Compare commits

...

10 Commits
v2.9.0 ... main

Author SHA1 Message Date
KINDNICK
d8c6a9d784 docs(AGENTS): note UTF-8 handling for version.py in release 2026-06-16 10:54:22 +09:00
KINDNICK
71161b2707 fix(release): preserve UTF-8 when rewriting core/version.py 2026-06-16 10:53:52 +09:00
KINDNICK
63b0e324b9 i18n: complete UI/achievement keying, fix help_view tr() and i18n syntax errors 2026-06-16 10:52:57 +09:00
KINDNICK
f5751460e3 v2.11.2: 통계 차트 frozen 실제 수정(numpy _multiarray_tests) + 도전과제 라이트 가독성
- fix: frozen main.exe에서 numpy.core._multiarray_tests 누락으로 matplotlib import 실패 → 차트 폴백. spec에 hiddenimport 추가 + numpy.testing 제외 제거 (디버그 로그로 원인 확인)
- fix: 도전과제 라이트 테마 헤더 숫자/배지/진행 텍스트/바 대비 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:22:00 +09:00
KINDNICK
e7e85dcf7b v2.11.1: 통계 차트(frozen) 수정 + 통계/도움말/도전과제 테마 대응
- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅)
- fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종
- dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:08:24 +09:00
KINDNICK
130c61ea62 test: disable holiday auto-sync in _integration_test (fixes S2/S31 WinError 32 temp-DB lock)
Database.__init__의 공휴일 동기화 백그라운드 스레드가 SQLite 연결을 잡고 있어
임시 DB os.remove가 실패하던 문제. 문서화된 CLOCKOUT_DISABLE_HOLIDAY_SYNC 플래그를 테스트 시작 시 설정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:27:43 +09:00
KINDNICK
5fb8655a47 v2.11.0: UI 전면 다크 리디자인 + 라인 아이콘 + 적립 가드/삭제
- 모던 다크 미니멀 테마(NanumSquare 번들, 단일 accent #4DABF7, 8px radius, flat 버튼, 다크 기본값)
- 라인 아이콘 시스템(ui/icons.py, QtSvg) — 앱 전반 이모지 교체
- 다크 깨짐 수정: 테이블 헤더/코너 흰색, 도움말 탭 흰 라인, 트레이/미니위젯 메뉴
- fix: 자동 적립 OFF가 자동 퇴근 경로에서 무시되던 버그(게이팅)
- feat: 연장근무 적립 기록 삭제(우클릭)
- 테스트 3건 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:21:54 +09:00
KINDNICK
da5f91984b v2.10.2: 휴일 근무 카운터 초 표시 수정
원격 v2.10.1(cmd창 깜빡임 hotfix) 위에 rebase → 2.10.2.
- 휴일 분기 표시용 remaining을 초 정밀도 timedelta로 분리
  (적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
- pytest 189 + 통합 53 green
2026-05-16 18:16:45 +09:00
68893236+KINDNICK@users.noreply.github.com
3db4ed2351 v2.10.1: 업데이트 시 cmd 창 깜빡임 제거 (hotfix)
- updater.spec: console=True → console=False (windowed 빌드)
- updater.py: stderr 출력을 ~/.clockout_logs/updater.log 파일 폴백으로 전환
  (windowed 모드라도 진단 로그 보존). 모든 단계 타임스탬프 기록.
- updater.py launch(): subprocess.Popen에 CREATE_NO_WINDOW 플래그 추가
- utils/updater_client.py apply_update(): 같은 패턴으로 CREATE_NO_WINDOW 추가
  main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:33:55 +09:00
KINDNICK
97dd4e39f7 v2.10.0: \uc815\ubd80 \ud2b9\uc77c\uc815\ubcf4 API \uc5f0\ub3d9 + \uc77c \uc790\ub3d9 \ub3d9\uae30\ud654
\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0
\uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback.

- utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item)
- Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain
- migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654
  (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var)
- Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8

Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green
\ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green

\uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5,
fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
2026-05-01 13:51:33 +09:00
67 changed files with 5008 additions and 1733 deletions

610
AGENTS.md
View File

@ -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.02.2 + 15+ for v2.3+)
- `python _i18n_gui_test.py` — ko/en switch on real widgets
- `python _gui_smoke_test.py` — widget instantiation
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
> 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 <main_pid> --new <new_main.exe> --target <current_main.exe>`. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater.
## 🧩 Module Map
### `core/`
- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`).
- `time_calculator.py``work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit).
- `event_monitor.py` — Win Event IDs 6005/4624/6006.
- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable.
- `i18n.py``_DICT` ko/en + `_HELP_HTML` (6 tabs).
- `salary.py``estimate_pay(records, hourly_wage, overtime_rate=1.5)`.
- `settings_keys.py` — All setting keys as constants.
- `version.py``__version__` single source of truth.
### `ui/`
- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts.
- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
- `mini_widget.py` — always-on-top frameless.
- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button.
- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel).
- `today_summary.py` — post-clockout card.
- `goal_widget.py` — monthly progress bars (overtime cap, daily avg).
- `meal_time_dialog.py` — lunch/dinner real start-end input.
- `past_record_dialog.py` — calendar right-click "add past record".
- `leave_calendar_view.py` — color-coded leave (green/yellow/purple).
- `accessibility.py``apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`.
- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing.
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
### `ui/controllers/`
- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
- `auto_lunch.py` — toggles lunch after 4 hours since clock-in.
- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays.
### `utils/`
- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API.
- `lock_detector.py``OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally.
- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`.
- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
- `csv_exporter.py` — same format.
- `crash_handler.py``install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report.
- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`.
- `system_tray.py` — tray menu with i18n labels.
- `time_format.py``format_hours_minutes(minutes)`.
- `debug_log.py``dlog(...)` env-gated by `CLOCKOUT_DEBUG`.
- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver.
### Top-level
- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow.
- `updater.py` — Standalone PID wait + file replace + relaunch.
- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`.
- `updater.spec` — Standalone updater build.
- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.
## ⚙️ Build Process
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/<version>`. 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** (S1S52, S52AE) covering fresh-install migrations, work-pattern calculations, overtime banking, leave, weekends/holidays, notifications, backup, settings sync, i18n, CSV import/export, salary, crash log, updater semver, Discord guards, goals, and accessibility keys.
**Current status:** `PASS: 53 FAIL: 0 WARN: 0`.
### 5.3 GUI smoke tests
```bash
python _i18n_gui_test.py # Korean/English label switching
python _gui_smoke_test.py # Widget instantiation
```
Both use `QT_QPA_PLATFORM=offscreen` so no real windows appear.
- `_i18n_gui_test.py`: 5 cases, **all passing**.
- `_gui_smoke_test.py`: 8 cases, **all passing**.
### 5.4 Pre-release test command summary
```bash
python -m pytest tests/ -q
python _integration_test.py
python _i18n_gui_test.py
python _gui_smoke_test.py
```
`release.ps1` runs the first two by default (skippable with `-SkipTests`).
---
## 6. Code Style and Conventions
### Identifiers
- Classes: `PascalCase`
- Functions / variables: `snake_case`
- Module-level constants (especially setting keys): `UPPER_CASE`
### Settings keys
All setting keys are defined as constants in `core/settings_keys.py`. Import and use the constants; **never use raw strings** for new logic. When adding a new key, also add a default value in `Database.init_default_settings()`.
### Imports
Order is typically: standard library → third-party → project modules. Several newer modules use `from __future__ import annotations`.
### Comments and docstrings
Code identifiers are English, but inline comments and docstrings are predominantly Korean. New code should follow the existing bilingual style: English identifiers, Korean explanatory comments.
### UI construction
- Build widgets in `init_ui()`.
- Use helper methods such as `create_*_group()` for readability.
- Set `objectName` for QSS styling.
- Always verify `self.setLayout(main_layout)` is at the **end** of `init_ui()`, not accidentally indented into a method body.
### Type hints
Use type hints on public DB and helper methods (`-> int`, `-> Optional[Dict]`, `-> List[Dict]`, etc.).
### String formatting
Use f-strings for internal messages. For user-visible text, use the i18n API:
```python
from core.i18n import tr
tr('key', name=value)
```
### No enforced linter
There is no `pyproject.toml`, `.flake8`, or `setup.cfg`. Formatting is informal; keep line lengths reasonable and match the surrounding style.
---
## 7. Critical Invariants (MUST PRESERVE)
### 7.1 Time-off subtraction order in `MainWindow.update_display()`
Pass the actual `break_minutes` to `TimeCalculator.calculate_remaining_time()`, then subtract `total_time_off = overtime_used + leave_used` from the resulting `timedelta` **after** the call. **Never** mutate `break_minutes` to `break_minutes - overtime_used` before calling. Doing so previously caused a "+29h remaining time" bug.
```python
break_minutes = self.db.get_total_break_minutes_today()
overtime_used_today = self.db.get_today_overtime_usage()
leave_used_today = self.db.get_today_leave_minutes()
total_time_off = overtime_used_today + leave_used_today
remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_minutes)
remaining -= timedelta(minutes=total_time_off)
```
### 7.2 Hot-path caching
`update_display()` runs at **1 Hz**. DB reads inside it must be cached or throttled:
- `cached_time_format` in `MainWindow`
- `AutoLunchManager` caches enabled/non-working state
- `NotificationOrchestrator` gates periodic checks to 5-minute buckets
- Use `_set_text_if_changed()` to avoid useless repaints
### 7.3 24-hour internal time
All calculation uses 24-hour `datetime`. 12-hour conversion (Korean "오전"/"오후") happens only in `MainWindow.format_time()`.
### 7.4 Workday boundary
`workday_boundary_hour` defaults to 6. Overnight work stays on the previous day's record until that hour. Do not naively use `date.today()` in time logic.
### 7.5 Database invariants
- `work_records.date` is `UNIQUE`.
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are **NULLable** for manual entries. Never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
- `leave_records.days` is `REAL` (1.0 / 0.5 / 0.25 / hourly).
- Canonical time unit is **minutes** (`work_minutes`). `work_hours` is a derived/floor-synced property. Use `int(minutes) // 60` for hours, not `round()`.
- Overtime balance = `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` plus any `INITIAL_OVERTIME_MINUTES`.
- WAL mode + 5-second busy timeout is enabled for cloud-sync friendliness (OneDrive/Dropbox).
### 7.6 Migration idempotency
Every `migrate_*()` method must early-return if already applied. Use sentinel settings or `IF NOT EXISTS`. Examples: `balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`.
### 7.7 Settings auto-sync pairs
`Database.save_settings()` automatically keeps these pairs in sync:
- `WORK_MINUTES ↔ WORK_HOURS` (floor division)
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
### 7.8 Single-file deployment and updater handoff
- `main.exe` embeds `updater.exe` via `main.spec` data files.
- `_ensure_updater_extracted()` in `main.py` extracts the embedded updater on first launch from `sys._MEIPASS`.
- Protect the staging copy at `build/staging/updater.exe`.
- `updater.py` is **standalone** (no Qt/network deps). It accepts `--pid`, `--new`, and `--target`, waits for the PID, swaps files with `.bak` rollback, and relaunches.
---
## 8. Security Considerations
### 8.1 HTTP API
`utils/http_api.py` is **not present** in the current tree. If it is reintroduced, it must bind to `127.0.0.1` only and remain read-only. Never expose it externally.
### 8.2 Discord webhook
- URL is validated against official Discord webhook domains (`discord.com`, `discordapp.com`, canary/ptb).
- A browser User-Agent is mandatory; Cloudflare blocks the default Python UA.
- Push failures are silent so they do not break the app.
### 8.3 Auto-update
- Update check polls a self-hosted Gitea API: `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`.
- Downloads `main_new.exe` next to the running executable; `updater.exe` performs the swap with `.bak` rollback.
- Update apply only works in frozen builds; development `.py` runs are notified but not modified.
### 8.4 DB path handling and cloud sync
- `main.py` and `MainWindow` both bootstrap with the default DB first, read `DB_PATH_OVERRIDE`, then reopen with the override path. Do not break this order.
- WAL mode + busy timeout tolerates OneDrive/Dropbox sync.
### 8.5 Crash reporting
- Gitea issue reporting is **opt-in** via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
- Tokens are stored as plain strings in the SQLite `settings` table. The UI uses `QLineEdit.Password` echo mode.
### 8.6 Single instance
Multiple copies are prevented via `QLocalServer` named `ClockOutCalculatorInstance`.
### 8.7 Debug logging
`utils/debug_log.py` only emits output when `CLOCKOUT_DEBUG=1` (or `true`/`yes`).
---
## 9. External Integrations
- **Auto-update** (`utils/updater_client.py`): polls the Gitea Releases API. User-Agent: `ClockOutCalculator-Updater/1.0`.
- **Discord webhook** (`utils/discord_webhook.py`): optional one-direction push. Browser User-Agent required.
- **Gitea Issues** (`utils/crash_handler.py`): optional crash reporting; opt-in only.
- **Cloud sync via `DB_PATH_OVERRIDE`**: settings stores the DB path; the app reopens the override path after reading it.
- **`holidays` package**: `Database.add_korean_holidays_auto()` returns `-1` if the package is missing, and the UI falls back to `add_korean_holidays()` (8 fixed dates).
- **Korean holiday API** (`utils/holiday_api.py`): 공공데이터포털 특일정보 API 키는 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수에서 읽음. 소스코드/바이너리에 키를 하드코딩하지 않음.
---
## 10. i18n Conventions
- API: `tr(key, **kwargs)` and `tr_html(key)` from `core/i18n.py`.
- Translations live in `_DICT['ko']` and `_DICT['en']`. **Both languages must receive any new key.**
- Sentence interpolation uses Python `str.format(**kwargs)`.
- HelpView HTML content is in `_HELP_HTML`.
- Runtime retranslation is supported via `ui/i18n_runtime.py` using a weakref registry. Register widgets with `register(widget, key, setter='setText', kwargs={}, post=None)` and call `set_language_and_retranslate(lang)` to update them.
- UI files (`ui/`) and achievement metadata (`core/achievements.py`) are fully key-based.
- Remaining P4 internal-data hardcoding (not user-facing labels) includes DB-stored `leave_type` values and Korean holiday names in `core/database.py`.
- A language change may still prompt a restart for widgets not registered for runtime retranslate.
---
## 11. Release Flow
`release.ps1 vX.Y.Z` performs the full release locally:
1. **Pre-checks**: verify `$env:GITEA_TOKEN`, no running `main.exe`, no existing tag, no uncommitted changes (unless `-DryRun`).
2. **Bump version**: rewrite `core/version.py`.
3. **Tests**: run `pytest tests/` and `python _integration_test.py` (skippable with `-SkipTests`).
4. **Build**: `updater.spec``build/staging/``main.spec`.
5. **Code signing** (optional): sign both EXEs if `$env:CODE_SIGN_CERT` is set.
6. **ZIP packaging**: `dist/ClockOutCalculator-vX.Y.Z.zip` containing `main.exe` and `updater.exe`.
7. **Git commit + tag + push**: commit `core/version.py` and `CHANGELOG.md`.
8. **Gitea Release POST**: read `CHANGELOG.md` with UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section.
9. **Asset upload**: `main.exe`, `updater.exe`, and the ZIP.
Use `-DryRun` to preview without git push or API calls.
---
## 12. Past Incidents (Do Not Re-introduce)
| Incident | Root cause / fix |
|----------|------------------|
| +29h remaining time | Mutating `break_minutes -= overtime_used` before calculation. Fixed by subtracting `total_time_off` after `calculate_remaining_time`. |
| Manual overtime invisible | Filtering `work_record_id IS NOT NULL`. Now show all rows and label NULL as "수동 추가" / "Manual". |
| `annual_leave_total` vs `annual_leave_days` | Two keys for the same value; auto-synced in `save_settings()`. |
| Banker's rounding | `round(450/60) = 8` because Python rounds half to even. Use `int(minutes) // 60`. |
| FK enforcement rollback | `PRAGMA foreign_keys=ON` conflicted with existing manual overtime records. Kept WAL + timeout, no FK enforcement. |
| Discord 403 / Cloudflare 1010 | Default Python UA blocked. Fixed with browser UA. |
| Help dialog blank | `self.setLayout(main_layout)` indented into `_reopen_onboarding`. Verify `setLayout()` is at the end of `init_ui()`. |
| `dist/updater.exe` wiped by `--clean` | Solved by staging copy at `build/staging/updater.exe`. |
| Onboarding auto-skipped | Existing users with `work_records` were auto-completed. Added "Re-run Onboarding" button to Help. |
| PowerShell 5.1 ANSI | `Get-Content`/`Set-Content` default to ANSI and mangle Korean in `CHANGELOG.md` and `core/version.py`. Use `[System.IO.File]::ReadAllText`/`WriteAllText(path, UTF8)`. |
| PowerShell NativeCommandError | Use `Invoke-Native` helper with `$ErrorActionPreference = 'Continue'` and explicit `$LASTEXITCODE`. |
| Frozen chart numpy failure | Added `numpy.core._multiarray_tests` to `main.spec` hiddenimports. |
---
## 13. Additional References
- `README.md` — end-user feature overview (Korean/English mixed).
- `CLAUDE.md` — additional Korean-language guidance for this project.
- `INSTALL.md` — installation and troubleshooting instructions.
- `CHANGELOG.md` — release notes in Korean.
- `resources/resource_links.md` — links to external resources.
When modifying code, update this `AGENTS.md` if you change any conventions, build steps, module map, or external integrations described here.

View File

@ -4,6 +4,126 @@ 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
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
## [2.11.1] — 2026-06-04
### Fixed
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget``backend_qt5agg`
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
폴백) + 실패 원인 로깅, `main.spec``backend_qtagg`/`PyQt5.sip` 명시.
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제**`dark_components`
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
## [2.11.0] — 2026-06-04
### Changed — UI 전면 다크 리디자인
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
`main.spec`에 동봉
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
### Added
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec``PyQt5.QtSvg` 포함)
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
(`Database.delete_overtime_earned`)
### Fixed
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
### Tests
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
## [2.10.2] — 2026-05-16
### Fixed
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
그대로 표시에 사용 → 초 정보 소실
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
## [2.10.1] — 2026-05-01
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
- **`updater.spec`**: `console=True``console=False` (windowed 빌드).
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
에 타임스탬프 + 결과 기록.
- **`updater.py launch()`**: `subprocess.Popen``CREATE_NO_WINDOW` 플래그 추가
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
## [2.10.0] — 2026-05-01
### Added — 정부 공휴일 API 자동 동기화
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
- 키는 dev 본인 계정의 특일정보 API 한정 키
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
- sentinel: `settings['holidays_synced_date']`
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
### Changed
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
### Tests
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
- pytest: 175 → **189**
### 주의
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
## [2.9.0] — 2026-05-01
### Fixed — 휴일 hot-path 버그 (사용자 보고)

View File

@ -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)

View File

@ -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

View File

@ -15,6 +15,11 @@ from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
PASS = []
FAIL = []
WARN = []

View File

@ -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, '🌑'),
]

View File

@ -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 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
@ -89,10 +90,55 @@ class Database:
self.migrate_v271_work_records_indexes()
self.migrate_v280_achievements_columns()
self.migrate_v280_hire_date()
self.migrate_v290_holidays_auto_sync()
# 기본 설정 초기화
self.init_default_settings()
def migrate_v290_holidays_auto_sync(self) -> None:
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
값이 오늘과 같으면 스킵 같은 여러 켜도 호출 1.
매일 호출하므로 정부가 임시공휴일 발표하면 다음 자동 반영.
일일 한도 10000, 사용자 50 × 1 = 0.5% 소비.
실제 동기화는 백그라운드 스레드에서 부트스트랩이 네트워크에 묶이지 않음.
실패는 silent, 다음 실행 재시도.
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 비활성화.
"""
import os
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
return
from datetime import datetime as _dt
import threading
try:
today = _dt.now().date().isoformat()
sentinel = self.get_setting('holidays_synced_date', '')
if sentinel == today:
return
except Exception as e:
dlog(f"holiday sync precheck failed: {e}")
return
cur_year = _dt.now().year
def _worker():
try:
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
from core.database import Database
db = Database(self.db_path)
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
if added >= 0:
db.set_setting('holidays_synced_date', today)
except Exception as e:
dlog(f"holiday sync worker failed: {e}")
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
t.start()
def _create_tables(self, conn) -> None:
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
cursor = conn.cursor()
@ -726,7 +772,7 @@ class Database:
'dinner_duration_minutes': '60',
'auto_lunch': 'false',
'auto_overtime': 'true',
'theme': 'light',
'theme': 'dark',
'notification_before_minutes': '30',
'notification_clock_out': 'true',
'notification_lunch': 'true',
@ -933,6 +979,19 @@ class Database:
''', (work_record_id, earned_minutes, date))
conn.commit()
def delete_overtime_earned(self, bank_id: int) -> bool:
"""연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소.
Returns:
bool: 실제로 삭제된 행이 있으면 True.
"""
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,))
deleted = cursor.rowcount
conn.commit()
return deleted > 0
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
date: str, reason: str = None):
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
@ -1013,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
@ -1299,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):
@ -1331,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
@ -1617,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 설정값을 갱신할 필요가 없음.
# ===== 공휴일 관련 메서드 =====
@ -1713,6 +1779,32 @@ class Database:
for date, name in fixed_holidays:
self.add_holiday(date, name, is_recurring=True)
def add_korean_holidays_from_api(self, year: int) -> int:
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
임시공휴일 + 근로자의 holidays 패키지가 놓치는 항목까지 포함.
네트워크 실패 -1 반환 호출자 fallback.
Returns:
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 -1.
"""
try:
from utils.holiday_api import fetch_korean_holidays
except ImportError:
return -1
items = fetch_korean_holidays(year)
if items is None:
return -1
added = 0
for it in items:
if not it.get('is_holiday'):
continue
date_str = it['date']
if not self.is_holiday(date_str):
self.add_holiday(date_str, it['name'], is_recurring=False)
added += 1
return added
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
@ -1738,30 +1830,33 @@ class Database:
Returns:
추가된 공휴일 개수. 패키지 미설치 -1.
"""
try:
import holidays as _holidays
except ImportError:
return -1
years_to_add = [year]
if include_next_year:
years_to_add.append(year + 1)
added = 0
for y in years_to_add:
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
api_count = self.add_korean_holidays_from_api(y)
if api_count >= 0:
added += api_count
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
continue
# 2차 fallback: holidays 패키지
try:
import holidays as _holidays
kr = _holidays.country_holidays('KR', years=y)
except Exception:
continue # 패키지 내부 오류는 해당 연도만 스킵
except Exception as e:
dlog(f"holidays package fallback failed for {y}: {e}")
continue # 둘 다 실패면 해당 연도만 스킵
for d, name in kr.items():
date_str = d.isoformat()
if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=False)
added += 1
# holidays.KR이 누락하는 한국 노동자 휴일 보강.
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
# holidays.KR이 누락하는 근로자의 날 명시적 보강
extra = [(f"{y}-05-01", "근로자의 날")]
for date_str, name in extra:
if not self.is_holiday(date_str):

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

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

BIN
font/NanumSquareB.otf Normal file

Binary file not shown.

BIN
font/NanumSquareB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareEB.otf Normal file

Binary file not shown.

BIN
font/NanumSquareEB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareL.otf Normal file

Binary file not shown.

BIN
font/NanumSquareL.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareOTF_acB.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
font/NanumSquareOTF_acL.otf Normal file

Binary file not shown.

BIN
font/NanumSquareOTF_acR.otf Normal file

Binary file not shown.

BIN
font/NanumSquareR.otf Normal file

Binary file not shown.

BIN
font/NanumSquareR.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acEB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acL.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acR.ttf Normal file

Binary file not shown.

View File

@ -96,8 +96,9 @@ def main():
)
return 1
# 폰트 설정
app.setFont(QFont("Segoe UI", 9))
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
from utils.font_loader import apply_app_font
apply_app_font(app, 9)
# 필수 패키지 확인
if not check_requirements():

View File

@ -14,20 +14,35 @@ if os.path.exists(_staged):
elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.'))
# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
_font_files = [
'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
]
_font_datas = [
(os.path.join('font', f), 'font')
for f in _font_files if os.path.exists(os.path.join('font', f))
]
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[('3d-alarm.png', '.')] + _extra_datas,
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
hiddenimports=[
'holidays', 'holidays.countries.south_korea',
'win32evtlog', 'win32evtlogutil',
'matplotlib.backends.backend_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
'matplotlib.backends.backend_qt5agg',
'PyQt5.QtSvg',
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
noarchive=False,
optimize=0,
)

View File

@ -86,12 +86,12 @@ OkMsg "All checks passed (Version: $Version)"
# ====== 1. Bump version.py ======
Step "1/7 Bump core/version.py"
$verFile = 'core/version.py'
$verContent = Get-Content $verFile -Raw
$verContent = [System.IO.File]::ReadAllText((Resolve-Path $verFile).Path, [System.Text.Encoding]::UTF8)
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
if ($verContent -eq $newContent) {
Info "Already at $VersionRaw (no change)"
} else {
if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline }
if (-not $DryRun) { [System.IO.File]::WriteAllText((Resolve-Path $verFile).Path, $newContent, [System.Text.Encoding]::UTF8) }
OkMsg "$verFile -> $VersionRaw"
}

9
tests/conftest.py Normal file
View File

@ -0,0 +1,9 @@
"""
pytest 공통 설정.
모든 테스트는 백그라운드 휴일 동기화를 Database 생성 spawn되는
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
"""
import os
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'

View File

@ -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

173
tests/test_holiday_api.py Normal file
View File

@ -0,0 +1,173 @@
"""
utils.holiday_api 단위 테스트.
실제 정부 API는 호출하지 않음 모두 urlopen mock.
"""
import json
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.holiday_api import (
fetch_korean_holidays, _parse_response, is_configured,
)
def _ok_response(items):
"""API 정상 응답 형식 빌드."""
return {
'response': {
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
'body': {
'items': {'item': items} if items else {'item': []},
'numOfRows': 100,
'pageNo': 1,
'totalCount': len(items) if isinstance(items, list) else 1,
},
}
}
class TestParseResponse:
def test_multiple_items(self):
items = [
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
'locdate': 20260501, 'seq': 1},
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
'locdate': 20260505, 'seq': 1},
]
out = _parse_response(_ok_response(items))
assert len(out) == 2
assert out[0]['date'] == '2026-05-01'
assert out[0]['name'] == '근로자의 날'
assert out[0]['is_holiday'] is True
assert out[1]['date'] == '2026-05-05'
def test_single_item_as_dict(self):
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': {'item': item}, 'totalCount': 1},
}
}
out = _parse_response(data)
assert len(out) == 1
assert out[0]['name'] == '근로자의 날'
def test_empty_year(self):
# totalCount=0 같은 정상 빈 응답
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': '', 'totalCount': 0},
}
}
assert _parse_response(data) == []
def test_error_result_code(self):
data = {
'response': {
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
'body': {},
}
}
assert _parse_response(data) is None
def test_isholiday_n_filtered_at_caller_level(self):
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
out = _parse_response(_ok_response(items))
assert len(out) == 1
assert out[0]['is_holiday'] is False
def test_locdate_str_form(self):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
out = _parse_response(_ok_response(items))
assert out[0]['date'] == '2026-05-01'
def test_invalid_locdate_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
]
out = _parse_response(_ok_response(items))
assert len(out) == 1 # 정상 1개만
assert out[0]['name'] == '근로자의 날'
def test_missing_required_fields_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
]
out = _parse_response(_ok_response(items))
assert len(out) == 1
def test_malformed_response_returns_none(self):
# response 구조 자체가 깨진 경우
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
# 위는 implementation-dependent — 둘 다 합리적
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
assert _parse_response({}) is None
class TestFetchNetwork:
@patch('utils.holiday_api.urllib.request.urlopen')
def test_success(self, mock_urlopen):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
resp = MagicMock()
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
out = fetch_korean_holidays(2026)
assert out is not None
assert len(out) == 1
assert out[0]['date'] == '2026-05-01'
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
req = mock_urlopen.call_args[0][0]
assert 'serviceKey=' in req.full_url
assert 'solYear=2026' in req.full_url
assert '_type=json' in req.full_url
@patch('utils.holiday_api.urllib.request.urlopen')
def test_network_error_returns_none(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_timeout_returns_none(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_invalid_json_returns_none(self, mock_urlopen):
resp = MagicMock()
resp.read.return_value = b'<html>error</html>'
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert fetch_korean_holidays(2026) is None
class TestConfigured:
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

View File

@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save')
assert label.text() == '💾 저장'
assert label.text() == '저장'
def test_retranslate_after_language_change(qapp, i18n):
@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
set_language('ko')
dlg = QDialog()
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
assert dlg.windowTitle() == '⚙️ 설정'
assert dlg.windowTitle() == '설정'
i18n.set_language_and_retranslate('en')
assert dlg.windowTitle() == '⚙️ Settings'
assert dlg.windowTitle() == 'Settings'
def test_post_callback_applied(qapp, i18n):
@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
assert label.text() == '[💾 저장]'
assert label.text() == '[저장]'
i18n.set_language_and_retranslate('en')
assert label.text() == '[💾 Save]'
assert label.text() == '[Save]'
def test_dead_widget_pruned(qapp, i18n):

View File

@ -0,0 +1,72 @@
"""연장근무 자동 적립 가드 테스트.
auto_overtime(자동 적립) OFF면, 자동 퇴근 경로(근무일 경계 롤오버 )에서도
은행 적립을 하지 않아야 한다 clock_out() 대화상자에서 '아니오' 고른 것과 동일한 의미.
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
"""
import os
import sys
from datetime import datetime, timedelta, time as dtime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from core.time_calculator import TimeCalculator
from ui.main_window import MainWindow
def _rollover_balance(db, monkeypatch):
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
from PyQt5.QtWidgets import QMessageBox
monkeypatch.setattr(QMessageBox, 'information',
staticmethod(lambda *a, **k: QMessageBox.Ok))
today = datetime.now().date()
y = today - timedelta(days=1)
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
w.db = db
w.time_calc = TimeCalculator(work_minutes=480)
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
w.is_clocked_in = True
w.midnight_rollover_handled = False
w.is_on_break = False
w.lunch_break_enabled = False
w.dinner_break_enabled = False
w.load_today_data = lambda: None # tail UI refresh stub
w.update_overtime_balance = lambda: None # tail UI refresh stub
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
return db.get_total_overtime_balance()
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
db = Database(str(tmp_path / 'off.db'))
db.set_setting('auto_overtime', 'false')
assert _rollover_balance(db, monkeypatch) == 0
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
db = Database(str(tmp_path / 'on.db'))
db.set_setting('auto_overtime', 'true')
assert _rollover_balance(db, monkeypatch) > 0
def test_delete_overtime_earned_reduces_balance(tmp_path):
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
from datetime import date
db = Database(str(tmp_path / 'del.db'))
today = date.today().isoformat()
db.add_overtime_earned(None, 90, today)
assert db.get_total_overtime_balance() == 90
bank_id = db.get_connection().execute(
'SELECT id FROM overtime_bank').fetchone()[0]
assert db.delete_overtime_earned(bank_id) is True
assert db.get_total_overtime_balance() == 0
# 없는 id 삭제는 False
assert db.delete_overtime_earned(999999) is False

View File

@ -16,7 +16,9 @@ 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
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
@ -28,7 +30,6 @@ TIER_THEMES = {
'bg_bot': '#241810',
'text': '#ffd9a8',
'label': '🥉',
'name': '브론즈',
},
'silver': {
'border': '#a8a8a8',
@ -37,7 +38,6 @@ TIER_THEMES = {
'bg_bot': '#1c1c22',
'text': '#e8e8f0',
'label': '🥈',
'name': '실버',
},
'gold': {
'border': '#ffb700',
@ -46,7 +46,6 @@ TIER_THEMES = {
'bg_bot': '#241c08',
'text': '#ffe9a0',
'label': '🥇',
'name': '골드',
},
'platinum': {
'border': '#7fdbff',
@ -55,7 +54,6 @@ TIER_THEMES = {
'bg_bot': '#0e1f28',
'text': '#c5ecff',
'label': '💎',
'name': '플래티넘',
},
'legend': {
'border': '#ff6b9d',
@ -64,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탭 + 통계 헤더."""
@ -85,13 +72,13 @@ 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()
self.setStyleSheet("QDialog { background: #0e0e14; }")
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
self.init_ui()
apply_dark_titlebar(self, dark=True)
apply_dark_titlebar(self) # 현재 테마에 맞춰
def _increment_view_count(self) -> None:
try:
@ -120,30 +107,23 @@ 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("""
QPushButton {
background: #2a2a36; color: #e0e0e8;
border: 1px solid #44446a; border-radius: 6px;
padding: 8px 20px; font-size: 10pt;
}
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
""")
close_btn.setStyleSheet(button_qss('default'))
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
@ -153,14 +133,13 @@ class AchievementsView(QDialog):
# ----- 헤더 -----
def _build_header(self, stats: dict) -> QWidget:
container = QFrame()
container.setStyleSheet("""
QFrame {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #1a1a30, stop:1 #2a1a3a);
border: 1px solid #3a3a5a;
container.setStyleSheet(f"""
QFrame {{
background: {tc('panel')};
border: 1px solid {tc('border')};
border-radius: 12px;
}
QLabel { background: transparent; border: none; color: #e8e8f4; }
}}
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
""")
layout = QVBoxLayout()
layout.setContentsMargins(20, 16, 20, 16)
@ -172,22 +151,28 @@ class AchievementsView(QDialog):
num_row = QHBoxLayout()
num_row.setSpacing(24)
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>")
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
if _is_dark():
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
else:
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
big.setTextFormat(Qt.RichText)
num_row.addWidget(big)
spacer = QFrame()
spacer.setFrameShape(QFrame.VLine)
spacer.setStyleSheet("color: #3a3a5a;")
spacer.setStyleSheet(f"color: {tc('border')};")
num_row.addWidget(spacer)
secret_lbl = QLabel(
f"<div style='line-height: 1.3;'>"
f"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>"
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
f"{stats['secret_earned']}</span>"
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
f"</div>"
)
secret_lbl.setTextFormat(Qt.RichText)
@ -197,8 +182,8 @@ class AchievementsView(QDialog):
pct_lbl = QLabel(
f"<div style='text-align: right; line-height: 1.3;'>"
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>"
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
f"{pct:.1f}%</span></div>"
)
pct_lbl.setTextFormat(Qt.RichText)
@ -214,17 +199,17 @@ class AchievementsView(QDialog):
bar.setTextVisible(False)
bar.setMinimumHeight(8)
bar.setMaximumHeight(8)
bar.setStyleSheet("""
QProgressBar {
background: #1a1a26;
bar.setStyleSheet(f"""
QProgressBar {{
background: {tc('panel2')};
border: none;
border-radius: 4px;
}
QProgressBar::chunk {
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
border-radius: 4px;
}
}}
""")
layout.addWidget(bar)
@ -235,17 +220,7 @@ class AchievementsView(QDialog):
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet("""
QScrollArea { background: transparent; border: none; }
QScrollBar:vertical {
background: #1a1a24; width: 10px; border-radius: 5px;
}
QScrollBar::handle:vertical {
background: #44446a; border-radius: 5px; min-height: 30px;
}
QScrollBar::handle:vertical:hover { background: #6b9eff; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
""")
scroll.setStyleSheet(scroll_qss())
container = QWidget()
container.setStyleSheet("background: transparent;")
grid = QGridLayout()
@ -253,10 +228,10 @@ 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(
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
)
grid.addWidget(empty, 0, 0)
else:
@ -279,11 +254,18 @@ class AchievementsView(QDialog):
tier = item['tier'] or 'bronze'
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
# 시크릿 미발견은 회색 톤으로
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
light = not _is_dark()
if is_locked_secret:
bg_top, bg_bot = '#1a1a26', '#0e0e16'
border = '#3a3a4a'
text_color = '#666'
if light:
bg_top = bg_bot = tc('panel'); border = tc('border')
else:
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
text_color = tc('text_faint')
elif light:
bg_top = bg_bot = tc('panel')
border = theme['border_strong'] if is_earned else theme['border']
text_color = tc('text') if is_earned else tc('text_dim')
else:
bg_top = theme['bg_top']
bg_bot = theme['bg_bot']
@ -342,19 +324,19 @@ class AchievementsView(QDialog):
name = QLabel(name_text)
name.setStyleSheet(
f"font-size: 12pt; font-weight: bold; "
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
f"color: {tc('text') if is_earned else tc('text_dim')}; "
f"background: transparent; border: none;"
)
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']}; "
f"background: rgba(255,255,255,0.05); "
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
f"border: 1px solid {theme['border']}; "
f"border-radius: 8px; "
f"padding: 1px 4px;"
@ -372,24 +354,24 @@ 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)
desc.setWordWrap(True)
desc.setStyleSheet(
f"color: #a0a0b8; font-size: 9.5pt; "
f"color: {tc('text_dim')}; font-size: 9.5pt; "
f"background: transparent; border: none; padding: 0;"
)
outer.addWidget(desc)
# 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']}; "
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
f"font-weight: bold; font-size: 9.5pt; "
f"background: rgba(255,255,255,0.08); "
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
f"border: 1px solid {theme['border']}; "
f"border-radius: 6px; padding: 4px 8px;"
)
@ -415,7 +397,7 @@ class AchievementsView(QDialog):
pb.setMaximumHeight(10)
pb.setStyleSheet(f"""
QProgressBar {{
background: rgba(0,0,0,0.4);
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
border: none;
border-radius: 5px;
}}
@ -429,7 +411,7 @@ class AchievementsView(QDialog):
num = QLabel(f"{progress} / {target}")
num.setStyleSheet(
f"color: {theme['border_strong']}; font-size: 9pt; "
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
f"font-weight: bold; background: transparent; border: none;"
)
num.setMinimumWidth(60)
@ -453,32 +435,5 @@ class AchievementsView(QDialog):
# ----- 탭 QSS (다이얼로그 전용) -----
def _tabs_qss(self) -> str:
return """
QTabWidget::pane {
background: #14141c;
border: 1px solid #2a2a3a;
border-radius: 10px;
top: -1px;
}
QTabBar::tab {
background: #1c1c28;
color: #a0a0b8;
padding: 9px 18px;
border: 1px solid #2a2a3a;
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 3px;
font-size: 10pt;
}
QTabBar::tab:selected {
background: #14141c;
color: #ffd24a;
font-weight: bold;
border-bottom: 2px solid #ffd24a;
}
QTabBar::tab:hover:!selected {
background: #2a2a36;
color: #e0e0e8;
}
"""
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
return tabs_qss(ACCENT_GOLD)

View File

@ -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,15 +56,16 @@ class CalendarView(QDialog):
# 범례
legend_layout = QHBoxLayout()
legend_layout.setSpacing(12)
legend_layout.addWidget(QLabel("🟢 정상"))
legend_layout.addWidget(QLabel("🔴 연장"))
legend_layout.addWidget(QLabel("🟡 휴가"))
legend_layout.addWidget(QLabel("⚪ 없음"))
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"<span style='color:{_color};'>●</span> {_txt}")
_item.setTextFormat(Qt.RichText)
legend_layout.addWidget(_item)
legend_layout.addStretch()
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)
@ -77,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)
@ -94,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)
@ -163,21 +165,23 @@ class CalendarView(QDialog):
existing = self.db.get_work_record(date_str)
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:
return
if existing and action.text().startswith("✏️"):
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
if action == edit_action:
self._open_edit_dialog(date_str)
elif existing and action.text().startswith("🗑️"):
elif action == delete_action:
self._delete_record(date_str)
elif not existing and action.text().startswith(""):
elif action == add_action:
self._add_past_record(date_str)
def _add_past_record(self, date_str: str):
@ -214,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 있음)."""
@ -228,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:
@ -242,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()
@ -267,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)
@ -303,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('')
@ -316,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
)
@ -329,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)
)
# 캘린더 새로고침
@ -350,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)
)
# 상세 정보 새로고침
@ -397,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)
@ -406,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)
@ -429,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)
@ -438,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)
@ -455,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)
@ -464,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)
@ -510,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
@ -594,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()
@ -610,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:

View File

@ -9,25 +9,52 @@ from typing import List, Tuple
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import Qt
from core.i18n import tr
try:
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import matplotlib
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
from matplotlib.figure import Figure
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
try:
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
except Exception:
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
matplotlib.rcParams['axes.unicode_minus'] = False
_MPL = True
except ImportError:
except Exception as _mpl_err:
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
_MPL = False
try:
from utils.debug_log import dlog
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
except Exception:
pass
# 다크 테마 색상 (dark_components 톤과 일치)
_CHART_BG = '#14141c'
_CHART_GRID = '#2a2a3a'
_CHART_TEXT = '#c0c0d0'
_CHART_BAR_NORMAL = '#6b9eff' # blue
_CHART_BAR_OVERTIME = '#ff90b8' # pink
_CHART_BAR_WEEKEND = '#fcd34d' # gold
_CHART_AVG_LINE = '#4ade80' # green
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
# 막대/선은 데이터 구분용 고정 색.
_CHART_BG = '#25262B'
_CHART_GRID = '#2C2E33'
_CHART_TEXT = '#909296'
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
_CHART_AVG_LINE = '#51CF66' # green
def _refresh_chart_colors() -> None:
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
global _CHART_BG, _CHART_GRID, _CHART_TEXT
try:
from ui.styles import ThemeColors
_CHART_BG = ThemeColors.get('bg_secondary')
_CHART_GRID = ThemeColors.get('border_subtle')
_CHART_TEXT = ThemeColors.get('text_secondary')
except Exception:
pass
def _apply_dark_axes(ax) -> None:
@ -43,7 +70,8 @@ def _apply_dark_axes(ax) -> None:
def _apply_dark_figure(fig) -> None:
"""figure 배경을 다크 톤으로."""
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
_refresh_chart_colors()
fig.patch.set_facecolor(_CHART_BG)
@ -55,7 +83,7 @@ class _Fallback(QWidget):
label = QLabel(message)
label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True)
label.setStyleSheet("color: #888; padding: 20px;")
label.setStyleSheet("color: #909296; padding: 20px;")
layout.addWidget(label)
self.setLayout(layout)
@ -63,7 +91,8 @@ 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;")
layout = QVBoxLayout()
@ -88,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
@ -100,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)
@ -130,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)
@ -164,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
@ -184,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
@ -198,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)
@ -230,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()

View File

@ -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()

View File

@ -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()

View File

@ -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,
)

View File

@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt
# ── 색상 팔레트 ────────────────────────────────────────────────
DARK_BG = '#0e0e14'
DARK_PANEL = '#14141c'
DARK_PANEL_2 = '#1c1c28'
DARK_BORDER = '#2a2a3a'
DARK_BORDER_STRONG = '#44446a'
DARK_TEXT = '#e8e8f4'
DARK_TEXT_DIM = '#a0a0b8'
DARK_TEXT_FAINT = '#666680'
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
DARK_BG = '#1A1B1E'
DARK_PANEL = '#25262B'
DARK_PANEL_2 = '#2C2E33'
DARK_BORDER = '#2C2E33'
DARK_BORDER_STRONG = '#373A40'
DARK_TEXT = '#E9ECEF'
DARK_TEXT_DIM = '#909296'
DARK_TEXT_FAINT = '#6C6E73'
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
ACCENT_GOLD = '#ffd24a'
ACCENT_BLUE = '#6b9eff'
ACCENT_BLUE = '#4DABF7'
ACCENT_CYAN = '#4adef0'
ACCENT_PINK = '#ff90b8'
ACCENT_GREEN = '#4ade80'
ACCENT_GREEN = '#51CF66'
ACCENT_ORANGE = '#fcd34d'
ACCENT_RED = '#fb7185'
ACCENT_RED = '#FA5252'
# 카드 테마 (등급/상태별)
CARD_THEMES = {
@ -76,26 +78,59 @@ CARD_THEMES = {
}
# ── 테마 연동 ──────────────────────────────────────────────────
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
def _pal() -> dict:
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
from ui.styles import ThemeColors
g = ThemeColors.get
return {
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
'border': g('border_subtle'), 'border_strong': g('border_default'),
'text': g('text_primary'), 'text_dim': g('text_secondary'),
'text_faint': g('text_tertiary'),
'blue': g('accent_primary'), 'green': g('accent_success'),
'red': g('accent_danger'),
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
}
def _is_dark() -> bool:
from ui.styles import ThemeColors, DARK_COLORS
return ThemeColors.current is DARK_COLORS
def tc(role: str) -> str:
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
return _pal().get(role, '#FF00FF')
# ── QSS 헬퍼 ───────────────────────────────────────────────────
def dialog_qss() -> str:
"""다이얼로그 전체 배경."""
return f"QDialog {{ background: {DARK_BG}; }}"
"""다이얼로그 전체 배경 (현재 테마)."""
return f"QDialog {{ background: {_pal()['bg']}; }}"
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
def tabs_qss(accent: str = None) -> str:
p = _pal()
if accent is None:
accent = p['blue']
return f"""
QTabWidget::pane {{
background: {DARK_PANEL};
border: 1px solid {DARK_BORDER};
background: {p['panel']};
border: 1px solid {p['border']};
border-radius: 10px;
top: -1px;
}}
QTabBar::tab {{
background: {DARK_PANEL_2};
color: {DARK_TEXT_DIM};
background: {p['panel2']};
color: {p['text_dim']};
padding: 9px 18px;
border: 1px solid {DARK_BORDER};
border: 1px solid {p['border']};
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
@ -103,88 +138,90 @@ def tabs_qss(accent: str = ACCENT_GOLD) -> str:
font-size: 10pt;
}}
QTabBar::tab:selected {{
background: {DARK_PANEL};
background: {p['panel']};
color: {accent};
font-weight: bold;
border-bottom: 2px solid {accent};
}}
QTabBar::tab:hover:!selected {{
background: #2a2a36;
color: {DARK_TEXT};
background: {p['border_strong']};
color: {p['text']};
}}
"""
def scroll_qss() -> str:
p = _pal()
return f"""
QScrollArea {{ background: transparent; border: none; }}
QScrollBar:vertical {{
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
background: {p['panel2']}; width: 10px; border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
background: {p['panel2']}; height: 10px; border-radius: 5px;
}}
QScrollBar::handle:horizontal {{
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
"""
def button_qss(variant: str = 'default') -> str:
""" variant: default | primary | success | danger | ghost """
""" variant: default | primary | success | danger | ghost (현재 테마) """
p = _pal()
if variant == 'primary':
return f"""
QPushButton {{
background: {ACCENT_BLUE}; color: white;
border: none; border-radius: 6px;
background: {p['blue']}; color: white;
border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: #82b0ff; }}
QPushButton:pressed {{ background: #5a8eee; }}
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
QPushButton:hover {{ background: {p['blue_hover']}; }}
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
"""
if variant == 'success':
return f"""
QPushButton {{
background: {ACCENT_GREEN}; color: #0e2a1a;
border: none; border-radius: 6px;
background: {p['green']}; color: white;
border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: #6ae899; }}
QPushButton:hover {{ background: {p['green_hover']}; }}
"""
if variant == 'danger':
return f"""
QPushButton {{
background: {ACCENT_RED}; color: white;
border: none; border-radius: 6px;
background: {p['red']}; color: white;
border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: #fc8896; }}
QPushButton:hover {{ background: {p['red_hover']}; }}
"""
if variant == 'ghost':
return f"""
QPushButton {{
background: transparent; color: {DARK_TEXT_DIM};
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
background: transparent; color: {p['text_dim']};
border: 1px solid {p['border_strong']}; border-radius: 8px;
padding: 6px 14px; font-size: 9.5pt;
}}
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
border-color: {ACCENT_BLUE}; }}
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
border-color: {p['blue']}; }}
"""
# default
return f"""
QPushButton {{
background: {DARK_PANEL_2}; color: {DARK_TEXT};
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
background: {p['panel2']}; color: {p['text']};
border: 1px solid {p['border_strong']}; border-radius: 8px;
padding: 8px 18px; font-size: 10pt;
}}
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
"""
@ -202,15 +239,15 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
big_color: 숫자
extra_widgets: 우측에 배치할 위젯 (: 추가 통계, 토글)
"""
p = _pal()
container = QFrame()
container.setStyleSheet(f"""
QFrame {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #1a1a30, stop:1 #2a1a3a);
border: 1px solid #3a3a5a;
border-radius: 12px;
background: {p['panel']};
border: 1px solid {p['border']};
border-radius: 8px;
}}
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
""")
layout = QHBoxLayout()
layout.setContentsMargins(20, 14, 20, 14)
@ -222,13 +259,13 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
if title:
t = QLabel(title)
t.setStyleSheet(
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
f"font-size: 9pt; color: {p['text_dim']}; "
f"background: transparent; border: none;"
)
left.addWidget(t)
big = QLabel(
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
f" {subtitle}</span>" if subtitle else '')
)
big.setTextFormat(Qt.RichText)
@ -252,29 +289,49 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
theme: str = 'blue', icon: str = '') -> QFrame:
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
p = _pal()
dark = _is_dark()
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
if dark:
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
card_border = t['border']
label_color = t['text']
value_color = t['border_strong']
else:
card_bg = p['panel']
card_border = p['border']
label_color = p['text']
value_color = p['text']
card = QFrame()
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
card.setStyleSheet(f"""
QFrame {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
border: 1px solid {t['border']};
background: {card_bg};
border: 1px solid {card_border};
border-radius: 10px;
}}
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
QLabel {{ background: transparent; border: none; color: {label_color}; }}
""")
outer = QHBoxLayout()
outer.setContentsMargins(16, 12, 16, 12)
outer.setSpacing(12)
if icon:
icon_lbl = QLabel(icon)
icon_lbl.setStyleSheet(
f"font-size: 28pt; background: transparent; border: none; "
f"color: {t['border_strong']};"
)
icon_lbl = QLabel()
icon_lbl.setMinimumWidth(48)
icon_lbl.setAlignment(Qt.AlignCenter)
from ui.icons import get_icon, _PATHS
if icon in _PATHS:
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
else:
# 이모지/텍스트 폴백 (구버전 호환)
icon_lbl.setText(icon)
icon_lbl.setStyleSheet(
f"font-size: 28pt; background: transparent; border: none; "
f"color: {t['border_strong']};"
)
outer.addWidget(icon_lbl)
text_box = QVBoxLayout()
@ -282,13 +339,13 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; "
f"font-size: 9.5pt; color: {p['text_dim']}; "
f"background: transparent; border: none;"
)
text_box.addWidget(title_lbl)
val_lbl = QLabel(
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
f"{value}</span>"
)
val_lbl.setTextFormat(Qt.RichText)
@ -298,7 +355,7 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
if subtitle:
sub_lbl = QLabel(subtitle)
sub_lbl.setStyleSheet(
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
f"font-size: 9pt; color: {p['text_dim']}; "
f"background: transparent; border: none;"
)
sub_lbl.setWordWrap(True)
@ -313,16 +370,25 @@ def build_section_card(title: str, content: QWidget,
theme: str = 'gray', icon: str = '') -> QFrame:
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
p = _pal()
if _is_dark():
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
card_border = t['border']
label_color = t['text']
else:
card_bg = p['panel']
card_border = p['border']
label_color = p['text']
card = QFrame()
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
card.setStyleSheet(f"""
QFrame {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
border: 1px solid {t['border']};
background: {card_bg};
border: 1px solid {card_border};
border-radius: 10px;
}}
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
QLabel {{ background: transparent; border: none; color: {label_color}; }}
""")
layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 14)
@ -330,15 +396,20 @@ def build_section_card(title: str, content: QWidget,
head = QHBoxLayout()
if icon:
i = QLabel(icon)
i.setStyleSheet(
f"font-size: 16pt; color: {t['border_strong']}; "
f"background: transparent; border: none;"
)
i = QLabel()
from ui.icons import get_icon, _PATHS
if icon in _PATHS:
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
else:
i.setText(icon)
i.setStyleSheet(
f"font-size: 16pt; color: {t['border_strong']}; "
f"background: transparent; border: none;"
)
head.addWidget(i)
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
f"background: transparent; border: none;"
)
head.addWidget(title_lbl)
@ -372,9 +443,11 @@ def style_progressbar(pb: QProgressBar, theme: str = 'blue',
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
color: str = DARK_TEXT) -> QLabel:
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
color: str = None) -> QLabel:
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
lbl = QLabel(text)
if color is None:
color = _pal()['text']
weight_str = 'bold' if weight == 'bold' else 'normal'
lbl.setStyleSheet(
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "

View File

@ -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)
@ -78,7 +80,7 @@ class GoalWidget(QWidget):
ot_h, ot_m = ot_total // 60, ot_total % 60
tg_h, tg_m = ot_target // 60, ot_target % 60
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.ot_label.setVisible(False)
@ -93,7 +95,7 @@ class GoalWidget(QWidget):
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
ratio = avg / avg_target if avg_target else 0
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.avg_label.setVisible(False)

View File

@ -10,10 +10,7 @@ from PyQt5.QtCore import Qt
from core.i18n import tr, tr_html
from ui.styles import apply_dark_titlebar
from ui.dark_components import (
dialog_qss, tabs_qss, button_qss,
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
)
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
class HelpView(QDialog):
@ -37,7 +34,7 @@ class HelpView(QDialog):
self.resize(820, 760)
self.setStyleSheet(dialog_qss())
self.init_ui()
apply_dark_titlebar(self, dark=True)
apply_dark_titlebar(self) # 현재 테마에 맞춰
def init_ui(self):
main_layout = QVBoxLayout()
@ -45,15 +42,14 @@ class HelpView(QDialog):
main_layout.setSpacing(10)
# 다크 타이틀
title = QLabel(f"📖 {tr('window.help')}")
title = QLabel(tr('window.help'))
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;"
)
main_layout.addWidget(title)
tabs = QTabWidget()
tabs.setDocumentMode(True)
tabs.setStyleSheet(tabs_qss())
for html_key, tab_label_key in self._TABS:
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
@ -63,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)
@ -89,7 +85,7 @@ class HelpView(QDialog):
def _make_tab(self, html: str) -> QWidget:
container = QWidget()
container.setStyleSheet(f"background: {DARK_PANEL};")
container.setStyleSheet(f"background: {tc('panel')};")
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
@ -100,21 +96,21 @@ class HelpView(QDialog):
browser.setHtml(styled_html)
browser.setStyleSheet(f"""
QTextBrowser {{
background: {DARK_PANEL};
color: {DARK_TEXT};
background: {tc('panel')};
color: {tc('text')};
border: none;
padding: 16px 20px;
font-size: 10.5pt;
selection-background-color: {ACCENT_GOLD};
selection-color: #1a1a26;
selection-background-color: {tc('blue')};
selection-color: #ffffff;
}}
QScrollBar:vertical {{
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
background: {tc('panel')}; width: 10px; border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
""")
layout.addWidget(browser)
@ -123,61 +119,67 @@ class HelpView(QDialog):
def _inject_dark_styles(self, html: str) -> str:
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
text = tc('text')
dim = tc('text_dim')
blue = tc('blue')
green = tc('green')
panel2 = tc('panel2')
border = tc('border')
css = f"""
<style>
body, p, li {{
color: #e8e8f4;
color: {text};
font-size: 14px;
line-height: 1.65;
}}
h1, h2, h3, h4 {{
color: #ffd24a;
color: {blue};
margin-top: 1.2em;
margin-bottom: 0.5em;
}}
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }}
h3 {{ font-size: 13pt; color: #6b9eff; }}
h4 {{ font-size: 11pt; color: #4ade80; }}
b, strong {{ color: #ff90b8; }}
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
h3 {{ font-size: 13pt; color: {blue}; }}
h4 {{ font-size: 11pt; color: {green}; }}
b, strong {{ color: {text}; }}
code {{
background: #1c1c28;
color: #ffd24a;
background: {panel2};
color: {blue};
padding: 2px 6px;
border-radius: 4px;
font-family: Consolas, monospace;
font-size: 12px;
}}
pre {{
background: #1c1c28;
border: 1px solid #2a2a3a;
background: {panel2};
border: 1px solid {border};
border-radius: 6px;
padding: 10px;
color: #e8e8f4;
color: {text};
}}
ul, ol {{ margin-left: 0; padding-left: 24px; }}
li {{ margin-bottom: 4px; }}
a {{ color: #4adef0; text-decoration: none; }}
a {{ color: {blue}; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
table {{ border-collapse: collapse; margin: 10px 0; }}
th {{
background: #2a2a3a;
color: #ffd24a;
background: {panel2};
color: {text};
padding: 8px 12px;
border: 1px solid #44446a;
border: 1px solid {border};
text-align: left;
}}
td {{
padding: 6px 12px;
border: 1px solid #2a2a3a;
color: #e8e8f4;
border: 1px solid {border};
color: {text};
}}
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
blockquote {{
border-left: 3px solid #6b9eff;
border-left: 3px solid {blue};
margin-left: 0;
padding: 4px 16px;
color: #a0a0b8;
background: rgba(107, 158, 255, 0.05);
color: {dim};
}}
</style>
"""

82
ui/icons.py Normal file
View File

@ -0,0 +1,82 @@
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환
재호출하면 자동으로 재틴팅된다.
사용:
from ui.icons import get_icon
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
"""
from __future__ import annotations
from PyQt5.QtCore import QByteArray, QRectF, Qt
from PyQt5.QtGui import QIcon, QPixmap, QPainter
from PyQt5.QtSvg import QSvgRenderer
from ui.styles import ThemeColors
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
_PATHS = {
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
}
_SVG_TMPL = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
'fill="none" stroke="{color}" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
)
_cache: dict = {}
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
if color is None:
color = ThemeColors.get('text_secondary')
key = (name, color, size)
cached = _cache.get(key)
if cached is not None:
return cached
paths = _PATHS.get(name)
if paths is None:
return QIcon()
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
renderer = QSvgRenderer(QByteArray(svg))
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
pm = QPixmap(size * dpr, size * dpr)
pm.fill(Qt.transparent)
painter = QPainter(pm)
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
painter.end()
pm.setDevicePixelRatio(dpr)
icon = QIcon(pm)
_cache[key] = icon
return icon
def clear_cache() -> None:
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
_cache.clear()

View File

@ -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,10 +46,11 @@ class LeaveCalendarView(QDialog):
# 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout()
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
"🔵 예정", "🔘 종일+예정"]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
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"<span style='color:{_color};'>●</span> {_txt}")
l.setStyleSheet("padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
@ -61,13 +63,13 @@ class LeaveCalendarView(QDialog):
# 선택 일자 정보
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
layout.addWidget(self.detail_label)
# 닫기 버튼
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)
@ -108,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 ""))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
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))

View File

@ -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,16 +137,16 @@ 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(231, 76, 60)) # 빨간색
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
memo_item = QTableWidgetItem(record['memo'] or "")
@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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,13 +45,12 @@ 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: #888; padding-bottom: 6px;")
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
layout.addWidget(info)
# 합리적 기본값: 출근 이후로 보정
@ -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))
@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
# 미리보기 라벨
self.preview = QLabel("")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
layout.addWidget(self.preview)
self._update_preview()
self.start_edit.timeChanged.connect(self._update_preview)
@ -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.setStyleSheet("color: #f44336;")
self.preview.setText(reason)
self.preview.setStyleSheet("color: #FA5252;")
else:
self.preview.setText(f"{minutes}")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
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()

View File

@ -41,7 +41,7 @@ class MiniWidget(QWidget):
self.title_label = QLabel(tr('label.remaining'))
self.title_label.setAlignment(Qt.AlignCenter)
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
self.time_label = QLabel("--:--:--")
self.time_label.setAlignment(Qt.AlignCenter)
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
layout.addWidget(self.time_label)
self.setLayout(layout)
# 기본 스타일 (테마 무관 가독성 유지)
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
self.setStyleSheet("""
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
QLabel { color: #fff; }
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
QLabel { color: #E9ECEF; background: transparent; }
""")
apply_dark_titlebar(self)
@ -63,11 +63,12 @@ class MiniWidget(QWidget):
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
self.time_label.setText(remaining_str)
if remaining_str.startswith('+'):
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
self.title_label.setText(tr('label.overtime_progress'))
self.time_label.setStyleSheet("color: #ff6b6b;")
self.time_label.setStyleSheet("color: #51CF66;")
else:
self.title_label.setText(tr('label.remaining'))
self.time_label.setStyleSheet("color: #fff;")
self.time_label.setStyleSheet("color: #E9ECEF;")
# 드래그 이동
def mousePressEvent(self, event: QMouseEvent):
@ -90,8 +91,19 @@ class MiniWidget(QWidget):
def contextMenuEvent(self, event):
from PyQt5.QtWidgets import QMenu
menu = QMenu(self)
open_main = menu.addAction("메인 창 열기")
close_mini = menu.addAction("미니 위젯 닫기")
# 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
# 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
qss = self.parent_window.styleSheet() if self.parent_window else ''
if not qss:
try:
from ui.styles import get_theme
qss = get_theme('dark')
except Exception:
qss = ''
if qss:
menu.setStyleSheet(qss)
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()

View File

@ -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,22 +120,20 @@ 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(
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info = QLabel(tr('onboarding.detection_info'))
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;")
info.setStyleSheet("color: #909296; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
@ -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.setStyleSheet("color: #888; padding: 6px;")
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 = {

View File

@ -66,6 +66,8 @@ class OvertimeView(QDialog):
self.earned_table.setAlternatingRowColors(True)
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
@ -126,7 +128,7 @@ class OvertimeView(QDialog):
cursor = conn.cursor()
cursor.execute('''
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC
@ -138,6 +140,7 @@ class OvertimeView(QDialog):
for i, record in enumerate(earned_records):
date_item = QTableWidgetItem(record[0])
date_item.setTextAlignment(Qt.AlignCenter)
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
minutes = record[1]
hours = minutes // 60
@ -148,7 +151,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "")
@ -183,7 +186,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
reason_item = QTableWidgetItem(record[3] or "")
@ -249,6 +252,46 @@ class OvertimeView(QDialog):
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def show_earned_context_menu(self, position):
"""적립 내역 우클릭 메뉴 (삭제)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
menu = QMenu(self)
delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_earned_record)
menu.addAction(delete_action)
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
def delete_earned_record(self):
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
row = selected_rows[0].row()
date_item = self.earned_table.item(row, 0)
time_item = self.earned_table.item(row, 1)
# 행에 저장된 overtime_bank.id
bank_id = date_item.data(Qt.UserRole)
if bank_id is None:
return
reply = QMessageBox.question(
self,
tr('msg.confirm_delete.title'),
tr('view.overtime.delete_earned_confirm_body',
date=date_item.text(), time=time_item.text()),
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_overtime_earned(bank_id)
self.load_data()
# 부모 윈도우 잔액 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def add_earned_record(self):
"""수동 적립 추가"""
dialog = AddOvertimeEarnedDialog(self, self.db)

View File

@ -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()

View File

@ -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()

View File

@ -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:

View File

@ -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,18 +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"
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
tr('settings.korean_holidays_title'),
tr('settings.korean_holidays_body', years=target_label),
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
@ -706,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):
@ -728,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
)
@ -743,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,
""
)
@ -766,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):
@ -778,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
)
@ -818,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
@ -829,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:
"""데이터 관리 그룹"""
@ -840,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()
@ -864,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()
@ -877,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)
@ -892,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)
@ -940,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)"
)
@ -951,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):
@ -1067,7 +1056,7 @@ class SettingsView(QDialog):
self.time_format_combo.setCurrentIndex(index)
# 테마
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
# 언어 선택 적용
if hasattr(self, 'language_combo'):
@ -1104,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 (*.*)",
)
@ -1114,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:
@ -1136,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):
@ -1234,8 +1221,8 @@ class SettingsView(QDialog):
QMessageBox.information(
self,
"저장 완료",
"설정이 저장되었습니다."
tr('settings.save_done'),
tr('settings.save_done_body')
)
# 부모 윈도우에 설정 변경 알림
@ -1251,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:
@ -1285,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
)
@ -1298,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)
)
# 부모 윈도우 잔액 업데이트
@ -1311,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):
@ -1320,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):
"""남은 연차 계산 및 표시"""
@ -1346,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):
@ -1357,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)"
)
@ -1374,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)"
)
@ -1394,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)"
)
@ -1415,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):
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
@ -1434,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
)
@ -1447,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)
)
# 남은 연차 재계산

View File

@ -15,7 +15,7 @@ from core.i18n import tr
from ui.styles import apply_dark_titlebar
from ui.dark_components import (
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM,
transparent_label, tc,
)
@ -27,7 +27,7 @@ class StatsView(QDialog):
self.db = db if db else Database()
self.init_ui()
self.load_stats()
apply_dark_titlebar(self, dark=True)
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
def init_ui(self):
"""UI 초기화"""
@ -42,9 +42,9 @@ class StatsView(QDialog):
layout.setContentsMargins(20, 16, 20, 14)
# 다크 톤 타이틀
title = QLabel(f"📊 {tr('stats.title')}")
title = QLabel(f"{tr('stats.title')}")
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(title)
@ -93,14 +93,14 @@ class StatsView(QDialog):
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
cards_row = QHBoxLayout()
cards_row.setSpacing(10)
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
theme='blue', icon='⏱️')
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
theme='cyan', icon='📅')
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
theme='green', icon='📊')
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
theme='gold', icon='🔥')
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(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(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(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):
cards_row.addWidget(c, 1)
@ -109,8 +109,8 @@ 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,
theme='gray', icon='📈')
chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart,
theme='gray', icon='trending-up')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
@ -127,14 +127,14 @@ class StatsView(QDialog):
# 카드 4개
cards_row = QHBoxLayout()
cards_row.setSpacing(10)
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
theme='blue', icon='⏱️')
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
theme='cyan', icon='📅')
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
theme='green', icon='📊')
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
theme='gold', icon='🔥')
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(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(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(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):
cards_row.addWidget(c, 1)
@ -143,9 +143,9 @@ class StatsView(QDialog):
# 추정 급여 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet(
f"background: rgba(74, 222, 128, 0.12); "
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; "
f"color: {ACCENT_GREEN}; font-weight: bold; "
f"background: rgba(81, 207, 102, 0.12); "
f"border: 1px solid {tc('green')}; border-radius: 8px; "
f"color: {tc('green')}; font-weight: bold; "
f"padding: 10px 14px; font-size: 11pt;"
)
self.salary_label.setVisible(False)
@ -159,8 +159,8 @@ 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,
theme='gray', icon='📊')
chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart,
theme='gray', icon='chart')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
@ -179,17 +179,17 @@ class StatsView(QDialog):
self.pattern_text.setWordWrap(True)
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.pattern_text.setStyleSheet(
f"font-size: 11pt; color: {DARK_TEXT}; "
f"font-size: 11pt; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
theme='cyan', icon='🔍'))
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,
theme='gray', icon=''), 1)
layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
theme='gray', icon='clock'), 1)
widget.setLayout(layout)
return widget
@ -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'))
# 테스트 코드

View File

@ -33,8 +33,8 @@ def _ensure_icons():
for name, color_hex, points in [
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
]:
path = os.path.join(_arrow_dir, f'{name}.png')
if not os.path.exists(path):
@ -78,6 +78,9 @@ LIGHT_COLORS = {
'bg_primary': '#F5F5F7',
'bg_secondary': '#FFFFFF',
'bg_tertiary': '#EDEDF0',
# 인터랙션 표면
'surface_hover': '#E2E3E7',
'surface_pressed': '#D5D6DB',
# 텍스트 계층
'text_primary': '#1A1A2E',
'text_secondary': '#4A4A68',
@ -85,9 +88,15 @@ LIGHT_COLORS = {
'text_inverse': '#FFFFFF',
# 액센트
'accent_primary': '#3B82F6',
'accent_primary_hover': '#2F74EE',
'accent_primary_pressed': '#2563EB',
'accent_success': '#10B981',
'accent_success_hover': '#0EA372',
'accent_success_pressed': '#0C8F63',
'accent_warning': '#F59E0B',
'accent_danger': '#EF4444',
'accent_danger_hover': '#DC2626',
'accent_danger_pressed': '#B91C1C',
# 테두리
'border_subtle': '#E5E7EB',
'border_default': '#D1D5DB',
@ -120,40 +129,58 @@ LIGHT_COLORS = {
}
DARK_COLORS = {
'bg_primary': '#111118',
'bg_secondary': '#1C1C2E',
'bg_tertiary': '#282842',
'text_primary': '#ECECF4',
'text_secondary': '#B0B0C8',
'text_tertiary': '#808098',
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
'bg_primary': '#1A1B1E', # 앱 배경
'bg_secondary': '#25262B', # 카드 / 패널
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
# 인터랙션 표면
'surface_hover': '#34363D',
'surface_pressed': '#3A3D44',
# 텍스트 계층
'text_primary': '#E9ECEF',
'text_secondary': '#909296',
'text_tertiary': '#6C6E73',
'text_inverse': '#FFFFFF',
'accent_primary': '#6B9EFF',
'accent_success': '#4ADE80',
'accent_warning': '#FCD34D',
'accent_danger': '#FB7185',
'border_subtle': '#32324E',
'border_default': '#44446A',
'border_focus': '#6B9EFF',
'badge_overtime_bg': '#3D2008',
'badge_overtime_text': '#FDE68A',
'badge_leave_bg': '#1E2D5F',
'badge_leave_text': '#A5D0FE',
'badge_total_bg': '#0A3324',
'badge_total_text': '#86EFAC',
'progress_bg': '#282842',
'progress_start': '#6B9EFF',
'progress_end': '#4ADE80',
'status_overtime': '#FB7185',
'status_warning': '#FCD34D',
'status_normal': '#4ADE80',
'status_break_active': '#FB7185',
'status_break_idle': '#808098',
'cal_normal': '#1A4D3A',
'cal_overtime': '#5C1A1A',
'cal_incomplete': '#5C3A10',
'scrollbar_bg': '#111118',
'scrollbar_handle': '#44446A',
'scrollbar_hover': '#5A5A88',
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
'accent_primary': '#4DABF7',
'accent_primary_hover': '#69B6F8',
'accent_primary_pressed': '#3D97E0',
'accent_success': '#51CF66',
'accent_success_hover': '#69DB7C',
'accent_success_pressed': '#43B85A',
'accent_warning': '#FAB005',
'accent_danger': '#FA5252',
'accent_danger_hover': '#FF6B6B',
'accent_danger_pressed': '#E64545',
# 테두리
'border_subtle': '#2C2E33',
'border_default': '#373A40',
'border_focus': '#4DABF7',
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
'badge_overtime_bg': '#2C2E33',
'badge_overtime_text': '#FAB005',
'badge_leave_bg': '#2C2E33',
'badge_leave_text': '#4DABF7',
'badge_total_bg': '#2C2E33',
'badge_total_text': '#51CF66',
# 프로그레스 — 단일 accent 솔리드
'progress_bg': '#2C2E33',
'progress_start': '#4DABF7',
'progress_end': '#4DABF7',
# 상태 색상 (동적 텍스트 피드백)
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
'status_warning': '#FAB005',
'status_normal': '#51CF66',
'status_break_active': '#FA5252',
'status_break_idle': '#6C6E73',
# 캘린더 날짜 배경 — 미묘한 다크 틴트
'cal_normal': '#1E3A2A',
'cal_overtime': '#3A2122',
'cal_incomplete': '#3A331E',
# 스크롤바
'scrollbar_bg': '#1A1B1E',
'scrollbar_handle': '#373A40',
'scrollbar_hover': '#4DABF7',
}
@ -192,7 +219,7 @@ QMainWindow, QDialog {{
}}
QWidget {{
font-family: "Segoe UI", "맑은 고딕", sans-serif;
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
font-size: 9.5pt;
color: {c['text_primary']};
}}
@ -206,14 +233,14 @@ QWidget#central_widget {{
*/
QLabel#app_title {{
font-size: 12pt;
font-size: 13pt;
font-weight: bold;
color: {c['text_primary']};
padding: 2px;
}}
QLabel#date_label {{
font-size: 9pt;
font-size: 9.5pt;
color: {c['text_secondary']};
padding-bottom: 4px;
}}
@ -221,7 +248,7 @@ QLabel#date_label {{
QLabel#section_title {{
font-size: 9.5pt;
font-weight: bold;
color: {c['text_primary']};
color: {c['text_secondary']};
}}
QLabel#field_label {{
@ -229,29 +256,30 @@ QLabel#field_label {{
color: {c['text_secondary']};
}}
/* 출근/현재 시각 나란히 표시되는 중간 크기 모노스페이스 */
QLabel#time_value {{
font-family: "Consolas", "D2Coding", monospace;
font-size: 11pt;
font-size: 15pt;
font-weight: bold;
color: {c['text_primary']};
}}
/* 히어로 남은 시간 (화면에서 가장 결과 표시). 카드 안에 투명 배치 */
QLabel#time_display {{
font-family: "Consolas", "D2Coding", monospace;
font-size: 22pt;
font-size: 30pt;
font-weight: bold;
color: {c['text_primary']};
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 10px;
padding: 10px;
background: transparent;
border: none;
padding: 4px 0;
}}
QLabel#expected_time {{
font-size: 10pt;
font-size: 11.5pt;
font-weight: bold;
color: {c['text_primary']};
padding: 4px;
color: {c['text_secondary']};
padding: 2px;
}}
QLabel#dialog_title {{
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
qproperty-alignment: AlignCenter;
background: {c['badge_overtime_bg']};
color: {c['badge_overtime_text']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_leave {{
@ -306,7 +334,7 @@ QLabel#badge_leave {{
qproperty-alignment: AlignCenter;
background: {c['badge_leave_bg']};
color: {c['badge_leave_text']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_total {{
@ -317,7 +345,7 @@ QLabel#badge_total {{
qproperty-alignment: AlignCenter;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_balance {{
@ -326,7 +354,7 @@ QLabel#badge_balance {{
padding: 10px;
background: {c['bg_tertiary']};
color: {c['text_primary']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_success {{
@ -335,7 +363,7 @@ QLabel#badge_success {{
padding: 8px;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
border-radius: 6px;
border-radius: 8px;
}}
/*
@ -355,9 +383,9 @@ QLabel#separator {{
QGroupBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 10px;
border-radius: 8px;
margin-top: 10px;
padding: 14px;
padding: 16px;
padding-top: 28px;
font-size: 9.5pt;
color: {c['text_primary']};
@ -378,52 +406,55 @@ QGroupBox::title {{
버튼
*/
/* 기본 버튼 그라데이션/베벨 없는 플랫 (border:none 기반) */
QPushButton {{
background: {c['bg_tertiary']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
padding: 7px 14px;
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 9pt;
}}
QPushButton:hover {{
background: {c['border_default']};
background: {c['surface_hover']};
}}
QPushButton:pressed {{
background: {c['border_subtle']};
background: {c['surface_pressed']};
}}
QPushButton:disabled {{
background: {c['bg_tertiary']};
background: {c['bg_secondary']};
color: {c['text_tertiary']};
border-color: {c['border_subtle']};
}}
QPushButton:checked {{
background: {c['accent_primary']};
color: {c['text_inverse']};
border-color: {c['accent_primary']};
}}
/* 퇴근 버튼 (primary action) */
QPushButton:focus {{
outline: none;
}}
/* 퇴근 버튼 주요 액션 (단일 포인트 컬러) */
QPushButton#clock_out_button {{
background: {c['accent_success']};
background: {c['accent_primary']};
color: {c['text_inverse']};
font-size: 11pt;
font-weight: bold;
padding: 8px;
padding: 11px;
border: none;
border-radius: 8px;
}}
QPushButton#clock_out_button:hover {{
background: {'#0EA572' if not is_dark else '#2BB885'};
background: {c['accent_primary_hover']};
}}
QPushButton#clock_out_button:pressed {{
background: {'#0C8F63' if not is_dark else '#28A87A'};
background: {c['accent_primary_pressed']};
}}
/* 주요 액션 버튼 */
@ -435,11 +466,11 @@ QPushButton#btn_primary {{
}}
QPushButton#btn_primary:hover {{
background: {c['accent_primary']}DD;
background: {c['accent_primary_hover']};
}}
QPushButton#btn_primary:pressed {{
background: {c['accent_primary']}BB;
background: {c['accent_primary_pressed']};
}}
/* 위험 버튼 */
@ -450,11 +481,11 @@ QPushButton#btn_danger {{
}}
QPushButton#btn_danger:hover {{
background: {c['accent_danger']}DD;
background: {c['accent_danger_hover']};
}}
QPushButton#btn_danger:pressed {{
background: {c['accent_danger']}BB;
background: {c['accent_danger_pressed']};
}}
/* 성공 버튼 */
@ -465,25 +496,44 @@ QPushButton#btn_success {{
}}
QPushButton#btn_success:hover {{
background: {c['accent_success']}DD;
background: {c['accent_success_hover']};
}}
QPushButton#btn_success:pressed {{
background: {c['accent_success']}BB;
background: {c['accent_success_pressed']};
}}
/* 작은 버튼 */
/* 작은 버튼 미묘한 표면 */
QPushButton#btn_small {{
font-size: 8.5pt;
padding: 5px 10px;
padding: 6px 10px;
}}
QPushButton#btn_small:hover {{
background: {c['accent_primary']}20;
background: {c['surface_hover']};
}}
QPushButton#btn_small:pressed {{
background: {c['accent_primary']}35;
background: {c['surface_pressed']};
}}
/* 하단 네비게이션 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 ) */
QPushButton#nav_btn {{
background: transparent;
border: none;
border-radius: 8px;
padding: 8px 4px;
font-size: 8.5pt;
color: {c['text_secondary']};
}}
QPushButton#nav_btn:hover {{
background: {c['surface_hover']};
color: {c['text_primary']};
}}
QPushButton#nav_btn:pressed {{
background: {c['surface_pressed']};
}}
/*
@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
QLineEdit, QTextEdit, QComboBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
border-radius: 8px;
padding: 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
border-radius: 8px;
padding: 6px 28px 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
min-height: 20px;
}}
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
border: 2px solid {c['border_focus']};
padding: 5px 7px;
}}
/* 포커스 보더 컬러만 포인트 컬러로 (두께 유지 레이아웃 흔들림 없음) */
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
border: 2px solid {c['border_focus']};
padding: 5px 27px 5px 7px;
border: 1px solid {c['border_focus']};
}}
/* 비활성 입력 필드 */
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
QSpinBox::up-button, QDoubleSpinBox::up-button,
QDateEdit::up-button, QTimeEdit::up-button {{
subcontrol-position: top right;
border-top-right-radius: 4px;
border-top-right-radius: 7px;
}}
QSpinBox::down-button, QDoubleSpinBox::down-button,
QDateEdit::down-button, QTimeEdit::down-button {{
subcontrol-position: bottom right;
border-bottom-right-radius: 4px;
border-bottom-right-radius: 7px;
}}
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
QProgressBar {{
border: none;
background: {c['progress_bg']};
border-radius: 4px;
height: 8px;
border-radius: 3px;
min-height: 6px;
max-height: 6px;
text-align: center;
color: transparent;
font-size: 0px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
border-radius: 4px;
background: {c['progress_start']};
border-radius: 3px;
}}
/*
@ -648,7 +694,7 @@ QProgressBar::chunk {{
QTableWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
border-radius: 8px;
gridline-color: {c['border_subtle']};
color: {c['text_primary']};
font-size: 9pt;
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
background: {c['bg_tertiary']};
}}
/* 헤더 위젯 배경 (세로헤더 영역의 흰색 누수 방지) */
QHeaderView {{
background: {c['bg_secondary']};
border: none;
}}
QHeaderView::section {{
background: {c['bg_tertiary']};
color: {c['text_secondary']};
padding: 8px;
border: none;
border-bottom: 2px solid {c['accent_primary']};
font-weight: bold;
font-size: 9pt;
}}
QHeaderView::section:horizontal {{
border-bottom: 2px solid {c['accent_primary']};
}}
/* 세로헤더(행번호) accent 밑줄 없이 미묘하게 */
QHeaderView::section:vertical {{
border-right: 1px solid {c['border_subtle']};
color: {c['text_tertiary']};
font-weight: normal;
padding: 4px 8px;
}}
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
QTableView QTableCornerButton::section {{
background: {c['bg_tertiary']};
border: none;
border-bottom: 2px solid {c['accent_primary']};
}}
/*
위젯
*/
QTabWidget::pane {{
border: 1px solid {c['border_subtle']};
border-radius: 6px;
border-radius: 8px;
background: {c['bg_secondary']};
top: -1px;
}}
@ -694,8 +764,8 @@ QTabBar::tab {{
padding: 8px 20px;
border: 1px solid {c['border_subtle']};
border-bottom: none;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 2px;
font-size: 10pt;
}}
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
QCalendarWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
border-radius: 8px;
font-size: 10pt;
}}
@ -902,7 +972,7 @@ QToolTip {{
QMenu {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
border-radius: 8px;
padding: 4px;
color: {c['text_primary']};
}}
@ -916,6 +986,16 @@ QMenu::item:selected {{
background: {c['accent_primary']};
color: {c['text_inverse']};
}}
QMenu::separator {{
height: 1px;
background: {c['border_subtle']};
margin: 4px 8px;
}}
QMenu::icon {{
padding-left: 8px;
}}
"""

View File

@ -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):
"""퇴근 처리 직후 표시되는 요약 카드."""
@ -16,12 +18,12 @@ class TodaySummaryCard(QFrame):
self.setObjectName("today_summary_card")
self.setStyleSheet("""
QFrame#today_summary_card {
background-color: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.4);
background-color: rgba(81, 207, 102, 0.08);
border: 1px solid rgba(81, 207, 102, 0.40);
border-radius: 8px;
padding: 6px;
}
QLabel { padding: 1px; }
QLabel { padding: 1px; background: transparent; border: none; }
""")
self.setVisible(False)
@ -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()
@ -43,9 +45,9 @@ class TodaySummaryCard(QFrame):
self.total_label = QLabel("")
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
layout.addWidget(self.total_label)
layout.addWidget(self.detail_label)
@ -70,22 +72,22 @@ 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))
if salary_text:
self.salary_label.setText(f"💰 {salary_text}")
self.salary_label.setText(f"{salary_text}")
self.salary_label.setVisible(True)
else:
self.salary_label.setVisible(False)

View File

@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
3. new_exe target_exe 이동
4. target_exe 재실행 + 업데이터 자가 종료
실패 .bak 복원
빌드: console=False (windowed) 사용자 눈엔 cmd 창이 보임.
모든 진단 출력은 ~/.clockout_logs/updater.log append.
"""
from __future__ import annotations
import argparse
@ -21,9 +24,30 @@ import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'
def _log(msg: str) -> None:
"""진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
# stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
try:
print(line, end='', file=sys.stderr)
except Exception:
pass
try:
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_LOG_PATH, 'a', encoding='utf-8') as f:
f.write(line)
except OSError:
pass
def is_pid_running(pid: int) -> bool:
"""Windows에서 PID 실행 중인지 확인."""
if sys.platform != 'win32':
@ -81,8 +105,7 @@ def replace_file(new_path: Path, target_path: Path,
try:
backup.unlink()
except OSError as e:
print(f"[updater] old backup unlink failed (continuing): {e}",
file=sys.stderr)
_log(f"[updater] old backup unlink failed (continuing): {e}")
# 2단계: target → backup 이동 (락 해제 대기 재시도)
for attempt in range(max_retries):
@ -94,13 +117,12 @@ def replace_file(new_path: Path, target_path: Path,
except OSError as e:
last_err = e
wait = 0.3 * (2 ** attempt)
print(f"[updater] target move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait)
else:
# 모든 재시도 실패
print(f"[updater] target move failed after {max_retries} attempts: {last_err}",
file=sys.stderr)
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
return None
# 3단계: new → target 이동
@ -111,19 +133,18 @@ def replace_file(new_path: Path, target_path: Path,
except OSError as e:
last_err = e
wait = 0.3 * (2 ** attempt)
print(f"[updater] new move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait)
# new 이동 실패 → backup으로 롤백 시도
print(f"[updater] new move failed after {max_retries} attempts: {last_err}",
file=sys.stderr)
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
if backup.exists() and not target_path.exists():
try:
shutil.move(str(backup), str(target_path))
print("[updater] rolled back from backup", file=sys.stderr)
_log("[updater] rolled back from backup")
except OSError as e:
print(f"[updater] rollback also failed: {e}", file=sys.stderr)
_log(f"[updater] rollback also failed: {e}")
return None
@ -131,13 +152,20 @@ def launch(exe_path: Path) -> bool:
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
try:
if sys.platform == 'win32':
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
DETACHED_PROCESS = 0x00000008
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
CREATE_NO_WINDOW = 0x08000000
subprocess.Popen(
[str(exe_path)],
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
close_fds=True,
)
else:
subprocess.Popen([str(exe_path)], close_fds=True)
return True
except OSError as e:
print(f"[updater] launch failed: {e}", file=sys.stderr)
_log(f"[updater] launch failed: {e}")
return False
@ -149,15 +177,17 @@ def main() -> int:
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
args = parser.parse_args()
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
new_exe = Path(args.new).resolve()
target_exe = Path(args.target).resolve()
if not new_exe.exists():
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
_log(f"[updater] new exe not found: {new_exe}")
return 2
if not wait_for_exit(args.pid, timeout_sec=30):
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
_log(f"[updater] timeout waiting for PID {args.pid}")
return 3
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
@ -166,13 +196,15 @@ def main() -> int:
backup = replace_file(new_exe, target_exe)
if backup is None:
_log("[updater] replace_file failed — aborting")
return 4
if args.no_launch:
_log("[updater] --no-launch set, exiting after replace")
return 0
if not launch(target_exe):
# 시작 실패 시 롤백
_log("[updater] launch failed — rolling back")
try:
target_exe.unlink()
shutil.move(str(backup), str(target_exe))
@ -181,7 +213,7 @@ def main() -> int:
pass
return 5
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
_log("[updater] update complete, new app launched")
return 0

View File

@ -33,7 +33,7 @@ exe = EXE(
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,

View File

@ -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'],))

View File

@ -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

84
utils/font_loader.py Normal file
View File

@ -0,0 +1,84 @@
"""번들 폰트(NanumSquare) 로딩.
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
등록 실패 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
"""
from __future__ import annotations
import os
import sys
from PyQt5.QtGui import QFontDatabase, QFont
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
_FONT_FILES = [
'NanumSquareL.ttf',
'NanumSquareR.ttf',
'NanumSquareB.ttf',
'NanumSquareEB.ttf',
'NanumSquare_acR.ttf',
'NanumSquare_acB.ttf',
]
def _font_dir() -> str:
"""번들 font/ 디렉토리 절대 경로."""
if getattr(sys, 'frozen', False):
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, 'font')
def load_bundled_fonts() -> list:
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
families: list = []
fdir = _font_dir()
if not os.path.isdir(fdir):
return families
for name in _FONT_FILES:
path = os.path.join(fdir, name)
if not os.path.exists(path):
continue
fid = QFontDatabase.addApplicationFont(path)
if fid == -1:
continue
for fam in QFontDatabase.applicationFontFamilies(fid):
if fam not in families:
families.append(fam)
return families
def _pick_primary(families: list) -> str:
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
if 'NanumSquare' in families:
return 'NanumSquare'
for fam in families:
low = fam.lower()
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
return fam
return 'Malgun Gothic'
def apply_app_font(app, point_size: int = 9) -> str:
"""앱 전역 기본 폰트를 NanumSquare로 설정.
Returns:
실제 적용된 primary family 이름 (폴백 'Malgun Gothic').
"""
families = load_bundled_fonts()
primary = _pick_primary(families)
font = QFont(primary, point_size)
font.setStyleStrategy(QFont.PreferAntialias)
app.setFont(font)
return primary
if __name__ == '__main__':
from PyQt5.QtWidgets import QApplication
_app = QApplication(sys.argv)
fams = load_bundled_fonts()
print('font dir:', _font_dir())
print('registered families:', fams)
print('picked primary:', _pick_primary(fams))

100
utils/holiday_api.py Normal file
View File

@ -0,0 +1,100 @@
"""
공공데이터포털 한국천문연구원 특일정보 OpenAPI 클라이언트.
엔드포인트: getRestDeInfo (국경일/공휴일 임시공휴일 포함)
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
`holidays` 패키지가 누락하는 임시공휴일·근로자의 등을
정부 공인 데이터로 보강하기 위해 사용.
설계:
- 네트워크 실패는 silent (None 반환) 호출자가 fallback 처리
- API 키는 코드 박혀있으나 dev 본인 계정의 특일정보 API 한정
(50 이내 사용 환경에서 일일 한도 1000 충분)
"""
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
import urllib.error
from typing import List, Dict, Optional
# 공공데이터포털 특일정보 API 서비스 키.
# 소스코드/바이너리 노출 방지를 위해 환경변수에서 읽습니다.
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
_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)'
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
Returns:
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 )
"""
params = {
'serviceKey': _SERVICE_KEY,
'solYear': str(year),
'_type': 'json',
'numOfRows': '100',
'pageNo': '1',
}
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except (urllib.error.URLError, urllib.error.HTTPError,
json.JSONDecodeError, OSError, TimeoutError):
return None
return _parse_response(data)
def _parse_response(data: Dict) -> Optional[List[Dict]]:
"""API 응답 JSON을 표준 형식으로 정규화.
API 응답 패턴:
- resultCode == '00' 정상
- items.item: 단일 결과면 dict, 여러 개면 list
- items가 문자열일 (totalCount=0) 정상으로 간주
"""
try:
response = data.get('response') or {}
header = response.get('header') or {}
if header.get('resultCode') != '00':
return None
body = response.get('body') or {}
items_root = body.get('items')
if not items_root:
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
item = items_root.get('item') if isinstance(items_root, dict) else None
if item is None:
return []
if isinstance(item, dict):
item = [item]
out = []
for entry in item:
locdate = entry.get('locdate')
name = entry.get('dateName')
is_holiday = (entry.get('isHoliday') == 'Y')
if not locdate or not name:
continue
# locdate: 20260501 (int 또는 str)
ds = str(locdate)
if len(ds) != 8 or not ds.isdigit():
continue
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
return out
except (AttributeError, TypeError, KeyError):
return None
def is_configured() -> bool:
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10

View File

@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
return QIcon(pixmap)
def setup_menu(self):
"""트레이 메뉴 설정"""
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
menu = QMenu()
show_action = QAction(tr('tray.open'), self)
show_action.triggered.connect(self.show_window)
menu.addAction(show_action)
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
self._icon_actions = []
mini_action = QAction(tr('tray.mini_widget'), self)
mini_action.triggered.connect(self._open_mini_widget)
menu.addAction(mini_action)
def add(text, slot, icon_name=None):
action = QAction(text, self)
action.triggered.connect(slot)
menu.addAction(action)
if icon_name:
self._icon_actions.append((action, icon_name))
return action
add(tr('tray.open'), self.show_window, 'home')
add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
menu.addSeparator()
lunch_action = QAction(tr('tray.toggle_lunch'), self)
lunch_action.triggered.connect(self._toggle_lunch)
menu.addAction(lunch_action)
break_out_action = QAction(tr('btn.break_out'), self)
break_out_action.triggered.connect(self._break_out)
menu.addAction(break_out_action)
break_in_action = QAction(tr('btn.break_in'), self)
break_in_action.triggered.connect(self._break_in)
menu.addAction(break_in_action)
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
add(tr('btn.break_out'), self._break_out)
add(tr('btn.break_in'), self._break_in)
menu.addSeparator()
clock_out_action = QAction("" + tr('btn.clock_out'), self)
clock_out_action.triggered.connect(self.quick_clock_out)
menu.addAction(clock_out_action)
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
menu.addSeparator()
stats_action = QAction("📊 " + tr('menu.stats'), self)
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
menu.addAction(stats_action)
cal_action = QAction("📅 " + tr('menu.calendar'), self)
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
schedule_action = QAction("🗓️ 스케줄", self)
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
menu.addAction(schedule_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
menu.addSeparator()
quit_action = QAction(tr('tray.quit'), self)
quit_action.triggered.connect(self.quit_app)
menu.addAction(quit_action)
add(tr('tray.quit'), self.quit_app)
self.setContextMenu(menu)
self.refresh_theme()
def refresh_theme(self):
"""트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
QMenu() 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
명시적으로 적용한다. 테마 변경 main_window.apply_theme에서 호출.
"""
menu = self.contextMenu()
if menu is None:
return
# 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
qss = self.parent_window.styleSheet() if self.parent_window else ''
if not qss:
try:
from ui.styles import get_theme
qss = get_theme('dark')
except Exception:
qss = ''
if qss:
menu.setStyleSheet(qss)
# 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
try:
from ui.icons import get_icon
from ui.styles import ThemeColors
color = ThemeColors.get('text_primary')
for action, name in getattr(self, '_icon_actions', []):
action.setIcon(get_icon(name, color, 16))
except Exception:
pass
def _call_parent(self, method_name: str):
if self.parent_window and hasattr(self.parent_window, method_name):

View File

@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
pid = os.getpid()
try:
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
if sys.platform == 'win32':
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
else:
creationflags = 0
subprocess.Popen(
[str(updater_exe),
'--pid', str(pid),