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:
co-authored by
Claude Opus 4.7
parent
ff71886fd7
commit
c5df37ca57
File diff suppressed because it is too large
Load Diff
+875
-990
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
+58
-37
@@ -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
|
||||
|
||||
@@ -22,9 +22,17 @@ CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로
|
||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||
NOTIF_LUNCH = 'notification_lunch'
|
||||
NOTIF_DINNER = 'notification_dinner'
|
||||
NOTIF_OVERTIME = 'notification_overtime'
|
||||
NOTIF_HEALTH = 'notification_health'
|
||||
|
||||
# 알림 임계값 (설정화 — 이전엔 하드코딩이던 것)
|
||||
LUNCH_REMINDER_HOURS = 'lunch_reminder_hours' # 출근 후 N시간 경과 시 점심 미등록 알림 (기본 4)
|
||||
DINNER_REMINDER_HOURS = 'dinner_reminder_hours' # 출근 후 N시간 경과 시 저녁 미등록 알림 (기본 8)
|
||||
OVERTIME_THRESHOLD_HOURS = 'overtime_threshold_hours' # 누적 적립 알림 시간 (기본 20)
|
||||
WEEKLY_HOURS_THRESHOLD = 'weekly_hours_threshold' # 주 X시간 경고 (기본 52, 한국 노동법)
|
||||
HEALTH_CONSECUTIVE_OT_DAYS = 'health_consecutive_ot_days' # 연속 연장근무 일수 경고 (기본 3)
|
||||
|
||||
# 연차
|
||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||
@@ -76,3 +84,22 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
|
||||
# 마이그레이션 sentinel
|
||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
||||
|
||||
# === v2.8.0 도전과제 시스템 ===
|
||||
# 사용자 메타
|
||||
BIRTHDAY = 'birthday' # MM-DD 형식, 빈 문자열이면 비활성
|
||||
HIRE_DATE = 'hire_date' # YYYY-MM-DD, 첫 work_records 자동 기록
|
||||
|
||||
# 뷰 진입 카운터 (도전과제 + 사용 통계용)
|
||||
STAT_WEEKLY_VIEW_COUNT = 'stat_weekly_view_count'
|
||||
STAT_MONTHLY_VIEW_COUNT = 'stat_monthly_view_count'
|
||||
STAT_PATTERN_VIEW_COUNT = 'stat_pattern_view_count'
|
||||
CALENDAR_VIEW_COUNT = 'calendar_view_count'
|
||||
LEAVE_CALENDAR_VIEW_COUNT = 'leave_calendar_view_count'
|
||||
DAILY_REPORT_COUNT = 'daily_report_count'
|
||||
ACHIEVEMENTS_VIEW_COUNT = 'achievements_view_count'
|
||||
CHART_HOVER_DISCOVERED = 'chart_hover_discovered'
|
||||
|
||||
# 도전과제 알림
|
||||
NOTIF_ACHIEVEMENT = 'notification_achievement'
|
||||
DISCORD_NOTIF_ACHIEVEMENT = 'discord_notif_achievement'
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.7.0'
|
||||
__version__ = '2.8.0'
|
||||
|
||||
Reference in New Issue
Block a user