Compare commits

...

11 Commits
v2.7.0 ... main

Author SHA1 Message Date
KINDNICK
f5751460e3 v2.11.2: 통계 차트 frozen 실제 수정(numpy _multiarray_tests) + 도전과제 라이트 가독성
- fix: frozen main.exe에서 numpy.core._multiarray_tests 누락으로 matplotlib import 실패 → 차트 폴백. spec에 hiddenimport 추가 + numpy.testing 제외 제거 (디버그 로그로 원인 확인)
- fix: 도전과제 라이트 테마 헤더 숫자/배지/진행 텍스트/바 대비 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:22:00 +09:00
KINDNICK
e7e85dcf7b v2.11.1: 통계 차트(frozen) 수정 + 통계/도움말/도전과제 테마 대응
- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅)
- fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종
- dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:08:24 +09:00
KINDNICK
130c61ea62 test: disable holiday auto-sync in _integration_test (fixes S2/S31 WinError 32 temp-DB lock)
Database.__init__의 공휴일 동기화 백그라운드 스레드가 SQLite 연결을 잡고 있어
임시 DB os.remove가 실패하던 문제. 문서화된 CLOCKOUT_DISABLE_HOLIDAY_SYNC 플래그를 테스트 시작 시 설정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:27:43 +09:00
KINDNICK
5fb8655a47 v2.11.0: UI 전면 다크 리디자인 + 라인 아이콘 + 적립 가드/삭제
- 모던 다크 미니멀 테마(NanumSquare 번들, 단일 accent #4DABF7, 8px radius, flat 버튼, 다크 기본값)
- 라인 아이콘 시스템(ui/icons.py, QtSvg) — 앱 전반 이모지 교체
- 다크 깨짐 수정: 테이블 헤더/코너 흰색, 도움말 탭 흰 라인, 트레이/미니위젯 메뉴
- fix: 자동 적립 OFF가 자동 퇴근 경로에서 무시되던 버그(게이팅)
- feat: 연장근무 적립 기록 삭제(우클릭)
- 테스트 3건 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:21:54 +09:00
KINDNICK
da5f91984b v2.10.2: 휴일 근무 카운터 초 표시 수정
원격 v2.10.1(cmd창 깜빡임 hotfix) 위에 rebase → 2.10.2.
- 휴일 분기 표시용 remaining을 초 정밀도 timedelta로 분리
  (적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
- pytest 189 + 통합 53 green
2026-05-16 18:16:45 +09:00
68893236+KINDNICK@users.noreply.github.com
3db4ed2351 v2.10.1: 업데이트 시 cmd 창 깜빡임 제거 (hotfix)
- updater.spec: console=True → console=False (windowed 빌드)
- updater.py: stderr 출력을 ~/.clockout_logs/updater.log 파일 폴백으로 전환
  (windowed 모드라도 진단 로그 보존). 모든 단계 타임스탬프 기록.
- updater.py launch(): subprocess.Popen에 CREATE_NO_WINDOW 플래그 추가
- utils/updater_client.py apply_update(): 같은 패턴으로 CREATE_NO_WINDOW 추가
  main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:33:55 +09:00
KINDNICK
97dd4e39f7 v2.10.0: \uc815\ubd80 \ud2b9\uc77c\uc815\ubcf4 API \uc5f0\ub3d9 + \uc77c \uc790\ub3d9 \ub3d9\uae30\ud654
\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0
\uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback.

- utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item)
- Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain
- migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654
  (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var)
- Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8

Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green
\ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green

\uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5,
fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
2026-05-01 13:51:33 +09:00
KINDNICK
47296dd35b v2.9.0: \ud734\uc77c hot-path \uc218\uc815 + \uc5f0\ucc28 \ubbf8\ub9ac\ub4f1\ub85d + \ubc18\ubcf5 \ud328\ud134 + \uadfc\ub85c\uc790\uc758\ub0a0 \ucd94\uac00
\uc0ac\uc6a9\uc790 \ubcf4\uace0: \ud734\uc77c\uc5d0 \ucd9c\uadfc\ud574\ub3c4 \uc815\uc0c1 \ucd9c\uadfc\uc73c\ub85c \ucc98\ub9ac\ub418\uc5b4 \ucd94\uac00\uadfc\ubb34 \uc801\ub9bd \uc548\ub428.
- update_display() 1Hz \ub8e8\ud504\uc5d0 is_non_working_day \ubd84\uae30 \ub204\ub77d\uc774 \uc6d0\uc778 (d41e5cb)
- \uc5f0\ucc28 \ubbf8\ub9ac\ub4f1\ub85d \uc2dc\uc2a4\ud15c\uacfc \ud568\uaed8 v2.9.0\uc73c\ub85c \ud1b5\ud569 \ub9b4\ub9ac\uc2a4 (c98ca36)
- holidays.KR\uc774 \uc54a \uc7a1\ub294 \uadfc\ub85c\uc790\uc758 \ub0a0(5/1) \uba85\uc2dc\uc801 \uc790\ub3d9 \ucd94\uac00

\ub9b4\ub9ac\uc2a4 \uadf8\ub8f9: 122\u2192175 pytest, 48\u219253 \ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 (\uc0c1\uc138 CHANGELOG \ucc38\uc870)
2026-05-01 13:13:01 +09:00
KINDNICK
c98ca361cd feat(leave): \uc5f0\ucc28 \ubbf8\ub9ac \ub4f1\ub85d + \uc218\uc544\ud55c \uc790\ub3d9 \uc801\uc6a9 + \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
  get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
    \u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
    \u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
    \u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
    \u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
    \u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
    \u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac

Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
    \u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
    \u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
    \u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
    \u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc

Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
2026-05-01 13:07:52 +09:00
KINDNICK
d41e5cb921 fix(holiday): live hot-path now banks overtime from minute 1 on holidays
3 bugs found and fixed:

A. update_display() 1Hz 루프에 is_non_working_day 분기 누락
   - 휴일/주말 출근 시 '남은 시간 8h'부터 카운트다운 → 실제 휴일 추가근무 시작 안 됨
   - 수정: is_non_working_day=True면 calculate_holiday_overtime로 즉시 음수 remaining 표시
   - 그룹 타이틀: '주말 근무 (전체 적립)' / '공휴일 근무 (전체 적립)'
   - 진행바: 휴일은 100% 고정 (의미 없음)
   - 예상 퇴근: '휴일 근무 (정해진 퇴근시각 없음)'

B. check_clock_out_soon 알림이 휴일에도 fire
   - 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림
   - 수정: orchestrator.tick(is_holiday=True)면 스킵
   - 점심/저녁/장시간 휴식 알림은 휴일에도 유지 (식사·건강은 휴일에도 챙김)

C. 자동복구 퇴근 3곳이 (work_minutes // 30) * 30 하드코딩
   - main_window.py:1385, 1512, 1581 — 사용자 overtime_unit (15/30/60) 무시
   - 수정: 모두 settings에서 unit_minutes 읽어 calculate_holiday_overtime/calculate_overtime에 전달

리팩터링: 4곳에 중복되던 휴일 연장 계산 로직을 TimeCalculator.calculate_holiday_overtime로 추출.

Tests:
- tests/test_time_calculator.py: 9개 신규 (TestHolidayOvertime)
- _integration_test.py: S52A 휴일 hot-path 회귀 시나리오
2026-05-01 12:54:24 +09:00
68893236+KINDNICK@users.noreply.github.com
c5df37ca57 v2.8.0: 도전과제 시스템 + 다크 디자인 리뉴얼 + 안정성 강화
Added — 도전과제 시스템 (153개 자동 평가)
- core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제
- ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿)
- 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push
- achievements 테이블 확장 (code/category/tier/is_secret/progress/target)
- hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키

Changed — 다크 테마 디자인 리뉴얼
- ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress)
- 통계/도움말/도전과제 다이얼로그 일관 다크 톤
- matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend)
- 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드)

Fixed — 안정성·일관성
- 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch)
- DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환
- DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자)
- crash_handler 다단계 폴백 (DB → 파일 → stderr)
- updater PID race: 지수 backoff 재시도 (총 ~9초)
- Discord URL 형식 검증 (snowflake regex)
- 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증
- check_dinner_reminder 신규, 알림 임계값 5개 설정화
- closeEvent timer/notifier 정리 (aboutToQuit hook)
- 마이그레이션 12개 모두 _conn() + try/finally
- DB 인덱스 5개 추가 (break/overtime/leave date)

Tests
- pytest 116/116 PASS, 통합 시나리오 48/48 PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:11:13 +09:00
71 changed files with 7185 additions and 1752 deletions

View File

@ -4,6 +4,204 @@ 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.11.2] — 2026-06-04
### Fixed
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
## [2.11.1] — 2026-06-04
### Fixed
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget``backend_qt5agg`
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
폴백) + 실패 원인 로깅, `main.spec``backend_qtagg`/`PyQt5.sip` 명시.
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제**`dark_components`
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
## [2.11.0] — 2026-06-04
### Changed — UI 전면 다크 리디자인
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
`main.spec`에 동봉
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
### Added
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec``PyQt5.QtSvg` 포함)
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
(`Database.delete_overtime_earned`)
### Fixed
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
### Tests
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
## [2.10.2] — 2026-05-16
### Fixed
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
그대로 표시에 사용 → 초 정보 소실
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
## [2.10.1] — 2026-05-01
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
- **`updater.spec`**: `console=True``console=False` (windowed 빌드).
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
에 타임스탬프 + 결과 기록.
- **`updater.py launch()`**: `subprocess.Popen``CREATE_NO_WINDOW` 플래그 추가
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
## [2.10.0] — 2026-05-01
### Added — 정부 공휴일 API 자동 동기화
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
- 키는 dev 본인 계정의 특일정보 API 한정 키
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
- sentinel: `settings['holidays_synced_date']`
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
### Changed
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
### Tests
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
- pytest: 175 → **189**
### 주의
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
## [2.9.0] — 2026-05-01
### Fixed — 휴일 hot-path 버그 (사용자 보고)
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
- 진행바: 휴일은 100% 고정 (의미 없음)
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
#### Phase 1 — 연차 미리등록 + 자동 적용
- **DB:** `get_leave_minutes_for(date)` / `has_full_day_leave(date)` /
`get_leave_records_by_date(date)` / `get_leave_records_by_range(start, end)`
- **TimeCalculator:** `effective_work_minutes(date_obj, db)` — 부분 연차만큼 정규 근무 차감
- **종일 연차일 자동 처리:**
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
#### Phase 2 — 통합 스케줄 + 반복 연차
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
### Changed
- **근로자의 날(5/1) 자동 추가**`holidays.KR` 패키지가 누락하는 노동자 휴일을
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
### Tests
- pytest: 122 → **175** (+53)
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
- 통합 시나리오: 48 → **53** (+5)
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
## [2.8.0] — 2026-05-01
### Added — 도전과제 시스템 + 디자인 리뉴얼
- **🏆 도전과제 시스템** (153개 자동 평가) — 출근·퇴근·연장·연차·식사·외출·계절·시간대·시크릿 등 16개 카테고리.
- `core/achievements.py`: `Achievement` dataclass + `evaluate_all(db)` + `sync_definitions_to_db(db)`.
- 5분 throttle로 자동 평가, 신규 잠금 해제 시 시스템 알림 + Discord embed push.
- `achievements` 테이블 확장: `code`(UNIQUE), `category`, `tier`, `is_secret`, `progress`, `target`, `created_at` 컬럼 추가 (idempotent 마이그레이션).
- 5단계 등급: 🥉 브론즈 / 🥈 실버 / 🥇 골드 / 💎 플래티넘 / 🌟 레전드.
- 시크릿 9개 (회문, 잭팟 시각, 13일의 금요일, 7-7-7, 정확 8시간, π day, 피보나치 등).
- 메타 도전과제 (도전과제 자체 달성 카운트).
- **`ui/achievements_view.py`** — 4탭 다이얼로그 (전체 / 진행 중 / 완료 / 시크릿).
- 등급별 그라디언트 카드, 진행 게이지, 카테고리 태그, 시크릿 ❓ 처리.
- **자동 hire_date 추적** — 첫 `add_work_record` 호출 시 settings에 자동 기록 (1주년, 365일 후 출근 등 도전과제 활성화).
- **뷰 진입 카운터**`stat_*_view_count`, `calendar_view_count`, `daily_report_count` 등 8개 settings 키. 도전과제 + 사용 통계용.
### Changed — 다크 테마 디자인 리뉴얼
- **`ui/dark_components.py`** 신설 — 재사용 가능한 다크 디자인 컴포넌트:
- `dialog_qss()`, `tabs_qss()`, `scroll_qss()`, `button_qss(variant)`.
- `build_gradient_header()`, `build_stat_card()`, `build_section_card()`.
- `style_progressbar()`, `transparent_label()` — 글로벌 QSS 충돌 회피.
- 카드 테마 7종: blue / cyan / green / gold / pink / red / gray.
- **`ui/stats_view.py`** — 4분할 카드 + 차트 섹션 카드. 골드 강조 탭. ghost 닫기 버튼.
- **`ui/help_view.py`** — 다크 톤 + HelpHTML 내부에 다크 CSS 주입 (h1/h2/h3, code, blockquote, table 컬러).
- **`ui/chart_widget.py`** — 모든 matplotlib 차트 다크 테마 적용 (figure/axes facecolor, grid, ticks, legend).
- **온보딩 위저드** — 저녁 분(minutes) 입력 옵션 + i18n화 (Korean/English 프리셋 라벨).
### Fixed — 안정성·일관성
- **타임존 자정 경계 버그**: `has_notification_today``CURRENT_TIMESTAMP`(UTC) vs `DATE('now', 'localtime')` mismatch로 KST 0~9시 사이 알림 중복 발송 가능 — `DATE(sent_at, 'localtime')` 양쪽 적용.
- **DB 연결 누수 가드**`_conn()` 컨텍스트 매니저 도입, 40+ 메서드 변환 (직접 `get_connection()` 호출 0건).
- **DB 이중 부트스트랩 제거**`MainWindow(db=None)` 옵션 인자 추가, main.py가 만든 db를 재사용.
- **crash_handler 폴백** — DB 로깅/다이얼로그 단계 분리, 모두 실패해도 `~/.clockout_logs/crashes.log`에 기록.
- **updater PID race window** — 지수 backoff 재시도 (0.3→4.8s, 총 ~9초). Windows Defender 락 해제 대기 충분.
- **Discord URL 형식 검증** — Snowflake ID 17~20자리 + 50+자 토큰 정규식.
- **MealTimeDialog** — 출근 시각 범위 검증 + 야간 출근자 자정 경계 자동 처리.
- **CSV importer/exporter**`dinner_minutes` 컬럼 round-trip 지원.
- **일일 보고서** — 저녁 섹션 추가 (이전엔 점심만 표시), `break_type` 분리, 자정 경계 처리.
- **저녁 알림**`check_dinner_reminder()` 신규 (점심 알림과 대칭).
- **알림 임계값 설정화** — 5개 하드코딩 값(점심/저녁 알림 시간, 연장 누적, 주간 한도, 연속 야근)을 settings로 노출.
- **closeEvent 정리**`aboutToQuit` 시그널로 timer/notifier/tray 정리.
- **마이그레이션 일관성** — 12개 마이그레이션 모두 `_conn()` + try/finally 보장.
### DB 인덱스 (성능)
- `idx_break_records_date_type`, `idx_break_records_date`.
- `idx_overtime_bank_date`, `idx_overtime_usage_date`, `idx_leave_records_date`.
### Tests
- pytest 116개 PASS, 통합 시나리오 44/48 PASS (PyQt 환경 4개 제외).
- 도전과제 한 사이클 시나리오 검증 (30일 시뮬레이션 → 19개 자동 잠금 해제).
## [2.7.0] — 2026-04-30
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)

View File

@ -67,6 +67,11 @@ $env:GITEA_TOKEN = '<PAT>'
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
- **[meal_controller.py](ui/controllers/meal_controller.py)** — Lunch/dinner toggle + label refresh, extracted from `main_window.py` in v2.7.0. Same controller pattern as Lock/AutoLunch/Notification.
### ui/ (cross-cutting)
- **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
- **[styles.py](ui/styles.py)** — Shared QSS / color tokens.
### utils/
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
@ -74,6 +79,8 @@ $env:GITEA_TOKEN = '<PAT>'
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
- **[csv_exporter.py](utils/csv_exporter.py)** — Same standard format as importer. Round-trips with `csv_importer`.
- **[resource_manager.py](utils/resource_manager.py)** — PyInstaller `_MEIPASS`-aware path resolver for icons / assets.
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
@ -137,7 +144,9 @@ Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+).
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
All should be green before any release.

View File

@ -15,6 +15,11 @@ from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
PASS = []
FAIL = []
WARN = []
@ -696,6 +701,74 @@ def s51_accessibility_keys():
assert db.get_setting_bool('high_contrast') is True
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
def s52b_planned_leave():
db = fresh_db('s52b')
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert db.has_full_day_leave('2026-05-15')
assert db.get_leave_minutes_for('2026-05-15') == 480
# 다른 날엔 영향 없음
assert not db.has_full_day_leave('2026-05-16')
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
def s52c_recurring_leave():
db = fresh_db('s52c')
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
# 2026-05-01 = Friday
assert db.get_leave_minutes_for('2026-05-01') == 240
# Monday
assert db.get_leave_minutes_for('2026-05-04') == 0
# 종일 아님
assert not db.has_full_day_leave('2026-05-01')
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
def s52d_effective():
from core.time_calculator import TimeCalculator
db = fresh_db('s52d')
db.add_leave_record('2026-05-15', '오전반차', 0.5)
calc = TimeCalculator(work_minutes=480)
target = datetime(2026, 5, 15)
assert calc.effective_work_minutes(target, db) == 240
# 다른 날엔 변화 없음
other = datetime(2026, 5, 16)
assert calc.effective_work_minutes(other, db) == 480
@case("S52E. effective_work_minutes: 종일 연차 시 0")
def s52e_full_day():
from core.time_calculator import TimeCalculator
db = fresh_db('s52e')
db.add_leave_record('2026-05-15', '연차', 1.0)
calc = TimeCalculator(work_minutes=480)
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
def s52a_holiday_hotpath():
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
from core.time_calculator import TimeCalculator
db = fresh_db('s52a')
holiday_date = '2026-05-01' # 근로자의 날
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
ci = datetime(2026, 5, 1, 9, 0)
# 휴일 인식
assert calc.is_non_working_day(ci, db)
assert calc.get_day_type(ci, db) == 'holiday'
# 출근 1분 후: 적립 0 (30분 단위 절삭)
now1 = ci + timedelta(minutes=1)
actual, earned = calc.calculate_holiday_overtime(ci, now1)
assert actual == 1 and earned == 0
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
now30 = ci + timedelta(minutes=30)
actual, earned = calc.calculate_holiday_overtime(ci, now30)
assert actual == 30 and earned == 30
@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
from utils.csv_importer import parse_csv, import_records
@ -764,6 +837,11 @@ def main():
s49_discord_empty()
s50_goals()
s51_accessibility_keys()
s52a_holiday_hotpath()
s52b_planned_leave()
s52c_recurring_leave()
s52d_effective()
s52e_full_day()
s52_csv_overtime()
print()

1216
core/achievements.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -21,14 +21,14 @@ _DICT = {
'menu.help': '도움말',
'menu.settings': '설정',
'btn.clock_out': '퇴근하기',
'btn.clock_out_cancel': '🔄 퇴근 취소',
'btn.clock_out_cancel': '퇴근 취소',
'btn.lunch_add': '점심시간 추가',
'btn.lunch_applied': '점심시간 (적용됨)',
'btn.dinner_add': '저녁시간 추가',
'btn.dinner_applied': '저녁시간 (적용됨)',
'btn.break_out': '🚪 외출 시작',
'btn.break_in': '↩️ 복귀',
'btn.save': '💾 저장',
'btn.break_out': '외출 시작',
'btn.break_in': '복귀',
'btn.save': '저장',
'btn.close': '닫기',
'btn.apply': '적용',
'btn.cancel': '취소',
@ -40,10 +40,10 @@ _DICT = {
# === 윈도우/다이얼로그 제목 ===
'window.main_title': '퇴근시간 계산기',
'window.settings': '⚙️ 설정',
'window.help': '📖 사용 설명서',
'window.stats': '📊 근무 통계',
'window.calendar': '📅 캘린더',
'window.settings': '설정',
'window.help': '사용 설명서',
'window.stats': '근무 통계',
'window.calendar': '캘린더',
'window.mini_widget': '퇴근시간',
'window.clock_in_dialog': '출근 시간',
'window.break_view': '외출 관리',
@ -82,6 +82,24 @@ _DICT = {
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
'notif.lunch_reminder.title': '🍱 점심시간 등록',
'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?',
'notif.dinner_reminder.title': '🍽️ 저녁시간 등록',
'notif.dinner_reminder.body': '저녁시간을 등록하지 않으셨네요.\n저녁시간을 추가하시겠어요?',
# === 온보딩 근무 패턴 프리셋 ===
'onboarding.preset.standard_8h': '표준 8시간 (점심 60분)',
'onboarding.preset.short_7h30m': '단축근무 7시간 30분 (점심 30분)',
'onboarding.preset.short_7h': '단축근무 7시간 (점심 60분)',
'onboarding.preset.short_6h': '단축근무 6시간 (점심 30분)',
'onboarding.preset.half_4h': '반일 4시간 (점심 0분)',
'onboarding.preset.custom_box': '사용자 정의',
'onboarding.preset.custom_radio': '직접 입력:',
'onboarding.preset.suffix_hours': ' 시간',
'onboarding.preset.suffix_minutes': '',
'onboarding.preset.lunch_prefix': '점심 ',
'onboarding.preset.dinner_prefix': '저녁 ',
'onboarding.preset.dinner_tooltip': '야근으로 저녁 식사가 자주 발생하면 입력 (보통 0~60분)',
'notif.health_break.title': '🌿 휴식 권고',
'notif.health_break.body': '{hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.',
'notif.overtime_earning.title': '🔥 연장근무 적립 예정',
'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
@ -107,8 +125,8 @@ _DICT = {
# === 트레이 ===
'tray.open': '프로그램 열기',
'tray.mini_widget': '📌 미니 위젯',
'tray.toggle_lunch': '🍱 점심시간 토글',
'tray.mini_widget': '미니 위젯',
'tray.toggle_lunch': '점심시간 토글',
'tray.quit': '종료',
'tray.tooltip_remaining': '퇴근까지: {time}',
'tray.tooltip_overtime': '추가 근무 중: {time}',
@ -148,12 +166,12 @@ _DICT = {
'cal.edit_record': '기록 편집',
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
'help.tab_intro': '👋 시작하기',
'help.tab_work_hours': '🕘 근무시간',
'help.tab_overtime': '🏦 연장근무',
'help.tab_leave': '🌴 연차/휴가',
'help.tab_break': '🚪 외출/저녁',
'help.tab_faq': '자주 묻는 질문',
'help.tab_intro': '시작하기',
'help.tab_work_hours': '근무시간',
'help.tab_overtime': '연장근무',
'help.tab_leave': '연차/휴가',
'help.tab_break': '외출/저녁',
'help.tab_faq': '자주 묻는 질문',
# === clock_in_dialog ===
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
@ -186,17 +204,18 @@ _DICT = {
'view.overtime.title': '연장근무 내역',
'view.overtime.balance_zero': '잔액: 0분',
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
'view.overtime.earned_group': '💰 적립 내역',
'view.overtime.used_group': '📤 사용 내역',
'view.overtime.earned_group': '적립 내역',
'view.overtime.used_group': '사용 내역',
'view.overtime.col_date': '날짜',
'view.overtime.col_earned': '적립',
'view.overtime.col_used': '사용',
'view.overtime.col_memo': '메모',
'view.overtime.col_reason': '사유',
'view.overtime.btn_add_earned': ' 수동 적립',
'view.overtime.btn_add_used': ' 수동 사용',
'view.overtime.menu_delete': '삭제',
'view.overtime.btn_add_earned': '수동 적립',
'view.overtime.btn_add_used': '수동 사용',
'view.overtime.menu_delete': '삭제',
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
'view.overtime.delete_earned_confirm_body': '다음 적립 기록을 삭제하시겠습니까?\n\n날짜: {date}\n적립: {time}\n\n삭제 시 잔액에서 차감됩니다.',
'view.overtime.manual_earned_title': '추가근무 수동 적립',
'view.overtime.manual_used_title': '추가근무 수동 사용',
'view.overtime.field_date': '날짜:',
@ -220,13 +239,13 @@ _DICT = {
'view.leave.balance_zero': '잔여: 0일',
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
'view.leave.btn_set_balance': '잔여 설정',
'view.leave.used_group': '📤 사용 내역',
'view.leave.used_group': '사용 내역',
'view.leave.col_date': '날짜',
'view.leave.col_type': '구분',
'view.leave.col_used': '사용',
'view.leave.col_reason': '사유',
'view.leave.btn_add': ' 연차 사용 추가',
'view.leave.btn_calendar': '📅 캘린더 보기',
'view.leave.btn_add': '연차 사용 추가',
'view.leave.btn_calendar': '캘린더 보기',
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
'view.leave.set_title': '연차 시간 설정',
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
@ -260,14 +279,14 @@ _DICT = {
'menu.help': 'Help',
'menu.settings': 'Settings',
'btn.clock_out': 'Clock Out',
'btn.clock_out_cancel': '🔄 Cancel Clock-out',
'btn.clock_out_cancel': 'Cancel Clock-out',
'btn.lunch_add': 'Add Lunch',
'btn.lunch_applied': 'Lunch (Applied)',
'btn.dinner_add': 'Add Dinner',
'btn.dinner_applied': 'Dinner (Applied)',
'btn.break_out': '🚪 Start Break',
'btn.break_in': '↩️ Return',
'btn.save': '💾 Save',
'btn.break_out': 'Start Break',
'btn.break_in': 'Return',
'btn.save': 'Save',
'btn.close': 'Close',
'btn.apply': 'Apply',
'btn.cancel': 'Cancel',
@ -279,10 +298,10 @@ _DICT = {
# === Windows ===
'window.main_title': 'Clock-out Time Calculator',
'window.settings': '⚙️ Settings',
'window.help': '📖 User Guide',
'window.stats': '📊 Statistics',
'window.calendar': '📅 Calendar',
'window.settings': 'Settings',
'window.help': 'User Guide',
'window.stats': 'Statistics',
'window.calendar': 'Calendar',
'window.mini_widget': 'Clock-out',
'window.clock_in_dialog': 'Clock-in Time',
'window.break_view': 'Break Management',
@ -321,6 +340,24 @@ _DICT = {
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?",
'notif.dinner_reminder.title': '🍽️ Dinner Reminder',
'notif.dinner_reminder.body': "You haven't registered dinner yet.\nWant to add it?",
# === Onboarding work pattern presets ===
'onboarding.preset.standard_8h': 'Standard 8h (Lunch 60min)',
'onboarding.preset.short_7h30m': 'Reduced 7h 30m (Lunch 30min)',
'onboarding.preset.short_7h': 'Reduced 7h (Lunch 60min)',
'onboarding.preset.short_6h': 'Reduced 6h (Lunch 30min)',
'onboarding.preset.half_4h': 'Half-day 4h (No Lunch)',
'onboarding.preset.custom_box': 'Custom',
'onboarding.preset.custom_radio': 'Manual entry:',
'onboarding.preset.suffix_hours': ' h',
'onboarding.preset.suffix_minutes': ' min',
'onboarding.preset.lunch_prefix': 'Lunch ',
'onboarding.preset.dinner_prefix': 'Dinner ',
'onboarding.preset.dinner_tooltip': 'Set if you often have dinner during overtime (typically 0~60 min)',
'notif.health_break.title': '🌿 Take a Break',
'notif.health_break.body': "You've been at your desk for over {hours} hours.\nStand up and stretch!",
'notif.overtime_earning.title': '🔥 Overtime Will Accrue',
'notif.overtime_earning.body': "{time_str} of overtime will be banked today!",
'notif.overtime_threshold.title': '💰 Overtime Balance High',
@ -346,8 +383,8 @@ _DICT = {
# === Tray ===
'tray.open': 'Open Program',
'tray.mini_widget': '📌 Mini Widget',
'tray.toggle_lunch': '🍱 Toggle Lunch',
'tray.mini_widget': 'Mini Widget',
'tray.toggle_lunch': 'Toggle Lunch',
'tray.quit': 'Quit',
'tray.tooltip_remaining': 'Until clock-out: {time}',
'tray.tooltip_overtime': 'Overtime: {time}',
@ -387,12 +424,12 @@ _DICT = {
'cal.edit_record': 'Edit record',
# === HelpView ===
'help.tab_intro': '👋 Getting Started',
'help.tab_work_hours': '🕘 Work Hours',
'help.tab_overtime': '🏦 Overtime',
'help.tab_leave': '🌴 Leave',
'help.tab_break': '🚪 Break/Dinner',
'help.tab_faq': 'FAQ',
'help.tab_intro': 'Getting Started',
'help.tab_work_hours': 'Work Hours',
'help.tab_overtime': 'Overtime',
'help.tab_leave': 'Leave',
'help.tab_break': 'Break/Dinner',
'help.tab_faq': 'FAQ',
# === clock_in_dialog ===
'dlg.clock_in.prompt': "Enter today's clock-in time",
@ -425,17 +462,18 @@ _DICT = {
'view.overtime.title': 'Overtime History',
'view.overtime.balance_zero': 'Balance: 0 min',
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
'view.overtime.earned_group': '💰 Earned',
'view.overtime.used_group': '📤 Used',
'view.overtime.earned_group': 'Earned',
'view.overtime.used_group': 'Used',
'view.overtime.col_date': 'Date',
'view.overtime.col_earned': 'Earned',
'view.overtime.col_used': 'Used',
'view.overtime.col_memo': 'Memo',
'view.overtime.col_reason': 'Reason',
'view.overtime.btn_add_earned': ' Manual Earn',
'view.overtime.btn_add_used': ' Manual Use',
'view.overtime.menu_delete': 'Delete',
'view.overtime.btn_add_earned': 'Manual Earn',
'view.overtime.btn_add_used': 'Manual Use',
'view.overtime.menu_delete': 'Delete',
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
'view.overtime.delete_earned_confirm_body': 'Delete this accrual record?\n\nDate: {date}\nEarned: {time}\n\nThe balance will be reduced accordingly.',
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
'view.overtime.manual_used_title': 'Manual Overtime Use',
'view.overtime.field_date': 'Date:',
@ -459,13 +497,13 @@ _DICT = {
'view.leave.balance_zero': 'Balance: 0 days',
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
'view.leave.btn_set_balance': 'Set Balance',
'view.leave.used_group': '📤 Used',
'view.leave.used_group': 'Used',
'view.leave.col_date': 'Date',
'view.leave.col_type': 'Type',
'view.leave.col_used': 'Used',
'view.leave.col_reason': 'Reason',
'view.leave.btn_add': ' Add Leave Usage',
'view.leave.btn_calendar': '📅 Calendar',
'view.leave.btn_add': 'Add Leave Usage',
'view.leave.btn_calendar': 'Calendar',
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
'view.leave.set_title': 'Set Leave Hours',
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',

View File

@ -7,11 +7,25 @@ from typing import Optional
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
from core.settings_keys import (
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
OVERTIME_UNIT,
)
from core.i18n import tr
def _get_int_setting(db, key: str, default: int, lo: int, hi: int) -> int:
"""db에서 정수 설정값을 안전하게 읽어 [lo, hi]로 클램프."""
if db is None:
return default
try:
v = int(db.get_setting(key, str(default)) or default)
except (ValueError, TypeError):
v = default
return max(lo, min(hi, v))
class Notifier(QObject):
"""알림 시스템 클래스"""
@ -27,6 +41,7 @@ class Notifier(QObject):
# 알림 상태 추적
self.notified_30min = False
self.notified_lunch = False
self.notified_dinner = False
self.notified_overtime = False
self.notified_health = False
self.notified_weekly = False
@ -85,46 +100,52 @@ class Notifier(QObject):
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
current_time: Optional[datetime] = None):
"""
점심시간 등록 알림
Args:
clock_in_time: 출근 시간
lunch_enabled: 점심시간 등록 여부
current_time: 현재 시간
"""
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
if current_time is None:
current_time = datetime.now()
if not self._enabled(NOTIF_LUNCH):
return
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
if lunch_enabled or self.notified_lunch:
return
# 출근 후 4시간 경과 (점심시간으로 추정)
time_since_clock_in = current_time - clock_in_time
if time_since_clock_in.total_seconds() >= 4 * 3600:
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
self.notification_signal.emit(
tr('notif.lunch_reminder.title'),
tr('notif.lunch_reminder.body'),
)
self.notified_lunch = True
def check_dinner_reminder(self, clock_in_time: datetime, dinner_enabled: bool,
current_time: Optional[datetime] = None):
"""저녁시간 등록 알림. 출근 후 DINNER_REMINDER_HOURS 경과 시.
야근(연장근무) 사용자가 저녁을 깜빡 잊는 패턴 대응.
"""
if current_time is None:
current_time = datetime.now()
if not self._enabled(NOTIF_DINNER):
return
if dinner_enabled or self.notified_dinner:
return
threshold_hours = _get_int_setting(self.db, DINNER_REMINDER_HOURS, 8, 1, 16)
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
self.notification_signal.emit(
tr('notif.dinner_reminder.title'),
tr('notif.dinner_reminder.body'),
)
self.notified_dinner = True
def check_overtime_earning(self, overtime_minutes: int):
"""
연장근무 적립 알림
Args:
overtime_minutes: 예상 연장근무 시간 ()
"""
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
if not self._enabled(NOTIF_OVERTIME):
return
if overtime_minutes >= 30 and not self.notified_overtime:
hours = overtime_minutes // 60
mins = overtime_minutes % 60
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
if overtime_minutes >= unit and not self.notified_overtime:
from utils.time_format import format_hours_minutes
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
self.notification_signal.emit(
tr('notif.overtime_earning.title'),
tr('notif.overtime_earning.body', time_str=time_str),
@ -132,10 +153,11 @@ class Notifier(QObject):
self.notified_overtime = True
def notify_overtime_threshold(self, total_overtime_hours: float):
"""연장근무 누적 알림 (20시간 이상)"""
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
if not self._enabled(NOTIF_OVERTIME):
return
if total_overtime_hours >= 20 and not self.notified_threshold:
threshold = _get_int_setting(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
if total_overtime_hours >= threshold and not self.notified_threshold:
self.notification_signal.emit(
tr('notif.overtime_threshold.title'),
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
@ -143,10 +165,11 @@ class Notifier(QObject):
self.notified_threshold = True
def notify_health_warning(self, consecutive_overtime_days: int):
"""건강 경고 (연속 연장근무 일수)"""
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
if not self._enabled(NOTIF_HEALTH):
return
if consecutive_overtime_days >= 3 and not self.notified_health:
threshold = _get_int_setting(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
if consecutive_overtime_days >= threshold and not self.notified_health:
self.notification_signal.emit(
tr('notif.health.title'),
tr('notif.health.body', days=consecutive_overtime_days),
@ -154,10 +177,11 @@ class Notifier(QObject):
self.notified_health = True
def notify_weekly_hours(self, total_hours: float):
"""52시간 경고"""
"""X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
if not self._enabled(NOTIF_HEALTH):
return
if total_hours > 52 and not self.notified_weekly:
threshold = _get_int_setting(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
if total_hours > threshold and not self.notified_weekly:
self.notification_signal.emit(
tr('notif.weekly_52.title'),
tr('notif.weekly_52.body', hours=total_hours),
@ -167,7 +191,7 @@ class Notifier(QObject):
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
"""장시간 연속 근무 휴식 알림.
조건: HEALTH_BREAK_ENABLED=true, 출근 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
오늘 미발송. 5 throttle은 호출자(NotificationOrchestrator)에서.
"""
if current_time is None:
@ -178,16 +202,12 @@ class Notifier(QObject):
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
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
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잠시 일어나서 스트레칭하세요.",
tr('notif.health_break.title'),
tr('notif.health_break.body', hours=threshold_hours),
)
self.db.log_notification('system', 'health_break')
@ -195,6 +215,7 @@ class Notifier(QObject):
"""알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False
self.notified_lunch = False
self.notified_dinner = False
self.notified_overtime = False
self.notified_health = False
self.notified_weekly = False

153
core/recurring_leaves.py Normal file
View File

@ -0,0 +1,153 @@
"""
반복 연차 패턴 파싱 + 일자 확장.
지원 패턴:
- 'weekly:friday' 매주 금요일
- 'weekly:mon,wed,fri' 매주 ··
- 'biweekly:friday' 격주 금요일 (start_date 기준)
- 'monthly:15' 매월 15 (해당 월에 일이 없으면 스킵)
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range() 펼친다.
같은 날짜에 leave_records(구체 인스턴스) 이미 있으면 호출자가 합산 로직 책임.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import List, Dict, Optional
_WEEKDAY_MAP = {
'mon': 0, 'monday': 0,
'tue': 1, 'tuesday': 1,
'wed': 2, 'wednesday': 2,
'thu': 3, 'thursday': 3,
'fri': 4, 'friday': 4,
'sat': 5, 'saturday': 5,
'sun': 6, 'sunday': 6,
}
@dataclass
class Occurrence:
"""반복 패턴이 펼친 한 인스턴스."""
date: date
leave_type: str
days: float
pattern: str
memo: str = ''
recurring_id: Optional[int] = None
def _parse_pattern(pattern: str):
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
if not pattern or ':' not in pattern:
return None
kind, rest = pattern.split(':', 1)
kind = kind.strip().lower()
rest = rest.strip().lower()
if kind in ('weekly', 'biweekly'):
days = [d.strip() for d in rest.split(',') if d.strip()]
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
if not weekdays:
return None
return (kind, weekdays)
if kind == 'monthly':
try:
day_of_month = int(rest)
if 1 <= day_of_month <= 31:
return (kind, day_of_month)
except ValueError:
return None
return None
def matches(rec: Dict, target_date: date) -> bool:
"""단일 패턴 rec이 target_date에 매치되는지."""
start = _parse_date(rec.get('start_date'))
end = _parse_date(rec.get('end_date'))
if start is None:
return False
if target_date < start:
return False
if end is not None and target_date > end:
return False
parsed = _parse_pattern(rec.get('pattern', ''))
if parsed is None:
return False
kind, info = parsed
if kind == 'weekly':
return target_date.weekday() in info
if kind == 'biweekly':
if target_date.weekday() not in info:
return False
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
weeks = (target_date - start).days // 7
return weeks % 2 == 0
if kind == 'monthly':
return target_date.day == info
return False
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
반환은 날짜 오름차순. 같은 여러 패턴이 매치되면 모두 포함.
"""
out: List[Occurrence] = []
if start > end:
return out
cur = start
while cur <= end:
for r in records:
try:
if matches(r, cur):
out.append(Occurrence(
date=cur,
leave_type=r.get('leave_type') or '연차',
days=float(r.get('days') or 0),
pattern=r.get('pattern', ''),
memo=r.get('memo') or '',
recurring_id=r.get('id'),
))
except Exception:
# 잘못된 패턴 1개가 전체를 망치지 않도록
continue
cur += timedelta(days=1)
return out
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
"""단일 날짜에 매치되는 인스턴스만."""
return expand_for_range(records, target_date, target_date)
def _parse_date(s: Optional[str]) -> Optional[date]:
if not s:
return None
try:
return datetime.strptime(s, '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
_KO_WEEKDAY_NAMES = ['', '', '', '', '', '', '']
def describe_pattern(pattern: str) -> str:
"""사용자에게 보여줄 패턴 설명. ko."""
parsed = _parse_pattern(pattern)
if parsed is None:
return pattern
kind, info = parsed
if kind in ('weekly', 'biweekly'):
names = [_KO_WEEKDAY_NAMES[w] for w in info]
prefix = '매주' if kind == 'weekly' else '격주'
return f"{prefix} {','.join(names)}요일"
if kind == 'monthly':
return f"매월 {info}"
return pattern

View File

@ -22,9 +22,17 @@ CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
NOTIF_CLOCK_OUT = 'notification_clock_out'
NOTIF_LUNCH = 'notification_lunch'
NOTIF_DINNER = 'notification_dinner'
NOTIF_OVERTIME = 'notification_overtime'
NOTIF_HEALTH = 'notification_health'
# 알림 임계값 (설정화 — 이전엔 하드코딩이던 것)
LUNCH_REMINDER_HOURS = 'lunch_reminder_hours' # 출근 후 N시간 경과 시 점심 미등록 알림 (기본 4)
DINNER_REMINDER_HOURS = 'dinner_reminder_hours' # 출근 후 N시간 경과 시 저녁 미등록 알림 (기본 8)
OVERTIME_THRESHOLD_HOURS = 'overtime_threshold_hours' # 누적 적립 알림 시간 (기본 20)
WEEKLY_HOURS_THRESHOLD = 'weekly_hours_threshold' # 주 X시간 경고 (기본 52, 한국 노동법)
HEALTH_CONSECUTIVE_OT_DAYS = 'health_consecutive_ot_days' # 연속 연장근무 일수 경고 (기본 3)
# 연차
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
@ -76,3 +84,22 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
# 마이그레이션 sentinel
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
# === v2.8.0 도전과제 시스템 ===
# 사용자 메타
BIRTHDAY = 'birthday' # MM-DD 형식, 빈 문자열이면 비활성
HIRE_DATE = 'hire_date' # YYYY-MM-DD, 첫 work_records 자동 기록
# 뷰 진입 카운터 (도전과제 + 사용 통계용)
STAT_WEEKLY_VIEW_COUNT = 'stat_weekly_view_count'
STAT_MONTHLY_VIEW_COUNT = 'stat_monthly_view_count'
STAT_PATTERN_VIEW_COUNT = 'stat_pattern_view_count'
CALENDAR_VIEW_COUNT = 'calendar_view_count'
LEAVE_CALENDAR_VIEW_COUNT = 'leave_calendar_view_count'
DAILY_REPORT_COUNT = 'daily_report_count'
ACHIEVEMENTS_VIEW_COUNT = 'achievements_view_count'
CHART_HOVER_DISCOVERED = 'chart_hover_discovered'
# 도전과제 알림
NOTIF_ACHIEVEMENT = 'notification_achievement'
DISCORD_NOTIF_ACHIEVEMENT = 'discord_notif_achievement'

View File

@ -236,6 +236,55 @@ class TimeCalculator:
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
def effective_work_minutes(self, date_obj: datetime, db) -> int:
"""해당 날짜의 실효 근무 시간(분).
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
종일 연차(>= work_minutes) 0 반환.
Args:
date_obj: 기준 날짜 (datetime; 시각은 무시)
db: Database 인스턴스 (None이면 차감 없음)
Returns:
실효 work_minutes (>= 0)
"""
if db is None:
return self.work_minutes
date_str = date_obj.strftime("%Y-%m-%d")
leave_min = db.get_leave_minutes_for(date_str)
return max(0, self.work_minutes - leave_min)
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
include_lunch: bool = False,
include_dinner: bool = False,
break_minutes: int = 0,
unit_minutes: int = 30) -> Tuple[int, int]:
"""
휴일/주말 근무: 모든 시간을 연장근무로 계산.
Args:
clock_in: 출근 시간
current_time: 현재(또는 퇴근) 시간
include_lunch/dinner: 식사 시간 차감 여부
break_minutes: 외출 시간 () 연장근무에서 제외
unit_minutes: 적립 단위 (15/30/60)
Returns:
(실제 연장 , 적립 ) 0 이상.
"""
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
if include_lunch:
elapsed_minutes -= self.lunch_duration_minutes
if include_dinner:
elapsed_minutes -= self.dinner_duration_minutes
elapsed_minutes -= break_minutes
elapsed_minutes = max(0, elapsed_minutes)
unit = unit_minutes if unit_minutes > 0 else 30
earned = (elapsed_minutes // unit) * unit
return elapsed_minutes, earned
def is_weekend(self, date_obj: datetime) -> bool:
"""
주말 여부 확인

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 .
"""
__version__ = '2.7.0'
__version__ = '2.11.2'

BIN
font/NanumSquareB.otf Normal file

Binary file not shown.

BIN
font/NanumSquareB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareEB.otf Normal file

Binary file not shown.

BIN
font/NanumSquareEB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareL.otf Normal file

Binary file not shown.

BIN
font/NanumSquareL.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareOTF_acB.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
font/NanumSquareOTF_acL.otf Normal file

Binary file not shown.

BIN
font/NanumSquareOTF_acR.otf Normal file

Binary file not shown.

BIN
font/NanumSquareR.otf Normal file

Binary file not shown.

BIN
font/NanumSquareR.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acEB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acL.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acR.ttf Normal file

Binary file not shown.

View File

@ -96,8 +96,9 @@ def main():
)
return 1
# 폰트 설정
app.setFont(QFont("Segoe UI", 9))
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
from utils.font_loader import apply_app_font
apply_app_font(app, 9)
# 필수 패키지 확인
if not check_requirements():
@ -145,9 +146,9 @@ def main():
from utils.debug_log import dlog
dlog(f"onboarding skipped: {e}")
# 메인 윈도우 생성 및 표시
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
try:
window = MainWindow()
window = MainWindow(db=db)
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
def on_new_connection():

View File

@ -14,20 +14,35 @@ if os.path.exists(_staged):
elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.'))
# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
_font_files = [
'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
]
_font_datas = [
(os.path.join('font', f), 'font')
for f in _font_files if os.path.exists(os.path.join('font', f))
]
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[('3d-alarm.png', '.')] + _extra_datas,
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
hiddenimports=[
'holidays', 'holidays.countries.south_korea',
'win32evtlog', 'win32evtlogutil',
'matplotlib.backends.backend_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
'matplotlib.backends.backend_qt5agg',
'PyQt5.QtSvg',
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
noarchive=False,
optimize=0,
)

9
tests/conftest.py Normal file
View File

@ -0,0 +1,9 @@
"""
pytest 공통 설정.
모든 테스트는 백그라운드 휴일 동기화를 Database 생성 spawn되는
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
"""
import os
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'

View File

@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
overtime_minutes=120, overtime_earned=120)
assert fresh_db.get_consecutive_overtime_days() == 3
class TestLeaveQueriesByDate:
def test_get_leave_minutes_for_no_records(self, fresh_db):
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
def test_full_day_leave_detected(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
def test_half_day_not_full(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
assert not fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
def test_two_halves_become_full(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
assert fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
def test_records_by_date(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
recs = fresh_db.get_leave_records_by_date('2026-05-15')
assert len(recs) == 1
assert recs[0]['leave_type'] == '연차'
assert recs[0]['memo'] == '메모'
def test_records_by_range(self, fresh_db):
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
assert len(recs) == 2
# 날짜 정렬
assert recs[0]['date'] == '2026-05-01'
assert recs[1]['date'] == '2026-05-10'
class TestRecurringLeavesDB:
def test_add_and_list(self, fresh_db):
rid = fresh_db.add_recurring_leave(
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
)
assert rid > 0
recs = fresh_db.get_recurring_leaves()
assert len(recs) == 1
assert recs[0]['pattern'] == 'weekly:friday'
assert recs[0]['memo'] == '단축'
def test_active_on_filter(self, fresh_db):
# 종료일이 지난 패턴
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
'2025-01-01', '2025-12-31')
# 아직 시작 안 한 패턴
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
'2027-01-01', None)
# 현재 활성 패턴
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
'2026-01-01', None)
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
assert len(active) == 1
assert active[0]['pattern'] == 'monthly:15'
def test_delete(self, fresh_db):
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
'2026-01-01')
assert len(fresh_db.get_recurring_leaves()) == 1
fresh_db.delete_recurring_leave(rid)
assert fresh_db.get_recurring_leaves() == []
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
# 매주 금요일 반차
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
'2026-01-01')
# 2026-05-01 = Friday → 240분
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
# 2026-05-04 = Monday → 0분
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
def test_concrete_plus_recurring_sum(self, fresh_db):
# 매주 금요일 반차 + 그날 별도 반반차 추가
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
# 0.5 + 0.25 = 0.75일 = 360분
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
assert not fresh_db.has_full_day_leave('2026-05-01')
def test_concrete_plus_recurring_full_day(self, fresh_db):
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
# 0.5 + 0.5 = 1.0일
assert fresh_db.has_full_day_leave('2026-05-01')

View File

@ -39,7 +39,7 @@ class TestSendNetwork:
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
ok = send('https://discord.com/api/webhooks/123/abc', 'title', 'desc')
ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc')
assert ok is True
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
@ -55,18 +55,18 @@ class TestSendNetwork:
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_network_error_returns_false(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
@patch('utils.discord_webhook.urllib.request.urlopen')
def test_timeout_returns_false(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
class TestPayloadShape:
@ -79,7 +79,7 @@ class TestPayloadShape:
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
send('https://discord.com/api/webhooks/x', 'TITLE', 'DESC',
send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 'TITLE', 'DESC',
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
req = mock_urlopen.call_args[0][0]
body = _json.loads(req.data.decode('utf-8'))

166
tests/test_holiday_api.py Normal file
View File

@ -0,0 +1,166 @@
"""
utils.holiday_api 단위 테스트.
실제 정부 API는 호출하지 않음 모두 urlopen mock.
"""
import json
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.holiday_api import (
fetch_korean_holidays, _parse_response, is_configured,
)
def _ok_response(items):
"""API 정상 응답 형식 빌드."""
return {
'response': {
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
'body': {
'items': {'item': items} if items else {'item': []},
'numOfRows': 100,
'pageNo': 1,
'totalCount': len(items) if isinstance(items, list) else 1,
},
}
}
class TestParseResponse:
def test_multiple_items(self):
items = [
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
'locdate': 20260501, 'seq': 1},
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
'locdate': 20260505, 'seq': 1},
]
out = _parse_response(_ok_response(items))
assert len(out) == 2
assert out[0]['date'] == '2026-05-01'
assert out[0]['name'] == '근로자의 날'
assert out[0]['is_holiday'] is True
assert out[1]['date'] == '2026-05-05'
def test_single_item_as_dict(self):
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': {'item': item}, 'totalCount': 1},
}
}
out = _parse_response(data)
assert len(out) == 1
assert out[0]['name'] == '근로자의 날'
def test_empty_year(self):
# totalCount=0 같은 정상 빈 응답
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': '', 'totalCount': 0},
}
}
assert _parse_response(data) == []
def test_error_result_code(self):
data = {
'response': {
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
'body': {},
}
}
assert _parse_response(data) is None
def test_isholiday_n_filtered_at_caller_level(self):
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
out = _parse_response(_ok_response(items))
assert len(out) == 1
assert out[0]['is_holiday'] is False
def test_locdate_str_form(self):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
out = _parse_response(_ok_response(items))
assert out[0]['date'] == '2026-05-01'
def test_invalid_locdate_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
]
out = _parse_response(_ok_response(items))
assert len(out) == 1 # 정상 1개만
assert out[0]['name'] == '근로자의 날'
def test_missing_required_fields_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
]
out = _parse_response(_ok_response(items))
assert len(out) == 1
def test_malformed_response_returns_none(self):
# response 구조 자체가 깨진 경우
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
# 위는 implementation-dependent — 둘 다 합리적
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
assert _parse_response({}) is None
class TestFetchNetwork:
@patch('utils.holiday_api.urllib.request.urlopen')
def test_success(self, mock_urlopen):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
resp = MagicMock()
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
out = fetch_korean_holidays(2026)
assert out is not None
assert len(out) == 1
assert out[0]['date'] == '2026-05-01'
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
req = mock_urlopen.call_args[0][0]
assert 'serviceKey=' in req.full_url
assert 'solYear=2026' in req.full_url
assert '_type=json' in req.full_url
@patch('utils.holiday_api.urllib.request.urlopen')
def test_network_error_returns_none(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_timeout_returns_none(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_invalid_json_returns_none(self, mock_urlopen):
resp = MagicMock()
resp.read.return_value = b'<html>error</html>'
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert fetch_korean_holidays(2026) is None
class TestConfigured:
def test_key_set(self):
assert is_configured() is True

View File

@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save')
assert label.text() == '💾 저장'
assert label.text() == '저장'
def test_retranslate_after_language_change(qapp, i18n):
@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
set_language('ko')
dlg = QDialog()
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
assert dlg.windowTitle() == '⚙️ 설정'
assert dlg.windowTitle() == '설정'
i18n.set_language_and_retranslate('en')
assert dlg.windowTitle() == '⚙️ Settings'
assert dlg.windowTitle() == 'Settings'
def test_post_callback_applied(qapp, i18n):
@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
set_language('ko')
label = QLabel()
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
assert label.text() == '[💾 저장]'
assert label.text() == '[저장]'
i18n.set_language_and_retranslate('en')
assert label.text() == '[💾 Save]'
assert label.text() == '[Save]'
def test_dead_widget_pruned(qapp, i18n):

View File

@ -0,0 +1,72 @@
"""연장근무 자동 적립 가드 테스트.
auto_overtime(자동 적립) OFF면, 자동 퇴근 경로(근무일 경계 롤오버 )에서도
은행 적립을 하지 않아야 한다 clock_out() 대화상자에서 '아니오' 고른 것과 동일한 의미.
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
"""
import os
import sys
from datetime import datetime, timedelta, time as dtime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from core.time_calculator import TimeCalculator
from ui.main_window import MainWindow
def _rollover_balance(db, monkeypatch):
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
from PyQt5.QtWidgets import QMessageBox
monkeypatch.setattr(QMessageBox, 'information',
staticmethod(lambda *a, **k: QMessageBox.Ok))
today = datetime.now().date()
y = today - timedelta(days=1)
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
w.db = db
w.time_calc = TimeCalculator(work_minutes=480)
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
w.is_clocked_in = True
w.midnight_rollover_handled = False
w.is_on_break = False
w.lunch_break_enabled = False
w.dinner_break_enabled = False
w.load_today_data = lambda: None # tail UI refresh stub
w.update_overtime_balance = lambda: None # tail UI refresh stub
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
return db.get_total_overtime_balance()
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
db = Database(str(tmp_path / 'off.db'))
db.set_setting('auto_overtime', 'false')
assert _rollover_balance(db, monkeypatch) == 0
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
db = Database(str(tmp_path / 'on.db'))
db.set_setting('auto_overtime', 'true')
assert _rollover_balance(db, monkeypatch) > 0
def test_delete_overtime_earned_reduces_balance(tmp_path):
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
from datetime import date
db = Database(str(tmp_path / 'del.db'))
today = date.today().isoformat()
db.add_overtime_earned(None, 90, today)
assert db.get_total_overtime_balance() == 90
bank_id = db.get_connection().execute(
'SELECT id FROM overtime_bank').fetchone()[0]
assert db.delete_overtime_earned(bank_id) is True
assert db.get_total_overtime_balance() == 0
# 없는 id 삭제는 False
assert db.delete_overtime_earned(999999) is False

View File

@ -0,0 +1,153 @@
"""
core.recurring_leaves 단위 테스트.
"""
import os
import sys
from datetime import date
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.recurring_leaves import (
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
)
class TestParsePattern:
@pytest.mark.parametrize("pattern,expected_kind", [
('weekly:friday', 'weekly'),
('weekly:fri', 'weekly'),
('weekly:mon,wed,fri', 'weekly'),
('biweekly:friday', 'biweekly'),
('monthly:15', 'monthly'),
('monthly:1', 'monthly'),
])
def test_valid(self, pattern, expected_kind):
result = _parse_pattern(pattern)
assert result is not None
assert result[0] == expected_kind
@pytest.mark.parametrize("pattern", [
'', 'weekly', 'weekly:', 'weekly:xyz',
'monthly:0', 'monthly:32', 'monthly:abc',
'unknown:fri', None,
])
def test_invalid(self, pattern):
assert _parse_pattern(pattern) is None
class TestMatches:
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
return {
'pattern': pattern,
'start_date': start,
'end_date': end,
'days': days,
'leave_type': leave_type,
}
def test_weekly_single_day(self):
rec = self._rec('weekly:friday')
assert matches(rec, date(2026, 5, 1)) # Fri
assert not matches(rec, date(2026, 5, 2)) # Sat
assert not matches(rec, date(2026, 5, 4)) # Mon
def test_weekly_multiple_days(self):
rec = self._rec('weekly:mon,wed,fri')
assert matches(rec, date(2026, 5, 4)) # Mon
assert matches(rec, date(2026, 5, 6)) # Wed
assert matches(rec, date(2026, 5, 8)) # Fri
assert not matches(rec, date(2026, 5, 5)) # Tue
assert not matches(rec, date(2026, 5, 7)) # Thu
def test_biweekly_alignment(self):
# start_date 2026-01-02 = Friday (week 0)
rec = self._rec('biweekly:friday', start='2026-01-02')
assert matches(rec, date(2026, 1, 2)) # week 0
assert not matches(rec, date(2026, 1, 9)) # week 1
assert matches(rec, date(2026, 1, 16)) # week 2
def test_monthly(self):
rec = self._rec('monthly:15')
assert matches(rec, date(2026, 1, 15))
assert matches(rec, date(2026, 5, 15))
assert not matches(rec, date(2026, 5, 14))
assert not matches(rec, date(2026, 5, 16))
def test_monthly_skipped_in_short_month(self):
# 31일은 30일 달에는 매치되지 않음
rec = self._rec('monthly:31')
assert matches(rec, date(2026, 1, 31))
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
def test_before_start(self):
rec = self._rec('weekly:friday', start='2026-05-01')
assert matches(rec, date(2026, 5, 1))
assert not matches(rec, date(2026, 4, 24)) # 시작 전
def test_after_end(self):
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
def test_no_end_means_forever(self):
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
def test_invalid_pattern_returns_false(self):
rec = self._rec('garbage:xyz')
assert not matches(rec, date(2026, 5, 1))
class TestExpandRange:
def _rec(self, pattern, start='2026-01-01'):
return {
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
'days': 0.5, 'leave_type': '반차', 'memo': '',
}
def test_expand_weekly_one_month(self):
rec = self._rec('weekly:friday')
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
assert len(occs) == 5
assert all(o.date.weekday() == 4 for o in occs)
def test_expand_empty_when_outside(self):
rec = self._rec('weekly:friday', start='2027-01-01')
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
assert occs == []
def test_expand_invalid_range(self):
# start > end
rec = self._rec('weekly:friday')
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
assert occs == []
def test_expand_multiple_recs(self):
rec_fri = self._rec('weekly:friday')
rec_mon = self._rec('weekly:monday')
rec_mon['id'] = 2
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
assert len(occs) == 2
def test_expand_for_date_single(self):
rec = self._rec('monthly:15')
occs = expand_for_date([rec], date(2026, 5, 15))
assert len(occs) == 1
assert occs[0].date == date(2026, 5, 15)
class TestDescribePattern:
def test_weekly_korean(self):
assert '매주' in describe_pattern('weekly:friday')
assert '' in describe_pattern('weekly:friday')
def test_biweekly(self):
assert '격주' in describe_pattern('biweekly:friday')
def test_monthly(self):
assert '매월' in describe_pattern('monthly:15')
assert '15' in describe_pattern('monthly:15')

View File

@ -98,3 +98,77 @@ class TestDayType:
mon = datetime(2026, 5, 4)
assert not calc.is_weekend(mon)
assert calc.get_day_type(mon) == 'normal'
class TestHolidayOvertime:
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
def test_zero_elapsed_returns_zero(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
assert actual == 0 and earned == 0
def test_one_minute_elapsed_no_lunch(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=1)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 1
assert earned == 0 # 30분 단위 절삭
def test_30min_elapsed_truncates_to_30(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=30)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 30 and earned == 30
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=29)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 29 and earned == 0
def test_lunch_subtracted(self, calc_8h):
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=9)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, include_lunch=True
)
assert actual == 8 * 60
assert earned == 8 * 60
def test_break_minutes_subtracted(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=2)
# 외출 30분 → 90분 적립
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, break_minutes=30
)
assert actual == 90 and earned == 90
def test_unit_minutes_15(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=44)
# 44분 → 30분 적립 (15분 단위)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, unit_minutes=15
)
assert actual == 44 and earned == 30
def test_unit_minutes_60(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=119)
# 119분 → 60분 적립 (60분 단위)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, unit_minutes=60
)
assert actual == 119 and earned == 60
def test_negative_clamped_to_zero(self, calc_8h):
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=1)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, include_lunch=True, include_dinner=True
)
assert actual == 0 and earned == 0

453
ui/achievements_view.py Normal file
View File

@ -0,0 +1,453 @@
"""
도전과제 4 (전체 / 진행 / 완료 / 시크릿).
디자인 원칙:
- 카드 = 등급별 그라디언트 배경 + 외곽선 (획득 강한 )
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
- 진행 게이지 = 두꺼운 색상 막대 (등급 )
- 카테고리 = 작은 인라인 태그
- 시크릿 미발견 = 처리
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTabWidget, QWidget, QScrollArea,
QProgressBar, QFrame, QGridLayout, QSizePolicy)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from core.achievements import get_all_with_status, get_stats
from ui.styles import apply_dark_titlebar
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
TIER_THEMES = {
'bronze': {
'border': '#cd7f32',
'border_strong': '#e09947',
'bg_top': '#3a2a18',
'bg_bot': '#241810',
'text': '#ffd9a8',
'label': '🥉',
'name': '브론즈',
},
'silver': {
'border': '#a8a8a8',
'border_strong': '#d0d0d0',
'bg_top': '#2e2e36',
'bg_bot': '#1c1c22',
'text': '#e8e8f0',
'label': '🥈',
'name': '실버',
},
'gold': {
'border': '#ffb700',
'border_strong': '#ffd24a',
'bg_top': '#3a2e10',
'bg_bot': '#241c08',
'text': '#ffe9a0',
'label': '🥇',
'name': '골드',
},
'platinum': {
'border': '#7fdbff',
'border_strong': '#a8e8ff',
'bg_top': '#1a3340',
'bg_bot': '#0e1f28',
'text': '#c5ecff',
'label': '💎',
'name': '플래티넘',
},
'legend': {
'border': '#ff6b9d',
'border_strong': '#ff90b8',
'bg_top': '#3a1a2a',
'bg_bot': '#26101a',
'text': '#ffc0d4',
'label': '🌟',
'name': '레전드',
},
}
CATEGORY_LABELS = {
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
'meal': '식사', 'break_use': '외출', 'settings': '설정',
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
'ambition': '야망', 'meta': '메타',
}
class AchievementsView(QDialog):
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("도전과제")
self.setMinimumSize(960, 720)
self.resize(1100, 800)
self._increment_view_count()
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
self.init_ui()
apply_dark_titlebar(self) # 현재 테마에 맞춰
def _increment_view_count(self) -> None:
try:
cur = self.db.get_setting_int('achievements_view_count', 0)
self.db.set_setting('achievements_view_count', str(cur + 1))
except Exception:
pass
def init_ui(self) -> None:
layout = QVBoxLayout()
layout.setContentsMargins(20, 20, 20, 16)
layout.setSpacing(12)
stats = get_stats(self.db)
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
layout.addWidget(self._build_header(stats))
# === 탭 ===
self.tabs = QTabWidget()
self.tabs.setStyleSheet(self._tabs_qss())
all_items = get_all_with_status(self.db)
earned_items = [a for a in all_items if a['earned_date'] is not None]
in_progress = [a for a in all_items
if a['earned_date'] is None and not a['is_secret']]
secret_items = [a for a in all_items if a['is_secret']]
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
self.tabs.addTab(self._build_grid_tab(in_progress),
f"⚡ 진행 중 · {len(in_progress)}")
self.tabs.addTab(self._build_grid_tab(earned_items),
f"✓ 완료 · {len(earned_items)}")
self.tabs.addTab(
self._build_grid_tab(secret_items, secret_mode=True),
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
)
layout.addWidget(self.tabs, 1)
# === 닫기 버튼 ===
btn_row = QHBoxLayout()
btn_row.addStretch()
close_btn = QPushButton("닫기")
close_btn.setMinimumWidth(100)
close_btn.setStyleSheet(button_qss('default'))
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
# ----- 헤더 -----
def _build_header(self, stats: dict) -> QWidget:
container = QFrame()
container.setStyleSheet(f"""
QFrame {{
background: {tc('panel')};
border: 1px solid {tc('border')};
border-radius: 12px;
}}
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
""")
layout = QVBoxLayout()
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(8)
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
# 큰 숫자 행
num_row = QHBoxLayout()
num_row.setSpacing(24)
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
if _is_dark():
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
else:
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
big.setTextFormat(Qt.RichText)
num_row.addWidget(big)
spacer = QFrame()
spacer.setFrameShape(QFrame.VLine)
spacer.setStyleSheet(f"color: {tc('border')};")
num_row.addWidget(spacer)
secret_lbl = QLabel(
f"<div style='line-height: 1.3;'>"
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 시크릿</span><br>"
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
f"{stats['secret_earned']}</span>"
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
f"</div>"
)
secret_lbl.setTextFormat(Qt.RichText)
num_row.addWidget(secret_lbl)
num_row.addStretch()
pct_lbl = QLabel(
f"<div style='text-align: right; line-height: 1.3;'>"
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>달성률</span><br>"
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
f"{pct:.1f}%</span></div>"
)
pct_lbl.setTextFormat(Qt.RichText)
pct_lbl.setAlignment(Qt.AlignRight)
num_row.addWidget(pct_lbl)
layout.addLayout(num_row)
# 진행 바
bar = QProgressBar()
bar.setMaximum(max(stats['total'], 1))
bar.setValue(stats['earned'])
bar.setTextVisible(False)
bar.setMinimumHeight(8)
bar.setMaximumHeight(8)
bar.setStyleSheet(f"""
QProgressBar {{
background: {tc('panel2')};
border: none;
border-radius: 4px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
border-radius: 4px;
}}
""")
layout.addWidget(bar)
container.setLayout(layout)
return container
# ----- 탭 그리드 -----
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setStyleSheet(scroll_qss())
container = QWidget()
container.setStyleSheet("background: transparent;")
grid = QGridLayout()
grid.setSpacing(12)
grid.setContentsMargins(8, 8, 8, 8)
if not items:
empty = QLabel("(아직 없음)")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet(
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
)
grid.addWidget(empty, 0, 0)
else:
cols = 3
for i, item in enumerate(items):
card = self._build_card(item, secret_mode=secret_mode)
grid.addWidget(card, i // cols, i % cols)
# 빈 컬럼 stretch 방지
for c in range(cols):
grid.setColumnStretch(c, 1)
container.setLayout(grid)
scroll.setWidget(container)
return scroll
# ----- 단일 카드 -----
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
is_earned = item['earned_date'] is not None
is_locked_secret = item['is_secret'] and not is_earned
tier = item['tier'] or 'bronze'
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
light = not _is_dark()
if is_locked_secret:
if light:
bg_top = bg_bot = tc('panel'); border = tc('border')
else:
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
text_color = tc('text_faint')
elif light:
bg_top = bg_bot = tc('panel')
border = theme['border_strong'] if is_earned else theme['border']
text_color = tc('text') if is_earned else tc('text_dim')
else:
bg_top = theme['bg_top']
bg_bot = theme['bg_bot']
border = theme['border_strong'] if is_earned else theme['border']
text_color = theme['text'] if is_earned else '#c0c0d0'
# 외곽선 강도: 획득 시 2px + 더 진한 색
border_width = 2 if is_earned else 1
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
card = QFrame()
card.setFrameShape(QFrame.NoFrame)
card.setMinimumHeight(150)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
card.setStyleSheet(f"""
QFrame {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 {bg_top}, stop:1 {bg_bot});
border: {border_width}px solid {border};
border-radius: 10px;
}}
QLabel {{
background: transparent;
border: none;
color: {text_color};
}}
""")
outer = QVBoxLayout()
outer.setContentsMargins(14, 12, 14, 12)
outer.setSpacing(8)
# 1행: 이모지 + 이름 + 등급 라벨
top_row = QHBoxLayout()
top_row.setSpacing(10)
if is_locked_secret:
icon_text = ""
else:
icon_text = item['badge_icon'] or '🏆'
icon = QLabel(icon_text)
icon.setStyleSheet(
f"font-size: 32pt; background: transparent; border: none; "
f"color: {text_color};"
)
icon.setMinimumWidth(48)
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
top_row.addWidget(icon)
# 이름 + 카테고리 (세로 스택)
name_box = QVBoxLayout()
name_box.setSpacing(2)
name_box.setContentsMargins(0, 4, 0, 0)
name_text = "???" if is_locked_secret else (item['name'] or '')
name = QLabel(name_text)
name.setStyleSheet(
f"font-size: 12pt; font-weight: bold; "
f"color: {tc('text') if is_earned else tc('text_dim')}; "
f"background: transparent; border: none;"
)
name.setWordWrap(True)
name_box.addWidget(name)
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
if not is_locked_secret:
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
cat_label.setStyleSheet(
f"font-size: 8.5pt; "
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
f"border: 1px solid {theme['border']}; "
f"border-radius: 8px; "
f"padding: 1px 4px;"
)
cat_label.setMaximumHeight(20)
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
cat_wrap = QHBoxLayout()
cat_wrap.setContentsMargins(0, 0, 0, 0)
cat_wrap.addWidget(cat_label)
cat_wrap.addStretch()
name_box.addLayout(cat_wrap)
top_row.addLayout(name_box, 1)
outer.addLayout(top_row)
# 2행: 설명
if is_locked_secret:
desc_text = "🔒 달성하면 공개됩니다"
else:
desc_text = item['description'] or ''
desc = QLabel(desc_text)
desc.setWordWrap(True)
desc.setStyleSheet(
f"color: {tc('text_dim')}; font-size: 9.5pt; "
f"background: transparent; border: none; padding: 0;"
)
outer.addWidget(desc)
# 3행: 진행 게이지 또는 획득 일자
if is_earned:
earned = QLabel(f"{item['earned_date']} 달성 ")
earned.setStyleSheet(
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
f"font-weight: bold; font-size: 9.5pt; "
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
f"border: 1px solid {theme['border']}; "
f"border-radius: 6px; padding: 4px 8px;"
)
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
row = QHBoxLayout()
row.addWidget(earned)
row.addStretch()
outer.addLayout(row)
elif not is_locked_secret:
target = max(1, item.get('target') or 1)
progress = item.get('progress') or 0
pct = (progress / target * 100) if target else 0
# 게이지 + 숫자 라벨
gauge_row = QHBoxLayout()
gauge_row.setSpacing(8)
pb = QProgressBar()
pb.setMaximum(target)
pb.setValue(min(progress, target))
pb.setTextVisible(False)
pb.setMinimumHeight(10)
pb.setMaximumHeight(10)
pb.setStyleSheet(f"""
QProgressBar {{
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
border: none;
border-radius: 5px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
border-radius: 5px;
}}
""")
gauge_row.addWidget(pb, 1)
num = QLabel(f"{progress} / {target}")
num.setStyleSheet(
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
f"font-weight: bold; background: transparent; border: none;"
)
num.setMinimumWidth(60)
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
gauge_row.addWidget(num)
outer.addLayout(gauge_row)
else:
# 시크릿 잠금 — 회색 점선 placeholder
placeholder = QLabel("· · · · · · · · · ·")
placeholder.setStyleSheet(
"color: #444; font-size: 12pt; letter-spacing: 4px; "
"background: transparent; border: none;"
)
placeholder.setAlignment(Qt.AlignCenter)
outer.addWidget(placeholder)
outer.addStretch(1)
card.setLayout(outer)
return card
# ----- 탭 QSS (다이얼로그 전용) -----
def _tabs_qss(self) -> str:
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
return tabs_qss(ACCENT_GOLD)

View File

@ -55,10 +55,11 @@ class CalendarView(QDialog):
# 범례
legend_layout = QHBoxLayout()
legend_layout.setSpacing(12)
legend_layout.addWidget(QLabel("🟢 정상"))
legend_layout.addWidget(QLabel("🔴 연장"))
legend_layout.addWidget(QLabel("🟡 휴가"))
legend_layout.addWidget(QLabel("⚪ 없음"))
for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
('#FAB005', '휴가'), ('#6C6E73', '없음')]:
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
_item.setTextFormat(Qt.RichText)
legend_layout.addWidget(_item)
legend_layout.addStretch()
layout.addLayout(legend_layout)
@ -77,13 +78,13 @@ class CalendarView(QDialog):
button_layout = QHBoxLayout()
button_layout.setSpacing(6)
self.edit_time_button = QPushButton("✏️ 시간 수정")
self.edit_time_button = QPushButton("시간 수정")
self.edit_time_button.setObjectName("btn_primary")
self.edit_time_button.setEnabled(False)
self.edit_time_button.clicked.connect(self.edit_work_time)
button_layout.addWidget(self.edit_time_button)
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
self.delete_record_button = QPushButton("기록 삭제")
self.delete_record_button.setObjectName("btn_danger")
self.delete_record_button.setEnabled(False)
self.delete_record_button.clicked.connect(self.delete_selected_record)
@ -104,7 +105,7 @@ class CalendarView(QDialog):
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
memo_layout.addWidget(self.memo_edit)
self.save_memo_button = QPushButton("💾 메모 저장")
self.save_memo_button = QPushButton("메모 저장")
self.save_memo_button.setObjectName("btn_primary")
self.save_memo_button.setEnabled(False)
self.save_memo_button.clicked.connect(self.save_memo)
@ -163,21 +164,23 @@ class CalendarView(QDialog):
existing = self.db.get_work_record(date_str)
menu = QMenu(self)
edit_action = delete_action = add_action = None
if existing:
edit_action = menu.addAction(f"✏️ {date_str} 편집")
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
edit_action = menu.addAction(f"{date_str} 편집")
delete_action = menu.addAction(f"{date_str} 삭제")
else:
add_action = menu.addAction(f" {date_str} 기록 추가")
add_action = menu.addAction(f"{date_str} 기록 추가")
action = menu.exec_(self.calendar.mapToGlobal(pos))
if action is None:
return
if existing and action.text().startswith("✏️"):
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
if action == edit_action:
self._open_edit_dialog(date_str)
elif existing and action.text().startswith("🗑️"):
elif action == delete_action:
self._delete_record(date_str)
elif not existing and action.text().startswith(""):
elif action == add_action:
self._add_past_record(date_str)
def _add_past_record(self, date_str: str):
@ -267,7 +270,7 @@ class CalendarView(QDialog):
if record:
# 상세 정보 표시
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d')}\n\n"
detail = f"{selected_date.strftime('%Y년 %m월 %d')}\n\n"
detail += f"출근: {record['clock_in']}\n"
if record.get('clock_out'):
@ -303,7 +306,7 @@ class CalendarView(QDialog):
self.memo_edit.setPlainText(record.get('memo', ''))
self.save_memo_button.setEnabled(True)
else:
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d')}\n\n기록이 없습니다.")
self.detail_text.setText(f"{selected_date.strftime('%Y년 %m월 %d')}\n\n기록이 없습니다.")
self.edit_time_button.setEnabled(False)
self.delete_record_button.setEnabled(False)
self.memo_edit.setPlainText('')
@ -406,7 +409,7 @@ class EditWorkTimeDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
title = QLabel(f"{self.date_str} 출퇴근 시간 수정")
title.setObjectName("dialog_subtitle")
layout.addWidget(title)

View File

@ -10,14 +10,67 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import Qt
try:
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import matplotlib
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
from matplotlib.figure import Figure
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
try:
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
except Exception:
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
matplotlib.rcParams['axes.unicode_minus'] = False
_MPL = True
except ImportError:
except Exception as _mpl_err:
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
_MPL = False
try:
from utils.debug_log import dlog
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
except Exception:
pass
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
# 막대/선은 데이터 구분용 고정 색.
_CHART_BG = '#25262B'
_CHART_GRID = '#2C2E33'
_CHART_TEXT = '#909296'
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
_CHART_AVG_LINE = '#51CF66' # green
def _refresh_chart_colors() -> None:
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
global _CHART_BG, _CHART_GRID, _CHART_TEXT
try:
from ui.styles import ThemeColors
_CHART_BG = ThemeColors.get('bg_secondary')
_CHART_GRID = ThemeColors.get('border_subtle')
_CHART_TEXT = ThemeColors.get('text_secondary')
except Exception:
pass
def _apply_dark_axes(ax) -> None:
"""차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경."""
ax.set_facecolor(_CHART_BG)
ax.tick_params(axis='both', colors=_CHART_TEXT)
ax.xaxis.label.set_color(_CHART_TEXT)
ax.yaxis.label.set_color(_CHART_TEXT)
ax.title.set_color(_CHART_TEXT)
for spine in ax.spines.values():
spine.set_color(_CHART_GRID)
ax.grid(axis='y', alpha=0.25, color=_CHART_GRID)
def _apply_dark_figure(fig) -> None:
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
_refresh_chart_colors()
fig.patch.set_facecolor(_CHART_BG)
class _Fallback(QWidget):
@ -28,7 +81,7 @@ class _Fallback(QWidget):
label = QLabel(message)
label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True)
label.setStyleSheet("color: #888; padding: 20px;")
label.setStyleSheet("color: #909296; padding: 20px;")
layout.addWidget(label)
self.setLayout(layout)
@ -37,11 +90,14 @@ def make_chart_widget(parent=None) -> QWidget:
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
if not _MPL:
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
_refresh_chart_colors()
widget = QWidget(parent)
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG)
canvas = FigureCanvas(fig)
canvas.setStyleSheet(f"background: {_CHART_BG};")
layout.addWidget(canvas)
widget.setLayout(layout)
widget._figure = fig
@ -55,9 +111,12 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
return
fig = widget._figure
fig.clear()
_apply_dark_figure(fig)
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
_apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw()
return
@ -68,20 +127,23 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
ax = fig.add_subplot(111)
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
color=_CHART_BAR_OVERTIME)
ax.set_ylabel('시간')
ax.legend(loc='upper left', fontsize=8)
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
ax.grid(axis='y', alpha=0.3)
_apply_dark_axes(ax)
# 호버 annotation 설정
annot = ax.annotate(
"", xy=(0, 0), xytext=(15, 15),
textcoords="offset points",
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
alpha=0.95),
color="white", fontsize=9,
arrowprops=dict(arrowstyle="->", color="#888"),
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
)
annot.set_visible(False)
@ -102,6 +164,14 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
annot.set_text(text)
annot.set_visible(True)
widget._canvas.draw_idle()
# 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록
db = getattr(widget, '_achievement_db', None)
if db is not None:
try:
if db.get_setting('chart_hover_discovered', 'false').lower() != 'true':
db.set_setting('chart_hover_discovered', 'true')
except Exception:
pass
return
if annot.get_visible():
annot.set_visible(False)
@ -117,13 +187,15 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
return
fig = widget._figure
fig.clear()
_apply_dark_figure(fig)
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
_apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw()
return
# 출근 시각을 분 단위로 (00:00=0)
minutes_list = []
for r in records:
ci = r.get('clock_in')
@ -137,26 +209,31 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
pass
if not minutes_list:
ax = fig.add_subplot(111)
_apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw()
return
# 30분 빈
bin_size = 30
min_m = (min(minutes_list) // bin_size) * bin_size
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
bins = list(range(min_m, max_m + bin_size, bin_size))
ax = fig.add_subplot(111)
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
edgecolor=_CHART_BG, linewidth=1)
avg = sum(minutes_list) / len(minutes_list)
ax.axvline(avg, color='#ff6b6b', linestyle='--',
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
ax.set_xticks([m for m in bins if m % 60 == 0])
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
rotation=45, fontsize=8)
ax.set_ylabel('일수')
ax.set_title('출근 시각 분포')
ax.legend(loc='upper right', fontsize=8)
ax.grid(axis='y', alpha=0.3)
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
_apply_dark_axes(ax)
widget._canvas.draw()
@ -166,6 +243,7 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
return
fig = widget._figure
fig.clear()
_apply_dark_figure(fig)
from datetime import datetime as _dt
weekday_totals = [0.0] * 7
@ -182,9 +260,8 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
labels = ['', '', '', '', '', '', '']
ax = fig.add_subplot(111)
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
ax.bar(labels, avg, color=colors)
ax.set_ylabel('평균 시간')
ax.set_title('요일별 평균 근무시간')
ax.grid(axis='y', alpha=0.3)
_apply_dark_axes(ax)
widget._canvas.draw()

View File

@ -17,12 +17,17 @@ class LockMonitor:
self.window = window
self.db = window.db
self.last_locked: bool = False
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
def tick(self) -> None:
try:
from utils.lock_detector import is_screen_locked
locked = is_screen_locked()
except Exception:
except Exception as e:
if not self._detector_failed_once:
self._detector_failed_once = True
from utils.debug_log import dlog
dlog(f"lock detector failed (silenced after first): {e}")
return
was_locked = self.last_locked

View File

@ -2,11 +2,24 @@
알림 오케스트레이션.
5 가드로 건강/주간/누적 임계 알림을 throttle.
notifier.py의 6 알림 메서드를 적절한 시점에 호출.
notifier.py의 알림 메서드를 적절한 시점에 호출.
"""
from __future__ import annotations
from datetime import datetime
from core.settings_keys import (
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
)
from utils.debug_log import dlog
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
try:
v = int(db.get_setting(key, str(default)) or default)
except (ValueError, TypeError):
v = default
return max(lo, min(hi, v))
class NotificationOrchestrator:
"""update_display() 1Hz tick에서 호출."""
@ -66,19 +79,26 @@ class NotificationOrchestrator:
f"기간: {last_mon} ~ {last_sun}",
color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception:
pass
except Exception as e:
dlog(f"discord weekly_report failed: {e}")
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
is_holiday: bool = False) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
n.check_clock_out_soon(expected_clock_out, now)
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
if not is_holiday:
n.check_clock_out_soon(expected_clock_out, now)
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
n.check_lunch_reminder(self.window.clock_in_time,
self.window.lunch_break_enabled, now)
n.check_dinner_reminder(self.window.clock_in_time,
getattr(self.window, 'dinner_break_enabled', False), now)
if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute
@ -89,18 +109,73 @@ class NotificationOrchestrator:
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3:
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
if consecutive >= consecutive_th:
n.notify_health_warning(consecutive)
self._discord_health(consecutive, break_minutes, now)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52:
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
if weekly_hours > weekly_th:
n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200:
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
if balance_minutes / 60.0 >= ot_threshold_h:
n.notify_overtime_threshold(balance_minutes / 60.0)
# 도전과제 평가 (5분 throttle)
self._evaluate_achievements(now)
def _evaluate_achievements(self, now: datetime) -> None:
"""도전과제 평가 + 신규 잠금 해제 알림.
실패는 silent 도전과제 시스템이 메인 흐름을 막으면 .
"""
try:
from core.achievements import evaluate_all
unlocked = evaluate_all(self.db)
except Exception as e:
dlog(f"achievement eval failed: {e}")
return
if not unlocked:
return
# 시스템 알림 + Discord push (옵션)
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
for a in unlocked:
self.db.log_notification('system', f'achievement:{a.code}')
if notif_on:
title = f"{a.badge_icon} 도전과제 달성!"
body = f"{a.name}\n{a.description}"
self.notifier.notification_signal.emit(title, body)
# Discord 통합 push (여러 개면 묶어서)
self._discord_achievements(unlocked)
def _discord_achievements(self, unlocked: list) -> None:
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
return
url = self.db.get_setting('discord_webhook_url', '') or ''
if not url:
return
try:
from utils import discord_webhook
fields = [{"name": f"{a.badge_icon} {a.name}",
"value": a.description, "inline": False}
for a in unlocked[:10]]
extra = (f"\n... 외 {len(unlocked) - 10}" if len(unlocked) > 10 else '')
ok = discord_webhook.send(
url,
f"🏆 도전과제 {len(unlocked)}개 달성!",
f"새로 잠금 해제된 도전과제 입니다.{extra}",
color=discord_webhook.COLOR_YELLOW,
fields=fields,
)
self.db.log_notification('discord', 'achievement', success=ok)
except Exception as e:
dlog(f"discord achievement push failed: {e}")
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
"""건강 경고 Discord push (옵션)."""
if self.db.has_notification_today('discord', 'health'):
@ -115,5 +190,5 @@ class NotificationOrchestrator:
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
except Exception as e:
dlog(f"discord health push failed: {e}")

456
ui/dark_components.py Normal file
View File

@ -0,0 +1,456 @@
"""
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
핵심 원칙:
- 다이얼로그 배경: #0e0e14 (깊은 다크)
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
- 헤더: 숫자 + 그라디언트 progress bar
- : 골드 강조 선택
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
모든 컴포넌트는 stand-alone 부모가 dark 다이얼로그라고 가정.
"""
from __future__ import annotations
from typing import Optional, List, Tuple
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
QProgressBar, QPushButton, QWidget, QSizePolicy,
QTabWidget)
from PyQt5.QtCore import Qt
# ── 색상 팔레트 ────────────────────────────────────────────────
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
DARK_BG = '#1A1B1E'
DARK_PANEL = '#25262B'
DARK_PANEL_2 = '#2C2E33'
DARK_BORDER = '#2C2E33'
DARK_BORDER_STRONG = '#373A40'
DARK_TEXT = '#E9ECEF'
DARK_TEXT_DIM = '#909296'
DARK_TEXT_FAINT = '#6C6E73'
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
ACCENT_GOLD = '#ffd24a'
ACCENT_BLUE = '#4DABF7'
ACCENT_CYAN = '#4adef0'
ACCENT_PINK = '#ff90b8'
ACCENT_GREEN = '#51CF66'
ACCENT_ORANGE = '#fcd34d'
ACCENT_RED = '#FA5252'
# 카드 테마 (등급/상태별)
CARD_THEMES = {
'gold': {
'border': '#ffb700', 'border_strong': '#ffd24a',
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
},
'blue': {
'border': '#5a8eff', 'border_strong': '#6b9eff',
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
},
'cyan': {
'border': '#3acce0', 'border_strong': '#4adef0',
'bg_top': '#0e3340', 'bg_bot': '#08222b',
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
},
'green': {
'border': '#3ace70', 'border_strong': '#4ade80',
'bg_top': '#0e3324', 'bg_bot': '#082218',
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
},
'pink': {
'border': '#ff5a8c', 'border_strong': '#ff90b8',
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
'text': '#ffc0d4', 'accent': ACCENT_PINK,
},
'red': {
'border': '#ea5566', 'border_strong': '#fb7185',
'bg_top': '#3a1620', 'bg_bot': '#260e16',
'text': '#ffb8c0', 'accent': ACCENT_RED,
},
'gray': {
'border': '#44446a', 'border_strong': '#666688',
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
},
}
# ── 테마 연동 ──────────────────────────────────────────────────
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
def _pal() -> dict:
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
from ui.styles import ThemeColors
g = ThemeColors.get
return {
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
'border': g('border_subtle'), 'border_strong': g('border_default'),
'text': g('text_primary'), 'text_dim': g('text_secondary'),
'text_faint': g('text_tertiary'),
'blue': g('accent_primary'), 'green': g('accent_success'),
'red': g('accent_danger'),
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
}
def _is_dark() -> bool:
from ui.styles import ThemeColors, DARK_COLORS
return ThemeColors.current is DARK_COLORS
def tc(role: str) -> str:
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
return _pal().get(role, '#FF00FF')
# ── QSS 헬퍼 ───────────────────────────────────────────────────
def dialog_qss() -> str:
"""다이얼로그 전체 배경 (현재 테마)."""
return f"QDialog {{ background: {_pal()['bg']}; }}"
def tabs_qss(accent: str = None) -> str:
p = _pal()
if accent is None:
accent = p['blue']
return f"""
QTabWidget::pane {{
background: {p['panel']};
border: 1px solid {p['border']};
border-radius: 10px;
top: -1px;
}}
QTabBar::tab {{
background: {p['panel2']};
color: {p['text_dim']};
padding: 9px 18px;
border: 1px solid {p['border']};
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 3px;
font-size: 10pt;
}}
QTabBar::tab:selected {{
background: {p['panel']};
color: {accent};
font-weight: bold;
border-bottom: 2px solid {accent};
}}
QTabBar::tab:hover:!selected {{
background: {p['border_strong']};
color: {p['text']};
}}
"""
def scroll_qss() -> str:
p = _pal()
return f"""
QScrollArea {{ background: transparent; border: none; }}
QScrollBar:vertical {{
background: {p['panel2']}; width: 10px; border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{
background: {p['panel2']}; height: 10px; border-radius: 5px;
}}
QScrollBar::handle:horizontal {{
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
"""
def button_qss(variant: str = 'default') -> str:
""" variant: default | primary | success | danger | ghost (현재 테마) """
p = _pal()
if variant == 'primary':
return f"""
QPushButton {{
background: {p['blue']}; color: white;
border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: {p['blue_hover']}; }}
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
"""
if variant == 'success':
return f"""
QPushButton {{
background: {p['green']}; color: white;
border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: {p['green_hover']}; }}
"""
if variant == 'danger':
return f"""
QPushButton {{
background: {p['red']}; color: white;
border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt;
}}
QPushButton:hover {{ background: {p['red_hover']}; }}
"""
if variant == 'ghost':
return f"""
QPushButton {{
background: transparent; color: {p['text_dim']};
border: 1px solid {p['border_strong']}; border-radius: 8px;
padding: 6px 14px; font-size: 9.5pt;
}}
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
border-color: {p['blue']}; }}
"""
# default
return f"""
QPushButton {{
background: {p['panel2']}; color: {p['text']};
border: 1px solid {p['border_strong']}; border-radius: 8px;
padding: 8px 18px; font-size: 10pt;
}}
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
"""
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
big_color: str = ACCENT_GOLD,
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
Args:
title: 숫자 작은 라벨 (: "달성")
big_value: 숫자/문자열 (RichText 가능 HTML 사용)
subtitle: 숫자 아래 부제 (: "/ 153")
big_color: 숫자
extra_widgets: 우측에 배치할 위젯 (: 추가 통계, 토글)
"""
p = _pal()
container = QFrame()
container.setStyleSheet(f"""
QFrame {{
background: {p['panel']};
border: 1px solid {p['border']};
border-radius: 8px;
}}
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
""")
layout = QHBoxLayout()
layout.setContentsMargins(20, 14, 20, 14)
layout.setSpacing(20)
# 좌측: 제목 + 큰 숫자
left = QVBoxLayout()
left.setSpacing(2)
if title:
t = QLabel(title)
t.setStyleSheet(
f"font-size: 9pt; color: {p['text_dim']}; "
f"background: transparent; border: none;"
)
left.addWidget(t)
big = QLabel(
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
f" {subtitle}</span>" if subtitle else '')
)
big.setTextFormat(Qt.RichText)
big.setStyleSheet("background: transparent; border: none;")
left.addWidget(big)
layout.addLayout(left)
# 우측: extra widgets
if extra_widgets:
layout.addStretch()
for w in extra_widgets:
layout.addWidget(w)
else:
layout.addStretch()
container.setLayout(layout)
return container
def build_stat_card(title: str, value: str, subtitle: str = '',
theme: str = 'blue', icon: str = '') -> QFrame:
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
p = _pal()
dark = _is_dark()
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
if dark:
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
card_border = t['border']
label_color = t['text']
value_color = t['border_strong']
else:
card_bg = p['panel']
card_border = p['border']
label_color = p['text']
value_color = p['text']
card = QFrame()
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
card.setStyleSheet(f"""
QFrame {{
background: {card_bg};
border: 1px solid {card_border};
border-radius: 10px;
}}
QLabel {{ background: transparent; border: none; color: {label_color}; }}
""")
outer = QHBoxLayout()
outer.setContentsMargins(16, 12, 16, 12)
outer.setSpacing(12)
if icon:
icon_lbl = QLabel()
icon_lbl.setMinimumWidth(48)
icon_lbl.setAlignment(Qt.AlignCenter)
from ui.icons import get_icon, _PATHS
if icon in _PATHS:
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
else:
# 이모지/텍스트 폴백 (구버전 호환)
icon_lbl.setText(icon)
icon_lbl.setStyleSheet(
f"font-size: 28pt; background: transparent; border: none; "
f"color: {t['border_strong']};"
)
outer.addWidget(icon_lbl)
text_box = QVBoxLayout()
text_box.setSpacing(2)
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
f"font-size: 9.5pt; color: {p['text_dim']}; "
f"background: transparent; border: none;"
)
text_box.addWidget(title_lbl)
val_lbl = QLabel(
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
f"{value}</span>"
)
val_lbl.setTextFormat(Qt.RichText)
val_lbl.setStyleSheet("background: transparent; border: none;")
text_box.addWidget(val_lbl)
if subtitle:
sub_lbl = QLabel(subtitle)
sub_lbl.setStyleSheet(
f"font-size: 9pt; color: {p['text_dim']}; "
f"background: transparent; border: none;"
)
sub_lbl.setWordWrap(True)
text_box.addWidget(sub_lbl)
outer.addLayout(text_box, 1)
card.setLayout(outer)
return card
def build_section_card(title: str, content: QWidget,
theme: str = 'gray', icon: str = '') -> QFrame:
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
p = _pal()
if _is_dark():
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
card_border = t['border']
label_color = t['text']
else:
card_bg = p['panel']
card_border = p['border']
label_color = p['text']
card = QFrame()
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
card.setStyleSheet(f"""
QFrame {{
background: {card_bg};
border: 1px solid {card_border};
border-radius: 10px;
}}
QLabel {{ background: transparent; border: none; color: {label_color}; }}
""")
layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 14)
layout.setSpacing(8)
head = QHBoxLayout()
if icon:
i = QLabel()
from ui.icons import get_icon, _PATHS
if icon in _PATHS:
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
else:
i.setText(icon)
i.setStyleSheet(
f"font-size: 16pt; color: {t['border_strong']}; "
f"background: transparent; border: none;"
)
head.addWidget(i)
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
f"background: transparent; border: none;"
)
head.addWidget(title_lbl)
head.addStretch()
layout.addLayout(head)
layout.addWidget(content, 1)
card.setLayout(layout)
return card
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
height: int = 10) -> None:
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
pb.setMinimumHeight(height)
pb.setMaximumHeight(height)
pb.setTextVisible(False)
pb.setStyleSheet(f"""
QProgressBar {{
background: rgba(0,0,0,0.4);
border: none;
border-radius: {height // 2}px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {t['border']}, stop:1 {t['border_strong']});
border-radius: {height // 2}px;
}}
""")
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
color: str = None) -> QLabel:
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
lbl = QLabel(text)
if color is None:
color = _pal()['text']
weight_str = 'bold' if weight == 'bold' else 'normal'
lbl.setStyleSheet(
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
f"background: transparent; border: none; padding: 0; margin: 0;"
)
return lbl

View File

@ -20,7 +20,7 @@ class GoalWidget(QWidget):
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(4)
title = QLabel("🎯 이번 달 목표")
title = QLabel("이번 달 목표")
title.setStyleSheet("font-weight: bold;")
layout.addWidget(title)
@ -78,7 +78,7 @@ class GoalWidget(QWidget):
ot_h, ot_m = ot_total // 60, ot_total % 60
tg_h, tg_m = ot_target // 60, ot_target % 60
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.ot_label.setVisible(False)
@ -93,7 +93,7 @@ class GoalWidget(QWidget):
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
ratio = avg / avg_target if avg_target else 0
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.avg_label.setVisible(False)

View File

@ -2,6 +2,7 @@
사용 설명 가이드 .
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6 탭으로 표시.
도전과제/통계 다이얼로그와 동일한 다크 .
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QWidget, QTabWidget, QTextBrowser)
@ -9,6 +10,7 @@ from PyQt5.QtCore import Qt
from core.i18n import tr, tr_html
from ui.styles import apply_dark_titlebar
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
class HelpView(QDialog):
@ -28,42 +30,47 @@ class HelpView(QDialog):
super().__init__(parent)
self.setWindowTitle(tr('window.help'))
self.setModal(True)
self.setMinimumSize(680, 720)
self.setMinimumSize(720, 720)
self.resize(820, 760)
self.setStyleSheet(dialog_qss())
self.init_ui()
apply_dark_titlebar(self)
apply_dark_titlebar(self) # 현재 테마에 맞춰
def init_ui(self):
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.setContentsMargins(20, 16, 20, 14)
main_layout.setSpacing(10)
# 다크 타이틀
title = QLabel(tr('window.help'))
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;"
)
main_layout.addWidget(title)
tabs = QTabWidget()
tabs.setDocumentMode(True)
tabs.setStyleSheet(tabs_qss())
for html_key, tab_label_key in self._TABS:
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
main_layout.addWidget(tabs)
main_layout.addWidget(tabs, 1)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 10, 20, 20)
button_layout.setContentsMargins(0, 6, 0, 0)
# 온보딩 다시 보기 (왼쪽)
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
onboarding_button.setMinimumHeight(40)
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
onboarding_button = QPushButton("온보딩 다시 보기")
onboarding_button.setMinimumHeight(36)
onboarding_button.setStyleSheet(button_qss('ghost'))
onboarding_button.clicked.connect(self._reopen_onboarding)
button_layout.addWidget(onboarding_button)
button_layout.addStretch()
close_button = QPushButton(tr('btn.close'))
close_button.setObjectName("btn_primary")
close_button.setMinimumHeight(40)
close_button.setMinimumHeight(36)
close_button.setMinimumWidth(120)
close_button.setStyleSheet(button_qss('primary'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
main_layout.addLayout(button_layout)
@ -78,15 +85,106 @@ class HelpView(QDialog):
def _make_tab(self, html: str) -> QWidget:
container = QWidget()
container.setStyleSheet(f"background: {tc('panel')};")
layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 12)
layout.setContentsMargins(0, 0, 0, 0)
browser = QTextBrowser()
browser.setOpenExternalLinks(False)
browser.setHtml(html)
# HTML 내부에 다크 톤 스타일 주입
styled_html = self._inject_dark_styles(html)
browser.setHtml(styled_html)
browser.setStyleSheet(f"""
QTextBrowser {{
background: {tc('panel')};
color: {tc('text')};
border: none;
padding: 16px 20px;
font-size: 10.5pt;
selection-background-color: {tc('blue')};
selection-color: #ffffff;
}}
QScrollBar:vertical {{
background: {tc('panel')}; width: 10px; border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
""")
layout.addWidget(browser)
container.setLayout(layout)
return container
def _inject_dark_styles(self, html: str) -> str:
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
text = tc('text')
dim = tc('text_dim')
blue = tc('blue')
green = tc('green')
panel2 = tc('panel2')
border = tc('border')
css = f"""
<style>
body, p, li {{
color: {text};
font-size: 14px;
line-height: 1.65;
}}
h1, h2, h3, h4 {{
color: {blue};
margin-top: 1.2em;
margin-bottom: 0.5em;
}}
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
h3 {{ font-size: 13pt; color: {blue}; }}
h4 {{ font-size: 11pt; color: {green}; }}
b, strong {{ color: {text}; }}
code {{
background: {panel2};
color: {blue};
padding: 2px 6px;
border-radius: 4px;
font-family: Consolas, monospace;
font-size: 12px;
}}
pre {{
background: {panel2};
border: 1px solid {border};
border-radius: 6px;
padding: 10px;
color: {text};
}}
ul, ol {{ margin-left: 0; padding-left: 24px; }}
li {{ margin-bottom: 4px; }}
a {{ color: {blue}; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
table {{ border-collapse: collapse; margin: 10px 0; }}
th {{
background: {panel2};
color: {text};
padding: 8px 12px;
border: 1px solid {border};
text-align: left;
}}
td {{
padding: 6px 12px;
border: 1px solid {border};
color: {text};
}}
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
blockquote {{
border-left: 3px solid {blue};
margin-left: 0;
padding: 4px 16px;
color: {dim};
}}
</style>
"""
return css + html
# 단독 실행 테스트
if __name__ == "__main__":

82
ui/icons.py Normal file
View File

@ -0,0 +1,82 @@
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환
재호출하면 자동으로 재틴팅된다.
사용:
from ui.icons import get_icon
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
"""
from __future__ import annotations
from PyQt5.QtCore import QByteArray, QRectF, Qt
from PyQt5.QtGui import QIcon, QPixmap, QPainter
from PyQt5.QtSvg import QSvgRenderer
from ui.styles import ThemeColors
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
_PATHS = {
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
}
_SVG_TMPL = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
'fill="none" stroke="{color}" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
)
_cache: dict = {}
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
if color is None:
color = ThemeColors.get('text_secondary')
key = (name, color, size)
cached = _cache.get(key)
if cached is not None:
return cached
paths = _PATHS.get(name)
if paths is None:
return QIcon()
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
renderer = QSvgRenderer(QByteArray(svg))
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
pm = QPixmap(size * dpr, size * dpr)
pm.fill(Qt.transparent)
painter = QPainter(pm)
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
painter.end()
pm.setDevicePixelRatio(dpr)
icon = QIcon(pm)
_cache[key] = icon
return icon
def clear_cache() -> None:
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
_cache.clear()

View File

@ -22,7 +22,7 @@ class LeaveCalendarView(QDialog):
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("📅 연차 캘린더")
self.setWindowTitle("연차 캘린더")
self.setModal(True)
self.setMinimumSize(540, 480)
self._build_ui()
@ -37,19 +37,19 @@ class LeaveCalendarView(QDialog):
balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
layout.addLayout(header)
# 범례
# 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout()
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
('#748FFC', '종일+예정')]:
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
l.setStyleSheet("padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
@ -62,7 +62,7 @@ class LeaveCalendarView(QDialog):
# 선택 일자 정보
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
layout.addWidget(self.detail_label)
# 닫기 버튼
@ -76,7 +76,9 @@ class LeaveCalendarView(QDialog):
self.setLayout(layout)
def _mark_dates(self):
"""연차 사용 일자에 색상 표시."""
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
from datetime import date as _date
today = _date.today()
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
@ -85,12 +87,18 @@ class LeaveCalendarView(QDialog):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
is_planned = d > today
if is_planned:
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
else:
color = QColor("#9c27b0")
# 과거/오늘 = 사용 완료 색상
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))
@ -109,4 +117,4 @@ class LeaveCalendarView(QDialog):
d = float(r.get('days') or 0)
memo = r.get('memo') or ''
parts.append(f"{t} {d}" + (f" ({memo})" if memo else ""))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
self.detail_label.setText(f"{date_str}: " + ", ".join(parts))

View File

@ -90,6 +90,11 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
schedule_button = QPushButton("스케줄")
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_button)
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
@ -102,6 +107,13 @@ class LeaveView(QDialog):
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def _show_schedule(self):
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
# 닫고 돌아오면 잔액/리스트 갱신
self.load_data()
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
@ -134,7 +146,7 @@ class LeaveView(QDialog):
days_str = f"{days}"
days_item = QTableWidgetItem(days_str)
days_item.setTextAlignment(Qt.AlignCenter)
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
memo_item = QTableWidgetItem(record['memo'] or "")
@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
@ -345,6 +359,38 @@ class AddLeaveDialog(QDialog):
)
return
# 휴일/주말 검증 — 차감 의미 없으므로 차단
from datetime import datetime as _dt
date_dt = _dt.strptime(date, "%Y-%m-%d")
if date_dt.weekday() in (5, 6): # 토/일
QMessageBox.warning(
self,
"주말 등록 불가",
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
)
return
if self.db.is_holiday(date):
holiday = self.db.get_holiday(date)
name = (holiday or {}).get('name', '공휴일')
QMessageBox.warning(
self,
"공휴일 등록 불가",
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
)
return
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
existing_min = self.db.get_leave_minutes_for(date)
existing_days = existing_min / max(1, self.db.get_work_minutes())
if existing_days + days > 1.0001: # 부동소수점 여유
QMessageBox.warning(
self,
"중복 등록 초과",
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
)
return
# 확인 메시지
hours = days * 8
reply = QMessageBox.question(

File diff suppressed because it is too large Load Diff

View File

@ -3,44 +3,70 @@
기본 60 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
입력하면 값을 break_records.break_type='lunch'/'dinner' 저장.
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QMessageBox)
from PyQt5.QtCore import QTime, Qt
from PyQt5.QtCore import QTime
from ui.styles import apply_dark_titlebar
class MealTimeDialog(QDialog):
"""점심/저녁 실제 시작·종료 시간 입력."""
"""점심/저녁 실제 시작·종료 시간 입력.
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
Args:
parent: 부모 위젯
meal_type: 'lunch' 또는 'dinner'
default_minutes: 안내 문구에 표시할 기본 차감
clock_in_time: 출근 datetime (옵션). 주어지면 식사가 출근 이후인지 검증.
clock_out_time: 퇴근 datetime (옵션, 보통 미퇴근 상태). 주어지면 퇴근 이전인지 검증.
"""
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60,
clock_in_time: Optional[datetime] = None,
clock_out_time: Optional[datetime] = None):
super().__init__(parent)
self.meal_type = meal_type
self._clock_in = clock_in_time
self._clock_out = clock_out_time
title_kr = '점심' if meal_type == 'lunch' else '저녁'
self.setWindowTitle(f"{title_kr} 시간 입력")
self.setModal(True)
self.setFixedSize(360, 220)
self.setFixedSize(380, 260)
layout = QVBoxLayout()
layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
if clock_in_time is not None:
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
info = QLabel(info_text)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding-bottom: 6px;")
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
layout.addWidget(info)
# 합리적 기본값: 출근 이후로 보정
default_start_h = 12 if meal_type == 'lunch' else 18
default_end_h = 13 if meal_type == 'lunch' else 19
if clock_in_time is not None and clock_in_time.hour >= default_start_h:
# 출근이 이미 점심/저녁 기본 시각을 지났으면 출근 직후를 기본값으로
default_start_h = (clock_in_time.hour + 1) % 24
default_end_h = (default_start_h + 1) % 24
# 시작
start_row = QHBoxLayout()
start_row.addWidget(QLabel("시작:"))
self.start_edit = QTimeEdit()
self.start_edit.setDisplayFormat("HH:mm")
# 기본값: 점심=12:00, 저녁=18:00
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
self.start_edit.setTime(default_start)
self.start_edit.setTime(QTime(default_start_h, 0))
start_row.addWidget(self.start_edit)
start_row.addStretch()
layout.addLayout(start_row)
@ -50,15 +76,14 @@ class MealTimeDialog(QDialog):
end_row.addWidget(QLabel("종료:"))
self.end_edit = QTimeEdit()
self.end_edit.setDisplayFormat("HH:mm")
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
self.end_edit.setTime(default_end)
self.end_edit.setTime(QTime(default_end_h, 0))
end_row.addWidget(self.end_edit)
end_row.addStretch()
layout.addLayout(end_row)
# 미리보기 라벨
self.preview = QLabel("")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
layout.addWidget(self.preview)
self._update_preview()
self.start_edit.timeChanged.connect(self._update_preview)
@ -79,20 +104,67 @@ class MealTimeDialog(QDialog):
self.setLayout(layout)
apply_dark_titlebar(self)
def _update_preview(self):
s = self.start_edit.time()
e = self.end_edit.time()
start_dt = datetime.combine(datetime.today(), s.toPyTime())
end_dt = datetime.combine(datetime.today(), e.toPyTime())
# -------- 내부 시각 계산 --------
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
자정 경계 처리:
1) 야간 출근자(clock_in 18 이후) 새벽 식사 시각을 입력하면
시작이 출근 이전으로 보이는데, 이를 다음날 새벽으로 +1day shift.
2) 종료가 시작보다 빠르면 종료에 +1day (점심 12:5513:30 같은 정상은 영향 X).
주간 출근자(clock_in 09) 08 입력 +1day는 적용하지 않아 검증에서 거절.
"""
s = self.start_edit.time().toPyTime()
e = self.end_edit.time().toPyTime()
base_date = (self._clock_in.date() if self._clock_in is not None
else datetime.today().date())
start_dt = datetime.combine(base_date, s)
end_dt = datetime.combine(base_date, e)
# 야간 출근자 자동 보정
if (self._clock_in is not None and start_dt < self._clock_in
and self._clock_in.hour >= 18):
start_dt += timedelta(days=1)
end_dt += timedelta(days=1)
if end_dt < start_dt:
end_dt += timedelta(days=1)
minutes = int((end_dt - start_dt).total_seconds() / 60)
if minutes <= 0:
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
self.preview.setStyleSheet("color: #f44336;")
return start_dt, end_dt, minutes
def _update_preview(self):
start_dt, end_dt, minutes = self._resolve_meal_window()
ok, reason = self._validate_window(start_dt, end_dt, minutes)
if not ok:
self.preview.setText(f"{reason}")
self.preview.setStyleSheet("color: #FA5252;")
else:
self.preview.setText(f"{minutes}")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
def _validate_window(self, start_dt: datetime, end_dt: datetime,
minutes: int) -> tuple[bool, str]:
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
if minutes <= 0:
return False, "시작이 종료보다 늦습니다"
if minutes > 8 * 60:
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
return False, "식사 시간이 8시간을 초과합니다"
if self._clock_in is not None and start_dt < self._clock_in:
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다"
if self._clock_out is not None and end_dt > self._clock_out:
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다"
return True, ""
def accept(self):
"""저장 버튼: 검증 실패 시 다이얼로그 닫지 않고 메시지박스."""
start_dt, end_dt, minutes = self._resolve_meal_window()
ok, reason = self._validate_window(start_dt, end_dt, minutes)
if not ok:
QMessageBox.warning(self, "입력 오류", reason)
return
super().accept()
def get_times(self) -> tuple[str, str, int]:
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
@ -100,9 +172,5 @@ class MealTimeDialog(QDialog):
e = self.end_edit.time().toPyTime()
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
s_dt = datetime.combine(datetime.today(), s)
e_dt = datetime.combine(datetime.today(), e)
if e_dt < s_dt:
e_dt += timedelta(days=1)
minutes = int((e_dt - s_dt).total_seconds() / 60)
_, _, minutes = self._resolve_meal_window()
return start_str, end_str, minutes

View File

@ -41,7 +41,7 @@ class MiniWidget(QWidget):
self.title_label = QLabel(tr('label.remaining'))
self.title_label.setAlignment(Qt.AlignCenter)
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
self.time_label = QLabel("--:--:--")
self.time_label.setAlignment(Qt.AlignCenter)
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
layout.addWidget(self.time_label)
self.setLayout(layout)
# 기본 스타일 (테마 무관 가독성 유지)
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
self.setStyleSheet("""
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
QLabel { color: #fff; }
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
QLabel { color: #E9ECEF; background: transparent; }
""")
apply_dark_titlebar(self)
@ -63,11 +63,12 @@ class MiniWidget(QWidget):
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
self.time_label.setText(remaining_str)
if remaining_str.startswith('+'):
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
self.title_label.setText(tr('label.overtime_progress'))
self.time_label.setStyleSheet("color: #ff6b6b;")
self.time_label.setStyleSheet("color: #51CF66;")
else:
self.title_label.setText(tr('label.remaining'))
self.time_label.setStyleSheet("color: #fff;")
self.time_label.setStyleSheet("color: #E9ECEF;")
# 드래그 이동
def mousePressEvent(self, event: QMouseEvent):
@ -90,6 +91,17 @@ class MiniWidget(QWidget):
def contextMenuEvent(self, event):
from PyQt5.QtWidgets import QMenu
menu = QMenu(self)
# 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
# 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
qss = self.parent_window.styleSheet() if self.parent_window else ''
if not qss:
try:
from ui.styles import get_theme
qss = get_theme('dark')
except Exception:
qss = ''
if qss:
menu.setStyleSheet(qss)
open_main = menu.addAction("메인 창 열기")
close_mini = menu.addAction("미니 위젯 닫기")
action = menu.exec_(event.globalPos())

View File

@ -12,23 +12,27 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
QMessageBox, QGroupBox)
from PyQt5.QtCore import Qt
from core.i18n import tr
from ui.styles import apply_dark_titlebar
# (label, work_minutes, lunch_minutes)
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
WORK_PRESETS = [
("표준 8시간 (점심 60분)", 480, 60),
("단축근무 7시간 30분 (점심 30분)", 450, 30),
("단축근무 7시간 (점심 60분)", 420, 60),
("단축근무 6시간 (점심 30분)", 360, 30),
("반일 4시간 (점심 0분)", 240, 0),
('onboarding.preset.standard_8h', 480, 60, 0),
('onboarding.preset.short_7h30m', 450, 30, 0),
('onboarding.preset.short_7h', 420, 60, 0),
('onboarding.preset.short_6h', 360, 30, 0),
('onboarding.preset.half_4h', 240, 0, 0),
]
class WelcomePage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("👋 환영합니다!")
self.setTitle("환영합니다!")
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
layout = QVBoxLayout()
intro = QLabel(
@ -47,59 +51,83 @@ class WelcomePage(QWizardPage):
class WorkPatternPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🕘 근무 패턴")
self.setTitle("근무 패턴")
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
layout = QVBoxLayout()
self.button_group = QButtonGroup(self)
for i, (label, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(label)
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(tr(key))
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("직접 입력:")
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
custom_layout = QVBoxLayout()
custom_layout.setSpacing(4)
suffix_h = tr('onboarding.preset.suffix_hours')
suffix_m = tr('onboarding.preset.suffix_minutes')
row1 = QHBoxLayout()
self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio'))
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.hours_spin.setSuffix(suffix_h)
self.minutes_spin = QSpinBox()
self.minutes_spin.setRange(0, 59)
self.minutes_spin.setSingleStep(15)
self.minutes_spin.setSuffix("")
self.minutes_spin.setSuffix(suffix_m)
row1.addWidget(self.custom_radio)
row1.addWidget(self.hours_spin)
row1.addWidget(self.minutes_spin)
row1.addStretch()
custom_layout.addLayout(row1)
row2 = QHBoxLayout()
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()
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
self.lunch_spin.setSuffix(suffix_m)
self.dinner_spin = QSpinBox()
self.dinner_spin.setRange(0, 120)
self.dinner_spin.setSingleStep(5)
self.dinner_spin.setValue(0)
self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix'))
self.dinner_spin.setSuffix(suffix_m)
self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip'))
row2.addSpacing(20)
row2.addWidget(self.lunch_spin)
row2.addWidget(self.dinner_spin)
row2.addStretch()
custom_layout.addLayout(row2)
custom_box.setLayout(custom_layout)
layout.addWidget(custom_box)
self.setLayout(layout)
def selected_minutes(self):
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
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()
_, wm, lm, dm = WORK_PRESETS[idx]
return wm, lm, dm
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
self.lunch_spin.value(),
self.dinner_spin.value())
class ClockInDetectionPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("출근 시간 감지 방식")
self.setTitle("출근 시간 감지 방식")
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
layout = QVBoxLayout()
@ -111,10 +139,10 @@ class ClockInDetectionPage(QWizardPage):
layout.addWidget(opt)
info = QLabel(
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
"\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;")
info.setStyleSheet("color: #909296; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
@ -131,7 +159,7 @@ class ClockInDetectionPage(QWizardPage):
class LeaveSalaryPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
self.setTitle("연차 + 급여 (옵션)")
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
layout = QVBoxLayout()
@ -190,7 +218,7 @@ class LeaveSalaryPage(QWizardPage):
class DiscordPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("💬 Discord 알림 (선택)")
self.setTitle("Discord 알림 (선택)")
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
layout = QVBoxLayout()
@ -208,7 +236,7 @@ class DiscordPage(QWizardPage):
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
guide.setStyleSheet("color: #888; padding: 6px;")
guide.setStyleSheet("color: #909296; padding: 6px;")
guide.setWordWrap(True)
layout.addWidget(guide)
@ -232,6 +260,13 @@ class DiscordPage(QWizardPage):
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
return
from utils import discord_webhook
if not discord_webhook.is_valid_webhook_url(url):
QMessageBox.warning(
self, "URL 형식 오류",
"Discord 웹훅 URL 형식이 아닙니다.\n"
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
)
return
ok = discord_webhook.send_test(url)
if ok:
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
@ -242,14 +277,14 @@ class DiscordPage(QWizardPage):
class FinishPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🎉 준비 완료!")
self.setTitle("준비 완료!")
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
layout = QVBoxLayout()
msg = QLabel(
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
"🕐 단축키:\n"
"단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
@ -286,7 +321,7 @@ class OnboardingWizard(QWizard):
def accept(self):
# 1. 근무 패턴
wm, lm = self.work_page.selected_minutes()
wm, lm, dm = self.work_page.selected_minutes()
if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
return
@ -294,6 +329,7 @@ class OnboardingWizard(QWizard):
settings = {
'work_minutes': wm,
'lunch_duration_minutes': lm,
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
'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(),
@ -301,6 +337,8 @@ class OnboardingWizard(QWizard):
'overtime_rate': self.leave_page.rate_combo.currentData(),
'onboarding_completed': True,
}
if dm > 0:
settings['dinner_duration_minutes'] = dm
# 2. 출근 감지 방식
mode = self.detect_page.detection_mode()

View File

@ -66,6 +66,8 @@ class OvertimeView(QDialog):
self.earned_table.setAlternatingRowColors(True)
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
@ -126,7 +128,7 @@ class OvertimeView(QDialog):
cursor = conn.cursor()
cursor.execute('''
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC
@ -138,6 +140,7 @@ class OvertimeView(QDialog):
for i, record in enumerate(earned_records):
date_item = QTableWidgetItem(record[0])
date_item.setTextAlignment(Qt.AlignCenter)
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
minutes = record[1]
hours = minutes // 60
@ -148,7 +151,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "")
@ -183,7 +186,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
reason_item = QTableWidgetItem(record[3] or "")
@ -249,6 +252,46 @@ class OvertimeView(QDialog):
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def show_earned_context_menu(self, position):
"""적립 내역 우클릭 메뉴 (삭제)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
menu = QMenu(self)
delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_earned_record)
menu.addAction(delete_action)
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
def delete_earned_record(self):
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
row = selected_rows[0].row()
date_item = self.earned_table.item(row, 0)
time_item = self.earned_table.item(row, 1)
# 행에 저장된 overtime_bank.id
bank_id = date_item.data(Qt.UserRole)
if bank_id is None:
return
reply = QMessageBox.question(
self,
tr('msg.confirm_delete.title'),
tr('view.overtime.delete_earned_confirm_body',
date=date_item.text(), time=time_item.text()),
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_overtime_earned(bank_id)
self.load_data()
# 부모 윈도우 잔액 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def add_earned_record(self):
"""수동 적립 추가"""
dialog = AddOvertimeEarnedDialog(self, self.db)

View File

@ -26,7 +26,7 @@ class PastRecordDialog(QDialog):
layout.setSpacing(8)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
info = QLabel(f"{date_str} 근무 기록을 입력하세요.")
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
layout.addWidget(info)
@ -56,9 +56,9 @@ class PastRecordDialog(QDialog):
# 점심/저녁
meal_row = QHBoxLayout()
self.lunch_check = QCheckBox("🍱 점심시간 포함")
self.lunch_check = QCheckBox("점심시간 포함")
self.lunch_check.setChecked(True)
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
self.dinner_check = QCheckBox("저녁시간 포함")
meal_row.addWidget(self.lunch_check)
meal_row.addWidget(self.dinner_check)
meal_row.addStretch()

View File

@ -0,0 +1,199 @@
"""
반복 연차 등록/관리 다이얼로그.
지원: 매주/격주 요일, 매월 N일.
"""
from __future__ import annotations
from datetime import date
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QComboBox, QDateEdit, QSpinBox,
QDoubleSpinBox, QLineEdit, QGroupBox,
QListWidget, QListWidgetItem, QMessageBox,
QCheckBox, QButtonGroup, QRadioButton)
from PyQt5.QtCore import QDate, Qt
from core.recurring_leaves import describe_pattern
from ui.styles import apply_dark_titlebar
_KO_WEEKDAYS = [('', 'mon'), ('', 'tue'), ('', 'wed'),
('', 'thu'), ('', 'fri'), ('', 'sat'), ('', 'sun')]
class RecurringLeaveDialog(QDialog):
"""반복 연차 패턴 추가/삭제."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("반복 연차 관리")
self.setMinimumSize(540, 480)
self._build_ui()
self._reload_list()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 기존 패턴 목록
list_group = QGroupBox("등록된 반복 패턴")
lg = QVBoxLayout()
self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(160)
lg.addWidget(self.list_widget)
del_btn = QPushButton("선택 삭제")
del_btn.clicked.connect(self._delete_selected)
lg.addWidget(del_btn)
list_group.setLayout(lg)
layout.addWidget(list_group)
# 신규 등록
add_group = QGroupBox("신규 패턴 추가")
ag = QVBoxLayout()
# 패턴 종류
kind_row = QHBoxLayout()
kind_row.addWidget(QLabel("주기:"))
self.kind_group = QButtonGroup(self)
self.rb_weekly = QRadioButton("매주")
self.rb_weekly.setChecked(True)
self.rb_biweekly = QRadioButton("격주")
self.rb_monthly = QRadioButton("매월 N일")
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
self.kind_group.addButton(rb)
kind_row.addWidget(rb)
kind_row.addStretch()
ag.addLayout(kind_row)
# 요일 체크박스 (weekly/biweekly)
wd_row = QHBoxLayout()
wd_row.addWidget(QLabel("요일:"))
self.weekday_checks = []
for ko, en in _KO_WEEKDAYS:
cb = QCheckBox(ko)
self.weekday_checks.append((cb, en))
wd_row.addWidget(cb)
wd_row.addStretch()
ag.addLayout(wd_row)
# 매월 N일
month_row = QHBoxLayout()
month_row.addWidget(QLabel("매월:"))
self.day_of_month = QSpinBox()
self.day_of_month.setRange(1, 31)
self.day_of_month.setValue(15)
self.day_of_month.setSuffix("")
month_row.addWidget(self.day_of_month)
month_row.addStretch()
ag.addLayout(month_row)
# 차감 일수
days_row = QHBoxLayout()
days_row.addWidget(QLabel("차감:"))
self.days_combo = QComboBox()
self.days_combo.addItem("1.0일 (종일)", 1.0)
self.days_combo.addItem("0.5일 (반차)", 0.5)
self.days_combo.addItem("0.25일 (반반차)", 0.25)
days_row.addWidget(self.days_combo)
days_row.addStretch()
ag.addLayout(days_row)
# 시작/종료 날짜
date_row = QHBoxLayout()
date_row.addWidget(QLabel("시작:"))
self.start_edit = QDateEdit()
self.start_edit.setDate(QDate.currentDate())
self.start_edit.setCalendarPopup(True)
date_row.addWidget(self.start_edit)
date_row.addWidget(QLabel("종료:"))
self.end_edit = QDateEdit()
self.end_edit.setDate(QDate.currentDate().addMonths(6))
self.end_edit.setCalendarPopup(True)
date_row.addWidget(self.end_edit)
self.no_end_check = QCheckBox("종료 없음 (무기한)")
self.no_end_check.toggled.connect(
lambda v: self.end_edit.setEnabled(not v)
)
date_row.addWidget(self.no_end_check)
date_row.addStretch()
ag.addLayout(date_row)
# 메모
memo_row = QHBoxLayout()
memo_row.addWidget(QLabel("메모:"))
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
memo_row.addWidget(self.memo_edit)
ag.addLayout(memo_row)
# 추가 버튼
add_btn = QPushButton("추가")
add_btn.setObjectName("btn_primary")
add_btn.clicked.connect(self._save)
ag.addWidget(add_btn)
add_group.setLayout(ag)
layout.addWidget(add_group)
# 닫기
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
def _reload_list(self):
self.list_widget.clear()
for r in self.db.get_recurring_leaves():
desc = describe_pattern(r['pattern'])
end = r.get('end_date') or '무기한'
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
f"· {r['start_date']} ~ {end}")
if r.get('memo'):
text += f"{r['memo']}"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, r['id'])
self.list_widget.addItem(item)
def _delete_selected(self):
item = self.list_widget.currentItem()
if not item:
return
rec_id = item.data(Qt.UserRole)
reply = QMessageBox.question(
self, "삭제 확인",
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(rec_id)
self._reload_list()
def _build_pattern(self) -> str | None:
if self.rb_monthly.isChecked():
return f"monthly:{self.day_of_month.value()}"
# weekly/biweekly
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
if not chosen:
return None
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
return f"{prefix}:" + ",".join(chosen)
def _save(self):
pattern = self._build_pattern()
if not pattern:
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
return
days = self.days_combo.currentData()
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
start = self.start_edit.date().toString('yyyy-MM-dd')
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
memo = self.memo_edit.text().strip()
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
QMessageBox.information(self, "추가 완료",
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
self.memo_edit.clear()
self._reload_list()

315
ui/schedule_view.py Normal file
View File

@ -0,0 +1,315 @@
"""
통합 스케줄 화면 휴일 + 연차(예정/사용) + 반복 패턴.
기능:
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 /, 반차 노랑, 반반차 보라, 반복 회색)
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
- 반복 패턴 관리 RecurringLeaveDialog
"""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import List
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget, QListWidget,
QListWidgetItem, QMessageBox, QMenu,
QGroupBox, QSplitter, QWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from core.recurring_leaves import expand_for_range, describe_pattern
from ui.styles import apply_dark_titlebar
# 색상 팔레트
_C_HOLIDAY = QColor("#e53935") # 빨강
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
class ScheduleView(QDialog):
"""월간 통합 스케줄 다이얼로그."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("스케줄")
self.setMinimumSize(820, 560)
self._build_ui()
self._reload()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 상단 툴바
bar = QHBoxLayout()
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
bar.addWidget(title)
bar.addStretch()
rec_btn = QPushButton("반복 패턴 관리")
rec_btn.clicked.connect(self._open_recurring_dialog)
bar.addWidget(rec_btn)
add_btn = QPushButton("연차 등록")
add_btn.clicked.connect(self._open_add_leave_dialog)
bar.addWidget(add_btn)
layout.addLayout(bar)
# 범례
legend = QHBoxLayout()
for label, color in [("공휴일", _C_HOLIDAY),
("연차 사용", _C_LEAVE_FULL_PAST),
("연차 예정", _C_LEAVE_FULL_PLAN),
("반차/반반차", _C_LEAVE_HALF_PAST),
("반복 패턴", _C_RECURRING)]:
sw = QLabel(f" {label} ")
sw.setStyleSheet(
f"background-color: {color.name()}; color: white; "
f"padding: 2px 6px; border-radius: 3px;"
)
legend.addWidget(sw)
legend.addStretch()
layout.addLayout(legend)
# 캘린더 + 상세 splitter
splitter = QSplitter(Qt.Horizontal)
# 좌측: 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
self.calendar.currentPageChanged.connect(self._on_page_change)
splitter.addWidget(self.calendar)
# 우측: 상세 패널
right = QWidget()
right_layout = QVBoxLayout()
self.detail_title = QLabel("날짜를 선택하세요")
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
right_layout.addWidget(self.detail_title)
self.detail_list = QListWidget()
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
right_layout.addWidget(self.detail_list, 1)
right.setLayout(right_layout)
splitter.addWidget(right)
splitter.setSizes([520, 280])
layout.addWidget(splitter, 1)
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
# ------------------------------------------------------------- reload
def _reload(self):
"""현재 화면 월에 대해 색상/리스트 갱신."""
# 모든 날짜 포맷 초기화
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
y = self.calendar.yearShown()
m = self.calendar.monthShown()
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
first = date(y, m, 1)
if m == 12:
last = date(y + 1, 1, 1) - timedelta(days=1)
else:
last = date(y, m + 1, 1) - timedelta(days=1)
view_start = first - timedelta(days=7)
view_end = last + timedelta(days=7)
# 휴일
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
view_end.isoformat()) \
if hasattr(self.db, 'get_holidays_in_range') else []
if not holidays:
holidays = self._fallback_holidays(view_start, view_end)
for h in holidays:
d = self._parse_date(h.get('date'))
if d is None:
continue
self._paint(d, _C_HOLIDAY, fg='white')
# 연차 (구체)
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
view_end.isoformat())
today = date.today()
for r in leaves:
d = self._parse_date(r.get('date'))
if d is None:
continue
days = float(r.get('days') or 0)
is_planned = d > today
if is_planned:
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
else:
if days >= 1.0:
color = _C_LEAVE_FULL_PAST
elif days >= 0.5:
color = _C_LEAVE_HALF_PAST
else:
color = _C_LEAVE_QUART_PAST
self._paint(d, color, fg='white')
# 반복 패턴 인스턴스
recurring = self.db.get_recurring_leaves()
for occ in expand_for_range(recurring, view_start, view_end):
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
existing = self.calendar.dateTextFormat(
QDate(occ.date.year, occ.date.month, occ.date.day))
if existing.background() != QBrush():
continue
self._paint(occ.date, _C_RECURRING, fg='white')
def _paint(self, d: date, color: QColor, fg: str = 'white'):
qd = QDate(d.year, d.month, d.day)
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor(fg)))
self.calendar.setDateTextFormat(qd, fmt)
# ------------------------------------------------------------- events
def _on_date_click(self, qd: QDate):
d = date(qd.year(), qd.month(), qd.day())
date_str = d.isoformat()
weekday_kr = ['', '', '', '', '', '', '']
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
self.detail_list.clear()
# 휴일
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
if holiday:
item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}")
item.setForeground(QBrush(QColor("#e53935")))
self.detail_list.addItem(item)
elif d.weekday() in (5, 6):
item = QListWidgetItem(f"주말 ({weekday_kr[d.weekday()]}요일)")
self.detail_list.addItem(item)
# 연차 (구체)
for r in self.db.get_leave_records_by_date(date_str):
days = float(r.get('days') or 0)
t = r.get('leave_type', '연차')
memo = r.get('memo') or ''
label = f"{t} {days}"
if memo:
label += f"{memo}"
label += f" [id={r['id']}]"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, ('concrete', r['id']))
self.detail_list.addItem(item)
# 반복 패턴 매치
recurring = self.db.get_recurring_leaves(active_on=date_str)
from core.recurring_leaves import expand_for_date
for occ in expand_for_date(recurring, d):
item = QListWidgetItem(
f"{describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
)
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
self.detail_list.addItem(item)
if self.detail_list.count() == 0:
self.detail_list.addItem("일정 없음")
def _on_page_change(self, year: int, month: int):
self._reload()
def _on_list_menu(self, pos):
item = self.detail_list.currentItem()
if not item:
return
data = item.data(Qt.UserRole)
if not data:
return
kind, _id = data
menu = QMenu(self)
del_act = menu.addAction("삭제")
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
if chosen == del_act:
self._delete_record(kind, _id)
def _delete_record(self, kind: str, _id: int):
if kind == 'concrete':
reply = QMessageBox.question(
self, "삭제 확인",
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_leave_record(_id)
self._reload()
# 상세 갱신
d = self.calendar.selectedDate()
self._on_date_click(d)
elif kind == 'recurring':
reply = QMessageBox.question(
self, "삭제 확인",
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(_id)
self._reload()
d = self.calendar.selectedDate()
self._on_date_click(d)
def _open_recurring_dialog(self):
from ui.recurring_leave_dialog import RecurringLeaveDialog
dlg = RecurringLeaveDialog(self, self.db)
dlg.exec_()
self._reload()
def _open_add_leave_dialog(self):
from ui.leave_view import AddLeaveDialog
dlg = AddLeaveDialog(self, self.db)
# 선택된 날짜로 기본값 설정
d = self.calendar.selectedDate()
if d.isValid():
dlg.date_edit.setDate(d)
if dlg.exec_() == dlg.Accepted:
self._reload()
self._on_date_click(d)
# ------------------------------------------------------------- helpers
@staticmethod
def _parse_date(s):
if not s:
return None
try:
return datetime.strptime(s, '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
if not hasattr(self.db, 'get_holiday'):
return []
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
out = []
cur = view_start
while cur <= view_end:
h = self.db.get_holiday(cur.isoformat())
if h:
out.append(h)
cur += timedelta(days=1)
return out

View File

@ -17,8 +17,11 @@ from core.i18n import tr
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,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIFICATION_BEFORE_MINUTES,
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
HEALTH_BREAK_HOURS, HEALTH_BREAK_ENABLED,
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
@ -258,13 +261,50 @@ class SettingsView(QDialog):
self.work_preset_combo.setCurrentIndex(custom_index)
self.work_preset_combo.blockSignals(False)
def _load_threshold_safely(self, settings: dict, setting_key: str,
attr_name: str, default: int) -> None:
"""settings dict에서 임계값을 읽어 SpinBox에 안전하게 적용.
get_settings() 결과는 이미 타입 변환된 dict이라 추가 DB 호출 없이 사용.
"""
if not hasattr(self, attr_name):
return
spin = getattr(self, attr_name)
try:
val = int(settings.get(setting_key, default))
except (ValueError, TypeError):
val = default
# 이미 설정된 setRange를 존중 — 사용자 옛 값이 범위 밖이면 클램프
spin.setValue(max(spin.minimum(), min(spin.maximum(), val)))
@staticmethod
def _make_threshold_spin(lo: int, hi: int, default: int, suffix: str) -> QSpinBox:
"""고급 임계값용 표준 SpinBox."""
sb = QSpinBox()
sb.setRange(lo, hi)
sb.setValue(default)
sb.setSuffix(suffix)
sb.setFixedWidth(110)
return sb
@staticmethod
def _labeled_row(label_text: str, widget) -> QHBoxLayout:
"""좌측 라벨(고정 폭) + 위젯 + 오른쪽 stretch 한 줄 레이아웃."""
row = QHBoxLayout()
lbl = QLabel(label_text)
lbl.setFixedWidth(140)
row.addWidget(lbl)
row.addWidget(widget)
row.addStretch()
return row
def create_notification_group(self) -> QGroupBox:
"""알림 설정 그룹"""
group = QGroupBox(tr('group.notification'))
layout = QVBoxLayout()
layout.setSpacing(6)
# 알림 체크박스들을 2열로 배치
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
check_row1 = QHBoxLayout()
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
self.clock_out_notification_check.setChecked(True)
@ -275,13 +315,25 @@ class SettingsView(QDialog):
layout.addLayout(check_row1)
check_row2 = QHBoxLayout()
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
self.dinner_notification_check.setChecked(True)
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
self.overtime_notification_check.setChecked(True)
check_row2.addWidget(self.dinner_notification_check)
check_row2.addWidget(self.overtime_notification_check)
layout.addLayout(check_row2)
check_row3 = QHBoxLayout()
self.health_notification_check = QCheckBox("건강 경고 알림")
self.health_notification_check.setChecked(True)
check_row2.addWidget(self.overtime_notification_check)
check_row2.addWidget(self.health_notification_check)
layout.addLayout(check_row2)
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
self.health_break_notification_check.setChecked(True)
self.health_break_notification_check.setToolTip(
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
)
check_row3.addWidget(self.health_notification_check)
check_row3.addWidget(self.health_break_notification_check)
layout.addLayout(check_row3)
# 퇴근 N분 전 알림 시점 설정
before_row = QHBoxLayout()
@ -299,6 +351,47 @@ class SettingsView(QDialog):
before_row.addStretch()
layout.addLayout(before_row)
# === 고급 임계값 (접이식 그룹박스) ===
adv_box = QGroupBox("고급 임계값")
adv_box.setCheckable(True)
adv_box.setChecked(False) # 기본 접힘
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
adv_layout = QVBoxLayout()
adv_layout.setSpacing(4)
# 점심 알림 임계 (출근 후 N시간)
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
# 저녁 알림 임계 (출근 후 N시간)
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
# 연장근무 누적 임계
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
# 주 X시간 임계
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
# 연속 연장근무 일수
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, "")
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
# 휴식 권고 (연속 근무 시간)
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
adv_box.setLayout(adv_layout)
layout.addWidget(adv_box)
# 시간 형식 + 테마 한 줄에
format_row = QHBoxLayout()
time_format_label = QLabel("시간 형식:")
@ -423,7 +516,7 @@ class SettingsView(QDialog):
def create_goal_group(self) -> QGroupBox:
"""월간 목표 설정 그룹 (0=비활성)."""
group = QGroupBox("🎯 월간 목표 (0=비활성)")
group = QGroupBox("월간 목표 (0=비활성)")
layout = QVBoxLayout()
layout.setSpacing(6)
@ -578,27 +671,39 @@ class SettingsView(QDialog):
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
def add_korean_holidays_auto(self):
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가"""
current_year = datetime.now().year
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
11 이후 호출 자동으로 다음 연도까지 등록 연말 경계에서
신정 등이 누락되는 방지.
"""
now = datetime.now()
current_year = now.year
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
include_next = now.month >= 11
target_label = (f"{current_year}년 + {current_year + 1}"
if include_next else f"{current_year}")
reply = QMessageBox.question(
self,
"한국 공휴일 자동 추가",
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
"포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
"• 정부 지정 대체·임시공휴일\n\n"
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
added = self.db.add_korean_holidays_auto(current_year)
added = self.db.add_korean_holidays_auto(current_year, include_next_year=include_next)
if added < 0:
# 패키지 미설치 시 고정 공휴일로 폴백
self.db.add_korean_holidays(current_year)
if include_next:
self.db.add_korean_holidays(current_year + 1)
self.update_holiday_count()
QMessageBox.warning(
self,
@ -760,7 +865,7 @@ class SettingsView(QDialog):
# CSV 가져오기
import_layout = QHBoxLayout()
import_btn = QPushButton("📥 CSV 가져오기")
import_btn = QPushButton("CSV 가져오기")
import_btn.setObjectName("btn_small")
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
import_btn.clicked.connect(self._import_csv)
@ -933,14 +1038,27 @@ class SettingsView(QDialog):
# 알림
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
if hasattr(self, 'dinner_notification_check'):
self.dinner_notification_check.setChecked(settings.get(NOTIF_DINNER, True))
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
if hasattr(self, 'health_break_notification_check'):
self.health_break_notification_check.setChecked(
settings.get(HEALTH_BREAK_ENABLED, 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)
# 고급 임계값
self._load_threshold_safely(settings, LUNCH_REMINDER_HOURS, 'lunch_reminder_spin', 4)
self._load_threshold_safely(settings, DINNER_REMINDER_HOURS, 'dinner_reminder_spin', 8)
self._load_threshold_safely(settings, OVERTIME_THRESHOLD_HOURS, 'overtime_threshold_spin', 20)
self._load_threshold_safely(settings, WEEKLY_HOURS_THRESHOLD, 'weekly_hours_spin', 52)
self._load_threshold_safely(settings, HEALTH_CONSECUTIVE_OT_DAYS, 'health_consecutive_spin', 3)
self._load_threshold_safely(settings, HEALTH_BREAK_HOURS, 'health_break_hours_spin', 4)
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
time_format = settings.get(TIME_FORMAT, '24')
if isinstance(time_format, int):
@ -950,7 +1068,7 @@ class SettingsView(QDialog):
self.time_format_combo.setCurrentIndex(index)
# 테마
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
# 언어 선택 적용
if hasattr(self, 'language_combo'):
@ -1067,11 +1185,22 @@ class SettingsView(QDialog):
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
NOTIF_HEALTH: self.health_notification_check.isChecked(),
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
# 고급 임계값
LUNCH_REMINDER_HOURS: self.lunch_reminder_spin.value(),
DINNER_REMINDER_HOURS: self.dinner_reminder_spin.value(),
OVERTIME_THRESHOLD_HOURS: self.overtime_threshold_spin.value(),
WEEKLY_HOURS_THRESHOLD: self.weekly_hours_spin.value(),
HEALTH_CONSECUTIVE_OT_DAYS: self.health_consecutive_spin.value(),
HEALTH_BREAK_HOURS: self.health_break_hours_spin.value(),
TIME_FORMAT: self.time_format_combo.currentData(),
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
}
if hasattr(self, 'dinner_notification_check'):
settings[NOTIF_DINNER] = self.dinner_notification_check.isChecked()
if hasattr(self, 'health_break_notification_check'):
settings[HEALTH_BREAK_ENABLED] = self.health_break_notification_check.isChecked()
if hasattr(self, 'auto_break_check'):
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
if hasattr(self, 'clock_in_unlock_check'):
@ -1243,7 +1372,7 @@ class SettingsView(QDialog):
if filename:
try:
saved_path = CSVExporter.export_work_records(records, filename)
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
QMessageBox.information(
self,
"내보내기 완료",

View File

@ -2,7 +2,8 @@
통계 대시보드 - 주간/월간 통계
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
QFrame)
from PyQt5.QtCore import Qt
from datetime import datetime, timedelta
import sys
@ -12,6 +13,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from core.i18n import tr
from ui.styles import apply_dark_titlebar
from ui.dark_components import (
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
transparent_label, tc,
)
class StatsView(QDialog):
@ -22,72 +27,91 @@ class StatsView(QDialog):
self.db = db if db else Database()
self.init_ui()
self.load_stats()
apply_dark_titlebar(self)
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle(tr('window.stats'))
self.setModal(True)
self.setMinimumSize(420, 350)
self.setMinimumSize(720, 600)
self.resize(900, 720)
self.setStyleSheet(dialog_qss())
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 14)
title = QLabel(tr('stats.title'))
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
# 다크 톤 타이틀
title = QLabel(f"{tr('stats.title')}")
title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(title)
tabs = QTabWidget()
tabs.setStyleSheet(tabs_qss())
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
layout.addWidget(tabs)
# 도전과제용 탭 진입 카운터
tabs.currentChanged.connect(self._on_tab_changed)
self._on_tab_changed(0)
layout.addWidget(tabs, 1)
# 닫기 버튼 — 우측 정렬
btn_row = QHBoxLayout()
btn_row.addStretch()
close_button = QPushButton(tr('btn.close'))
close_button.setMinimumWidth(100)
close_button.setStyleSheet(button_qss('ghost'))
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
btn_row.addWidget(close_button)
layout.addLayout(btn_row)
self.setLayout(layout)
def _on_tab_changed(self, idx: int) -> None:
"""탭별 진입 카운터 (도전과제 시스템용). 실패는 silent."""
keys = ['stat_weekly_view_count', 'stat_monthly_view_count',
'stat_pattern_view_count']
if 0 <= idx < len(keys):
try:
cur = self.db.get_setting_int(keys[idx], 0)
self.db.set_setting(keys[idx], str(cur + 1))
except Exception:
pass
def create_weekly_tab(self) -> QWidget:
"""주간 통계 탭 생성"""
widget = QWidget()
widget.setStyleSheet("background: transparent;")
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(10)
layout.setContentsMargins(8, 12, 8, 8)
summary_group = QGroupBox(tr('stats.weekly_summary'))
summary_layout = QGridLayout()
summary_layout.setSpacing(4)
summary_layout.setContentsMargins(8, 20, 8, 6)
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
cards_row = QHBoxLayout()
cards_row.setSpacing(10)
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
theme='blue', icon='clock')
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
theme='cyan', icon='calendar')
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
theme='green', icon='chart')
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
theme='gold', icon='flame')
for c in (self.weekly_total_card, self.weekly_days_card,
self.weekly_avg_card, self.weekly_ot_card):
cards_row.addWidget(c, 1)
layout.addLayout(cards_row)
self.weekly_total_hours = QLabel("0")
self.weekly_total_hours.setObjectName("stat_value")
self.weekly_work_days = QLabel("0")
self.weekly_work_days.setObjectName("stat_value")
self.weekly_avg_hours = QLabel("0")
self.weekly_avg_hours.setObjectName("stat_value")
self.weekly_overtime = QLabel("0")
self.weekly_overtime.setObjectName("stat_value")
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
summary_layout.addWidget(self.weekly_total_hours, 0, 1)
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
summary_layout.addWidget(self.weekly_work_days, 1, 1)
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
summary_layout.addWidget(self.weekly_avg_hours, 2, 1)
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
summary_layout.addWidget(self.weekly_overtime, 3, 1)
summary_group.setLayout(summary_layout)
layout.addWidget(summary_group)
# 주간 차트 (일별 근무시간)
# 주간 차트 (일별 근무시간) — 카드 안에
from ui.chart_widget import make_chart_widget
self.weekly_chart = make_chart_widget(widget)
layout.addWidget(self.weekly_chart, 1)
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
theme='gray', icon='trending-up')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
return widget
@ -95,40 +119,36 @@ class StatsView(QDialog):
def create_monthly_tab(self) -> QWidget:
"""월간 통계 탭 생성"""
widget = QWidget()
widget.setStyleSheet("background: transparent;")
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(10)
layout.setContentsMargins(8, 12, 8, 8)
# 추정 급여 카드 (옵션 활성 시)
# 카드 4개
cards_row = QHBoxLayout()
cards_row.setSpacing(10)
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
theme='blue', icon='clock')
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
theme='cyan', icon='calendar')
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
theme='green', icon='chart')
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
theme='gold', icon='flame')
for c in (self.monthly_total_card, self.monthly_days_card,
self.monthly_avg_card, self.monthly_ot_card):
cards_row.addWidget(c, 1)
layout.addLayout(cards_row)
# 추정 급여 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
self.salary_label.setStyleSheet(
f"background: rgba(81, 207, 102, 0.12); "
f"border: 1px solid {tc('green')}; border-radius: 8px; "
f"color: {tc('green')}; font-weight: bold; "
f"padding: 10px 14px; font-size: 11pt;"
)
self.salary_label.setVisible(False)
summary_group = QGroupBox(tr('stats.monthly_summary'))
summary_layout = QGridLayout()
summary_layout.setSpacing(4)
summary_layout.setContentsMargins(8, 20, 8, 6)
self.monthly_total_hours = QLabel("0")
self.monthly_total_hours.setObjectName("stat_value")
self.monthly_work_days = QLabel("0")
self.monthly_work_days.setObjectName("stat_value")
self.monthly_avg_hours = QLabel("0")
self.monthly_avg_hours.setObjectName("stat_value")
self.monthly_overtime = QLabel("0")
self.monthly_overtime.setObjectName("stat_value")
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
summary_layout.addWidget(self.monthly_total_hours, 0, 1)
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
summary_layout.addWidget(self.monthly_work_days, 1, 1)
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
summary_layout.addWidget(self.monthly_avg_hours, 2, 1)
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
summary_layout.addWidget(self.monthly_overtime, 3, 1)
summary_group.setLayout(summary_layout)
layout.addWidget(summary_group)
layout.addWidget(self.salary_label)
# 목표 진행률
@ -139,7 +159,9 @@ class StatsView(QDialog):
# 월간 차트
from ui.chart_widget import make_chart_widget
self.monthly_chart = make_chart_widget(widget)
layout.addWidget(self.monthly_chart, 1)
chart_card = build_section_card("요일별 평균", self.monthly_chart,
theme='gray', icon='chart')
layout.addWidget(chart_card, 1)
widget.setLayout(layout)
return widget
@ -147,46 +169,71 @@ class StatsView(QDialog):
def create_pattern_tab(self) -> QWidget:
"""패턴 분석 탭 생성"""
widget = QWidget()
widget.setStyleSheet("background: transparent;")
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
pattern_group = QGroupBox(tr('stats.pattern_insights'))
pattern_layout = QVBoxLayout()
pattern_layout.setSpacing(4)
pattern_layout.setContentsMargins(8, 20, 8, 6)
layout.setSpacing(10)
layout.setContentsMargins(8, 12, 8, 8)
# 패턴 텍스트 카드
self.pattern_text = QLabel(tr('stats.analyzing'))
self.pattern_text.setWordWrap(True)
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
pattern_layout.addWidget(self.pattern_text)
pattern_group.setLayout(pattern_layout)
layout.addWidget(pattern_group)
self.pattern_text.setStyleSheet(
f"font-size: 11pt; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;"
)
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
theme='cyan', icon='search'))
# 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(self.clock_in_chart, 1)
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
theme='gray', icon='clock'), 1)
widget.setLayout(layout)
return widget
def _set_card_value(self, card, value: str) -> None:
"""build_stat_card로 만든 카드의 큰 숫자 라벨 업데이트.
카드 구조: QFrame > QHBoxLayout > [icon QLabel] [text QVBoxLayout > title, value, subtitle]
value는 번째 QLabel.
"""
# text_box는 outer hbox의 마지막 layout
outer = card.layout()
if outer is None or outer.count() == 0:
return
# text_box 찾기 (마지막 item, layout)
text_item = outer.itemAt(outer.count() - 1)
text_box = text_item.layout() if text_item else None
if text_box is None or text_box.count() < 2:
return
val_lbl = text_box.itemAt(1).widget() # 두 번째가 큰 숫자
if val_lbl is None:
return
# 큰 숫자 RichText 형식 유지
from ui.dark_components import CARD_THEMES
# tier color는 카드 자체에 알 방법이 없으니 기본 골드 톤
val_lbl.setText(
f"<span style='font-size: 18pt; font-weight: bold; color: #ffd24a;'>"
f"{value}</span>"
)
def load_stats(self):
"""통계 로드"""
# 주간 통계
weekly_stats = self.db.get_weekly_stats()
total_hours = weekly_stats.get('total_hours', 0) or 0
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 0)}")
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}")
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간")
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간")
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
overtime_hours = overtime_minutes // 60
overtime_mins = overtime_minutes % 60
self.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}")
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}")
# 주간 차트
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
@ -196,26 +243,29 @@ class StatsView(QDialog):
(today - _td(days=6)).isoformat(), today.isoformat()
)
if hasattr(self, 'weekly_chart'):
# 도전과제 chart_hover 감지를 위해 db 참조 attach
self.weekly_chart._achievement_db = self.db
draw_daily_hours(self.weekly_chart, week_records)
# 월간 통계
now = datetime.now()
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
total_hours = monthly_stats.get('total_hours', 0) or 0
self.monthly_total_hours.setText(f"{total_hours:.1f}시간")
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
work_days = monthly_stats.get('work_days', 0) or 0
self.monthly_work_days.setText(f"{work_days}")
self._set_card_value(self.monthly_days_card, f"{work_days}")
if work_days > 0:
avg = total_hours / work_days
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
else:
self.monthly_avg_hours.setText("0시간")
self._set_card_value(self.monthly_avg_card, "0시간")
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
overtime_hours = overtime_minutes // 60
overtime_mins = overtime_minutes % 60
self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}")
self._set_card_value(self.monthly_ot_card,
f"{overtime_hours}시간 {overtime_mins}")
# 월간 차트 (요일별 평균)
if hasattr(self, 'monthly_chart'):

View File

@ -33,8 +33,8 @@ def _ensure_icons():
for name, color_hex, points in [
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
]:
path = os.path.join(_arrow_dir, f'{name}.png')
if not os.path.exists(path):
@ -78,6 +78,9 @@ LIGHT_COLORS = {
'bg_primary': '#F5F5F7',
'bg_secondary': '#FFFFFF',
'bg_tertiary': '#EDEDF0',
# 인터랙션 표면
'surface_hover': '#E2E3E7',
'surface_pressed': '#D5D6DB',
# 텍스트 계층
'text_primary': '#1A1A2E',
'text_secondary': '#4A4A68',
@ -85,9 +88,15 @@ LIGHT_COLORS = {
'text_inverse': '#FFFFFF',
# 액센트
'accent_primary': '#3B82F6',
'accent_primary_hover': '#2F74EE',
'accent_primary_pressed': '#2563EB',
'accent_success': '#10B981',
'accent_success_hover': '#0EA372',
'accent_success_pressed': '#0C8F63',
'accent_warning': '#F59E0B',
'accent_danger': '#EF4444',
'accent_danger_hover': '#DC2626',
'accent_danger_pressed': '#B91C1C',
# 테두리
'border_subtle': '#E5E7EB',
'border_default': '#D1D5DB',
@ -120,40 +129,58 @@ LIGHT_COLORS = {
}
DARK_COLORS = {
'bg_primary': '#111118',
'bg_secondary': '#1C1C2E',
'bg_tertiary': '#282842',
'text_primary': '#ECECF4',
'text_secondary': '#B0B0C8',
'text_tertiary': '#808098',
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
'bg_primary': '#1A1B1E', # 앱 배경
'bg_secondary': '#25262B', # 카드 / 패널
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
# 인터랙션 표면
'surface_hover': '#34363D',
'surface_pressed': '#3A3D44',
# 텍스트 계층
'text_primary': '#E9ECEF',
'text_secondary': '#909296',
'text_tertiary': '#6C6E73',
'text_inverse': '#FFFFFF',
'accent_primary': '#6B9EFF',
'accent_success': '#4ADE80',
'accent_warning': '#FCD34D',
'accent_danger': '#FB7185',
'border_subtle': '#32324E',
'border_default': '#44446A',
'border_focus': '#6B9EFF',
'badge_overtime_bg': '#3D2008',
'badge_overtime_text': '#FDE68A',
'badge_leave_bg': '#1E2D5F',
'badge_leave_text': '#A5D0FE',
'badge_total_bg': '#0A3324',
'badge_total_text': '#86EFAC',
'progress_bg': '#282842',
'progress_start': '#6B9EFF',
'progress_end': '#4ADE80',
'status_overtime': '#FB7185',
'status_warning': '#FCD34D',
'status_normal': '#4ADE80',
'status_break_active': '#FB7185',
'status_break_idle': '#808098',
'cal_normal': '#1A4D3A',
'cal_overtime': '#5C1A1A',
'cal_incomplete': '#5C3A10',
'scrollbar_bg': '#111118',
'scrollbar_handle': '#44446A',
'scrollbar_hover': '#5A5A88',
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
'accent_primary': '#4DABF7',
'accent_primary_hover': '#69B6F8',
'accent_primary_pressed': '#3D97E0',
'accent_success': '#51CF66',
'accent_success_hover': '#69DB7C',
'accent_success_pressed': '#43B85A',
'accent_warning': '#FAB005',
'accent_danger': '#FA5252',
'accent_danger_hover': '#FF6B6B',
'accent_danger_pressed': '#E64545',
# 테두리
'border_subtle': '#2C2E33',
'border_default': '#373A40',
'border_focus': '#4DABF7',
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
'badge_overtime_bg': '#2C2E33',
'badge_overtime_text': '#FAB005',
'badge_leave_bg': '#2C2E33',
'badge_leave_text': '#4DABF7',
'badge_total_bg': '#2C2E33',
'badge_total_text': '#51CF66',
# 프로그레스 — 단일 accent 솔리드
'progress_bg': '#2C2E33',
'progress_start': '#4DABF7',
'progress_end': '#4DABF7',
# 상태 색상 (동적 텍스트 피드백)
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
'status_warning': '#FAB005',
'status_normal': '#51CF66',
'status_break_active': '#FA5252',
'status_break_idle': '#6C6E73',
# 캘린더 날짜 배경 — 미묘한 다크 틴트
'cal_normal': '#1E3A2A',
'cal_overtime': '#3A2122',
'cal_incomplete': '#3A331E',
# 스크롤바
'scrollbar_bg': '#1A1B1E',
'scrollbar_handle': '#373A40',
'scrollbar_hover': '#4DABF7',
}
@ -192,7 +219,7 @@ QMainWindow, QDialog {{
}}
QWidget {{
font-family: "Segoe UI", "맑은 고딕", sans-serif;
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
font-size: 9.5pt;
color: {c['text_primary']};
}}
@ -206,14 +233,14 @@ QWidget#central_widget {{
*/
QLabel#app_title {{
font-size: 12pt;
font-size: 13pt;
font-weight: bold;
color: {c['text_primary']};
padding: 2px;
}}
QLabel#date_label {{
font-size: 9pt;
font-size: 9.5pt;
color: {c['text_secondary']};
padding-bottom: 4px;
}}
@ -221,7 +248,7 @@ QLabel#date_label {{
QLabel#section_title {{
font-size: 9.5pt;
font-weight: bold;
color: {c['text_primary']};
color: {c['text_secondary']};
}}
QLabel#field_label {{
@ -229,29 +256,30 @@ QLabel#field_label {{
color: {c['text_secondary']};
}}
/* 출근/현재 시각 나란히 표시되는 중간 크기 모노스페이스 */
QLabel#time_value {{
font-family: "Consolas", "D2Coding", monospace;
font-size: 11pt;
font-size: 15pt;
font-weight: bold;
color: {c['text_primary']};
}}
/* 히어로 남은 시간 (화면에서 가장 결과 표시). 카드 안에 투명 배치 */
QLabel#time_display {{
font-family: "Consolas", "D2Coding", monospace;
font-size: 22pt;
font-size: 30pt;
font-weight: bold;
color: {c['text_primary']};
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 10px;
padding: 10px;
background: transparent;
border: none;
padding: 4px 0;
}}
QLabel#expected_time {{
font-size: 10pt;
font-size: 11.5pt;
font-weight: bold;
color: {c['text_primary']};
padding: 4px;
color: {c['text_secondary']};
padding: 2px;
}}
QLabel#dialog_title {{
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
qproperty-alignment: AlignCenter;
background: {c['badge_overtime_bg']};
color: {c['badge_overtime_text']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_leave {{
@ -306,7 +334,7 @@ QLabel#badge_leave {{
qproperty-alignment: AlignCenter;
background: {c['badge_leave_bg']};
color: {c['badge_leave_text']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_total {{
@ -317,7 +345,7 @@ QLabel#badge_total {{
qproperty-alignment: AlignCenter;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_balance {{
@ -326,7 +354,7 @@ QLabel#badge_balance {{
padding: 10px;
background: {c['bg_tertiary']};
color: {c['text_primary']};
border-radius: 6px;
border-radius: 8px;
}}
QLabel#badge_success {{
@ -335,7 +363,7 @@ QLabel#badge_success {{
padding: 8px;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
border-radius: 6px;
border-radius: 8px;
}}
/*
@ -355,9 +383,9 @@ QLabel#separator {{
QGroupBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 10px;
border-radius: 8px;
margin-top: 10px;
padding: 14px;
padding: 16px;
padding-top: 28px;
font-size: 9.5pt;
color: {c['text_primary']};
@ -378,52 +406,55 @@ QGroupBox::title {{
버튼
*/
/* 기본 버튼 그라데이션/베벨 없는 플랫 (border:none 기반) */
QPushButton {{
background: {c['bg_tertiary']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
padding: 7px 14px;
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 9pt;
}}
QPushButton:hover {{
background: {c['border_default']};
background: {c['surface_hover']};
}}
QPushButton:pressed {{
background: {c['border_subtle']};
background: {c['surface_pressed']};
}}
QPushButton:disabled {{
background: {c['bg_tertiary']};
background: {c['bg_secondary']};
color: {c['text_tertiary']};
border-color: {c['border_subtle']};
}}
QPushButton:checked {{
background: {c['accent_primary']};
color: {c['text_inverse']};
border-color: {c['accent_primary']};
}}
/* 퇴근 버튼 (primary action) */
QPushButton:focus {{
outline: none;
}}
/* 퇴근 버튼 주요 액션 (단일 포인트 컬러) */
QPushButton#clock_out_button {{
background: {c['accent_success']};
background: {c['accent_primary']};
color: {c['text_inverse']};
font-size: 11pt;
font-weight: bold;
padding: 8px;
padding: 11px;
border: none;
border-radius: 8px;
}}
QPushButton#clock_out_button:hover {{
background: {'#0EA572' if not is_dark else '#2BB885'};
background: {c['accent_primary_hover']};
}}
QPushButton#clock_out_button:pressed {{
background: {'#0C8F63' if not is_dark else '#28A87A'};
background: {c['accent_primary_pressed']};
}}
/* 주요 액션 버튼 */
@ -435,11 +466,11 @@ QPushButton#btn_primary {{
}}
QPushButton#btn_primary:hover {{
background: {c['accent_primary']}DD;
background: {c['accent_primary_hover']};
}}
QPushButton#btn_primary:pressed {{
background: {c['accent_primary']}BB;
background: {c['accent_primary_pressed']};
}}
/* 위험 버튼 */
@ -450,11 +481,11 @@ QPushButton#btn_danger {{
}}
QPushButton#btn_danger:hover {{
background: {c['accent_danger']}DD;
background: {c['accent_danger_hover']};
}}
QPushButton#btn_danger:pressed {{
background: {c['accent_danger']}BB;
background: {c['accent_danger_pressed']};
}}
/* 성공 버튼 */
@ -465,25 +496,44 @@ QPushButton#btn_success {{
}}
QPushButton#btn_success:hover {{
background: {c['accent_success']}DD;
background: {c['accent_success_hover']};
}}
QPushButton#btn_success:pressed {{
background: {c['accent_success']}BB;
background: {c['accent_success_pressed']};
}}
/* 작은 버튼 */
/* 작은 버튼 미묘한 표면 */
QPushButton#btn_small {{
font-size: 8.5pt;
padding: 5px 10px;
padding: 6px 10px;
}}
QPushButton#btn_small:hover {{
background: {c['accent_primary']}20;
background: {c['surface_hover']};
}}
QPushButton#btn_small:pressed {{
background: {c['accent_primary']}35;
background: {c['surface_pressed']};
}}
/* 하단 네비게이션 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 ) */
QPushButton#nav_btn {{
background: transparent;
border: none;
border-radius: 8px;
padding: 8px 4px;
font-size: 8.5pt;
color: {c['text_secondary']};
}}
QPushButton#nav_btn:hover {{
background: {c['surface_hover']};
color: {c['text_primary']};
}}
QPushButton#nav_btn:pressed {{
background: {c['surface_pressed']};
}}
/*
@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
QLineEdit, QTextEdit, QComboBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
border-radius: 8px;
padding: 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
border-radius: 8px;
padding: 6px 28px 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
min-height: 20px;
}}
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
border: 2px solid {c['border_focus']};
padding: 5px 7px;
}}
/* 포커스 보더 컬러만 포인트 컬러로 (두께 유지 레이아웃 흔들림 없음) */
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
border: 2px solid {c['border_focus']};
padding: 5px 27px 5px 7px;
border: 1px solid {c['border_focus']};
}}
/* 비활성 입력 필드 */
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
QSpinBox::up-button, QDoubleSpinBox::up-button,
QDateEdit::up-button, QTimeEdit::up-button {{
subcontrol-position: top right;
border-top-right-radius: 4px;
border-top-right-radius: 7px;
}}
QSpinBox::down-button, QDoubleSpinBox::down-button,
QDateEdit::down-button, QTimeEdit::down-button {{
subcontrol-position: bottom right;
border-bottom-right-radius: 4px;
border-bottom-right-radius: 7px;
}}
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
QProgressBar {{
border: none;
background: {c['progress_bg']};
border-radius: 4px;
height: 8px;
border-radius: 3px;
min-height: 6px;
max-height: 6px;
text-align: center;
color: transparent;
font-size: 0px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
border-radius: 4px;
background: {c['progress_start']};
border-radius: 3px;
}}
/*
@ -648,7 +694,7 @@ QProgressBar::chunk {{
QTableWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
border-radius: 8px;
gridline-color: {c['border_subtle']};
color: {c['text_primary']};
font-size: 9pt;
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
background: {c['bg_tertiary']};
}}
/* 헤더 위젯 배경 (세로헤더 영역의 흰색 누수 방지) */
QHeaderView {{
background: {c['bg_secondary']};
border: none;
}}
QHeaderView::section {{
background: {c['bg_tertiary']};
color: {c['text_secondary']};
padding: 8px;
border: none;
border-bottom: 2px solid {c['accent_primary']};
font-weight: bold;
font-size: 9pt;
}}
QHeaderView::section:horizontal {{
border-bottom: 2px solid {c['accent_primary']};
}}
/* 세로헤더(행번호) accent 밑줄 없이 미묘하게 */
QHeaderView::section:vertical {{
border-right: 1px solid {c['border_subtle']};
color: {c['text_tertiary']};
font-weight: normal;
padding: 4px 8px;
}}
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
QTableView QTableCornerButton::section {{
background: {c['bg_tertiary']};
border: none;
border-bottom: 2px solid {c['accent_primary']};
}}
/*
위젯
*/
QTabWidget::pane {{
border: 1px solid {c['border_subtle']};
border-radius: 6px;
border-radius: 8px;
background: {c['bg_secondary']};
top: -1px;
}}
@ -694,8 +764,8 @@ QTabBar::tab {{
padding: 8px 20px;
border: 1px solid {c['border_subtle']};
border-bottom: none;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 2px;
font-size: 10pt;
}}
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
QCalendarWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
border-radius: 8px;
font-size: 10pt;
}}
@ -902,7 +972,7 @@ QToolTip {{
QMenu {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
border-radius: 8px;
padding: 4px;
color: {c['text_primary']};
}}
@ -916,6 +986,16 @@ QMenu::item:selected {{
background: {c['accent_primary']};
color: {c['text_inverse']};
}}
QMenu::separator {{
height: 1px;
background: {c['border_subtle']};
margin: 4px 8px;
}}
QMenu::icon {{
padding-left: 8px;
}}
"""

View File

@ -16,12 +16,12 @@ class TodaySummaryCard(QFrame):
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);
background-color: rgba(81, 207, 102, 0.08);
border: 1px solid rgba(81, 207, 102, 0.40);
border-radius: 8px;
padding: 6px;
}
QLabel { padding: 1px; }
QLabel { padding: 1px; background: transparent; border: none; }
""")
self.setVisible(False)
@ -30,7 +30,7 @@ class TodaySummaryCard(QFrame):
layout.setSpacing(2)
header = QHBoxLayout()
title = QLabel("📋 오늘의 요약")
title = QLabel("오늘의 요약")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
@ -43,9 +43,9 @@ class TodaySummaryCard(QFrame):
self.total_label = QLabel("")
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
layout.addWidget(self.total_label)
layout.addWidget(self.detail_label)
@ -55,24 +55,28 @@ class TodaySummaryCard(QFrame):
def show_summary(self, total_hours: float, lunch_minutes: int,
break_minutes: int, overtime_actual: int,
overtime_earned: int, salary_text: str = "") -> None:
overtime_earned: int, salary_text: str = "",
dinner_minutes: int = 0) -> None:
"""카드 내용 채우고 표시.
Args:
total_hours: 근무시간(시간)
lunch_minutes: 점심 시간()
break_minutes: 외출 시간()
break_minutes: 외출 시간(, 식사 제외)
overtime_actual: 실제 연장근무()
overtime_earned: 적립 연장근무()
salary_text: 추정 급여 표시 문자열 (옵션 활성 )
dinner_minutes: 저녁 시간(), 0이면 표시
"""
h = int(total_hours)
m = int((total_hours - h) * 60)
self.total_label.setText(f"총 근무: {h}시간 {m}")
self.total_label.setText(f"총 근무: {h}시간 {m}")
details = []
if lunch_minutes > 0:
details.append(f"점심 {lunch_minutes}")
if dinner_minutes > 0:
details.append(f"저녁 {dinner_minutes}")
if break_minutes > 0:
details.append(f"외출 {break_minutes}")
if overtime_actual > 0:
@ -81,7 +85,7 @@ class TodaySummaryCard(QFrame):
self.detail_label.setVisible(bool(details))
if salary_text:
self.salary_label.setText(f"💰 {salary_text}")
self.salary_label.setText(f"{salary_text}")
self.salary_label.setVisible(True)
else:
self.salary_label.setVisible(False)

View File

@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
3. new_exe target_exe 이동
4. target_exe 재실행 + 업데이터 자가 종료
실패 .bak 복원
빌드: console=False (windowed) 사용자 눈엔 cmd 창이 보임.
모든 진단 출력은 ~/.clockout_logs/updater.log append.
"""
from __future__ import annotations
import argparse
@ -21,9 +24,30 @@ import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'
def _log(msg: str) -> None:
"""진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
# stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
try:
print(line, end='', file=sys.stderr)
except Exception:
pass
try:
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_LOG_PATH, 'a', encoding='utf-8') as f:
f.write(line)
except OSError:
pass
def is_pid_running(pid: int) -> bool:
"""Windows에서 PID 실행 중인지 확인."""
if sys.platform != 'win32':
@ -62,42 +86,86 @@ def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
return False
def replace_file(new_path: Path, target_path: Path) -> Path | None:
def replace_file(new_path: Path, target_path: Path,
max_retries: int = 5) -> Path | None:
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
Windows에서 메인 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할
있음. 지수 backoff로 재시도 0.3, 0.6, 1.2, 2.4, 4.8 ( ~9).
Returns:
백업 파일 경로 (롤백용). 실패 None.
백업 파일 경로 (롤백용). 모든 재시도 실패 None.
"""
backup = target_path.with_suffix(target_path.suffix + '.bak')
try:
if backup.exists():
last_err: Exception | None = None
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
if backup.exists():
try:
backup.unlink()
if target_path.exists():
except OSError as e:
_log(f"[updater] old backup unlink failed (continuing): {e}")
# 2단계: target → backup 이동 (락 해제 대기 재시도)
for attempt in range(max_retries):
if not target_path.exists():
break # 첫 설치 등 — target 없으면 그냥 다음 단계로
try:
shutil.move(str(target_path), str(backup))
shutil.move(str(new_path), str(target_path))
return backup
except OSError as e:
print(f"[updater] replace failed: {e}", file=sys.stderr)
# 롤백 시도
if backup.exists() and not target_path.exists():
try:
shutil.move(str(backup), str(target_path))
except OSError:
pass
break
except OSError as e:
last_err = e
wait = 0.3 * (2 ** attempt)
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait)
else:
# 모든 재시도 실패
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
return None
# 3단계: new → target 이동
for attempt in range(max_retries):
try:
shutil.move(str(new_path), str(target_path))
return backup
except OSError as e:
last_err = e
wait = 0.3 * (2 ** attempt)
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait)
# new 이동 실패 → backup으로 롤백 시도
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
if backup.exists() and not target_path.exists():
try:
shutil.move(str(backup), str(target_path))
_log("[updater] rolled back from backup")
except OSError as e:
_log(f"[updater] rollback also failed: {e}")
return None
def launch(exe_path: Path) -> bool:
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
try:
if sys.platform == 'win32':
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
DETACHED_PROCESS = 0x00000008
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
CREATE_NO_WINDOW = 0x08000000
subprocess.Popen(
[str(exe_path)],
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
close_fds=True,
)
else:
subprocess.Popen([str(exe_path)], close_fds=True)
return True
except OSError as e:
print(f"[updater] launch failed: {e}", file=sys.stderr)
_log(f"[updater] launch failed: {e}")
return False
@ -109,29 +177,34 @@ def main() -> int:
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
args = parser.parse_args()
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
new_exe = Path(args.new).resolve()
target_exe = Path(args.target).resolve()
if not new_exe.exists():
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
_log(f"[updater] new exe not found: {new_exe}")
return 2
if not wait_for_exit(args.pid, timeout_sec=30):
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
_log(f"[updater] timeout waiting for PID {args.pid}")
return 3
# Windows 파일 핸들 해제 시간 여유
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
time.sleep(0.5)
backup = replace_file(new_exe, target_exe)
if backup is None:
_log("[updater] replace_file failed — aborting")
return 4
if args.no_launch:
_log("[updater] --no-launch set, exiting after replace")
return 0
if not launch(target_exe):
# 시작 실패 시 롤백
_log("[updater] launch failed — rolling back")
try:
target_exe.unlink()
shutil.move(str(backup), str(target_exe))
@ -140,7 +213,7 @@ def main() -> int:
pass
return 5
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
_log("[updater] update complete, new app launched")
return 0

View File

@ -33,7 +33,7 @@ exe = EXE(
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,

View File

@ -71,5 +71,7 @@ def _rotate(directory: Path, keep: int) -> None:
for old in files[keep:]:
try:
old.unlink()
except OSError:
pass
except OSError as e:
# 회전 실패 시 로그만 — 다음 실행에 재시도. 누적 시 디스크 압박 가능.
from utils.debug_log import dlog
dlog(f"backup rotation failed for {old}: {e}")

View File

@ -20,24 +20,79 @@ USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
def install_global_handler(db, app_version: str = 'unknown') -> None:
"""sys.excepthook 등록. db는 crash_log 저장용."""
"""sys.excepthook 등록. db는 crash_log 저장용.
단계(log dialog 파일 fallback original hook) 모두 독립 try로 감싸
하나가 실패해도 다음 단계가 동작. 가장 최후 fallback은 stderr + 로컬 파일.
"""
original = sys.excepthook
def hook(exc_type, exc_value, exc_tb):
if exc_type is KeyboardInterrupt:
original(exc_type, exc_value, exc_tb)
return
# 1단계: traceback 직렬화 (실패하면 minimal fallback)
try:
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
_log_crash(db, exc_type.__name__, str(exc_value), tb_str, app_version)
_show_dialog(db, exc_type.__name__, str(exc_value), tb_str, app_version)
except Exception:
pass # 후킹 자체 실패는 silent
original(exc_type, exc_value, exc_tb)
tb_str = f"{exc_type}: {exc_value} (traceback formatting failed)"
type_name = getattr(exc_type, '__name__', str(exc_type))
msg = str(exc_value)
# 2단계: DB 로깅 (DB 잠금/디스크 풀 등으로 실패 가능 — 단계 분리)
log_ok = False
try:
_log_crash(db, type_name, msg, tb_str, app_version)
log_ok = True
except Exception as log_err:
_fallback_to_file(type_name, msg, tb_str, app_version,
f"DB log failed: {log_err}")
# 3단계: 다이얼로그 (PyQt 미초기화/이미 종료 등으로 실패 가능)
try:
_show_dialog(db, type_name, msg, tb_str, app_version)
except Exception as dlg_err:
# 다이얼로그 실패 시 stderr + 파일에 기록 (DB 로깅도 실패했으면 이게 유일한 흔적)
if not log_ok:
_fallback_to_file(type_name, msg, tb_str, app_version,
f"DB+dialog both failed; dialog err: {dlg_err}")
try:
print(f"\n[CRASH] {type_name}: {msg}\n{tb_str}", file=sys.stderr)
except Exception:
pass
# 4단계: 원래 hook도 호출 (콘솔 출력 + 종료)
try:
original(exc_type, exc_value, exc_tb)
except Exception:
pass # 마지막 단계라 더 할 게 없음
sys.excepthook = hook
def _fallback_to_file(exc_type: str, message: str, tb: str,
version: str, reason: str) -> None:
"""DB 로깅이 실패한 경우 ~/.clockout_logs/crashes.log에 append.
Best-effort 파일 쓰기 실패도 silent ( 시점엔 없음).
"""
try:
from pathlib import Path
log_dir = Path.home() / '.clockout_logs'
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / 'crashes.log'
with open(log_file, 'a', encoding='utf-8') as f:
f.write(
f"\n=== {datetime.now().isoformat(timespec='seconds')} ===\n"
f"version={version} reason={reason}\n"
f"{exc_type}: {message}\n{tb}\n"
)
except Exception:
pass
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
"""crash_log 테이블에 기록."""
try:

View File

@ -11,12 +11,15 @@ class CSVExporter:
"""CSV 내보내기 클래스"""
@staticmethod
def export_work_records(records: List[Dict], filename: str = None) -> str:
def export_work_records(records: List[Dict], filename: str = None,
db=None) -> str:
"""
근무 기록을 CSV로 내보내기
근무 기록을 CSV로 내보내기 (사람이 읽는 한글 헤더 + 사용/미사용 표기).
Args:
records: 근무 기록 리스트
filename: 저장할 파일명 (None이면 자동 생성)
db: 점심/저녁 기본 (minutes) 표시용으로 읽을 Database 인스턴스 (옵션)
Returns:
str: 저장된 파일 경로
"""
@ -24,31 +27,70 @@ class CSVExporter:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_{timestamp}.csv"
# CSV 파일 작성
# 표시용 기본 분 (옵션)
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = [
'날짜', '출근시간', '퇴근시간', '점심시간',
'총근무시간', '연장근무(분)', '적립(분)', '메모'
'날짜', '출근시간', '퇴근시간',
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
'총근무시간', '연장근무(분)', '적립(분)', '메모',
]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
lunch_on = bool(record.get('lunch_break'))
dinner_on = bool(record.get('dinner_break'))
row = {
'날짜': record.get('date', ''),
'출근시간': record.get('clock_in', ''),
'퇴근시간': record.get('clock_out', '미기록'),
'점심시간': '사용' if record.get('lunch_break') else '미사용',
'점심시간': '사용' if lunch_on else '미사용',
'점심(분)': lunch_default if lunch_on else 0,
'저녁시간': '사용' if dinner_on else '미사용',
'저녁(분)': dinner_default if dinner_on else 0,
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
'연장근무(분)': record.get('overtime_minutes', 0),
'적립(분)': record.get('overtime_earned', 0),
'메모': record.get('memo', '')
'메모': record.get('memo', ''),
}
writer.writerow(row)
return filename
@staticmethod
def export_work_records_for_reimport(records: List[Dict], filename: str = None,
db=None) -> str:
"""csv_importer가 직접 다시 읽을 수 있는 round-trip 포맷으로 내보내기.
헤더: date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
"""
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"work_records_reimport_{timestamp}.csv"
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
fieldnames = ['date', 'clock_in', 'clock_out',
'lunch_minutes', 'dinner_minutes', 'memo']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for record in records:
writer.writerow({
'date': record.get('date', ''),
'clock_in': record.get('clock_in', ''),
'clock_out': record.get('clock_out', '') or '',
'lunch_minutes': lunch_default if record.get('lunch_break') else 0,
'dinner_minutes': dinner_default if record.get('dinner_break') else 0,
'memo': record.get('memo', '') or '',
})
return filename
@staticmethod
def export_overtime_summary(db, filename: str = None) -> str:
"""

View File

@ -1,14 +1,15 @@
"""
CSV 가져오기 우리 표준 포맷.
표준 포맷:
date,clock_in,clock_out,lunch_minutes,memo
2026-04-01,09:00:00,18:30:00,60,"메모"
표준 포맷 (v2.7.1+):
date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
2026-04-01,09:00:00,18:30:00,60,0,"메모"
- 헤더 필수
- date: YYYY-MM-DD
- clock_in/out: HH:MM:SS 또는 HH:MM
- clock_in/out: HH:MM:SS 또는 HH:MM (clock_out이 clock_in보다 빠르면 익일로 간주)
- lunch_minutes: 정수 (0이면 점심 미포함)
- dinner_minutes: 정수 (옵션, 0/누락이면 저녁 미포함)
- memo: 선택 (따옴표 가능)
기존 일자와 충돌 import 호출자가 'overwrite'/'skip' 정책 결정.
@ -45,6 +46,20 @@ def parse_csv(path: str) -> List[Dict]:
return rows
def _parse_minutes(raw: str, field_name: str) -> int:
"""0 이상 정수 파싱. 빈 값이면 0."""
s = (raw or '').strip()
if not s:
return 0
try:
v = int(s)
if v < 0:
raise ValueError
except ValueError:
raise ValueError(f"{field_name}는 0 이상 정수여야 함: '{raw}'")
return v
def _normalize_row(row: Dict) -> Dict:
"""단일 행 검증 + 정규화."""
date_str = (row.get('date') or '').strip()
@ -59,15 +74,8 @@ def _normalize_row(row: Dict) -> Dict:
co_raw = (row.get('clock_out') or '').strip()
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
lunch = 0
lm = (row.get('lunch_minutes') or '').strip()
if lm:
try:
lunch = int(lm)
if lunch < 0:
raise ValueError
except ValueError:
raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'")
lunch = _parse_minutes(row.get('lunch_minutes', ''), 'lunch_minutes')
dinner = _parse_minutes(row.get('dinner_minutes', ''), 'dinner_minutes')
memo = (row.get('memo') or '').strip()
return {
@ -75,6 +83,7 @@ def _normalize_row(row: Dict) -> Dict:
'clock_in': ci,
'clock_out': co,
'lunch_minutes': lunch,
'dinner_minutes': dinner,
'memo': memo,
}
@ -111,9 +120,11 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
added = updated = skipped = 0
from datetime import timedelta
from core.time_calculator import TimeCalculator
work_minutes = db.get_work_minutes()
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
dinner_default = db.get_setting_int('dinner_duration_minutes', 60)
for row in rows:
existing = db.get_work_record(row['date'])
@ -131,6 +142,9 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
updated += 1
@ -141,16 +155,27 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
if row.get('clock_out'):
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
calc = TimeCalculator(work_minutes=work_minutes,
lunch_duration_minutes=row.get('lunch_minutes') or lunch_default)
include_lunch = (row.get('lunch_minutes') or 0) > 0
# 자정 경계: 퇴근이 출근보다 빠르면 익일로 간주
if co_dt <= ci_dt:
co_dt += timedelta(days=1)
lunch_min = row.get('lunch_minutes') or 0
dinner_min = row.get('dinner_minutes') or 0
calc = TimeCalculator(
work_minutes=work_minutes,
lunch_duration_minutes=lunch_min or lunch_default,
dinner_duration_minutes=dinner_min or dinner_default,
)
include_lunch = lunch_min > 0
include_dinner = dinner_min > 0
total = (co_dt - ci_dt).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci_dt, co_dt, include_lunch=include_lunch
ci_dt, co_dt, include_lunch=include_lunch, include_dinner=include_dinner,
)
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
if include_lunch:
db.update_lunch_break(row['date'], True)
if include_dinner:
db.update_dinner_break(row['date'], True)
if ot_earned > 0:
db.add_overtime_earned(wid, ot_earned, row['date'])

View File

@ -5,6 +5,7 @@ URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안
"""
from __future__ import annotations
import json
import re
import urllib.request
import urllib.error
from datetime import datetime
@ -21,6 +22,23 @@ COLOR_YELLOW = 0xFEE75C
COLOR_PINK = 0xEB459E
COLOR_ORANGE = 0xED4245
# Discord 웹훅 URL 패턴: https://(discord.com|discordapp.com|...)/api/webhooks/{ID:digits}/{TOKEN}
# canary./ptb. 서브도메인도 허용. PTB 모바일 앱이 종종 그 URL 발급.
_WEBHOOK_RE = re.compile(
r'^https://(?:(?:canary|ptb)\.)?discord(?:app)?\.com/api/webhooks/\d{17,20}/[\w-]{50,}$'
)
def is_valid_webhook_url(url: str) -> bool:
"""입력된 URL이 Discord webhook 형식인지 검증.
형식만 확인 실제 도달성·토큰 유효성은 send_test() 검증해야 .
문자열이나 공백은 False (비활성 상태로 간주).
"""
if not url or not isinstance(url, str):
return False
return bool(_WEBHOOK_RE.match(url.strip()))
def send(webhook_url: str, title: str, description: str,
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
@ -37,7 +55,7 @@ def send(webhook_url: str, title: str, description: str,
Returns:
성공 True. URL 비었거나 네트워크/4xx/5xx False.
"""
if not webhook_url or not webhook_url.startswith('https://'):
if not is_valid_webhook_url(webhook_url):
return False
payload = {

84
utils/font_loader.py Normal file
View File

@ -0,0 +1,84 @@
"""번들 폰트(NanumSquare) 로딩.
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
등록 실패 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
"""
from __future__ import annotations
import os
import sys
from PyQt5.QtGui import QFontDatabase, QFont
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
_FONT_FILES = [
'NanumSquareL.ttf',
'NanumSquareR.ttf',
'NanumSquareB.ttf',
'NanumSquareEB.ttf',
'NanumSquare_acR.ttf',
'NanumSquare_acB.ttf',
]
def _font_dir() -> str:
"""번들 font/ 디렉토리 절대 경로."""
if getattr(sys, 'frozen', False):
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, 'font')
def load_bundled_fonts() -> list:
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
families: list = []
fdir = _font_dir()
if not os.path.isdir(fdir):
return families
for name in _FONT_FILES:
path = os.path.join(fdir, name)
if not os.path.exists(path):
continue
fid = QFontDatabase.addApplicationFont(path)
if fid == -1:
continue
for fam in QFontDatabase.applicationFontFamilies(fid):
if fam not in families:
families.append(fam)
return families
def _pick_primary(families: list) -> str:
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
if 'NanumSquare' in families:
return 'NanumSquare'
for fam in families:
low = fam.lower()
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
return fam
return 'Malgun Gothic'
def apply_app_font(app, point_size: int = 9) -> str:
"""앱 전역 기본 폰트를 NanumSquare로 설정.
Returns:
실제 적용된 primary family 이름 (폴백 'Malgun Gothic').
"""
families = load_bundled_fonts()
primary = _pick_primary(families)
font = QFont(primary, point_size)
font.setStyleStrategy(QFont.PreferAntialias)
app.setFont(font)
return primary
if __name__ == '__main__':
from PyQt5.QtWidgets import QApplication
_app = QApplication(sys.argv)
fams = load_bundled_fonts()
print('font dir:', _font_dir())
print('registered families:', fams)
print('picked primary:', _pick_primary(fams))

98
utils/holiday_api.py Normal file
View File

@ -0,0 +1,98 @@
"""
공공데이터포털 한국천문연구원 특일정보 OpenAPI 클라이언트.
엔드포인트: getRestDeInfo (국경일/공휴일 임시공휴일 포함)
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
`holidays` 패키지가 누락하는 임시공휴일·근로자의 등을
정부 공인 데이터로 보강하기 위해 사용.
설계:
- 네트워크 실패는 silent (None 반환) 호출자가 fallback 처리
- API 키는 코드 박혀있으나 dev 본인 계정의 특일정보 API 한정
(50 이내 사용 환경에서 일일 한도 1000 충분)
"""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
import urllib.error
from typing import List, Dict, Optional
# 공공데이터포털 dev 키 (특일정보 API 한정).
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
Returns:
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 )
"""
params = {
'serviceKey': _SERVICE_KEY,
'solYear': str(year),
'_type': 'json',
'numOfRows': '100',
'pageNo': '1',
}
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except (urllib.error.URLError, urllib.error.HTTPError,
json.JSONDecodeError, OSError, TimeoutError):
return None
return _parse_response(data)
def _parse_response(data: Dict) -> Optional[List[Dict]]:
"""API 응답 JSON을 표준 형식으로 정규화.
API 응답 패턴:
- resultCode == '00' 정상
- items.item: 단일 결과면 dict, 여러 개면 list
- items가 문자열일 (totalCount=0) 정상으로 간주
"""
try:
response = data.get('response') or {}
header = response.get('header') or {}
if header.get('resultCode') != '00':
return None
body = response.get('body') or {}
items_root = body.get('items')
if not items_root:
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
item = items_root.get('item') if isinstance(items_root, dict) else None
if item is None:
return []
if isinstance(item, dict):
item = [item]
out = []
for entry in item:
locdate = entry.get('locdate')
name = entry.get('dateName')
is_holiday = (entry.get('isHoliday') == 'Y')
if not locdate or not name:
continue
# locdate: 20260501 (int 또는 str)
ds = str(locdate)
if len(ds) != 8 or not ds.isdigit():
continue
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
return out
except (AttributeError, TypeError, KeyError):
return None
def is_configured() -> bool:
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10

View File

@ -57,58 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
return QIcon(pixmap)
def setup_menu(self):
"""트레이 메뉴 설정"""
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
menu = QMenu()
show_action = QAction(tr('tray.open'), self)
show_action.triggered.connect(self.show_window)
menu.addAction(show_action)
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
self._icon_actions = []
mini_action = QAction(tr('tray.mini_widget'), self)
mini_action.triggered.connect(self._open_mini_widget)
menu.addAction(mini_action)
def add(text, slot, icon_name=None):
action = QAction(text, self)
action.triggered.connect(slot)
menu.addAction(action)
if icon_name:
self._icon_actions.append((action, icon_name))
return action
add(tr('tray.open'), self.show_window, 'home')
add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
menu.addSeparator()
lunch_action = QAction(tr('tray.toggle_lunch'), self)
lunch_action.triggered.connect(self._toggle_lunch)
menu.addAction(lunch_action)
break_out_action = QAction(tr('btn.break_out'), self)
break_out_action.triggered.connect(self._break_out)
menu.addAction(break_out_action)
break_in_action = QAction(tr('btn.break_in'), self)
break_in_action.triggered.connect(self._break_in)
menu.addAction(break_in_action)
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
add(tr('btn.break_out'), self._break_out)
add(tr('btn.break_in'), self._break_in)
menu.addSeparator()
clock_out_action = QAction("" + tr('btn.clock_out'), self)
clock_out_action.triggered.connect(self.quick_clock_out)
menu.addAction(clock_out_action)
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
menu.addSeparator()
stats_action = QAction("📊 " + tr('menu.stats'), self)
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
menu.addAction(stats_action)
cal_action = QAction("📅 " + tr('menu.calendar'), self)
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
menu.addSeparator()
quit_action = QAction(tr('tray.quit'), self)
quit_action.triggered.connect(self.quit_app)
menu.addAction(quit_action)
add(tr('tray.quit'), self.quit_app)
self.setContextMenu(menu)
self.refresh_theme()
def refresh_theme(self):
"""트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
QMenu() 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
명시적으로 적용한다. 테마 변경 main_window.apply_theme에서 호출.
"""
menu = self.contextMenu()
if menu is None:
return
# 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
qss = self.parent_window.styleSheet() if self.parent_window else ''
if not qss:
try:
from ui.styles import get_theme
qss = get_theme('dark')
except Exception:
qss = ''
if qss:
menu.setStyleSheet(qss)
# 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
try:
from ui.icons import get_icon
from ui.styles import ThemeColors
color = ThemeColors.get('text_primary')
for action, name in getattr(self, '_icon_actions', []):
action.setIcon(get_icon(name, color, 16))
except Exception:
pass
def _call_parent(self, method_name: str):
if self.parent_window and hasattr(self.parent_window, method_name):

View File

@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
pid = os.getpid()
try:
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
if sys.platform == 'win32':
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
else:
creationflags = 0
subprocess.Popen(
[str(updater_exe),
'--pid', str(pid),