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>
177 lines
7.6 KiB
Python
177 lines
7.6 KiB
Python
"""
|
|
점심/저녁 실제 시간 입력 다이얼로그.
|
|
|
|
기본 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
|
|
|
|
from ui.styles import apply_dark_titlebar
|
|
|
|
|
|
class MealTimeDialog(QDialog):
|
|
"""점심/저녁 실제 시작·종료 시간 입력.
|
|
|
|
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(380, 260)
|
|
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(10)
|
|
layout.setContentsMargins(20, 16, 20, 16)
|
|
|
|
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")
|
|
self.start_edit.setTime(QTime(default_start_h, 0))
|
|
start_row.addWidget(self.start_edit)
|
|
start_row.addStretch()
|
|
layout.addLayout(start_row)
|
|
|
|
# 종료
|
|
end_row = QHBoxLayout()
|
|
end_row.addWidget(QLabel("종료:"))
|
|
self.end_edit = QTimeEdit()
|
|
self.end_edit.setDisplayFormat("HH:mm")
|
|
self.end_edit.setTime(QTime(default_end_h, 0))
|
|
end_row.addWidget(self.end_edit)
|
|
end_row.addStretch()
|
|
layout.addLayout(end_row)
|
|
|
|
# 미리보기 라벨
|
|
self.preview = QLabel("")
|
|
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
|
|
layout.addWidget(self.preview)
|
|
self._update_preview()
|
|
self.start_edit.timeChanged.connect(self._update_preview)
|
|
self.end_edit.timeChanged.connect(self._update_preview)
|
|
|
|
# 버튼
|
|
btn_row = QHBoxLayout()
|
|
btn_row.addStretch()
|
|
ok_btn = QPushButton("저장")
|
|
ok_btn.setObjectName("btn_primary")
|
|
ok_btn.clicked.connect(self.accept)
|
|
cancel_btn = QPushButton("취소")
|
|
cancel_btn.clicked.connect(self.reject)
|
|
btn_row.addWidget(ok_btn)
|
|
btn_row.addWidget(cancel_btn)
|
|
layout.addLayout(btn_row)
|
|
|
|
self.setLayout(layout)
|
|
apply_dark_titlebar(self)
|
|
|
|
# -------- 내부 시각 계산 --------
|
|
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)
|
|
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"
|
|
_, _, minutes = self._resolve_meal_window()
|
|
return start_str, end_str, minutes
|