v2.8.0: 도전과제 시스템 + 다크 디자인 리뉴얼 + 안정성 강화

Added — 도전과제 시스템 (153개 자동 평가)
- core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제
- ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿)
- 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push
- achievements 테이블 확장 (code/category/tier/is_secret/progress/target)
- hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
68893236+KINDNICK@users.noreply.github.com
2026-05-01 01:11:13 +09:00
co-authored by Claude Opus 4.7
parent ff71886fd7
commit c5df37ca57
28 changed files with 4247 additions and 1313 deletions
+6 -1
View File
@@ -17,12 +17,17 @@ class LockMonitor:
self.window = window
self.db = window.db
self.last_locked: bool = False
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
def tick(self) -> None:
try:
from utils.lock_detector import is_screen_locked
locked = is_screen_locked()
except Exception:
except Exception as e:
if not self._detector_failed_once:
self._detector_failed_once = True
from utils.debug_log import dlog
dlog(f"lock detector failed (silenced after first): {e}")
return
was_locked = self.last_locked
+81 -9
View File
@@ -2,11 +2,24 @@
알림 오케스트레이션.
5분 가드로 건강/주간/누적 임계 알림을 throttle.
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
notifier.py의 알림 메서드를 적절한 시점에 호출.
"""
from __future__ import annotations
from datetime import datetime
from core.settings_keys import (
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
)
from utils.debug_log import dlog
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
try:
v = int(db.get_setting(key, str(default)) or default)
except (ValueError, TypeError):
v = default
return max(lo, min(hi, v))
class NotificationOrchestrator:
"""update_display() 1Hz tick에서 호출."""
@@ -66,19 +79,23 @@ class NotificationOrchestrator:
f"기간: {last_mon} ~ {last_sun}",
color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception:
pass
except Exception as e:
dlog(f"discord weekly_report failed: {e}")
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
n.check_clock_out_soon(expected_clock_out, now)
n.check_lunch_reminder(self.window.clock_in_time,
self.window.lunch_break_enabled, now)
n.check_dinner_reminder(self.window.clock_in_time,
getattr(self.window, 'dinner_break_enabled', False), now)
if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute
@@ -89,18 +106,73 @@ class NotificationOrchestrator:
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3:
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
if consecutive >= consecutive_th:
n.notify_health_warning(consecutive)
self._discord_health(consecutive, break_minutes, now)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52:
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
if weekly_hours > weekly_th:
n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200:
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
if balance_minutes / 60.0 >= ot_threshold_h:
n.notify_overtime_threshold(balance_minutes / 60.0)
# 도전과제 평가 (5분 throttle)
self._evaluate_achievements(now)
def _evaluate_achievements(self, now: datetime) -> None:
"""도전과제 평가 + 신규 잠금 해제 알림.
실패는 silent — 도전과제 시스템이 메인 흐름을 막으면 안 됨.
"""
try:
from core.achievements import evaluate_all
unlocked = evaluate_all(self.db)
except Exception as e:
dlog(f"achievement eval failed: {e}")
return
if not unlocked:
return
# 시스템 알림 + Discord push (옵션)
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
for a in unlocked:
self.db.log_notification('system', f'achievement:{a.code}')
if notif_on:
title = f"{a.badge_icon} 도전과제 달성!"
body = f"{a.name}\n{a.description}"
self.notifier.notification_signal.emit(title, body)
# Discord 통합 push (여러 개면 묶어서)
self._discord_achievements(unlocked)
def _discord_achievements(self, unlocked: list) -> None:
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
return
url = self.db.get_setting('discord_webhook_url', '') or ''
if not url:
return
try:
from utils import discord_webhook
fields = [{"name": f"{a.badge_icon} {a.name}",
"value": a.description, "inline": False}
for a in unlocked[:10]]
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
ok = discord_webhook.send(
url,
f"🏆 도전과제 {len(unlocked)}개 달성!",
f"새로 잠금 해제된 도전과제 입니다.{extra}",
color=discord_webhook.COLOR_YELLOW,
fields=fields,
)
self.db.log_notification('discord', 'achievement', success=ok)
except Exception as e:
dlog(f"discord achievement push failed: {e}")
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
"""건강 경고 Discord push (옵션)."""
if self.db.has_notification_today('discord', 'health'):
@@ -115,5 +187,5 @@ class NotificationOrchestrator:
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
ok = discord_webhook.send_health_warning(url, elapsed)
self.db.log_notification('discord', 'health', success=ok)
except Exception:
pass
except Exception as e:
dlog(f"discord health push failed: {e}")