v2.5.0: Phase 3 — weekly report, chart hover, clock-in distribution, leave calendar

- Weekly auto report on Monday (system alert + Discord push, dedupe)
- Matplotlib chart hover annotation for daily hours
- Clock-in time distribution histogram (30-min bins) with avg line
- Leave usage calendar with color-coded full/half/quarter days

Fixed: leave_view setLayout indentation regression

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KINDNICK 2026-04-30 18:51:47 +09:00
parent 9ebf4ad961
commit 606da976a0
7 changed files with 294 additions and 11 deletions

View File

@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.5.0] — 2026-04-30
### Added — Phase 3 (4종)
- **주간 자동 리포트** — 월요일 첫 출근 시 (또는 첫 5분 tick) 지난주 요약 발송
- 시스템 알림 + Discord push (옵션) 동시
- `notification_log`로 중복 발송 방지
- 항목: 총 근무·일평균·연장근무·가장 긴 날
- **matplotlib 차트 호버 디테일** — 막대 위에 마우스 올리면 정확한 수치 툴팁
- 일별 근무 시간 차트(주간 탭)에 적용
- **출근 시각 분포 차트** — 패턴 분석 탭에 30분 단위 히스토그램
- 평균 출근 시각 빨간 점선으로 표시
- **휴가 캘린더 시각화** — 연차 관리 → "📅 캘린더 보기"
- 사용 일자에 종일/반차/반반차별 색상 표시
- 날짜 클릭 → 사용 내역 표시
### Fixed
- `leave_view.py` setLayout 들여쓰기 회귀 수정
## [2.4.0] — 2026-04-30
### Added — Phase 2 (5종)

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 .
"""
__version__ = '2.4.0'
__version__ = '2.5.0'

View File

@ -50,12 +50,7 @@ def make_chart_widget(parent=None) -> QWidget:
def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
"""일별 근무시간 막대 그래프.
Args:
widget: make_chart_widget() 만든 위젯
records: [{date, total_hours, overtime_minutes}, ...]
"""
"""일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁)."""
if not getattr(widget, '_figure', None):
return
fig = widget._figure
@ -67,17 +62,101 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
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)
ax.bar(dates, base, label='정상', color='#4a90e2')
ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
ax.set_ylabel('시간')
ax.legend(loc='upper left', fontsize=8)
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
ax.grid(axis='y', alpha=0.3)
# 호버 annotation 설정
annot = ax.annotate(
"", xy=(0, 0), xytext=(15, 15),
textcoords="offset points",
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
color="white", fontsize=9,
arrowprops=dict(arrowstyle="->", color="#888"),
)
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()
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()
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
widget._canvas.draw()
return
# 출근 시각을 분 단위로 (00:00=0)
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:
return
# 30분 빈
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='#4a90e2', edgecolor='white')
avg = sum(minutes_list) / len(minutes_list)
ax.axvline(avg, color='#ff6b6b', linestyle='--',
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('일수')
ax.set_title('출근 시각 분포')
ax.legend(loc='upper right', fontsize=8)
ax.grid(axis='y', alpha=0.3)
widget._canvas.draw()

View File

@ -17,6 +17,58 @@ class NotificationOrchestrator:
self.notifier = window.notifier
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
def maybe_send_weekly_report(self, now: datetime) -> None:
"""월요일 첫 update_display 호출 시 지난주 요약 발송 (시스템 + Discord).
notification_log로 중복 가드. 월요일이 아니거나 이미 보냈으면 no-op.
"""
if now.weekday() != 0: # 0=월요일
return
if self.db.has_notification_today('system', 'weekly_report'):
return
# 지난주 데이터 (월~일)
from datetime import timedelta as _td
last_mon = now.date() - _td(days=7)
last_sun = now.date() - _td(days=1)
records = self.db.get_work_records_by_range(last_mon.isoformat(), last_sun.isoformat())
closed = [r for r in records if r.get('clock_out')]
if not closed:
return # 지난주 기록 없음
total_h = sum((r.get('total_hours') or 0) for r in closed)
ot_total = sum((r.get('overtime_minutes') or 0) for r in closed)
ot_h, ot_m = ot_total // 60, ot_total % 60
avg_h = total_h / len(closed) if closed else 0
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
title = "📊 지난주 요약"
body = (f"기간: {last_mon} ~ {last_sun}\n"
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
f"일 평균: {avg_h:.1f}시간\n"
f"연장근무: {ot_h}시간 {ot_m}\n"
f"가장 긴 날: {longest_str}")
self.notifier.notification_signal.emit(title, body)
self.db.log_notification('system', 'weekly_report')
# Discord 도 옵션 활성 시 push
if self.db.get_setting('discord_notif_clock_out', 'true').lower() == 'true':
url = self.db.get_setting('discord_webhook_url', '') or ''
if url:
try:
from utils.discord_webhook import send, COLOR_BLUE
fields = [
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}", "inline": True},
{"name": "가장 긴 날", "value": longest_str, "inline": False},
]
ok = send(url, "📊 지난주 요약",
f"기간: {last_mon} ~ {last_sun}",
color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception:
pass
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
@ -26,10 +78,13 @@ class NotificationOrchestrator:
if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적/휴식권고
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute
# 월요일 첫 출근 시 지난주 리포트
self.maybe_send_weekly_report(now)
# 휴식 권고 (장시간 연속 근무)
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)

112
ui/leave_calendar_view.py Normal file
View File

@ -0,0 +1,112 @@
"""
연차 사용 캘린더 시각화.
QCalendarWidget에 사용 연차일을 색칠로 표시.
- 1.0: 진한
- 0.5(반차): 중간
- 0.25(반반차): 옅은
"""
from __future__ import annotations
from datetime import datetime
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from ui.styles import apply_dark_titlebar
class LeaveCalendarView(QDialog):
"""연차 캘린더 시각화."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("📅 연차 캘린더")
self.setModal(True)
self.setMinimumSize(540, 480)
self._build_ui()
self._mark_dates()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 헤더: 잔여 + 범례
header = QHBoxLayout()
balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
layout.addLayout(header)
# 범례
legend = QHBoxLayout()
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
# 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
layout.addWidget(self.calendar, 1)
# 선택 일자 정보
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
layout.addWidget(self.detail_label)
# 닫기 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
def _mark_dates(self):
"""연차 사용 일자에 색상 표시."""
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
d = datetime.strptime(r['date'], '%Y-%m-%d').date()
except (ValueError, TypeError):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))
self.calendar.setDateTextFormat(qd, fmt)
def _on_date_click(self, qdate):
date_str = qdate.toString('yyyy-MM-dd')
records = self.db.get_all_leave_records(limit=365)
match = [r for r in records if r['date'] == date_str]
if not match:
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
return
parts = []
for r in match:
t = r.get('leave_type', 'annual')
d = float(r.get('days') or 0)
memo = r.get('memo') or ''
parts.append(f"{t} {d}" + (f" ({memo})" if memo else ""))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))

View File

@ -80,6 +80,11 @@ class LeaveView(QDialog):
add_leave_button = QPushButton(" 연차 사용 추가")
add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button)
cal_button = QPushButton("📅 캘린더 보기")
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
close_button = QPushButton("닫기")
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
@ -87,6 +92,11 @@ class LeaveView(QDialog):
self.setLayout(layout)
def _show_calendar(self):
from ui.leave_calendar_view import LeaveCalendarView
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트

View File

@ -165,7 +165,11 @@ class StatsView(QDialog):
pattern_group.setLayout(pattern_layout)
layout.addWidget(pattern_group)
layout.addStretch()
# 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(self.clock_in_chart, 1)
widget.setLayout(layout)
return widget
@ -253,6 +257,11 @@ class StatsView(QDialog):
def analyze_patterns(self, records):
"""패턴 분석"""
# 출근 분포 차트는 데이터 유무와 무관하게 갱신 (빈 차트 표시)
if hasattr(self, 'clock_in_chart'):
from ui.chart_widget import draw_clock_in_distribution
draw_clock_in_distribution(self.clock_in_chart, records or [])
if not records:
self.pattern_text.setText(tr('stats.no_data'))
return