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:
co-authored by
Claude Opus 4.7
parent
9ebf4ad961
commit
606da976a0
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user