1218 lines
52 KiB
Python
1218 lines
52 KiB
Python
"""
|
|
도전과제 시스템 — 357개 자동 평가.
|
|
|
|
설계:
|
|
- 각 도전과제는 dataclass `Achievement` + 평가 함수 (db) -> (progress, target).
|
|
- 평가자는 5분 throttle로 호출되어 미획득 도전과제만 검사.
|
|
- 신규 잠금 해제 시 main_window의 `notify_achievement_unlocked` 시그널 emit.
|
|
- 진행도(progress)는 부분 달성 표시용 — UI 게이지에 활용.
|
|
|
|
데이터 소스:
|
|
- work_records: 출근/퇴근 기록
|
|
- overtime_bank, overtime_usage: 적립/사용
|
|
- leave_records: 연차
|
|
- break_records: 외출/식사
|
|
- holidays: 공휴일
|
|
- settings: 사용자 메타 + 뷰 카운터
|
|
- notification_log: 휴식 권고 카운트
|
|
"""
|
|
from __future__ import annotations
|
|
from core.i18n import tr
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, datetime, timedelta
|
|
from typing import Callable, Optional, List, Tuple
|
|
|
|
# === 등급 상수 ===
|
|
TIER_BRONZE = 'bronze'
|
|
TIER_SILVER = 'silver'
|
|
TIER_GOLD = 'gold'
|
|
TIER_PLATINUM = 'platinum'
|
|
TIER_LEGEND = 'legend'
|
|
|
|
# === 카테고리 ===
|
|
CAT_STREAK = 'streak'
|
|
CAT_PUNCTUAL = 'punctual'
|
|
CAT_BALANCE = 'balance'
|
|
CAT_OT_BANK = 'ot_bank'
|
|
CAT_OT_USE = 'ot_use'
|
|
CAT_LEAVE = 'leave'
|
|
CAT_HEALTH = 'health'
|
|
CAT_SPECIAL_DAY = 'special_day'
|
|
CAT_PATTERN = 'pattern'
|
|
CAT_MILESTONE = 'milestone'
|
|
CAT_SEASON = 'season'
|
|
CAT_TIME_SLOT = 'time_slot'
|
|
CAT_MEAL = 'meal'
|
|
CAT_BREAK = 'break_use'
|
|
CAT_SETTINGS = 'settings'
|
|
CAT_STATS = 'stats'
|
|
CAT_SECRET = 'secret'
|
|
CAT_KOREA = 'korea'
|
|
CAT_AMBITION = 'ambition'
|
|
CAT_META = 'meta'
|
|
|
|
|
|
@dataclass
|
|
class Achievement:
|
|
"""도전과제 정의.
|
|
|
|
평가 함수 `evaluator`는 `(db) -> (progress, target)` 시그니처.
|
|
progress >= target이면 잠금 해제. progress가 0이면 미시작.
|
|
"""
|
|
code: str
|
|
name: str
|
|
description: str
|
|
category: str
|
|
tier: str
|
|
badge_icon: str
|
|
is_secret: bool = False
|
|
target: int = 1
|
|
evaluator: Optional[Callable] = field(default=None, repr=False)
|
|
|
|
|
|
# ============================================================
|
|
# 평가 헬퍼
|
|
# ============================================================
|
|
|
|
def _count_work_records(db) -> int:
|
|
"""전체 work_records 수 (clock_in이 있는 모든 행)."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM work_records")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_clocked_out(db) -> int:
|
|
"""퇴근까지 마친 work_records 수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM work_records WHERE clock_out IS NOT NULL")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _consecutive_workdays(db) -> int:
|
|
"""오늘 또는 마지막 출근일 기준 연속 영업일 출근 수.
|
|
|
|
영업일 = 토/일이 아니고 holidays 테이블에 없는 날.
|
|
"""
|
|
today = date.today()
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT date FROM work_records ORDER BY date DESC LIMIT 1")
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return 0
|
|
last = datetime.strptime(row[0], '%Y-%m-%d').date()
|
|
if (today - last).days > 3:
|
|
return 0 # 끊김
|
|
# holidays 셋
|
|
cur.execute("SELECT date FROM holidays")
|
|
holidays = {r[0] for r in cur.fetchall()}
|
|
cur.execute("SELECT date FROM work_records")
|
|
worked = {r[0] for r in cur.fetchall()}
|
|
|
|
streak = 0
|
|
d = last
|
|
while True:
|
|
is_workday = d.weekday() < 5 and d.isoformat() not in holidays
|
|
if is_workday:
|
|
if d.isoformat() in worked:
|
|
streak += 1
|
|
else:
|
|
break
|
|
d -= timedelta(days=1)
|
|
if streak > 1000: # safety
|
|
break
|
|
return streak
|
|
|
|
|
|
def _consecutive_calendar_days(db) -> int:
|
|
"""달력일 기준 연속 출근 수 (주말 포함)."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT date FROM work_records ORDER BY date DESC")
|
|
dates = [datetime.strptime(r[0], '%Y-%m-%d').date() for r in cur.fetchall()]
|
|
if not dates:
|
|
return 0
|
|
streak = 1
|
|
for i in range(1, len(dates)):
|
|
if (dates[i-1] - dates[i]).days == 1:
|
|
streak += 1
|
|
else:
|
|
break
|
|
return streak
|
|
|
|
|
|
def _ot_balance_minutes(db) -> int:
|
|
"""현재 연장근무 잔액 (분)."""
|
|
return db.get_total_overtime_balance()
|
|
|
|
|
|
def _ot_total_earned(db) -> int:
|
|
"""누적 적립 분."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COALESCE(SUM(earned_minutes), 0) FROM overtime_bank")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _ot_total_used(db) -> int:
|
|
"""누적 사용 분."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COALESCE(SUM(used_minutes), 0) FROM overtime_usage")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_punctual_clockouts(db) -> int:
|
|
"""정시 퇴근 (overtime_minutes <= 0) 횟수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE clock_out IS NOT NULL AND COALESCE(overtime_minutes, 0) <= 0
|
|
""")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_clock_in_before(db, hour: int, minute: int = 0) -> int:
|
|
"""특정 시각 이전 출근 횟수."""
|
|
threshold = f"{hour:02d}:{minute:02d}:00"
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records WHERE clock_in < ?
|
|
""", (threshold,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_clock_in_in_range(db, start_hh: int, end_hh: int) -> int:
|
|
"""[start_hh:00, end_hh:00) 시간대 출근 횟수."""
|
|
s = f"{start_hh:02d}:00:00"
|
|
e = f"{end_hh:02d}:00:00"
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records WHERE clock_in >= ? AND clock_in < ?
|
|
""", (s, e))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_clock_out_after(db, hour: int) -> int:
|
|
"""특정 시각(시) 이후 퇴근 횟수. 자정 이후는 hour=24로 별도 처리."""
|
|
threshold = f"{hour:02d}:00:00"
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE clock_out IS NOT NULL AND clock_out >= ?
|
|
""", (threshold,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_clock_out_after_midnight(db) -> int:
|
|
"""자정 이후 ~ 오전 퇴근 횟수 (clock_out < clock_in인 경우, 익일).
|
|
같은 날짜 내에서 clock_out HH:MM:SS가 clock_in보다 작으면 자정 넘긴 것."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE clock_out IS NOT NULL AND clock_out < clock_in
|
|
""")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_weekend_clockins(db) -> int:
|
|
"""토/일 출근 횟수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE CAST(strftime('%w', date) AS INTEGER) IN (0, 6)
|
|
""")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_holiday_clockins(db) -> int:
|
|
"""공휴일 출근 (holidays 테이블에 있는 날)."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records w
|
|
WHERE EXISTS (SELECT 1 FROM holidays h WHERE h.date = w.date)
|
|
""")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _has_clockin_on(db, mm_dd: str) -> bool:
|
|
"""특정 MM-DD에 출근 기록 있는지."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records WHERE strftime('%m-%d', date) = ? LIMIT 1
|
|
""", (mm_dd,))
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _has_punctual_clockout_on(db, mm_dd: str) -> bool:
|
|
"""특정 MM-DD에 정시 퇴근."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records
|
|
WHERE strftime('%m-%d', date) = ?
|
|
AND clock_out IS NOT NULL AND COALESCE(overtime_minutes, 0) <= 0
|
|
LIMIT 1
|
|
""", (mm_dd,))
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _count_lunch_registrations(db) -> int:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM work_records WHERE lunch_break = 1")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_dinner_registrations(db) -> int:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM work_records WHERE dinner_break = 1")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_break_records_type(db, break_type: str = 'break') -> int:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM break_records WHERE break_type = ?", (break_type,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_leave_records(db, leave_type: str = None) -> int:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
if leave_type:
|
|
cur.execute("SELECT COUNT(*) FROM leave_records WHERE leave_type = ?", (leave_type,))
|
|
else:
|
|
cur.execute("SELECT COUNT(*) FROM leave_records")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _has_leave_with_days(db, days_value: float) -> bool:
|
|
"""특정 days 값(0.5, 0.25 등)의 연차 사용 여부."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT 1 FROM leave_records WHERE days = ? LIMIT 1", (days_value,))
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _consecutive_leave_days(db) -> int:
|
|
"""가장 긴 연속 연차 사용 일수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT date FROM leave_records ORDER BY date")
|
|
dates = [datetime.strptime(r[0], '%Y-%m-%d').date() for r in cur.fetchall()]
|
|
if not dates:
|
|
return 0
|
|
max_streak = 1
|
|
cur_streak = 1
|
|
for i in range(1, len(dates)):
|
|
if (dates[i] - dates[i-1]).days == 1:
|
|
cur_streak += 1
|
|
max_streak = max(max_streak, cur_streak)
|
|
else:
|
|
cur_streak = 1
|
|
return max_streak
|
|
|
|
|
|
def _setting_int(db, key: str, default: int = 0) -> int:
|
|
return db.get_setting_int(key, default)
|
|
|
|
|
|
def _setting_str(db, key: str, default: str = '') -> str:
|
|
return db.get_setting(key, default) or default
|
|
|
|
|
|
def _days_since_first_work(db) -> int:
|
|
"""첫 work_records로부터 오늘까지 경과 일수."""
|
|
hire = _setting_str(db, 'hire_date', '')
|
|
if not hire:
|
|
return 0
|
|
try:
|
|
d = datetime.strptime(hire, '%Y-%m-%d').date()
|
|
return (date.today() - d).days
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def _has_clockin_on_date(db, target_date: date) -> bool:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT 1 FROM work_records WHERE date = ? LIMIT 1",
|
|
(target_date.isoformat(),))
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _count_overtime_days(db, min_minutes: int = 1) -> int:
|
|
"""야근(overtime_minutes >= min_minutes)한 일수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records WHERE overtime_minutes >= ?
|
|
""", (min_minutes,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_clockouts_at_minute(db, minute: int) -> int:
|
|
"""퇴근 시각의 분 자릿수가 특정 값인 횟수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE clock_out IS NOT NULL
|
|
AND CAST(strftime('%M', clock_out) AS INTEGER) = ?
|
|
""", (minute,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _count_in_year_month(db, year: int, month: int) -> int:
|
|
"""특정 연-월 출근 일수."""
|
|
prefix = f"{year:04d}-{month:02d}"
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE date LIKE ? || '%'
|
|
""", (prefix,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _workdays_in_year_month(year: int, month: int) -> int:
|
|
"""해당 월의 영업일 수 (토/일 제외, 공휴일은 무시)."""
|
|
from calendar import monthrange
|
|
last = monthrange(year, month)[1]
|
|
count = 0
|
|
for d in range(1, last + 1):
|
|
if date(year, month, d).weekday() < 5:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
# ============================================================
|
|
# 도전과제 정의 (357개)
|
|
# ============================================================
|
|
|
|
def _make_streak_eval(target_days: int, business_only: bool = True):
|
|
def _eval(db):
|
|
cur = (_consecutive_workdays(db) if business_only
|
|
else _consecutive_calendar_days(db))
|
|
return min(cur, target_days), target_days
|
|
return _eval
|
|
|
|
|
|
def _make_count_eval(getter, target: int):
|
|
def _eval(db):
|
|
cur = getter(db)
|
|
return min(cur, target), target
|
|
return _eval
|
|
|
|
|
|
def _bool_eval(condition_fn):
|
|
"""True/False 조건 → (1, 1) 또는 (0, 1)."""
|
|
def _eval(db):
|
|
return (1, 1) if condition_fn(db) else (0, 1)
|
|
return _eval
|
|
|
|
|
|
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
|
_STREAK_DEFS = [
|
|
# (code, name, desc, target, evaluator, tier, icon)
|
|
('streak_first', tr('achieve.streak_first.name'), tr('achieve.streak_first.desc'), 1,
|
|
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
|
|
('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3,
|
|
_make_streak_eval(3), TIER_BRONZE, '🌱'),
|
|
('streak_5', tr('achieve.streak_5.name'), tr('achieve.streak_5.desc'), 5,
|
|
_make_streak_eval(5), TIER_SILVER, '📅'),
|
|
('streak_7_cal', tr('achieve.streak_7_cal.name'), tr('achieve.streak_7_cal.desc'), 7,
|
|
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
|
|
('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10,
|
|
_make_streak_eval(10), TIER_SILVER, '💪'),
|
|
('streak_22', tr('achieve.streak_22.name'), tr('achieve.streak_22.desc'), 22,
|
|
_make_streak_eval(22), TIER_GOLD, '🏔️'),
|
|
('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50,
|
|
_make_streak_eval(50), TIER_GOLD, '🎯'),
|
|
('streak_100', tr('achieve.streak_100.name'), tr('achieve.streak_100.desc'), 100,
|
|
_make_streak_eval(100), TIER_PLATINUM, '💎'),
|
|
('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65,
|
|
_make_streak_eval(65), TIER_PLATINUM, '🏆'),
|
|
('streak_half_year', tr('achieve.streak_half_year.name'), tr('achieve.streak_half_year.desc'), 130,
|
|
_make_streak_eval(130), TIER_PLATINUM, '👑'),
|
|
('streak_year', tr('achieve.streak_year.name'), tr('achieve.streak_year.desc'), 260,
|
|
_make_streak_eval(260), TIER_LEGEND, '🌟'),
|
|
('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200,
|
|
_make_streak_eval(200), TIER_LEGEND, '🌌'),
|
|
('streak_365_cal', tr('achieve.streak_365_cal.name'), tr('achieve.streak_365_cal.desc'), 365,
|
|
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
|
|
('streak_resilience', tr('achieve.streak_resilience.name'), tr('achieve.streak_resilience.desc'), 1,
|
|
_bool_eval(lambda db: _consecutive_workdays(db) >= 1
|
|
and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'),
|
|
('streak_total_100', tr('achieve.streak_total_100.name'), tr('achieve.streak_total_100.desc'), 100,
|
|
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
|
|
('streak_total_500', tr('achieve.streak_total_500.name'), tr('achieve.streak_total_500.desc'), 500,
|
|
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
|
|
('streak_total_1000', tr('achieve.streak_total_1000.name'), tr('achieve.streak_total_1000.desc'), 1000,
|
|
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
|
|
]
|
|
|
|
|
|
def _count_weekday_clockins(db, weekday: int) -> int:
|
|
"""특정 요일(0=월 ... 6=일) 출근 횟수."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE CAST(strftime('%w', date) AS INTEGER) = ?
|
|
""", (weekday,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
_STREAK_DEFS.extend([
|
|
('streak_monday_10', tr('achieve.streak_monday_10.name'), tr('achieve.streak_monday_10.desc'), 10,
|
|
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
|
|
('streak_friday_10', tr('achieve.streak_friday_10.name'), tr('achieve.streak_friday_10.desc'), 10,
|
|
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
|
|
])
|
|
|
|
|
|
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
|
_PUNCTUAL_DEFS = [
|
|
('punc_before_8_1', tr('achieve.punc_before_8_1.name'), tr('achieve.punc_before_8_1.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
|
|
('punc_before_8_10', tr('achieve.punc_before_8_10.name'), tr('achieve.punc_before_8_10.desc'), 10,
|
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
|
|
('punc_before_8_30', tr('achieve.punc_before_8_30.name'), tr('achieve.punc_before_8_30.desc'), 30,
|
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
|
|
('punc_before_6_1', tr('achieve.punc_before_6_1.name'), tr('achieve.punc_before_6_1.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
|
|
('punc_before_6_10', tr('achieve.punc_before_6_10.name'), tr('achieve.punc_before_6_10.desc'), 10,
|
|
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
|
|
('punc_before_5', tr('achieve.punc_before_5.name'), tr('achieve.punc_before_5.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
|
|
('punc_at_9', tr('achieve.punc_at_9.name'), tr('achieve.punc_at_9.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
|
|
TIER_BRONZE, '🎯'),
|
|
('punc_at_9_5', tr('achieve.punc_at_9_5.name'), tr('achieve.punc_at_9_5.desc'), 5,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
|
|
TIER_SILVER, '🏹'),
|
|
('punc_late_5min', tr('achieve.punc_late_5min.name'), tr('achieve.punc_late_5min.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
|
|
TIER_BRONZE, '🛌'),
|
|
('punc_at_909', tr('achieve.punc_at_909.name'), tr('achieve.punc_at_909.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
|
|
TIER_GOLD, '🎰'),
|
|
]
|
|
|
|
|
|
def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> int:
|
|
s = f"{sh:02d}:{sm:02d}:00"
|
|
e = f"{eh:02d}:{em:02d}:00"
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records WHERE clock_in >= ? AND clock_in < ?
|
|
""", (s, e))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
|
|
_BALANCE_DEFS = [
|
|
('bal_first_punct', tr('achieve.bal_first_punct.name'), tr('achieve.bal_first_punct.desc'), 1,
|
|
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
|
|
('bal_punct_10', tr('achieve.bal_punct_10.name'), tr('achieve.bal_punct_10.desc'), 10,
|
|
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
|
|
('bal_punct_30', tr('achieve.bal_punct_30.name'), tr('achieve.bal_punct_30.desc'), 30,
|
|
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
|
|
('bal_punct_100', tr('achieve.bal_punct_100.name'), tr('achieve.bal_punct_100.desc'), 100,
|
|
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
|
|
('bal_punct_300', tr('achieve.bal_punct_300.name'), tr('achieve.bal_punct_300.desc'), 300,
|
|
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
|
|
]
|
|
|
|
|
|
# ---- 4. 연장근무 적립 ----
|
|
_OT_BANK_DEFS = [
|
|
('ot_first_30m', tr('achieve.ot_first_30m.name'), tr('achieve.ot_first_30m.desc'), 30,
|
|
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
|
|
('ot_total_60m', tr('achieve.ot_total_60m.name'), tr('achieve.ot_total_60m.desc'), 60,
|
|
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
|
|
('ot_total_5h', tr('achieve.ot_total_5h.name'), tr('achieve.ot_total_5h.desc'), 300,
|
|
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
|
|
('ot_total_10h', tr('achieve.ot_total_10h.name'), tr('achieve.ot_total_10h.desc'), 600,
|
|
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
|
|
('ot_total_25h', tr('achieve.ot_total_25h.name'), tr('achieve.ot_total_25h.desc'), 1500,
|
|
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
|
|
('ot_total_50h', tr('achieve.ot_total_50h.name'), tr('achieve.ot_total_50h.desc'), 3000,
|
|
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
|
|
('ot_total_100h', tr('achieve.ot_total_100h.name'), tr('achieve.ot_total_100h.desc'), 6000,
|
|
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
|
|
('ot_total_200h', tr('achieve.ot_total_200h.name'), tr('achieve.ot_total_200h.desc'), 12000,
|
|
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
|
|
('ot_total_300h', tr('achieve.ot_total_300h.name'), tr('achieve.ot_total_300h.desc'), 18000,
|
|
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
|
|
('ot_total_500h', tr('achieve.ot_total_500h.name'), tr('achieve.ot_total_500h.desc'), 30000,
|
|
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
|
|
]
|
|
|
|
|
|
# ---- 5. 연장근무 사용 ----
|
|
_OT_USE_DEFS = [
|
|
('use_first', tr('achieve.use_first.name'), tr('achieve.use_first.desc'), 1,
|
|
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
|
|
('use_total_5h', tr('achieve.use_total_5h.name'), tr('achieve.use_total_5h.desc'), 300,
|
|
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
|
|
('use_total_25h', tr('achieve.use_total_25h.name'), tr('achieve.use_total_25h.desc'), 1500,
|
|
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
|
|
('use_total_50h', tr('achieve.use_total_50h.name'), tr('achieve.use_total_50h.desc'), 3000,
|
|
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
|
|
('use_total_100h', tr('achieve.use_total_100h.name'), tr('achieve.use_total_100h.desc'), 6000,
|
|
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
|
|
]
|
|
|
|
|
|
# ---- 6. 연차 ----
|
|
_LEAVE_DEFS = [
|
|
('leave_first', tr('achieve.leave_first.name'), tr('achieve.leave_first.desc'), 1,
|
|
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
|
|
('leave_half', tr('achieve.leave_half.name'), tr('achieve.leave_half.desc'), 1,
|
|
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
|
|
('leave_quarter', tr('achieve.leave_quarter.name'), tr('achieve.leave_quarter.desc'), 1,
|
|
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
|
|
('leave_streak_3', tr('achieve.leave_streak_3.name'), tr('achieve.leave_streak_3.desc'), 3,
|
|
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
|
|
('leave_streak_5', tr('achieve.leave_streak_5.name'), tr('achieve.leave_streak_5.desc'), 5,
|
|
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
|
|
('leave_streak_7', tr('achieve.leave_streak_7.name'), tr('achieve.leave_streak_7.desc'), 7,
|
|
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
|
|
('leave_total_10', tr('achieve.leave_total_10.name'), tr('achieve.leave_total_10.desc'), 10,
|
|
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
|
|
('leave_sick', tr('achieve.leave_sick.name'), tr('achieve.leave_sick.desc'), 1,
|
|
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
|
|
TIER_BRONZE, '🏥'),
|
|
]
|
|
|
|
|
|
# ---- 7. 식사 (점심/저녁) ----
|
|
_MEAL_DEFS = [
|
|
('meal_lunch_first', tr('achieve.meal_lunch_first.name'), tr('achieve.meal_lunch_first.desc'), 1,
|
|
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
|
|
('meal_lunch_30', tr('achieve.meal_lunch_30.name'), tr('achieve.meal_lunch_30.desc'), 30,
|
|
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
|
|
('meal_lunch_100', tr('achieve.meal_lunch_100.name'), tr('achieve.meal_lunch_100.desc'), 100,
|
|
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
|
|
('meal_dinner_first', tr('achieve.meal_dinner_first.name'), tr('achieve.meal_dinner_first.desc'), 1,
|
|
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
|
|
('meal_dinner_10', tr('achieve.meal_dinner_10.name'), tr('achieve.meal_dinner_10.desc'), 10,
|
|
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
|
|
('meal_dinner_30', tr('achieve.meal_dinner_30.name'), tr('achieve.meal_dinner_30.desc'), 30,
|
|
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
|
|
('meal_lunch_actual', tr('achieve.meal_lunch_actual.name'), tr('achieve.meal_lunch_actual.desc'), 1,
|
|
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
|
|
TIER_BRONZE, '⏱️'),
|
|
('meal_dinner_actual', tr('achieve.meal_dinner_actual.name'), tr('achieve.meal_dinner_actual.desc'), 1,
|
|
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
|
|
TIER_BRONZE, '⏰'),
|
|
]
|
|
|
|
|
|
# ---- 8. 외출 ----
|
|
_BREAK_DEFS = [
|
|
('break_first', tr('achieve.break_first.name'), tr('achieve.break_first.desc'), 1,
|
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
|
|
TIER_BRONZE, '🚶'),
|
|
('break_10', tr('achieve.break_10.name'), tr('achieve.break_10.desc'), 10,
|
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
|
|
TIER_SILVER, '🚪'),
|
|
('break_50', tr('achieve.break_50.name'), tr('achieve.break_50.desc'), 50,
|
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
|
|
TIER_GOLD, '🚶♂️'),
|
|
]
|
|
|
|
|
|
# ---- 9. 시간대별 ----
|
|
_TIME_SLOT_DEFS = [
|
|
('slot_in_06', tr('achieve.slot_in_06.name'), tr('achieve.slot_in_06.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
|
|
TIER_BRONZE, '🌅'),
|
|
('slot_in_07', tr('achieve.slot_in_07.name'), tr('achieve.slot_in_07.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
|
|
TIER_BRONZE, '🌄'),
|
|
('slot_in_08', tr('achieve.slot_in_08.name'), tr('achieve.slot_in_08.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
|
|
TIER_BRONZE, '☀️'),
|
|
('slot_in_10', tr('achieve.slot_in_10.name'), tr('achieve.slot_in_10.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
|
|
TIER_BRONZE, '🕙'),
|
|
('slot_in_11', tr('achieve.slot_in_11.name'), tr('achieve.slot_in_11.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
|
|
TIER_SILVER, '🕦'),
|
|
('slot_out_19', tr('achieve.slot_out_19.name'), tr('achieve.slot_out_19.desc'), 10,
|
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
|
|
TIER_SILVER, '🌆'),
|
|
('slot_out_20', tr('achieve.slot_out_20.name'), tr('achieve.slot_out_20.desc'), 10,
|
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
|
|
TIER_GOLD, '🌌'),
|
|
('slot_out_21', tr('achieve.slot_out_21.name'), tr('achieve.slot_out_21.desc'), 5,
|
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
|
|
TIER_GOLD, '🌑'),
|
|
('slot_out_22', tr('achieve.slot_out_22.name'), tr('achieve.slot_out_22.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
|
|
TIER_PLATINUM, '🦉'),
|
|
('slot_out_23', tr('achieve.slot_out_23.name'), tr('achieve.slot_out_23.desc'), 1,
|
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
|
|
TIER_PLATINUM, '🦇'),
|
|
('slot_midnight', tr('achieve.slot_midnight.name'), tr('achieve.slot_midnight.desc'), 1,
|
|
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
|
|
('slot_midnight_3', tr('achieve.slot_midnight_3.name'), tr('achieve.slot_midnight_3.desc'), 3,
|
|
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
|
|
]
|
|
|
|
|
|
def _count_clockouts_in_hour(db, hour: int) -> int:
|
|
"""clock_out이 hour시대(HH:00-HH:59)인 횟수. 자정 넘김 케이스 무시."""
|
|
s = f"{hour:02d}:00:00"
|
|
e = f"{hour+1:02d}:00:00" if hour < 23 else "23:59:59"
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM work_records
|
|
WHERE clock_out IS NOT NULL AND clock_out >= ? AND clock_out < ?
|
|
AND clock_out >= clock_in
|
|
""", (s, e))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
# ---- 10. 공휴일·주말 ----
|
|
_SPECIAL_DAY_DEFS = [
|
|
('weekend_1', tr('achieve.weekend_1.name'), tr('achieve.weekend_1.desc'), 1,
|
|
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
|
|
('weekend_5', tr('achieve.weekend_5.name'), tr('achieve.weekend_5.desc'), 5,
|
|
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
|
|
('weekend_20', tr('achieve.weekend_20.name'), tr('achieve.weekend_20.desc'), 20,
|
|
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
|
|
('holiday_1', tr('achieve.holiday_1.name'), tr('achieve.holiday_1.desc'), 1,
|
|
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
|
|
('holiday_5', tr('achieve.holiday_5.name'), tr('achieve.holiday_5.desc'), 5,
|
|
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
|
|
('day_christmas', tr('achieve.day_christmas.name'), tr('achieve.day_christmas.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
|
|
('day_newyear', tr('achieve.day_newyear.name'), tr('achieve.day_newyear.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
|
|
('day_liberation', tr('achieve.day_liberation.name'), tr('achieve.day_liberation.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
|
|
('day_children', tr('achieve.day_children.name'), tr('achieve.day_children.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
|
|
('day_hangul', tr('achieve.day_hangul.name'), tr('achieve.day_hangul.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
|
|
('day_valentine', tr('achieve.day_valentine.name'), tr('achieve.day_valentine.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
|
|
('day_white', tr('achieve.day_white.name'), tr('achieve.day_white.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
|
|
('day_pepero', tr('achieve.day_pepero.name'), tr('achieve.day_pepero.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
|
|
('day_halloween', tr('achieve.day_halloween.name'), tr('achieve.day_halloween.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
|
|
('day_aprilfools', tr('achieve.day_aprilfools.name'), tr('achieve.day_aprilfools.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
|
|
('day_77', tr('achieve.day_77.name'), tr('achieve.day_77.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
|
|
('day_dongji', tr('achieve.day_dongji.name'), tr('achieve.day_dongji.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
|
|
('day_parents', tr('achieve.day_parents.name'), tr('achieve.day_parents.desc'), 1,
|
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
|
|
TIER_SILVER, '🪅'),
|
|
('day_teacher', tr('achieve.day_teacher.name'), tr('achieve.day_teacher.desc'), 1,
|
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
|
|
TIER_BRONZE, '🎂'),
|
|
('day_xmas_eve', tr('achieve.day_xmas_eve.name'), tr('achieve.day_xmas_eve.desc'), 1,
|
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
|
|
TIER_SILVER, '🎁'),
|
|
('day_earth', tr('achieve.day_earth.name'), tr('achieve.day_earth.desc'), 1,
|
|
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
|
|
]
|
|
|
|
|
|
# ---- 11. 시즌·월별 ----
|
|
def _make_month_full_attendance_eval(month: int):
|
|
"""해당 월 영업일 모두 출근."""
|
|
def _eval(db):
|
|
year = date.today().year
|
|
target = _workdays_in_year_month(year, month)
|
|
cur = _count_in_year_month(db, year, month)
|
|
# 영업일 수 정확히 카운트는 holidays 제외 안 함 — 단순화
|
|
return min(cur, target), max(target, 1)
|
|
return _eval
|
|
|
|
|
|
def _make_month_first_eval(month: int):
|
|
def _eval(db):
|
|
year = date.today().year
|
|
return ((1, 1) if _count_in_year_month(db, year, month) >= 1 else (0, 1))
|
|
return _eval
|
|
|
|
|
|
_SEASON_DEFS = [
|
|
('season_jan', tr('achieve.season_jan.name'), tr('achieve.season_jan.desc'), 1,
|
|
_make_month_first_eval(1), TIER_BRONZE, '⛄'),
|
|
('season_feb', tr('achieve.season_feb.name'), tr('achieve.season_feb.desc'), 1,
|
|
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
|
|
('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1,
|
|
_make_month_first_eval(3), TIER_BRONZE, '🌸'),
|
|
('season_apr', tr('achieve.season_apr.name'), tr('achieve.season_apr.desc'), 1,
|
|
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
|
|
('season_may', tr('achieve.season_may.name'), tr('achieve.season_may.desc'), 1,
|
|
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
|
|
('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1,
|
|
_make_month_first_eval(6), TIER_BRONZE, '☀️'),
|
|
('season_jul', tr('achieve.season_jul.name'), tr('achieve.season_jul.desc'), 1,
|
|
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
|
|
('season_aug', tr('achieve.season_aug.name'), tr('achieve.season_aug.desc'), 1,
|
|
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
|
|
('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1,
|
|
_make_month_first_eval(9), TIER_BRONZE, '🍂'),
|
|
('season_oct', tr('achieve.season_oct.name'), tr('achieve.season_oct.desc'), 1,
|
|
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
|
|
('season_nov', tr('achieve.season_nov.name'), tr('achieve.season_nov.desc'), 1,
|
|
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
|
|
('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1,
|
|
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
|
]
|
|
|
|
|
|
# ---- 12. 앱 사용 마일스톤 ----
|
|
_MILESTONE_DEFS = [
|
|
('mile_first', tr('achieve.mile_first.name'), tr('achieve.mile_first.desc'), 1,
|
|
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
|
|
TIER_BRONZE, '👋'),
|
|
('mile_7days', tr('achieve.mile_7days.name'), tr('achieve.mile_7days.desc'), 7,
|
|
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
|
|
('mile_30days', tr('achieve.mile_30days.name'), tr('achieve.mile_30days.desc'), 30,
|
|
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
|
|
('mile_365days', tr('achieve.mile_365days.name'), tr('achieve.mile_365days.desc'), 365,
|
|
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
|
|
('mile_730days', tr('achieve.mile_730days.name'), tr('achieve.mile_730days.desc'), 730,
|
|
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
|
|
('mile_1095days', tr('achieve.mile_1095days.name'), tr('achieve.mile_1095days.desc'), 1095,
|
|
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
|
|
('mile_5years', tr('achieve.mile_5years.name'), tr('achieve.mile_5years.desc'), 1825,
|
|
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
|
|
('mile_10years', tr('achieve.mile_10years.name'), tr('achieve.mile_10years.desc'), 3650,
|
|
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
|
|
]
|
|
|
|
|
|
# ---- 13. 통계·분석 (view counter 기반) ----
|
|
_STATS_DEFS = [
|
|
('stat_weekly_10', tr('achieve.stat_weekly_10.name'), tr('achieve.stat_weekly_10.desc'), 10,
|
|
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
|
|
TIER_BRONZE, '📊'),
|
|
('stat_monthly_10', tr('achieve.stat_monthly_10.name'), tr('achieve.stat_monthly_10.desc'), 10,
|
|
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
|
|
TIER_BRONZE, '📈'),
|
|
('stat_pattern_10', tr('achieve.stat_pattern_10.name'), tr('achieve.stat_pattern_10.desc'), 10,
|
|
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
|
|
TIER_SILVER, '🔍'),
|
|
('stat_calendar_30', tr('achieve.stat_calendar_30.name'), tr('achieve.stat_calendar_30.desc'), 30,
|
|
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
|
|
TIER_SILVER, '📅'),
|
|
('stat_report_first', tr('achieve.stat_report_first.name'), tr('achieve.stat_report_first.desc'), 1,
|
|
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
|
|
TIER_BRONZE, '📋'),
|
|
('stat_report_30', tr('achieve.stat_report_30.name'), tr('achieve.stat_report_30.desc'), 30,
|
|
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
|
|
TIER_SILVER, '📰'),
|
|
('stat_chart_hover', tr('achieve.stat_chart_hover.name'), tr('achieve.stat_chart_hover.desc'), 1,
|
|
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
|
|
TIER_BRONZE, '🎨'),
|
|
('stat_achievements_open', tr('achieve.stat_achievements_open.name'), tr('achieve.stat_achievements_open.desc'), 50,
|
|
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
|
|
TIER_BRONZE, '🦄'),
|
|
]
|
|
|
|
|
|
# ---- 14. 시크릿 ----
|
|
def _has_clock_in_palindrome(db) -> bool:
|
|
"""출근 시각이 회문 (HH:MM에서 H1H2:M1M2가 회문)."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT clock_in FROM work_records WHERE clock_in IS NOT NULL")
|
|
for (t,) in cur.fetchall():
|
|
if not t:
|
|
continue
|
|
digits = t[:5].replace(':', '')
|
|
if len(digits) == 4 and digits == digits[::-1]:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_clock_in_jackpot(db) -> bool:
|
|
"""출근 시각 모든 자릿수 동일 (11:11, 22:22)."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT clock_in FROM work_records WHERE clock_in IS NOT NULL")
|
|
for (t,) in cur.fetchall():
|
|
if not t:
|
|
continue
|
|
digits = t[:5].replace(':', '')
|
|
if len(digits) == 4 and len(set(digits)) == 1:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has_friday_13th_clockin(db) -> bool:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records
|
|
WHERE strftime('%w', date) = '5'
|
|
AND CAST(strftime('%d', date) AS INTEGER) = 13
|
|
LIMIT 1
|
|
""")
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _has_777(db) -> bool:
|
|
"""7월 7일 7시 7분 출근."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records
|
|
WHERE strftime('%m-%d', date) = '07-07'
|
|
AND clock_in LIKE '07:07%'
|
|
LIMIT 1
|
|
""")
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _has_exact_8h(db) -> bool:
|
|
"""정확히 8시간 0분 0초 근무."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records
|
|
WHERE clock_in IS NOT NULL AND clock_out IS NOT NULL
|
|
AND total_hours = 8.0
|
|
LIMIT 1
|
|
""")
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _has_pi_day(db) -> bool:
|
|
"""3/14 1:59 출근 (π = 3.14159)."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records
|
|
WHERE strftime('%m-%d', date) = '03-14'
|
|
AND clock_in LIKE '01:59%'
|
|
LIMIT 1
|
|
""")
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _has_fibonacci_minute(db) -> bool:
|
|
"""출근 시각 분이 피보나치 (1,2,3,5,8,13,21,34,55)."""
|
|
fibs = {1, 2, 3, 5, 8, 13, 21, 34, 55}
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT CAST(strftime('%M', clock_in) AS INTEGER) FROM work_records
|
|
WHERE clock_in IS NOT NULL
|
|
""")
|
|
return any(r[0] in fibs for r in cur.fetchall())
|
|
|
|
|
|
def _has_double_six(db) -> bool:
|
|
"""6/6 18:06 출근."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT 1 FROM work_records
|
|
WHERE strftime('%m-%d', date) = '06-06'
|
|
AND clock_in LIKE '18:06%'
|
|
LIMIT 1
|
|
""")
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _has_500_anniv_clockin(db) -> bool:
|
|
"""가입 후 정확히 365일 후 출근."""
|
|
hire = _setting_str(db, 'hire_date', '')
|
|
if not hire:
|
|
return False
|
|
try:
|
|
d = datetime.strptime(hire, '%Y-%m-%d').date()
|
|
target = d + timedelta(days=365)
|
|
return _has_clockin_on_date(db, target)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
_SECRET_DEFS = [
|
|
('secret_palindrome', tr('achieve.secret_palindrome.name'), tr('achieve.secret_palindrome.desc'), 1,
|
|
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
|
|
('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1,
|
|
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
|
|
('secret_fri13', tr('achieve.secret_fri13.name'), tr('achieve.secret_fri13.desc'), 1,
|
|
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
|
|
('secret_777', tr('achieve.secret_777.name'), tr('achieve.secret_777.desc'), 1,
|
|
_bool_eval(_has_777), TIER_LEGEND, '🔮'),
|
|
('secret_exact_8h', tr('achieve.secret_exact_8h.name'), tr('achieve.secret_exact_8h.desc'), 1,
|
|
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
|
|
('secret_pi_day', tr('achieve.secret_pi_day.name'), tr('achieve.secret_pi_day.desc'), 1,
|
|
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
|
|
('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1,
|
|
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
|
|
('secret_double_six', tr('achieve.secret_double_six.name'), tr('achieve.secret_double_six.desc'), 1,
|
|
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
|
|
('secret_anniversary', tr('achieve.secret_anniversary.name'), tr('achieve.secret_anniversary.desc'), 1,
|
|
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
|
|
]
|
|
|
|
|
|
# ---- 15. 다양성·설정 ----
|
|
def _setting_changed_from_default(db, key: str, default_value: str) -> bool:
|
|
return str(db.get_setting(key, default_value)) != default_value
|
|
|
|
|
|
_SETTINGS_DEFS = [
|
|
('set_dark', tr('achieve.set_dark.name'), tr('achieve.set_dark.desc'), 1,
|
|
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
|
|
TIER_BRONZE, '🌗'),
|
|
('set_lang', tr('achieve.set_lang.name'), tr('achieve.set_lang.desc'), 1,
|
|
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
|
|
TIER_BRONZE, '🌐'),
|
|
('set_a11y', tr('achieve.set_a11y.name'), tr('achieve.set_a11y.desc'), 1,
|
|
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
|
|
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
|
TIER_BRONZE, '♿'),
|
|
('set_overtime_unit', tr('achieve.set_overtime_unit.name'), tr('achieve.set_overtime_unit.desc'), 1,
|
|
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
|
|
TIER_BRONZE, '⏱️'),
|
|
('set_goal_full', tr('achieve.set_goal_full.name'), tr('achieve.set_goal_full.desc'), 1,
|
|
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
|
|
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
|
TIER_SILVER, '🎯'),
|
|
('set_discord_full', tr('achieve.set_discord_full.name'), tr('achieve.set_discord_full.desc'), 1,
|
|
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
|
|
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
|
('notification_clock_out', 'notification_lunch',
|
|
'notification_overtime', 'notification_health'))),
|
|
TIER_SILVER, '🔔'),
|
|
('set_cloud', tr('achieve.set_cloud.name'), tr('achieve.set_cloud.desc'), 1,
|
|
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
|
|
TIER_SILVER, '☁️'),
|
|
]
|
|
|
|
|
|
# ---- 16. 메타 (도전과제 자체) ----
|
|
def _earned_count(db) -> int:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COUNT(*) FROM achievements WHERE earned_date IS NOT NULL")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def _earned_secret_count(db) -> int:
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT COUNT(*) FROM achievements
|
|
WHERE earned_date IS NOT NULL AND is_secret = 1
|
|
""")
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
_META_DEFS = [
|
|
('meta_first', tr('achieve.meta_first.name'), tr('achieve.meta_first.desc'), 1,
|
|
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
|
|
('meta_10', tr('achieve.meta_10.name'), tr('achieve.meta_10.desc'), 10,
|
|
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
|
|
('meta_25', tr('achieve.meta_25.name'), tr('achieve.meta_25.desc'), 25,
|
|
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
|
|
('meta_50', tr('achieve.meta_50.name'), tr('achieve.meta_50.desc'), 50,
|
|
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
|
|
('meta_75', tr('achieve.meta_75.name'), tr('achieve.meta_75.desc'), 75,
|
|
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
|
|
('meta_100', tr('achieve.meta_100.name'), tr('achieve.meta_100.desc'), 100,
|
|
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
|
|
('meta_secret_1', tr('achieve.meta_secret_1.name'), tr('achieve.meta_secret_1.desc'), 1,
|
|
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
|
|
('meta_secret_5', tr('achieve.meta_secret_5.name'), tr('achieve.meta_secret_5.desc'), 5,
|
|
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
|
|
]
|
|
|
|
|
|
# ============================================================
|
|
# 모든 도전과제 통합
|
|
# ============================================================
|
|
|
|
def _build_all() -> List[Achievement]:
|
|
"""모든 카테고리를 합쳐 Achievement 리스트로 반환."""
|
|
all_defs = []
|
|
sections = [
|
|
(CAT_STREAK, _STREAK_DEFS),
|
|
(CAT_PUNCTUAL, _PUNCTUAL_DEFS),
|
|
(CAT_BALANCE, _BALANCE_DEFS),
|
|
(CAT_OT_BANK, _OT_BANK_DEFS),
|
|
(CAT_OT_USE, _OT_USE_DEFS),
|
|
(CAT_LEAVE, _LEAVE_DEFS),
|
|
(CAT_MEAL, _MEAL_DEFS),
|
|
(CAT_BREAK, _BREAK_DEFS),
|
|
(CAT_TIME_SLOT, _TIME_SLOT_DEFS),
|
|
(CAT_SPECIAL_DAY, _SPECIAL_DAY_DEFS),
|
|
(CAT_SEASON, _SEASON_DEFS),
|
|
(CAT_MILESTONE, _MILESTONE_DEFS),
|
|
(CAT_STATS, _STATS_DEFS),
|
|
(CAT_SETTINGS, _SETTINGS_DEFS),
|
|
(CAT_SECRET, _SECRET_DEFS),
|
|
(CAT_META, _META_DEFS),
|
|
]
|
|
for cat, defs in sections:
|
|
for tup in defs:
|
|
code, name, desc, target, evaluator, tier, icon = tup
|
|
is_secret = (cat == CAT_SECRET)
|
|
all_defs.append(Achievement(
|
|
code=code, name=name, description=desc, category=cat,
|
|
tier=tier, badge_icon=icon, is_secret=is_secret,
|
|
target=target, evaluator=evaluator,
|
|
))
|
|
return all_defs
|
|
|
|
|
|
ALL_ACHIEVEMENTS: List[Achievement] = _build_all()
|
|
|
|
|
|
def get_achievement(code: str) -> Optional[Achievement]:
|
|
for a in ALL_ACHIEVEMENTS:
|
|
if a.code == code:
|
|
return a
|
|
return None
|
|
|
|
|
|
# ============================================================
|
|
# DB 동기화 + 평가
|
|
# ============================================================
|
|
|
|
def sync_definitions_to_db(db) -> None:
|
|
"""ALL_ACHIEVEMENTS 정의를 achievements 테이블에 upsert.
|
|
|
|
code가 unique key. 신규 도전과제는 INSERT, 기존은 메타데이터 UPDATE
|
|
(earned_date, progress는 보존).
|
|
"""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
for a in ALL_ACHIEVEMENTS:
|
|
cur.execute("SELECT id FROM achievements WHERE code = ?", (a.code,))
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
cur.execute('''
|
|
INSERT INTO achievements
|
|
(code, name, description, category, tier, is_secret,
|
|
progress, target, badge_icon)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
''', (a.code, a.name, a.description, a.category, a.tier,
|
|
1 if a.is_secret else 0, a.target, a.badge_icon))
|
|
else:
|
|
cur.execute('''
|
|
UPDATE achievements
|
|
SET name = ?, description = ?, category = ?, tier = ?,
|
|
is_secret = ?, target = ?, badge_icon = ?
|
|
WHERE code = ?
|
|
''', (a.name, a.description, a.category, a.tier,
|
|
1 if a.is_secret else 0, a.target, a.badge_icon, a.code))
|
|
conn.commit()
|
|
|
|
|
|
def evaluate_all(db) -> List[Achievement]:
|
|
"""미획득 도전과제만 평가. 새로 잠금 해제된 것 리스트 반환.
|
|
|
|
side effect: progress 업데이트, earned_date 기록.
|
|
"""
|
|
newly_unlocked = []
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
for a in ALL_ACHIEVEMENTS:
|
|
if a.evaluator is None:
|
|
continue
|
|
cur.execute("""
|
|
SELECT progress, earned_date FROM achievements WHERE code = ?
|
|
""", (a.code,))
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
continue
|
|
stored_progress, earned = row[0], row[1]
|
|
if earned is not None:
|
|
continue # 이미 획득
|
|
|
|
try:
|
|
progress, target = a.evaluator(db)
|
|
except Exception:
|
|
continue # 평가 실패는 silent (다음 tick에 재시도)
|
|
|
|
now_unlocked = progress >= target
|
|
|
|
if progress != stored_progress or now_unlocked:
|
|
if now_unlocked:
|
|
cur.execute("""
|
|
UPDATE achievements
|
|
SET progress = ?, earned_date = DATE('now', 'localtime')
|
|
WHERE code = ?
|
|
""", (target, a.code))
|
|
newly_unlocked.append(a)
|
|
else:
|
|
cur.execute("""
|
|
UPDATE achievements SET progress = ? WHERE code = ?
|
|
""", (progress, a.code))
|
|
conn.commit()
|
|
return newly_unlocked
|
|
|
|
|
|
def get_all_with_status(db) -> List[dict]:
|
|
"""UI용: 모든 도전과제 + 진행도/획득 상태 dict 리스트."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT code, name, description, category, tier, is_secret,
|
|
progress, target, earned_date, badge_icon
|
|
FROM achievements
|
|
ORDER BY
|
|
CASE WHEN earned_date IS NOT NULL THEN 0 ELSE 1 END,
|
|
category, tier
|
|
""")
|
|
return [dict(zip(
|
|
['code', 'name', 'description', 'category', 'tier', 'is_secret',
|
|
'progress', 'target', 'earned_date', 'badge_icon'],
|
|
row
|
|
)) for row in cur.fetchall()]
|
|
|
|
|
|
def get_stats(db) -> dict:
|
|
"""전체 통계 — 획득/총개수/비밀발견 등."""
|
|
with db._conn() as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN earned_date IS NOT NULL THEN 1 ELSE 0 END) AS earned,
|
|
SUM(CASE WHEN is_secret = 1 THEN 1 ELSE 0 END) AS secret_total,
|
|
SUM(CASE WHEN is_secret = 1 AND earned_date IS NOT NULL THEN 1 ELSE 0 END) AS secret_earned
|
|
FROM achievements
|
|
""")
|
|
row = cur.fetchone()
|
|
return {
|
|
'total': row[0] or 0,
|
|
'earned': row[1] or 0,
|
|
'secret_total': row[2] or 0,
|
|
'secret_earned': row[3] or 0,
|
|
}
|