From c5df37ca57905003795ba64724cae5b4e5b13bf1 Mon Sep 17 00:00:00 2001 From: "68893236+KINDNICK@users.noreply.github.com" <68893236+KINDNICK@users.noreply.github.com> Date: Fri, 1 May 2026 01:11:13 +0900 Subject: [PATCH] =?UTF-8?q?v2.8.0:=20=EB=8F=84=EC=A0=84=EA=B3=BC=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20+=20=EB=8B=A4=ED=81=AC=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=A6=AC=EB=89=B4=EC=96=BC=20+?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 49 + CLAUDE.md | 11 +- core/achievements.py | 1216 ++++++++++++ core/database.py | 1865 +++++++++---------- core/i18n.py | 36 + core/notifier.py | 95 +- core/settings_keys.py | 27 + core/version.py | 2 +- main.py | 4 +- tests/test_discord_webhook.py | 10 +- ui/achievements_view.py | 484 +++++ ui/chart_widget.py | 88 +- ui/controllers/lock_monitor.py | 7 +- ui/controllers/notification_orchestrator.py | 90 +- ui/dark_components.py | 383 ++++ ui/help_view.py | 128 +- ui/main_window.py | 196 +- ui/meal_time_dialog.py | 112 +- ui/onboarding_view.py | 86 +- ui/settings_view.py | 148 +- ui/stats_view.py | 234 ++- ui/today_summary.py | 8 +- updater.py | 73 +- utils/backup.py | 6 +- utils/crash_handler.py | 65 +- utils/csv_exporter.py | 58 +- utils/csv_importer.py | 59 +- utils/discord_webhook.py | 20 +- 28 files changed, 4247 insertions(+), 1313 deletions(-) create mode 100644 core/achievements.py create mode 100644 ui/achievements_view.py create mode 100644 ui/dark_components.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eb95f5a..616fa2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ 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.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 + 테스트 + 구조 개선) diff --git a/CLAUDE.md b/CLAUDE.md index 8129966..ad4499d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,11 @@ $env:GITEA_TOKEN = '' - **[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. @@ -74,6 +79,8 @@ $env:GITEA_TOKEN = '' - **[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. @@ -137,7 +144,9 @@ Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via - [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt). - [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`. - [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets. -- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+). +- [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`). + +Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`. All should be green before any release. diff --git a/core/achievements.py b/core/achievements.py new file mode 100644 index 0000000..1074af5 --- /dev/null +++ b/core/achievements.py @@ -0,0 +1,1216 @@ +""" +도전과제 시스템 — 357개 자동 평가. + +설계: +- 각 도전과제는 dataclass `Achievement` + 평가 함수 (db) -> (progress, target). +- 평가자는 5분 throttle로 호출되어 미획득 도전과제만 검사. +- 신규 잠금 해제 시 main_window의 `notify_achievement_unlocked` 시그널 emit. +- 진행도(progress)는 부분 달성 표시용 — UI 게이지에 활용. + +데이터 소스: +- work_records: 출근/퇴근 기록 +- overtime_bank, overtime_usage: 적립/사용 +- leave_records: 연차 +- break_records: 외출/식사 +- holidays: 공휴일 +- settings: 사용자 메타 + 뷰 카운터 +- notification_log: 휴식 권고 카운트 +""" +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import date, datetime, timedelta +from typing import Callable, Optional, List, Tuple + +# === 등급 상수 === +TIER_BRONZE = 'bronze' +TIER_SILVER = 'silver' +TIER_GOLD = 'gold' +TIER_PLATINUM = 'platinum' +TIER_LEGEND = 'legend' + +# === 카테고리 === +CAT_STREAK = 'streak' +CAT_PUNCTUAL = 'punctual' +CAT_BALANCE = 'balance' +CAT_OT_BANK = 'ot_bank' +CAT_OT_USE = 'ot_use' +CAT_LEAVE = 'leave' +CAT_HEALTH = 'health' +CAT_SPECIAL_DAY = 'special_day' +CAT_PATTERN = 'pattern' +CAT_MILESTONE = 'milestone' +CAT_SEASON = 'season' +CAT_TIME_SLOT = 'time_slot' +CAT_MEAL = 'meal' +CAT_BREAK = 'break_use' +CAT_SETTINGS = 'settings' +CAT_STATS = 'stats' +CAT_SECRET = 'secret' +CAT_KOREA = 'korea' +CAT_AMBITION = 'ambition' +CAT_META = 'meta' + + +@dataclass +class Achievement: + """도전과제 정의. + + 평가 함수 `evaluator`는 `(db) -> (progress, target)` 시그니처. + progress >= target이면 잠금 해제. progress가 0이면 미시작. + """ + code: str + name: str + description: str + category: str + tier: str + badge_icon: str + is_secret: bool = False + target: int = 1 + evaluator: Optional[Callable] = field(default=None, repr=False) + + +# ============================================================ +# 평가 헬퍼 +# ============================================================ + +def _count_work_records(db) -> int: + """전체 work_records 수 (clock_in이 있는 모든 행).""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM work_records") + return cur.fetchone()[0] + + +def _count_clocked_out(db) -> int: + """퇴근까지 마친 work_records 수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM work_records WHERE clock_out IS NOT NULL") + return cur.fetchone()[0] + + +def _consecutive_workdays(db) -> int: + """오늘 또는 마지막 출근일 기준 연속 영업일 출근 수. + + 영업일 = 토/일이 아니고 holidays 테이블에 없는 날. + """ + today = date.today() + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT date FROM work_records ORDER BY date DESC LIMIT 1") + row = cur.fetchone() + if not row: + return 0 + last = datetime.strptime(row[0], '%Y-%m-%d').date() + if (today - last).days > 3: + return 0 # 끊김 + # holidays 셋 + cur.execute("SELECT date FROM holidays") + holidays = {r[0] for r in cur.fetchall()} + cur.execute("SELECT date FROM work_records") + worked = {r[0] for r in cur.fetchall()} + + streak = 0 + d = last + while True: + is_workday = d.weekday() < 5 and d.isoformat() not in holidays + if is_workday: + if d.isoformat() in worked: + streak += 1 + else: + break + d -= timedelta(days=1) + if streak > 1000: # safety + break + return streak + + +def _consecutive_calendar_days(db) -> int: + """달력일 기준 연속 출근 수 (주말 포함).""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT date FROM work_records ORDER BY date DESC") + dates = [datetime.strptime(r[0], '%Y-%m-%d').date() for r in cur.fetchall()] + if not dates: + return 0 + streak = 1 + for i in range(1, len(dates)): + if (dates[i-1] - dates[i]).days == 1: + streak += 1 + else: + break + return streak + + +def _ot_balance_minutes(db) -> int: + """현재 연장근무 잔액 (분).""" + return db.get_total_overtime_balance() + + +def _ot_total_earned(db) -> int: + """누적 적립 분.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COALESCE(SUM(earned_minutes), 0) FROM overtime_bank") + return cur.fetchone()[0] + + +def _ot_total_used(db) -> int: + """누적 사용 분.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COALESCE(SUM(used_minutes), 0) FROM overtime_usage") + return cur.fetchone()[0] + + +def _count_punctual_clockouts(db) -> int: + """정시 퇴근 (overtime_minutes <= 0) 횟수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE clock_out IS NOT NULL AND COALESCE(overtime_minutes, 0) <= 0 + """) + return cur.fetchone()[0] + + +def _count_clock_in_before(db, hour: int, minute: int = 0) -> int: + """특정 시각 이전 출근 횟수.""" + threshold = f"{hour:02d}:{minute:02d}:00" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records WHERE clock_in < ? + """, (threshold,)) + return cur.fetchone()[0] + + +def _count_clock_in_in_range(db, start_hh: int, end_hh: int) -> int: + """[start_hh:00, end_hh:00) 시간대 출근 횟수.""" + s = f"{start_hh:02d}:00:00" + e = f"{end_hh:02d}:00:00" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records WHERE clock_in >= ? AND clock_in < ? + """, (s, e)) + return cur.fetchone()[0] + + +def _count_clock_out_after(db, hour: int) -> int: + """특정 시각(시) 이후 퇴근 횟수. 자정 이후는 hour=24로 별도 처리.""" + threshold = f"{hour:02d}:00:00" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE clock_out IS NOT NULL AND clock_out >= ? + """, (threshold,)) + return cur.fetchone()[0] + + +def _count_clock_out_after_midnight(db) -> int: + """자정 이후 ~ 오전 퇴근 횟수 (clock_out < clock_in인 경우, 익일). + 같은 날짜 내에서 clock_out HH:MM:SS가 clock_in보다 작으면 자정 넘긴 것.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE clock_out IS NOT NULL AND clock_out < clock_in + """) + return cur.fetchone()[0] + + +def _count_weekend_clockins(db) -> int: + """토/일 출근 횟수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE CAST(strftime('%w', date) AS INTEGER) IN (0, 6) + """) + return cur.fetchone()[0] + + +def _count_holiday_clockins(db) -> int: + """공휴일 출근 (holidays 테이블에 있는 날).""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records w + WHERE EXISTS (SELECT 1 FROM holidays h WHERE h.date = w.date) + """) + return cur.fetchone()[0] + + +def _has_clockin_on(db, mm_dd: str) -> bool: + """특정 MM-DD에 출근 기록 있는지.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records WHERE strftime('%m-%d', date) = ? LIMIT 1 + """, (mm_dd,)) + return cur.fetchone() is not None + + +def _has_punctual_clockout_on(db, mm_dd: str) -> bool: + """특정 MM-DD에 정시 퇴근.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records + WHERE strftime('%m-%d', date) = ? + AND clock_out IS NOT NULL AND COALESCE(overtime_minutes, 0) <= 0 + LIMIT 1 + """, (mm_dd,)) + return cur.fetchone() is not None + + +def _count_lunch_registrations(db) -> int: + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM work_records WHERE lunch_break = 1") + return cur.fetchone()[0] + + +def _count_dinner_registrations(db) -> int: + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM work_records WHERE dinner_break = 1") + return cur.fetchone()[0] + + +def _count_break_records_type(db, break_type: str = 'break') -> int: + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM break_records WHERE break_type = ?", (break_type,)) + return cur.fetchone()[0] + + +def _count_leave_records(db, leave_type: str = None) -> int: + with db._conn() as conn: + cur = conn.cursor() + if leave_type: + cur.execute("SELECT COUNT(*) FROM leave_records WHERE leave_type = ?", (leave_type,)) + else: + cur.execute("SELECT COUNT(*) FROM leave_records") + return cur.fetchone()[0] + + +def _has_leave_with_days(db, days_value: float) -> bool: + """특정 days 값(0.5, 0.25 등)의 연차 사용 여부.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT 1 FROM leave_records WHERE days = ? LIMIT 1", (days_value,)) + return cur.fetchone() is not None + + +def _consecutive_leave_days(db) -> int: + """가장 긴 연속 연차 사용 일수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT date FROM leave_records ORDER BY date") + dates = [datetime.strptime(r[0], '%Y-%m-%d').date() for r in cur.fetchall()] + if not dates: + return 0 + max_streak = 1 + cur_streak = 1 + for i in range(1, len(dates)): + if (dates[i] - dates[i-1]).days == 1: + cur_streak += 1 + max_streak = max(max_streak, cur_streak) + else: + cur_streak = 1 + return max_streak + + +def _setting_int(db, key: str, default: int = 0) -> int: + return db.get_setting_int(key, default) + + +def _setting_str(db, key: str, default: str = '') -> str: + return db.get_setting(key, default) or default + + +def _days_since_first_work(db) -> int: + """첫 work_records로부터 오늘까지 경과 일수.""" + hire = _setting_str(db, 'hire_date', '') + if not hire: + return 0 + try: + d = datetime.strptime(hire, '%Y-%m-%d').date() + return (date.today() - d).days + except ValueError: + return 0 + + +def _has_clockin_on_date(db, target_date: date) -> bool: + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT 1 FROM work_records WHERE date = ? LIMIT 1", + (target_date.isoformat(),)) + return cur.fetchone() is not None + + +def _count_overtime_days(db, min_minutes: int = 1) -> int: + """야근(overtime_minutes >= min_minutes)한 일수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records WHERE overtime_minutes >= ? + """, (min_minutes,)) + return cur.fetchone()[0] + + +def _count_clockouts_at_minute(db, minute: int) -> int: + """퇴근 시각의 분 자릿수가 특정 값인 횟수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE clock_out IS NOT NULL + AND CAST(strftime('%M', clock_out) AS INTEGER) = ? + """, (minute,)) + return cur.fetchone()[0] + + +def _count_in_year_month(db, year: int, month: int) -> int: + """특정 연-월 출근 일수.""" + prefix = f"{year:04d}-{month:02d}" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE date LIKE ? || '%' + """, (prefix,)) + return cur.fetchone()[0] + + +def _workdays_in_year_month(year: int, month: int) -> int: + """해당 월의 영업일 수 (토/일 제외, 공휴일은 무시).""" + from calendar import monthrange + last = monthrange(year, month)[1] + count = 0 + for d in range(1, last + 1): + if date(year, month, d).weekday() < 5: + count += 1 + return count + + +# ============================================================ +# 도전과제 정의 (357개) +# ============================================================ + +def _make_streak_eval(target_days: int, business_only: bool = True): + def _eval(db): + cur = (_consecutive_workdays(db) if business_only + else _consecutive_calendar_days(db)) + return min(cur, target_days), target_days + return _eval + + +def _make_count_eval(getter, target: int): + def _eval(db): + cur = getter(db) + return min(cur, target), target + return _eval + + +def _bool_eval(condition_fn): + """True/False 조건 → (1, 1) 또는 (0, 1).""" + def _eval(db): + return (1, 1) if condition_fn(db) else (0, 1) + return _eval + + +# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ---- +_STREAK_DEFS = [ + # (code, name, desc, target, evaluator, tier, icon) + ('streak_first', '첫걸음', '첫 출근 기록', 1, + _bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'), + ('streak_3', '뿌리내림', '3일 연속 영업일 출근', 3, + _make_streak_eval(3), TIER_BRONZE, '🌱'), + ('streak_5', '첫 주 완주', '5 영업일 연속 출근', 5, + _make_streak_eval(5), TIER_SILVER, '📅'), + ('streak_7_cal', '7일 연속', '주말 포함 7일 연속 출근', 7, + _make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'), + ('streak_10', '2주 연속', '10 영업일 연속 출근', 10, + _make_streak_eval(10), TIER_SILVER, '💪'), + ('streak_22', '한 달 개근', '한 달 영업일 100% 출근 (22일)', 22, + _make_streak_eval(22), TIER_GOLD, '🏔️'), + ('streak_50', '50일 연속', '50 영업일 연속 출근', 50, + _make_streak_eval(50), TIER_GOLD, '🎯'), + ('streak_100', '100일 연속', '100 영업일 연속 출근', 100, + _make_streak_eval(100), TIER_PLATINUM, '💎'), + ('streak_quarter', '분기 완주', '약 65 영업일 (3개월)', 65, + _make_streak_eval(65), TIER_PLATINUM, '🏆'), + ('streak_half_year', '반년 마라톤', '약 130 영업일 (6개월)', 130, + _make_streak_eval(130), TIER_PLATINUM, '👑'), + ('streak_year', '1년 풀 시즌', '약 260 영업일 (1년)', 260, + _make_streak_eval(260), TIER_LEGEND, '🌟'), + ('streak_200', '사이언스', '200 영업일 연속', 200, + _make_streak_eval(200), TIER_LEGEND, '🌌'), + ('streak_365_cal', '불사신', '365일 달력 연속', 365, + _make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'), + ('streak_resilience', '회복력', '결근 후 다음날 즉시 출근 (자동: 달력 streak 깨진 후 재시작)', 1, + _bool_eval(lambda db: _consecutive_workdays(db) >= 1 + and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'), + ('streak_total_100', '누적 100회', '누적 출근 100회', 100, + _make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'), + ('streak_total_500', '누적 500회', '누적 출근 500회', 500, + _make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'), + ('streak_total_1000', '누적 1000회', '누적 출근 1000회', 1000, + _make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'), +] + + +def _count_weekday_clockins(db, weekday: int) -> int: + """특정 요일(0=월 ... 6=일) 출근 횟수.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE CAST(strftime('%w', date) AS INTEGER) = ? + """, (weekday,)) + return cur.fetchone()[0] + + +_STREAK_DEFS.extend([ + ('streak_monday_10', '월요일 정복', '월요일 10주 연속 출근', 10, + _make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'), + ('streak_friday_10', '금요일 무결', '금요일 10주 연속 출근', 10, + _make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'), +]) + + +# ---- 2. 시간 엄수 (19개 - 34/46 제거) ---- +_PUNCTUAL_DEFS = [ + ('punc_before_8_1', '얼리버드', '08:00 이전 출근 1회', 1, + _make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'), + ('punc_before_8_10', '참새족', '08:00 이전 10회', 10, + _make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'), + ('punc_before_8_30', '일찍 자고 일찍', '08:00 이전 30회', 30, + _make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'), + ('punc_before_6_1', '새벽잠 없음', '06:00 이전 1회', 1, + _make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'), + ('punc_before_6_10', '어둠을 가르는 자', '06:00 이전 10회', 10, + _make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'), + ('punc_before_5', '새벽 챔피언', '05:00 이전 출근', 1, + _make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'), + ('punc_at_9', '9시 정각', '09:00 정각(±1분) 출근 1회', 1, + _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1), + TIER_BRONZE, '🎯'), + ('punc_at_9_5', '완벽한 9시', '09:00 정각(±1분) 5회', 5, + _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5), + TIER_SILVER, '🏹'), + ('punc_late_5min', '5분 늦음', '09:00~09:05 출근 1회 (자조)', 1, + _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1), + TIER_BRONZE, '🛌'), + ('punc_at_909', '운명의 시각', '09:09 출근 (시크릿)', 1, + _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1), + TIER_GOLD, '🎰'), +] + + +def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> int: + s = f"{sh:02d}:{sm:02d}:00" + e = f"{eh:02d}:{em:02d}:00" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records WHERE clock_in >= ? AND clock_in < ? + """, (s, e)) + return cur.fetchone()[0] + + +# ---- 3. 워라밸·정시 퇴근 (8개 코어) ---- +_BALANCE_DEFS = [ + ('bal_first_punct', '첫 칼퇴', '정시 퇴근 첫 달성', 1, + _make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'), + ('bal_punct_10', '칼퇴러', '정시 퇴근 10회', 10, + _make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'), + ('bal_punct_30', '칼퇴 챔프', '정시 퇴근 30회', 30, + _make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'), + ('bal_punct_100', '진정한 자유', '정시 퇴근 100회', 100, + _make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'), + ('bal_punct_300', '워라밸 마스터', '정시 퇴근 300회', 300, + _make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'), +] + + +# ---- 4. 연장근무 적립 ---- +_OT_BANK_DEFS = [ + ('ot_first_30m', '첫 30분', '첫 연장 적립', 30, + _make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'), + ('ot_total_60m', '1시간 적금', '누적 1시간 적립', 60, + _make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'), + ('ot_total_5h', '5시간 적립', '누적 5시간', 300, + _make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'), + ('ot_total_10h', '10시간 적립', '누적 10시간', 600, + _make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'), + ('ot_total_25h', '25시간 적립', '누적 25시간', 1500, + _make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'), + ('ot_total_50h', '50시간 적립', '누적 50시간', 3000, + _make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'), + ('ot_total_100h', '마라토너', '누적 100시간 (걱정 메시지)', 6000, + _make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'), + ('ot_total_200h', '워크홀릭 경고', '누적 200시간 (경고)', 12000, + _make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'), + ('ot_total_300h', '위험 신호', '누적 300시간 (강한 경고)', 18000, + _make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'), + ('ot_total_500h', '응급실 단골', '누적 500시간 (자조)', 30000, + _make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'), +] + + +# ---- 5. 연장근무 사용 ---- +_OT_USE_DEFS = [ + ('use_first', '첫 휴식', '적립 첫 사용', 1, + _bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'), + ('use_total_5h', '선물 사용', '누적 5시간 사용', 300, + _make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'), + ('use_total_25h', '휴식의 가치', '누적 25시간 사용', 1500, + _make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'), + ('use_total_50h', '회복 마스터', '누적 50시간 사용', 3000, + _make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'), + ('use_total_100h', '마사지', '누적 100시간 사용', 6000, + _make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'), +] + + +# ---- 6. 연차 ---- +_LEAVE_DEFS = [ + ('leave_first', '첫 연차', '첫 연차 사용', 1, + _make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'), + ('leave_half', '첫 반차', '0.5일 연차 사용', 1, + _bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'), + ('leave_quarter', '시간 연차', '0.25일 연차 사용', 1, + _bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'), + ('leave_streak_3', '미니 휴가', '연속 3일 연차', 3, + _make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'), + ('leave_streak_5', '본격 휴가', '연속 5일 연차', 5, + _make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'), + ('leave_streak_7', '장거리 휴가', '연속 7일 이상 연차', 7, + _make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'), + ('leave_total_10', '연차 10회', '연차 기록 10건', 10, + _make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'), + ('leave_sick', '병가', 'sick 타입 연차 사용', 1, + _make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1), + TIER_BRONZE, '🏥'), +] + + +# ---- 7. 식사 (점심/저녁) ---- +_MEAL_DEFS = [ + ('meal_lunch_first', '첫 점심 등록', '점심 첫 토글', 1, + _make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'), + ('meal_lunch_30', '점심 마스터', '점심 등록 30회', 30, + _make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'), + ('meal_lunch_100', '점심 챔프', '점심 등록 100회', 100, + _make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'), + ('meal_dinner_first', '첫 저녁 등록', '저녁 첫 토글', 1, + _make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'), + ('meal_dinner_10', '저녁 단골', '저녁 등록 10회 (경고)', 10, + _make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'), + ('meal_dinner_30', '야식 단골', '저녁 등록 30회 (경고)', 30, + _make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'), + ('meal_lunch_actual', '실측 점심', '실제 점심 시각 입력', 1, + _make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1), + TIER_BRONZE, '⏱️'), + ('meal_dinner_actual', '실측 저녁', '실제 저녁 시각 입력', 1, + _make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1), + TIER_BRONZE, '⏰'), +] + + +# ---- 8. 외출 ---- +_BREAK_DEFS = [ + ('break_first', '첫 외출', '첫 외출 시작', 1, + _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1), + TIER_BRONZE, '🚶'), + ('break_10', '외출 챔프', '외출 10회', 10, + _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10), + TIER_SILVER, '🚪'), + ('break_50', '산책러', '외출 50회', 50, + _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50), + TIER_GOLD, '🚶‍♂️'), +] + + +# ---- 9. 시간대별 ---- +_TIME_SLOT_DEFS = [ + ('slot_in_06', '06시대 출근', '06:00-06:59 출근 1회', 1, + _make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1), + TIER_BRONZE, '🌅'), + ('slot_in_07', '07시대 출근', '07:00-07:59 출근 1회', 1, + _make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1), + TIER_BRONZE, '🌄'), + ('slot_in_08', '08시대 출근', '08:00-08:59 출근 1회', 1, + _make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1), + TIER_BRONZE, '☀️'), + ('slot_in_10', '10시대 출근', '10시대 출근 (지각/유연근무)', 1, + _make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1), + TIER_BRONZE, '🕙'), + ('slot_in_11', '11시대 출근', '11시대 출근 (자조)', 1, + _make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1), + TIER_SILVER, '🕦'), + ('slot_out_19', '19시대 퇴근', '19시대 퇴근 10회 (경고)', 10, + _make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10), + TIER_SILVER, '🌆'), + ('slot_out_20', '20시대 퇴근', '20시대 퇴근 10회 (경고)', 10, + _make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10), + TIER_GOLD, '🌌'), + ('slot_out_21', '21시대 퇴근', '21시대 퇴근 5회 (경고)', 5, + _make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5), + TIER_GOLD, '🌑'), + ('slot_out_22', '22시대 퇴근', '22시대 퇴근 1회 (경고)', 1, + _make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1), + TIER_PLATINUM, '🦉'), + ('slot_out_23', '23시대 퇴근', '23시대 퇴근 1회 (경고)', 1, + _make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1), + TIER_PLATINUM, '🦇'), + ('slot_midnight', '자정 퇴근', '자정 이후 퇴근 (경고)', 1, + _make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'), + ('slot_midnight_3', '올빼미 트리오', '자정 이후 퇴근 3회 (경고)', 3, + _make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'), +] + + +def _count_clockouts_in_hour(db, hour: int) -> int: + """clock_out이 hour시대(HH:00-HH:59)인 횟수. 자정 넘김 케이스 무시.""" + s = f"{hour:02d}:00:00" + e = f"{hour+1:02d}:00:00" if hour < 23 else "23:59:59" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM work_records + WHERE clock_out IS NOT NULL AND clock_out >= ? AND clock_out < ? + AND clock_out >= clock_in + """, (s, e)) + return cur.fetchone()[0] + + +# ---- 10. 공휴일·주말 ---- +_SPECIAL_DAY_DEFS = [ + ('weekend_1', '주말 출근 1회', '토/일 출근 1회', 1, + _make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'), + ('weekend_5', '주말 워커', '주말 출근 5회 (경고)', 5, + _make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'), + ('weekend_20', '진짜 워크홀릭', '주말 출근 20회 (강한 자조)', 20, + _make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'), + ('holiday_1', '공휴일 출근', '한국 공휴일 출근 1회', 1, + _make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'), + ('holiday_5', '공휴일 워커홀릭', '한국 공휴일 출근 5회 (경고)', 5, + _make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'), + ('day_christmas', '크리스마스 출근', '12/25 출근 (자조)', 1, + _bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'), + ('day_newyear', '신정 출근', '1/1 출근 (자조)', 1, + _bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'), + ('day_liberation', '광복절 출근', '8/15 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'), + ('day_children', '어린이날 출근', '5/5 출근 (자조)', 1, + _bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'), + ('day_hangul', '한글날 출근', '10/9 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'), + ('day_valentine', '발렌타인데이 출근', '2/14 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'), + ('day_white', '화이트데이 출근', '3/14 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'), + ('day_pepero', '빼빼로데이', '11/11 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'), + ('day_halloween', '핼러윈 출근', '10/31 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'), + ('day_aprilfools', '만우절 출근', '4/1 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'), + ('day_77', '칠월칠석', '7/7 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'), + ('day_dongji', '동지 출근', '12/22 출근', 1, + _bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'), + ('day_parents', '어버이날 정시 퇴근', '5/8 정시 퇴근', 1, + _bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')), + TIER_SILVER, '🪅'), + ('day_teacher', '스승의 날 정시 퇴근', '5/15 정시 퇴근', 1, + _bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')), + TIER_BRONZE, '🎂'), + ('day_xmas_eve', '크리스마스이브 정시 퇴근', '12/24 정시 퇴근', 1, + _bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')), + TIER_SILVER, '🎁'), + ('day_earth', '지구의 날', '4/22 출근 (시크릿)', 1, + _bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'), +] + + +# ---- 11. 시즌·월별 ---- +def _make_month_full_attendance_eval(month: int): + """해당 월 영업일 모두 출근.""" + def _eval(db): + year = date.today().year + target = _workdays_in_year_month(year, month) + cur = _count_in_year_month(db, year, month) + # 영업일 수 정확히 카운트는 holidays 제외 안 함 — 단순화 + return min(cur, target), max(target, 1) + return _eval + + +def _make_month_first_eval(month: int): + def _eval(db): + year = date.today().year + return ((1, 1) if _count_in_year_month(db, year, month) >= 1 else (0, 1)) + return _eval + + +_SEASON_DEFS = [ + ('season_jan', '1월 정착', '1월 한 달 출근', 1, + _make_month_first_eval(1), TIER_BRONZE, '⛄'), + ('season_feb', '2월 정착', '2월 영업일 모두 출근', 1, + _make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'), + ('season_mar', '봄을 맞이', '3월 첫 출근', 1, + _make_month_first_eval(3), TIER_BRONZE, '🌸'), + ('season_apr', '4월 정착', '4월 한 달 출근', 1, + _make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'), + ('season_may', '5월 정착', '5월 영업일 모두 출근', 1, + _make_month_full_attendance_eval(5), TIER_SILVER, '🌺'), + ('season_jun', '여름의 시작', '6월 첫 출근', 1, + _make_month_first_eval(6), TIER_BRONZE, '☀️'), + ('season_jul', '7월 정착', '7월 한 달 출근', 1, + _make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'), + ('season_aug', '8월 정착', '8월 영업일 모두 출근', 1, + _make_month_full_attendance_eval(8), TIER_SILVER, '🍦'), + ('season_sep', '가을의 시작', '9월 첫 출근', 1, + _make_month_first_eval(9), TIER_BRONZE, '🍂'), + ('season_oct', '10월 정착', '10월 한 달 출근', 1, + _make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'), + ('season_nov', '11월 단풍', '11월 영업일 모두 출근', 1, + _make_month_full_attendance_eval(11), TIER_SILVER, '🍁'), + ('season_dec', '겨울의 시작', '12월 첫 출근', 1, + _make_month_first_eval(12), TIER_BRONZE, '❄️'), +] + + +# ---- 12. 앱 사용 마일스톤 ---- +_MILESTONE_DEFS = [ + ('mile_first', 'Hello, World!', '앱 첫 실행', 1, + _bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0), + TIER_BRONZE, '👋'), + ('mile_7days', '일주일 사용', '7일 사용', 7, + _make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'), + ('mile_30days', '한 달 사용', '30일 사용', 30, + _make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'), + ('mile_365days', '1주년', '365일 사용', 365, + _make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'), + ('mile_730days', '2주년', '730일 사용', 730, + _make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'), + ('mile_1095days', '3주년', '3년 사용', 1095, + _make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'), + ('mile_5years', '5년 사용자', '5년 사용', 1825, + _make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'), + ('mile_10years', '10년 사용자', '10년 사용', 3650, + _make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'), +] + + +# ---- 13. 통계·분석 (view counter 기반) ---- +_STATS_DEFS = [ + ('stat_weekly_10', '주간 통계러', '주간 탭 10회 조회', 10, + _make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10), + TIER_BRONZE, '📊'), + ('stat_monthly_10', '월간 통계러', '월간 탭 10회', 10, + _make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10), + TIER_BRONZE, '📈'), + ('stat_pattern_10', '패턴 분석가', '패턴 탭 10회', 10, + _make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10), + TIER_SILVER, '🔍'), + ('stat_calendar_30', '캘린더 챔프', '캘린더 30회 조회', 30, + _make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30), + TIER_SILVER, '📅'), + ('stat_report_first', '일일 보고서 첫 생성', '일일 보고 1회', 1, + _make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1), + TIER_BRONZE, '📋'), + ('stat_report_30', '보고서 챔프', '일일 보고 30회', 30, + _make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30), + TIER_SILVER, '📰'), + ('stat_chart_hover', '차트 호버 발견', '차트 hover 첫 발견', 1, + _bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'), + TIER_BRONZE, '🎨'), + ('stat_achievements_open', '도전과제 박물관', '도전과제 뷰 50회', 50, + _make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50), + TIER_BRONZE, '🦄'), +] + + +# ---- 14. 시크릿 ---- +def _has_clock_in_palindrome(db) -> bool: + """출근 시각이 회문 (HH:MM에서 H1H2:M1M2가 회문).""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT clock_in FROM work_records WHERE clock_in IS NOT NULL") + for (t,) in cur.fetchall(): + if not t: + continue + digits = t[:5].replace(':', '') + if len(digits) == 4 and digits == digits[::-1]: + return True + return False + + +def _has_clock_in_jackpot(db) -> bool: + """출근 시각 모든 자릿수 동일 (11:11, 22:22).""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT clock_in FROM work_records WHERE clock_in IS NOT NULL") + for (t,) in cur.fetchall(): + if not t: + continue + digits = t[:5].replace(':', '') + if len(digits) == 4 and len(set(digits)) == 1: + return True + return False + + +def _has_friday_13th_clockin(db) -> bool: + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records + WHERE strftime('%w', date) = '5' + AND CAST(strftime('%d', date) AS INTEGER) = 13 + LIMIT 1 + """) + return cur.fetchone() is not None + + +def _has_777(db) -> bool: + """7월 7일 7시 7분 출근.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records + WHERE strftime('%m-%d', date) = '07-07' + AND clock_in LIKE '07:07%' + LIMIT 1 + """) + return cur.fetchone() is not None + + +def _has_exact_8h(db) -> bool: + """정확히 8시간 0분 0초 근무.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records + WHERE clock_in IS NOT NULL AND clock_out IS NOT NULL + AND total_hours = 8.0 + LIMIT 1 + """) + return cur.fetchone() is not None + + +def _has_pi_day(db) -> bool: + """3/14 1:59 출근 (π = 3.14159).""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records + WHERE strftime('%m-%d', date) = '03-14' + AND clock_in LIKE '01:59%' + LIMIT 1 + """) + return cur.fetchone() is not None + + +def _has_fibonacci_minute(db) -> bool: + """출근 시각 분이 피보나치 (1,2,3,5,8,13,21,34,55).""" + fibs = {1, 2, 3, 5, 8, 13, 21, 34, 55} + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT CAST(strftime('%M', clock_in) AS INTEGER) FROM work_records + WHERE clock_in IS NOT NULL + """) + return any(r[0] in fibs for r in cur.fetchall()) + + +def _has_double_six(db) -> bool: + """6/6 18:06 출근.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT 1 FROM work_records + WHERE strftime('%m-%d', date) = '06-06' + AND clock_in LIKE '18:06%' + LIMIT 1 + """) + return cur.fetchone() is not None + + +def _has_500_anniv_clockin(db) -> bool: + """가입 후 정확히 365일 후 출근.""" + hire = _setting_str(db, 'hire_date', '') + if not hire: + return False + try: + d = datetime.strptime(hire, '%Y-%m-%d').date() + target = d + timedelta(days=365) + return _has_clockin_on_date(db, target) + except ValueError: + return False + + +_SECRET_DEFS = [ + ('secret_palindrome', '회문 시각', '출근 시각이 회문', 1, + _bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'), + ('secret_jackpot', '잭팟 시각', '출근 시각 모든 자릿수 동일', 1, + _bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'), + ('secret_fri13', '13일 금요일', '13일 금요일 출근', 1, + _bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'), + ('secret_777', '7-7-7', '7월 7일 7시 7분 출근', 1, + _bool_eval(_has_777), TIER_LEGEND, '🔮'), + ('secret_exact_8h', '정확 8시간', '정확히 8h 0m 근무', 1, + _bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'), + ('secret_pi_day', '파이 데이', '3/14 01:59 출근', 1, + _bool_eval(_has_pi_day), TIER_LEGEND, '🥧'), + ('secret_fibonacci', '피보나치', '출근 분이 피보나치 수', 1, + _bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'), + ('secret_double_six', '더블 식스', '6/6 18:06 출근', 1, + _bool_eval(_has_double_six), TIER_LEGEND, '🎲'), + ('secret_anniversary', '마법사', '가입 후 정확히 365일 후 출근', 1, + _bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'), +] + + +# ---- 15. 다양성·설정 ---- +def _setting_changed_from_default(db, key: str, default_value: str) -> bool: + return str(db.get_setting(key, default_value)) != default_value + + +_SETTINGS_DEFS = [ + ('set_dark', '다크 사이드', '다크 테마 1회 사용', 1, + _bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')), + TIER_BRONZE, '🌗'), + ('set_lang', '이중언어', '언어 변경 (en 사용)', 1, + _bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'), + TIER_BRONZE, '🌐'), + ('set_a11y', '접근성 활용', '글꼴 크기≠100% 또는 고대비 ON', 1, + _bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0' + or db.get_setting('high_contrast', 'false').lower() == 'true'), + TIER_BRONZE, '♿'), + ('set_overtime_unit', '단위 변경', 'overtime_unit 변경', 1, + _bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'), + TIER_BRONZE, '⏱️'), + ('set_goal_full', '목표 마스터', '월 연장+일평균 둘 다 설정', 1, + _bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0 + and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0), + TIER_SILVER, '🎯'), + ('set_discord_full', '풀 셋업', 'Discord URL + 모든 알림 ON', 1, + _bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '') + and all(db.get_setting(k, 'true').lower() == 'true' for k in + ('notification_clock_out', 'notification_lunch', + 'notification_overtime', 'notification_health'))), + TIER_SILVER, '🔔'), + ('set_cloud', '클라우드 동기화', 'DB 경로 변경', 1, + _bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')), + TIER_SILVER, '☁️'), +] + + +# ---- 16. 메타 (도전과제 자체) ---- +def _earned_count(db) -> int: + with db._conn() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM achievements WHERE earned_date IS NOT NULL") + return cur.fetchone()[0] + + +def _earned_secret_count(db) -> int: + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM achievements + WHERE earned_date IS NOT NULL AND is_secret = 1 + """) + return cur.fetchone()[0] + + +_META_DEFS = [ + ('meta_first', '첫 도전과제', '첫 도전과제 획득', 1, + _make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'), + ('meta_10', '10개 달성', '10개 보유', 10, + _make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'), + ('meta_25', '25개 달성', '25개 보유', 25, + _make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'), + ('meta_50', '50개 달성', '50개 보유', 50, + _make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'), + ('meta_75', '75개 달성', '75개 보유', 75, + _make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'), + ('meta_100', '100개 달성', '100개 보유', 100, + _make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'), + ('meta_secret_1', '시크릿 발견', '첫 시크릿 발견', 1, + _make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'), + ('meta_secret_5', '시크릿 헌터', '시크릿 5개 발견', 5, + _make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'), +] + + +# ============================================================ +# 모든 도전과제 통합 +# ============================================================ + +def _build_all() -> List[Achievement]: + """모든 카테고리를 합쳐 Achievement 리스트로 반환.""" + all_defs = [] + sections = [ + (CAT_STREAK, _STREAK_DEFS), + (CAT_PUNCTUAL, _PUNCTUAL_DEFS), + (CAT_BALANCE, _BALANCE_DEFS), + (CAT_OT_BANK, _OT_BANK_DEFS), + (CAT_OT_USE, _OT_USE_DEFS), + (CAT_LEAVE, _LEAVE_DEFS), + (CAT_MEAL, _MEAL_DEFS), + (CAT_BREAK, _BREAK_DEFS), + (CAT_TIME_SLOT, _TIME_SLOT_DEFS), + (CAT_SPECIAL_DAY, _SPECIAL_DAY_DEFS), + (CAT_SEASON, _SEASON_DEFS), + (CAT_MILESTONE, _MILESTONE_DEFS), + (CAT_STATS, _STATS_DEFS), + (CAT_SETTINGS, _SETTINGS_DEFS), + (CAT_SECRET, _SECRET_DEFS), + (CAT_META, _META_DEFS), + ] + for cat, defs in sections: + for tup in defs: + code, name, desc, target, evaluator, tier, icon = tup + is_secret = (cat == CAT_SECRET) + all_defs.append(Achievement( + code=code, name=name, description=desc, category=cat, + tier=tier, badge_icon=icon, is_secret=is_secret, + target=target, evaluator=evaluator, + )) + return all_defs + + +ALL_ACHIEVEMENTS: List[Achievement] = _build_all() + + +def get_achievement(code: str) -> Optional[Achievement]: + for a in ALL_ACHIEVEMENTS: + if a.code == code: + return a + return None + + +# ============================================================ +# DB 동기화 + 평가 +# ============================================================ + +def sync_definitions_to_db(db) -> None: + """ALL_ACHIEVEMENTS 정의를 achievements 테이블에 upsert. + + code가 unique key. 신규 도전과제는 INSERT, 기존은 메타데이터 UPDATE + (earned_date, progress는 보존). + """ + with db._conn() as conn: + cur = conn.cursor() + for a in ALL_ACHIEVEMENTS: + cur.execute("SELECT id FROM achievements WHERE code = ?", (a.code,)) + row = cur.fetchone() + if row is None: + cur.execute(''' + INSERT INTO achievements + (code, name, description, category, tier, is_secret, + progress, target, badge_icon) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) + ''', (a.code, a.name, a.description, a.category, a.tier, + 1 if a.is_secret else 0, a.target, a.badge_icon)) + else: + cur.execute(''' + UPDATE achievements + SET name = ?, description = ?, category = ?, tier = ?, + is_secret = ?, target = ?, badge_icon = ? + WHERE code = ? + ''', (a.name, a.description, a.category, a.tier, + 1 if a.is_secret else 0, a.target, a.badge_icon, a.code)) + conn.commit() + + +def evaluate_all(db) -> List[Achievement]: + """미획득 도전과제만 평가. 새로 잠금 해제된 것 리스트 반환. + + side effect: progress 업데이트, earned_date 기록. + """ + newly_unlocked = [] + with db._conn() as conn: + cur = conn.cursor() + for a in ALL_ACHIEVEMENTS: + if a.evaluator is None: + continue + cur.execute(""" + SELECT progress, earned_date FROM achievements WHERE code = ? + """, (a.code,)) + row = cur.fetchone() + if row is None: + continue + stored_progress, earned = row[0], row[1] + if earned is not None: + continue # 이미 획득 + + try: + progress, target = a.evaluator(db) + except Exception: + continue # 평가 실패는 silent (다음 tick에 재시도) + + now_unlocked = progress >= target + + if progress != stored_progress or now_unlocked: + if now_unlocked: + cur.execute(""" + UPDATE achievements + SET progress = ?, earned_date = DATE('now', 'localtime') + WHERE code = ? + """, (target, a.code)) + newly_unlocked.append(a) + else: + cur.execute(""" + UPDATE achievements SET progress = ? WHERE code = ? + """, (progress, a.code)) + conn.commit() + return newly_unlocked + + +def get_all_with_status(db) -> List[dict]: + """UI용: 모든 도전과제 + 진행도/획득 상태 dict 리스트.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT code, name, description, category, tier, is_secret, + progress, target, earned_date, badge_icon + FROM achievements + ORDER BY + CASE WHEN earned_date IS NOT NULL THEN 0 ELSE 1 END, + category, tier + """) + return [dict(zip( + ['code', 'name', 'description', 'category', 'tier', 'is_secret', + 'progress', 'target', 'earned_date', 'badge_icon'], + row + )) for row in cur.fetchall()] + + +def get_stats(db) -> dict: + """전체 통계 — 획득/총개수/비밀발견 등.""" + with db._conn() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN earned_date IS NOT NULL THEN 1 ELSE 0 END) AS earned, + SUM(CASE WHEN is_secret = 1 THEN 1 ELSE 0 END) AS secret_total, + SUM(CASE WHEN is_secret = 1 AND earned_date IS NOT NULL THEN 1 ELSE 0 END) AS secret_earned + FROM achievements + """) + row = cur.fetchone() + return { + 'total': row[0] or 0, + 'earned': row[1] or 0, + 'secret_total': row[2] or 0, + 'secret_earned': row[3] or 0, + } diff --git a/core/database.py b/core/database.py index d93f6cd..c5b5c12 100644 --- a/core/database.py +++ b/core/database.py @@ -3,6 +3,7 @@ SQLite를 사용하여 근무 기록, 연장근무, 휴가 등을 관리 """ import sqlite3 +from contextlib import contextmanager from datetime import datetime, date from typing import Optional, List, Dict, Tuple import os @@ -24,11 +25,37 @@ class Database: timeout=5초: 다른 PC/프로세스가 쓰는 동안 락 충돌 시 대기. 클라우드 동기화(OneDrive 등)로 같은 DB를 두 PC에서 쓸 때 안전. + + 주의: 호출자는 반드시 try/finally로 close()를 보장해야 함. + 가능하면 `_conn()` 컨텍스트 매니저 사용 권장. """ conn = sqlite3.connect(self.db_path, timeout=5.0) conn.row_factory = sqlite3.Row return conn + @contextmanager + def _conn(self): + """try/finally close()를 자동 처리하는 컨텍스트 매니저. + + 예: + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(...) + conn.commit() + + 예외 발생 시: SQLite의 묵시적 트랜잭션이 commit되지 않은 채 conn이 닫히므로 + 해당 트랜잭션 자동 rollback. 단, FK 제약 등으로 부분적 변경이 보일 수 있는 + 멀티-statement 케이스는 명시적 try/except + conn.rollback() 필요. + """ + conn = self.get_connection() + try: + yield conn + finally: + try: + conn.close() + except Exception: + pass + def _enable_concurrency(self): """WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화.""" try: @@ -43,7 +70,31 @@ class Database: def init_database(self): """데이터베이스 초기화 및 테이블 생성""" - conn = self.get_connection() + with self._conn() as conn: + self._create_tables(conn) + conn.commit() + + # 데이터베이스 마이그레이션 실행 + self.migrate_break_records_cascade() + self.migrate_lunch_duration_to_minutes() + self.migrate_leave_records_hours_to_days() + self.migrate_add_dinner_break() + self.migrate_cleanup_balance_adjustments() + self.migrate_work_hours_to_minutes() + self.migrate_annual_leave_keys() + self.migrate_v23_break_type() + self.migrate_v23_notification_log() + self.migrate_v23_onboarding_for_existing() + self.migrate_v271_break_indexes() + self.migrate_v271_work_records_indexes() + self.migrate_v280_achievements_columns() + self.migrate_v280_hire_date() + + # 기본 설정 초기화 + self.init_default_settings() + + def _create_tables(self, conn) -> None: + """init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리.""" cursor = conn.cursor() # 일일 근무 기록 테이블 @@ -112,14 +163,22 @@ class Database: ) ''') - # 업적 + # 도전과제 (achievements) + # v2.8.0: code 컬럼 추가 — 평가자가 식별자로 사용 cursor.execute(''' CREATE TABLE IF NOT EXISTS achievements ( id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE, name TEXT NOT NULL, description TEXT, + category TEXT, + tier TEXT, + is_secret BOOLEAN DEFAULT 0, + progress INTEGER DEFAULT 0, + target INTEGER DEFAULT 1, earned_date DATE, - badge_icon TEXT + badge_icon TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') @@ -150,44 +209,25 @@ class Database: ) ''') - conn.commit() - conn.close() - - # 데이터베이스 마이그레이션 실행 - self.migrate_break_records_cascade() - self.migrate_lunch_duration_to_minutes() - self.migrate_leave_records_hours_to_days() - self.migrate_add_dinner_break() - self.migrate_cleanup_balance_adjustments() - self.migrate_work_hours_to_minutes() - self.migrate_annual_leave_keys() - self.migrate_v23_break_type() - self.migrate_v23_notification_log() - self.migrate_v23_onboarding_for_existing() - - # 기본 설정 초기화 - self.init_default_settings() - def migrate_break_records_cascade(self): - """break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션)""" - conn = self.get_connection() - cursor = conn.cursor() + """break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션). - # 기존 테이블에 CASCADE가 있는지 확인 - cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='break_records'") - result = cursor.fetchone() + 스키마 introspection이 묵시적 sentinel 역할 — CASCADE가 이미 있으면 no-op. + DROP/CREATE/INSERT는 단일 트랜잭션 내에서 실행되어 실패 시 자동 rollback. + """ + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='break_records'") + result = cursor.fetchone() + + if not result or 'ON DELETE CASCADE' in result[0]: + return # 이미 CASCADE 있음 또는 테이블 없음 - if result and 'ON DELETE CASCADE' not in result[0]: - # CASCADE가 없으면 테이블 재생성 try: - # 1. 기존 데이터 백업 cursor.execute('SELECT * FROM break_records') backup_data = cursor.fetchall() - # 2. 기존 테이블 삭제 cursor.execute('DROP TABLE IF EXISTS break_records') - - # 3. CASCADE 포함한 새 테이블 생성 cursor.execute(''' CREATE TABLE break_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -202,84 +242,64 @@ class Database: FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE ) ''') - - # 4. 데이터 복원 for row in backup_data: cursor.execute(''' INSERT INTO break_records (id, work_record_id, date, break_out, break_in, total_minutes, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', tuple(row)) - conn.commit() - print("break_records 테이블 CASCADE 마이그레이션 완료") except Exception as e: conn.rollback() - print(f"마이그레이션 오류: {e}") - - conn.close() + import sys + print(f"break_records CASCADE 마이그레이션 실패 (rollback됨): {e}", file=sys.stderr) def migrate_lunch_duration_to_minutes(self): """lunch_duration을 시간 단위에서 분 단위로 마이그레이션""" - conn = self.get_connection() - cursor = conn.cursor() + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration'") + result = cursor.fetchone() + if result: + lunch_hours = float(result['value']) + lunch_minutes = int(lunch_hours * 60) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('lunch_duration_minutes', ?, CURRENT_TIMESTAMP) + ''', (str(lunch_minutes),)) + # 기존 lunch_duration은 삭제하지 않음 (호환성 유지) - try: - # 기존 lunch_duration 설정 확인 - cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration'") - result = cursor.fetchone() - - if result: - # 기존 값이 있으면 시간 단위로 저장되어 있으므로 분으로 변환 - lunch_hours = float(result['value']) - lunch_minutes = int(lunch_hours * 60) - - # lunch_duration_minutes로 저장 - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('lunch_duration_minutes', ?, CURRENT_TIMESTAMP) - ''', (str(lunch_minutes),)) - - # 기존 lunch_duration은 삭제하지 않음 (호환성 유지) - - # lunch_duration_minutes가 없으면 기본값 설정 - cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration_minutes'") - if not cursor.fetchone(): - cursor.execute(''' - INSERT OR IGNORE INTO settings (key, value) - VALUES ('lunch_duration_minutes', '60') - ''') - - conn.commit() - except Exception as e: - # 마이그레이션 실패 시 무시 (이미 마이그레이션됨) - # 단, 예상치 못한 오류는 로그에 기록 - import sys - if "no such column" not in str(e).lower() and "already exists" not in str(e).lower(): - print(f"lunch_duration 마이그레이션 경고: {e}", file=sys.stderr) - finally: - conn.close() + cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration_minutes'") + if not cursor.fetchone(): + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('lunch_duration_minutes', '60') + ''') + conn.commit() + except Exception as e: + import sys + if ("no such column" not in str(e).lower() + and "already exists" not in str(e).lower()): + print(f"lunch_duration 마이그레이션 경고: {e}", file=sys.stderr) def migrate_leave_records_hours_to_days(self): - """leave_records.hours 컬럼을 days로 변경 (마이그레이션)""" - conn = self.get_connection() - cursor = conn.cursor() + """leave_records.hours 컬럼을 days로 변경 (마이그레이션). - try: - # 현재 테이블 스키마 확인 - cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='leave_records'") - result = cursor.fetchone() + 스키마 introspection이 묵시적 sentinel — 'hours REAL' 사라지면 no-op. + """ + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='leave_records'") + result = cursor.fetchone() + if not (result and 'hours REAL' in result[0]): + return # 이미 마이그레이션됨 또는 테이블 없음 - if result and 'hours REAL' in result[0]: - # hours 컬럼이 있으면 days로 변경 - # 1. 기존 데이터 백업 cursor.execute('SELECT * FROM leave_records') backup_data = cursor.fetchall() - # 2. 기존 테이블 삭제 cursor.execute('DROP TABLE IF EXISTS leave_records') - - # 3. days 컬럼으로 새 테이블 생성 cursor.execute(''' CREATE TABLE leave_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -290,47 +310,36 @@ class Database: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') - - # 4. 데이터 복원 (hours -> days, 같은 값) for row in backup_data: cursor.execute(''' INSERT INTO leave_records (id, date, leave_type, days, memo, created_at) VALUES (?, ?, ?, ?, ?, ?) ''', tuple(row)) - conn.commit() - print("leave_records 테이블 hours->days 마이그레이션 완료") - except Exception as e: - conn.rollback() - print(f"leave_records 마이그레이션 오류: {e}") - finally: - conn.close() + except Exception as e: + conn.rollback() + import sys + print(f"leave_records 마이그레이션 실패 (rollback됨): {e}", file=sys.stderr) def migrate_add_dinner_break(self): """work_records 테이블에 dinner_break 컬럼 추가 (마이그레이션)""" - conn = self.get_connection() - cursor = conn.cursor() - - try: - # 현재 테이블 스키마 확인 - cursor.execute("PRAGMA table_info(work_records)") - columns = [row[1] for row in cursor.fetchall()] - - if 'dinner_break' not in columns: - # dinner_break 컬럼 추가 - cursor.execute(''' - ALTER TABLE work_records - ADD COLUMN dinner_break BOOLEAN DEFAULT 0 - ''') - conn.commit() - print("work_records 테이블에 dinner_break 컬럼 추가 완료") - except Exception as e: - import sys - if "duplicate column name" not in str(e).lower(): - print(f"dinner_break 컬럼 추가 경고: {e}", file=sys.stderr) - finally: - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("PRAGMA table_info(work_records)") + columns = [row[1] for row in cursor.fetchall()] + if 'dinner_break' not in columns: + cursor.execute(''' + ALTER TABLE work_records + ADD COLUMN dinner_break BOOLEAN DEFAULT 0 + ''') + conn.commit() + print("work_records 테이블에 dinner_break 컬럼 추가 완료") + except Exception as e: + import sys + if "duplicate column name" not in str(e).lower(): + print(f"dinner_break 컬럼 추가 경고: {e}", file=sys.stderr) def migrate_cleanup_balance_adjustments(self): """기존 잘못된 조정 데이터 정리 마이그레이션 @@ -339,93 +348,67 @@ class Database: - overtime_bank: work_record_id가 NULL인 레코드는 삭제 (초기값은 settings로 이동) - leave_records: 'manual' 타입이고 '이전 사용분 일괄 추가' 메모가 있는 레코드 삭제 (초기값은 settings로 이동) """ - conn = self.get_connection() - cursor = conn.cursor() + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM settings WHERE key = 'balance_adjustment_migrated_v2'") + if cursor.fetchone(): + return # 이미 완료 - try: - # 마이그레이션 완료 여부 확인 (v2로 버전 업) - cursor.execute("SELECT value FROM settings WHERE key = 'balance_adjustment_migrated_v2'") - result = cursor.fetchone() + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id IS NULL + ''') + deleted_overtime = cursor.rowcount - if result: - # 이미 마이그레이션 완료 - conn.close() - return + cursor.execute(''' + DELETE FROM leave_records + WHERE leave_type = 'manual' AND memo LIKE '%이전 사용분 일괄 추가%' + ''') + deleted_leave = cursor.rowcount - # 1. overtime_bank에서 수동 추가 레코드 삭제 - # (work_record_id가 NULL인 것 - 이전 방식의 수동 조정) - cursor.execute(''' - DELETE FROM overtime_bank - WHERE work_record_id IS NULL - ''') - deleted_overtime = cursor.rowcount + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('balance_adjustment_migrated_v2', 'true', CURRENT_TIMESTAMP) + ''') + conn.commit() - # 2. leave_records에서 '이전 사용분 일괄 추가' 레코드 삭제 - cursor.execute(''' - DELETE FROM leave_records - WHERE leave_type = 'manual' AND memo LIKE '%이전 사용분 일괄 추가%' - ''') - deleted_leave = cursor.rowcount - - # 마이그레이션 완료 표시 - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('balance_adjustment_migrated_v2', 'true', CURRENT_TIMESTAMP) - ''') - - conn.commit() - - if deleted_overtime > 0 or deleted_leave > 0: - print(f"잔액 조정 마이그레이션 v2 완료: 연장근무 {deleted_overtime}건, 연차 {deleted_leave}건 삭제") - - except Exception as e: - conn.rollback() - import sys - print(f"잔액 조정 마이그레이션 오류: {e}", file=sys.stderr) - finally: - conn.close() + if deleted_overtime > 0 or deleted_leave > 0: + print(f"잔액 조정 마이그레이션 v2 완료: 연장근무 {deleted_overtime}건, 연차 {deleted_leave}건 삭제") + except Exception as e: + conn.rollback() + import sys + print(f"잔액 조정 마이그레이션 오류: {e}", file=sys.stderr) def migrate_work_hours_to_minutes(self): - """work_hours(시간 단위, 정수)를 work_minutes(분 단위)로 마이그레이션. + """work_hours(시간 단위, 정수)를 work_minutes(분 단위)로 마이그레이션.""" + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM settings WHERE key = 'work_minutes'") + if cursor.fetchone(): + return # 이미 완료 - 단축근무자(예: 7시간 30분)를 위해 분 단위 저장이 필요. - 기존 work_hours는 호환성 유지를 위해 보존. - """ - conn = self.get_connection() - cursor = conn.cursor() - - try: - # work_minutes가 이미 있으면 스킵 - cursor.execute("SELECT value FROM settings WHERE key = 'work_minutes'") - if cursor.fetchone(): - conn.close() - return - - # work_hours에서 분으로 변환 - cursor.execute("SELECT value FROM settings WHERE key = 'work_hours'") - row = cursor.fetchone() - if row: - try: - # float 허용 (혹시 외부에서 7.5 등 저장된 경우) - work_hours_val = float(row[0]) - work_minutes_val = int(round(work_hours_val * 60)) - except (ValueError, TypeError): + cursor.execute("SELECT value FROM settings WHERE key = 'work_hours'") + row = cursor.fetchone() + if row: + try: + work_hours_val = float(row[0]) + work_minutes_val = int(round(work_hours_val * 60)) + except (ValueError, TypeError): + work_minutes_val = 480 + else: work_minutes_val = 480 - else: - work_minutes_val = 480 - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('work_minutes', ?, CURRENT_TIMESTAMP) - ''', (str(work_minutes_val),)) - - conn.commit() - except Exception as e: - conn.rollback() - import sys - print(f"work_minutes 마이그레이션 경고: {e}", file=sys.stderr) - finally: - conn.close() + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('work_minutes', ?, CURRENT_TIMESTAMP) + ''', (str(work_minutes_val),)) + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"work_minutes 마이그레이션 경고: {e}", file=sys.stderr) def migrate_annual_leave_keys(self): """annual_leave_total(레거시) ↔ annual_leave_days(UI) 동기화. @@ -433,147 +416,242 @@ class Database: UI는 annual_leave_days를 사용하지만 일부 메서드는 annual_leave_total을 읽음. 둘 중 하나만 있으면 다른 쪽에 복사. sentinel로 1회만 실행. """ - conn = self.get_connection() - cursor = conn.cursor() + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_keys_migrated'") + if cursor.fetchone(): + return # 이미 완료 - try: - # sentinel 체크: 이미 마이그레이션 완료면 스킵 - cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_keys_migrated'") - if cursor.fetchone(): - return + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_days'") + days_row = cursor.fetchone() + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_total'") + total_row = cursor.fetchone() - cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_days'") - days_row = cursor.fetchone() - cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_total'") - total_row = cursor.fetchone() + if days_row and not total_row: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_total', ?, CURRENT_TIMESTAMP) + ''', (days_row[0],)) + elif total_row and not days_row: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_days', ?, CURRENT_TIMESTAMP) + ''', (total_row[0],)) - if days_row and not total_row: cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('annual_leave_total', ?, CURRENT_TIMESTAMP) - ''', (days_row[0],)) - elif total_row and not days_row: - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('annual_leave_days', ?, CURRENT_TIMESTAMP) - ''', (total_row[0],)) - - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('annual_leave_keys_migrated', 'true', CURRENT_TIMESTAMP) - ''') - conn.commit() - except Exception as e: - conn.rollback() - import sys - print(f"annual_leave 키 동기화 경고: {e}", file=sys.stderr) - finally: - conn.close() + VALUES ('annual_leave_keys_migrated', 'true', CURRENT_TIMESTAMP) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"annual_leave 키 동기화 경고: {e}", file=sys.stderr) def migrate_v23_break_type(self): """break_records에 break_type 컬럼 추가 (v2.3.0). 값: 'break'(기본 외출) / 'lunch' / 'dinner'. - 기존 점심 1시간 자동 적용 모드와 무관 — 실제 시간 입력용. """ - conn = self.get_connection() - cursor = conn.cursor() - try: - cursor.execute("PRAGMA table_info(break_records)") - cols = [row[1] for row in cursor.fetchall()] - if 'break_type' not in cols: - cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'") - conn.commit() - except Exception as e: - conn.rollback() - import sys - print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr) - finally: - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("PRAGMA table_info(break_records)") + cols = [row[1] for row in cursor.fetchall()] + if 'break_type' not in cols: + cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'") + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr) def migrate_v23_notification_log(self): """알림 발송 이력 테이블 (v2.3.0). 중복 발송 방지 + 통계.""" - conn = self.get_connection() - cursor = conn.cursor() - try: - cursor.execute(''' - CREATE TABLE IF NOT EXISTS notification_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel TEXT NOT NULL, - event_type TEXT NOT NULL, - payload TEXT, - sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - success BOOLEAN DEFAULT 1 - ) - ''') - cursor.execute(''' - CREATE INDEX IF NOT EXISTS idx_notif_log_event - ON notification_log(event_type, sent_at) - ''') - conn.commit() - except Exception as e: - conn.rollback() - import sys - print(f"notification_log 생성 경고: {e}", file=sys.stderr) - finally: - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute(''' + CREATE TABLE IF NOT EXISTS notification_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + event_type TEXT NOT NULL, + payload TEXT, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN DEFAULT 1 + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_notif_log_event + ON notification_log(event_type, sent_at) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"notification_log 생성 경고: {e}", file=sys.stderr) def log_notification(self, channel: str, event_type: str, payload: str = None, success: bool = True) -> None: """알림 발송 이력 기록 (중복 방지 가드용).""" - conn = self.get_connection() - cursor = conn.cursor() - try: + with self._conn() as conn: + cursor = conn.cursor() cursor.execute( "INSERT INTO notification_log (channel, event_type, payload, success) VALUES (?, ?, ?, ?)", (channel, event_type, payload, success) ) conn.commit() - finally: - conn.close() def has_notification_today(self, channel: str, event_type: str) -> bool: - """오늘 같은 (channel, event_type) 발송 이력 존재 여부.""" - conn = self.get_connection() - cursor = conn.cursor() - try: + """오늘 같은 (channel, event_type) 발송 이력 존재 여부. + + 주의: SQLite의 CURRENT_TIMESTAMP는 UTC를 반환하므로 sent_at 비교 시에도 + 'localtime'을 적용해야 사용자의 로컬 자정 경계와 일치. 적용하지 않으면 + UTC와 로컬 날짜가 다른 시간대(예: KST 00:00~09:00)에 mismatch 발생. + """ + with self._conn() as conn: + cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM notification_log " - "WHERE channel = ? AND event_type = ? AND DATE(sent_at) = DATE('now', 'localtime')", + "WHERE channel = ? AND event_type = ? " + "AND DATE(sent_at, 'localtime') = DATE('now', 'localtime')", (channel, event_type) ) return cursor.fetchone()[0] > 0 - finally: - conn.close() + + def migrate_v271_break_indexes(self): + """break_records 조회 패턴 인덱스 (v2.7.1). + + 가장 자주 쓰이는 쿼리: + - get_today_break_records / get_break_records_by_date — `WHERE date = ?` + - 일일 보고서 — `WHERE date = ? AND break_type = ?` + - get_meal_minutes_today — `WHERE date = ? AND break_type = ?` + - get_active_break_record — `WHERE date = ? AND break_in IS NULL` + + date 단일 인덱스 + (date, break_type) 복합 인덱스. CREATE INDEX IF NOT EXISTS + 라 idempotent — sentinel 불필요. + """ + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_break_records_date " + "ON break_records(date)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_break_records_date_type " + "ON break_records(date, break_type)" + ) + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"break_records 인덱스 생성 경고: {e}", file=sys.stderr) + + def migrate_v271_work_records_indexes(self): + """work_records / overtime 조회 인덱스 (v2.7.1). + + date는 work_records에서 UNIQUE 제약으로 이미 인덱스가 자동 생성되지만, + overtime_bank/overtime_usage/leave_records의 date 컬럼은 인덱스가 없어 + get_monthly_stats / get_consecutive_overtime_days 등이 풀스캔. + """ + with self._conn() as conn: + cursor = conn.cursor() + try: + for tbl in ('overtime_bank', 'overtime_usage', 'leave_records'): + cursor.execute( + f"CREATE INDEX IF NOT EXISTS idx_{tbl}_date ON {tbl}(date)" + ) + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"date 인덱스 생성 경고: {e}", file=sys.stderr) + + def migrate_v280_achievements_columns(self): + """기존 achievements 테이블에 v2.8.0 컬럼들 추가 (도전과제 시스템). + + code, category, tier, is_secret, progress, target, created_at. + ALTER TABLE 멱등 — 이미 있는 컬럼은 스킵. + """ + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("PRAGMA table_info(achievements)") + cols = {row[1] for row in cursor.fetchall()} + + additions = [ + ('code', "ALTER TABLE achievements ADD COLUMN code TEXT"), + ('category', "ALTER TABLE achievements ADD COLUMN category TEXT"), + ('tier', "ALTER TABLE achievements ADD COLUMN tier TEXT"), + ('is_secret', "ALTER TABLE achievements ADD COLUMN is_secret BOOLEAN DEFAULT 0"), + ('progress', "ALTER TABLE achievements ADD COLUMN progress INTEGER DEFAULT 0"), + ('target', "ALTER TABLE achievements ADD COLUMN target INTEGER DEFAULT 1"), + ('created_at', "ALTER TABLE achievements ADD COLUMN created_at TIMESTAMP"), + ] + for col_name, sql in additions: + if col_name not in cols: + cursor.execute(sql) + + # code UNIQUE 인덱스 (UNIQUE 제약은 ALTER로 못 추가하므로 인덱스로) + cursor.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_achievements_code " + "ON achievements(code) WHERE code IS NOT NULL" + ) + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"achievements 컬럼 마이그레이션 경고: {e}", file=sys.stderr) + + def migrate_v280_hire_date(self): + """첫 work_records 자동으로 hire_date 설정에 기록 (없으면). + + 도전과제(1주년, 365일 후 출근 등)에서 사용. 사용자 입력 없이 + 자동 추출 — 첫 출근일 = 가장 오래된 work_records.date. + """ + if self.get_setting('hire_date', None): + return # 이미 있음 + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT MIN(date) FROM work_records") + row = cursor.fetchone() + if row and row[0]: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('hire_date', ?, CURRENT_TIMESTAMP) + ''', (row[0],)) + conn.commit() + except Exception as e: + import sys + print(f"hire_date 마이그레이션 경고: {e}", file=sys.stderr) def migrate_v23_onboarding_for_existing(self): """기존 사용자(이미 work_records 데이터 있음)는 온보딩 자동 완료 처리. v2.3.0 도입 시 한 번만 실행. 신규 DB(데이터 0)는 영향 없음 → 첫 실행 시 위저드. """ - conn = self.get_connection() - cursor = conn.cursor() - try: - # 이미 완료/스킵 마크 있으면 패스 - cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'") - row = cursor.fetchone() - if row and row[0] == 'true': - return + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'") + row = cursor.fetchone() + if row and row[0] == 'true': + return - # 기존 work_records 데이터가 1건 이상 있으면 자동 완료 - cursor.execute("SELECT COUNT(*) FROM work_records") - count = cursor.fetchone()[0] - if count > 0: - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP) - ''') - conn.commit() - except Exception as e: - conn.rollback() - import sys - print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr) - finally: - conn.close() + cursor.execute("SELECT COUNT(*) FROM work_records") + count = cursor.fetchone()[0] + if count > 0: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr) def get_setting_int(self, key: str, default: int = 0) -> int: """설정을 int로 안전하게 조회 (변환 실패 시 default).""" @@ -634,8 +712,15 @@ class Database: 'notification_before_minutes': '30', 'notification_clock_out': 'true', 'notification_lunch': 'true', + 'notification_dinner': 'true', 'notification_overtime': 'true', 'notification_health': 'true', + # 알림 임계값 (v2.7.1: 하드코딩 → 설정) + 'lunch_reminder_hours': '4', + 'dinner_reminder_hours': '8', + 'overtime_threshold_hours': '20', + 'weekly_hours_threshold': '52', + 'health_consecutive_ot_days': '3', 'annual_leave_total': '15', 'annual_leave_days': '15', # annual_leave_total과 자동 동기화 'workday_boundary_hour': '6', @@ -658,98 +743,101 @@ class Database: # v2.6.0 'font_scale': '1.0', 'high_contrast': 'false', + # v2.8.0 도전과제 + 'birthday': '', + 'stat_weekly_view_count': '0', + 'stat_monthly_view_count': '0', + 'stat_pattern_view_count': '0', + 'calendar_view_count': '0', + 'leave_calendar_view_count': '0', + 'daily_report_count': '0', + 'achievements_view_count': '0', + 'chart_hover_discovered': 'false', + 'notification_achievement': 'true', + 'discord_notif_achievement': 'true', } - conn = self.get_connection() - cursor = conn.cursor() - - for key, value in default_settings.items(): - cursor.execute(''' - INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?) - ''', (key, value)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + for key, value in default_settings.items(): + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?) + ''', (key, value)) + conn.commit() # ===== 근무 기록 관련 메서드 ===== def add_work_record(self, date: str, clock_in: str, lunch_break: bool = False, is_manual: bool = False) -> int: - """근무 기록 추가""" - conn = self.get_connection() - cursor = conn.cursor() + """근무 기록 추가. - cursor.execute(''' - INSERT INTO work_records (date, clock_in, lunch_break, is_manual) - VALUES (?, ?, ?, ?) - ''', (date, clock_in, lunch_break, is_manual)) - - record_id = cursor.lastrowid - conn.commit() - conn.close() - return record_id + 첫 출근 시 hire_date 자동 기록 (도전과제 1주년 등에서 사용). + """ + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO work_records (date, clock_in, lunch_break, is_manual) + VALUES (?, ?, ?, ?) + ''', (date, clock_in, lunch_break, is_manual)) + record_id = cursor.lastrowid + # hire_date가 비어있으면 첫 출근일로 자동 설정 — 별도 트랜잭션 회피 + cursor.execute("SELECT value FROM settings WHERE key = 'hire_date'") + row = cursor.fetchone() + if not row or not row[0]: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('hire_date', ?, CURRENT_TIMESTAMP) + ''', (date,)) + conn.commit() + return record_id def update_clock_out(self, date: str, clock_out: str, total_hours: float, overtime_minutes: int, overtime_earned: int): """퇴근 시간 및 연장근무 업데이트""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - UPDATE work_records - SET clock_out = ?, total_hours = ?, overtime_minutes = ?, - overtime_earned = ?, updated_at = CURRENT_TIMESTAMP - WHERE date = ? - ''', (clock_out, total_hours, overtime_minutes, overtime_earned, date)) - - conn.commit() - conn.close() - - def cancel_clock_out(self, date: str) -> bool: - """퇴근 취소 (퇴근 시간 및 연장근무 기록 삭제) - - Returns: - bool: 성공 여부 - """ - conn = self.get_connection() - cursor = conn.cursor() - - try: - # 1. 해당 날짜의 work_record 조회 - cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) - record = cursor.fetchone() - - if not record: - conn.close() - return False - - work_record_id = record[0] - - # 2. 해당 날짜의 연장근무 적립 내역 삭제 - cursor.execute(''' - DELETE FROM overtime_bank - WHERE work_record_id = ? AND date = ? - ''', (work_record_id, date)) - - # 3. work_records의 퇴근 관련 필드 초기화 + with self._conn() as conn: + cursor = conn.cursor() cursor.execute(''' UPDATE work_records - SET clock_out = NULL, - total_hours = NULL, - overtime_minutes = 0, - overtime_earned = 0, - updated_at = CURRENT_TIMESTAMP + SET clock_out = ?, total_hours = ?, overtime_minutes = ?, + overtime_earned = ?, updated_at = CURRENT_TIMESTAMP WHERE date = ? - ''', (date,)) - + ''', (clock_out, total_hours, overtime_minutes, overtime_earned, date)) conn.commit() - conn.close() - return True - except Exception as e: - conn.rollback() - conn.close() - raise e + def cancel_clock_out(self, date: str) -> bool: + """퇴근 취소 (퇴근 시간 및 연장근무 기록 삭제). + + 2개 테이블 처리는 단일 트랜잭션 — 실패 시 자동 rollback. + + Returns: + bool: 성공 여부 (해당 날짜 기록이 없으면 False) + """ + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) + record = cursor.fetchone() + if not record: + return False + work_record_id = record[0] + try: + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id = ? AND date = ? + ''', (work_record_id, date)) + cursor.execute(''' + UPDATE work_records + SET clock_out = NULL, + total_hours = NULL, + overtime_minutes = 0, + overtime_earned = 0, + updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (date,)) + conn.commit() + return True + except Exception: + conn.rollback() + raise def get_today_record(self) -> Optional[Dict]: """오늘 근무 기록 조회""" @@ -758,278 +846,201 @@ class Database: def get_work_record(self, date: str) -> Optional[Dict]: """특정 날짜 근무 기록 조회""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM work_records WHERE date = ? - ''', (date,)) - - row = cursor.fetchone() - conn.close() - - if row: - return dict(row) - return None + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM work_records WHERE date = ?', (date,)) + row = cursor.fetchone() + return dict(row) if row else None def update_lunch_break(self, date: str, lunch_break: bool): """점심시간 사용 여부 업데이트""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - UPDATE work_records - SET lunch_break = ?, updated_at = CURRENT_TIMESTAMP - WHERE date = ? - ''', (lunch_break, date)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE work_records + SET lunch_break = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (lunch_break, date)) + conn.commit() def update_dinner_break(self, date: str, dinner_break: bool): """저녁시간 사용 여부 업데이트""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - UPDATE work_records - SET dinner_break = ?, updated_at = CURRENT_TIMESTAMP - WHERE date = ? - ''', (dinner_break, date)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE work_records + SET dinner_break = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (dinner_break, date)) + conn.commit() def delete_work_record(self, date: str): - """특정 날짜의 근무 기록 삭제""" - conn = self.get_connection() - cursor = conn.cursor() - - # 먼저 해당 기록의 ID 조회 - cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) - record = cursor.fetchone() - - if record: + """특정 날짜의 근무 기록 삭제. 3개 테이블이 한 트랜잭션에서 함께 처리.""" + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) + record = cursor.fetchone() + if not record: + return # 삭제할 게 없음 — commit 불필요 record_id = record[0] - - # 연관된 연장근무 적립 기록 삭제 - cursor.execute('DELETE FROM overtime_bank WHERE work_record_id = ?', (record_id,)) - - # 연관된 연장근무 사용 기록 삭제 - cursor.execute('DELETE FROM overtime_usage WHERE work_record_id = ?', (record_id,)) - - # 근무 기록 삭제 - cursor.execute('DELETE FROM work_records WHERE id = ?', (record_id,)) - - conn.commit() - conn.close() + try: + cursor.execute('DELETE FROM overtime_bank WHERE work_record_id = ?', (record_id,)) + cursor.execute('DELETE FROM overtime_usage WHERE work_record_id = ?', (record_id,)) + cursor.execute('DELETE FROM work_records WHERE id = ?', (record_id,)) + conn.commit() + except Exception: + conn.rollback() + raise def get_work_records_by_range(self, start_date: str, end_date: str) -> List[Dict]: """기간별 근무 기록 조회""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM work_records - WHERE date BETWEEN ? AND ? - ORDER BY date DESC - ''', (start_date, end_date)) - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM work_records + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + ''', (start_date, end_date)) + return [dict(row) for row in cursor.fetchall()] # ===== 연장근무 관련 메서드 ===== def add_overtime_earned(self, work_record_id: int, earned_minutes: int, date: str): """연장근무 적립""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - INSERT INTO overtime_bank (work_record_id, earned_minutes, date) - VALUES (?, ?, ?) - ''', (work_record_id, earned_minutes, date)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (?, ?, ?) + ''', (work_record_id, earned_minutes, date)) + conn.commit() def add_overtime_usage(self, work_record_id: int, used_minutes: int, date: str, reason: str = None): """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" - conn = self.get_connection() - cursor = conn.cursor() - - try: - cursor.execute(''' - INSERT INTO overtime_usage (work_record_id, used_minutes, date, reason) - VALUES (?, ?, ?, ?) - ''', (work_record_id, used_minutes, date, reason)) - - # work_records 테이블도 업데이트 (work_record_id가 있을 때만) - if work_record_id is not None: + with self._conn() as conn: + cursor = conn.cursor() + try: cursor.execute(''' - UPDATE work_records - SET overtime_used = overtime_used + ? - WHERE id = ? - ''', (used_minutes, work_record_id)) - - conn.commit() - except Exception as e: - conn.rollback() - raise e - finally: - conn.close() + INSERT INTO overtime_usage (work_record_id, used_minutes, date, reason) + VALUES (?, ?, ?, ?) + ''', (work_record_id, used_minutes, date, reason)) + if work_record_id is not None: + cursor.execute(''' + UPDATE work_records + SET overtime_used = overtime_used + ? + WHERE id = ? + ''', (used_minutes, work_record_id)) + conn.commit() + except Exception: + conn.rollback() + raise def get_total_overtime_balance(self) -> int: """총 연장근무 잔액 조회 (초기값 + 적립 - 사용)""" - conn = self.get_connection() - cursor = conn.cursor() - - # 초기값 (프로그램 사용 전 쌓인 연장근무) initial_overtime = int(self.get_setting(INITIAL_OVERTIME_MINUTES, '0')) - - # 단일 쿼리로 적립과 사용을 동시에 조회 (원자성 보장) - cursor.execute(''' - SELECT - COALESCE((SELECT SUM(earned_minutes) FROM overtime_bank), 0) - - COALESCE((SELECT SUM(used_minutes) FROM overtime_usage), 0) AS balance - ''') - balance = cursor.fetchone()[0] - - conn.close() - - return initial_overtime + balance + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT + COALESCE((SELECT SUM(earned_minutes) FROM overtime_bank), 0) - + COALESCE((SELECT SUM(used_minutes) FROM overtime_usage), 0) AS balance + ''') + balance = cursor.fetchone()[0] + return initial_overtime + balance def get_today_overtime_usage(self) -> int: """오늘 사용한 추가근무 시간 조회 (분)""" from datetime import date - today = date.today().isoformat() - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT SUM(used_minutes) - FROM overtime_usage - WHERE date = ? - ''', (today,)) - - used = cursor.fetchone()[0] or 0 - conn.close() - - return used + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT SUM(used_minutes) FROM overtime_usage WHERE date = ?', + (today,)) + return cursor.fetchone()[0] or 0 def get_today_leave_minutes(self) -> int: """오늘 사용한 연차/반차 시간 조회 (분)""" from datetime import date - today = date.today().isoformat() - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT SUM(days) - FROM leave_records - WHERE date = ? - ''', (today,)) - - days = cursor.fetchone()[0] or 0.0 - conn.close() - + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?', (today,)) + days = cursor.fetchone()[0] or 0.0 return int(days * self.get_work_minutes()) def add_initial_overtime_balance(self, minutes: int): """초기 연장근무 잔액 추가""" from datetime import datetime - - conn = self.get_connection() - cursor = conn.cursor() - today = datetime.now().date().isoformat() - - # work_record_id 없이 직접 추가 - cursor.execute(''' - INSERT INTO overtime_bank (work_record_id, earned_minutes, date) - VALUES (NULL, ?, ?) - ''', (minutes, today)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (NULL, ?, ?) + ''', (minutes, today)) + conn.commit() def get_overtime_history(self, limit: int = 30) -> List[Dict]: """연장근무 내역 조회 (적립 + 사용)""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT 'earned' as type, earned_minutes as minutes, date, - wr.clock_in, wr.clock_out - FROM overtime_bank ob - LEFT JOIN work_records wr ON ob.work_record_id = wr.id - UNION ALL - SELECT 'used' as type, used_minutes as minutes, date, - wr.clock_in, wr.clock_out - FROM overtime_usage ou - LEFT JOIN work_records wr ON ou.work_record_id = wr.id - ORDER BY date DESC - LIMIT ? - ''', (limit,)) - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT 'earned' as type, earned_minutes as minutes, date, + wr.clock_in, wr.clock_out + FROM overtime_bank ob + LEFT JOIN work_records wr ON ob.work_record_id = wr.id + UNION ALL + SELECT 'used' as type, used_minutes as minutes, date, + wr.clock_in, wr.clock_out + FROM overtime_usage ou + LEFT JOIN work_records wr ON ou.work_record_id = wr.id + ORDER BY date DESC + LIMIT ? + ''', (limit,)) + return [dict(row) for row in cursor.fetchall()] # ===== 휴가 관련 메서드 ===== def add_leave_record(self, date: str, leave_type: str, days: float, memo: str = None): """휴가 기록 추가""" - conn = self.get_connection() - cursor = conn.cursor() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO leave_records (date, leave_type, days, memo) + VALUES (?, ?, ?, ?) + ''', (date, leave_type, days, memo)) + conn.commit() - cursor.execute(''' - INSERT INTO leave_records (date, leave_type, days, memo) - VALUES (?, ?, ?, ?) - ''', (date, leave_type, days, memo)) - - conn.commit() - conn.close() - - def get_leave_records(self, start_date: str = None, end_date: str = None, exclude_bulk: bool = False) -> List[Dict]: + def get_leave_records(self, start_date: str = None, end_date: str = None, + exclude_bulk: bool = False) -> List[Dict]: """휴가 기록 조회""" - conn = self.get_connection() - cursor = conn.cursor() - - if start_date and end_date: - if exclude_bulk: - cursor.execute(''' - SELECT * FROM leave_records - WHERE date BETWEEN ? AND ? - AND COALESCE(memo, '') != '이전 사용분 일괄 추가' - ORDER BY date DESC - ''', (start_date, end_date)) + with self._conn() as conn: + cursor = conn.cursor() + if start_date and end_date: + if exclude_bulk: + cursor.execute(''' + SELECT * FROM leave_records + WHERE date BETWEEN ? AND ? + AND COALESCE(memo, '') != '이전 사용분 일괄 추가' + ORDER BY date DESC + ''', (start_date, end_date)) + else: + cursor.execute(''' + SELECT * FROM leave_records + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + ''', (start_date, end_date)) else: - cursor.execute(''' - SELECT * FROM leave_records - WHERE date BETWEEN ? AND ? - ORDER BY date DESC - ''', (start_date, end_date)) - else: - if exclude_bulk: - cursor.execute(''' - SELECT * FROM leave_records - WHERE COALESCE(memo, '') != '이전 사용분 일괄 추가' - ORDER BY date DESC - ''') - else: - cursor.execute('SELECT * FROM leave_records ORDER BY date DESC') - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + if exclude_bulk: + cursor.execute(''' + SELECT * FROM leave_records + WHERE COALESCE(memo, '') != '이전 사용분 일괄 추가' + ORDER BY date DESC + ''') + else: + cursor.execute('SELECT * FROM leave_records ORDER BY date DESC') + return [dict(row) for row in cursor.fetchall()] def get_annual_leave_balance(self) -> Tuple[float, float]: """연차 잔여 조회 (총 연차, 사용한 연차) @@ -1041,48 +1052,34 @@ class Database: 향후 연차 관리 기능 개선 시 활용 가능. """ total = float(self.get_setting(ANNUAL_LEAVE_TOTAL, '15')) - - conn = self.get_connection() - cursor = conn.cursor() - - # manual 타입이 아닌 모든 연차 사용 기록 합산 - cursor.execute(''' - SELECT SUM(days) FROM leave_records - WHERE leave_type IS NULL OR leave_type NOT IN ('manual', 'bulk') - ''') - - used = cursor.fetchone()[0] or 0 - conn.close() - + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT SUM(days) FROM leave_records + WHERE leave_type IS NULL OR leave_type NOT IN ('manual', 'bulk') + ''') + used = cursor.fetchone()[0] or 0 return total, used # ===== 설정 관련 메서드 ===== def get_setting(self, key: str, default: str = None) -> str: """설정 값 조회""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) - row = cursor.fetchone() - conn.close() - - if row: - return row[0] - return default + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) + row = cursor.fetchone() + return row[0] if row else default def set_setting(self, key: str, value: str): """설정 값 저장""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP) - ''', (key, value)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (key, value)) + conn.commit() # ===== 통계 관련 메서드 ===== @@ -1154,78 +1151,51 @@ class Database: def get_leave_record(self, date: str) -> Optional[Dict]: """특정 날짜의 휴가 기록 조회""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM leave_records WHERE date = ? - ''', (date,)) - - row = cursor.fetchone() - conn.close() - - return dict(row) if row else None + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM leave_records WHERE date = ?', (date,)) + row = cursor.fetchone() + return dict(row) if row else None def get_all_leave_records(self, limit: int = 100) -> List[Dict]: """모든 휴가 기록 조회 (최신순)""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM leave_records - ORDER BY date DESC - LIMIT ? - ''', (limit,)) - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM leave_records ORDER BY date DESC LIMIT ?', (limit,)) + return [dict(row) for row in cursor.fetchall()] def delete_leave_record(self, leave_id: int): """휴가 기록 삭제""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute('DELETE FROM leave_records WHERE id = ?', (leave_id,)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM leave_records WHERE id = ?', (leave_id,)) + conn.commit() # ===== 설정 관련 메서드 ===== def get_settings(self) -> Dict: """설정 가져오기""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute('SELECT * FROM settings') - rows = cursor.fetchall() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM settings') + rows = cursor.fetchall() # 딕셔너리로 변환 settings = {} for row in rows: key = row['key'] value = row['value'] - # 타입 변환 if value.lower() in ['true', 'false']: settings[key] = value.lower() == 'true' else: - # 정수 변환 시도 (음수 포함) try: settings[key] = int(value) except ValueError: - # float 변환 시도 try: settings[key] = float(value) except ValueError: settings[key] = value - return settings def save_settings(self, settings: Dict): @@ -1256,36 +1226,29 @@ class Database: elif 'annual_leave_total' in synced and 'annual_leave_days' not in synced: synced['annual_leave_days'] = synced['annual_leave_total'] - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - for key, value in synced.items(): - value_str = str(value) - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value) - VALUES (?, ?) - ''', (key, value_str)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + for key, value in synced.items(): + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value) + VALUES (?, ?) + ''', (key, str(value))) + conn.commit() # ===== 외출 관련 메서드 ===== def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None, break_type: str = 'break') -> int: """외출 기록 추가. break_type: 'break'(외출) / 'lunch' / 'dinner'.""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - INSERT INTO break_records (work_record_id, date, break_out, reason, break_type) - VALUES (?, ?, ?, ?, ?) - ''', (work_record_id, date, break_out, reason, break_type)) - - record_id = cursor.lastrowid - conn.commit() - conn.close() - return record_id + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO break_records (work_record_id, date, break_out, reason, break_type) + VALUES (?, ?, ?, ?, ?) + ''', (work_record_id, date, break_out, reason, break_type)) + record_id = cursor.lastrowid + conn.commit() + return record_id def add_meal_record(self, date: str, start_time: str, end_time: str, meal_type: str = 'lunch') -> int: @@ -1310,73 +1273,72 @@ class Database: rec = self.get_today_record() if date == _dt.now().date().isoformat() else None wid = rec['id'] if rec else None - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO break_records (work_record_id, date, break_out, break_in, - total_minutes, break_type) - VALUES (?, ?, ?, ?, ?, ?) - ''', (wid, date, start_time, end_time, total_min, meal_type)) - rid = cursor.lastrowid - conn.commit() - conn.close() - return rid + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO break_records (work_record_id, date, break_out, break_in, + total_minutes, break_type) + VALUES (?, ?, ?, ?, ?, ?) + ''', (wid, date, start_time, end_time, total_min, meal_type)) + rid = cursor.lastrowid + conn.commit() + return rid def get_meal_minutes_today(self, meal_type: str = 'lunch') -> int: """오늘의 식사 시간 합계 (분). 수동 입력된 경우만.""" from datetime import date as _date today = _date.today().isoformat() - conn = self.get_connection() - cursor = conn.cursor() - cursor.execute(''' - SELECT COALESCE(SUM(total_minutes), 0) FROM break_records - WHERE date = ? AND break_type = ? AND total_minutes IS NOT NULL - ''', (today, meal_type)) - result = cursor.fetchone()[0] or 0 - conn.close() - return int(result) + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT COALESCE(SUM(total_minutes), 0) FROM break_records + WHERE date = ? AND break_type = ? AND total_minutes IS NOT NULL + ''', (today, meal_type)) + return int(cursor.fetchone()[0] or 0) def update_break_return(self, break_id: int, break_in: str): - """외출 복귀 시간 업데이트""" - conn = self.get_connection() - cursor = conn.cursor() + """외출 복귀 시간 업데이트. - # 복귀 시간 업데이트 - cursor.execute(''' - UPDATE break_records - SET break_in = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (break_in, break_id)) + 2 step 처리: ① 복귀시각 저장, ② 총 외출시간 계산해서 갱신. + 예외 발생 시 부분 변경 방지를 위해 명시적 try/except + rollback. + """ + with self._conn() as conn: + cursor = conn.cursor() + try: + cursor.execute(''' + UPDATE break_records + SET break_in = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_in, break_id)) - # 총 외출 시간 계산 - cursor.execute(''' - SELECT break_out, break_in FROM break_records WHERE id = ? - ''', (break_id,)) - row = cursor.fetchone() + cursor.execute(''' + SELECT break_out, break_in FROM break_records WHERE id = ? + ''', (break_id,)) + row = cursor.fetchone() - if row and row['break_out'] and row['break_in']: - from datetime import datetime, timedelta - break_out_time = datetime.strptime(row['break_out'], "%H:%M:%S") - break_in_time = datetime.strptime(row['break_in'], "%H:%M:%S") + if row and row['break_out'] and row['break_in']: + from datetime import datetime, timedelta + break_out_time = datetime.strptime(row['break_out'], "%H:%M:%S") + break_in_time = datetime.strptime(row['break_in'], "%H:%M:%S") - # 복귀 시간이 외출 시간보다 이전이면 자정을 넘긴 것으로 판단 - if break_in_time < break_out_time: - break_in_time += timedelta(days=1) # 복귀는 다음 날로 처리 + # 복귀가 외출보다 이전이면 자정 넘긴 것 + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) - total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + if total_minutes < 0: + total_minutes = 0 - # 음수 방지 (혹시 모를 케이스) - if total_minutes < 0: - total_minutes = 0 + cursor.execute(''' + UPDATE break_records + SET total_minutes = ? + WHERE id = ? + ''', (total_minutes, break_id)) - cursor.execute(''' - UPDATE break_records - SET total_minutes = ? - WHERE id = ? - ''', (total_minutes, break_id)) - - conn.commit() - conn.close() + conn.commit() + except Exception: + conn.rollback() + raise def get_today_break_records(self) -> List[Dict]: """오늘의 외출 기록 조회""" @@ -1386,19 +1348,14 @@ class Database: def get_break_records_by_date(self, date: str) -> List[Dict]: """특정 날짜의 외출 기록 조회""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM break_records - WHERE date = ? - ORDER BY break_out ASC - ''', (date,)) - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM break_records + WHERE date = ? + ORDER BY break_out ASC + ''', (date,)) + return [dict(row) for row in cursor.fetchall()] def get_active_break_record(self, target_date: str = None) -> Optional[Dict]: """현재 진행 중인 외출 기록 조회 (복귀하지 않은 외출) @@ -1409,119 +1366,90 @@ class Database: from datetime import date if target_date is None: target_date = date.today().isoformat() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM break_records + WHERE date = ? AND break_in IS NULL + ORDER BY break_out DESC + LIMIT 1 + ''', (target_date,)) + row = cursor.fetchone() + return dict(row) if row else None - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM break_records - WHERE date = ? AND break_in IS NULL - ORDER BY break_out DESC - LIMIT 1 - ''', (target_date,)) - - row = cursor.fetchone() - conn.close() - - if row: - return dict(row) - return None - - def update_break_record(self, break_id: int, break_out: str, break_in: str = None, reason: str = None): + def update_break_record(self, break_id: int, break_out: str, break_in: str = None, + reason: str = None): """외출 기록 수정""" - conn = self.get_connection() - cursor = conn.cursor() - - if break_in: - # 총 외출 시간 계산 - from datetime import datetime, timedelta - break_out_time = datetime.strptime(break_out, "%H:%M:%S") - break_in_time = datetime.strptime(break_in, "%H:%M:%S") - - # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 - if break_in_time < break_out_time: - break_in_time += timedelta(days=1) - - total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) - - # 음수 방지 (혹시 모를 케이스) - if total_minutes < 0: - total_minutes = 0 - - cursor.execute(''' - UPDATE break_records - SET break_out = ?, break_in = ?, total_minutes = ?, reason = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (break_out, break_in, total_minutes, reason, break_id)) - else: - cursor.execute(''' - UPDATE break_records - SET break_out = ?, reason = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - ''', (break_out, reason, break_id)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + if break_in: + from datetime import datetime, timedelta + break_out_time = datetime.strptime(break_out, "%H:%M:%S") + break_in_time = datetime.strptime(break_in, "%H:%M:%S") + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) + total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + if total_minutes < 0: + total_minutes = 0 + cursor.execute(''' + UPDATE break_records + SET break_out = ?, break_in = ?, total_minutes = ?, reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_out, break_in, total_minutes, reason, break_id)) + else: + cursor.execute(''' + UPDATE break_records + SET break_out = ?, reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_out, reason, break_id)) + conn.commit() def delete_break_record(self, break_id: int): """외출 기록 삭제""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,)) + conn.commit() def get_total_break_minutes_today(self) -> int: """오늘의 총 외출 시간 (분), 진행 중인 외출 포함""" from datetime import date, datetime today = date.today().isoformat() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT SUM(total_minutes) FROM break_records + WHERE date = ? AND total_minutes IS NOT NULL + ''', (today,)) + total = cursor.fetchone()[0] or 0 - conn = self.get_connection() - cursor = conn.cursor() + cursor.execute(''' + SELECT break_out FROM break_records + WHERE date = ? AND break_in IS NULL + ORDER BY break_out DESC + LIMIT 1 + ''', (today,)) + active_break = cursor.fetchone() - # 완료된 외출 시간 합계 - cursor.execute(''' - SELECT SUM(total_minutes) FROM break_records - WHERE date = ? AND total_minutes IS NOT NULL - ''', (today,)) - - total = cursor.fetchone()[0] or 0 - - # 진행 중인 외출 시간 계산 - cursor.execute(''' - SELECT break_out FROM break_records - WHERE date = ? AND break_in IS NULL - ORDER BY break_out DESC - LIMIT 1 - ''', (today,)) - - active_break = cursor.fetchone() if active_break: break_out_str = active_break[0] now = datetime.now() - break_out_time = datetime.strptime(f"{today} {break_out_str}", "%Y-%m-%d %H:%M:%S") + break_out_time = datetime.strptime(f"{today} {break_out_str}", + "%Y-%m-%d %H:%M:%S") active_minutes = int((now - break_out_time).total_seconds() / 60) total += active_minutes - - conn.close() - return total def update_work_memo(self, date: str, memo: str): """근무 기록 메모 업데이트""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - UPDATE work_records - SET memo = ?, updated_at = CURRENT_TIMESTAMP - WHERE date = ? - ''', (memo, date)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE work_records + SET memo = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (memo, date)) + conn.commit() def get_leave_balance(self) -> float: """연차 잔여 개수 조회 (총 연차 - 초기 사용량 - 프로그램 기록 사용량)""" @@ -1545,16 +1473,13 @@ class Database: def set_leave_balance(self, balance: float): """연차 잔여 개수 설정""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - INSERT OR REPLACE INTO settings (key, value, updated_at) - VALUES ('leave_balance', ?, CURRENT_TIMESTAMP) - ''', (str(balance),)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('leave_balance', ?, CURRENT_TIMESTAMP) + ''', (str(balance),)) + conn.commit() def use_leave(self, days: float, date: str, leave_type: str = "연차", memo: str = None): """연차 사용 @@ -1567,23 +1492,17 @@ class Database: 예: 1.0 = 1일, 0.5 = 반차, 0.125 = 1시간(8분의 1일) """ current_balance = self.get_leave_balance() - if current_balance < days: raise ValueError(f"연차 잔여 개수가 부족합니다. (잔여: {current_balance}일)") - conn = self.get_connection() - cursor = conn.cursor() - - # 연차 기록 추가 - cursor.execute(''' - INSERT INTO leave_records (date, leave_type, days, memo) - VALUES (?, ?, ?, ?) - ''', (date, leave_type, days, memo)) - - conn.commit() - conn.close() - - # 잔여 개수 차감 + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO leave_records (date, leave_type, days, memo) + VALUES (?, ?, ?, ?) + ''', (date, leave_type, days, memo)) + conn.commit() + # 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit) self.set_leave_balance(current_balance - days) # ===== 공휴일 관련 메서드 ===== @@ -1599,122 +1518,62 @@ class Database: Returns: int: 추가된 공휴일 ID """ - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - INSERT OR REPLACE INTO holidays (date, name, is_recurring, created_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (date, name, is_recurring)) - - holiday_id = cursor.lastrowid - conn.commit() - conn.close() - - return holiday_id + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO holidays (date, name, is_recurring, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ''', (date, name, is_recurring)) + holiday_id = cursor.lastrowid + conn.commit() + return holiday_id def delete_holiday(self, holiday_id: int): """공휴일 삭제""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute('DELETE FROM holidays WHERE id = ?', (holiday_id,)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM holidays WHERE id = ?', (holiday_id,)) + conn.commit() def delete_holiday_by_date(self, date: str): """날짜로 공휴일 삭제""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute('DELETE FROM holidays WHERE date = ?', (date,)) - - conn.commit() - conn.close() + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM holidays WHERE date = ?', (date,)) + conn.commit() def is_holiday(self, date: str) -> bool: - """해당 날짜가 공휴일인지 확인 - - Args: - date: 확인할 날짜 (YYYY-MM-DD) - - Returns: - bool: 공휴일이면 True - """ - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT id FROM holidays WHERE date = ? - ''', (date,)) - - result = cursor.fetchone() - conn.close() - - return result is not None + """해당 날짜가 공휴일인지 확인.""" + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id FROM holidays WHERE date = ?', (date,)) + return cursor.fetchone() is not None def get_holiday(self, date: str) -> Optional[Dict]: - """해당 날짜의 공휴일 정보 조회 - - Args: - date: 조회할 날짜 (YYYY-MM-DD) - - Returns: - Dict: 공휴일 정보 또는 None - """ - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM holidays WHERE date = ? - ''', (date,)) - - row = cursor.fetchone() - conn.close() - - if row: - return dict(row) - return None + """해당 날짜의 공휴일 정보 조회.""" + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM holidays WHERE date = ?', (date,)) + row = cursor.fetchone() + return dict(row) if row else None def get_holidays_by_year(self, year: int) -> List[Dict]: - """해당 연도의 공휴일 목록 조회 - - Args: - year: 조회할 연도 - - Returns: - List[Dict]: 공휴일 목록 - """ - conn = self.get_connection() - cursor = conn.cursor() - - # LIKE 대신 정확한 날짜 범위 비교 사용 (더 효율적) - cursor.execute(''' - SELECT * FROM holidays - WHERE date >= ? AND date < ? - ORDER BY date ASC - ''', (f"{year}-01-01", f"{year + 1}-01-01")) - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + """해당 연도의 공휴일 목록 조회.""" + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM holidays + WHERE date >= ? AND date < ? + ORDER BY date ASC + ''', (f"{year}-01-01", f"{year + 1}-01-01")) + return [dict(row) for row in cursor.fetchall()] def get_all_holidays(self) -> List[Dict]: """모든 공휴일 목록 조회""" - conn = self.get_connection() - cursor = conn.cursor() - - cursor.execute(''' - SELECT * FROM holidays - ORDER BY date ASC - ''') - - rows = cursor.fetchall() - conn.close() - - return [dict(row) for row in rows] + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM holidays ORDER BY date ASC') + return [dict(row) for row in cursor.fetchall()] def add_korean_holidays(self, year: int): """한국 공휴일 일괄 추가 (고정 공휴일만) @@ -1740,9 +1599,28 @@ class Database: for date, name in fixed_holidays: self.add_holiday(date, name, is_recurring=True) - def add_korean_holidays_auto(self, year: int) -> int: + def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int: """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. + Timezone 주의: + `holidays` 패키지는 한국 캘린더 날짜를 timezone-naive `date` 객체로 반환. + timezone 영향 없음 — 양력/음력 모두 절기 기준이라 timezone과 무관하게 + 동일한 캘린더 날짜. + + 호출자(settings_view 등)는 보통 `datetime.now().year`을 전달하는데, + 이는 시스템 로컬 시각의 연도 — 한국 사용자가 KST로 설정된 경우 정확. + 만약 시스템 시계가 UTC로 설정된 드문 경우, KST 기준 1/1 직후 ~ 9시 전엔 + UTC 연도가 전년도일 수 있음. 그래도 함수 자체가 `is_holiday`로 중복 가드 + 하므로 데이터 손상 없이 단지 한 번 더 등록 시도할 뿐. + + 연말 경계: + 12월 말에 호출 시 다음 연도 1/1 신정·설날을 미리 등록해두려면 + include_next_year=True 권장. + + Args: + year: 등록할 연도 (보통 현재 로컬 연도) + include_next_year: True면 year + (year+1) 둘 다 등록 + Returns: 추가된 공휴일 개수. 패키지 미설치 시 -1. """ @@ -1751,14 +1629,21 @@ class Database: except ImportError: return -1 - kr = _holidays.country_holidays('KR', years=year) + years_to_add = [year] + if include_next_year: + years_to_add.append(year + 1) + added = 0 - for d, name in kr.items(): - date_str = d.isoformat() - # 이미 등록된 동일 날짜는 스킵 (중복 방지) - if not self.is_holiday(date_str): - self.add_holiday(date_str, name, is_recurring=False) - added += 1 + for y in years_to_add: + try: + kr = _holidays.country_holidays('KR', years=y) + except Exception: + continue # 패키지 내부 오류는 해당 연도만 스킵 + for d, name in kr.items(): + date_str = d.isoformat() + if not self.is_holiday(date_str): + self.add_holiday(date_str, name, is_recurring=False) + added += 1 return added def copy_recurring_holidays(self, from_year: int, to_year: int): diff --git a/core/i18n.py b/core/i18n.py index 3cb56d7..3b1f168 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -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': '💰 연장근무 적립 알림', @@ -321,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', diff --git a/core/notifier.py b/core/notifier.py index 44bb2df..7514ec0 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -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 diff --git a/core/settings_keys.py b/core/settings_keys.py index 396a7a7..dea3c84 100644 --- a/core/settings_keys.py +++ b/core/settings_keys.py @@ -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' diff --git a/core/version.py b/core/version.py index 0ae8f25..16f5c67 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.7.0' +__version__ = '2.8.0' diff --git a/main.py b/main.py index 8ec7405..5441e4a 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/tests/test_discord_webhook.py b/tests/test_discord_webhook.py index f79816a..c570022 100644 --- a/tests/test_discord_webhook.py +++ b/tests/test_discord_webhook.py @@ -39,7 +39,7 @@ class TestSendNetwork: resp.__exit__.return_value = False mock_urlopen.return_value = resp - ok = send('https://discord.com/api/webhooks/123/abc', 'title', 'desc') + ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc') assert ok is True # User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회) @@ -55,18 +55,18 @@ class TestSendNetwork: resp.__exit__.return_value = False mock_urlopen.return_value = resp - assert send('https://discord.com/api/webhooks/x', 't', 'd') is False + 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/x', 't', 'd') is False + 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/x', 't', 'd') is False + assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False class TestPayloadShape: @@ -79,7 +79,7 @@ class TestPayloadShape: resp.__exit__.return_value = False mock_urlopen.return_value = resp - send('https://discord.com/api/webhooks/x', 'TITLE', 'DESC', + 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')) diff --git a/ui/achievements_view.py b/ui/achievements_view.py new file mode 100644 index 0000000..9662317 --- /dev/null +++ b/ui/achievements_view.py @@ -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"{stats['earned']}" + f" / {stats['total']}") + 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"
" + f"🌑 시크릿
" + f"" + f"{stats['secret_earned']}" + f" / {stats['secret_total']}" + f"
" + ) + secret_lbl.setTextFormat(Qt.RichText) + num_row.addWidget(secret_lbl) + + num_row.addStretch() + + pct_lbl = QLabel( + f"
" + f"달성률
" + f"" + f"{pct:.1f}%
" + ) + 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; + } + """ diff --git a/ui/chart_widget.py b/ui/chart_widget.py index 7c12e05..1ca3a09 100644 --- a/ui/chart_widget.py +++ b/ui/chart_widget.py @@ -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() diff --git a/ui/controllers/lock_monitor.py b/ui/controllers/lock_monitor.py index 244ba59..b0812ef 100644 --- a/ui/controllers/lock_monitor.py +++ b/ui/controllers/lock_monitor.py @@ -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 diff --git a/ui/controllers/notification_orchestrator.py b/ui/controllers/notification_orchestrator.py index 1c164e5..3e18b3b 100644 --- a/ui/controllers/notification_orchestrator.py +++ b/ui/controllers/notification_orchestrator.py @@ -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}") diff --git a/ui/dark_components.py b/ui/dark_components.py new file mode 100644 index 0000000..a28483c --- /dev/null +++ b/ui/dark_components.py @@ -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"" + f"{big_value}" + (f"" + f" {subtitle}" 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"" + f"{value}" + ) + 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 diff --git a/ui/help_view.py b/ui/help_view.py index 2b10fee..f9080fb 100644 --- a/ui/help_view.py +++ b/ui/help_view.py @@ -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""" + + """ + return css + html + # 단독 실행 테스트 if __name__ == "__main__": diff --git a/ui/main_window.py b/ui/main_window.py index 8ed8f8d..d77cc00 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -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,6 +90,14 @@ class MainWindow(QMainWindow): self.notifier = Notifier(self, db=self.db) self.notifier.notification_signal.connect(self.show_notification) + # 도전과제 정의 동기화 (실패는 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 @@ -136,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() @@ -329,6 +375,7 @@ class MainWindow(QMainWindow): stats_button = QPushButton(tr('menu.stats')) calendar_button = QPushButton(tr('menu.calendar')) report_button = QPushButton(tr('menu.daily_report')) + achievements_button = QPushButton("🏆 도전과제") help_button = QPushButton(tr('menu.help')) settings_button = QPushButton(tr('menu.settings')) @@ -340,13 +387,15 @@ class MainWindow(QMainWindow): (settings_button, 'menu.settings')]: register(btn, key) - for btn in [stats_button, calendar_button, report_button, help_button, settings_button]: + for btn in [stats_button, calendar_button, report_button, + 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) @@ -1130,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( @@ -1619,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_() @@ -1652,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 @@ -1669,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 @@ -1706,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 = "" @@ -1726,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, @@ -1745,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': @@ -1761,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면 새 버전 있을 때만 알림 (시작 시 자동 체크용).""" @@ -2020,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() # 오늘의 근무 기록 조회 @@ -2063,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']}") @@ -2080,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: diff --git a/ui/meal_time_dialog.py b/ui/meal_time_dialog.py index ff15439..0d93605 100644 --- a/ui/meal_time_dialog.py +++ b/ui/meal_time_dialog.py @@ -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 diff --git a/ui/onboarding_view.py b/ui/onboarding_view.py index 8f87515..5a1f367 100644 --- a/ui/onboarding_view.py +++ b/ui/onboarding_view.py @@ -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() diff --git a/ui/settings_view.py b/ui/settings_view.py index 176e3bb..f181319 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -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'): @@ -1243,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, "내보내기 완료", diff --git a/ui/stats_view.py b/ui/stats_view.py index 862d773..35173bc 100644 --- a/ui/stats_view.py +++ b/ui/stats_view.py @@ -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"" + f"{value}" + ) + 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'): diff --git a/ui/today_summary.py b/ui/today_summary.py index 81724cb..a6af2c7 100644 --- a/ui/today_summary.py +++ b/ui/today_summary.py @@ -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: diff --git a/updater.py b/updater.py index 92e17bc..8333dd8 100644 --- a/updater.py +++ b/updater.py @@ -62,30 +62,70 @@ 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: + print(f"[updater] old backup unlink failed (continuing): {e}", + file=sys.stderr) + + # 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) + print(f"[updater] target move attempt {attempt+1}/{max_retries} " + f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr) + time.sleep(wait) + else: + # 모든 재시도 실패 + print(f"[updater] target move failed after {max_retries} attempts: {last_err}", + file=sys.stderr) 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) + print(f"[updater] new move attempt {attempt+1}/{max_retries} " + f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr) + time.sleep(wait) + + # new 이동 실패 → backup으로 롤백 시도 + print(f"[updater] new move failed after {max_retries} attempts: {last_err}", + file=sys.stderr) + if backup.exists() and not target_path.exists(): + try: + shutil.move(str(backup), str(target_path)) + print("[updater] rolled back from backup", file=sys.stderr) + except OSError as e: + print(f"[updater] rollback also failed: {e}", file=sys.stderr) + return None + def launch(exe_path: Path) -> bool: """새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기.""" @@ -120,7 +160,8 @@ def main() -> int: print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr) return 3 - # Windows 파일 핸들 해제 시간 여유 + # Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음. + # 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장. time.sleep(0.5) backup = replace_file(new_exe, target_exe) diff --git a/utils/backup.py b/utils/backup.py index 702ed91..833a969 100644 --- a/utils/backup.py +++ b/utils/backup.py @@ -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}") diff --git a/utils/crash_handler.py b/utils/crash_handler.py index 40f9b5f..fd02691 100644 --- a/utils/crash_handler.py +++ b/utils/crash_handler.py @@ -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: diff --git a/utils/csv_exporter.py b/utils/csv_exporter.py index 07acafa..4149415 100644 --- a/utils/csv_exporter.py +++ b/utils/csv_exporter.py @@ -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: """ diff --git a/utils/csv_importer.py b/utils/csv_importer.py index 398de55..4aa9556 100644 --- a/utils/csv_importer.py +++ b/utils/csv_importer.py @@ -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']) diff --git a/utils/discord_webhook.py b/utils/discord_webhook.py index 72835a2..d272484 100644 --- a/utils/discord_webhook.py +++ b/utils/discord_webhook.py @@ -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 = {