From 14d88656fe280eae4be219e0ac875e8316103d96 Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Thu, 30 Apr 2026 17:29:44 +0900 Subject: [PATCH] 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) --- CHANGELOG.md | 20 +++++++++++++ core/database.py | 8 ++--- core/notifier.py | 13 +++++++-- core/settings_keys.py | 3 -- core/time_calculator.py | 12 ++++---- core/version.py | 2 +- tests/test_updater.py | 30 ++++++++++++++++++- ui/main_window.py | 65 +++++++++++++++++++++++++++++++---------- ui/settings_view.py | 23 +++++++++++++++ utils/updater_client.py | 38 +++++++++++++++++------- 10 files changed, 172 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 540adfa..7e32bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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.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 diff --git a/core/database.py b/core/database.py index a93829f..be6bbaa 100644 --- a/core/database.py +++ b/core/database.py @@ -520,21 +520,19 @@ class Database: 'work_minutes': '480', 'lunch_duration_minutes': '60', 'dinner_duration_minutes': '60', - 'auto_detect_boot': 'true', 'auto_lunch': 'false', + 'auto_overtime': 'true', 'theme': 'light', - 'notification_enabled': 'true', 'notification_before_minutes': '30', 'notification_clock_out': 'true', 'notification_lunch': 'true', 'notification_overtime': 'true', 'notification_health': 'true', 'annual_leave_total': '15', - 'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화) - 'annual_leave_used': '0', + 'annual_leave_days': '15', # annual_leave_total과 자동 동기화 'workday_boundary_hour': '6', 'overtime_unit': '30', - 'time_format': '24' + 'time_format': '24', } conn = self.get_connection() diff --git a/core/notifier.py b/core/notifier.py index c267813..03f3d6b 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -65,8 +65,17 @@ class Notifier(QObject): return time_diff = clock_out_time - current_time - # 30분 이내, 아직 알림 안 했으면 - if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min: + # 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위) + 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) self.notification_signal.emit( tr('notif.clock_out_soon.title'), diff --git a/core/settings_keys.py b/core/settings_keys.py index d5b1a6c..ae926a4 100644 --- a/core/settings_keys.py +++ b/core/settings_keys.py @@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes' WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour' # 자동화 -AUTO_DETECT_BOOT = 'auto_detect_boot' AUTO_LUNCH = 'auto_lunch' AUTO_OVERTIME = 'auto_overtime' AUTO_BREAK_ON_LOCK = 'auto_break_on_lock' CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용) # 알림 -NOTIFICATION_ENABLED = 'notification_enabled' NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes' NOTIF_CLOCK_OUT = 'notification_clock_out' NOTIF_LUNCH = 'notification_lunch' @@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health' # 연차 ANNUAL_LEAVE_TOTAL = 'annual_leave_total' ANNUAL_LEAVE_DAYS = 'annual_leave_days' -ANNUAL_LEAVE_USED = 'annual_leave_used' LEAVE_BALANCE = 'leave_balance' INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes' INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours' diff --git a/core/time_calculator.py b/core/time_calculator.py index 6b6eea8..e3b9a52 100644 --- a/core/time_calculator.py +++ b/core/time_calculator.py @@ -135,17 +135,19 @@ class TimeCalculator: def calculate_overtime(self, clock_in: datetime, clock_out: datetime, 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: clock_in: 출근 시간 clock_out: 퇴근 시간 include_lunch: 점심시간 포함 여부 include_dinner: 저녁시간 포함 여부 break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외 + unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT. 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) @@ -155,8 +157,8 @@ class TimeCalculator: overtime_duration = clock_out - expected_clock_out overtime_minutes = int(overtime_duration.total_seconds() / 60) - # 30분 단위로 절삭 - overtime_earned = (overtime_minutes // 30) * 30 + unit = unit_minutes if unit_minutes > 0 else 30 + overtime_earned = (overtime_minutes // unit) * unit return overtime_minutes, overtime_earned diff --git a/core/version.py b/core/version.py index 99ea382..a3d3674 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.2.3' +__version__ = '2.2.4' diff --git a/tests/test_updater.py b/tests/test_updater.py index 2d70399..cc67438 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -8,7 +8,10 @@ import pytest 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: @@ -59,6 +62,31 @@ class TestApiUrl: 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: """updater.py 자체 로직.""" diff --git a/ui/main_window.py b/ui/main_window.py index 8a6a27b..03b8825 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1081,18 +1081,21 @@ class MainWindow(QMainWindow): # 오늘의 외출 시간 가져오기 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: # 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외) work_minutes = int(total_hours * 60) 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: - work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외 - work_minutes -= break_minutes # 외출시간 제외 - # 음수 방지 + work_minutes -= self.time_calc.dinner_duration_minutes + work_minutes -= break_minutes work_minutes = max(0, work_minutes) - # 30분 단위로 절삭 - overtime_earned = (work_minutes // 30) * 30 + overtime_earned = (work_minutes // unit_minutes) * unit_minutes overtime_actual = work_minutes else: # 평일: 정상 연장근무 계산 (외출 시간 포함) @@ -1100,9 +1103,27 @@ class MainWindow(QMainWindow): self.clock_in_time, now, include_lunch=self.lunch_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 업데이트 today = datetime.now().date().isoformat() clock_out_str = now.strftime("%H:%M:%S") @@ -1601,16 +1622,30 @@ class MainWindow(QMainWindow): def check_for_updates(self, silent: bool = False): """업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용).""" 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 not silent: - QMessageBox.information( - self, - "업데이트 확인", - f"현재 최신 버전입니다 (v{__version__})." - ) + if silent: + return + # 사용자가 명시적으로 트리거한 경우만 메시지 표시 + messages = { + 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 # 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만 diff --git a/ui/settings_view.py b/ui/settings_view.py index 099d709..7a467cc 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -18,6 +18,7 @@ from core.settings_keys import ( WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME, NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH, + NOTIFICATION_BEFORE_MINUTES, THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK, @@ -275,6 +276,22 @@ class SettingsView(QDialog): check_row2.addWidget(self.health_notification_check) 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() time_format_label = QLabel("시간 형식:") @@ -796,6 +813,11 @@ class SettingsView(QDialog): self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, 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') @@ -879,6 +901,7 @@ class SettingsView(QDialog): NOTIF_LUNCH: self.lunch_notification_check.isChecked(), NOTIF_OVERTIME: self.overtime_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(), OVERTIME_UNIT: self.overtime_unit_combo.currentData(), AUTO_OVERTIME: self.auto_overtime_check.isChecked(), diff --git a/utils/updater_client.py b/utils/updater_client.py index e044ff6..619dfa9 100644 --- a/utils/updater_client.py +++ b/utils/updater_client.py @@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool: return _parse_version(remote) > _parse_version(local) -def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]: - """GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None. +# 업데이트 체크 결과 상수 (info=None 일 때 reason 식별) +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: req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read()) - except (urllib.error.URLError, json.JSONDecodeError, TimeoutError): - return None + except urllib.error.HTTPError as e: + 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', '') - if not tag or not is_newer(tag, current_version): - return None + if not tag: + return None, NO_RELEASE + if not is_newer(tag, current_version): + return None, UP_TO_DATE asset_url = None for asset in data.get('assets', []): if asset.get('name') == ASSET_NAME: asset_url = asset.get('browser_download_url') break - if not asset_url: - return None + return None, NO_ASSET - return ReleaseInfo( + info = ReleaseInfo( version=tag, asset_url=asset_url, notes=data.get('body', ''), published_at=data.get('published_at', ''), ) + return info, None def download_update(asset_url: str, dest_dir: Optional[Path] = None,