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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:11:13 +09:00

361 lines
14 KiB
Python

"""
첫 실행 온보딩 위저드.
5단계: 환영 → 근무패턴 → 출근감지 → 연차/시급 → Discord(옵션) → 완료
첫 실행 시 강제 표시. 완료 시 ONBOARDING_COMPLETED=true 저장.
재방문은 메인 메뉴 → "온보딩 다시 보기"로 가능.
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
QLabel, QRadioButton, QButtonGroup, QSpinBox,
QCheckBox, QLineEdit, QComboBox, QPushButton,
QMessageBox, QGroupBox)
from PyQt5.QtCore import Qt
from core.i18n import tr
from ui.styles import apply_dark_titlebar
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
WORK_PRESETS = [
('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),
]
class WelcomePage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("👋 환영합니다!")
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
layout = QVBoxLayout()
intro = QLabel(
"이 앱은:\n"
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
"• 30분 단위 연장근무 적립\n"
"• 연차·반차·외출 시간 추적\n"
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
"[다음] 버튼을 눌러 시작하세요."
)
intro.setWordWrap(True)
layout.addWidget(intro)
self.setLayout(layout)
class WorkPatternPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🕘 근무 패턴")
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
layout = QVBoxLayout()
self.button_group = QButtonGroup(self)
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(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(suffix_h)
self.minutes_spin = QSpinBox()
self.minutes_spin.setRange(0, 59)
self.minutes_spin.setSingleStep(15)
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(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, 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):
def __init__(self):
super().__init__()
self.setTitle("⏰ 출근 시간 감지 방식")
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
layout = QVBoxLayout()
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
self.option_boot.setChecked(True)
for opt in (self.option_boot, self.option_unlock, self.option_manual):
layout.addWidget(opt)
info = QLabel(
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
self.setLayout(layout)
def detection_mode(self) -> str:
if self.option_unlock.isChecked():
return 'unlock'
if self.option_manual.isChecked():
return 'manual'
return 'boot'
class LeaveSalaryPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
layout = QVBoxLayout()
# 연차
leave_box = QGroupBox("연간 연차")
leave_layout = QHBoxLayout()
self.leave_spin = QSpinBox()
self.leave_spin.setRange(0, 30)
self.leave_spin.setValue(15)
self.leave_spin.setSuffix("")
leave_layout.addWidget(QLabel("내 연차:"))
leave_layout.addWidget(self.leave_spin)
leave_layout.addStretch()
leave_box.setLayout(leave_layout)
layout.addWidget(leave_box)
# 급여 (옵션)
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
salary_layout = QVBoxLayout()
self.salary_enabled = QCheckBox("급여 추정 활성화")
salary_layout.addWidget(self.salary_enabled)
wage_row = QHBoxLayout()
wage_row.addWidget(QLabel("시급:"))
self.wage_spin = QSpinBox()
self.wage_spin.setRange(0, 1000000)
self.wage_spin.setSingleStep(1000)
self.wage_spin.setSuffix(" 원/시간")
self.wage_spin.setValue(0)
self.wage_spin.setEnabled(False)
wage_row.addWidget(self.wage_spin)
wage_row.addStretch()
salary_layout.addLayout(wage_row)
rate_row = QHBoxLayout()
rate_row.addWidget(QLabel("연장수당 가산률:"))
self.rate_combo = QComboBox()
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
self.rate_combo.setCurrentIndex(1)
self.rate_combo.setEnabled(False)
rate_row.addWidget(self.rate_combo)
rate_row.addStretch()
salary_layout.addLayout(rate_row)
self.salary_enabled.toggled.connect(self.wage_spin.setEnabled)
self.salary_enabled.toggled.connect(self.rate_combo.setEnabled)
salary_box.setLayout(salary_layout)
layout.addWidget(salary_box)
layout.addStretch()
self.setLayout(layout)
class DiscordPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("💬 Discord 알림 (선택)")
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
layout = QVBoxLayout()
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
layout.addWidget(self.enable_check)
self.url_edit = QLineEdit()
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
self.url_edit.setEnabled(False)
layout.addWidget(self.url_edit)
guide = QLabel(
"셋업 방법:\n"
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
guide.setStyleSheet("color: #888; padding: 6px;")
guide.setWordWrap(True)
layout.addWidget(guide)
test_row = QHBoxLayout()
self.test_btn = QPushButton("테스트 메시지 보내기")
self.test_btn.setEnabled(False)
self.test_btn.clicked.connect(self._test_webhook)
test_row.addWidget(self.test_btn)
test_row.addStretch()
layout.addLayout(test_row)
self.enable_check.toggled.connect(self.url_edit.setEnabled)
self.enable_check.toggled.connect(self.test_btn.setEnabled)
layout.addStretch()
self.setLayout(layout)
def _test_webhook(self):
url = self.url_edit.text().strip()
if not url:
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 채널에서 테스트 메시지를 확인하세요.")
else:
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
class FinishPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🎉 준비 완료!")
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
layout = QVBoxLayout()
msg = QLabel(
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
"🕐 단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
" • Ctrl+, — 설정"
)
msg.setWordWrap(True)
layout.addWidget(msg)
self.setLayout(layout)
class OnboardingWizard(QWizard):
"""5단계 첫 실행 위저드. accept() 시 모든 설정 저장 + ONBOARDING_COMPLETED=true."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("Clock-out Calculator — 시작 설정")
self.setMinimumSize(600, 500)
self.setWizardStyle(QWizard.ModernStyle)
self.setOption(QWizard.NoBackButtonOnStartPage, True)
self.welcome_page = WelcomePage()
self.work_page = WorkPatternPage()
self.detect_page = ClockInDetectionPage()
self.leave_page = LeaveSalaryPage()
self.discord_page = DiscordPage()
self.finish_page = FinishPage()
for page in (self.welcome_page, self.work_page, self.detect_page,
self.leave_page, self.discord_page, self.finish_page):
self.addPage(page)
apply_dark_titlebar(self)
def accept(self):
# 1. 근무 패턴
wm, lm, dm = self.work_page.selected_minutes()
if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
return
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(),
'hourly_wage': self.leave_page.wage_spin.value(),
'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()
settings['clock_in_on_unlock'] = (mode == 'unlock')
# 3. Discord 웹훅 (옵션)
if self.discord_page.enable_check.isChecked():
settings['discord_webhook_url'] = self.discord_page.url_edit.text().strip()
self.db.save_settings(settings)
super().accept()
def maybe_show_onboarding(db, parent=None) -> bool:
"""ONBOARDING_COMPLETED=false 일 때만 위저드 표시. 사용자가 끝까지 완료하면 True."""
if db.get_setting('onboarding_completed', 'false').lower() == 'true':
return False # 이미 완료
wizard = OnboardingWizard(db, parent)
return wizard.exec_() == QWizard.Accepted