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:
co-authored by
Claude Opus 4.7
parent
14d88656fe
commit
e4011d9fc3
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)):,}원"
|
||||
@@ -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
@@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.2.4'
|
||||
__version__ = '2.3.0'
|
||||
|
||||
Reference in New Issue
Block a user