v2.3.0: Phase 1 + E1 — onboarding wizard, salary, inline edit, today card, health break, Discord webhook

Added (6 user-friendly features):
- Onboarding wizard (5 steps, forced on first launch, re-runnable from Help menu)
- Salary estimation (optional, hourly wage + overtime multiplier)
- Inline clock-in/out time editing (click label to edit)
- Today summary card (post-clockout, auto-hide on next clock-in)
- Health break reminder (continuous N-hour at desk warning)
- Discord webhook notifications (clock-in/out/health, mobile push, no server)

Database:
- break_records.break_type column ('break'/'lunch'/'dinner')
- notification_log table (dedupe + analytics)
- Auto-complete onboarding for existing users (work_records exists)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KINDNICK
2026-04-30 18:01:48 +09:00
co-authored by Claude Opus 4.7
parent 14d88656fe
commit e4011d9fc3
13 changed files with 925 additions and 3 deletions
+119
View File
@@ -161,6 +161,9 @@ class Database:
self.migrate_cleanup_balance_adjustments()
self.migrate_work_hours_to_minutes()
self.migrate_annual_leave_keys()
self.migrate_v23_break_type()
self.migrate_v23_notification_log()
self.migrate_v23_onboarding_for_existing()
# 기본 설정 초기화
self.init_default_settings()
@@ -467,6 +470,111 @@ class Database:
finally:
conn.close()
def migrate_v23_break_type(self):
"""break_records에 break_type 컬럼 추가 (v2.3.0).
값: 'break'(기본 외출) / 'lunch' / 'dinner'.
기존 점심 1시간 자동 적용 모드와 무관 — 실제 시간 입력용.
"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(break_records)")
cols = [row[1] for row in cursor.fetchall()]
if 'break_type' not in cols:
cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'")
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr)
finally:
conn.close()
def migrate_v23_notification_log(self):
"""알림 발송 이력 테이블 (v2.3.0). 중복 발송 방지 + 통계."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN DEFAULT 1
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_notif_log_event
ON notification_log(event_type, sent_at)
''')
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"notification_log 생성 경고: {e}", file=sys.stderr)
finally:
conn.close()
def log_notification(self, channel: str, event_type: str,
payload: str = None, success: bool = True) -> None:
"""알림 발송 이력 기록 (중복 방지 가드용)."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"INSERT INTO notification_log (channel, event_type, payload, success) VALUES (?, ?, ?, ?)",
(channel, event_type, payload, success)
)
conn.commit()
finally:
conn.close()
def has_notification_today(self, channel: str, event_type: str) -> bool:
"""오늘 같은 (channel, event_type) 발송 이력 존재 여부."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT COUNT(*) FROM notification_log "
"WHERE channel = ? AND event_type = ? AND DATE(sent_at) = DATE('now', 'localtime')",
(channel, event_type)
)
return cursor.fetchone()[0] > 0
finally:
conn.close()
def migrate_v23_onboarding_for_existing(self):
"""기존 사용자(이미 work_records 데이터 있음)는 온보딩 자동 완료 처리.
v2.3.0 도입 시 한 번만 실행. 신규 DB(데이터 0)는 영향 없음 → 첫 실행 시 위저드.
"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 이미 완료/스킵 마크 있으면 패스
cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'")
row = cursor.fetchone()
if row and row[0] == 'true':
return
# 기존 work_records 데이터가 1건 이상 있으면 자동 완료
cursor.execute("SELECT COUNT(*) FROM work_records")
count = cursor.fetchone()[0]
if count > 0:
cursor.execute('''
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP)
''')
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr)
finally:
conn.close()
def get_setting_int(self, key: str, default: int = 0) -> int:
"""설정을 int로 안전하게 조회 (변환 실패 시 default)."""
raw = self.get_setting(key, None)
@@ -533,6 +641,17 @@ class Database:
'workday_boundary_hour': '6',
'overtime_unit': '30',
'time_format': '24',
# v2.3.0
'onboarding_completed': 'false',
'salary_enabled': 'false',
'hourly_wage': '0',
'overtime_rate': '1.5',
'health_break_enabled': 'true',
'health_break_hours': '4',
'discord_webhook_url': '',
'discord_notif_clock_in': 'true',
'discord_notif_clock_out': 'true',
'discord_notif_health': 'true',
}
conn = self.get_connection()
+27
View File
@@ -164,6 +164,33 @@ class Notifier(QObject):
)
self.notified_weekly = True
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
"""장시간 연속 근무 휴식 알림.
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
"""
if current_time is None:
current_time = datetime.now()
if self.db is None:
return
if not self._enabled('health_break_enabled'):
return
if self.db.has_notification_today('system', 'health_break'):
return
try:
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
except (ValueError, TypeError):
threshold_hours = 4
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
if elapsed_sec >= threshold_hours * 3600:
from core.i18n import tr
self.notification_signal.emit(
tr('notif.health_break.title') if False else "🌿 휴식 권고",
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
)
self.db.log_notification('system', 'health_break')
def reset_notifications(self):
"""알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False
+50
View File
@@ -0,0 +1,50 @@
"""
급여 추정 (옵션).
포괄임금제 회사면 사용 안 함. 시급 + 연장수당 가산률만 받아서 단순 계산.
"""
from __future__ import annotations
from typing import List, Dict
def estimate_pay(records: List[Dict], hourly_wage: float,
overtime_rate: float = 1.5) -> Dict[str, float]:
"""근무 기록 리스트로부터 추정 급여 계산.
Args:
records: [{'total_hours': float, 'overtime_minutes': int}, ...]
hourly_wage: 시급(원)
overtime_rate: 연장수당 가산률 (기본 1.5배 - 한국 노동법)
Returns:
{'base': 기본급, 'overtime': 연장수당, 'total': 합계}
"""
if hourly_wage <= 0:
return {'base': 0.0, 'overtime': 0.0, 'total': 0.0}
base_hours = 0.0
overtime_hours = 0.0
for r in records:
total = float(r.get('total_hours') or 0)
ot_min = int(r.get('overtime_minutes') or 0)
ot_hours = ot_min / 60.0
# 정규 근무 = 총 - 연장
regular = max(0.0, total - ot_hours)
base_hours += regular
overtime_hours += ot_hours
base = base_hours * hourly_wage
overtime = overtime_hours * hourly_wage * overtime_rate
return {
'base': base,
'overtime': overtime,
'total': base + overtime,
'base_hours': base_hours,
'overtime_hours': overtime_hours,
}
def format_won(amount: float) -> str:
"""원화 포맷팅. 1234567 → '1,234,567원'."""
return f"{int(round(amount)):,}원"
+19
View File
@@ -44,6 +44,25 @@ DB_PATH_OVERRIDE = 'db_path_override'
# 백업
LAST_BACKUP_DATE = 'last_backup_date'
# === v2.3.0 신규 ===
# 온보딩
ONBOARDING_COMPLETED = 'onboarding_completed'
# 급여 (옵션, 포괄임금이면 미설정)
SALARY_ENABLED = 'salary_enabled'
HOURLY_WAGE = 'hourly_wage'
OVERTIME_RATE = 'overtime_rate' # 1.5
# 휴식 알림
HEALTH_BREAK_ENABLED = 'health_break_enabled'
HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4
# Discord 웹훅
DISCORD_WEBHOOK_URL = 'discord_webhook_url'
DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in'
DISCORD_NOTIF_CLOCK_OUT = 'discord_notif_clock_out'
DISCORD_NOTIF_HEALTH = 'discord_notif_health'
# 마이그레이션 sentinel
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
__version__ = '2.2.4'
__version__ = '2.3.0'