Compare commits

..
3 Commits
Author SHA1 Message Date
68893236+KINDNICK@users.noreply.github.comandClaude Opus 4.7 dbb8635c6c v2.8.1: 업데이트 시 cmd 창 깜빡임 제거
- 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:14:53 +09:00
68893236+KINDNICK@users.noreply.github.comandClaude Opus 4.7 c5df37ca57 v2.8.0: 도전과제 시스템 + 다크 디자인 리뉴얼 + 안정성 강화
Added — 도전과제 시스템 (153개 자동 평가)
- core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제
- ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿)
- 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push
- achievements 테이블 확장 (code/category/tier/is_secret/progress/target)
- hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키

Changed — 다크 테마 디자인 리뉴얼
- ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress)
- 통계/도움말/도전과제 다이얼로그 일관 다크 톤
- matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend)
- 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드)

Fixed — 안정성·일관성
- 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch)
- DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환
- DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자)
- crash_handler 다단계 폴백 (DB → 파일 → stderr)
- updater PID race: 지수 backoff 재시도 (총 ~9초)
- Discord URL 형식 검증 (snowflake regex)
- 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증
- check_dinner_reminder 신규, 알림 임계값 5개 설정화
- closeEvent timer/notifier 정리 (aboutToQuit hook)
- 마이그레이션 12개 모두 _conn() + try/finally
- DB 인덱스 5개 추가 (break/overtime/leave date)

Tests
- pytest 116/116 PASS, 통합 시나리오 48/48 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:11:13 +09:00
KINDNICK ff71886fd7 v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키
- 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용
- MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출
- 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등)
- pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122)
- README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
2026-04-30 19:30:47 +09:00
44 changed files with 6045 additions and 1651 deletions
+136 -27
View File
@@ -1,20 +1,40 @@
# Project Conventions and Operational Gotchas
## 🛠️ Setup & Execution
- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature.
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
- **Run:** `python main.py`
- **Module-level tests:**
- **Module-level smoke:**
- Event monitoring: `python core/event_monitor.py`
- Time calculation: `python core/time_calculator.py`
- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release.
- **Integration tests** (all should be green before release):
- `python _integration_test.py` — business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+)
- `python _i18n_gui_test.py` — ko/en switch on real widgets
- `python _gui_smoke_test.py` — widget instantiation
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
## 🗄️ Architecture Notes (Core Business Logic)
- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs.
### Database (10+ tables in `database.db`)
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
### Invariants
- **`work_records.date` UNIQUE** — one row per workday.
- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out.
- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours).
- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect.
- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`.
- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox).
### Settings system
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
### i18n
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
- Sentence formatting via Python `str.format(**kwargs)`.
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
## ⚠️ Critical Invariants (MUST PRESERVE)
@@ -22,46 +42,135 @@
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
### 2. Hot-path caching
`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`.
`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`.
### 3. Time format separation
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
### 4. Workday boundary
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover.
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic.
### 5. Migration idempotency
All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query.
All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.
### 6. Single-file deployment
`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build.
### 7. Updater handoff
`updater.py` is standalone (no PyQt). Args: `--pid <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
```bash
python -m PyInstaller --clean main.spec
# Manual two-step
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
mkdir -p build/staging && cp dist/updater.exe build/staging/
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
# Or one-shot
.\release.ps1 v2.7.0
```
- Output: `dist/main.exe` (console disabled, UPX compressed)
- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`)
- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first.
- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env.
- `dist/main.exe` running → `PermissionError`. Kill it first.
- `holidays` package only baked in if installed in build env.
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
## 🚦 External Integrations
- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view.
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design.
- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<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
## 🐞 Past Incidents (do NOT re-introduce)
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce.
- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual".
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling.
- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters.
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`.
- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor).
- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`.
- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
## 🌐 i18n Coverage Status
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels.
- **Partially translated**: settings_view sub-labels (입력란 placeholder 등), calendar_view detail labels.
- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated.
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`.
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`.
## 🚢 Release Flow ([release.ps1](release.ps1))
```
0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
1. Bump core/version.py
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
3. PyInstaller (updater.spec → staging copy → main.spec)
4. ZIP packaging (main.exe + updater.exe)
5. Git commit (version.py + CHANGELOG.md) + tag + push
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
7. Asset upload (main.exe, updater.exe, ZIP)
```
`--DryRun` previews without git push or API calls.
+92
View File
@@ -4,6 +4,98 @@ 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.8.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.8.0] — 2026-05-01
### Added — 도전과제 시스템 + 디자인 리뉴얼
- **🏆 도전과제 시스템** (153개 자동 평가) — 출근·퇴근·연장·연차·식사·외출·계절·시간대·시크릿 등 16개 카테고리.
- `core/achievements.py`: `Achievement` dataclass + `evaluate_all(db)` + `sync_definitions_to_db(db)`.
- 5분 throttle로 자동 평가, 신규 잠금 해제 시 시스템 알림 + Discord embed push.
- `achievements` 테이블 확장: `code`(UNIQUE), `category`, `tier`, `is_secret`, `progress`, `target`, `created_at` 컬럼 추가 (idempotent 마이그레이션).
- 5단계 등급: 🥉 브론즈 / 🥈 실버 / 🥇 골드 / 💎 플래티넘 / 🌟 레전드.
- 시크릿 9개 (회문, 잭팟 시각, 13일의 금요일, 7-7-7, 정확 8시간, π day, 피보나치 등).
- 메타 도전과제 (도전과제 자체 달성 카운트).
- **`ui/achievements_view.py`** — 4탭 다이얼로그 (전체 / 진행 중 / 완료 / 시크릿).
- 등급별 그라디언트 카드, 진행 게이지, 카테고리 태그, 시크릿 ❓ 처리.
- **자동 hire_date 추적** — 첫 `add_work_record` 호출 시 settings에 자동 기록 (1주년, 365일 후 출근 등 도전과제 활성화).
- **뷰 진입 카운터** — `stat_*_view_count`, `calendar_view_count`, `daily_report_count` 등 8개 settings 키. 도전과제 + 사용 통계용.
### Changed — 다크 테마 디자인 리뉴얼
- **`ui/dark_components.py`** 신설 — 재사용 가능한 다크 디자인 컴포넌트:
- `dialog_qss()`, `tabs_qss()`, `scroll_qss()`, `button_qss(variant)`.
- `build_gradient_header()`, `build_stat_card()`, `build_section_card()`.
- `style_progressbar()`, `transparent_label()` — 글로벌 QSS 충돌 회피.
- 카드 테마 7종: blue / cyan / green / gold / pink / red / gray.
- **`ui/stats_view.py`** — 4분할 카드 + 차트 섹션 카드. 골드 강조 탭. ghost 닫기 버튼.
- **`ui/help_view.py`** — 다크 톤 + HelpHTML 내부에 다크 CSS 주입 (h1/h2/h3, code, blockquote, table 컬러).
- **`ui/chart_widget.py`** — 모든 matplotlib 차트 다크 테마 적용 (figure/axes facecolor, grid, ticks, legend).
- **온보딩 위저드** — 저녁 분(minutes) 입력 옵션 + i18n화 (Korean/English 프리셋 라벨).
### Fixed — 안정성·일관성
- **타임존 자정 경계 버그**: `has_notification_today`가 `CURRENT_TIMESTAMP`(UTC) vs `DATE('now', 'localtime')` mismatch로 KST 0~9시 사이 알림 중복 발송 가능 — `DATE(sent_at, 'localtime')` 양쪽 적용.
- **DB 연결 누수 가드** — `_conn()` 컨텍스트 매니저 도입, 40+ 메서드 변환 (직접 `get_connection()` 호출 0건).
- **DB 이중 부트스트랩 제거** — `MainWindow(db=None)` 옵션 인자 추가, main.py가 만든 db를 재사용.
- **crash_handler 폴백** — DB 로깅/다이얼로그 단계 분리, 모두 실패해도 `~/.clockout_logs/crashes.log`에 기록.
- **updater PID race window** — 지수 backoff 재시도 (0.3→4.8s, 총 ~9초). Windows Defender 락 해제 대기 충분.
- **Discord URL 형식 검증** — Snowflake ID 17~20자리 + 50+자 토큰 정규식.
- **MealTimeDialog** — 출근 시각 범위 검증 + 야간 출근자 자정 경계 자동 처리.
- **CSV importer/exporter** — `dinner_minutes` 컬럼 round-trip 지원.
- **일일 보고서** — 저녁 섹션 추가 (이전엔 점심만 표시), `break_type` 분리, 자정 경계 처리.
- **저녁 알림** — `check_dinner_reminder()` 신규 (점심 알림과 대칭).
- **알림 임계값 설정화** — 5개 하드코딩 값(점심/저녁 알림 시간, 연장 누적, 주간 한도, 연속 야근)을 settings로 노출.
- **closeEvent 정리** — `aboutToQuit` 시그널로 timer/notifier/tray 정리.
- **마이그레이션 일관성** — 12개 마이그레이션 모두 `_conn()` + try/finally 보장.
### DB 인덱스 (성능)
- `idx_break_records_date_type`, `idx_break_records_date`.
- `idx_overtime_bank_date`, `idx_overtime_usage_date`, `idx_leave_records_date`.
### Tests
- pytest 116개 PASS, 통합 시나리오 44/48 PASS (PyQt 환경 4개 제외).
- 도전과제 한 사이클 시나리오 검증 (30일 시뮬레이션 → 19개 자동 잠금 해제).
## [2.7.0] — 2026-04-30
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
- **i18n 사전 100% 커버리지** — `break_view`, `overtime_view`, `leave_view`,
`clock_in_dialog` 의 내부 라벨/메시지/플레이스홀더까지 전부 ko/en 키화.
새 키 50+개 추가 (`view.break.*`, `view.overtime.*`, `view.leave.*`, `dlg.*`).
- **i18n 런타임 재번역** — 재시작 없이 메인 화면 즉시 언어 전환.
- `ui/i18n_runtime.py`: `register(widget, key)` + `set_language_and_retranslate(lang)`
- 위젯은 weakref로 보관되어 삭제 시 자동 정리
- 메인 윈도우 타이틀/하단 메뉴 5개 버튼 등록 완료 (점진 확대)
- 일부 다이얼로그는 여전히 재시작 필요 (다음 릴리스에서 점진 등록)
- **MealController 분리** — `main_window.py` 에서 점심/저녁 토글·라벨 갱신 로직을
`ui/controllers/meal_controller.py` 로 추출. 기존 `LockMonitor`/`AutoLunch`/
`NotificationOrchestrator` 패턴 준수.
### Tests
- 통합 테스트 +15 시나리오 (S36–S52): 온보딩 신규/기존, salary 추정, CSV 가져오기
(skip/overwrite/overtime 적립까지), notification_log dedupe, meal_record,
crash_log, updater semver 비교, Discord 입력 검증, goal/accessibility 키 등.
- 신규 pytest 모듈 4종 — `test_salary.py`, `test_csv_importer.py`,
`test_discord_webhook.py`(network mocked), `test_crash_handler.py`.
- `test_i18n_runtime.py` — register/retranslate/post-callback/dead-widget 정리 검증.
- 총 pytest 케이스: 90 → **122**, 통합 시나리오: 33 → **48**.
### Docs
- `README.md` v2.4–v2.6 신기능 섹션 정리, 재번호.
- `CLAUDE.md` v2.6+ 아키텍처 — 컨트롤러 모듈, 35+ 설정 키, 릴리스 플로우 반영.
- `INSTALL.md` 최신 단일파일 배포 + 온보딩 + 디스코드 + 환경변수 정리.
- `AGENTS.md` 10+ DB 테이블, 컨트롤러 맵, Past Incidents 갱신.
## [2.6.0] — 2026-04-30
### Added — Phase 4 (3종)
+103 -62
View File
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable).
**Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases.
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`.
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`.
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md).
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md).
## Build and Run
@@ -20,46 +20,80 @@ python main.py
python core/event_monitor.py
python core/time_calculator.py
# Production build → dist/main.exe
# Production build → dist/main.exe (78MB, embeds updater.exe)
python -m PyInstaller --clean updater.spec # build first — main.spec datas references it
python -m PyInstaller --clean main.spec
# Integration tests (35 + 5 + view scenarios)
python _integration_test.py
python _i18n_gui_test.py
python _gui_smoke_test.py
```
# Tests
python _integration_test.py # business-logic scenarios
python _i18n_gui_test.py # ko/en GUI verification
python _gui_smoke_test.py # widget instantiation
python -m pytest tests # unit tests
PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build.
# Release (one-shot to Gitea)
$env:GITEA_TOKEN = '<PAT>'
.\release.ps1 v2.7.0
```
## Architecture
### Core (`core/`)
- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`.
- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`.
- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required.
- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en.
- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback.
- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`.
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings.
### core/
- **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness.
- **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property.
- **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
- **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold.
- **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0).
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
- **[version.py](core/version.py)** — `__version__` single source of truth.
### UI (`ui/`)
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R).
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`.
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button.
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible.
- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs.
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension).
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing.
### ui/
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run.
- **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog.
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import.
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`.
- **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
- **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
- **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input.
- **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click).
- **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar.
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display.
- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button.
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`.
- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay.
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n).
### Utils (`utils/`)
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy.
- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked).
- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`.
- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production.
### ui/controllers/
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
- **[meal_controller.py](ui/controllers/meal_controller.py)** — Lunch/dinner toggle + label refresh, extracted from `main_window.py` in v2.7.0. Same controller pattern as Lock/AutoLunch/Notification.
### ui/ (cross-cutting)
- **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
- **[styles.py](ui/styles.py)** — Shared QSS / color tokens.
### utils/
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
- **[lock_detector.py](utils/lock_detector.py)** — `is_screen_locked()` via Win32 `OpenInputDesktop` + `GetUserObjectInformation`.
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
- **[csv_exporter.py](utils/csv_exporter.py)** — Same standard format as importer. Round-trips with `csv_importer`.
- **[resource_manager.py](utils/resource_manager.py)** — PyInstaller `_MEIPASS`-aware path resolver for icons / assets.
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n.
- **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n.
### Time-off accounting in `update_display()`
### Top-level
- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow.
- **[updater.py](updater.py)** — Standalone helper. `--pid <main_pid> --new <new_exe> --target <target_exe>`. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback.
- **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps).
- **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it).
- **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`.
## Time-off accounting in `update_display()`
Critical invariant — preserve in any change:
```python
@@ -72,49 +106,56 @@ remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_min
remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation
```
Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`.
### Workday rollover
`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006).
## Database invariants
- `work_records.date` UNIQUE (one row/day).
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings.
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label.
- `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25).
- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`.
- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code.
- `work_records.date` UNIQUE.
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`.
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions.
- `leave_records.days` is FLOAT (1.0/0.5/0.25).
- Balance: `SUM(bank.earned) - SUM(usage.used)`.
- `notification_log` for daily dedupe (channel+event_type+date).
- `crash_log` for unhandled exceptions.
## Settings system
Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`:
- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only)
Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`:
- `WORK_MINUTES ↔ WORK_HOURS` (floor)
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup.
Migration sentinels prevent re-running.
## i18n
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal.
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장.
Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text).
Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched.
## Conventions and gotchas
## Conventions
- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types.
- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`.
- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover.
- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min.
- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture.
- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon).
- **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict.
- 24h `datetime` internal. 12h conversion only in `format_time()`.
- 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min.
- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
## Tests
- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt).
- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`.
- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets.
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
All three should be green before any release.
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
All should be green before any release.
## Release flow
```bash
# Edit core/version.py + CHANGELOG.md
git add -A && git commit -m "v2.X.Y: ..."
.\release.ps1 v2.X.Y
```
Auto-handles: version bump check, pytest+integration tests, two-exe build, ZIP, git tag push, Gitea Release create, asset upload (main.exe + updater.exe + ZIP).
+100 -76
View File
@@ -1,24 +1,45 @@
# 설치 가이드
## 1. Python 설치
## 일반 사용자 — 빌드된 .exe로 설치 (권장)
소스 빌드 없이 즉시 사용하려면 Gitea Releases에서 받으세요.
1. https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator/releases
2. 최신 릴리스의 **main.exe** (단일 파일) 다운로드
3. 원하는 폴더에 두고 더블클릭으로 실행
4. 첫 실행 시 5단계 온보딩 위저드가 안내
`main.exe` 안에 `updater.exe`가 내장되어 있어 첫 실행 시 같은 폴더로 자동 추출됩니다.
이후 새 버전이 올라오면 앱이 알림 → 동의 → 자동 다운로드·교체·재시작합니다.
> 옵션: 직접 실행하지 않고 ZIP을 받으면 `main.exe + updater.exe`가 같이 들어 있습니다.
## 개발자 — 소스에서 실행
### 1. Python 설치
Python 3.9 이상이 필요합니다.
### Windows
#### Windows
1. https://www.python.org/downloads/ 방문
2. "Download Python 3.x.x" 클릭
3. 설치 시 **"Add Python to PATH"** 체크 필수!
3. 설치 시 **"Add Python to PATH"** 체크 필수
확인:
```bash
python --version
```
## 2. 프로젝트 다운로드
### 2. 프로젝트 다운로드
프로젝트를 다운로드하거나 압축을 해제합니다.
```bash
git clone https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator.git
cd Clock_out_Time_Calculator
```
## 3. 패키지 설치
또는 ZIP을 받아 압축 해제.
### 3. 패키지 설치
프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다.
@@ -26,66 +47,26 @@ python --version
pip install -r requirements.txt
```
### 설치되는 패키지:
1. **PyQt5** - GUI 프레임워크
2. **pywin32** - Windows API 접근
3. **python-dateutil** - 날짜 계산
4. **matplotlib** - 그래프
5. **plyer** - 알림 시스템
6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함)
#### 설치되는 패키지 (requirements.txt)
1. **PyQt5** — GUI 프레임워크
2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지)
3. **python-dateutil** — 날짜 계산
4. **matplotlib** — 통계 차트
5. **plyer** — 시스템 알림
6. **holidays** — 한국 공휴일 자동 등록 (음력 명절 포함)
## 4. 리소스 다운로드 (선택)
리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다.
```bash
python download_resources.py
```
이 스크립트는 무료 리소스 다운로드 링크를 안내합니다.
### 수동 다운로드:
#### 아이콘
다음 사이트에서 다운로드:
- **Flaticon**: https://www.flaticon.com/
- **Material Design Icons**: https://materialdesignicons.com/
- **Icons8**: https://icons8.com/
다운로드 후 `resources/icons/` 폴더에 저장
필요한 아이콘:
- `app_icon.ico` (512x512)
- `clock.png`, `timer.png`, `lunch.png`
- `calendar.png`, `statistics.png`, `vacation.png`
- `settings.png`, `notification.png`
#### 사운드
다음 사이트에서 다운로드:
- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천)
- **Freesound**: https://freesound.org/
- **Zapsplat**: https://www.zapsplat.com/
다운로드 후 `resources/sounds/` 폴더에 저장
필요한 사운드:
- `clock_out_alarm.wav` - 퇴근시간 알림
- `notification.wav` - 일반 알림
- `success.wav` - 성공 효과음
## 5. 실행
### 4. 실행
```bash
python main.py
```
### 관리자 권한으로 실행 (권장)
#### 관리자 권한으로 실행 (권장)
Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다.
1. **방법 1**: cmd를 관리자 권한으로 실행
- Windows 키 + X
- "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택
- Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)"
- 프로젝트 폴더로 이동: `cd "경로"`
- `python main.py` 실행
@@ -93,11 +74,39 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있
- `python main.py`를 실행하는 배치 파일(.bat) 생성
- 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크
## 6. 첫 실행
### 5. 첫 실행
1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성
2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지
3. 감지된 시간이 출근시간으로 설정됨
1. 실행 시 자동으로 데이터베이스(`database.db`) 생성
2. 5단계 온보딩 위저드 표시
- 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → Discord 웹훅(옵션)
3. 위저드 완료 후 메인 화면 진입
4. 이후 실행에서는 위저드 없이 바로 시작
### 6. 단축근무자 설정 (예: 7시간 30분)
온보딩에서 미설정한 경우:
1. 설정(`Ctrl+,`) → 근무 시간 → **근무 패턴**
2. "단축근무 7시간 30분 (점심 30분)" 또는 사용자 정의 선택
3. 시·분 직접 입력 가능 (5분 단위)
4. 저장 → 즉시 메인 화면 반영
## 클라우드 동기화 (여러 PC 공용)
OneDrive / Dropbox 폴더에 DB를 두면 자동 동기화됩니다 (WAL 모드).
1. OneDrive/Dropbox 안에 `database.db` 위치 결정
2. 설정 → 데이터 관리 → DB 경로 → 변경
3. 기존 DB를 새 위치로 복사 → 재시작
4. 다른 PC에서도 같은 경로 지정하면 데이터 공유
## 환경 변수
| 변수 | 용도 |
|------|------|
| `CLOCKOUT_DEBUG=1` | `~/.clockout_logs/debug.log`에 디버그 로그 출력 |
| `CLOCKOUT_DEBUG_DIR=경로` | 로그 저장 위치 변경 |
| `GITEA_TOKEN` | 릴리스 발행 시 PAT (개발자용) |
| `CODE_SIGN_CERT` / `CODE_SIGN_PASS` | Authenticode 인증서 경로/암호 (개발자용) |
## 문제 해결
@@ -119,42 +128,57 @@ https://aka.ms/vs/17/release/vc_redist.x64.exe
### 이벤트 뷰어 접근 불가
- 관리자 권한으로 실행
- 또는 설정에서 수동 입력 모드 사용
- 또는 메인 화면 출근시각 옆 ✏️ 버튼으로 수동 입력
### Discord 웹훅 "실패" 표시
- v2.3.3 이전 버전에서 발생 — Cloudflare가 Python User-Agent 차단
- 최신 버전으로 업데이트하면 해결 (브라우저 UA로 우회)
### 온보딩 위저드를 다시 보고 싶음
- 도움말(F1) → "🚀 온보딩 다시 보기"
## 업그레이드
### .exe 사용자
- 앱이 자동 감지 → 알림 → 동의 → 자동 처리
- 수동 트리거: F5 또는 설정 → 데이터 관리 → "업데이트 확인"
### 소스 사용자
```bash
git pull
pip install --upgrade -r requirements.txt
```
## 제거
1. 프로젝트 폴더 삭제
2. 패키지 제거 (선택):
```bash
pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer
```
1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스)
2. 데이터 보존하려면 `database.db`만 별도 백업
3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제)
## 프로덕션 빌드 (PyInstaller)
소스 없이 실행 파일만 배포하려면:
```bash
python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB)
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터)
# 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드)
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
# updater.exe를 staging 폴더로 복사 (main.spec --clean 시 보호)
mkdir -p build/staging
cp dist/updater.exe build/staging/
# 메인 앱 빌드 (updater.exe 임베드 포함)
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB)
```
배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요.
자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다.
또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화.
빌드 시 주의:
- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행
- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨
## 환경 변수
- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력
- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경
- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출)
## 다음 단계
설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요!
설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요.
개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요.
+30 -6
View File
@@ -45,9 +45,11 @@
### 7. 통계·분석
- 주간/월간 요약 + matplotlib 차트
- 일별 근무시간 + 연장 누적 막대 그래프
- 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁)
- 요일별 평균 근무시간
- 근무 패턴 인사이트 (정적 통계 요약)
- 출근 시각 분포 히스토그램 (30분 단위 + 평균선)
- 근무 패턴 인사이트
- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약)
### 8. 공휴일 관리
- 한국 공휴일 자동 등록 (`holidays` 패키지)
@@ -69,13 +71,35 @@
- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작
- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거
- 실패 시 자동 롤백
- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출)
### 12. 다국어 지원 (i18n)
- 한국어 / English 전환
- 알림 메시지·UI 라벨 28개 카테고리
### 12. 첫 실행 온보딩 위저드
- 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시
- 기존 사용자: 자동 완료 처리 + 도움말(F1) → "🚀 온보딩 다시 보기"
### 13. 사용자 친화 기능
- **메인 화면 인라인 편집** — 출퇴근 시각 라벨 클릭으로 즉시 수정
- **퇴근 후 "오늘 요약" 카드** — 총 근무·점심·외출·연장·추정급여 한눈에
- **장시간 근무 휴식 권고** — 4시간 연속 근무 시 토스트 + Discord push
- **점심/저녁 실제 시간 입력** — 버튼 우클릭 → 시작·종료 시각
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
- **월간 목표** — 연장근무 상한 / 일평균 목표 + 진행률 게이지
- **CSV 가져오기** — 표준 포맷으로 타 도구에서 마이그레이션
- **자동 Crash Report** — 예외 발생 시 Gitea Issues 자동 등록 (옵션)
- **주간 자동 리포트** — 월요일 첫 출근 시 지난주 요약 push
- **휴가 캘린더** — 종일/반차/반반차 색상 구분
- **Discord 웹훅 알림** — 출퇴근/휴식권고/주간리포트 모바일 push (옵션, 서버 0)
### 14. 접근성
- 글꼴 크기 100% / 125% / 150%
- 고대비 모드 (검정 배경 + 노란 텍스트)
### 16. 다국어 지원 (i18n)
- 한국어 / English 전환 (재시작 또는 즉시 갱신)
- 알림 메시지·UI 라벨 30+ 카테고리
- HelpView 6개 탭 ko/en HTML 콘텐츠
### 13. 단축키
### 17. 단축키
- `Ctrl+O` 출/퇴근 토글
- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글
- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말
+245
View File
@@ -485,6 +485,234 @@ def s35_lock():
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)
# ============================================================
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
# ============================================================
@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
def s36_onboarding_new():
db = fresh_db('s36')
assert db.get_setting('onboarding_completed') == 'false'
@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
def s37_onboarding_existing():
db = fresh_db('s37')
# work_record 1건 추가 후 마이그레이션 재실행
today = date.today().isoformat()
db.add_work_record(today, '09:00:00', is_manual=True)
# 다시 호출 (init_database에서 호출되는 헬퍼)
db.migrate_v23_onboarding_for_existing()
assert db.get_setting('onboarding_completed') == 'true'
@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
def s38_salary_zero_wage():
from core.salary import estimate_pay
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0
@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
def s39_salary_basic():
from core.salary import estimate_pay
out = estimate_pay(
[{'total_hours': 8.0, 'overtime_minutes': 30}],
hourly_wage=10000,
overtime_rate=1.5,
)
# 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
# 연장 = 0.5h × 10000 × 1.5 = 7500
assert abs(out['base'] - 75000) < 0.01, out['base']
assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
assert abs(out['total'] - 82500) < 0.01
@case("S40. salary.format_won: 콤마 + '원' 접미사")
def s40_format_won():
from core.salary import format_won
assert format_won(0) == '0원'
assert format_won(1234567) == '1,234,567원'
assert format_won(82500.4) == '82,500원'
@case("S41. CSV 가져오기: 표준 포맷 파싱")
def s41_csv_parse():
from utils.csv_importer import parse_csv
p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,첫째날\n")
f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
rows = parse_csv(p)
os.remove(p)
assert len(rows) == 2
assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS)
assert rows[0]['lunch_minutes'] == 60
assert rows[1]['lunch_minutes'] == 30
assert rows[1]['memo'] == '단축'
@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
def s42_csv_invalid():
from utils.csv_importer import parse_csv
p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("not-a-date,09:00,18:00,60,\n")
try:
parse_csv(p)
assert False, "should have raised"
except ValueError:
pass
finally:
os.remove(p)
@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
def s43_csv_skip():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s43')
# 기존 레코드 1건
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,중복\n")
f.write("2026-04-02,09:00,18:00,60,신규\n")
rows = parse_csv(p)
added, updated, skipped = import_records(db, rows, on_conflict='skip')
os.remove(p)
assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
# 기존 레코드 보존 확인 (08:30 그대로)
rec = db.get_work_record('2026-04-01')
assert rec['clock_in'] == '08:30:00'
@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
def s44_csv_overwrite():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s44')
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")
rows = parse_csv(p)
added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
os.remove(p)
assert updated == 1 and added == 0
rec = db.get_work_record('2026-04-01')
assert rec['clock_in'] == '09:00:00'
@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
def s45_notification_dedupe():
db = fresh_db('s45')
assert not db.has_notification_today('discord', 'weekly_report')
db.log_notification('discord', 'weekly_report', payload='test', success=True)
assert db.has_notification_today('discord', 'weekly_report')
# 다른 event_type은 별개
assert not db.has_notification_today('discord', 'clock_in')
@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
def s46_meal_record():
db = fresh_db('s46')
today = date.today().isoformat()
# 오늘이 아닌 날짜로 add (work_record 미존재 OK)
db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
# get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
('2026-04-01',))
assert cur.fetchone()[0] == 60
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
('2026-04-01',))
assert cur.fetchone()[0] == 30
conn.close()
@case("S47. crash_log 자동 생성 + 기록")
def s47_crash_log():
from utils.crash_handler import _log_crash
db = fresh_db('s47')
_log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
assert cur.fetchone()[0] == 1
cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
msg, ver = cur.fetchone()
assert msg == 'sample message' and ver == 'v2.6.0'
conn.close()
@case("S48. updater_client.is_newer: 정확한 semver 비교")
def s48_updater_compare():
from utils.updater_client import is_newer
assert is_newer('v2.7.0', '2.6.0')
assert is_newer('2.6.1', 'v2.6.0')
assert not is_newer('v2.6.0', 'v2.6.0')
assert not is_newer('v2.5.0', 'v2.6.0')
assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교
@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
def s49_discord_empty():
from utils.discord_webhook import send, send_test
assert send('', 't', 'd') is False
assert send('http://invalid', 't', 'd') is False # https:// 아님
assert send_test('') is False
@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
def s50_goals():
db = fresh_db('s50')
# 기본값 확인 (0)
assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
# 활성화
db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
assert db.get_setting_int('goal_overtime_max_monthly') == 1200
assert db.get_setting_int('goal_avg_hours_daily') == 8
@case("S51. 글꼴 크기 / 고대비 설정 키")
def s51_accessibility_keys():
db = fresh_db('s51')
# 기본값
assert db.get_setting_float('font_scale', 1.0) == 1.0
assert db.get_setting_bool('high_contrast', False) is False
# 변경
db.save_settings({'font_scale': 1.5, 'high_contrast': True})
assert db.get_setting_float('font_scale') == 1.5
assert db.get_setting_bool('high_contrast') is True
@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
from utils.csv_importer import parse_csv, import_records
db = fresh_db('s52')
p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
with open(p, 'w', encoding='utf-8') as f:
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
# 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
f.write("2026-04-01,09:00,19:30,60,연장근무\n")
rows = parse_csv(p)
added, _, _ = import_records(db, rows, on_conflict='skip')
os.remove(p)
assert added == 1
bal = db.get_total_overtime_balance()
assert bal == 90, f"overtime balance: {bal}"
# ============================================================
# Run all
# ============================================================
@@ -520,6 +748,23 @@ def main():
s33_short_overtime()
s34_format()
s35_lock()
s36_onboarding_new()
s37_onboarding_existing()
s38_salary_zero_wage()
s39_salary_basic()
s40_format_won()
s41_csv_parse()
s42_csv_invalid()
s43_csv_skip()
s44_csv_overwrite()
s45_notification_dedupe()
s46_meal_record()
s47_crash_log()
s48_updater_compare()
s49_discord_empty()
s50_goals()
s51_accessibility_keys()
s52_csv_overtime()
print()
print("=" * 60)
+1216
View File
File diff suppressed because it is too large Load Diff
+875 -990
View File
File diff suppressed because it is too large Load Diff
+232
View File
@@ -82,6 +82,24 @@ _DICT = {
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
'notif.lunch_reminder.title': '🍱 점심시간 등록',
'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?',
'notif.dinner_reminder.title': '🍽️ 저녁시간 등록',
'notif.dinner_reminder.body': '저녁시간을 등록하지 않으셨네요.\n저녁시간을 추가하시겠어요?',
# === 온보딩 근무 패턴 프리셋 ===
'onboarding.preset.standard_8h': '표준 8시간 (점심 60분)',
'onboarding.preset.short_7h30m': '단축근무 7시간 30분 (점심 30분)',
'onboarding.preset.short_7h': '단축근무 7시간 (점심 60분)',
'onboarding.preset.short_6h': '단축근무 6시간 (점심 30분)',
'onboarding.preset.half_4h': '반일 4시간 (점심 0분)',
'onboarding.preset.custom_box': '사용자 정의',
'onboarding.preset.custom_radio': '직접 입력:',
'onboarding.preset.suffix_hours': ' 시간',
'onboarding.preset.suffix_minutes': ' 분',
'onboarding.preset.lunch_prefix': '점심 ',
'onboarding.preset.dinner_prefix': '저녁 ',
'onboarding.preset.dinner_tooltip': '야근으로 저녁 식사가 자주 발생하면 입력 (보통 0~60분)',
'notif.health_break.title': '🌿 휴식 권고',
'notif.health_break.body': '{hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.',
'notif.overtime_earning.title': '🔥 연장근무 적립 예정',
'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
@@ -103,6 +121,7 @@ _DICT = {
'msg.no_record.body': '오늘 출근 기록이 없습니다.',
'msg.confirm_delete.title': '삭제 확인',
'msg.no_data.title': '데이터 없음',
'msg.manual_added': '수동 추가',
# === 트레이 ===
'tray.open': '프로그램 열기',
@@ -153,6 +172,103 @@ _DICT = {
'help.tab_leave': '🌴 연차/휴가',
'help.tab_break': '🚪 외출/저녁',
'help.tab_faq': '❓ 자주 묻는 질문',
# === clock_in_dialog ===
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
'dlg.clock_in.label': '출근시간:',
'dlg.clock_in.quick': '빠른 선택:',
'dlg.clock_in.btn_now': '현재',
# === break_view ===
'dlg.break.edit_title': '외출 기록 수정',
'dlg.break.out_label': '외출 시간:',
'dlg.break.in_label': '복귀 시간:',
'dlg.break.reason_label': '사유:',
'view.break.today_title': '오늘의 외출 기록',
'view.break.col_out': '외출 시간',
'view.break.col_in': '복귀 시간',
'view.break.col_duration': '소요 시간',
'view.break.col_reason': '사유',
'view.break.in_progress': '진행중',
'view.break.total_zero': '총 외출 시간: 0분',
'view.break.total_fmt': '총 외출 시간: {h}시간 {m}분',
'view.break.total_min_only': '총 외출 시간: {m}분',
'view.break.duration_fmt': '{h}시간 {m}분',
'view.break.duration_min_only': '{m}분',
'view.break.delete_confirm': '이 외출 기록을 삭제하시겠습니까?',
'btn.refresh': '새로고침',
'btn.edit_short': '수정',
'btn.delete_short': '삭제',
# === overtime_view ===
'view.overtime.title': '연장근무 내역',
'view.overtime.balance_zero': '잔액: 0분',
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
'view.overtime.earned_group': '💰 적립 내역',
'view.overtime.used_group': '📤 사용 내역',
'view.overtime.col_date': '날짜',
'view.overtime.col_earned': '적립',
'view.overtime.col_used': '사용',
'view.overtime.col_memo': '메모',
'view.overtime.col_reason': '사유',
'view.overtime.btn_add_earned': '➕ 수동 적립',
'view.overtime.btn_add_used': '➕ 수동 사용',
'view.overtime.menu_delete': '❌ 삭제',
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
'view.overtime.manual_earned_title': '추가근무 수동 적립',
'view.overtime.manual_used_title': '추가근무 수동 사용',
'view.overtime.field_date': '날짜:',
'view.overtime.field_time': '시간:',
'view.overtime.field_memo': '메모:',
'view.overtime.field_reason': '사유:',
'view.overtime.unit_hour_suffix': '시간',
'view.overtime.minute_0': '0분',
'view.overtime.minute_30': '30분',
'view.overtime.placeholder_memo': '선택사항',
'view.overtime.placeholder_reason': '예: 개인 사유',
'view.overtime.zero_add_error': '0분은 추가할 수 없습니다.',
'view.overtime.zero_use_error': '0분은 사용할 수 없습니다.',
'view.overtime.balance_short_title': '잔액 부족',
'view.overtime.balance_short_body': '사용 가능한 시간이 부족합니다.\n\n요청: {req_h}시간 {req_m}분\n잔액: {bal_h}시간 {bal_m}분',
'view.overtime.saved_earned': '{h}시간 {m}분이 적립되었습니다.',
'view.overtime.saved_used': '{h}시간 {m}분이 사용 처리되었습니다.',
# === leave_view ===
'view.leave.title': '연차 관리',
'view.leave.balance_zero': '잔여: 0일',
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
'view.leave.btn_set_balance': '잔여 설정',
'view.leave.used_group': '📤 사용 내역',
'view.leave.col_date': '날짜',
'view.leave.col_type': '구분',
'view.leave.col_used': '사용',
'view.leave.col_reason': '사유',
'view.leave.btn_add': '➕ 연차 사용 추가',
'view.leave.btn_calendar': '📅 캘린더 보기',
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
'view.leave.set_title': '연차 시간 설정',
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
'view.leave.set_done_title': '설정 완료',
'view.leave.set_done_body': '연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다.',
'view.leave.add_title': '연차 사용 기록 추가',
'view.leave.field_date': '날짜:',
'view.leave.field_type': '구분:',
'view.leave.field_hours': '시간:',
'view.leave.field_reason': '사유:',
'view.leave.type_annual': '연차',
'view.leave.type_half': '반차',
'view.leave.type_quarter': '반반차',
'view.leave.type_hourly': '시간',
'view.leave.placeholder_reason': '예) 개인 사유, 병원 방문 등',
'view.leave.note_auto_deduct': '※ 잔여 연차가 자동 차감됩니다.',
'view.leave.short_title': '잔여 연차 부족',
'view.leave.short_body': '잔여 연차가 부족합니다.\n현재 잔여: {balance}일\n사용 요청: {req}일',
'view.leave.confirm_title': '연차 사용 기록 추가',
'view.leave.confirm_body': '날짜: {date}\n구분: {type}\n사용: {days}일 ({hours}시간)\n사유: {reason}\n\n이 기록을 추가하시겠습니까?',
'view.leave.added_title': '추가 완료',
'view.leave.added_body': '{days}일 ({hours}시간)의 연차 사용이 기록되었습니다.',
'view.leave.error_title': '오류',
'view.leave.error_body': '연차 기록 추가 중 오류가 발생했습니다:\n{err}',
},
'en': {
# === Menu/Buttons ===
@@ -223,6 +339,24 @@ _DICT = {
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?",
'notif.dinner_reminder.title': '🍽️ Dinner Reminder',
'notif.dinner_reminder.body': "You haven't registered dinner yet.\nWant to add it?",
# === Onboarding work pattern presets ===
'onboarding.preset.standard_8h': 'Standard 8h (Lunch 60min)',
'onboarding.preset.short_7h30m': 'Reduced 7h 30m (Lunch 30min)',
'onboarding.preset.short_7h': 'Reduced 7h (Lunch 60min)',
'onboarding.preset.short_6h': 'Reduced 6h (Lunch 30min)',
'onboarding.preset.half_4h': 'Half-day 4h (No Lunch)',
'onboarding.preset.custom_box': 'Custom',
'onboarding.preset.custom_radio': 'Manual entry:',
'onboarding.preset.suffix_hours': ' h',
'onboarding.preset.suffix_minutes': ' min',
'onboarding.preset.lunch_prefix': 'Lunch ',
'onboarding.preset.dinner_prefix': 'Dinner ',
'onboarding.preset.dinner_tooltip': 'Set if you often have dinner during overtime (typically 0~60 min)',
'notif.health_break.title': '🌿 Take a Break',
'notif.health_break.body': "You've been at your desk for over {hours} hours.\nStand up and stretch!",
'notif.overtime_earning.title': '🔥 Overtime Will Accrue',
'notif.overtime_earning.body': "{time_str} of overtime will be banked today!",
'notif.overtime_threshold.title': '💰 Overtime Balance High',
@@ -244,6 +378,7 @@ _DICT = {
'msg.no_record.body': 'No clock-in record for today.',
'msg.confirm_delete.title': 'Confirm Delete',
'msg.no_data.title': 'No Data',
'msg.manual_added': 'Manual',
# === Tray ===
'tray.open': 'Open Program',
@@ -294,6 +429,103 @@ _DICT = {
'help.tab_leave': '🌴 Leave',
'help.tab_break': '🚪 Break/Dinner',
'help.tab_faq': '❓ FAQ',
# === clock_in_dialog ===
'dlg.clock_in.prompt': "Enter today's clock-in time",
'dlg.clock_in.label': 'Clock-in time:',
'dlg.clock_in.quick': 'Quick pick:',
'dlg.clock_in.btn_now': 'Now',
# === break_view ===
'dlg.break.edit_title': 'Edit Break Record',
'dlg.break.out_label': 'Break out:',
'dlg.break.in_label': 'Return:',
'dlg.break.reason_label': 'Reason:',
'view.break.today_title': "Today's Break Records",
'view.break.col_out': 'Break out',
'view.break.col_in': 'Return',
'view.break.col_duration': 'Duration',
'view.break.col_reason': 'Reason',
'view.break.in_progress': 'In progress',
'view.break.total_zero': 'Total break: 0 min',
'view.break.total_fmt': 'Total break: {h}h {m}m',
'view.break.total_min_only': 'Total break: {m} min',
'view.break.duration_fmt': '{h}h {m}m',
'view.break.duration_min_only': '{m} min',
'view.break.delete_confirm': 'Delete this break record?',
'btn.refresh': 'Refresh',
'btn.edit_short': 'Edit',
'btn.delete_short': 'Delete',
# === overtime_view ===
'view.overtime.title': 'Overtime History',
'view.overtime.balance_zero': 'Balance: 0 min',
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
'view.overtime.earned_group': '💰 Earned',
'view.overtime.used_group': '📤 Used',
'view.overtime.col_date': 'Date',
'view.overtime.col_earned': 'Earned',
'view.overtime.col_used': 'Used',
'view.overtime.col_memo': 'Memo',
'view.overtime.col_reason': 'Reason',
'view.overtime.btn_add_earned': '➕ Manual Earn',
'view.overtime.btn_add_used': '➕ Manual Use',
'view.overtime.menu_delete': '❌ Delete',
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
'view.overtime.manual_used_title': 'Manual Overtime Use',
'view.overtime.field_date': 'Date:',
'view.overtime.field_time': 'Time:',
'view.overtime.field_memo': 'Memo:',
'view.overtime.field_reason': 'Reason:',
'view.overtime.unit_hour_suffix': 'h',
'view.overtime.minute_0': '0 min',
'view.overtime.minute_30': '30 min',
'view.overtime.placeholder_memo': 'Optional',
'view.overtime.placeholder_reason': 'e.g., personal',
'view.overtime.zero_add_error': 'Cannot add 0 minutes.',
'view.overtime.zero_use_error': 'Cannot use 0 minutes.',
'view.overtime.balance_short_title': 'Insufficient Balance',
'view.overtime.balance_short_body': 'Not enough balance.\n\nRequested: {req_h}h {req_m}m\nBalance: {bal_h}h {bal_m}m',
'view.overtime.saved_earned': '{h}h {m}m banked.',
'view.overtime.saved_used': '{h}h {m}m used.',
# === leave_view ===
'view.leave.title': 'Leave Management',
'view.leave.balance_zero': 'Balance: 0 days',
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
'view.leave.btn_set_balance': 'Set Balance',
'view.leave.used_group': '📤 Used',
'view.leave.col_date': 'Date',
'view.leave.col_type': 'Type',
'view.leave.col_used': 'Used',
'view.leave.col_reason': 'Reason',
'view.leave.btn_add': '➕ Add Leave Usage',
'view.leave.btn_calendar': '📅 Calendar',
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
'view.leave.set_title': 'Set Leave Hours',
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',
'view.leave.set_done_title': 'Saved',
'view.leave.set_done_body': 'Leave balance set to {days} days ({hours}h).',
'view.leave.add_title': 'Add Leave Usage',
'view.leave.field_date': 'Date:',
'view.leave.field_type': 'Type:',
'view.leave.field_hours': 'Hours:',
'view.leave.field_reason': 'Reason:',
'view.leave.type_annual': 'Annual',
'view.leave.type_half': 'Half',
'view.leave.type_quarter': 'Quarter',
'view.leave.type_hourly': 'Hourly',
'view.leave.placeholder_reason': 'e.g., personal, medical',
'view.leave.note_auto_deduct': '※ Leave balance is auto-deducted.',
'view.leave.short_title': 'Insufficient Leave',
'view.leave.short_body': 'Not enough leave.\nCurrent: {balance} days\nRequested: {req} days',
'view.leave.confirm_title': 'Add Leave Usage',
'view.leave.confirm_body': 'Date: {date}\nType: {type}\nUsed: {days} days ({hours}h)\nReason: {reason}\n\nAdd this record?',
'view.leave.added_title': 'Added',
'view.leave.added_body': '{days} days ({hours}h) of leave usage recorded.',
'view.leave.error_title': 'Error',
'view.leave.error_body': 'Failed to add leave record:\n{err}',
},
}
+58 -37
View File
@@ -7,11 +7,25 @@ from typing import Optional
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
from core.settings_keys import (
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
OVERTIME_UNIT,
)
from core.i18n import tr
def _get_int_setting(db, key: str, default: int, lo: int, hi: int) -> int:
"""db에서 정수 설정값을 안전하게 읽어 [lo, hi]로 클램프."""
if db is None:
return default
try:
v = int(db.get_setting(key, str(default)) or default)
except (ValueError, TypeError):
v = default
return max(lo, min(hi, v))
class Notifier(QObject):
"""알림 시스템 클래스"""
@@ -27,6 +41,7 @@ class Notifier(QObject):
# 알림 상태 추적
self.notified_30min = False
self.notified_lunch = False
self.notified_dinner = False
self.notified_overtime = False
self.notified_health = False
self.notified_weekly = False
@@ -85,46 +100,52 @@ class Notifier(QObject):
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
current_time: Optional[datetime] = None):
"""
점심시간 등록 알림
Args:
clock_in_time: 출근 시간
lunch_enabled: 점심시간 등록 여부
current_time: 현재 시간
"""
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
if current_time is None:
current_time = datetime.now()
if not self._enabled(NOTIF_LUNCH):
return
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
if lunch_enabled or self.notified_lunch:
return
# 출근 후 4시간 경과 (점심시간으로 추정)
time_since_clock_in = current_time - clock_in_time
if time_since_clock_in.total_seconds() >= 4 * 3600:
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
self.notification_signal.emit(
tr('notif.lunch_reminder.title'),
tr('notif.lunch_reminder.body'),
)
self.notified_lunch = True
def check_dinner_reminder(self, clock_in_time: datetime, dinner_enabled: bool,
current_time: Optional[datetime] = None):
"""저녁시간 등록 알림. 출근 후 DINNER_REMINDER_HOURS 경과 시.
야근(연장근무) 사용자가 저녁을 깜빡 잊는 패턴 대응.
"""
if current_time is None:
current_time = datetime.now()
if not self._enabled(NOTIF_DINNER):
return
if dinner_enabled or self.notified_dinner:
return
threshold_hours = _get_int_setting(self.db, DINNER_REMINDER_HOURS, 8, 1, 16)
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
self.notification_signal.emit(
tr('notif.dinner_reminder.title'),
tr('notif.dinner_reminder.body'),
)
self.notified_dinner = True
def check_overtime_earning(self, overtime_minutes: int):
"""
연장근무 적립 알림
Args:
overtime_minutes: 예상 연장근무 시간 (분)
"""
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
if not self._enabled(NOTIF_OVERTIME):
return
if overtime_minutes >= 30 and not self.notified_overtime:
hours = overtime_minutes // 60
mins = overtime_minutes % 60
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
if overtime_minutes >= unit and not self.notified_overtime:
from utils.time_format import format_hours_minutes
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
self.notification_signal.emit(
tr('notif.overtime_earning.title'),
tr('notif.overtime_earning.body', time_str=time_str),
@@ -132,10 +153,11 @@ class Notifier(QObject):
self.notified_overtime = True
def notify_overtime_threshold(self, total_overtime_hours: float):
"""연장근무 누적 알림 (20시간 이상)"""
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
if not self._enabled(NOTIF_OVERTIME):
return
if total_overtime_hours >= 20 and not self.notified_threshold:
threshold = _get_int_setting(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
if total_overtime_hours >= threshold and not self.notified_threshold:
self.notification_signal.emit(
tr('notif.overtime_threshold.title'),
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
@@ -143,10 +165,11 @@ class Notifier(QObject):
self.notified_threshold = True
def notify_health_warning(self, consecutive_overtime_days: int):
"""건강 경고 (연속 연장근무 일수)"""
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
if not self._enabled(NOTIF_HEALTH):
return
if consecutive_overtime_days >= 3 and not self.notified_health:
threshold = _get_int_setting(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
if consecutive_overtime_days >= threshold and not self.notified_health:
self.notification_signal.emit(
tr('notif.health.title'),
tr('notif.health.body', days=consecutive_overtime_days),
@@ -154,10 +177,11 @@ class Notifier(QObject):
self.notified_health = True
def notify_weekly_hours(self, total_hours: float):
"""주 52시간 경고"""
"""주 X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
if not self._enabled(NOTIF_HEALTH):
return
if total_hours > 52 and not self.notified_weekly:
threshold = _get_int_setting(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
if total_hours > threshold and not self.notified_weekly:
self.notification_signal.emit(
tr('notif.weekly_52.title'),
tr('notif.weekly_52.body', hours=total_hours),
@@ -167,7 +191,7 @@ class Notifier(QObject):
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
"""장시간 연속 근무 휴식 알림.
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
"""
if current_time is None:
@@ -178,16 +202,12 @@ class Notifier(QObject):
return
if self.db.has_notification_today('system', 'health_break'):
return
try:
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
except (ValueError, TypeError):
threshold_hours = 4
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
if elapsed_sec >= threshold_hours * 3600:
from core.i18n import tr
self.notification_signal.emit(
tr('notif.health_break.title') if False else "🌿 휴식 권고",
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
tr('notif.health_break.title'),
tr('notif.health_break.body', hours=threshold_hours),
)
self.db.log_notification('system', 'health_break')
@@ -195,6 +215,7 @@ class Notifier(QObject):
"""알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False
self.notified_lunch = False
self.notified_dinner = False
self.notified_overtime = False
self.notified_health = False
self.notified_weekly = False
+27
View File
@@ -22,9 +22,17 @@ CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
NOTIF_CLOCK_OUT = 'notification_clock_out'
NOTIF_LUNCH = 'notification_lunch'
NOTIF_DINNER = 'notification_dinner'
NOTIF_OVERTIME = 'notification_overtime'
NOTIF_HEALTH = 'notification_health'
# 알림 임계값 (설정화 — 이전엔 하드코딩이던 것)
LUNCH_REMINDER_HOURS = 'lunch_reminder_hours' # 출근 후 N시간 경과 시 점심 미등록 알림 (기본 4)
DINNER_REMINDER_HOURS = 'dinner_reminder_hours' # 출근 후 N시간 경과 시 저녁 미등록 알림 (기본 8)
OVERTIME_THRESHOLD_HOURS = 'overtime_threshold_hours' # 누적 적립 알림 시간 (기본 20)
WEEKLY_HOURS_THRESHOLD = 'weekly_hours_threshold' # 주 X시간 경고 (기본 52, 한국 노동법)
HEALTH_CONSECUTIVE_OT_DAYS = 'health_consecutive_ot_days' # 연속 연장근무 일수 경고 (기본 3)
# 연차
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
@@ -76,3 +84,22 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
# 마이그레이션 sentinel
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
# === v2.8.0 도전과제 시스템 ===
# 사용자 메타
BIRTHDAY = 'birthday' # MM-DD 형식, 빈 문자열이면 비활성
HIRE_DATE = 'hire_date' # YYYY-MM-DD, 첫 work_records 자동 기록
# 뷰 진입 카운터 (도전과제 + 사용 통계용)
STAT_WEEKLY_VIEW_COUNT = 'stat_weekly_view_count'
STAT_MONTHLY_VIEW_COUNT = 'stat_monthly_view_count'
STAT_PATTERN_VIEW_COUNT = 'stat_pattern_view_count'
CALENDAR_VIEW_COUNT = 'calendar_view_count'
LEAVE_CALENDAR_VIEW_COUNT = 'leave_calendar_view_count'
DAILY_REPORT_COUNT = 'daily_report_count'
ACHIEVEMENTS_VIEW_COUNT = 'achievements_view_count'
CHART_HOVER_DISCOVERED = 'chart_hover_discovered'
# 도전과제 알림
NOTIF_ACHIEVEMENT = 'notification_achievement'
DISCORD_NOTIF_ACHIEVEMENT = 'discord_notif_achievement'
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
__version__ = '2.6.0'
__version__ = '2.8.1'
+2 -2
View File
@@ -145,9 +145,9 @@ def main():
from utils.debug_log import dlog
dlog(f"onboarding skipped: {e}")
# 메인 윈도우 생성 및 표시
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
try:
window = MainWindow()
window = MainWindow(db=db)
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
def on_new_connection():
+110
View File
@@ -0,0 +1,110 @@
"""
utils.crash_handler 단위 테스트.
GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea).
"""
import os
import sys
import tempfile
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from utils.crash_handler import _log_crash, _send_to_gitea
@pytest.fixture
def db():
p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db')
if os.path.exists(p):
os.remove(p)
d = Database(p)
yield d
try:
os.remove(p)
except OSError:
pass
class TestLogCrash:
def test_creates_table_and_inserts(self, db):
_log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT exception_type, message, app_version FROM crash_log")
row = cur.fetchone()
conn.close()
assert row[0] == 'TestExc'
assert row[1] == 'msg'
assert row[2] == 'v2.6.0'
def test_table_idempotent_creation(self, db):
# 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS)
_log_crash(db, 'A', 'a', 't', 'v1')
_log_crash(db, 'B', 'b', 't', 'v1')
conn = db.get_connection()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM crash_log")
count = cur.fetchone()[0]
conn.close()
assert count == 2
def test_silent_on_db_error(self, db):
# 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음)
broken = MagicMock()
broken.get_connection.side_effect = RuntimeError('boom')
# raise되면 안 됨
_log_crash(broken, 'X', 'x', 'tb', 'v')
class TestSendToGitea:
@patch('utils.crash_handler.urllib.request.urlopen')
def test_success(self, mock_urlopen):
resp = MagicMock()
resp.status = 201
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
ok = _send_to_gitea('fake_token', 'title', 'body')
assert ok is True
req = mock_urlopen.call_args[0][0]
# PAT 헤더 확인
assert req.headers.get('Authorization') == 'token fake_token'
# User-Agent 위장
assert 'Mozilla' in req.headers.get('User-agent', '')
@patch('utils.crash_handler.urllib.request.urlopen')
def test_4xx_returns_false(self, mock_urlopen):
resp = MagicMock()
resp.status = 401
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert _send_to_gitea('bad', 't', 'b') is False
@patch('utils.crash_handler.urllib.request.urlopen')
def test_network_error(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert _send_to_gitea('t', 't', 'b') is False
@patch('utils.crash_handler.urllib.request.urlopen')
def test_payload_json(self, mock_urlopen):
import json as _json
resp = MagicMock()
resp.status = 201
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
_send_to_gitea('tok', 'TITLE', 'BODY')
req = mock_urlopen.call_args[0][0]
body = _json.loads(req.data.decode('utf-8'))
assert body['title'] == 'TITLE'
assert body['body'] == 'BODY'
+127
View File
@@ -0,0 +1,127 @@
"""
utils.csv_importer 단위 테스트.
"""
import os
import sys
import tempfile
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
class TestNormalizeTime:
def test_hh_mm_to_hh_mm_ss(self):
assert _normalize_time('09:00', 'clock_in') == '09:00:00'
def test_hh_mm_ss_unchanged(self):
assert _normalize_time('09:00:00', 'clock_in') == '09:00:00'
def test_empty_raises(self):
with pytest.raises(ValueError):
_normalize_time('', 'clock_in')
def test_invalid_format_raises(self):
with pytest.raises(ValueError):
_normalize_time('foo', 'clock_in')
with pytest.raises(ValueError):
_normalize_time('25:00', 'clock_in')
class TestNormalizeRow:
def test_basic_row(self):
row = {
'date': '2026-04-01',
'clock_in': '09:00',
'clock_out': '18:00',
'lunch_minutes': '60',
'memo': '메모',
}
out = _normalize_row(row)
assert out['date'] == '2026-04-01'
assert out['clock_in'] == '09:00:00'
assert out['clock_out'] == '18:00:00'
assert out['lunch_minutes'] == 60
assert out['memo'] == '메모'
def test_optional_clock_out(self):
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
'lunch_minutes': '0', 'memo': ''}
out = _normalize_row(row)
assert out['clock_out'] is None
def test_invalid_date(self):
row = {'date': 'not-a-date', 'clock_in': '09:00', 'clock_out': '',
'lunch_minutes': '0', 'memo': ''}
with pytest.raises(ValueError):
_normalize_row(row)
def test_negative_lunch_minutes(self):
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
'lunch_minutes': '-30', 'memo': ''}
with pytest.raises(ValueError):
_normalize_row(row)
class TestParseCsv:
def _write(self, content: str) -> str:
f = tempfile.NamedTemporaryFile('w', encoding='utf-8',
delete=False, suffix='.csv', newline='')
f.write(content)
f.close()
return f.name
def test_valid_csv(self):
path = self._write(
"date,clock_in,clock_out,lunch_minutes,memo\n"
"2026-04-01,09:00,18:00,60,첫째날\n"
"2026-04-02,09:30:00,17:30:00,30,단축\n"
)
try:
rows = parse_csv(path)
assert len(rows) == 2
assert rows[0]['lunch_minutes'] == 60
assert rows[1]['memo'] == '단축'
finally:
os.remove(path)
def test_utf8_bom(self):
# 엑셀 저장본 호환
path = self._write('\ufeff' +
"date,clock_in,clock_out,lunch_minutes,memo\n"
"2026-04-01,09:00,18:00,60,첫째날\n"
)
try:
rows = parse_csv(path)
assert len(rows) == 1
finally:
os.remove(path)
def test_missing_required_header(self):
path = self._write("date,memo\n2026-04-01,foo\n")
try:
with pytest.raises(ValueError) as exc:
parse_csv(path)
assert 'clock_in' in str(exc.value)
finally:
os.remove(path)
def test_file_not_found(self):
with pytest.raises(FileNotFoundError):
parse_csv('/nonexistent/file.csv')
def test_line_number_in_error(self):
path = self._write(
"date,clock_in,clock_out,lunch_minutes,memo\n"
"2026-04-01,09:00,18:00,60,ok\n"
"bad-date,09:00,18:00,60,broken\n"
)
try:
with pytest.raises(ValueError) as exc:
parse_csv(path)
assert '줄 3' in str(exc.value)
finally:
os.remove(path)
+129
View File
@@ -0,0 +1,129 @@
"""
utils.discord_webhook 단위 테스트.
네트워크 호출은 mock — 실제 Discord API는 절대 건드리지 않음.
"""
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.discord_webhook import (
send, send_test, send_clock_in, send_clock_out, send_health_warning,
USER_AGENT, COLOR_GREEN, COLOR_BLUE,
)
class TestSendInputValidation:
def test_empty_url_returns_false(self):
assert send('', 'title', 'desc') is False
def test_non_https_url_returns_false(self):
# 보안상 http:// 거부
assert send('http://example.com/webhook', 'title', 'desc') is False
assert send('ftp://example.com', 'title', 'desc') is False
def test_none_url_returns_false(self):
assert send(None, 'title', 'desc') is False
class TestSendNetwork:
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_success(self, mock_urlopen):
resp = MagicMock()
resp.status = 204
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc')
assert ok is True
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
req = mock_urlopen.call_args[0][0]
assert req.headers.get('User-agent') == USER_AGENT
assert 'Mozilla' in USER_AGENT # 봇으로 인식 안 되도록
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_4xx_returns_false(self, mock_urlopen):
resp = MagicMock()
resp.status = 403
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_network_error_returns_false(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_timeout_returns_false(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
class TestPayloadShape:
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_payload_contains_embed(self, mock_urlopen):
import json as _json
resp = MagicMock()
resp.status = 204
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 'TITLE', 'DESC',
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
req = mock_urlopen.call_args[0][0]
body = _json.loads(req.data.decode('utf-8'))
assert 'embeds' in body
assert body['embeds'][0]['title'] == 'TITLE'
assert body['embeds'][0]['description'] == 'DESC'
assert body['embeds'][0]['color'] == COLOR_GREEN
assert body['embeds'][0]['fields'] == [{"name": "f", "value": "v"}]
class TestHelpers:
@patch('utils.discord_webhook.send')
def test_send_test(self, mock_send):
mock_send.return_value = True
send_test('https://discord.com/x')
assert mock_send.called
kwargs = mock_send.call_args.kwargs
assert kwargs.get('color') == COLOR_GREEN
@patch('utils.discord_webhook.send')
def test_send_clock_in(self, mock_send):
send_clock_in('https://discord.com/x', '09:00')
kwargs = mock_send.call_args.kwargs
assert '09:00' in kwargs.get('description', '')
@patch('utils.discord_webhook.send')
def test_send_clock_out_no_overtime(self, mock_send):
send_clock_out('https://discord.com/x', '18:00', 8.0, 0, 0)
kwargs = mock_send.call_args.kwargs
# 연장 없음 → 파란색
assert kwargs.get('color') == COLOR_BLUE
# 필드: 총 근무시간만
assert len(kwargs.get('fields', [])) == 1
@patch('utils.discord_webhook.send')
def test_send_clock_out_with_overtime(self, mock_send):
send_clock_out('https://discord.com/x', '19:30', 9.5, 90, 90)
kwargs = mock_send.call_args.kwargs
# 연장 있음 → 노란색
assert kwargs.get('color') != COLOR_BLUE
assert len(kwargs.get('fields', [])) == 2
@patch('utils.discord_webhook.send')
def test_send_health_warning(self, mock_send):
send_health_warning('https://discord.com/x', 4.5)
kwargs = mock_send.call_args.kwargs
assert '4.5' in kwargs.get('description', '')
+106
View File
@@ -0,0 +1,106 @@
"""
ui.i18n_runtime 단위 테스트.
QApplication이 필요해서 offscreen으로.
"""
import os
import sys
import pytest
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@pytest.fixture(scope='module')
def qapp():
from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def i18n():
from core import i18n
from ui import i18n_runtime
saved_lang = i18n.get_language()
yield i18n_runtime
i18n_runtime.clear()
i18n.set_language(saved_lang)
def test_register_applies_initial_text(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save')
assert label.text() == '💾 저장'
def test_retranslate_after_language_change(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.close')
assert label.text() == '닫기'
i18n.set_language_and_retranslate('en')
assert label.text() == 'Close'
i18n.set_language_and_retranslate('ko')
assert label.text() == '닫기'
def test_setter_kwarg_for_window_title(qapp, i18n):
from PyQt5.QtWidgets import QDialog
from core.i18n import set_language
set_language('ko')
dlg = QDialog()
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
assert dlg.windowTitle() == '⚙️ 설정'
i18n.set_language_and_retranslate('en')
assert dlg.windowTitle() == '⚙️ Settings'
def test_post_callback_applied(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
assert label.text() == '[💾 저장]'
i18n.set_language_and_retranslate('en')
assert label.text() == '[💾 Save]'
def test_dead_widget_pruned(qapp, i18n):
"""삭제된 위젯은 retranslate에서 자동 제외 (RuntimeError 안 남)."""
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.cancel')
label.deleteLater()
label = None # weakref 끊기
# Qt 이벤트 처리 한 번 강제로 (deleteLater 처리)
qapp.processEvents()
# 죽은 위젯이 있어도 예외 없이 실행돼야 함
i18n.set_language_and_retranslate('en')
i18n.set_language_and_retranslate('ko')
def test_kwargs_format(qapp, i18n):
from PyQt5.QtWidgets import QLabel
from core.i18n import set_language
set_language('ko')
label = QLabel()
# 'tray.tooltip_remaining': '퇴근까지: {time}'
i18n.register(label, 'tray.tooltip_remaining', kwargs={'time': '01:23'})
assert '01:23' in label.text()
+98
View File
@@ -0,0 +1,98 @@
"""
core.salary 단위 테스트 — 포괄임금제 외 시급 추정.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.salary import estimate_pay, format_won
class TestEstimatePay:
def test_zero_wage_returns_zero(self):
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
assert out['base'] == 0
assert out['overtime'] == 0
assert out['total'] == 0
def test_negative_wage_returns_zero(self):
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 0}], -1000)
assert out['total'] == 0
def test_empty_records(self):
out = estimate_pay([], 10000)
assert out['base'] == 0
assert out['overtime'] == 0
assert out['total'] == 0
def test_basic_8h_no_overtime(self):
# 8h 정규 × 10000 = 80000
out = estimate_pay([{'total_hours': 8.0, 'overtime_minutes': 0}], 10000)
assert out['base'] == 80000
assert out['overtime'] == 0
assert out['total'] == 80000
def test_8h_with_30min_overtime(self):
# 정규 = 7.5h × 10000 = 75000
# 연장 = 0.5h × 10000 × 1.5 = 7500
out = estimate_pay(
[{'total_hours': 8.0, 'overtime_minutes': 30}],
hourly_wage=10000,
overtime_rate=1.5,
)
assert out['base'] == pytest.approx(75000)
assert out['overtime'] == pytest.approx(7500)
assert out['total'] == pytest.approx(82500)
def test_custom_overtime_rate(self):
# 연장 = 1h × 10000 × 2.0 = 20000
out = estimate_pay(
[{'total_hours': 9.0, 'overtime_minutes': 60}],
hourly_wage=10000,
overtime_rate=2.0,
)
assert out['overtime'] == pytest.approx(20000)
assert out['base'] == pytest.approx(80000)
def test_aggregated_multiple_records(self):
records = [
{'total_hours': 8.0, 'overtime_minutes': 0},
{'total_hours': 9.0, 'overtime_minutes': 60},
{'total_hours': 8.5, 'overtime_minutes': 30},
]
out = estimate_pay(records, hourly_wage=10000)
# base_hours = 8 + 8 + 8 = 24h
# overtime_hours = 0 + 1 + 0.5 = 1.5h
assert out['base_hours'] == pytest.approx(24.0)
assert out['overtime_hours'] == pytest.approx(1.5)
assert out['base'] == pytest.approx(240000)
assert out['overtime'] == pytest.approx(22500) # 1.5 * 10000 * 1.5
def test_missing_keys_default_zero(self):
out = estimate_pay([{}], 10000)
assert out['total'] == 0
def test_overtime_minutes_zero_when_negative_total(self):
# total - overtime이 음수가 되면 base는 0으로 클램프
out = estimate_pay(
[{'total_hours': 0.3, 'overtime_minutes': 60}], # 0.3h - 1h = -0.7
hourly_wage=10000,
)
assert out['base'] == 0
assert out['overtime'] == pytest.approx(15000)
class TestFormatWon:
@pytest.mark.parametrize("amount,expected", [
(0, '0원'),
(1000, '1,000원'),
(1234567, '1,234,567원'),
(999, '999원'),
(82500.4, '82,500원'), # round
(82500.6, '82,501원'),
])
def test_format(self, amount, expected):
assert format_won(amount) == expected
+484
View File
@@ -0,0 +1,484 @@
"""
도전과제 뷰 — 4탭 (전체 / 진행 중 / 완료 / 시크릿).
디자인 원칙:
- 카드 = 등급별 그라디언트 배경 + 외곽선 빛 (획득 시 강한 색)
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
- 진행 게이지 = 두꺼운 색상 막대 (등급 색)
- 카테고리 = 작은 인라인 태그
- 시크릿 미발견 = ❓ 처리
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTabWidget, QWidget, QScrollArea,
QProgressBar, QFrame, QGridLayout, QSizePolicy)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from core.achievements import get_all_with_status, get_stats
from ui.styles import apply_dark_titlebar
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
TIER_THEMES = {
'bronze': {
'border': '#cd7f32',
'border_strong': '#e09947',
'bg_top': '#3a2a18',
'bg_bot': '#241810',
'text': '#ffd9a8',
'label': '🥉',
'name': '브론즈',
},
'silver': {
'border': '#a8a8a8',
'border_strong': '#d0d0d0',
'bg_top': '#2e2e36',
'bg_bot': '#1c1c22',
'text': '#e8e8f0',
'label': '🥈',
'name': '실버',
},
'gold': {
'border': '#ffb700',
'border_strong': '#ffd24a',
'bg_top': '#3a2e10',
'bg_bot': '#241c08',
'text': '#ffe9a0',
'label': '🥇',
'name': '골드',
},
'platinum': {
'border': '#7fdbff',
'border_strong': '#a8e8ff',
'bg_top': '#1a3340',
'bg_bot': '#0e1f28',
'text': '#c5ecff',
'label': '💎',
'name': '플래티넘',
},
'legend': {
'border': '#ff6b9d',
'border_strong': '#ff90b8',
'bg_top': '#3a1a2a',
'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탭 + 통계 헤더."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("🏆 도전과제")
self.setMinimumSize(960, 720)
self.resize(1100, 800)
self._increment_view_count()
self.setStyleSheet("QDialog { background: #0e0e14; }")
self.init_ui()
apply_dark_titlebar(self, dark=True)
def _increment_view_count(self) -> None:
try:
cur = self.db.get_setting_int('achievements_view_count', 0)
self.db.set_setting('achievements_view_count', str(cur + 1))
except Exception:
pass
def init_ui(self) -> None:
layout = QVBoxLayout()
layout.setContentsMargins(20, 20, 20, 16)
layout.setSpacing(12)
stats = get_stats(self.db)
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
layout.addWidget(self._build_header(stats))
# === 탭 ===
self.tabs = QTabWidget()
self.tabs.setStyleSheet(self._tabs_qss())
all_items = get_all_with_status(self.db)
earned_items = [a for a in all_items if a['earned_date'] is not None]
in_progress = [a for a in all_items
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(in_progress),
f"⚡ 진행 중 · {len(in_progress)}")
self.tabs.addTab(self._build_grid_tab(earned_items),
f"✓ 완료 · {len(earned_items)}")
self.tabs.addTab(
self._build_grid_tab(secret_items, secret_mode=True),
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
)
layout.addWidget(self.tabs, 1)
# === 닫기 버튼 ===
btn_row = QHBoxLayout()
btn_row.addStretch()
close_btn = QPushButton("닫기")
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.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
# ----- 헤더 -----
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;
border-radius: 12px;
}
QLabel { background: transparent; border: none; color: #e8e8f4; }
""")
layout = QVBoxLayout()
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(8)
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
# 큰 숫자 행
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>")
big.setTextFormat(Qt.RichText)
num_row.addWidget(big)
spacer = QFrame()
spacer.setFrameShape(QFrame.VLine)
spacer.setStyleSheet("color: #3a3a5a;")
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"{stats['secret_earned']}</span>"
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
f"</div>"
)
secret_lbl.setTextFormat(Qt.RichText)
num_row.addWidget(secret_lbl)
num_row.addStretch()
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"{pct:.1f}%</span></div>"
)
pct_lbl.setTextFormat(Qt.RichText)
pct_lbl.setAlignment(Qt.AlignRight)
num_row.addWidget(pct_lbl)
layout.addLayout(num_row)
# 진행 바
bar = QProgressBar()
bar.setMaximum(max(stats['total'], 1))
bar.setValue(stats['earned'])
bar.setTextVisible(False)
bar.setMinimumHeight(8)
bar.setMaximumHeight(8)
bar.setStyleSheet("""
QProgressBar {
background: #1a1a26;
border: none;
border-radius: 4px;
}
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)
container.setLayout(layout)
return container
# ----- 탭 그리드 -----
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; }
""")
container = QWidget()
container.setStyleSheet("background: transparent;")
grid = QGridLayout()
grid.setSpacing(12)
grid.setContentsMargins(8, 8, 8, 8)
if not items:
empty = QLabel("(아직 없음)")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet(
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
)
grid.addWidget(empty, 0, 0)
else:
cols = 3
for i, item in enumerate(items):
card = self._build_card(item, secret_mode=secret_mode)
grid.addWidget(card, i // cols, i % cols)
# 빈 컬럼 stretch 방지
for c in range(cols):
grid.setColumnStretch(c, 1)
container.setLayout(grid)
scroll.setWidget(container)
return scroll
# ----- 단일 카드 -----
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
is_earned = item['earned_date'] is not None
is_locked_secret = item['is_secret'] and not is_earned
tier = item['tier'] or 'bronze'
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
# 시크릿 미발견은 회색 톤으로
if is_locked_secret:
bg_top, bg_bot = '#1a1a26', '#0e0e16'
border = '#3a3a4a'
text_color = '#666'
else:
bg_top = theme['bg_top']
bg_bot = theme['bg_bot']
border = theme['border_strong'] if is_earned else theme['border']
text_color = theme['text'] if is_earned else '#c0c0d0'
# 외곽선 강도: 획득 시 2px + 더 진한 색
border_width = 2 if is_earned else 1
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
card = QFrame()
card.setFrameShape(QFrame.NoFrame)
card.setMinimumHeight(150)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
card.setStyleSheet(f"""
QFrame {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 {bg_top}, stop:1 {bg_bot});
border: {border_width}px solid {border};
border-radius: 10px;
}}
QLabel {{
background: transparent;
border: none;
color: {text_color};
}}
""")
outer = QVBoxLayout()
outer.setContentsMargins(14, 12, 14, 12)
outer.setSpacing(8)
# 1행: 이모지 + 이름 + 등급 라벨
top_row = QHBoxLayout()
top_row.setSpacing(10)
if is_locked_secret:
icon_text = "❓"
else:
icon_text = item['badge_icon'] or '🏆'
icon = QLabel(icon_text)
icon.setStyleSheet(
f"font-size: 32pt; background: transparent; border: none; "
f"color: {text_color};"
)
icon.setMinimumWidth(48)
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
top_row.addWidget(icon)
# 이름 + 카테고리 (세로 스택)
name_box = QVBoxLayout()
name_box.setSpacing(2)
name_box.setContentsMargins(0, 4, 0, 0)
name_text = "???" if is_locked_secret else (item['name'] or '')
name = QLabel(name_text)
name.setStyleSheet(
f"font-size: 12pt; font-weight: bold; "
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
f"background: transparent; border: none;"
)
name.setWordWrap(True)
name_box.addWidget(name)
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
if not is_locked_secret:
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
cat_label.setStyleSheet(
f"font-size: 8.5pt; "
f"color: {theme['border_strong']}; "
f"background: rgba(255,255,255,0.05); "
f"border: 1px solid {theme['border']}; "
f"border-radius: 8px; "
f"padding: 1px 4px;"
)
cat_label.setMaximumHeight(20)
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
cat_wrap = QHBoxLayout()
cat_wrap.setContentsMargins(0, 0, 0, 0)
cat_wrap.addWidget(cat_label)
cat_wrap.addStretch()
name_box.addLayout(cat_wrap)
top_row.addLayout(name_box, 1)
outer.addLayout(top_row)
# 2행: 설명
if is_locked_secret:
desc_text = "🔒 달성하면 공개됩니다"
else:
desc_text = item['description'] or ''
desc = QLabel(desc_text)
desc.setWordWrap(True)
desc.setStyleSheet(
f"color: #a0a0b8; 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.setStyleSheet(
f"color: {theme['border_strong']}; "
f"font-weight: bold; font-size: 9.5pt; "
f"background: rgba(255,255,255,0.08); "
f"border: 1px solid {theme['border']}; "
f"border-radius: 6px; padding: 4px 8px;"
)
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
row = QHBoxLayout()
row.addWidget(earned)
row.addStretch()
outer.addLayout(row)
elif not is_locked_secret:
target = max(1, item.get('target') or 1)
progress = item.get('progress') or 0
pct = (progress / target * 100) if target else 0
# 게이지 + 숫자 라벨
gauge_row = QHBoxLayout()
gauge_row.setSpacing(8)
pb = QProgressBar()
pb.setMaximum(target)
pb.setValue(min(progress, target))
pb.setTextVisible(False)
pb.setMinimumHeight(10)
pb.setMaximumHeight(10)
pb.setStyleSheet(f"""
QProgressBar {{
background: rgba(0,0,0,0.4);
border: none;
border-radius: 5px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
border-radius: 5px;
}}
""")
gauge_row.addWidget(pb, 1)
num = QLabel(f"{progress} / {target}")
num.setStyleSheet(
f"color: {theme['border_strong']}; font-size: 9pt; "
f"font-weight: bold; background: transparent; border: none;"
)
num.setMinimumWidth(60)
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
gauge_row.addWidget(num)
outer.addLayout(gauge_row)
else:
# 시크릿 잠금 — 회색 점선 placeholder
placeholder = QLabel("· · · · · · · · · ·")
placeholder.setStyleSheet(
"color: #444; font-size: 12pt; letter-spacing: 4px; "
"background: transparent; border: none;"
)
placeholder.setAlignment(Qt.AlignCenter)
outer.addWidget(placeholder)
outer.addStretch(1)
card.setLayout(outer)
return card
# ----- 탭 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;
}
"""
+28 -19
View File
@@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("외출 기록 수정")
self.setWindowTitle(tr('dlg.break.edit_title'))
self.setFixedSize(380, 180)
layout = QVBoxLayout()
@@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
# 외출 시간
out_layout = QHBoxLayout()
out_label = QLabel("외출 시간:")
out_label = QLabel(tr('dlg.break.out_label'))
out_label.setFixedWidth(80)
self.out_time_edit = QTimeEdit()
self.out_time_edit.setDisplayFormat("HH:mm:ss")
@@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
# 복귀 시간
in_layout = QHBoxLayout()
in_label = QLabel("복귀 시간:")
in_label = QLabel(tr('dlg.break.in_label'))
in_label.setFixedWidth(80)
self.in_time_edit = QTimeEdit()
self.in_time_edit.setDisplayFormat("HH:mm:ss")
@@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
# 사유
reason_layout = QHBoxLayout()
reason_label = QLabel("사유:")
reason_label = QLabel(tr('dlg.break.reason_label'))
reason_label.setFixedWidth(80)
self.reason_edit = QLineEdit()
reason_layout.addWidget(reason_label)
@@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
cancel_button = QPushButton("취소")
save_button = QPushButton(tr('btn.save'))
cancel_button = QPushButton(tr('btn.cancel'))
save_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject)
@@ -128,7 +128,7 @@ class BreakView(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("오늘의 외출 기록")
title = QLabel(tr('view.break.today_title'))
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
@@ -136,7 +136,13 @@ class BreakView(QDialog):
# 외출 리스트 테이블
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""])
self.table.setHorizontalHeaderLabels([
tr('view.break.col_out'),
tr('view.break.col_in'),
tr('view.break.col_duration'),
tr('view.break.col_reason'),
"",
])
# 테이블 설정
header = self.table.horizontalHeader()
@@ -152,7 +158,7 @@ class BreakView(QDialog):
layout.addWidget(self.table)
# 총 외출 시간 표시
self.total_label = QLabel("총 외출 시간: 0분")
self.total_label = QLabel(tr('view.break.total_zero'))
self.total_label.setObjectName("section_title")
self.total_label.setAlignment(Qt.AlignRight)
layout.addWidget(self.total_label)
@@ -160,8 +166,8 @@ class BreakView(QDialog):
# 버튼
button_layout = QHBoxLayout()
self.refresh_button = QPushButton("새로고침")
close_button = QPushButton("닫기")
self.refresh_button = QPushButton(tr('btn.refresh'))
close_button = QPushButton(tr('btn.close'))
self.refresh_button.clicked.connect(self.load_break_records)
close_button.clicked.connect(self.accept)
@@ -190,7 +196,7 @@ class BreakView(QDialog):
if break_in:
self.table.setItem(i, 1, QTableWidgetItem(break_in))
else:
item = QTableWidgetItem("진행중")
item = QTableWidgetItem(tr('view.break.in_progress'))
item.setForeground(Qt.red)
self.table.setItem(i, 1, item)
@@ -199,7 +205,10 @@ class BreakView(QDialog):
if total_minutes:
hours = total_minutes // 60
minutes = total_minutes % 60
duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분"
if hours > 0:
duration_str = tr('view.break.duration_fmt', h=hours, m=minutes)
else:
duration_str = tr('view.break.duration_min_only', m=minutes)
self.table.setItem(i, 2, QTableWidgetItem(duration_str))
else:
self.table.setItem(i, 2, QTableWidgetItem("-"))
@@ -214,8 +223,8 @@ class BreakView(QDialog):
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(5)
edit_button = QPushButton("수정")
delete_button = QPushButton("삭제")
edit_button = QPushButton(tr('btn.edit_short'))
delete_button = QPushButton(tr('btn.delete_short'))
edit_button.setFixedSize(50, 25)
delete_button.setFixedSize(50, 25)
@@ -237,9 +246,9 @@ class BreakView(QDialog):
minutes = total_minutes % 60
if hours > 0:
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분")
self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
else:
self.total_label.setText(f"총 외출 시간: {minutes}분")
self.total_label.setText(tr('view.break.total_min_only', m=minutes))
def edit_record(self, record_id):
"""외출 기록 수정"""
@@ -277,8 +286,8 @@ class BreakView(QDialog):
"""외출 기록 삭제"""
reply = QMessageBox.question(
self,
"삭제 확인",
"이 외출 기록을 삭제하시겠습니까?",
tr('msg.confirm_delete.title'),
tr('view.break.delete_confirm'),
QMessageBox.Yes | QMessageBox.No
)
+69 -19
View File
@@ -20,6 +20,33 @@ except ImportError:
_MPL = False
# 다크 테마 색상 (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
def _apply_dark_axes(ax) -> None:
"""차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경."""
ax.set_facecolor(_CHART_BG)
ax.tick_params(axis='both', colors=_CHART_TEXT)
ax.xaxis.label.set_color(_CHART_TEXT)
ax.yaxis.label.set_color(_CHART_TEXT)
ax.title.set_color(_CHART_TEXT)
for spine in ax.spines.values():
spine.set_color(_CHART_GRID)
ax.grid(axis='y', alpha=0.25, color=_CHART_GRID)
def _apply_dark_figure(fig) -> None:
"""figure 배경을 다크 톤으로."""
fig.patch.set_facecolor(_CHART_BG)
class _Fallback(QWidget):
"""matplotlib 미설치 시 안내."""
def __init__(self, message: str):
@@ -38,10 +65,12 @@ def make_chart_widget(parent=None) -> QWidget:
if not _MPL:
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
widget = QWidget(parent)
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG)
canvas = FigureCanvas(fig)
canvas.setStyleSheet(f"background: {_CHART_BG};")
layout.addWidget(canvas)
widget.setLayout(layout)
widget._figure = fig
@@ -55,9 +84,12 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
return
fig = widget._figure
fig.clear()
_apply_dark_figure(fig)
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
_apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw()
return
@@ -68,20 +100,23 @@ 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='#4a90e2')
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
color=_CHART_BAR_OVERTIME)
ax.set_ylabel('시간')
ax.legend(loc='upper left', fontsize=8)
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)
ax.grid(axis='y', alpha=0.3)
_apply_dark_axes(ax)
# 호버 annotation 설정
annot = ax.annotate(
"", xy=(0, 0), xytext=(15, 15),
textcoords="offset points",
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
alpha=0.95),
color="white", fontsize=9,
arrowprops=dict(arrowstyle="->", color="#888"),
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
)
annot.set_visible(False)
@@ -102,6 +137,14 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
annot.set_text(text)
annot.set_visible(True)
widget._canvas.draw_idle()
# 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록
db = getattr(widget, '_achievement_db', None)
if db is not None:
try:
if db.get_setting('chart_hover_discovered', 'false').lower() != 'true':
db.set_setting('chart_hover_discovered', 'true')
except Exception:
pass
return
if annot.get_visible():
annot.set_visible(False)
@@ -117,13 +160,15 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
return
fig = widget._figure
fig.clear()
_apply_dark_figure(fig)
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
_apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw()
return
# 출근 시각을 분 단위로 (00:00=0)
minutes_list = []
for r in records:
ci = r.get('clock_in')
@@ -137,26 +182,31 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
pass
if not minutes_list:
ax = fig.add_subplot(111)
_apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw()
return
# 30분 빈
bin_size = 30
min_m = (min(minutes_list) // bin_size) * bin_size
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
bins = list(range(min_m, max_m + bin_size, bin_size))
ax = fig.add_subplot(111)
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
edgecolor=_CHART_BG, linewidth=1)
avg = sum(minutes_list) / len(minutes_list)
ax.axvline(avg, color='#ff6b6b', linestyle='--',
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
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_title('출근 시각 분포')
ax.legend(loc='upper right', fontsize=8)
ax.grid(axis='y', alpha=0.3)
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
_apply_dark_axes(ax)
widget._canvas.draw()
@@ -166,6 +216,7 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
return
fig = widget._figure
fig.clear()
_apply_dark_figure(fig)
from datetime import datetime as _dt
weekday_totals = [0.0] * 7
@@ -182,9 +233,8 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
labels = ['월', '화', '수', '목', '금', '토', '일']
ax = fig.add_subplot(111)
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
ax.bar(labels, avg, color=colors)
ax.set_ylabel('평균 시간')
ax.set_title('요일별 평균 근무시간')
ax.grid(axis='y', alpha=0.3)
_apply_dark_axes(ax)
widget._canvas.draw()
+6 -6
View File
@@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 안내 문구
info_label = QLabel("오늘의 출근시간을 입력해주세요")
info_label = QLabel(tr('dlg.clock_in.prompt'))
info_label.setObjectName("field_label")
info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label)
# 시간 입력
time_layout = QHBoxLayout()
time_label = QLabel("출근시간:")
time_label = QLabel(tr('dlg.clock_in.label'))
time_label.setObjectName("field_label")
self.time_edit = QTimeEdit()
@@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
# 빠른 선택 버튼
quick_layout = QHBoxLayout()
quick_label = QLabel("빠른 선택:")
quick_label = QLabel(tr('dlg.clock_in.quick'))
quick_label.setObjectName("field_label")
btn_8am = QPushButton("08:00")
btn_9am = QPushButton("09:00")
btn_10am = QPushButton("10:00")
btn_now = QPushButton("현재")
btn_now = QPushButton(tr('dlg.clock_in.btn_now'))
for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
btn.setMinimumHeight(30)
@@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
# 버튼
button_layout = QHBoxLayout()
ok_button = QPushButton("확인")
ok_button = QPushButton(tr('btn.confirm'))
ok_button.setObjectName("btn_primary")
ok_button.setMinimumHeight(40)
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.setMinimumHeight(40)
cancel_button.clicked.connect(self.reject)
+6 -1
View File
@@ -17,12 +17,17 @@ class LockMonitor:
self.window = window
self.db = window.db
self.last_locked: bool = False
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
def tick(self) -> None:
try:
from utils.lock_detector import is_screen_locked
locked = is_screen_locked()
except Exception:
except Exception as e:
if not self._detector_failed_once:
self._detector_failed_once = True
from utils.debug_log import dlog
dlog(f"lock detector failed (silenced after first): {e}")
return
was_locked = self.last_locked
+56
View File
@@ -0,0 +1,56 @@
"""
점심/저녁 토글 컨트롤러.
main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status /
update_dinner_status 가 합쳐져 있던 것을 분리. 1Hz hot path 외 사용자 액션 응답.
단위 테스트가 가능하도록 window 의존성을 명시적으로 받음.
"""
from __future__ import annotations
from datetime import datetime
from core.i18n import tr
class MealController:
"""점심/저녁 토글 + 상태 라벨 갱신."""
def __init__(self, window):
self.window = window
self.db = window.db
# -------- 토글 --------
def toggle_lunch(self) -> None:
w = self.window
w.lunch_break_enabled = w.lunch_button.isChecked()
self.refresh_lunch_label()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if w.lunch_break_enabled:
w.auto_lunch_applied_today = True
if w.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, w.lunch_break_enabled)
def toggle_dinner(self) -> None:
w = self.window
w.dinner_break_enabled = w.dinner_button.isChecked()
self.refresh_dinner_label()
if w.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, w.dinner_break_enabled)
# -------- 라벨 --------
def refresh_lunch_label(self) -> None:
w = self.window
w.lunch_button.setText(
tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add')
)
def refresh_dinner_label(self) -> None:
w = self.window
w.dinner_button.setText(
tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add')
)
+81 -9
View File
@@ -2,11 +2,24 @@
알림 오케스트레이션.
5분 가드로 건강/주간/누적 임계 알림을 throttle.
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
notifier.py의 알림 메서드를 적절한 시점에 호출.
"""
from __future__ import annotations
from datetime import datetime
from core.settings_keys import (
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
)
from utils.debug_log import dlog
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
try:
v = int(db.get_setting(key, str(default)) or default)
except (ValueError, TypeError):
v = default
return max(lo, min(hi, v))
class NotificationOrchestrator:
"""update_display() 1Hz tick에서 호출."""
@@ -66,19 +79,23 @@ class NotificationOrchestrator:
f"기간: {last_mon} ~ {last_sun}",
color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception:
pass
except Exception as e:
dlog(f"discord weekly_report failed: {e}")
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
n.check_clock_out_soon(expected_clock_out, now)
n.check_lunch_reminder(self.window.clock_in_time,
self.window.lunch_break_enabled, now)
n.check_dinner_reminder(self.window.clock_in_time,
getattr(self.window, 'dinner_break_enabled', False), now)
if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute
@@ -89,18 +106,73 @@ class NotificationOrchestrator:
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3:
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
if consecutive >= consecutive_th:
n.notify_health_warning(consecutive)
self._discord_health(consecutive, break_minutes, now)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52:
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
if weekly_hours > weekly_th:
n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200:
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
if balance_minutes / 60.0 >= ot_threshold_h:
n.notify_overtime_threshold(balance_minutes / 60.0)
# 도전과제 평가 (5분 throttle)
self._evaluate_achievements(now)
def _evaluate_achievements(self, now: datetime) -> None:
"""도전과제 평가 + 신규 잠금 해제 알림.
실패는 silent — 도전과제 시스템이 메인 흐름을 막으면 안 됨.
"""
try:
from core.achievements import evaluate_all
unlocked = evaluate_all(self.db)
except Exception as e:
dlog(f"achievement eval failed: {e}")
return
if not unlocked:
return
# 시스템 알림 + Discord push (옵션)
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
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}"
self.notifier.notification_signal.emit(title, body)
# Discord 통합 push (여러 개면 묶어서)
self._discord_achievements(unlocked)
def _discord_achievements(self, unlocked: list) -> None:
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
return
url = self.db.get_setting('discord_webhook_url', '') or ''
if not url:
return
try:
from utils import discord_webhook
fields = [{"name": f"{a.badge_icon} {a.name}",
"value": a.description, "inline": False}
for a in unlocked[:10]]
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
ok = discord_webhook.send(
url,
f"🏆 도전과제 {len(unlocked)}개 달성!",
f"새로 잠금 해제된 도전과제 입니다.{extra}",
color=discord_webhook.COLOR_YELLOW,
fields=fields,
)
self.db.log_notification('discord', 'achievement', success=ok)
except Exception as e:
dlog(f"discord achievement push failed: {e}")
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
"""건강 경고 Discord push (옵션)."""
if self.db.has_notification_today('discord', 'health'):
@@ -115,5 +187,5 @@ class NotificationOrchestrator:
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
ok = discord_webhook.send_health_warning(url, elapsed)
self.db.log_notification('discord', 'health', success=ok)
except Exception:
pass
except Exception as e:
dlog(f"discord health push failed: {e}")
+383
View File
@@ -0,0 +1,383 @@
"""
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
핵심 원칙:
- 다이얼로그 배경: #0e0e14 (깊은 다크)
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
- 헤더: 큰 숫자 + 그라디언트 progress bar
- 탭: 골드 강조 선택
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
"""
from __future__ import annotations
from typing import Optional, List, Tuple
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
QProgressBar, QPushButton, QWidget, QSizePolicy,
QTabWidget)
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'
ACCENT_GOLD = '#ffd24a'
ACCENT_BLUE = '#6b9eff'
ACCENT_CYAN = '#4adef0'
ACCENT_PINK = '#ff90b8'
ACCENT_GREEN = '#4ade80'
ACCENT_ORANGE = '#fcd34d'
ACCENT_RED = '#fb7185'
# 카드 테마 (등급/상태별)
CARD_THEMES = {
'gold': {
'border': '#ffb700', 'border_strong': '#ffd24a',
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
},
'blue': {
'border': '#5a8eff', 'border_strong': '#6b9eff',
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
},
'cyan': {
'border': '#3acce0', 'border_strong': '#4adef0',
'bg_top': '#0e3340', 'bg_bot': '#08222b',
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
},
'green': {
'border': '#3ace70', 'border_strong': '#4ade80',
'bg_top': '#0e3324', 'bg_bot': '#082218',
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
},
'pink': {
'border': '#ff5a8c', 'border_strong': '#ff90b8',
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
'text': '#ffc0d4', 'accent': ACCENT_PINK,
},
'red': {
'border': '#ea5566', 'border_strong': '#fb7185',
'bg_top': '#3a1620', 'bg_bot': '#260e16',
'text': '#ffb8c0', 'accent': ACCENT_RED,
},
'gray': {
'border': '#44446a', 'border_strong': '#666688',
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
},
}
# ── QSS 헬퍼 ───────────────────────────────────────────────────
def dialog_qss() -> str:
"""다이얼로그 전체 배경."""
return f"QDialog {{ background: {DARK_BG}; }}"
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
return f"""
QTabWidget::pane {{
background: {DARK_PANEL};
border: 1px solid {DARK_BORDER};
border-radius: 10px;
top: -1px;
}}
QTabBar::tab {{
background: {DARK_PANEL_2};
color: {DARK_TEXT_DIM};
padding: 9px 18px;
border: 1px solid {DARK_BORDER};
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 3px;
font-size: 10pt;
}}
QTabBar::tab:selected {{
background: {DARK_PANEL};
color: {accent};
font-weight: bold;
border-bottom: 2px solid {accent};
}}
QTabBar::tab:hover:!selected {{
background: #2a2a36;
color: {DARK_TEXT};
}}
"""
def scroll_qss() -> str:
return f"""
QScrollArea {{ background: transparent; border: none; }}
QScrollBar:vertical {{
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
}}
QScrollBar::handle:horizontal {{
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
"""
def button_qss(variant: str = 'default') -> str:
""" variant: default | primary | success | danger | ghost """
if variant == 'primary':
return f"""
QPushButton {{
background: {ACCENT_BLUE}; color: white;
border: none; border-radius: 6px;
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}; }}
"""
if variant == 'success':
return f"""
QPushButton {{
background: {ACCENT_GREEN}; color: #0e2a1a;
border: none; border-radius: 6px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: #6ae899; }}
"""
if variant == 'danger':
return f"""
QPushButton {{
background: {ACCENT_RED}; color: white;
border: none; border-radius: 6px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: #fc8896; }}
"""
if variant == 'ghost':
return f"""
QPushButton {{
background: transparent; color: {DARK_TEXT_DIM};
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
padding: 6px 14px; font-size: 9.5pt;
}}
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
border-color: {ACCENT_BLUE}; }}
"""
# default
return f"""
QPushButton {{
background: {DARK_PANEL_2}; color: {DARK_TEXT};
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
padding: 8px 18px; font-size: 10pt;
}}
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
"""
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
big_color: str = ACCENT_GOLD,
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
Args:
title: 큰 숫자 위 작은 라벨 (예: "달성")
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
big_color: 큰 숫자 색
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
"""
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;
}}
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
""")
layout = QHBoxLayout()
layout.setContentsMargins(20, 14, 20, 14)
layout.setSpacing(20)
# 좌측: 제목 + 큰 숫자
left = QVBoxLayout()
left.setSpacing(2)
if title:
t = QLabel(title)
t.setStyleSheet(
f"font-size: 9pt; color: {DARK_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" {subtitle}</span>" if subtitle else '')
)
big.setTextFormat(Qt.RichText)
big.setStyleSheet("background: transparent; border: none;")
left.addWidget(big)
layout.addLayout(left)
# 우측: extra widgets
if extra_widgets:
layout.addStretch()
for w in extra_widgets:
layout.addWidget(w)
else:
layout.addStretch()
container.setLayout(layout)
return container
def build_stat_card(title: str, value: str, subtitle: str = '',
theme: str = 'blue', icon: str = '') -> QFrame:
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
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']};
border-radius: 10px;
}}
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
""")
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.setMinimumWidth(48)
icon_lbl.setAlignment(Qt.AlignCenter)
outer.addWidget(icon_lbl)
text_box = QVBoxLayout()
text_box.setSpacing(2)
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
f"font-size: 9.5pt; color: {DARK_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"{value}</span>"
)
val_lbl.setTextFormat(Qt.RichText)
val_lbl.setStyleSheet("background: transparent; border: none;")
text_box.addWidget(val_lbl)
if subtitle:
sub_lbl = QLabel(subtitle)
sub_lbl.setStyleSheet(
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
f"background: transparent; border: none;"
)
sub_lbl.setWordWrap(True)
text_box.addWidget(sub_lbl)
outer.addLayout(text_box, 1)
card.setLayout(outer)
return card
def build_section_card(title: str, content: QWidget,
theme: str = 'gray', icon: str = '') -> QFrame:
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
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']};
border-radius: 10px;
}}
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
""")
layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 14)
layout.setSpacing(8)
head = QHBoxLayout()
if icon:
i = QLabel(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"background: transparent; border: none;"
)
head.addWidget(title_lbl)
head.addStretch()
layout.addLayout(head)
layout.addWidget(content, 1)
card.setLayout(layout)
return card
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
height: int = 10) -> None:
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
pb.setMinimumHeight(height)
pb.setMaximumHeight(height)
pb.setTextVisible(False)
pb.setStyleSheet(f"""
QProgressBar {{
background: rgba(0,0,0,0.4);
border: none;
border-radius: {height // 2}px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {t['border']}, stop:1 {t['border_strong']});
border-radius: {height // 2}px;
}}
""")
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
color: str = DARK_TEXT) -> QLabel:
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
lbl = QLabel(text)
weight_str = 'bold' if weight == 'bold' else 'normal'
lbl.setStyleSheet(
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
f"background: transparent; border: none; padding: 0; margin: 0;"
)
return lbl
+112 -16
View File
@@ -2,6 +2,7 @@
사용 설명 가이드 창.
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
도전과제/통계 다이얼로그와 동일한 다크 톤.
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QWidget, QTabWidget, QTextBrowser)
@@ -9,6 +10,10 @@ 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,
)
class HelpView(QDialog):
@@ -28,42 +33,48 @@ class HelpView(QDialog):
super().__init__(parent)
self.setWindowTitle(tr('window.help'))
self.setModal(True)
self.setMinimumSize(680, 720)
self.setMinimumSize(720, 720)
self.resize(820, 760)
self.setStyleSheet(dialog_qss())
self.init_ui()
apply_dark_titlebar(self)
apply_dark_titlebar(self, dark=True)
def init_ui(self):
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.setContentsMargins(20, 16, 20, 14)
main_layout.setSpacing(10)
title = QLabel(tr('window.help'))
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
# 다크 타이틀
title = QLabel(f"📖 {tr('window.help')}")
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_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))
main_layout.addWidget(tabs)
main_layout.addWidget(tabs, 1)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 10, 20, 20)
button_layout.setContentsMargins(0, 6, 0, 0)
# 온보딩 다시 보기 (왼쪽)
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
onboarding_button.setMinimumHeight(40)
onboarding_button.setMinimumHeight(36)
onboarding_button.setStyleSheet(button_qss('ghost'))
onboarding_button.clicked.connect(self._reopen_onboarding)
button_layout.addWidget(onboarding_button)
button_layout.addStretch()
close_button = QPushButton(tr('btn.close'))
close_button.setObjectName("btn_primary")
close_button.setMinimumHeight(40)
close_button.setMinimumHeight(36)
close_button.setMinimumWidth(120)
close_button.setStyleSheet(button_qss('primary'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
main_layout.addLayout(button_layout)
@@ -78,15 +89,100 @@ class HelpView(QDialog):
def _make_tab(self, html: str) -> QWidget:
container = QWidget()
container.setStyleSheet(f"background: {DARK_PANEL};")
layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 12)
layout.setContentsMargins(0, 0, 0, 0)
browser = QTextBrowser()
browser.setOpenExternalLinks(False)
browser.setHtml(html)
# HTML 내부에 다크 톤 스타일 주입
styled_html = self._inject_dark_styles(html)
browser.setHtml(styled_html)
browser.setStyleSheet(f"""
QTextBrowser {{
background: {DARK_PANEL};
color: {DARK_TEXT};
border: none;
padding: 16px 20px;
font-size: 10.5pt;
selection-background-color: {ACCENT_GOLD};
selection-color: #1a1a26;
}}
QScrollBar:vertical {{
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
""")
layout.addWidget(browser)
container.setLayout(layout)
return container
def _inject_dark_styles(self, html: str) -> str:
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
css = f"""
<style>
body, p, li {{
color: #e8e8f4;
font-size: 14px;
line-height: 1.65;
}}
h1, h2, h3, h4 {{
color: #ffd24a;
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; }}
code {{
background: #1c1c28;
color: #ffd24a;
padding: 2px 6px;
border-radius: 4px;
font-family: Consolas, monospace;
font-size: 12px;
}}
pre {{
background: #1c1c28;
border: 1px solid #2a2a3a;
border-radius: 6px;
padding: 10px;
color: #e8e8f4;
}}
ul, ol {{ margin-left: 0; padding-left: 24px; }}
li {{ margin-bottom: 4px; }}
a {{ color: #4adef0; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
table {{ border-collapse: collapse; margin: 10px 0; }}
th {{
background: #2a2a3a;
color: #ffd24a;
padding: 8px 12px;
border: 1px solid #44446a;
text-align: left;
}}
td {{
padding: 6px 12px;
border: 1px solid #2a2a3a;
color: #e8e8f4;
}}
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
blockquote {{
border-left: 3px solid #6b9eff;
margin-left: 0;
padding: 4px 16px;
color: #a0a0b8;
background: rgba(107, 158, 255, 0.05);
}}
</style>
"""
return css + html
# 단독 실행 테스트
if __name__ == "__main__":
+94
View File
@@ -0,0 +1,94 @@
"""
런타임 i18n 재번역 — 재시작 없이 언어 전환.
사용법:
from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate
label = QLabel(tr('label.foo'))
register(label, 'label.foo') # 약한 참조로 등록
button = QPushButton()
register(button, 'btn.bar', kwargs={'name': 'X'})
# 그룹박스 제목 등 setter가 다른 경우
register(group, 'group.work_time', setter='setTitle')
# 윈도우 제목
register(dialog, 'window.foo', setter='setWindowTitle')
언어 변경:
set_language_and_retranslate('en')
각 widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는
키는 kwargs를 함께 등록하면 retranslate 시 같은 인자로 재계산.
"""
from __future__ import annotations
import weakref
from typing import Any, Callable, List, Tuple, Optional
from core.i18n import tr, set_language as _set_language
# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트
# weakref가 죽으면 다음 retranslate 시 정리.
_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = []
def register(widget: Any, key: str, *, setter: str = 'setText',
kwargs: Optional[dict] = None,
post: Optional[Callable[[str], str]] = None) -> None:
"""위젯을 retranslate 대상으로 등록.
Args:
widget: PyQt 위젯 (setText/setTitle/setWindowTitle 등 지원)
key: i18n 키
setter: 호출할 메서드명 (기본 setText)
kwargs: tr()에 전달할 format 인자 (정적인 경우만)
post: 번역 후 한 번 더 가공할 콜백 — 예: 이모지 prefix
"""
# 약한 참조 — 위젯 삭제 시 자동 GC
try:
ref = weakref.ref(widget)
except TypeError:
# weakref 미지원 객체는 retranslate 불가
return
_registry.append((ref, key, setter, kwargs or {}, post))
# 초기 적용
_apply(widget, key, setter, kwargs or {}, post)
def retranslate_all() -> None:
"""모든 등록된 위젯에 현재 언어로 텍스트 재적용."""
global _registry
alive = []
for ref, key, setter, kw, post in _registry:
widget = ref()
if widget is None:
continue # 죽은 위젯은 빼버림
try:
_apply(widget, key, setter, kw, post)
alive.append((ref, key, setter, kw, post))
except RuntimeError:
# Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스
continue
_registry = alive
def set_language_and_retranslate(lang: str) -> None:
"""언어 전환 + 즉시 재번역."""
_set_language(lang)
retranslate_all()
def clear() -> None:
"""레지스트리 비우기 (테스트용)."""
_registry.clear()
def _apply(widget: Any, key: str, setter: str, kw: dict,
post: Optional[Callable[[str], str]]) -> None:
text = tr(key, **kw)
if post:
text = post(text)
fn = getattr(widget, setter, None)
if callable(fn):
fn(text)
+50 -48
View File
@@ -40,27 +40,32 @@ class LeaveView(QDialog):
# 제목 + 잔액 + 설정 한 줄
header_layout = QHBoxLayout()
title = QLabel("연차 관리")
title = QLabel(tr('view.leave.title'))
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔여: 0일")
self.balance_label = QLabel(tr('view.leave.balance_zero'))
self.balance_label.setObjectName("badge_leave")
header_layout.addWidget(self.balance_label)
set_balance_button = QPushButton("잔여 설정")
set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
set_balance_button.clicked.connect(self.set_balance)
header_layout.addWidget(set_balance_button)
layout.addLayout(header_layout)
# 사용 내역
used_group = QGroupBox("📤 사용 내역")
used_group = QGroupBox(tr('view.leave.used_group'))
used_layout = QVBoxLayout()
used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget()
self.used_table.setColumnCount(4)
self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"])
self.used_table.setHorizontalHeaderLabels([
tr('view.leave.col_date'),
tr('view.leave.col_type'),
tr('view.leave.col_used'),
tr('view.leave.col_reason'),
])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
@@ -77,15 +82,15 @@ class LeaveView(QDialog):
# 버튼들
button_layout = QHBoxLayout()
add_leave_button = QPushButton("➕ 연차 사용 추가")
add_leave_button = QPushButton(tr('view.leave.btn_add'))
add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button)
cal_button = QPushButton("📅 캘린더 보기")
cal_button = QPushButton(tr('view.leave.btn_calendar'))
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
close_button = QPushButton("닫기")
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
@@ -102,7 +107,8 @@ class LeaveView(QDialog):
# 잔액 업데이트
balance = self.db.get_leave_balance()
hours = balance * 8
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
self.balance_label.setText(tr('view.leave.balance_fmt',
days=balance, hours=hours))
# 사용 내역 로드 (잔액 조정 제외)
records = self.db.get_leave_records(exclude_bulk=True)
@@ -144,7 +150,7 @@ class LeaveView(QDialog):
return
menu = QMenu(self)
delete_action = QAction("삭제", self)
delete_action = QAction(tr('btn.delete_short'), self)
delete_action.triggered.connect(self.delete_leave_record)
menu.addAction(delete_action)
menu.exec_(self.used_table.viewport().mapToGlobal(position))
@@ -164,11 +170,10 @@ class LeaveView(QDialog):
reply = QMessageBox.question(
self,
"삭제 확인",
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"구분: {type_item.text()}\n"
f"사용: {days_item.text()}",
tr('msg.confirm_delete.title'),
tr('view.leave.delete_confirm_body',
date=date_item.text(), type=type_item.text(),
days=days_item.text()),
QMessageBox.Yes | QMessageBox.No
)
@@ -183,13 +188,12 @@ class LeaveView(QDialog):
hours, ok = QInputDialog.getDouble(
self,
"연차 시간 설정",
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
tr('view.leave.set_title'),
tr('view.leave.set_prompt'),
current_hours,
0.0,
999.0,
1 # 소수점 첫째자리까지 (0.5 단위)
1
)
if ok:
@@ -200,8 +204,8 @@ class LeaveView(QDialog):
self.db.set_leave_balance(days)
QMessageBox.information(
self,
"설정 완료",
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
tr('view.leave.set_done_title'),
tr('view.leave.set_done_body', days=days, hours=hours)
)
self.load_data()
@@ -223,7 +227,7 @@ class AddLeaveDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("연차 사용 기록 추가")
self.setWindowTitle(tr('view.leave.add_title'))
self.setModal(True)
self.setMinimumWidth(360)
@@ -232,14 +236,14 @@ class AddLeaveDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("연차 사용 기록 추가")
title = QLabel(tr('view.leave.add_title'))
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜 + 구분 한 줄
row1 = QHBoxLayout()
date_label = QLabel("날짜:")
date_label = QLabel(tr('view.leave.field_date'))
date_label.setObjectName("field_label")
date_label.setFixedWidth(40)
self.date_edit = QDateEdit()
@@ -248,14 +252,14 @@ class AddLeaveDialog(QDialog):
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
type_label = QLabel("구분:")
type_label = QLabel(tr('view.leave.field_type'))
type_label.setObjectName("field_label")
type_label.setFixedWidth(40)
self.type_combo = QComboBox()
self.type_combo.addItem("연차", "annual")
self.type_combo.addItem("반차", "half")
self.type_combo.addItem("반반차", "quarter")
self.type_combo.addItem("시간", "hourly")
self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
self.type_combo.addItem(tr('view.leave.type_half'), "half")
self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
row1.addWidget(type_label)
row1.addWidget(self.type_combo)
@@ -263,14 +267,14 @@ class AddLeaveDialog(QDialog):
# 사용 시간 (시간 연차용)
hours_layout = QHBoxLayout()
hours_label = QLabel("시간:")
hours_label = QLabel(tr('view.leave.field_hours'))
hours_label.setObjectName("field_label")
hours_label.setFixedWidth(40)
self.hours_spin = QDoubleSpinBox()
self.hours_spin.setRange(0.5, 8.0)
self.hours_spin.setSingleStep(0.5)
self.hours_spin.setValue(1.0)
self.hours_spin.setSuffix(" 시간")
self.hours_spin.setSuffix(' ' + tr('label.unit_hour'))
self.hours_spin.setEnabled(False)
hours_layout.addWidget(hours_label)
hours_layout.addWidget(self.hours_spin)
@@ -278,27 +282,27 @@ class AddLeaveDialog(QDialog):
# 사유
memo_layout = QHBoxLayout()
memo_label = QLabel("사유:")
memo_label = QLabel(tr('view.leave.field_reason'))
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(40)
self.memo_input = QLineEdit()
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_input)
layout.addLayout(memo_layout)
# 안내
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
info_label = QLabel(tr('view.leave.note_auto_deduct'))
info_label.setObjectName("note_text")
layout.addWidget(info_label)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save_record)
button_layout.addWidget(save_button)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
@@ -336,8 +340,8 @@ class AddLeaveDialog(QDialog):
if current_balance < days:
QMessageBox.warning(
self,
"잔여 연차 부족",
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
tr('view.leave.short_title'),
tr('view.leave.short_body', balance=current_balance, req=days)
)
return
@@ -345,12 +349,10 @@ class AddLeaveDialog(QDialog):
hours = days * 8
reply = QMessageBox.question(
self,
"연차 사용 기록 추가",
f"날짜: {date}\n"
f"구분: {leave_type_name}\n"
f"사용: {days}일 ({hours}시간)\n"
f"사유: {memo if memo else '(없음)'}\n\n"
f"이 기록을 추가하시겠습니까?",
tr('view.leave.confirm_title'),
tr('view.leave.confirm_body',
date=date, type=leave_type_name, days=days, hours=hours,
reason=(memo if memo else '-')),
QMessageBox.Yes | QMessageBox.No
)
@@ -360,15 +362,15 @@ class AddLeaveDialog(QDialog):
self.db.use_leave(days, date, leave_type_name, memo)
QMessageBox.information(
self,
"추가 완료",
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
tr('view.leave.added_title'),
tr('view.leave.added_body', days=days, hours=hours)
)
self.accept()
except Exception as e:
QMessageBox.critical(
self,
"오류",
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
tr('view.leave.error_title'),
tr('view.leave.error_body', err=str(e))
)
+186 -62
View File
@@ -34,25 +34,34 @@ from ui.settings_view import SettingsView
from ui.break_view import BreakView
from core.notifier import Notifier
from utils.system_tray import SystemTrayIcon
from utils.time_format import format_hours_minutes
from ui.styles import get_theme, ThemeColors, apply_dark_titlebar
class MainWindow(QMainWindow):
"""메인 윈도우 클래스"""
def __init__(self):
def __init__(self, db: 'Database' = None):
"""
Args:
db: 사전 초기화된 Database 인스턴스. None이면 자체 부트스트랩.
(main.py가 backup/crash_handler용으로 먼저 만들고 전달)
"""
super().__init__()
# 테마 적용
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
# 데이터베이스 — db_path_override 설정 시 그 경로 사용 (클라우드 동기화 폴더 등)
bootstrap = Database()
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
self.db = Database(override_path)
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
if db is not None:
self.db = db
else:
self.db = bootstrap
bootstrap = Database()
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
self.db = Database(override_path)
else:
self.db = bootstrap
self.event_monitor = EventMonitor()
# 언어 초기화 (설정값 반영)
@@ -81,13 +90,23 @@ class MainWindow(QMainWindow):
self.notifier = Notifier(self, db=self.db)
self.notifier.notification_signal.connect(self.show_notification)
# 책임 분리된 컨트롤러들 (1Hz hot path)
# 도전과제 정의 동기화 (실패는 silent — 핵심 기능 아님)
try:
from core.achievements import sync_definitions_to_db
sync_definitions_to_db(self.db)
except Exception as e:
from utils.debug_log import dlog
dlog(f"achievements sync failed: {e}")
# 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
from ui.controllers.lock_monitor import LockMonitor
from ui.controllers.auto_lunch import AutoLunchManager
from ui.controllers.notification_orchestrator import NotificationOrchestrator
from ui.controllers.meal_controller import MealController
self._lock_monitor = LockMonitor(self)
self._auto_lunch = AutoLunchManager(self)
self._notif_orch = NotificationOrchestrator(self)
self._meal = MealController(self)
# 시스템 트레이
self.tray_icon = SystemTrayIcon(self)
@@ -134,12 +153,41 @@ class MainWindow(QMainWindow):
self._lock_timer.timeout.connect(self._check_screen_lock)
self._lock_timer.start(5000)
# 종료 시 타이머 정리 — aboutToQuit은 QApplication 종료 직전에만 1회 fire.
# 모든 종료 경로(트레이 메뉴/Discord/언어변경 등)를 한 곳에서 커버.
try:
qapp = QApplication.instance()
if qapp is not None:
qapp.aboutToQuit.connect(self._on_app_quit)
except Exception:
pass
# 초기 데이터 로드
self.load_today_data()
# 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시)
QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True))
def _on_app_quit(self) -> None:
"""QApplication.aboutToQuit 핸들러 — 타이머 정지 + 트레이 숨김.
in-flight 1Hz/5s tick이 부분 파괴된 객체에 대해 fire하는 것 방지.
"""
try:
if hasattr(self, 'timer') and self.timer is not None:
self.timer.stop()
if hasattr(self, '_lock_timer') and self._lock_timer is not None:
self._lock_timer.stop()
if hasattr(self, 'notifier') and self.notifier is not None:
# Notifier 내부 1분 timer
if hasattr(self.notifier, 'timer'):
self.notifier.timer.stop()
if hasattr(self, 'tray_icon') and self.tray_icon is not None:
self.tray_icon.hide()
except Exception:
# 정리 실패해도 종료는 진행 — Qt가 결국 다 cleanup
pass
def _check_screen_lock(self):
"""LockMonitor 컨트롤러로 위임 (5초 polling)."""
self._lock_monitor.tick()
@@ -184,7 +232,11 @@ class MainWindow(QMainWindow):
def init_ui(self):
"""UI 초기화"""
from core.version import __version__
from ui.i18n_runtime import register
self._app_version = __version__
self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
register(self, 'window.main_title', setter='setWindowTitle',
post=lambda t: f"⏰ {t} v{__version__}")
self.setGeometry(100, 100, 500, 620)
self.setMinimumSize(480, 520)
@@ -323,16 +375,27 @@ class MainWindow(QMainWindow):
stats_button = QPushButton(tr('menu.stats'))
calendar_button = QPushButton(tr('menu.calendar'))
report_button = QPushButton(tr('menu.daily_report'))
achievements_button = QPushButton("🏆 도전과제")
help_button = QPushButton(tr('menu.help'))
settings_button = QPushButton(tr('menu.settings'))
for btn in [stats_button, calendar_button, report_button, help_button, settings_button]:
# 런타임 i18n 등록
for btn, key in [(stats_button, 'menu.stats'),
(calendar_button, 'menu.calendar'),
(report_button, 'menu.daily_report'),
(help_button, 'menu.help'),
(settings_button, 'menu.settings')]:
register(btn, key)
for btn in [stats_button, calendar_button, report_button,
achievements_button, help_button, settings_button]:
bottom_layout.addWidget(btn)
# 버튼 연결
stats_button.clicked.connect(self.show_stats)
calendar_button.clicked.connect(self.show_calendar)
report_button.clicked.connect(self.generate_daily_report)
achievements_button.clicked.connect(self.show_achievements)
help_button.clicked.connect(self.show_help)
settings_button.clicked.connect(self.show_settings)
@@ -750,40 +813,20 @@ class MainWindow(QMainWindow):
self.date_label.setText(date_str)
def toggle_lunch_break(self):
"""점심시간 토글"""
self.lunch_break_enabled = self.lunch_button.isChecked()
self.update_lunch_status()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if self.lunch_break_enabled:
self.auto_lunch_applied_today = True
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, self.lunch_break_enabled)
"""점심시간 토글 — MealController 위임."""
self._meal.toggle_lunch()
def toggle_dinner_break(self):
"""저녁시간 토글"""
self.dinner_break_enabled = self.dinner_button.isChecked()
self.update_dinner_status()
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, self.dinner_break_enabled)
"""저녁시간 토글 — MealController 위임."""
self._meal.toggle_dinner()
def update_lunch_status(self):
"""점심시간 상태 업데이트"""
self.lunch_button.setText(
tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add')
)
"""점심시간 상태 업데이트 — MealController 위임."""
self._meal.refresh_lunch_label()
def update_dinner_status(self):
"""저녁시간 상태 업데이트"""
self.dinner_button.setText(
tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add')
)
"""저녁시간 상태 업데이트 — MealController 위임."""
self._meal.refresh_dinner_label()
def update_overtime_balance(self):
"""연장근무 잔액 업데이트"""
@@ -1136,7 +1179,6 @@ class MainWindow(QMainWindow):
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
if not auto_overtime and overtime_earned > 0:
from utils.time_format import format_hours_minutes
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
ask = QMessageBox.question(
@@ -1625,6 +1667,12 @@ class MainWindow(QMainWindow):
def show_calendar(self):
"""캘린더 창 표시"""
# 도전과제 카운터
try:
cur = self.db.get_setting_int('calendar_view_count', 0)
self.db.set_setting('calendar_view_count', str(cur + 1))
except Exception:
pass
dialog = CalendarView(self, self.db)
dialog.exec_()
@@ -1658,6 +1706,18 @@ class MainWindow(QMainWindow):
dialog = HelpView(self)
dialog.exec_()
def show_achievements(self):
"""도전과제 다이얼로그 표시."""
from ui.achievements_view import AchievementsView
# 진입 시 즉시 한 번 평가 — UI에 최신 진행도 반영
try:
from core.achievements import evaluate_all
evaluate_all(self.db)
except Exception:
pass
dialog = AchievementsView(self.db, self)
dialog.exec_()
def _show_meal_context(self, meal_type: str, button, pos):
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
from PyQt5.QtWidgets import QMenu
@@ -1675,13 +1735,14 @@ class MainWindow(QMainWindow):
default_min = (self.time_calc.lunch_duration_minutes
if meal_type == 'lunch'
else self.time_calc.dinner_duration_minutes)
dialog = MealTimeDialog(self, meal_type=meal_type, default_minutes=default_min)
# 식사 시각은 출~퇴근 범위 내여야 함. 호출 시점은 항상 출근 후·미퇴근 상태.
dialog = MealTimeDialog(
self, meal_type=meal_type, default_minutes=default_min,
clock_in_time=self.clock_in_time,
)
if dialog.exec_() != QDialog.Accepted:
return
start, end, minutes = dialog.get_times()
if minutes <= 0:
QMessageBox.warning(self, "입력 오류", "종료 시간이 시작보다 빠릅니다.")
return
today = datetime.now().date().isoformat()
self.db.add_meal_record(today, start, end, meal_type=meal_type)
# 자동 토글 ON
@@ -1712,8 +1773,10 @@ class MainWindow(QMainWindow):
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
if not hasattr(self, 'today_summary_card'):
return
# 점심 시간 계산 (lunch_break_enabled면 설정값, 아니면 0)
# 점심/저녁 시간 (플래그 ON이면 설정값, 아니면 0)
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
dinner_min = (self.time_calc.dinner_duration_minutes
if getattr(self, 'dinner_break_enabled', False) else 0)
# 추정 급여 (옵션)
salary_text = ""
@@ -1732,6 +1795,7 @@ class MainWindow(QMainWindow):
self.today_summary_card.show_summary(
total_hours=total_hours,
lunch_minutes=lunch_min,
dinner_minutes=dinner_min,
break_minutes=break_minutes,
overtime_actual=overtime_actual,
overtime_earned=overtime_earned,
@@ -1751,8 +1815,9 @@ class MainWindow(QMainWindow):
from utils import discord_webhook
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
self.db.log_notification('discord', 'clock_in', success=ok)
except Exception:
pass
except Exception as e:
from utils.debug_log import dlog
dlog(f"discord clock_in push failed: {e}")
def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned):
if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true':
@@ -1767,8 +1832,9 @@ class MainWindow(QMainWindow):
total_hours, overtime_actual, overtime_earned,
)
self.db.log_notification('discord', 'clock_out', success=ok)
except Exception:
pass
except Exception as e:
from utils.debug_log import dlog
dlog(f"discord clock_out push failed: {e}")
def check_for_updates(self, silent: bool = False):
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
@@ -2026,10 +2092,52 @@ class MainWindow(QMainWindow):
if self.is_clocked_in and self.clock_in_time:
self.update_display()
def _append_meal_section(self, report_lines, today: str, label: str,
flag: bool, records: list, default_min: int) -> None:
"""일일 보고서의 점심/저녁 섹션 출력.
실측 기록(`break_records.break_type='lunch'/'dinner'`)이 있으면
시작/종료 시각과 실측 분을 표시하고, 플래그만 켜진 경우엔
설정값(`lunch_duration_minutes`/`dinner_duration_minutes`)을 표시.
"""
actual_min = sum((r.get('total_minutes') or 0) for r in records)
if records and actual_min > 0:
report_lines.append(f"{label}: {format_hours_minutes(actual_min)} (실측)")
for r in records:
try:
start_dt = datetime.fromisoformat(f"{today} {r['break_out']}")
except (KeyError, ValueError, TypeError):
continue
if r.get('break_in'):
try:
end_dt = datetime.fromisoformat(f"{today} {r['break_in']}")
except (ValueError, TypeError):
continue
if end_dt < start_dt:
end_dt += timedelta(days=1)
dur = int((end_dt - start_dt).total_seconds() / 60)
report_lines.append(
f" - {self.format_time(start_dt)} ~ {self.format_time(end_dt)} ({dur}분)"
)
else:
report_lines.append(f" - {self.format_time(start_dt)} ~ 진행중")
elif flag:
report_lines.append(f"{label}: 포함 ({format_hours_minutes(default_min)})")
else:
return
report_lines.append("")
def generate_daily_report(self):
"""오늘 하루 근무 내역 보고서 생성 및 클립보드 복사"""
from datetime import date
# 도전과제 카운터 (보고서 생성 횟수)
try:
cur = self.db.get_setting_int('daily_report_count', 0)
self.db.set_setting('daily_report_count', str(cur + 1))
except Exception:
pass
today = date.today().isoformat()
# 오늘의 근무 기록 조회
@@ -2069,16 +2177,20 @@ class MainWindow(QMainWindow):
report_lines.append("")
# 외출 시간
break_minutes = self.db.get_total_break_minutes_today()
if break_minutes > 0:
break_hours = break_minutes // 60
break_mins = break_minutes % 60
report_lines.append(f"🚶 외출 시간: {break_hours}시간 {break_mins}분")
# 외출 / 점심 / 저녁 분리 — break_type 으로 구분 (v2.7.0+)
# 'break'(또는 NULL) = 일반 외출, 'lunch'/'dinner' = 실측 식사 기록
all_break_records = self.db.get_today_break_records()
real_break_records = [b for b in all_break_records
if (b.get('break_type') or 'break') == 'break']
lunch_records = [b for b in all_break_records if b.get('break_type') == 'lunch']
dinner_records = [b for b in all_break_records if b.get('break_type') == 'dinner']
# 외출 상세 내역
break_records = self.db.get_today_break_records()
for br in break_records:
# 외출 시간 (식사 제외)
real_break_minutes = sum((b.get('total_minutes') or 0) for b in real_break_records)
has_active_break = any(not b.get('break_in') for b in real_break_records)
if real_break_minutes > 0 or has_active_break:
report_lines.append(f"🚶 외출 시간: {format_hours_minutes(real_break_minutes)}")
for br in real_break_records:
break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}")
if br['break_in']:
break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}")
@@ -2086,18 +2198,30 @@ class MainWindow(QMainWindow):
if break_in_time < break_out_time:
break_in_time += timedelta(days=1)
duration = int((break_in_time - break_out_time).total_seconds() / 60)
reason = f" ({br['reason']})" if br['reason'] else ""
reason = f" ({br['reason']})" if br.get('reason') else ""
report_lines.append(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}분){reason}")
else:
reason = f" ({br['reason']})" if br['reason'] else ""
reason = f" ({br['reason']})" if br.get('reason') else ""
report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{reason}")
report_lines.append("")
# 점심시간
lunch_break = work_record.get('lunch_break', False)
if lunch_break:
report_lines.append(f"🍱 점심시간: 포함 (1시간)")
report_lines.append("")
lunch_flag = bool(work_record.get('lunch_break', False))
if lunch_flag or lunch_records:
self._append_meal_section(
report_lines, today, '🍱 점심시간',
lunch_flag, lunch_records,
self.time_calc.lunch_duration_minutes,
)
# 저녁시간
dinner_flag = bool(work_record.get('dinner_break', False))
if dinner_flag or dinner_records:
self._append_meal_section(
report_lines, today, '🍽️ 저녁시간',
dinner_flag, dinner_records,
self.time_calc.dinner_duration_minutes,
)
# 추가 근무 적립
if work_record['overtime_minutes'] and work_record['overtime_minutes'] > 0:
+90 -22
View File
@@ -3,44 +3,70 @@
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QMessageBox)
from PyQt5.QtCore import QTime, Qt
from PyQt5.QtCore import QTime
from ui.styles import apply_dark_titlebar
class MealTimeDialog(QDialog):
"""점심/저녁 실제 시작·종료 시간 입력."""
"""점심/저녁 실제 시작·종료 시간 입력.
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
Args:
parent: 부모 위젯
meal_type: 'lunch' 또는 'dinner'
default_minutes: 안내 문구에 표시할 기본 차감 분
clock_in_time: 출근 datetime (옵션). 주어지면 식사가 출근 이후인지 검증.
clock_out_time: 퇴근 datetime (옵션, 보통 미퇴근 상태). 주어지면 퇴근 이전인지 검증.
"""
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60,
clock_in_time: Optional[datetime] = None,
clock_out_time: Optional[datetime] = None):
super().__init__(parent)
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} 시간 입력")
self.setModal(True)
self.setFixedSize(360, 220)
self.setFixedSize(380, 260)
layout = QVBoxLayout()
layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
if clock_in_time is not None:
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
info = QLabel(info_text)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding-bottom: 6px;")
layout.addWidget(info)
# 합리적 기본값: 출근 이후로 보정
default_start_h = 12 if meal_type == 'lunch' else 18
default_end_h = 13 if meal_type == 'lunch' else 19
if clock_in_time is not None and clock_in_time.hour >= default_start_h:
# 출근이 이미 점심/저녁 기본 시각을 지났으면 출근 직후를 기본값으로
default_start_h = (clock_in_time.hour + 1) % 24
default_end_h = (default_start_h + 1) % 24
# 시작
start_row = QHBoxLayout()
start_row.addWidget(QLabel("시작:"))
self.start_edit = QTimeEdit()
self.start_edit.setDisplayFormat("HH:mm")
# 기본값: 점심=12:00, 저녁=18:00
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
self.start_edit.setTime(default_start)
self.start_edit.setTime(QTime(default_start_h, 0))
start_row.addWidget(self.start_edit)
start_row.addStretch()
layout.addLayout(start_row)
@@ -50,8 +76,7 @@ class MealTimeDialog(QDialog):
end_row.addWidget(QLabel("종료:"))
self.end_edit = QTimeEdit()
self.end_edit.setDisplayFormat("HH:mm")
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
self.end_edit.setTime(default_end)
self.end_edit.setTime(QTime(default_end_h, 0))
end_row.addWidget(self.end_edit)
end_row.addStretch()
layout.addLayout(end_row)
@@ -79,30 +104,73 @@ class MealTimeDialog(QDialog):
self.setLayout(layout)
apply_dark_titlebar(self)
def _update_preview(self):
s = self.start_edit.time()
e = self.end_edit.time()
start_dt = datetime.combine(datetime.today(), s.toPyTime())
end_dt = datetime.combine(datetime.today(), e.toPyTime())
# -------- 내부 시각 계산 --------
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
자정 경계 처리:
1) 야간 출근자(clock_in 18시 이후)가 새벽 식사 시각을 입력하면
시작이 출근 이전으로 보이는데, 이를 다음날 새벽으로 +1day shift.
2) 종료가 시작보다 빠르면 종료에 +1day (점심 12:55→13:30 같은 정상은 영향 X).
주간 출근자(clock_in 09시)가 08시 입력 시 +1day는 적용하지 않아 검증에서 거절.
"""
s = self.start_edit.time().toPyTime()
e = self.end_edit.time().toPyTime()
base_date = (self._clock_in.date() if self._clock_in is not None
else datetime.today().date())
start_dt = datetime.combine(base_date, s)
end_dt = datetime.combine(base_date, e)
# 야간 출근자 자동 보정
if (self._clock_in is not None and start_dt < self._clock_in
and self._clock_in.hour >= 18):
start_dt += timedelta(days=1)
end_dt += timedelta(days=1)
if end_dt < start_dt:
end_dt += timedelta(days=1)
minutes = int((end_dt - start_dt).total_seconds() / 60)
if minutes <= 0:
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
return start_dt, end_dt, minutes
def _update_preview(self):
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;")
else:
self.preview.setText(f"총 {minutes}분")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
def _validate_window(self, start_dt: datetime, end_dt: datetime,
minutes: int) -> tuple[bool, str]:
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
if minutes <= 0:
return False, "시작이 종료보다 늦습니다"
if minutes > 8 * 60:
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
return False, "식사 시간이 8시간을 초과합니다"
if self._clock_in is not None and start_dt < self._clock_in:
return False, f"출근({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 True, ""
def accept(self):
"""저장 버튼: 검증 실패 시 다이얼로그 닫지 않고 메시지박스."""
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)
return
super().accept()
def get_times(self) -> tuple[str, str, int]:
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
s = self.start_edit.time().toPyTime()
e = self.end_edit.time().toPyTime()
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
s_dt = datetime.combine(datetime.today(), s)
e_dt = datetime.combine(datetime.today(), e)
if e_dt < s_dt:
e_dt += timedelta(days=1)
minutes = int((e_dt - s_dt).total_seconds() / 60)
_, _, minutes = self._resolve_meal_window()
return start_str, end_str, minutes
+62 -24
View File
@@ -12,16 +12,20 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
QMessageBox, QGroupBox)
from PyQt5.QtCore import Qt
from core.i18n import tr
from ui.styles import apply_dark_titlebar
# (label, work_minutes, lunch_minutes)
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
WORK_PRESETS = [
("표준 8시간 (점심 60분)", 480, 60),
("단축근무 7시간 30분 (점심 30분)", 450, 30),
("단축근무 7시간 (점심 60분)", 420, 60),
("단축근무 6시간 (점심 30분)", 360, 30),
("반일 4시간 (점심 0분)", 240, 0),
('onboarding.preset.standard_8h', 480, 60, 0),
('onboarding.preset.short_7h30m', 450, 30, 0),
('onboarding.preset.short_7h', 420, 60, 0),
('onboarding.preset.short_6h', 360, 30, 0),
('onboarding.preset.half_4h', 240, 0, 0),
]
@@ -52,48 +56,72 @@ class WorkPatternPage(QWizardPage):
layout = QVBoxLayout()
self.button_group = QButtonGroup(self)
for i, (label, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(label)
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(tr(key))
self.button_group.addButton(rb, i)
layout.addWidget(rb)
if i == 0:
rb.setChecked(True)
# 사용자 정의
custom_box = QGroupBox("사용자 정의")
custom_layout = QHBoxLayout()
self.custom_radio = QRadioButton("직접 입력:")
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
custom_layout = QVBoxLayout()
custom_layout.setSpacing(4)
suffix_h = tr('onboarding.preset.suffix_hours')
suffix_m = tr('onboarding.preset.suffix_minutes')
row1 = QHBoxLayout()
self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio'))
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
self.hours_spin = QSpinBox()
self.hours_spin.setRange(0, 12)
self.hours_spin.setValue(8)
self.hours_spin.setSuffix(" 시간")
self.hours_spin.setSuffix(suffix_h)
self.minutes_spin = QSpinBox()
self.minutes_spin.setRange(0, 59)
self.minutes_spin.setSingleStep(15)
self.minutes_spin.setSuffix(" 분")
self.minutes_spin.setSuffix(suffix_m)
row1.addWidget(self.custom_radio)
row1.addWidget(self.hours_spin)
row1.addWidget(self.minutes_spin)
row1.addStretch()
custom_layout.addLayout(row1)
row2 = QHBoxLayout()
self.lunch_spin = QSpinBox()
self.lunch_spin.setRange(0, 120)
self.lunch_spin.setSingleStep(5)
self.lunch_spin.setValue(60)
self.lunch_spin.setPrefix("점심 ")
self.lunch_spin.setSuffix(" 분")
custom_layout.addWidget(self.custom_radio)
custom_layout.addWidget(self.hours_spin)
custom_layout.addWidget(self.minutes_spin)
custom_layout.addWidget(self.lunch_spin)
custom_layout.addStretch()
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
self.lunch_spin.setSuffix(suffix_m)
self.dinner_spin = QSpinBox()
self.dinner_spin.setRange(0, 120)
self.dinner_spin.setSingleStep(5)
self.dinner_spin.setValue(0)
self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix'))
self.dinner_spin.setSuffix(suffix_m)
self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip'))
row2.addSpacing(20)
row2.addWidget(self.lunch_spin)
row2.addWidget(self.dinner_spin)
row2.addStretch()
custom_layout.addLayout(row2)
custom_box.setLayout(custom_layout)
layout.addWidget(custom_box)
self.setLayout(layout)
def selected_minutes(self):
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
idx = self.button_group.checkedId()
if 0 <= idx < len(WORK_PRESETS):
_, wm, lm = WORK_PRESETS[idx]
return wm, lm
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
_, wm, lm, dm = WORK_PRESETS[idx]
return wm, lm, dm
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
self.lunch_spin.value(),
self.dinner_spin.value())
class ClockInDetectionPage(QWizardPage):
@@ -232,6 +260,13 @@ class DiscordPage(QWizardPage):
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
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}"
)
return
ok = discord_webhook.send_test(url)
if ok:
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
@@ -286,7 +321,7 @@ class OnboardingWizard(QWizard):
def accept(self):
# 1. 근무 패턴
wm, lm = self.work_page.selected_minutes()
wm, lm, dm = self.work_page.selected_minutes()
if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
return
@@ -294,6 +329,7 @@ class OnboardingWizard(QWizard):
settings = {
'work_minutes': wm,
'lunch_duration_minutes': lm,
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
'annual_leave_days': self.leave_page.leave_spin.value(),
'annual_leave_total': self.leave_page.leave_spin.value(),
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
@@ -301,6 +337,8 @@ class OnboardingWizard(QWizard):
'overtime_rate': self.leave_page.rate_combo.currentData(),
'onboarding_completed': True,
}
if dm > 0:
settings['dinner_duration_minutes'] = dm
# 2. 출근 감지 방식
mode = self.detect_page.detection_mode()
+79 -58
View File
@@ -38,24 +38,28 @@ class OvertimeView(QDialog):
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("연장근무 내역")
title = QLabel(tr('view.overtime.title'))
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔액: 0분")
self.balance_label = QLabel(tr('view.overtime.balance_zero'))
self.balance_label.setObjectName("badge_balance")
header_layout.addWidget(self.balance_label)
layout.addLayout(header_layout)
# 적립 내역
earned_group = QGroupBox("💰 적립 내역")
earned_group = QGroupBox(tr('view.overtime.earned_group'))
earned_layout = QVBoxLayout()
earned_layout.setSpacing(4)
earned_layout.setContentsMargins(8, 20, 8, 6)
self.earned_table = QTableWidget()
self.earned_table.setColumnCount(3)
self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"])
self.earned_table.setHorizontalHeaderLabels([
tr('view.overtime.col_date'),
tr('view.overtime.col_earned'),
tr('view.overtime.col_memo'),
])
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -64,7 +68,7 @@ class OvertimeView(QDialog):
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton("➕ 수동 적립")
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
add_earned_button.clicked.connect(self.add_earned_record)
earned_layout.addWidget(add_earned_button)
@@ -72,14 +76,18 @@ class OvertimeView(QDialog):
layout.addWidget(earned_group)
# 사용 내역
used_group = QGroupBox("📤 사용 내역")
used_group = QGroupBox(tr('view.overtime.used_group'))
used_layout = QVBoxLayout()
used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget()
self.used_table.setColumnCount(3)
self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"])
self.used_table.setHorizontalHeaderLabels([
tr('view.overtime.col_date'),
tr('view.overtime.col_used'),
tr('view.overtime.col_reason'),
])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -90,7 +98,7 @@ class OvertimeView(QDialog):
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
used_layout.addWidget(self.used_table)
add_used_button = QPushButton("➕ 수동 사용")
add_used_button = QPushButton(tr('view.overtime.btn_add_used'))
add_used_button.clicked.connect(self.add_used_record)
used_layout.addWidget(add_used_button)
@@ -98,7 +106,7 @@ class OvertimeView(QDialog):
layout.addWidget(used_group)
# 닫기 버튼
close_button = QPushButton("닫기")
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
@@ -110,23 +118,21 @@ class OvertimeView(QDialog):
balance = self.db.get_total_overtime_balance()
hours = balance // 60
minutes = balance % 60
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
self.balance_label.setText(tr('view.overtime.balance_fmt',
h=hours, m=minutes, total=balance))
# 적립 내역 로드
conn = self.db.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT ob.date, ob.earned_minutes,
CASE
WHEN ob.work_record_id IS NULL THEN '수동 추가'
ELSE COALESCE(wr.memo, '')
END as memo
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC
''')
earned_records = cursor.fetchall()
manual_label = tr('msg.manual_added')
self.earned_table.setRowCount(len(earned_records))
for i, record in enumerate(earned_records):
@@ -136,12 +142,17 @@ class OvertimeView(QDialog):
minutes = record[1]
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
if hours > 0:
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
else:
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색
memo_item = QTableWidgetItem(record[2] or "")
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "")
memo_item = QTableWidgetItem(memo_text)
self.earned_table.setItem(i, 0, date_item)
self.earned_table.setItem(i, 1, time_item)
@@ -166,7 +177,10 @@ class OvertimeView(QDialog):
minutes = record[2]
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
if hours > 0:
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
else:
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
@@ -188,7 +202,7 @@ class OvertimeView(QDialog):
# 컨텍스트 메뉴 생성
menu = QMenu(self)
delete_action = QAction("❌ 삭제", self)
delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_used_record)
menu.addAction(delete_action)
@@ -213,11 +227,10 @@ class OvertimeView(QDialog):
# 확인 메시지
reply = QMessageBox.question(
self,
"삭제 확인",
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"시간: {time_item.text()}\n"
f"사유: {reason_item.text()}",
tr('msg.confirm_delete.title'),
tr('view.overtime.delete_confirm_body',
date=date_item.text(), time=time_item.text(),
reason=reason_item.text()),
QMessageBox.Yes | QMessageBox.No
)
@@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("추가근무 수동 적립")
self.setWindowTitle(tr('view.overtime.manual_earned_title'))
self.setModal(True)
self.setMinimumWidth(360)
@@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("추가근무 수동 적립")
title = QLabel(tr('view.overtime.manual_earned_title'))
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label")
date_label.setFixedWidth(60)
self.date_edit = QDateEdit()
@@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog):
# 시간 (30분 단위)
time_layout = QHBoxLayout()
time_label = QLabel("시간:")
time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"])
self.minute_combo.addItems([tr('view.overtime.minute_0'),
tr('view.overtime.minute_30')])
time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo)
@@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog):
# 메모
memo_layout = QHBoxLayout()
memo_label = QLabel("메모:")
memo_label = QLabel(tr('view.overtime.field_memo'))
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(60)
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("선택사항")
self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo'))
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_edit)
layout.addLayout(memo_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
@@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_add_error'))
return
date = self.date_edit.date().toString("yyyy-MM-dd")
@@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog):
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 적립되었습니다."
tr('msg.save_success.title'),
tr('view.overtime.saved_earned', h=hours, m=minutes)
)
self.accept()
@@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("추가근무 수동 사용")
self.setWindowTitle(tr('view.overtime.manual_used_title'))
self.setModal(True)
self.setMinimumWidth(360)
@@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog):
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("추가근무 수동 사용")
title = QLabel(tr('view.overtime.manual_used_title'))
title.setObjectName("dialog_subtitle")
header_layout.addWidget(title)
header_layout.addStretch()
balance = self.db.get_total_overtime_balance()
hours = balance // 60
minutes = balance % 60
balance_label = QLabel(f"잔액: {hours}시간 {minutes}분")
if hours > 0:
balance_text = tr('view.break.duration_fmt', h=hours, m=minutes)
else:
balance_text = tr('view.break.duration_min_only', m=minutes)
balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}")
balance_label.setObjectName("badge_balance")
header_layout.addWidget(balance_label)
layout.addLayout(header_layout)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label")
date_label.setFixedWidth(60)
self.date_edit = QDateEdit()
@@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog):
# 시간 (30분 단위)
time_layout = QHBoxLayout()
time_label = QLabel("시간:")
time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"])
self.minute_combo.addItems([tr('view.overtime.minute_0'),
tr('view.overtime.minute_30')])
time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo)
@@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog):
# 사유
reason_layout = QHBoxLayout()
reason_label = QLabel("사유:")
reason_label = QLabel(tr('view.overtime.field_reason'))
reason_label.setObjectName("field_label")
reason_label.setFixedWidth(60)
self.reason_edit = QLineEdit()
self.reason_edit.setPlaceholderText("예: 개인 사유")
self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason'))
reason_layout.addWidget(reason_label)
reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
@@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_use_error'))
return
# 잔액 확인
@@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog):
if total_minutes > balance:
QMessageBox.warning(
self,
"잔액 부족",
f"사용 가능한 시간이 부족합니다.\n\n"
f"요청: {hours}시간 {minutes}분\n"
f"잔액: {balance // 60}시간 {balance % 60}분"
tr('view.overtime.balance_short_title'),
tr('view.overtime.balance_short_body',
req_h=hours, req_m=minutes,
bal_h=balance // 60, bal_m=balance % 60)
)
return
date = self.date_edit.date().toString("yyyy-MM-dd")
reason = self.reason_edit.text().strip() or "수동 사용"
reason = self.reason_edit.text().strip() or tr('msg.manual_added')
# DB에 저장
self.db.add_overtime_usage(None, total_minutes, date, reason)
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
tr('msg.save_success.title'),
tr('view.overtime.saved_used', h=hours, m=minutes)
)
self.accept()
+144 -14
View File
@@ -17,8 +17,11 @@ from core.i18n import tr
from core.settings_keys import (
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIFICATION_BEFORE_MINUTES,
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
HEALTH_BREAK_HOURS, HEALTH_BREAK_ENABLED,
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
@@ -258,13 +261,50 @@ class SettingsView(QDialog):
self.work_preset_combo.setCurrentIndex(custom_index)
self.work_preset_combo.blockSignals(False)
def _load_threshold_safely(self, settings: dict, setting_key: str,
attr_name: str, default: int) -> None:
"""settings dict에서 임계값을 읽어 SpinBox에 안전하게 적용.
get_settings() 결과는 이미 타입 변환된 dict이라 추가 DB 호출 없이 사용.
"""
if not hasattr(self, attr_name):
return
spin = getattr(self, attr_name)
try:
val = int(settings.get(setting_key, default))
except (ValueError, TypeError):
val = default
# 이미 설정된 setRange를 존중 — 사용자 옛 값이 범위 밖이면 클램프
spin.setValue(max(spin.minimum(), min(spin.maximum(), val)))
@staticmethod
def _make_threshold_spin(lo: int, hi: int, default: int, suffix: str) -> QSpinBox:
"""고급 임계값용 표준 SpinBox."""
sb = QSpinBox()
sb.setRange(lo, hi)
sb.setValue(default)
sb.setSuffix(suffix)
sb.setFixedWidth(110)
return sb
@staticmethod
def _labeled_row(label_text: str, widget) -> QHBoxLayout:
"""좌측 라벨(고정 폭) + 위젯 + 오른쪽 stretch 한 줄 레이아웃."""
row = QHBoxLayout()
lbl = QLabel(label_text)
lbl.setFixedWidth(140)
row.addWidget(lbl)
row.addWidget(widget)
row.addStretch()
return row
def create_notification_group(self) -> QGroupBox:
"""알림 설정 그룹"""
group = QGroupBox(tr('group.notification'))
layout = QVBoxLayout()
layout.setSpacing(6)
# 알림 체크박스들을 2열로 배치
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
check_row1 = QHBoxLayout()
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
self.clock_out_notification_check.setChecked(True)
@@ -275,13 +315,25 @@ class SettingsView(QDialog):
layout.addLayout(check_row1)
check_row2 = QHBoxLayout()
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
self.dinner_notification_check.setChecked(True)
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
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.setChecked(True)
check_row2.addWidget(self.overtime_notification_check)
check_row2.addWidget(self.health_notification_check)
layout.addLayout(check_row2)
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
self.health_break_notification_check.setChecked(True)
self.health_break_notification_check.setToolTip(
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
)
check_row3.addWidget(self.health_notification_check)
check_row3.addWidget(self.health_break_notification_check)
layout.addLayout(check_row3)
# 퇴근 N분 전 알림 시점 설정
before_row = QHBoxLayout()
@@ -299,6 +351,47 @@ class SettingsView(QDialog):
before_row.addStretch()
layout.addLayout(before_row)
# === 고급 임계값 (접이식 그룹박스) ===
adv_box = QGroupBox("고급 임계값")
adv_box.setCheckable(True)
adv_box.setChecked(False) # 기본 접힘
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
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))
# 저녁 알림 임계 (출근 후 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.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))
# 주 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.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_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))
adv_box.setLayout(adv_layout)
layout.addWidget(adv_box)
# 시간 형식 + 테마 한 줄에
format_row = QHBoxLayout()
time_format_label = QLabel("시간 형식:")
@@ -578,13 +671,22 @@ class SettingsView(QDialog):
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
def add_korean_holidays_auto(self):
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가"""
current_year = datetime.now().year
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
11월 이후 호출 시 자동으로 다음 연도까지 등록 — 연말 경계에서
신정 등이 누락되는 것 방지.
"""
now = datetime.now()
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}년")
reply = QMessageBox.question(
self,
"한국 공휴일 자동 추가",
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
"포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
@@ -595,10 +697,12 @@ class SettingsView(QDialog):
if reply != QMessageBox.Yes:
return
added = self.db.add_korean_holidays_auto(current_year)
added = self.db.add_korean_holidays_auto(current_year, include_next_year=include_next)
if added < 0:
# 패키지 미설치 시 고정 공휴일로 폴백
self.db.add_korean_holidays(current_year)
if include_next:
self.db.add_korean_holidays(current_year + 1)
self.update_holiday_count()
QMessageBox.warning(
self,
@@ -933,14 +1037,27 @@ class SettingsView(QDialog):
# 알림
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
if hasattr(self, 'dinner_notification_check'):
self.dinner_notification_check.setChecked(settings.get(NOTIF_DINNER, True))
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
if hasattr(self, 'health_break_notification_check'):
self.health_break_notification_check.setChecked(
settings.get(HEALTH_BREAK_ENABLED, True))
if hasattr(self, 'notif_before_spin'):
try:
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
except (ValueError, TypeError):
self.notif_before_spin.setValue(30)
# 고급 임계값
self._load_threshold_safely(settings, LUNCH_REMINDER_HOURS, 'lunch_reminder_spin', 4)
self._load_threshold_safely(settings, DINNER_REMINDER_HOURS, 'dinner_reminder_spin', 8)
self._load_threshold_safely(settings, OVERTIME_THRESHOLD_HOURS, 'overtime_threshold_spin', 20)
self._load_threshold_safely(settings, WEEKLY_HOURS_THRESHOLD, 'weekly_hours_spin', 52)
self._load_threshold_safely(settings, HEALTH_CONSECUTIVE_OT_DAYS, 'health_consecutive_spin', 3)
self._load_threshold_safely(settings, HEALTH_BREAK_HOURS, 'health_break_hours_spin', 4)
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
time_format = settings.get(TIME_FORMAT, '24')
if isinstance(time_format, int):
@@ -1067,11 +1184,22 @@ class SettingsView(QDialog):
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
NOTIF_HEALTH: self.health_notification_check.isChecked(),
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
# 고급 임계값
LUNCH_REMINDER_HOURS: self.lunch_reminder_spin.value(),
DINNER_REMINDER_HOURS: self.dinner_reminder_spin.value(),
OVERTIME_THRESHOLD_HOURS: self.overtime_threshold_spin.value(),
WEEKLY_HOURS_THRESHOLD: self.weekly_hours_spin.value(),
HEALTH_CONSECUTIVE_OT_DAYS: self.health_consecutive_spin.value(),
HEALTH_BREAK_HOURS: self.health_break_hours_spin.value(),
TIME_FORMAT: self.time_format_combo.currentData(),
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
}
if hasattr(self, 'dinner_notification_check'):
settings[NOTIF_DINNER] = self.dinner_notification_check.isChecked()
if hasattr(self, 'health_break_notification_check'):
settings[HEALTH_BREAK_ENABLED] = self.health_break_notification_check.isChecked()
if hasattr(self, 'auto_break_check'):
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
if hasattr(self, 'clock_in_unlock_check'):
@@ -1114,16 +1242,18 @@ class SettingsView(QDialog):
if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
self.parent_window.reload_settings()
# 언어 변경 감지 → 재시작 제안
# 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장
if hasattr(self, 'language_combo'):
from core.i18n import get_language
from ui.i18n_runtime import set_language_and_retranslate
new_lang = self.language_combo.currentData()
if new_lang and new_lang != get_language():
set_language_and_retranslate(new_lang)
reply = QMessageBox.question(
self,
"재시작 필요 / Restart required",
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n"
"Restart now to fully apply the language change?",
"재시작 / Restart",
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
@@ -1241,7 +1371,7 @@ class SettingsView(QDialog):
if filename:
try:
saved_path = CSVExporter.export_work_records(records, filename)
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
QMessageBox.information(
self,
"내보내기 완료",
+142 -92
View File
@@ -2,7 +2,8 @@
통계 대시보드 - 주간/월간 통계
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
QFrame)
from PyQt5.QtCore import Qt
from datetime import datetime, timedelta
import sys
@@ -12,6 +13,10 @@ 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 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,
)
class StatsView(QDialog):
@@ -22,72 +27,91 @@ class StatsView(QDialog):
self.db = db if db else Database()
self.init_ui()
self.load_stats()
apply_dark_titlebar(self)
apply_dark_titlebar(self, dark=True)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle(tr('window.stats'))
self.setModal(True)
self.setMinimumSize(420, 350)
self.setMinimumSize(720, 600)
self.resize(900, 720)
self.setStyleSheet(dialog_qss())
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 14)
title = QLabel(tr('stats.title'))
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
# 다크 톤 타이틀
title = QLabel(f"📊 {tr('stats.title')}")
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(title)
tabs = QTabWidget()
tabs.setStyleSheet(tabs_qss())
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
layout.addWidget(tabs)
# 도전과제용 탭 진입 카운터
tabs.currentChanged.connect(self._on_tab_changed)
self._on_tab_changed(0)
layout.addWidget(tabs, 1)
# 닫기 버튼 — 우측 정렬
btn_row = QHBoxLayout()
btn_row.addStretch()
close_button = QPushButton(tr('btn.close'))
close_button.setMinimumWidth(100)
close_button.setStyleSheet(button_qss('ghost'))
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
btn_row.addWidget(close_button)
layout.addLayout(btn_row)
self.setLayout(layout)
def _on_tab_changed(self, idx: int) -> None:
"""탭별 진입 카운터 (도전과제 시스템용). 실패는 silent."""
keys = ['stat_weekly_view_count', 'stat_monthly_view_count',
'stat_pattern_view_count']
if 0 <= idx < len(keys):
try:
cur = self.db.get_setting_int(keys[idx], 0)
self.db.set_setting(keys[idx], str(cur + 1))
except Exception:
pass
def create_weekly_tab(self) -> QWidget:
"""주간 통계 탭 생성"""
widget = QWidget()
widget.setStyleSheet("background: transparent;")
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(10)
layout.setContentsMargins(8, 12, 8, 8)
summary_group = QGroupBox(tr('stats.weekly_summary'))
summary_layout = QGridLayout()
summary_layout.setSpacing(4)
summary_layout.setContentsMargins(8, 20, 8, 6)
# 카드 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='🔥')
for c in (self.weekly_total_card, self.weekly_days_card,
self.weekly_avg_card, self.weekly_ot_card):
cards_row.addWidget(c, 1)
layout.addLayout(cards_row)
self.weekly_total_hours = QLabel("0")
self.weekly_total_hours.setObjectName("stat_value")
self.weekly_work_days = QLabel("0")
self.weekly_work_days.setObjectName("stat_value")
self.weekly_avg_hours = QLabel("0")
self.weekly_avg_hours.setObjectName("stat_value")
self.weekly_overtime = QLabel("0")
self.weekly_overtime.setObjectName("stat_value")
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
summary_layout.addWidget(self.weekly_total_hours, 0, 1)
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
summary_layout.addWidget(self.weekly_work_days, 1, 1)
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
summary_layout.addWidget(self.weekly_avg_hours, 2, 1)
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
summary_layout.addWidget(self.weekly_overtime, 3, 1)
summary_group.setLayout(summary_layout)
layout.addWidget(summary_group)
# 주간 차트 (일별 근무시간)
# 주간 차트 (일별 근무시간) — 카드 안에
from ui.chart_widget import make_chart_widget
self.weekly_chart = make_chart_widget(widget)
layout.addWidget(self.weekly_chart, 1)
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
theme='gray', icon='📈')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
return widget
@@ -95,40 +119,36 @@ class StatsView(QDialog):
def create_monthly_tab(self) -> QWidget:
"""월간 통계 탭 생성"""
widget = QWidget()
widget.setStyleSheet("background: transparent;")
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(10)
layout.setContentsMargins(8, 12, 8, 8)
# 추정 급여 카드 (옵션 활성 시)
# 카드 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='🔥')
for c in (self.monthly_total_card, self.monthly_days_card,
self.monthly_avg_card, self.monthly_ot_card):
cards_row.addWidget(c, 1)
layout.addLayout(cards_row)
# 추정 급여 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
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"padding: 10px 14px; font-size: 11pt;"
)
self.salary_label.setVisible(False)
summary_group = QGroupBox(tr('stats.monthly_summary'))
summary_layout = QGridLayout()
summary_layout.setSpacing(4)
summary_layout.setContentsMargins(8, 20, 8, 6)
self.monthly_total_hours = QLabel("0")
self.monthly_total_hours.setObjectName("stat_value")
self.monthly_work_days = QLabel("0")
self.monthly_work_days.setObjectName("stat_value")
self.monthly_avg_hours = QLabel("0")
self.monthly_avg_hours.setObjectName("stat_value")
self.monthly_overtime = QLabel("0")
self.monthly_overtime.setObjectName("stat_value")
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
summary_layout.addWidget(self.monthly_total_hours, 0, 1)
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
summary_layout.addWidget(self.monthly_work_days, 1, 1)
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
summary_layout.addWidget(self.monthly_avg_hours, 2, 1)
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
summary_layout.addWidget(self.monthly_overtime, 3, 1)
summary_group.setLayout(summary_layout)
layout.addWidget(summary_group)
layout.addWidget(self.salary_label)
# 목표 진행률
@@ -139,7 +159,9 @@ class StatsView(QDialog):
# 월간 차트
from ui.chart_widget import make_chart_widget
self.monthly_chart = make_chart_widget(widget)
layout.addWidget(self.monthly_chart, 1)
chart_card = build_section_card("요일별 평균", self.monthly_chart,
theme='gray', icon='📊')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
return widget
@@ -147,46 +169,71 @@ class StatsView(QDialog):
def create_pattern_tab(self) -> QWidget:
"""패턴 분석 탭 생성"""
widget = QWidget()
widget.setStyleSheet("background: transparent;")
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
pattern_group = QGroupBox(tr('stats.pattern_insights'))
pattern_layout = QVBoxLayout()
pattern_layout.setSpacing(4)
pattern_layout.setContentsMargins(8, 20, 8, 6)
layout.setSpacing(10)
layout.setContentsMargins(8, 12, 8, 8)
# 패턴 텍스트 카드
self.pattern_text = QLabel(tr('stats.analyzing'))
self.pattern_text.setWordWrap(True)
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
pattern_layout.addWidget(self.pattern_text)
pattern_group.setLayout(pattern_layout)
layout.addWidget(pattern_group)
self.pattern_text.setStyleSheet(
f"font-size: 11pt; color: {DARK_TEXT}; "
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
theme='cyan', icon='🔍'))
# 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(self.clock_in_chart, 1)
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
theme='gray', icon='⏰'), 1)
widget.setLayout(layout)
return widget
def _set_card_value(self, card, value: str) -> None:
"""build_stat_card로 만든 카드의 큰 숫자 라벨 업데이트.
카드 구조: QFrame > QHBoxLayout > [icon QLabel] [text QVBoxLayout > title, value, subtitle]
value는 두 번째 QLabel.
"""
# text_box는 outer hbox의 마지막 layout
outer = card.layout()
if outer is None or outer.count() == 0:
return
# text_box 찾기 (마지막 item, layout)
text_item = outer.itemAt(outer.count() - 1)
text_box = text_item.layout() if text_item else None
if text_box is None or text_box.count() < 2:
return
val_lbl = text_box.itemAt(1).widget() # 두 번째가 큰 숫자
if val_lbl is None:
return
# 큰 숫자 RichText 형식 유지
from ui.dark_components import CARD_THEMES
# tier color는 카드 자체에 알 방법이 없으니 기본 골드 톤
val_lbl.setText(
f"<span style='font-size: 18pt; font-weight: bold; color: #ffd24a;'>"
f"{value}</span>"
)
def load_stats(self):
"""통계 로드"""
# 주간 통계
weekly_stats = self.db.get_weekly_stats()
total_hours = weekly_stats.get('total_hours', 0) or 0
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 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)}일")
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간")
self._set_card_value(self.weekly_avg_card, 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.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
# 주간 차트
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
@@ -196,26 +243,29 @@ class StatsView(QDialog):
(today - _td(days=6)).isoformat(), today.isoformat()
)
if hasattr(self, 'weekly_chart'):
# 도전과제 chart_hover 감지를 위해 db 참조 attach
self.weekly_chart._achievement_db = self.db
draw_daily_hours(self.weekly_chart, week_records)
# 월간 통계
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.monthly_total_hours.setText(f"{total_hours:.1f}시간")
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
work_days = monthly_stats.get('work_days', 0) or 0
self.monthly_work_days.setText(f"{work_days}일")
self._set_card_value(self.monthly_days_card, f"{work_days}일")
if work_days > 0:
avg = total_hours / work_days
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
else:
self.monthly_avg_hours.setText("0시간")
self._set_card_value(self.monthly_avg_card, "0시간")
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
overtime_hours = overtime_minutes // 60
overtime_mins = overtime_minutes % 60
self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
self._set_card_value(self.monthly_ot_card,
f"{overtime_hours}시간 {overtime_mins}분")
# 월간 차트 (요일별 평균)
if hasattr(self, 'monthly_chart'):
+6 -2
View File
@@ -55,16 +55,18 @@ class TodaySummaryCard(QFrame):
def show_summary(self, total_hours: float, lunch_minutes: int,
break_minutes: int, overtime_actual: int,
overtime_earned: int, salary_text: str = "") -> None:
overtime_earned: int, salary_text: str = "",
dinner_minutes: int = 0) -> None:
"""카드 내용 채우고 표시.
Args:
total_hours: 총 근무시간(시간)
lunch_minutes: 점심 시간(분)
break_minutes: 외출 시간(분)
break_minutes: 외출 시간(분, 식사 제외)
overtime_actual: 실제 연장근무(분)
overtime_earned: 적립 연장근무(분)
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
dinner_minutes: 저녁 시간(분), 0이면 표시 안 함
"""
h = int(total_hours)
m = int((total_hours - h) * 60)
@@ -73,6 +75,8 @@ class TodaySummaryCard(QFrame):
details = []
if lunch_minutes > 0:
details.append(f"점심 {lunch_minutes}분")
if dinner_minutes > 0:
details.append(f"저녁 {dinner_minutes}분")
if break_minutes > 0:
details.append(f"외출 {break_minutes}분")
if overtime_actual > 0:
+95 -22
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':
@@ -62,42 +86,86 @@ def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
return False
def replace_file(new_path: Path, target_path: Path) -> Path | None:
def replace_file(new_path: Path, target_path: Path,
max_retries: int = 5) -> Path | None:
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).
Returns:
백업 파일 경로 (롤백용). 실패 시 None.
백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
"""
backup = target_path.with_suffix(target_path.suffix + '.bak')
try:
if backup.exists():
last_err: Exception | None = None
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
if backup.exists():
try:
backup.unlink()
if target_path.exists():
except OSError as e:
_log(f"[updater] old backup unlink failed (continuing): {e}")
# 2단계: target → backup 이동 (락 해제 대기 재시도)
for attempt in range(max_retries):
if not target_path.exists():
break # 첫 설치 등 — target 없으면 그냥 다음 단계로
try:
shutil.move(str(target_path), str(backup))
shutil.move(str(new_path), str(target_path))
return backup
except OSError as e:
print(f"[updater] replace failed: {e}", file=sys.stderr)
# 롤백 시도
if backup.exists() and not target_path.exists():
try:
shutil.move(str(backup), str(target_path))
except OSError:
pass
break
except OSError as e:
last_err = e
wait = 0.3 * (2 ** attempt)
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait)
else:
# 모든 재시도 실패
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
return None
# 3단계: new → target 이동
for attempt in range(max_retries):
try:
shutil.move(str(new_path), str(target_path))
return backup
except OSError as e:
last_err = e
wait = 0.3 * (2 ** attempt)
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait)
# new 이동 실패 → backup으로 롤백 시도
_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))
_log("[updater] rolled back from backup")
except OSError as e:
_log(f"[updater] rollback also failed: {e}")
return None
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
@@ -109,29 +177,34 @@ 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 파일 핸들 해제 시간 여유
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
time.sleep(0.5)
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))
@@ -140,7 +213,7 @@ def main() -> int:
pass
return 5
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
_log("[updater] update complete, new app launched")
return 0
+1 -1
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,
+4 -2
View File
@@ -71,5 +71,7 @@ def _rotate(directory: Path, keep: int) -> None:
for old in files[keep:]:
try:
old.unlink()
except OSError:
pass
except OSError as e:
# 회전 실패 시 로그만 — 다음 실행에 재시도. 누적 시 디스크 압박 가능.
from utils.debug_log import dlog
dlog(f"backup rotation failed for {old}: {e}")
+60 -5
View File
@@ -20,24 +20,79 @@ USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
def install_global_handler(db, app_version: str = 'unknown') -> None:
"""sys.excepthook 등록. db는 crash_log 저장용."""
"""sys.excepthook 등록. db는 crash_log 저장용.
각 단계(log → dialog → 파일 fallback → original hook)는 모두 독립 try로 감싸
하나가 실패해도 다음 단계가 동작. 가장 최후 fallback은 stderr + 로컬 파일.
"""
original = sys.excepthook
def hook(exc_type, exc_value, exc_tb):
if exc_type is KeyboardInterrupt:
original(exc_type, exc_value, exc_tb)
return
# 1단계: traceback 직렬화 (실패하면 minimal fallback)
try:
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
_log_crash(db, exc_type.__name__, str(exc_value), tb_str, app_version)
_show_dialog(db, exc_type.__name__, str(exc_value), tb_str, app_version)
except Exception:
pass # 후킹 자체 실패는 silent
original(exc_type, exc_value, exc_tb)
tb_str = f"{exc_type}: {exc_value} (traceback formatting failed)"
type_name = getattr(exc_type, '__name__', str(exc_type))
msg = str(exc_value)
# 2단계: DB 로깅 (DB 잠금/디스크 풀 등으로 실패 가능 — 단계 분리)
log_ok = False
try:
_log_crash(db, type_name, msg, tb_str, app_version)
log_ok = True
except Exception as log_err:
_fallback_to_file(type_name, msg, tb_str, app_version,
f"DB log failed: {log_err}")
# 3단계: 다이얼로그 (PyQt 미초기화/이미 종료 등으로 실패 가능)
try:
_show_dialog(db, type_name, msg, tb_str, app_version)
except Exception as dlg_err:
# 다이얼로그 실패 시 stderr + 파일에 기록 (DB 로깅도 실패했으면 이게 유일한 흔적)
if not log_ok:
_fallback_to_file(type_name, msg, tb_str, app_version,
f"DB+dialog both failed; dialog err: {dlg_err}")
try:
print(f"\n[CRASH] {type_name}: {msg}\n{tb_str}", file=sys.stderr)
except Exception:
pass
# 4단계: 원래 hook도 호출 (콘솔 출력 + 종료)
try:
original(exc_type, exc_value, exc_tb)
except Exception:
pass # 마지막 단계라 더 할 게 없음
sys.excepthook = hook
def _fallback_to_file(exc_type: str, message: str, tb: str,
version: str, reason: str) -> None:
"""DB 로깅이 실패한 경우 ~/.clockout_logs/crashes.log에 append.
Best-effort — 파일 쓰기 실패도 silent (이 시점엔 뭐 할 게 없음).
"""
try:
from pathlib import Path
log_dir = Path.home() / '.clockout_logs'
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / 'crashes.log'
with open(log_file, 'a', encoding='utf-8') as f:
f.write(
f"\n=== {datetime.now().isoformat(timespec='seconds')} ===\n"
f"version={version} reason={reason}\n"
f"{exc_type}: {message}\n{tb}\n"
)
except Exception:
pass
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
"""crash_log 테이블에 기록."""
try:
+50 -8
View File
@@ -11,12 +11,15 @@ class CSVExporter:
"""CSV 내보내기 클래스"""
@staticmethod
def export_work_records(records: List[Dict], filename: str = None) -> str:
def export_work_records(records: List[Dict], filename: str = None,
db=None) -> str:
"""
근무 기록을 CSV로 내보내기
근무 기록을 CSV로 내보내기 (사람이 읽는 한글 헤더 + 사용/미사용 표기).
Args:
records: 근무 기록 리스트
filename: 저장할 파일명 (None이면 자동 생성)
db: 점심/저녁 기본 분(minutes)을 표시용으로 읽을 Database 인스턴스 (옵션)
Returns:
str: 저장된 파일 경로
"""
@@ -24,31 +27,70 @@ class CSVExporter:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_{timestamp}.csv"
# CSV 파일 작성
# 표시용 기본 분 (옵션)
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = [
'날짜', '출근시간', '퇴근시간', '점심시간',
'총근무시간', '연장근무(분)', '적립(분)', '메모'
'날짜', '출근시간', '퇴근시간',
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
'총근무시간', '연장근무(분)', '적립(분)', '메모',
]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
lunch_on = bool(record.get('lunch_break'))
dinner_on = bool(record.get('dinner_break'))
row = {
'날짜': record.get('date', ''),
'출근시간': record.get('clock_in', ''),
'퇴근시간': record.get('clock_out', '미기록'),
'점심시간': '사용' if record.get('lunch_break') else '미사용',
'점심시간': '사용' if lunch_on else '미사용',
'점심(분)': lunch_default if lunch_on else 0,
'저녁시간': '사용' if dinner_on else '미사용',
'저녁(분)': dinner_default if dinner_on else 0,
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
'연장근무(분)': record.get('overtime_minutes', 0),
'적립(분)': record.get('overtime_earned', 0),
'메모': record.get('memo', '')
'메모': record.get('memo', ''),
}
writer.writerow(row)
return filename
@staticmethod
def export_work_records_for_reimport(records: List[Dict], filename: str = None,
db=None) -> str:
"""csv_importer가 직접 다시 읽을 수 있는 round-trip 포맷으로 내보내기.
헤더: date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_reimport_{timestamp}.csv"
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = ['date', 'clock_in', 'clock_out',
'lunch_minutes', 'dinner_minutes', 'memo']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
writer.writerow({
'date': record.get('date', ''),
'clock_in': record.get('clock_in', ''),
'clock_out': record.get('clock_out', '') or '',
'lunch_minutes': lunch_default if record.get('lunch_break') else 0,
'dinner_minutes': dinner_default if record.get('dinner_break') else 0,
'memo': record.get('memo', '') or '',
})
return filename
@staticmethod
def export_overtime_summary(db, filename: str = None) -> str:
"""
+42 -17
View File
@@ -1,14 +1,15 @@
"""
CSV 가져오기 — 우리 표준 포맷.
표준 포맷:
date,clock_in,clock_out,lunch_minutes,memo
2026-04-01,09:00:00,18:30:00,60,"메모"
표준 포맷 (v2.7.1+):
date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
2026-04-01,09:00:00,18:30:00,60,0,"메모"
- 헤더 첫 줄 필수
- date: YYYY-MM-DD
- clock_in/out: HH:MM:SS 또는 HH:MM
- clock_in/out: HH:MM:SS 또는 HH:MM (clock_out이 clock_in보다 빠르면 익일로 간주)
- lunch_minutes: 정수 (0이면 점심 미포함)
- dinner_minutes: 정수 (옵션, 0/누락이면 저녁 미포함)
- memo: 선택 (따옴표 가능)
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
@@ -45,6 +46,20 @@ def parse_csv(path: str) -> List[Dict]:
return rows
def _parse_minutes(raw: str, field_name: str) -> int:
"""0 이상 정수 파싱. 빈 값이면 0."""
s = (raw or '').strip()
if not s:
return 0
try:
v = int(s)
if v < 0:
raise ValueError
except ValueError:
raise ValueError(f"{field_name}는 0 이상 정수여야 함: '{raw}'")
return v
def _normalize_row(row: Dict) -> Dict:
"""단일 행 검증 + 정규화."""
date_str = (row.get('date') or '').strip()
@@ -59,15 +74,8 @@ def _normalize_row(row: Dict) -> Dict:
co_raw = (row.get('clock_out') or '').strip()
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
lunch = 0
lm = (row.get('lunch_minutes') or '').strip()
if lm:
try:
lunch = int(lm)
if lunch < 0:
raise ValueError
except ValueError:
raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'")
lunch = _parse_minutes(row.get('lunch_minutes', ''), 'lunch_minutes')
dinner = _parse_minutes(row.get('dinner_minutes', ''), 'dinner_minutes')
memo = (row.get('memo') or '').strip()
return {
@@ -75,6 +83,7 @@ def _normalize_row(row: Dict) -> Dict:
'clock_in': ci,
'clock_out': co,
'lunch_minutes': lunch,
'dinner_minutes': dinner,
'memo': memo,
}
@@ -111,9 +120,11 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
added = updated = skipped = 0
from datetime import timedelta
from core.time_calculator import TimeCalculator
work_minutes = db.get_work_minutes()
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
dinner_default = db.get_setting_int('dinner_duration_minutes', 60)
for row in rows:
existing = db.get_work_record(row['date'])
@@ -131,6 +142,9 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
updated += 1
@@ -141,16 +155,27 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
if row.get('clock_out'):
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
calc = TimeCalculator(work_minutes=work_minutes,
lunch_duration_minutes=row.get('lunch_minutes') or lunch_default)
include_lunch = (row.get('lunch_minutes') or 0) > 0
# 자정 경계: 퇴근이 출근보다 빠르면 익일로 간주
if co_dt <= ci_dt:
co_dt += timedelta(days=1)
lunch_min = row.get('lunch_minutes') or 0
dinner_min = row.get('dinner_minutes') or 0
calc = TimeCalculator(
work_minutes=work_minutes,
lunch_duration_minutes=lunch_min or lunch_default,
dinner_duration_minutes=dinner_min or dinner_default,
)
include_lunch = lunch_min > 0
include_dinner = dinner_min > 0
total = (co_dt - ci_dt).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci_dt, co_dt, include_lunch=include_lunch
ci_dt, co_dt, include_lunch=include_lunch, include_dinner=include_dinner,
)
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
if include_lunch:
db.update_lunch_break(row['date'], True)
if include_dinner:
db.update_dinner_break(row['date'], True)
if ot_earned > 0:
db.add_overtime_earned(wid, ot_earned, row['date'])
+19 -1
View File
@@ -5,6 +5,7 @@ URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안
"""
from __future__ import annotations
import json
import re
import urllib.request
import urllib.error
from datetime import datetime
@@ -21,6 +22,23 @@ COLOR_YELLOW = 0xFEE75C
COLOR_PINK = 0xEB459E
COLOR_ORANGE = 0xED4245
# Discord 웹훅 URL 패턴: https://(discord.com|discordapp.com|...)/api/webhooks/{ID:digits}/{TOKEN}
# canary./ptb. 서브도메인도 허용. PTB 모바일 앱이 종종 그 URL 발급.
_WEBHOOK_RE = re.compile(
r'^https://(?:(?:canary|ptb)\.)?discord(?:app)?\.com/api/webhooks/\d{17,20}/[\w-]{50,}$'
)
def is_valid_webhook_url(url: str) -> bool:
"""입력된 URL이 Discord webhook 형식인지 검증.
형식만 확인 — 실제 도달성·토큰 유효성은 send_test()로 검증해야 함.
빈 문자열이나 공백은 False (비활성 상태로 간주).
"""
if not url or not isinstance(url, str):
return False
return bool(_WEBHOOK_RE.match(url.strip()))
def send(webhook_url: str, title: str, description: str,
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
@@ -37,7 +55,7 @@ def send(webhook_url: str, title: str, description: str,
Returns:
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
"""
if not webhook_url or not webhook_url.startswith('https://'):
if not is_valid_webhook_url(webhook_url):
return False
payload = {
+9 -2
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),