Compare commits

..
7 Commits
Author SHA1 Message Date
KINDNICKandClaude Opus 4.7 d3a4efc173 v2.3.3: fix Discord webhook always failing (Cloudflare blocks Python UA)
Cloudflare protects Discord webhook endpoints and rejects requests
with the default Python urllib User-Agent ('Python-urllib/3.x') with
HTTP 403 + 'error code 1010'. Add a browser-like User-Agent header
to the request.

The onboarding wizard's 'Send Test Message' button (and clock-in/out
push messages) now succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:23:51 +09:00
KINDNICKandClaude Opus 4.7 86cf438785 v2.3.2: fix HelpView showing blank window (regression in v2.3.1)
In v2.3.1 the new 'Re-run Onboarding' button was added but the
self.setLayout(main_layout) line was accidentally indented into the
new _reopen_onboarding method body, so init_ui returned without
applying the layout. Result: F1 opened an empty dialog with no tabs.

Move setLayout back to the end of init_ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:12:51 +09:00
KINDNICKandClaude Opus 4.7 fc5184ae07 v2.3.1: add 'Re-run Onboarding' button to Help dialog
v2.3.0 had show_onboarding() method but no UI entry point. Existing
users couldn't reach the wizard since auto-migration marks them done.
Now Help dialog (F1) has a button to re-trigger the wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:07:57 +09:00
KINDNICKandClaude Opus 4.7 e4011d9fc3 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>
2026-04-30 18:01:48 +09:00
KINDNICKandClaude Opus 4.7 14d88656fe v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect):
- auto_overtime: when OFF, prompt user on clock-out before banking
- overtime_unit: 15/30/60-min truncation choice now actually applied
- notification_before_minutes: 30-min hardcode -> user-configurable 1~120

Removed dead keys (no readers in business logic):
- auto_detect_boot, notification_enabled, annual_leave_used

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:29:44 +09:00
KINDNICKandClaude Opus 4.7 b91b229731 v2.2.3: stage updater.exe outside dist/ to survive main.spec --clean
main.spec's --clean flag wipes dist/ at the start of main.exe build,
which deleted dist/updater.exe before it could be embedded as data
(v2.2.2 main.exe shipped without embedded updater).

Fix: release.ps1 copies dist/updater.exe to build/staging/ after
the updater build, and main.spec datas references that staged path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:15:51 +09:00
KINDNICKandClaude Opus 4.7 7661afd33a v2.2.2: dummy release for auto-update test
- Show app version in window title for visual confirmation after update
- No functional changes — purely a test bump to verify the
  single-file (main.exe alone) auto-update flow end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:12:25 +09:00
20 changed files with 1184 additions and 48 deletions
+95
View File
@@ -4,6 +4,101 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.3.3] — 2026-04-30
### Fixed
- **Discord 웹훅 항상 실패하던 문제** (Cloudflare error 1010 / 403 Forbidden)
- 원인: Python 기본 User-Agent(`Python-urllib/3.x`)를 Discord/Cloudflare가
봇으로 인식해 차단
- 수정: `utils/discord_webhook.py`에 브라우저 UA 헤더 추가
(`Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3`)
- 이제 온보딩 위저드의 "테스트 메시지 보내기" + 출퇴근/휴식 push 모두 정상 동작
## [2.3.2] — 2026-04-30
### Fixed
- **도움말 다이얼로그가 빈 창으로 표시되던 치명적 버그**
- v2.3.1에서 "온보딩 다시 보기" 버튼 추가 시 `self.setLayout(main_layout)` 라인이
실수로 `_reopen_onboarding` 메서드 안으로 들여쓰기되어 init_ui가 setLayout 없이 종료
- F1 누르면 빈 화면만 나옴 (탭 6개 모두 미렌더링)
- setLayout 호출 위치 복원 → 정상 표시
## [2.3.1] — 2026-04-30
### Fixed
- **도움말 다이얼로그에 "온보딩 다시 보기" 버튼 추가**
- v2.3.0에서 `show_onboarding()` 메서드는 만들었지만 UI 진입점 누락
- 이제 도움말 (F1) → 좌측 하단 "🚀 온보딩 다시 보기" 버튼으로 위저드 재실행 가능
- 본인 검증/설정 변경 시 유용
## [2.3.0] — 2026-04-30
### Added — Phase 1 + E1 (소비자 친화 6종)
- **첫 실행 온보딩 위저드** (강제) — 5단계: 환영 → 근무패턴 → 출근 감지 방식 → 연차/시급(옵션) → Discord(옵션) → 완료
- 신규 사용자: 자동 표시 / 기존 사용자(work_records 있음): 자동 완료 처리
- "도움말 → 온보딩 다시 보기" 메뉴로 언제든 재실행 가능
- **시급 → 추정 급여** (옵션) — 포괄임금이 아닐 때만 활성화
- 통계 화면 월간 탭에 "이번 달 추정 급여" 카드
- 퇴근 후 "오늘 요약" 카드에도 추정 급여 표시
- 연장수당 가산률 1.0 / 1.5 / 2.0 선택
- **출퇴근 시각 인라인 편집** — 메인 화면 출근/퇴근 라벨 클릭 → 즉시 수정 다이얼로그
- **퇴근 후 "오늘 요약" 카드** — 메인 화면 상단에 총 근무/점심/외출/연장/추정급여 표시
- 다음 출근 시 자동 숨김 / X 버튼으로 수동 닫기
- **장시간 근무 휴식 권고 알림** — 연속 N시간(기본 4시간) 자리 비움 없으면 "🌿 잠시 일어나세요" 토스트
- 5분 throttle + 일 1회 가드 (notification_log 테이블)
- **Discord 웹훅 알림** (옵션) — 출퇴근/휴식권고 모바일 push
- 봇 등록·서버 운영 0. 채널 웹훅 URL만 입력
- 출근(녹색) / 퇴근 정시(파랑) / 퇴근 연장(주황) / 건강경고(분홍) embed
- 온보딩에서 즉시 활성화 + "테스트 메시지" 버튼
### Database
- `break_records.break_type` 컬럼 추가 ('break' / 'lunch' / 'dinner' 구분)
- `notification_log` 테이블 신규 (channel, event_type, sent_at, success — 중복 발송 가드 + 통계용)
- 기존 사용자 `onboarding_completed` 자동 true 처리 마이그레이션
### Settings (신규 11개)
- `onboarding_completed`, `salary_enabled`, `hourly_wage`, `overtime_rate`
- `health_break_enabled`, `health_break_hours`
- `discord_webhook_url`, `discord_notif_clock_in`, `discord_notif_clock_out`, `discord_notif_health`
## [2.2.4] — 2026-04-30
### Added
- **`auto_overtime` 옵션 실제 동작** — 기존엔 UI만 있고 동작 안 했음
- **OFF**: 퇴근 시 적립 가능한 분이 있으면 "적립할까요?" 다이얼로그 표시 (Y/N)
- **ON** (기본): 자동 적립 (이전과 동일)
- **`overtime_unit` 실제 동작** — 30/15/60분 단위 적립 선택 반영
- 기존엔 무조건 30분 절삭. 이제 설정값 사용 (`time_calculator.calculate_overtime` `unit_minutes` 파라미터)
- **퇴근 알림 시점 사용자 설정** (`notification_before_minutes`)
- 기존 30분 하드코드 → 1~120분 SpinBox로 사용자 지정
- 설정 → 알림 그룹에 "퇴근 알림 시점" SpinBox
### Removed (죽은 옵션 정리)
- `auto_detect_boot` — 부팅 감지 비활성화 모드. 어디서도 안 쓰임
- `notification_enabled` — 마스터 스위치. 4개 개별 NOTIF_* 키로 충분
- `annual_leave_used` — `leave_balance`로 대체된 레거시 카운터
### Fixed
- 죽은 옵션이 사용자에게 "동작하는 것처럼" 보이던 신뢰성 문제 일괄 해소
## [2.2.3] — 2026-04-30
### Fixed
- **`updater.exe` 임베딩 빌드 안정화**
- v2.2.2에서 `main.spec --clean`이 빌드 시작 시 `dist/updater.exe`를 지우면서
임베딩이 누락되던 문제 수정
- `release.ps1`이 `dist/updater.exe`를 `build/staging/`로 복사 후 main.spec이 그걸 참조
- main.exe 사이즈가 다시 ~78MB (updater 내장) 로 정상화
## [2.2.2] — 2026-04-30
### Added
- 윈도우 제목에 현재 버전 표시 — 자동 업데이트 후 신/구 버전 시각적 구분
- 예: `⏰ 퇴근시간 계산기 v2.2.2`
### Test
- 자동 업데이트 더미 릴리스 — 단독 main.exe 배포 + 자가 업데이트 흐름 검증용
## [2.2.1] — 2026-04-30 ## [2.2.1] — 2026-04-30
### Added ### Added
+122 -5
View File
@@ -161,6 +161,9 @@ class Database:
self.migrate_cleanup_balance_adjustments() self.migrate_cleanup_balance_adjustments()
self.migrate_work_hours_to_minutes() self.migrate_work_hours_to_minutes()
self.migrate_annual_leave_keys() 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() self.init_default_settings()
@@ -467,6 +470,111 @@ class Database:
finally: finally:
conn.close() 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: def get_setting_int(self, key: str, default: int = 0) -> int:
"""설정을 int로 안전하게 조회 (변환 실패 시 default).""" """설정을 int로 안전하게 조회 (변환 실패 시 default)."""
raw = self.get_setting(key, None) raw = self.get_setting(key, None)
@@ -520,21 +628,30 @@ class Database:
'work_minutes': '480', 'work_minutes': '480',
'lunch_duration_minutes': '60', 'lunch_duration_minutes': '60',
'dinner_duration_minutes': '60', 'dinner_duration_minutes': '60',
'auto_detect_boot': 'true',
'auto_lunch': 'false', 'auto_lunch': 'false',
'auto_overtime': 'true',
'theme': 'light', 'theme': 'light',
'notification_enabled': 'true',
'notification_before_minutes': '30', 'notification_before_minutes': '30',
'notification_clock_out': 'true', 'notification_clock_out': 'true',
'notification_lunch': 'true', 'notification_lunch': 'true',
'notification_overtime': 'true', 'notification_overtime': 'true',
'notification_health': 'true', 'notification_health': 'true',
'annual_leave_total': '15', 'annual_leave_total': '15',
'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화) 'annual_leave_days': '15', # annual_leave_total과 자동 동기화
'annual_leave_used': '0',
'workday_boundary_hour': '6', 'workday_boundary_hour': '6',
'overtime_unit': '30', 'overtime_unit': '30',
'time_format': '24' '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() conn = self.get_connection()
+38 -2
View File
@@ -65,8 +65,17 @@ class Notifier(QObject):
return return
time_diff = clock_out_time - current_time time_diff = clock_out_time - current_time
# 30분 이내, 아직 알림 안 했으면 # 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min: threshold_min = 30
if self.db is not None:
try:
threshold_min = int(self.db.get_setting('notification_before_minutes', '30') or '30')
threshold_min = max(1, min(120, threshold_min))
except (ValueError, TypeError):
threshold_min = 30
threshold_sec = threshold_min * 60
if 0 < time_diff.total_seconds() <= threshold_sec and not self.notified_30min:
minutes_left = int(time_diff.total_seconds() / 60) minutes_left = int(time_diff.total_seconds() / 60)
self.notification_signal.emit( self.notification_signal.emit(
tr('notif.clock_out_soon.title'), tr('notif.clock_out_soon.title'),
@@ -155,6 +164,33 @@ class Notifier(QObject):
) )
self.notified_weekly = True 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): def reset_notifications(self):
"""알림 상태 리셋 (날짜 변경 시)""" """알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False 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 -3
View File
@@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour' WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
# 자동화 # 자동화
AUTO_DETECT_BOOT = 'auto_detect_boot'
AUTO_LUNCH = 'auto_lunch' AUTO_LUNCH = 'auto_lunch'
AUTO_OVERTIME = 'auto_overtime' AUTO_OVERTIME = 'auto_overtime'
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock' AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용) CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
# 알림 # 알림
NOTIFICATION_ENABLED = 'notification_enabled'
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes' NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
NOTIF_CLOCK_OUT = 'notification_clock_out' NOTIF_CLOCK_OUT = 'notification_clock_out'
NOTIF_LUNCH = 'notification_lunch' NOTIF_LUNCH = 'notification_lunch'
@@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health'
# 연차 # 연차
ANNUAL_LEAVE_TOTAL = 'annual_leave_total' ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
ANNUAL_LEAVE_DAYS = 'annual_leave_days' ANNUAL_LEAVE_DAYS = 'annual_leave_days'
ANNUAL_LEAVE_USED = 'annual_leave_used'
LEAVE_BALANCE = 'leave_balance' LEAVE_BALANCE = 'leave_balance'
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes' INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours' INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
@@ -47,6 +44,25 @@ DB_PATH_OVERRIDE = 'db_path_override'
# 백업 # 백업
LAST_BACKUP_DATE = 'last_backup_date' 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 # 마이그레이션 sentinel
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated' ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2' BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
+7 -5
View File
@@ -135,17 +135,19 @@ class TimeCalculator:
def calculate_overtime(self, clock_in: datetime, clock_out: datetime, def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
include_lunch: bool = False, include_dinner: bool = False, include_lunch: bool = False, include_dinner: bool = False,
break_minutes: int = 0) -> Tuple[int, int]: break_minutes: int = 0,
unit_minutes: int = 30) -> Tuple[int, int]:
""" """
연장근무 시간 계산 (실제 시간, 30분 단위 적립) 연장근무 시간 계산 (실제 시간, unit_minutes 단위 적립)
Args: Args:
clock_in: 출근 시간 clock_in: 출근 시간
clock_out: 퇴근 시간 clock_out: 퇴근 시간
include_lunch: 점심시간 포함 여부 include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부 include_dinner: 저녁시간 포함 여부
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외 break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT.
Returns: Returns:
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분) Tuple[int, int]: (실제 연장근무 분, unit_minutes 단위 적립 분)
""" """
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes) expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
@@ -155,8 +157,8 @@ class TimeCalculator:
overtime_duration = clock_out - expected_clock_out overtime_duration = clock_out - expected_clock_out
overtime_minutes = int(overtime_duration.total_seconds() / 60) overtime_minutes = int(overtime_duration.total_seconds() / 60)
# 30분 단위로 절삭 unit = unit_minutes if unit_minutes > 0 else 30
overtime_earned = (overtime_minutes // 30) * 30 overtime_earned = (overtime_minutes // unit) * unit
return overtime_minutes, overtime_earned return overtime_minutes, overtime_earned
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push. 릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것. CHANGELOG.md의 최상단 항목과 일치시킬 것.
""" """
__version__ = '2.2.1' __version__ = '2.3.3'
+8
View File
@@ -128,6 +128,14 @@ def main():
from utils.debug_log import dlog from utils.debug_log import dlog
dlog(f"backup failed: {e}") dlog(f"backup failed: {e}")
# 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시
try:
from ui.onboarding_view import maybe_show_onboarding
maybe_show_onboarding(db)
except Exception as e:
from utils.debug_log import dlog
dlog(f"onboarding skipped: {e}")
# 메인 윈도우 생성 및 표시 # 메인 윈도우 생성 및 표시
try: try:
window = MainWindow() window = MainWindow()
+8 -2
View File
@@ -4,9 +4,15 @@
# the embedded updater is auto-extracted next to main.exe on first launch. # the embedded updater is auto-extracted next to main.exe on first launch.
import os import os
# release.ps1 stages updater.exe at build/staging/updater.exe
# (avoids PyInstaller --clean wiping dist/updater.exe before main build).
_extra_datas = [] _extra_datas = []
if os.path.exists('dist/updater.exe'): _staged = 'build/staging/updater.exe'
_extra_datas.append(('dist/updater.exe', '.')) _fallback = 'dist/updater.exe'
if os.path.exists(_staged):
_extra_datas.append((_staged, '.'))
elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.'))
a = Analysis( a = Analysis(
['main.py'], ['main.py'],
+12
View File
@@ -120,11 +120,23 @@ $rc = Invoke-Native python -m PyInstaller --clean updater.spec
if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" } if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/updater.exe')) { Fail "dist/updater.exe missing" } if (-not (Test-Path 'dist/updater.exe')) { Fail "dist/updater.exe missing" }
# Stage updater.exe outside dist/ so main.spec --clean does not wipe it
$stagingDir = 'build/staging'
if (-not (Test-Path $stagingDir)) { New-Item -ItemType Directory -Path $stagingDir | Out-Null }
Copy-Item 'dist/updater.exe' "$stagingDir/updater.exe" -Force
Info "Staged updater.exe -> $stagingDir/updater.exe"
Info "Building main.exe (with embedded updater)..." Info "Building main.exe (with embedded updater)..."
$rc = Invoke-Native python -m PyInstaller --clean main.spec $rc = Invoke-Native python -m PyInstaller --clean main.spec
if ($rc -ne 0) { Fail "main.exe build failed (exit $rc)" } if ($rc -ne 0) { Fail "main.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/main.exe')) { Fail "dist/main.exe missing" } if (-not (Test-Path 'dist/main.exe')) { Fail "dist/main.exe missing" }
# Restore updater.exe to dist/ for separate Release asset upload
if (-not (Test-Path 'dist/updater.exe')) {
Copy-Item "$stagingDir/updater.exe" 'dist/updater.exe' -Force
Info "Restored updater.exe to dist/"
}
$mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB) $mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB)
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB) $updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)" OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
+29 -1
View File
@@ -8,7 +8,10 @@ import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.updater_client import _parse_version, is_newer, RELEASES_API from utils.updater_client import (
_parse_version, is_newer, RELEASES_API,
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
)
class TestVersionParsing: class TestVersionParsing:
@@ -59,6 +62,31 @@ class TestApiUrl:
importlib.reload(updater_client) importlib.reload(updater_client)
class TestCheckForUpdate:
"""check_for_update returns (info, reason) tuple."""
def test_returns_tuple(self, monkeypatch):
# 네트워크 호출이 실패하도록 잘못된 URL
monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'http://127.0.0.1:1/nope')
import importlib
from utils import updater_client
importlib.reload(updater_client)
try:
result = updater_client.check_for_update('1.0.0', timeout=1)
assert isinstance(result, tuple) and len(result) == 2
info, reason = result
assert info is None
assert reason == updater_client.NETWORK_ERROR
finally:
monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False)
importlib.reload(updater_client)
def test_constants_distinct(self):
# 4개 상수가 모두 서로 다른 값
values = {UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET}
assert len(values) == 4
class TestUpdaterScript: class TestUpdaterScript:
"""updater.py 자체 로직.""" """updater.py 자체 로직."""
+25 -1
View File
@@ -26,15 +26,39 @@ class NotificationOrchestrator:
if remaining_seconds < 0: if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60))) 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: if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute self._last_5min_bucket = now.minute
# 휴식 권고 (장시간 연속 근무)
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)
consecutive = self.db.get_consecutive_overtime_days() consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3: if consecutive >= 3:
n.notify_health_warning(consecutive) n.notify_health_warning(consecutive)
self._discord_health(consecutive, break_minutes, now)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0) weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52: if weekly_hours > 52:
n.notify_weekly_hours(weekly_hours) n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance() balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200: if balance_minutes >= 1200:
n.notify_overtime_threshold(balance_minutes / 60.0) n.notify_overtime_threshold(balance_minutes / 60.0)
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
"""건강 경고 Discord push (옵션)."""
if self.db.has_notification_today('discord', 'health'):
return
if self.db.get_setting('discord_notif_health', 'true').lower() != 'true':
return
url = self.db.get_setting('discord_webhook_url', '') or ''
if not url:
return
try:
from utils import discord_webhook
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
ok = discord_webhook.send_health_warning(url, elapsed)
self.db.log_notification('discord', 'health', success=ok)
except Exception:
pass
+14 -1
View File
@@ -51,18 +51,31 @@ class HelpView(QDialog):
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 10, 20, 20) button_layout.setContentsMargins(20, 10, 20, 20)
# 온보딩 다시 보기 (왼쪽)
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
onboarding_button.setMinimumHeight(40)
onboarding_button.clicked.connect(self._reopen_onboarding)
button_layout.addWidget(onboarding_button)
button_layout.addStretch() button_layout.addStretch()
close_button = QPushButton(tr('btn.close')) close_button = QPushButton(tr('btn.close'))
close_button.setObjectName("btn_primary") close_button.setObjectName("btn_primary")
close_button.setMinimumHeight(40) close_button.setMinimumHeight(40)
close_button.setMinimumWidth(120) close_button.setMinimumWidth(120)
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
button_layout.addWidget(close_button) button_layout.addWidget(close_button)
button_layout.addStretch()
main_layout.addLayout(button_layout) main_layout.addLayout(button_layout)
self.setLayout(main_layout) self.setLayout(main_layout)
def _reopen_onboarding(self):
"""부모 윈도우의 show_onboarding 호출 후 도움말 닫음."""
self.close()
if self.parent() and hasattr(self.parent(), 'show_onboarding'):
self.parent().show_onboarding()
def _make_tab(self, html: str) -> QWidget: def _make_tab(self, html: str) -> QWidget:
container = QWidget() container = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
+146 -17
View File
@@ -176,7 +176,8 @@ class MainWindow(QMainWindow):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("⏰ " + tr('window.main_title')) from core.version import __version__
self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
self.setGeometry(100, 100, 500, 620) self.setGeometry(100, 100, 500, 620)
self.setMinimumSize(480, 520) self.setMinimumSize(480, 520)
@@ -218,6 +219,11 @@ class MainWindow(QMainWindow):
self.date_label.setAlignment(Qt.AlignCenter) self.date_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.date_label) main_layout.addWidget(self.date_label)
# 1.5 오늘 요약 카드 (퇴근 후 표시, 평소엔 숨김)
from ui.today_summary import TodaySummaryCard
self.today_summary_card = TodaySummaryCard()
main_layout.addWidget(self.today_summary_card)
# 2. 출근 정보 그룹 # 2. 출근 정보 그룹
clock_in_group = self.create_clock_in_group() clock_in_group = self.create_clock_in_group()
main_layout.addWidget(clock_in_group) main_layout.addWidget(clock_in_group)
@@ -355,7 +361,11 @@ class MainWindow(QMainWindow):
self.clock_in_value = QLabel("--:--:--") self.clock_in_value = QLabel("--:--:--")
self.clock_in_value.setObjectName("time_value") self.clock_in_value.setObjectName("time_value")
self.clock_in_value.setMinimumWidth(90) self.clock_in_value.setMinimumWidth(90)
self.edit_clock_in_button = QPushButton("수정") # 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
self.clock_in_value.setCursor(Qt.PointingHandCursor)
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
self.edit_clock_in_button = QPushButton("✏️ 수정")
self.edit_clock_in_button.setObjectName("btn_small") self.edit_clock_in_button.setObjectName("btn_small")
self.edit_clock_in_button.setFixedWidth(70) self.edit_clock_in_button.setFixedWidth(70)
self.edit_clock_in_button.clicked.connect(self.manual_clock_in) self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
@@ -1080,18 +1090,21 @@ class MainWindow(QMainWindow):
# 오늘의 외출 시간 가져오기 # 오늘의 외출 시간 가져오기
break_minutes = self.db.get_total_break_minutes_today() break_minutes = self.db.get_total_break_minutes_today()
# 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60.
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
if is_non_working_day: if is_non_working_day:
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외) # 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
work_minutes = int(total_hours * 60) work_minutes = int(total_hours * 60)
if self.lunch_break_enabled: if self.lunch_break_enabled:
work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외 work_minutes -= self.time_calc.lunch_duration_minutes
if self.dinner_break_enabled: if self.dinner_break_enabled:
work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외 work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes # 외출시간 제외 work_minutes -= break_minutes
# 음수 방지
work_minutes = max(0, work_minutes) work_minutes = max(0, work_minutes)
# 30분 단위로 절삭 overtime_earned = (work_minutes // unit_minutes) * unit_minutes
overtime_earned = (work_minutes // 30) * 30
overtime_actual = work_minutes overtime_actual = work_minutes
else: else:
# 평일: 정상 연장근무 계산 (외출 시간 포함) # 평일: 정상 연장근무 계산 (외출 시간 포함)
@@ -1099,9 +1112,27 @@ class MainWindow(QMainWindow):
self.clock_in_time, now, self.clock_in_time, now,
include_lunch=self.lunch_break_enabled, include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled, include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes break_minutes=break_minutes,
unit_minutes=unit_minutes,
) )
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
if not auto_overtime and overtime_earned > 0:
from utils.time_format import format_hours_minutes
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
ask = QMessageBox.question(
self,
"연장근무 적립 확인",
f"연장근무 {actual_str} 발생, {time_str} 적립 대상입니다.\n\n"
f"적립하시겠습니까?\n"
f"(아니오 선택 시 이번 퇴근분은 적립되지 않습니다)",
QMessageBox.Yes | QMessageBox.No,
)
if ask != QMessageBox.Yes:
overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지)
# DB 업데이트 # DB 업데이트
today = datetime.now().date().isoformat() today = datetime.now().date().isoformat()
clock_out_str = now.strftime("%H:%M:%S") clock_out_str = now.strftime("%H:%M:%S")
@@ -1147,6 +1178,12 @@ class MainWindow(QMainWindow):
# 잔액 업데이트 # 잔액 업데이트
self.update_overtime_balance() self.update_overtime_balance()
# Discord 웹훅 push (옵션)
self._discord_push_clock_out(now, total_hours, overtime_actual, overtime_earned)
# 오늘 요약 카드 표시
self._show_today_summary(total_hours, overtime_actual, overtime_earned, break_minutes)
def cancel_clock_out(self): def cancel_clock_out(self):
"""퇴근 취소""" """퇴근 취소"""
# 확인 대화상자 # 확인 대화상자
@@ -1557,6 +1594,13 @@ class MainWindow(QMainWindow):
f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}" f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}"
) )
# Discord 웹훅 (옵션)
self._discord_push_clock_in(selected_time)
# 오늘 요약 카드 숨김 (새 출근 시작)
if hasattr(self, 'today_summary_card'):
self.today_summary_card.hide()
def show_stats(self): def show_stats(self):
"""통계 창 표시""" """통계 창 표시"""
dialog = StatsView(self, self.db) dialog = StatsView(self, self.db)
@@ -1597,19 +1641,104 @@ class MainWindow(QMainWindow):
dialog = HelpView(self) dialog = HelpView(self)
dialog.exec_() dialog.exec_()
def show_onboarding(self):
"""온보딩 위저드 다시 보기."""
from ui.onboarding_view import OnboardingWizard
wizard = OnboardingWizard(self.db, self)
if wizard.exec_():
self.reload_settings()
QMessageBox.information(self, "설정 업데이트", "변경된 설정이 즉시 반영되었습니다.")
# ===== Discord 웹훅 push (옵션, 실패 silent) =====
def _show_today_summary(self, total_hours, overtime_actual, overtime_earned, break_minutes):
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
if not hasattr(self, 'today_summary_card'):
return
# 점심 시간 계산 (lunch_break_enabled면 설정값, 아니면 0)
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
# 추정 급여 (옵션)
salary_text = ""
if self.db.get_setting('salary_enabled', 'false').lower() == 'true':
try:
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
if wage > 0:
from core.salary import estimate_pay, format_won
fake_record = {'total_hours': total_hours, 'overtime_minutes': overtime_actual}
result = estimate_pay([fake_record], wage, rate)
salary_text = f"오늘 추정: {format_won(result['total'])}"
except (ValueError, TypeError):
pass
self.today_summary_card.show_summary(
total_hours=total_hours,
lunch_minutes=lunch_min,
break_minutes=break_minutes,
overtime_actual=overtime_actual,
overtime_earned=overtime_earned,
salary_text=salary_text,
)
def _discord_url(self) -> str:
return self.db.get_setting('discord_webhook_url', '') or ''
def _discord_push_clock_in(self, when):
if self.db.get_setting('discord_notif_clock_in', 'true').lower() != 'true':
return
url = self._discord_url()
if not url:
return
try:
from utils import discord_webhook
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
self.db.log_notification('discord', 'clock_in', success=ok)
except Exception:
pass
def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned):
if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true':
return
url = self._discord_url()
if not url:
return
try:
from utils import discord_webhook
ok = discord_webhook.send_clock_out(
url, when.strftime('%H:%M:%S'),
total_hours, overtime_actual, overtime_earned,
)
self.db.log_notification('discord', 'clock_out', success=ok)
except Exception:
pass
def check_for_updates(self, silent: bool = False): def check_for_updates(self, silent: bool = False):
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용).""" """업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
from core.version import __version__ from core.version import __version__
from utils.updater_client import check_for_update, download_update, apply_update from utils.updater_client import (
check_for_update, download_update, apply_update,
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
)
info = check_for_update(__version__) info, reason = check_for_update(__version__)
if info is None: if info is None:
if not silent: if silent:
QMessageBox.information( return
self, # 사용자가 명시적으로 트리거한 경우만 메시지 표시
"업데이트 확인", messages = {
f"현재 최신 버전입니다 (v{__version__})." UP_TO_DATE: ("업데이트 확인", f"현재 최신 버전입니다 (v{__version__})."),
) NETWORK_ERROR: ("연결 실패",
"업데이트 서버에 연결할 수 없습니다.\n"
"네트워크 상태를 확인해 주세요."),
NO_RELEASE: ("릴리스 없음",
"업데이트 저장소에서 릴리스를 찾을 수 없습니다.\n"
"(저장소 비공개 또는 첫 릴리스 전)"),
NO_ASSET: ("자산 누락",
"새 버전은 있지만 다운로드 가능한 main.exe 자산이 없습니다.\n"
"관리자에게 문의하세요."),
}
title, body = messages.get(reason, ("업데이트 확인", "알 수 없는 응답입니다."))
QMessageBox.information(self, title, body)
return return
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만 # 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
+322
View File
@@ -0,0 +1,322 @@
"""
첫 실행 온보딩 위저드.
5단계: 환영 → 근무패턴 → 출근감지 → 연차/시급 → Discord(옵션) → 완료
첫 실행 시 강제 표시. 완료 시 ONBOARDING_COMPLETED=true 저장.
재방문은 메인 메뉴 → "온보딩 다시 보기"로 가능.
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
QLabel, QRadioButton, QButtonGroup, QSpinBox,
QCheckBox, QLineEdit, QComboBox, QPushButton,
QMessageBox, QGroupBox)
from PyQt5.QtCore import Qt
from ui.styles import apply_dark_titlebar
# (label, work_minutes, lunch_minutes)
WORK_PRESETS = [
("표준 8시간 (점심 60분)", 480, 60),
("단축근무 7시간 30분 (점심 30분)", 450, 30),
("단축근무 7시간 (점심 60분)", 420, 60),
("단축근무 6시간 (점심 30분)", 360, 30),
("반일 4시간 (점심 0분)", 240, 0),
]
class WelcomePage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("👋 환영합니다!")
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
layout = QVBoxLayout()
intro = QLabel(
"이 앱은:\n"
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
"• 30분 단위 연장근무 적립\n"
"• 연차·반차·외출 시간 추적\n"
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
"[다음] 버튼을 눌러 시작하세요."
)
intro.setWordWrap(True)
layout.addWidget(intro)
self.setLayout(layout)
class WorkPatternPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🕘 근무 패턴")
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
layout = QVBoxLayout()
self.button_group = QButtonGroup(self)
for i, (label, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(label)
self.button_group.addButton(rb, i)
layout.addWidget(rb)
if i == 0:
rb.setChecked(True)
# 사용자 정의
custom_box = QGroupBox("사용자 정의")
custom_layout = QHBoxLayout()
self.custom_radio = QRadioButton("직접 입력:")
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
self.hours_spin = QSpinBox()
self.hours_spin.setRange(0, 12)
self.hours_spin.setValue(8)
self.hours_spin.setSuffix(" 시간")
self.minutes_spin = QSpinBox()
self.minutes_spin.setRange(0, 59)
self.minutes_spin.setSingleStep(15)
self.minutes_spin.setSuffix(" 분")
self.lunch_spin = QSpinBox()
self.lunch_spin.setRange(0, 120)
self.lunch_spin.setSingleStep(5)
self.lunch_spin.setValue(60)
self.lunch_spin.setPrefix("점심 ")
self.lunch_spin.setSuffix(" 분")
custom_layout.addWidget(self.custom_radio)
custom_layout.addWidget(self.hours_spin)
custom_layout.addWidget(self.minutes_spin)
custom_layout.addWidget(self.lunch_spin)
custom_layout.addStretch()
custom_box.setLayout(custom_layout)
layout.addWidget(custom_box)
self.setLayout(layout)
def selected_minutes(self):
idx = self.button_group.checkedId()
if 0 <= idx < len(WORK_PRESETS):
_, wm, lm = WORK_PRESETS[idx]
return wm, lm
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
class ClockInDetectionPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("⏰ 출근 시간 감지 방식")
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
layout = QVBoxLayout()
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
self.option_boot.setChecked(True)
for opt in (self.option_boot, self.option_unlock, self.option_manual):
layout.addWidget(opt)
info = QLabel(
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
self.setLayout(layout)
def detection_mode(self) -> str:
if self.option_unlock.isChecked():
return 'unlock'
if self.option_manual.isChecked():
return 'manual'
return 'boot'
class LeaveSalaryPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
layout = QVBoxLayout()
# 연차
leave_box = QGroupBox("연간 연차")
leave_layout = QHBoxLayout()
self.leave_spin = QSpinBox()
self.leave_spin.setRange(0, 30)
self.leave_spin.setValue(15)
self.leave_spin.setSuffix(" 일")
leave_layout.addWidget(QLabel("내 연차:"))
leave_layout.addWidget(self.leave_spin)
leave_layout.addStretch()
leave_box.setLayout(leave_layout)
layout.addWidget(leave_box)
# 급여 (옵션)
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
salary_layout = QVBoxLayout()
self.salary_enabled = QCheckBox("급여 추정 활성화")
salary_layout.addWidget(self.salary_enabled)
wage_row = QHBoxLayout()
wage_row.addWidget(QLabel("시급:"))
self.wage_spin = QSpinBox()
self.wage_spin.setRange(0, 1000000)
self.wage_spin.setSingleStep(1000)
self.wage_spin.setSuffix(" 원/시간")
self.wage_spin.setValue(0)
self.wage_spin.setEnabled(False)
wage_row.addWidget(self.wage_spin)
wage_row.addStretch()
salary_layout.addLayout(wage_row)
rate_row = QHBoxLayout()
rate_row.addWidget(QLabel("연장수당 가산률:"))
self.rate_combo = QComboBox()
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
self.rate_combo.setCurrentIndex(1)
self.rate_combo.setEnabled(False)
rate_row.addWidget(self.rate_combo)
rate_row.addStretch()
salary_layout.addLayout(rate_row)
self.salary_enabled.toggled.connect(self.wage_spin.setEnabled)
self.salary_enabled.toggled.connect(self.rate_combo.setEnabled)
salary_box.setLayout(salary_layout)
layout.addWidget(salary_box)
layout.addStretch()
self.setLayout(layout)
class DiscordPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("💬 Discord 알림 (선택)")
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
layout = QVBoxLayout()
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
layout.addWidget(self.enable_check)
self.url_edit = QLineEdit()
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
self.url_edit.setEnabled(False)
layout.addWidget(self.url_edit)
guide = QLabel(
"셋업 방법:\n"
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
guide.setStyleSheet("color: #888; padding: 6px;")
guide.setWordWrap(True)
layout.addWidget(guide)
test_row = QHBoxLayout()
self.test_btn = QPushButton("테스트 메시지 보내기")
self.test_btn.setEnabled(False)
self.test_btn.clicked.connect(self._test_webhook)
test_row.addWidget(self.test_btn)
test_row.addStretch()
layout.addLayout(test_row)
self.enable_check.toggled.connect(self.url_edit.setEnabled)
self.enable_check.toggled.connect(self.test_btn.setEnabled)
layout.addStretch()
self.setLayout(layout)
def _test_webhook(self):
url = self.url_edit.text().strip()
if not url:
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
return
from utils import discord_webhook
ok = discord_webhook.send_test(url)
if ok:
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
else:
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
class FinishPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🎉 준비 완료!")
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
layout = QVBoxLayout()
msg = QLabel(
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
"🕐 단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
" • Ctrl+, — 설정"
)
msg.setWordWrap(True)
layout.addWidget(msg)
self.setLayout(layout)
class OnboardingWizard(QWizard):
"""5단계 첫 실행 위저드. accept() 시 모든 설정 저장 + ONBOARDING_COMPLETED=true."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("Clock-out Calculator — 시작 설정")
self.setMinimumSize(600, 500)
self.setWizardStyle(QWizard.ModernStyle)
self.setOption(QWizard.NoBackButtonOnStartPage, True)
self.welcome_page = WelcomePage()
self.work_page = WorkPatternPage()
self.detect_page = ClockInDetectionPage()
self.leave_page = LeaveSalaryPage()
self.discord_page = DiscordPage()
self.finish_page = FinishPage()
for page in (self.welcome_page, self.work_page, self.detect_page,
self.leave_page, self.discord_page, self.finish_page):
self.addPage(page)
apply_dark_titlebar(self)
def accept(self):
# 1. 근무 패턴
wm, lm = self.work_page.selected_minutes()
if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
return
settings = {
'work_minutes': wm,
'lunch_duration_minutes': lm,
'annual_leave_days': self.leave_page.leave_spin.value(),
'annual_leave_total': self.leave_page.leave_spin.value(),
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
'hourly_wage': self.leave_page.wage_spin.value(),
'overtime_rate': self.leave_page.rate_combo.currentData(),
'onboarding_completed': True,
}
# 2. 출근 감지 방식
mode = self.detect_page.detection_mode()
settings['clock_in_on_unlock'] = (mode == 'unlock')
# 3. Discord 웹훅 (옵션)
if self.discord_page.enable_check.isChecked():
settings['discord_webhook_url'] = self.discord_page.url_edit.text().strip()
self.db.save_settings(settings)
super().accept()
def maybe_show_onboarding(db, parent=None) -> bool:
"""ONBOARDING_COMPLETED=false 일 때만 위저드 표시. 사용자가 끝까지 완료하면 True."""
if db.get_setting('onboarding_completed', 'false').lower() == 'true':
return False # 이미 완료
wizard = OnboardingWizard(db, parent)
return wizard.exec_() == QWizard.Accepted
+23
View File
@@ -18,6 +18,7 @@ from core.settings_keys import (
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME, AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH, NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIFICATION_BEFORE_MINUTES,
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS, THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK, DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
@@ -275,6 +276,22 @@ class SettingsView(QDialog):
check_row2.addWidget(self.health_notification_check) check_row2.addWidget(self.health_notification_check)
layout.addLayout(check_row2) layout.addLayout(check_row2)
# 퇴근 N분 전 알림 시점 설정
before_row = QHBoxLayout()
before_label = QLabel("퇴근 알림 시점:")
before_label.setFixedWidth(110)
self.notif_before_spin = QSpinBox()
self.notif_before_spin.setRange(1, 120)
self.notif_before_spin.setSingleStep(5)
self.notif_before_spin.setValue(30)
self.notif_before_spin.setSuffix(" 분 전")
self.notif_before_spin.setFixedWidth(110)
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
before_row.addWidget(before_label)
before_row.addWidget(self.notif_before_spin)
before_row.addStretch()
layout.addLayout(before_row)
# 시간 형식 + 테마 한 줄에 # 시간 형식 + 테마 한 줄에
format_row = QHBoxLayout() format_row = QHBoxLayout()
time_format_label = QLabel("시간 형식:") time_format_label = QLabel("시간 형식:")
@@ -796,6 +813,11 @@ class SettingsView(QDialog):
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True)) self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True)) self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
if hasattr(self, 'notif_before_spin'):
try:
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
except (ValueError, TypeError):
self.notif_before_spin.setValue(30)
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환) # 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
time_format = settings.get(TIME_FORMAT, '24') time_format = settings.get(TIME_FORMAT, '24')
@@ -879,6 +901,7 @@ class SettingsView(QDialog):
NOTIF_LUNCH: self.lunch_notification_check.isChecked(), NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(), NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
NOTIF_HEALTH: self.health_notification_check.isChecked(), NOTIF_HEALTH: self.health_notification_check.isChecked(),
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
TIME_FORMAT: self.time_format_combo.currentData(), TIME_FORMAT: self.time_format_combo.currentData(),
OVERTIME_UNIT: self.overtime_unit_combo.currentData(), OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
AUTO_OVERTIME: self.auto_overtime_check.isChecked(), AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
+33
View File
@@ -99,6 +99,11 @@ class StatsView(QDialog):
layout.setSpacing(6) layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4) layout.setContentsMargins(4, 4, 4, 4)
# 추정 급여 카드 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
self.salary_label.setVisible(False)
summary_group = QGroupBox(tr('stats.monthly_summary')) summary_group = QGroupBox(tr('stats.monthly_summary'))
summary_layout = QGridLayout() summary_layout = QGridLayout()
summary_layout.setSpacing(4) summary_layout.setSpacing(4)
@@ -124,6 +129,7 @@ class StatsView(QDialog):
summary_group.setLayout(summary_layout) summary_group.setLayout(summary_layout)
layout.addWidget(summary_group) layout.addWidget(summary_group)
layout.addWidget(self.salary_label)
# 월간 차트 # 월간 차트
from ui.chart_widget import make_chart_widget from ui.chart_widget import make_chart_widget
@@ -206,9 +212,36 @@ class StatsView(QDialog):
if hasattr(self, 'monthly_chart'): if hasattr(self, 'monthly_chart'):
draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', [])) draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', []))
# 추정 급여 (옵션 활성 시)
self._update_salary_estimate(monthly_stats.get('records', []))
# 패턴 분석 # 패턴 분석
self.analyze_patterns(monthly_stats.get('records', [])) self.analyze_patterns(monthly_stats.get('records', []))
def _update_salary_estimate(self, records):
"""월간 추정 급여 표시 (SALARY_ENABLED=true 일 때만)."""
if not hasattr(self, 'salary_label'):
return
if self.db.get_setting('salary_enabled', 'false').lower() != 'true':
self.salary_label.setVisible(False)
return
try:
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
except (ValueError, TypeError):
self.salary_label.setVisible(False)
return
if wage <= 0:
self.salary_label.setVisible(False)
return
from core.salary import estimate_pay, format_won
result = estimate_pay(records, wage, rate)
self.salary_label.setText(
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
)
self.salary_label.setVisible(True)
def analyze_patterns(self, records): def analyze_patterns(self, records):
"""패턴 분석""" """패턴 분석"""
if not records: if not records:
+89
View File
@@ -0,0 +1,89 @@
"""
퇴근 후 표시되는 "오늘 요약" 카드 위젯.
다음 출근 시 자동 숨김. 메인 화면 상단에 conditional하게 표시.
"""
from __future__ import annotations
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt5.QtCore import Qt
class TodaySummaryCard(QFrame):
"""퇴근 처리 직후 표시되는 요약 카드."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("today_summary_card")
self.setStyleSheet("""
QFrame#today_summary_card {
background-color: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.4);
border-radius: 8px;
padding: 6px;
}
QLabel { padding: 1px; }
""")
self.setVisible(False)
layout = QVBoxLayout()
layout.setContentsMargins(10, 6, 10, 6)
layout.setSpacing(2)
header = QHBoxLayout()
title = QLabel("📋 오늘의 요약")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
close_btn = QPushButton("✕")
close_btn.setFixedSize(20, 20)
close_btn.setStyleSheet("border: none; font-weight: bold;")
close_btn.clicked.connect(self.hide)
header.addWidget(close_btn)
layout.addLayout(header)
self.total_label = QLabel("")
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
layout.addWidget(self.total_label)
layout.addWidget(self.detail_label)
layout.addWidget(self.salary_label)
self.setLayout(layout)
def show_summary(self, total_hours: float, lunch_minutes: int,
break_minutes: int, overtime_actual: int,
overtime_earned: int, salary_text: str = "") -> None:
"""카드 내용 채우고 표시.
Args:
total_hours: 총 근무시간(시간)
lunch_minutes: 점심 시간(분)
break_minutes: 외출 시간(분)
overtime_actual: 실제 연장근무(분)
overtime_earned: 적립 연장근무(분)
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
"""
h = int(total_hours)
m = int((total_hours - h) * 60)
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
details = []
if lunch_minutes > 0:
details.append(f"점심 {lunch_minutes}분")
if break_minutes > 0:
details.append(f"외출 {break_minutes}분")
if overtime_actual > 0:
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
self.detail_label.setText(" · ".join(details) if details else "")
self.detail_label.setVisible(bool(details))
if salary_text:
self.salary_label.setText(f"💰 {salary_text}")
self.salary_label.setVisible(True)
else:
self.salary_label.setVisible(False)
self.setVisible(True)
+115
View File
@@ -0,0 +1,115 @@
"""
Discord 웹훅 알림 (단방향 push).
URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안 막음).
"""
from __future__ import annotations
import json
import urllib.request
import urllib.error
from datetime import datetime
from typing import Optional, List
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
# 일반 브라우저 UA로 위장해야 통과.
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
# Discord embed 색상 (decimal)
COLOR_GREEN = 0x57F287
COLOR_BLUE = 0x5865F2
COLOR_YELLOW = 0xFEE75C
COLOR_PINK = 0xEB459E
COLOR_ORANGE = 0xED4245
def send(webhook_url: str, title: str, description: str,
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
timeout: int = 5) -> bool:
"""Discord 웹훅으로 embed 메시지 발송.
Args:
webhook_url: Discord webhook URL (https://discord.com/api/webhooks/{ID}/{TOKEN})
title, description: embed 본문
color: embed 좌측 색상 바 (10진수, 0xRRGGBB)
fields: [{"name": "필드명", "value": "값", "inline": True}, ...]
timeout: 요청 타임아웃(초)
Returns:
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
"""
if not webhook_url or not webhook_url.startswith('https://'):
return False
payload = {
"embeds": [{
"title": title,
"description": description,
"color": color,
"fields": fields or [],
"timestamp": datetime.utcnow().isoformat(),
"footer": {"text": "Clock-out Time Calculator"},
}]
}
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
req = urllib.request.Request(
webhook_url, data=data,
headers={
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': USER_AGENT,
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
return False
def send_test(webhook_url: str) -> bool:
"""온보딩/설정에서 호출하는 테스트 메시지."""
return send(
webhook_url,
title="🔔 테스트 알림",
description="Clock-out Time Calculator의 Discord 연동이 정상 작동합니다.",
color=COLOR_GREEN,
)
def send_clock_in(webhook_url: str, time_str: str) -> bool:
return send(
webhook_url,
title="🟢 출근",
description=f"오늘 {time_str}에 출근 기록되었습니다.",
color=COLOR_GREEN,
)
def send_clock_out(webhook_url: str, time_str: str, total_hours: float,
overtime_minutes: int, overtime_earned: int) -> bool:
fields = [
{"name": "총 근무시간", "value": f"{total_hours:.1f}시간", "inline": True},
]
if overtime_minutes > 0:
fields.append({
"name": "연장근무",
"value": f"{overtime_minutes}분 → {overtime_earned}분 적립",
"inline": True,
})
color = COLOR_YELLOW if overtime_minutes > 0 else COLOR_BLUE
return send(
webhook_url,
title="✅ 퇴근",
description=f"오늘 {time_str}에 퇴근 처리되었습니다.",
color=color,
fields=fields,
)
def send_health_warning(webhook_url: str, hours_continuous: float) -> bool:
return send(
webhook_url,
title="🌿 휴식 권고",
description=f"{hours_continuous:.1f}시간 연속 근무 중입니다.\n잠시 일어나서 스트레칭하세요.",
color=COLOR_PINK,
)
+28 -10
View File
@@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool:
return _parse_version(remote) > _parse_version(local) return _parse_version(remote) > _parse_version(local)
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]: # 업데이트 체크 결과 상수 (info=None 일 때 reason 식별)
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None. UP_TO_DATE = 'up_to_date'
NETWORK_ERROR = 'network_error'
NO_RELEASE = 'no_release' # latest 응답 자체가 없음 (404 등)
NO_ASSET = 'no_asset' # release는 있으나 main.exe 자산 없음
네트워크 오류 시 None 반환 (앱 시작을 막지 않음).
def check_for_update(current_version: str, timeout: int = 5):
"""Releases API 조회.
Returns:
(ReleaseInfo, None) — 새 버전 있음
(None, UP_TO_DATE) — 이미 최신
(None, NETWORK_ERROR) — 네트워크/타임아웃 실패
(None, NO_RELEASE) — 저장소 비공개/리소스 없음 (404)
(None, NO_ASSET) — 새 버전이지만 main.exe 자산 미첨부
""" """
try: try:
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT}) req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read()) data = json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError): except urllib.error.HTTPError as e:
return None if e.code == 404:
return None, NO_RELEASE
return None, NETWORK_ERROR
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError):
return None, NETWORK_ERROR
tag = data.get('tag_name', '') tag = data.get('tag_name', '')
if not tag or not is_newer(tag, current_version): if not tag:
return None return None, NO_RELEASE
if not is_newer(tag, current_version):
return None, UP_TO_DATE
asset_url = None asset_url = None
for asset in data.get('assets', []): for asset in data.get('assets', []):
if asset.get('name') == ASSET_NAME: if asset.get('name') == ASSET_NAME:
asset_url = asset.get('browser_download_url') asset_url = asset.get('browser_download_url')
break break
if not asset_url: if not asset_url:
return None return None, NO_ASSET
return ReleaseInfo( info = ReleaseInfo(
version=tag, version=tag,
asset_url=asset_url, asset_url=asset_url,
notes=data.get('body', ''), notes=data.get('body', ''),
published_at=data.get('published_at', ''), published_at=data.get('published_at', ''),
) )
return info, None
def download_update(asset_url: str, dest_dir: Optional[Path] = None, def download_update(asset_url: str, dest_dir: Optional[Path] = None,