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>
241 lines
8.7 KiB
Python
241 lines
8.7 KiB
Python
"""
|
|
matplotlib 기반 차트 위젯.
|
|
|
|
stats_view에서 주간/월간 추세를 시각화. matplotlib 미설치 시
|
|
ImportError 안내 라벨로 fallback.
|
|
"""
|
|
from typing import List, Tuple
|
|
|
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
|
from PyQt5.QtCore import Qt
|
|
|
|
try:
|
|
from matplotlib.figure import Figure
|
|
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
import matplotlib
|
|
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
|
matplotlib.rcParams['axes.unicode_minus'] = False
|
|
_MPL = True
|
|
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):
|
|
super().__init__()
|
|
layout = QVBoxLayout()
|
|
label = QLabel(message)
|
|
label.setAlignment(Qt.AlignCenter)
|
|
label.setWordWrap(True)
|
|
label.setStyleSheet("color: #888; padding: 20px;")
|
|
layout.addWidget(label)
|
|
self.setLayout(layout)
|
|
|
|
|
|
def make_chart_widget(parent=None) -> QWidget:
|
|
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
|
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, facecolor=_CHART_BG)
|
|
canvas = FigureCanvas(fig)
|
|
canvas.setStyleSheet(f"background: {_CHART_BG};")
|
|
layout.addWidget(canvas)
|
|
widget.setLayout(layout)
|
|
widget._figure = fig
|
|
widget._canvas = canvas
|
|
return widget
|
|
|
|
|
|
def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|
"""일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁)."""
|
|
if not getattr(widget, '_figure', None):
|
|
return
|
|
fig = widget._figure
|
|
fig.clear()
|
|
_apply_dark_figure(fig)
|
|
if not records:
|
|
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
|
|
|
|
dates = [r['date'][5:] for r in records] # MM-DD만
|
|
full_dates = [r['date'] for r in records]
|
|
hours = [r.get('total_hours', 0) or 0 for r in records]
|
|
overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records]
|
|
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=_CHART_BAR_NORMAL)
|
|
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
|
color=_CHART_BAR_OVERTIME)
|
|
ax.set_ylabel('시간')
|
|
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)
|
|
_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="#1a1a26", ec=_CHART_BAR_NORMAL,
|
|
alpha=0.95),
|
|
color="white", fontsize=9,
|
|
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
|
|
)
|
|
annot.set_visible(False)
|
|
|
|
def on_hover(event):
|
|
if event.inaxes != ax:
|
|
if annot.get_visible():
|
|
annot.set_visible(False)
|
|
widget._canvas.draw_idle()
|
|
return
|
|
for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')):
|
|
for i, bar in enumerate(bars):
|
|
if bar.contains(event)[0]:
|
|
h = hours[i]; ot = overtimes[i]
|
|
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
|
if ot > 0:
|
|
text += f"\n연장 +{ot:.1f}h"
|
|
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
|
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)
|
|
widget._canvas.draw_idle()
|
|
|
|
widget._canvas.mpl_connect("motion_notify_event", on_hover)
|
|
widget._canvas.draw()
|
|
|
|
|
|
def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|
"""출근 시각 분포 히스토그램 (30분 빈)."""
|
|
if not getattr(widget, '_figure', None):
|
|
return
|
|
fig = widget._figure
|
|
fig.clear()
|
|
_apply_dark_figure(fig)
|
|
if not records:
|
|
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
|
|
|
|
minutes_list = []
|
|
for r in records:
|
|
ci = r.get('clock_in')
|
|
if not ci:
|
|
continue
|
|
parts = ci.split(':')
|
|
if len(parts) >= 2:
|
|
try:
|
|
minutes_list.append(int(parts[0]) * 60 + int(parts[1]))
|
|
except ValueError:
|
|
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
|
|
|
|
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=_CHART_BAR_NORMAL,
|
|
edgecolor=_CHART_BG, linewidth=1)
|
|
avg = sum(minutes_list) / len(minutes_list)
|
|
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('일수')
|
|
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
|
_apply_dark_axes(ax)
|
|
widget._canvas.draw()
|
|
|
|
|
|
def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
|
"""요일별 평균 근무시간 막대 그래프."""
|
|
if not getattr(widget, '_figure', None):
|
|
return
|
|
fig = widget._figure
|
|
fig.clear()
|
|
_apply_dark_figure(fig)
|
|
|
|
from datetime import datetime as _dt
|
|
weekday_totals = [0.0] * 7
|
|
weekday_counts = [0] * 7
|
|
for r in records:
|
|
try:
|
|
d = _dt.strptime(r['date'], '%Y-%m-%d')
|
|
except (ValueError, TypeError):
|
|
continue
|
|
weekday_totals[d.weekday()] += r.get('total_hours', 0) or 0
|
|
weekday_counts[d.weekday()] += 1
|
|
|
|
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
|
labels = ['월', '화', '수', '목', '금', '토', '일']
|
|
|
|
ax = fig.add_subplot(111)
|
|
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
|
ax.bar(labels, avg, color=colors)
|
|
ax.set_ylabel('평균 시간')
|
|
_apply_dark_axes(ax)
|
|
widget._canvas.draw()
|