← .mpy 공유
사용자 공유

인생 100년 달력

100년 인생을 5,200칸(1주 = 1칸)으로 펼쳐 놓고, 지금 내가 어디쯤 와 있는지와 남은 시간을 보여 주는 도구입니다.

인생 100년 달력 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

인생 100년 달력

설명

100년 인생을 5,200칸(1주 = 1칸)으로 펼쳐 놓고, 지금 내가 어디쯤 와 있는지와 남은 시간을 보여 주는 도구입니다.

태그

calender

작성자

메이커-4b22 | MDS 1.0

Source · 902줄 (실행 전에 꼭 확인하세요 — 펼치기)
# -*- coding: utf-8 -*-
# 인생 100년 달력 v3 — 100년 = 5,200주를 한 장에
#   인생의 사계절 달력 · 인생 시계 · 살아 있는 카운터 · 남은 시간의 쓰임
#   부모님과 남은 시간 · 부모님과 남은 여행(계획 D-day) · 남은 것들 · 이정표
#   배경: 지금 내 계절에 맞는 움직이는 배경 (봄 벚꽃잎 · 여름 반딧불이 · 가을 낙엽 · 겨울 눈)
# 조작
#   창            : 화면 크기에 맞게 최대화로 열림 (제목줄 최대화 버튼 사용 가능, 크기에 맞춰 글자·배치 자동 조정)
#   슬라이더      : 수면 · 일 · 은퇴 · 스마트폰 · 시간 여행 · 부모님 만남 주기 · 만날 때 시간(제한 없음, 숫자 직접 입력)
#                   · 부모님 기준 나이 · 부모님과 여행 주기 · 여행 일정
#   달력에 마우스 : 그 주가 언제인지, 그때 나와 부모님이 몇 살인지, 기념일·여행
#   달력 클릭     : 그 주로 이동 (슬라이더를 움직이면 오늘로 복귀)
#   시계 클릭     : 인생 재생 — 탄생부터 100세까지, 배경 계절도 함께 바뀜 (다시 클릭하면 정지)

import math
import datetime as _dt
from matplotlib.patches import FancyBboxPatch, Wedge, Circle, Rectangle

BG = '#0E1325'
BG_GRID = '#0F1427'
INK = '#E8ECF4'
SUB = '#8A93A8'
GOLD = '#FFD166'
PINK = '#FF7A9A'
MINT = '#5EEAD4'
CARD = '#172038'
SEASONS = [
    (0, 25, '봄', '자라는 시간', '#7BD389'),
    (25, 50, '여름', '뜨거운 시간', '#4FC3F7'),
    (50, 75, '가을', '거두는 시간', '#FFB74D'),
    (75, 100, '겨울', '돌아보는 시간', '#B39DDB'),
]
USE_ORDER = ['수면', '일', '스마트폰', '생활', '부모님과', '내 시간']
USE_COLORS = {'수면': '#3B4A7A', '일': '#5C6B8A', '스마트폰': '#8E5B7A', '생활': '#4A5A5A', '부모님과': PINK, '내 시간': GOLD}
CELL, GAP, DGAP = 4, 1, 3
YEARS, WEEKS = 100, 52
TOGETHER_H_PER_DAY = 4.0
TRIP_H_PER_DAY = 12.0
LIFE_H_PER_DAY = 2.5
P_WORKDAYS = 250.0 / 365.0
BASE_W, BASE_H = 11.0, 8.0
UI = {'s': 1.0}
FPS_MS = 60


def fs(x: float) -> float:
    return x * UI['s']


def hex_rgb(h: str) -> np.ndarray:
    h = h.lstrip('#')
    return np.array([int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)])


def season_of(age: int) -> tuple:
    for s in SEASONS:
        if s[0] <= age < s[1]:
            return s
    return SEASONS[-1]


def add_years(d: _dt.date, n: int) -> _dt.date:
    try:
        return d.replace(year=d.year + n)
    except ValueError:
        return d.replace(year=d.year + n, day=28)


def parse_date(s: str) -> _dt.date:
    t = str(s).strip().replace('.', '-').replace('/', '-').replace(' ', '')
    if len(t) == 8 and t.isdigit():
        t = t[:4] + '-' + t[4:6] + '-' + t[6:]
    y, m, d = [int(v) for v in t.split('-')[:3]]
    return _dt.date(y, m, d)


def split_entries(s: str) -> list:
    if not s:
        return []
    return [c.strip() for c in str(s).replace('\n', ';').replace(',', ';').split(';') if c.strip()]


def parse_events(s: str) -> list:
    out = []
    for chunk in split_entries(s):
        parts = chunk.split(None, 1)
        try:
            out.append((parse_date(parts[0]), parts[1].strip() if len(parts) > 1 else '기념일'))
        except Exception:
            continue
    return out


def parse_trips(s: str) -> list:
    out = []
    for chunk in split_entries(s):
        parts = chunk.split()
        try:
            d = parse_date(parts[0])
        except Exception:
            continue
        rest = parts[1:]
        days = 1
        if rest:
            tail = rest[-1].replace('일', '').replace('박', '')
            if tail.isdigit():
                days = max(1, int(tail))
                rest = rest[:-1]
        out.append((d, ' '.join(rest) if rest else '여행', days))
    return sorted(out, key=lambda t: t[0])


def week_pos(birth: _dt.date, d: _dt.date) -> tuple:
    age = d.year - birth.year - ((d.month, d.day) < (birth.month, birth.day))
    age = max(0, min(YEARS - 1, age))
    col = min(WEEKS - 1, max(0, (d - add_years(birth, age)).days // 7))
    return age, col


def cell_date(birth: _dt.date, rc: tuple) -> _dt.date:
    return add_years(birth, rc[0]) + _dt.timedelta(days=7 * rc[1])


def cell_xy(row: int, col: int) -> tuple:
    x = col * (CELL + GAP) + CELL / 2.0
    y = row * (CELL + GAP) + (row // 10) * DGAP + CELL / 2.0
    return x, y


def xy_cell(x: float, y: float) -> tuple:
    if x is None or y is None or x < 0 or x >= WEEKS * (CELL + GAP):
        return None
    col = int(x // (CELL + GAP))
    for r in range(YEARS):
        y0 = r * (CELL + GAP) + (r // 10) * DGAP
        if y0 - 0.5 <= y < y0 + CELL + GAP:
            return r, col
    return None


def grid_layers() -> tuple:
    H = YEARS * (CELL + GAP) + 9 * DGAP
    W = WEEKS * (CELL + GAP)
    bg = hex_rgb(BG_GRID)
    past = np.tile(bg, (H, W, 1))
    future = past.copy()
    idx = np.full((H, W), -1, dtype=int)
    for r in range(YEARS):
        c = hex_rgb(season_of(r)[4])
        y0 = r * (CELL + GAP) + (r // 10) * DGAP
        for k in range(WEEKS):
            x0 = k * (CELL + GAP)
            past[y0:y0 + CELL, x0:x0 + CELL] = c
            future[y0:y0 + CELL, x0:x0 + CELL] = hex_rgb(BG) * 0.80 + c * 0.20
            idx[y0:y0 + CELL, x0:x0 + CELL] = r * WEEKS + k
    return past, future, idx


def milestones(birth: _dt.date, events: list) -> list:
    base = [
        (birth + _dt.timedelta(days=10000), '1만 일', 'm'),
        (birth + _dt.timedelta(seconds=10 ** 9), '10억 초', 'm'),
        (birth + _dt.timedelta(days=20000), '2만 일', 'm'),
        (add_years(birth, 50), '인생의 정오 50세', 'm'),
        (birth + _dt.timedelta(days=30000), '3만 일', 'm'),
        (add_years(birth, 100), '100세', 'm'),
    ]
    base += [(d, name, 'e') for d, name in events if birth <= d < add_years(birth, 100)]
    return sorted(base, key=lambda t: t[0])


def kr_num(v: float) -> str:
    if v >= 1e8:
        return '%.1f억' % (v / 1e8)
    if v >= 1e4:
        return '%.1f만' % (v / 1e4)
    return '{:,.0f}'.format(v)


def dday(n: int, long: bool = False) -> str:
    if n == 0:
        return 'D-DAY'
    if n < 0:
        return '지남'
    return 'D-%s' % '{:,}'.format(n) + (' (%.1f년)' % (n / 365.25) if long and n > 365 else '')


def life_clock(frac: float) -> str:
    mins = int(round(frac * 1440)) % 1440 if frac < 1 else 1439
    h, m = divmod(mins, 60)
    return '%s %d:%02d' % ('오전' if h < 12 else '오후', (h % 12) or 12, m)


def count_april(start: _dt.date, end: _dt.date) -> int:
    return sum(1 for y in range(start.year, end.year + 1) if start <= _dt.date(y, 4, 1) < end)


def parents_at(prof: dict, when: _dt.date) -> list:
    dy = (when - prof['today']).days / 365.25
    return [(nm, a + dy) for nm, a in prof['parents']]


def parent_time(p: dict, prof: dict, now: _dt.date) -> dict:
    birth = prof['birth']
    my_age = (now - birth).days / 365.25
    target = float(p['부모님 기준 나이'])
    cyc = max(1.0, float(p['부모님 만남 주기 (주)']))
    hrs = float(p['만날 때 함께하는 시간'])
    t_cyc = max(1.0, float(p['부모님과 여행 주기 (개월)']))
    t_days = float(p['여행 일정 (일)'])
    ps = parents_at(prof, now)
    if not ps:
        return {}
    rem = {nm: max(0.0, target - a) for nm, a in ps}
    rem_max = max(rem.values())
    rem_min = min(rem.values())
    indep = prof['indep']
    living = indep <= 0 or indep > my_age
    if living:
        cut = rem_max if indep <= 0 else min(rem_max, indep - my_age)
        meet_h = cut * 365.25 * TOGETHER_H_PER_DAY + max(0.0, rem_max - cut) * 52.0 / cyc * hrs
        past_h = my_age * 365.25 * TOGETHER_H_PER_DAY
    else:
        meet_h = rem_max * 52.0 / cyc * hrs
        past_h = indep * 365.25 * TOGETHER_H_PER_DAY + (my_age - indep) * 52.0 / cyc * hrs
    trips = rem_max * 12.0 / t_cyc
    trip_days = trips * t_days
    fut_h = meet_h + trip_days * TRIP_H_PER_DAY
    return {
        'ages': ps, 'rem': rem, 'rem_max': rem_max,
        'visits': {nm: r * 52.0 / cyc for nm, r in rem.items()},
        'v_any': rem_max * 52.0 / cyc, 'v_both': rem_min * 52.0 / cyc,
        'meet_h': meet_h, 'fut_h': fut_h, 'used': past_h / max(1e-9, past_h + fut_h),
        'trips': trips, 'trips_both': rem_min * 12.0 / t_cyc, 'trip_days': trip_days,
        'holidays': int(round(2 * rem_max)), 'cyc': cyc, 'hrs': hrs, 't_cyc': t_cyc, 't_days': t_days,
        'target': target, 'living': living,
    }


def time_budget(p: dict, now: _dt.date, birth: _dt.date, pt: dict) -> dict:
    d100 = add_years(birth, 100)
    rem_days = max(0, (d100 - now).days)
    ret = add_years(birth, int(p['은퇴 나이']))
    hrs = {
        '수면': p['하루 수면 (시간)'] * rem_days,
        '일': p['하루 일 (시간, 평일)'] * max(0, (ret - now).days) * P_WORKDAYS,
        '스마트폰': p['하루 스마트폰 (시간)'] * rem_days,
        '생활': LIFE_H_PER_DAY * rem_days,
    }
    free = max(0.0, rem_days * 24.0 - sum(hrs.values()))
    hrs['부모님과'] = min(free, pt.get('fut_h', 0.0))
    hrs['내 시간'] = free - hrs['부모님과']
    return {k: v / 8766.0 for k, v in hrs.items()}


def card(ax, x: float, y: float, w: float, h: float, big: str, small: str, color: str = INK, size: float = 11) -> None:
    ax.add_patch(FancyBboxPatch((x, y), w, h, boxstyle='round,pad=0,rounding_size=0.008',
                                mutation_aspect=1.5, fc=CARD, ec='none', transform=ax.transAxes))
    ax.text(x + w / 2, y + h * 0.62, big, ha='center', va='center', fontsize=fs(size), fontweight='bold', color=color, transform=ax.transAxes)
    ax.text(x + w / 2, y + h * 0.24, small, ha='center', va='center', fontsize=fs(6.8), color=SUB, transform=ax.transAxes)
    return None


def draw_grid(gax, prof: dict, now: _dt.date, pt: dict, art: dict) -> None:
    birth = prof['birth']
    past, future, idx = prof['layers']
    gax.cla()
    gax.set_facecolor(BG)
    rc = week_pos(birth, now)
    ni = rc[0] * WEEKS + rc[1]
    img = np.where((idx < ni)[:, :, None], past, future)
    img[idx == ni] = 1.0
    gax.imshow(img, interpolation='nearest', origin='upper')
    H, W = img.shape[:2]
    gax.set_xlim(-34, W + 40)
    gax.set_ylim(H + 26, -14)
    gax.set_axis_off()
    k2 = UI['s'] ** 2
    for dec in range(0, 100, 10):
        gax.text(-4, cell_xy(dec, 0)[1], '%d' % dec, ha='right', va='center', fontsize=fs(7), color=SUB)
    for a0, a1, sname, _, col in SEASONS:
        ym = (cell_xy(a0, 0)[1] + cell_xy(a1 - 1, 0)[1]) / 2
        gax.text(-22, ym, sname, ha='center', va='center', fontsize=fs(10), color=col, fontweight='bold', rotation=90)
    for d, label, kind in prof['ms']:
        x, y = cell_xy(*week_pos(birth, d))
        if kind == 'm':
            gax.scatter([x], [y], marker='*', s=46 * k2, color='#FFFFFF' if d <= now else GOLD, edgecolors=BG, linewidths=0.4, zorder=5)
        else:
            gax.scatter([x], [y], marker='D', s=16 * k2, color=PINK, edgecolors=BG, linewidths=0.4, zorder=5)
    for d, label, days in prof['trips']:
        if birth <= d < add_years(birth, 100):
            x, y = cell_xy(*week_pos(birth, d))
            gax.scatter([x], [y], marker='^', s=22 * k2, color=MINT if d >= now else '#FFFFFF', edgecolors=BG, linewidths=0.4, zorder=5)
    if pt and pt['rem_max'] > 0:
        r_end = min(YEARS - 1, int(rc[0] + pt['rem_max']))
        y0, y1 = cell_xy(rc[0], 0)[1], cell_xy(r_end, 0)[1] + CELL / 2
        gax.plot([W + 3, W + 3], [y0, y1], color=PINK, lw=2.2 * UI['s'], solid_capstyle='round', alpha=0.9)
        gax.plot([W + 1, W + 3], [y1, y1], color=PINK, lw=2.2 * UI['s'], alpha=0.9)
        if y1 - y0 > 40:
            gax.text(W + 15, (y0 + y1) / 2 + 10, '부모님과 %.0f년' % pt['rem_max'], rotation=270,
                     ha='center', va='center', fontsize=fs(7.5), color=PINK)
    x, y = cell_xy(*rc)
    art['glow'] = gax.scatter([x], [y], marker='s', s=90 * k2, facecolors='none', edgecolors=GOLD, linewidths=1.4, zorder=6)
    gax.text(W + 7, y, '◀ 지금' if now == prof['today'] else '◀ 여기', ha='left', va='center', fontsize=fs(8), color=GOLD, fontweight='bold')
    gax.text(W / 2, -8, '%s의 100년 · 5,200주' % prof['name'], ha='center', va='center', fontsize=fs(12), color=INK, fontweight='bold')
    art['hover_mark'] = gax.scatter([np.nan], [np.nan], marker='s', s=60 * k2, facecolors='none', edgecolors='#FFFFFF', linewidths=1.2, zorder=7)
    art['hover_txt'] = gax.text(W / 2, H + 9, '달력에 마우스를 올려 보세요 · 클릭하면 그 주로 이동', ha='center', va='center', fontsize=fs(7.5), color=SUB)
    art['hover_txt2'] = gax.text(W / 2, H + 20, '', ha='center', va='center', fontsize=fs(7.5), color=PINK)
    return None


def draw_clock(cax, frac: float, playing: bool) -> None:
    cax.cla()
    cax.set_facecolor('none')
    cax.set_xlim(-1.25, 1.25)
    cax.set_ylim(-1.3, 1.25)
    cax.set_aspect('equal')
    cax.set_axis_off()
    for h0, h1, col in [(0, 6, '#1B2547'), (6, 18, '#2C3E73'), (18, 24, '#1B2547')]:
        cax.add_patch(Wedge((0, 0), 1.0, 90 - h1 * 15, 90 - h0 * 15, width=0.22, color=col))
    if frac > 0:
        cax.add_patch(Wedge((0, 0), 1.0, 90 - frac * 360, 90, width=0.22, color=GOLD, alpha=0.85))
    cax.add_patch(Circle((0, 0), 0.74, color=CARD))
    for h in range(0, 24, 3):
        a = np.radians(90 - h * 15)
        cax.plot([0.62 * np.cos(a), 0.70 * np.cos(a)], [0.62 * np.sin(a), 0.70 * np.sin(a)], color=SUB, lw=1)
    a = np.radians(90 - frac * 360)
    cax.plot([0, 0.60 * np.cos(a)], [0, 0.60 * np.sin(a)], color=GOLD, lw=2.4 * UI['s'], solid_capstyle='round')
    cax.add_patch(Circle((0, 0), 0.06, color=GOLD))
    cax.text(0, 1.13, '탄생', ha='center', va='center', fontsize=fs(6.3), color=SUB)
    cax.text(0, -1.17, '■ 정지' if playing else '▶ 클릭: 인생 재생', ha='center', va='center', fontsize=fs(6.3),
             color=GOLD if playing else SUB)
    return None


def draw_budget(bax, yrs: dict) -> None:
    bax.cla()
    bax.set_facecolor('none')
    total = sum(yrs.values())
    left = 0.0
    for k in USE_ORDER:
        v = yrs.get(k, 0)
        if v <= 0:
            continue
        bax.barh([0], [v], left=left, color=USE_COLORS[k], height=0.84, edgecolor=BG, linewidth=1.2)
        if total > 0 and v / total > 0.06:
            dark = k in ('내 시간', '부모님과')
            bax.text(left + v / 2, 0, '%s  %.1f년' % (k, v), ha='center', va='center', fontsize=fs(7.4),
                     color=BG if dark else INK, fontweight='bold' if dark else 'normal')
        left += v
    bax.set_xlim(0, max(total, 1e-6))
    bax.set_ylim(-0.5, 0.5)
    bax.set_axis_off()
    return None


def dot_field(axx, n_any: int, n_both: int, cols: int, color: str, marker: str, size: float, min_rows: int) -> None:
    axx.cla()
    axx.set_facecolor('none')
    axx.set_axis_off()
    if n_any > 0:
        i = np.arange(n_any)
        rgba = np.zeros((n_any, 4))
        rgba[:, :3] = hex_rgb(color)
        rgba[:, 3] = np.where(i < n_both, 1.0, 0.40)
        axx.scatter(i % cols, -(i // cols), s=size * UI['s'] ** 2, c=rgba, marker=marker, linewidths=0)
    rows = max(min_rows, int(math.ceil(max(n_any, 1) / cols)))
    axx.set_xlim(-1, cols)
    axx.set_ylim(-rows + 0.3, 0.7)
    return None


def draw_parents(ax, pax, pt: dict, X: float) -> None:
    ax.text(X, 0.600, '부모님과 남은 시간', fontsize=fs(10), color=PINK, fontweight='bold', va='center')
    if not pt or pt['rem_max'] <= 0:
        msg = ('부모님 나이를 입력하면\n남은 만남과 함께할 시간을\n보여 줍니다.' if not pt else
               '부모님 기준 나이(%.0f세)를 지난 시점이에요.\n슬라이더에서 기준 나이를 조정해 보세요.' % pt['target'])
        ax.text(X, 0.50, msg, fontsize=fs(8.3), color=SUB, va='center', linespacing=1.6)
        dot_field(pax, 0, 0, 30, PINK, 'o', 14, 8)
        return None
    if pt['living']:
        how = '지금은 함께 사는 중 · 따로 산 뒤 %.0f주마다 %s시간' % (pt['cyc'], '{:,.0f}'.format(pt['hrs']))
    else:
        how = '%.0f주마다 %s시간씩 · %.0f세 기준' % (pt['cyc'], '{:,.0f}'.format(pt['hrs']), pt['target'])
    ax.text(X, 0.573, how, fontsize=fs(7.3), color=SUB, va='center')
    ax.text(X, 0.527, '앞으로 약 %s번' % '{:,}'.format(int(pt['v_any'])), fontsize=fs(18), color=PINK, fontweight='bold', va='center')
    ax.text(X, 0.482, '함께할 시간 약 %s일 (여행 포함)' % '{:,}'.format(int(pt['fut_h'] / 24.0)), fontsize=fs(9.2),
            color=INK, fontweight='bold', va='center')
    ax.text(X, 0.455, ' · '.join('%s %s번' % (nm, '{:,}'.format(int(v))) for nm, v in pt['visits'].items()),
            fontsize=fs(7.3), color=SUB, va='center')
    cols, max_dots = 30, 240
    v_any = pt['v_any']
    unit = max(1, int(math.ceil(v_any / max_dots))) if v_any > 0 else 1
    n_any = int(math.ceil(v_any / unit)) if v_any > 0 else 0
    dot_field(pax, n_any, int(math.floor(pt['v_both'] / unit)), cols, PINK, 'o', 14, 8)
    note = '● 한 점 = 만남 %d번' % unit + (' · 흐린 점 = 한 분만 계신 시기' if pt['v_both'] < v_any - 0.5 else '')
    ax.text(X, 0.148, note, fontsize=fs(6.8), color=SUB, va='center')
    used = pt['used']
    W = 0.30
    ax.text(X, 0.112, '평생 함께할 시간 중 이미 %.0f%%를 썼어요' % (used * 100), fontsize=fs(8.2), color=PINK, fontweight='bold', va='center')
    ax.add_patch(Rectangle((X, 0.072), W, 0.016, fc=CARD, ec='none', transform=ax.transAxes))
    ax.add_patch(Rectangle((X, 0.072), W * used, 0.016, fc=PINK, ec='none', alpha=0.85, transform=ax.transAxes))
    ax.text(X + W, 0.045, '남은 %.0f%%' % ((1 - used) * 100), fontsize=fs(7.3), color=INK, va='center', ha='right')
    return None


def draw_trips(ax, tax, pt: dict, prof: dict, now: _dt.date, X: float) -> None:
    ax.text(X, 0.600, '부모님과 남은 여행', fontsize=fs(10), color=MINT, fontweight='bold', va='center')
    plans = prof['trips']
    fut = [t for t in plans if t[0] >= now]
    if pt and pt['rem_max'] > 0:
        ax.text(X, 0.573, '%.0f개월마다 %s일 여행 · %.0f세 기준' % (pt['t_cyc'], '{:,.0f}'.format(pt['t_days']), pt['target']),
                fontsize=fs(7.3), color=SUB, va='center')
        ax.text(X, 0.527, '앞으로 약 %s번' % '{:,}'.format(int(pt['trips'])), fontsize=fs(18), color=MINT, fontweight='bold', va='center')
        ax.text(X, 0.482, '함께할 여행 약 %s일' % '{:,}'.format(int(pt['trip_days'])), fontsize=fs(9.2), color=INK,
                fontweight='bold', va='center')
        n = int(math.ceil(pt['trips']))
        unit = max(1, int(math.ceil(n / 60.0)))
        dot_field(tax, int(math.ceil(n / unit)), int(math.floor(pt['trips_both'] / unit)), 20, MINT, '^', 26, 3)
        ax.text(X, 0.455, '▲ 한 개 = 여행 %d번' % unit, fontsize=fs(7.3), color=SUB, va='center')
    else:
        ax.text(X, 0.52, '부모님 나이를 입력하면\n남은 여행 횟수를 보여 줍니다.', fontsize=fs(8.5), color=SUB, va='center', linespacing=1.6)
        dot_field(tax, 0, 0, 20, MINT, '^', 26, 3)
    ax.text(X, 0.300, '여행 계획', fontsize=fs(9.2), color=INK, fontweight='bold', va='center')
    if fut:
        nd, nm, ndays = fut[0]
        ax.text(X + 0.30, 0.300, '다음 여행 %s' % dday((nd - now).days), fontsize=fs(9.2), color=MINT, fontweight='bold',
                va='center', ha='right')
    if not plans:
        ax.text(X, 0.225, '처음 입력 창의 "부모님과 여행 계획"에\n날짜 · 장소 · 일수를 넣으면\n여기에 D-day가 표시됩니다.',
                fontsize=fs(7.6), color=SUB, va='center', linespacing=1.6)
        return None
    past = [t for t in plans if t[0] < now]
    shown = (past[-1:] + fut)[:7]
    for i, (d, name, days) in enumerate(shown):
        y = 0.268 - i * 0.031
        done = d < now
        ax.text(X, y, '▲', fontsize=fs(7.5), color=SUB if done else MINT, va='center')
        ax.text(X + 0.022, y, '%s · %d일' % (name, days), fontsize=fs(7.8), color=SUB if done else INK, va='center')
        ax.text(X + 0.215, y, d.strftime('%y.%m.%d'), fontsize=fs(7.2), color=SUB, va='center', ha='right')
        ax.text(X + 0.30, y, '다녀옴' if done else dday((d - now).days), fontsize=fs(7.4), color=SUB if done else MINT,
                va='center', ha='right')
    return None


def draw_panel(ax, p: dict, prof: dict, now: _dt.date, pt: dict, art: dict) -> dict:
    birth = prof['birth']
    ax.set_position([0.345, 0.0, 0.64, 1.0])
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_axis_off()
    days = max(0, (now - birth).days)
    frac = min(1.0, days / float((add_years(birth, 100) - birth).days))
    age, wk = week_pos(birth, now)
    season = season_of(age)
    dy = (now - prof['today']).days / 365.25
    tag = ('   · %d년 뒤의 나' % round(dy)) if dy > 0.5 else (('   · %d년 전의 나' % round(-dy)) if dy < -0.5 else '')
    ax.text(0.16, 0.968, '인생을 하루로 치면, 지금은' + tag, fontsize=fs(8.8), color=SUB, va='center')
    ax.text(0.16, 0.928, life_clock(frac), fontsize=fs(22), color=GOLD, fontweight='bold', va='center')
    ax.text(0.16, 0.886, '%s · %d세 %d주차 · 100년 중 %.1f%% · 인생의 %s (%s)' % (
        now.strftime('%Y.%m.%d'), age, wk + 1, frac * 100, season[2], season[3]), fontsize=fs(8.1), color=INK, va='center')
    ax.text(1.0, 0.968, '지금 이 순간에도', fontsize=fs(7.3), color=SUB, va='center', ha='right')
    art['live_s'] = ax.text(1.0, 0.938, '', fontsize=fs(12), color=INK, fontweight='bold', va='center', ha='right')
    art['live_h'] = ax.text(1.0, 0.907, '', fontsize=fs(7.8), color=PINK, va='center', ha='right')

    ax.text(0.0, 0.848, '지금까지', fontsize=fs(10), color=INK, fontweight='bold', va='center')
    stats = [
        ('{:,}일'.format(days), '살아온 날'),
        (kr_num(days * 1440 * 72.0) + '번', '심장이 뛴 횟수'),
        (kr_num(days * 1440 * 16.0) + '번', '숨 쉰 횟수'),
        ('%d번' % int(days / 29.530589), '본 보름달'),
        (kr_num(days / 365.25 * 9.4e8) + 'km', '태양 주위를 달린 거리'),
        (kr_num(days * 3.0) + '끼', '먹은 밥'),
    ]
    cw, gx = (1 - 5 * 0.012) / 6, 0.012
    for i, (big, small) in enumerate(stats):
        card(ax, i * (cw + gx), 0.762, cw, 0.064, big, small, INK, 10.5)

    yrs = time_budget(p, now, birth, pt)
    ax.text(0.0, 0.727, '100세까지 남은 %.1f년, 어디에 쓰일까' % sum(yrs.values()), fontsize=fs(10), color=INK, fontweight='bold', va='center')
    ax.text(1.0, 0.727, '온전히 내 것인 시간  %.1f년' % yrs['내 시간'], fontsize=fs(10), color=GOLD, fontweight='bold', va='center', ha='right')

    XC = 0.665
    d100 = add_years(birth, 100)
    ret = add_years(birth, int(p['은퇴 나이']))
    rem = max(0, (d100 - now).days)
    counters = [
        ('{:,}'.format(rem // 7), '남은 주말', INK),
        ('%d' % count_april(now, d100), '남은 벚꽃 계절', INK),
        (('%d' % pt['holidays']) if pt else '-', '부모님과 남은 명절', PINK),
        ('{:,}'.format(max(0, (ret - now).days) // 7), '은퇴까지 남은 월요일', GOLD),
    ]
    ax.text(XC, 0.600, '남은 것들', fontsize=fs(10), color=INK, fontweight='bold', va='center')
    for i, (big, small, col) in enumerate(counters):
        card(ax, XC + (i % 2) * 0.172, 0.500 - (i // 2) * 0.078, 0.163, 0.070, big, small, col, 12)

    ax.text(XC, 0.378, '이정표', fontsize=fs(10), color=INK, fontweight='bold', va='center')
    ms = prof['ms']
    past = [m for m in ms if m[0] <= now]
    fut = [m for m in ms if m[0] > now]
    shown = (past[-2:] + fut)[:10]
    for i, (d, label, kind) in enumerate(shown):
        y = 0.345 - i * 0.031
        done = d <= now
        scol = SUB if done else (GOLD if kind == 'm' else PINK)
        ax.text(XC, y, '★' if kind == 'm' else '◆', fontsize=fs(8), color=scol, va='center')
        ax.text(XC + 0.022, y, label, fontsize=fs(7.8), color=SUB if done else INK, va='center')
        ax.text(XC + 0.225, y, d.strftime('%y.%m.%d'), fontsize=fs(7.2), color=SUB, va='center', ha='right')
        ax.text(1.0, y, '지남' if done else dday((d - now).days), fontsize=fs(7.4), color=SUB if done else GOLD, va='center', ha='right')
    return {'frac': frac, 'age': age, 'week': wk + 1, 'days': days, 'yrs': yrs, 'season': season[2]}


def update_live(art: dict, prof: dict, now: _dt.date) -> None:
    shift = _dt.timedelta(days=(now - prof['today']).days)
    t = _dt.datetime.now() + shift
    sec = max(0.0, (t - _dt.datetime.combine(prof['birth'], _dt.time())).total_seconds())
    if 'live_s' in art:
        art['live_s'].set_text('살아온 %s초' % '{:,}'.format(int(sec)))
        art['live_h'].set_text('♥ 심장은 %s번째 뛰는 중' % '{:,}'.format(int(sec * 1.2)))
    return None


def hover_info(prof: dict, rc: tuple, now: _dt.date) -> tuple:
    birth = prof['birth']
    d = cell_date(birth, rc)
    s = season_of(rc[0])
    n = (d - now).days
    when = '이번 주' if abs(n) < 7 else ('%s년 뒤' % '{:.1f}'.format(n / 365.25) if n > 365 else
                                        ('D-%d' % n if n > 0 else ('%s년 전' % '{:.1f}'.format(-n / 365.25) if n < -365 else '%d일 전' % -n)))
    line1 = '%s 주 · %d세 %d주차 · 인생의 %s · %s' % (d.strftime('%Y.%m.%d'), rc[0], rc[1] + 1, s[2], when)
    tags = ['★ ' + label for md, label, _ in prof['ms'] if week_pos(birth, md) == rc]
    tags += ['▲ %s %d일' % (nm, dd) for td, nm, dd in prof['trips'] if week_pos(birth, td) == rc]
    ps = ['%s %d세' % (nm, int(a)) for nm, a in parents_at(prof, d) if 0 <= a]
    return line1, ' · '.join(tags + ps)


# ── 움직이는 계절 배경 (배경색 픽셀에만 그려서 글자·카드 뒤로 지나가게 함) ─────────────

SEASON_FX = {
    '봄': {'n': 42, 'colors': ['#FFB7C5', '#FFC8D6', '#FFD9E3'], 'size': (4.5, 7.5)},
    '여름': {'n': 46, 'colors': ['#D9FF73', '#F4FF9A', '#B8FF6A'], 'size': (2.2, 3.6)},
    '가을': {'n': 32, 'colors': ['#FF8A3D', '#E0592A', '#FFB74D', '#C9763A'], 'size': (5.5, 8.5)},
    '겨울': {'n': 90, 'colors': ['#FFFFFF', '#E6EEFF'], 'size': (1.3, 3.2)},
}


def fx_init(season: str, rng) -> dict:
    cfg = SEASON_FX[season]
    n = cfg['n']
    return {
        'season': season, 'n': n,
        'x': rng.random(n), 'y': rng.random(n),
        'vx': rng.normal(0, 0.0006, n), 'vy': rng.uniform(0.6, 1.0, n),
        'size': rng.uniform(cfg['size'][0], cfg['size'][1], n),
        'ph': rng.uniform(0, 2 * np.pi, n), 'rot': rng.uniform(0, np.pi, n),
        'col': np.array([hex_rgb(cfg['colors'][i % len(cfg['colors'])]) for i in rng.integers(0, 99, n)]),
        't': 0.0, 'rng': rng,
    }


def fx_step(fx: dict) -> None:
    s, t, rng, n = fx['season'], fx['t'], fx['rng'], fx['n']
    fx['t'] = t + 1.0
    if s == '여름':
        fx['vx'] = np.clip(fx['vx'] + rng.normal(0, 0.00025, n), -0.0016, 0.0016)
        vy = np.clip(np.sin(t * 0.03 + fx['ph']) * 0.0009 - 0.00025, -0.0016, 0.0016)
        fx['x'] = (fx['x'] + fx['vx']) % 1.0
        fx['y'] = (fx['y'] + vy) % 1.0
        return None
    speed = {'봄': 0.0020, '가을': 0.0026, '겨울': 0.0016}[s]
    sway = {'봄': 0.0016, '가을': 0.0024, '겨울': 0.0007}[s]
    fx['y'] = fx['y'] + fx['vy'] * speed
    fx['x'] = fx['x'] + np.sin(t * 0.045 + fx['ph']) * sway + 0.0004
    fx['rot'] = fx['rot'] + 0.04 * np.sin(fx['ph'] + t * 0.02) + (0.05 if s == '가을' else 0.02)
    out = fx['y'] > 1.03
    if out.any():
        fx['y'][out] = -0.03
        fx['x'][out] = rng.random(int(out.sum()))
    fx['x'] = fx['x'] % 1.0
    return None


def fx_stamp(buf: np.ndarray, base: np.ndarray, mask: np.ndarray, fx: dict, scale: float) -> list:
    H, W = mask.shape
    s = fx['season']
    wins = []
    for i in range(fx['n']):
        r = fx['size'][i] * scale
        R = int(math.ceil(r * (3.0 if s == '여름' else 1.6))) + 1
        cx, cy = fx['x'][i] * W, fx['y'][i] * H
        x0, x1 = max(0, int(cx) - R), min(W, int(cx) + R + 1)
        y0, y1 = max(0, int(cy) - R), min(H, int(cy) + R + 1)
        if x1 <= x0 or y1 <= y0:
            continue
        yy, xx = np.mgrid[y0:y1, x0:x1]
        dx, dy = xx - cx, yy - cy
        if s == '여름':
            blink = 0.5 + 0.5 * math.sin(fx['t'] * 0.09 + fx['ph'][i])
            a = (0.95 * np.exp(-(dx * dx + dy * dy) / (2 * (0.55 * r) ** 2)) +
                 0.45 * np.exp(-(dx * dx + dy * dy) / (2 * (1.9 * r) ** 2))) * blink
        elif s == '겨울':
            a = 0.85 * np.exp(-(dx * dx + dy * dy) / (2 * (0.6 * r) ** 2))
        else:
            c, sn = math.cos(fx['rot'][i]), math.sin(fx['rot'][i])
            u, v = dx * c + dy * sn, -dx * sn + dy * c
            e = (u / r) ** 2 + (v / (r * (0.55 if s == '봄' else 0.42))) ** 2
            a = np.clip(1.25 - e, 0, 1) * (0.80 if s == '봄' else 0.88)
        a = (a * mask[y0:y1, x0:x1])[:, :, None]
        cur = buf[y0:y1, x0:x1, :3].astype(np.float32)
        buf[y0:y1, x0:x1, :3] = (cur * (1 - a) + fx['col'][i] * 255.0 * a).astype(np.uint8)
        wins.append((y0, y1, x0, x1))
    return wins


def ask_profile() -> dict:
    got = mIO.form([
        {"type": "section", "text": "── 나 ──"},
        {"type": "text", "label": "이름", "default": "나"},
        {"type": "text", "label": "생년월일 (YYYY-MM-DD)", "default": "1990-01-01"},
        {"type": "text", "label": "나의 기념일 (선택)", "default": ""},
        {"type": "section", "text": "예) 2015-03-02 입사; 2020-10-10 결혼"},
        {"type": "section", "text": "── 부모님 (0 = 생략) ──"},
        {"type": "spin", "label": "아버지 현재 나이", "min": 0, "max": 110, "default": 0, "step": 1},
        {"type": "spin", "label": "어머니 현재 나이", "min": 0, "max": 110, "default": 0, "step": 1},
        {"type": "spin", "label": "부모님과 따로 살기 시작한 나이 (아직 함께 살면 0)", "min": 0, "max": 99, "default": 20, "step": 1},
        {"type": "text", "label": "부모님과 여행 계획 (선택)", "default": ""},
        {"type": "section", "text": "예) 2026-12-20 제주도 3; 2027-05-01 오사카 4   (날짜 장소 일수)"},
    ], title="인생 100년 달력", width=600)
    if got is None:
        return {}
    try:
        birth = parse_date(got.get("생년월일 (YYYY-MM-DD)", ""))
    except Exception:
        mIO.print("[에러] 생년월일을 YYYY-MM-DD 형식으로 입력하세요.")
        return {}
    today = _dt.date.today()
    if birth > today or birth < add_years(today, -99):
        mIO.print("[에러] 생년월일은 오늘 기준 99년 이내 과거여야 합니다.")
        return {}
    parents = [(nm, float(got.get(k) or 0)) for nm, k in (('아버지', "아버지 현재 나이"), ('어머니', "어머니 현재 나이"))]
    return {
        'name': (got.get("이름") or "나").strip() or "나",
        'birth': birth, 'today': today, 'parents': [(nm, a) for nm, a in parents if a > 0],
        'indep': float(got.get("부모님과 따로 살기 시작한 나이 (아직 함께 살면 0)") or 0),
        'ms': milestones(birth, parse_events(got.get("나의 기념일 (선택)", ""))),
        'trips': parse_trips(got.get("부모님과 여행 계획 (선택)", "")),
        'layers': grid_layers(),
    }


def maximize_window(fig) -> None:
    try:
        from matplotlib.backends.qt_compat import QtCore
        w = fig.canvas.window()
        w.setMinimumWidth(600)
        w.setMaximumSize(16777215, 16777215)
        Q = QtCore.Qt.WindowType
        w.setWindowFlags(w.windowFlags() | Q.WindowMaximizeButtonHint | Q.WindowMinimizeButtonHint | Q.WindowSystemMenuHint)
        QtCore.QTimer.singleShot(0, w.showMaximized)
    except Exception:
        pass
    return None


def run_life_calendar() -> pd.DataFrame:
    plt.rcParams['font.family'] = 'Malgun Gothic'
    plt.rcParams['axes.unicode_minus'] = False
    prof = ask_profile()
    if not prof:
        return pd.DataFrame()
    birth, today = prof['birth'], prof['today']
    d_last = add_years(birth, 100) - _dt.timedelta(days=1)
    max_travel = max(0, 99 - (today.year - birth.year))
    rng = np.random.default_rng()
    bg_rgb = (hex_rgb(BG) * 255).round().astype(np.uint8)
    st = {'axes': None, 'ax': None, 'p': None, 'pin': None, 'play': None, 'alive': True,
          'timer': None, 'hover': None, 'art': {}, 'out': None, 'beat': 0, 'frame': 0,
          'fx': None, 'base': None, 'mask': None, 'wins': [], 'size': None}

    def view_date() -> _dt.date:
        if st['play'] is not None:
            return min(add_years(birth, int(st['play'])), d_last)
        if st['pin'] is not None:
            return st['pin']
        return min(add_years(today, int(round(st['p']['시간 여행 (년 뒤)']))), d_last)

    def render() -> None:
        gax, cax, bax, pax, tax = st['axes']
        ax = st['ax']
        fig = ax.figure
        w, h = fig.get_size_inches()
        UI['s'] = max(0.6, min(w / BASE_W, h / BASE_H))
        ax.cla()
        now = view_date()
        pt = parent_time(st['p'], prof, now)
        art = {}
        draw_grid(gax, prof, now, pt, art)
        out = draw_panel(ax, st['p'], prof, now, pt, art)
        draw_clock(cax, out['frac'], st['play'] is not None)
        draw_budget(bax, out['yrs'])
        draw_parents(ax, pax, pt, 0.0)
        draw_trips(ax, tax, pt, prof, now, 0.33)
        update_live(art, prof, now)
        if st['fx'] is None or st['fx']['season'] != out['season']:
            st['fx'] = fx_init(out['season'], rng)
        out['pt'] = pt
        st['art'], st['out'], st['hover'] = art, out, None
        return None

    def canvas_alive(fig) -> bool:
        try:
            vis = getattr(fig.canvas, 'isVisible', None)
            return st['alive'] and (vis() if callable(vis) else True)
        except Exception:
            return False

    def fx_frame(advance: bool) -> bool:
        fig = st['ax'].figure
        if st['base'] is None or st['fx'] is None:
            return False
        buf = np.asarray(fig.canvas.buffer_rgba())
        if buf.shape[:2] != st['base'].shape[:2]:
            return False
        for (y0, y1, x0, x1) in st['wins']:
            buf[y0:y1, x0:x1, :3] = st['base'][y0:y1, x0:x1]
        if advance:
            fx_step(st['fx'])
        st['wins'] = fx_stamp(buf, st['base'], st['mask'], st['fx'], buf.shape[0] / 800.0)
        return True

    def on_draw(ev) -> None:
        try:
            fig = st['ax'].figure
            if getattr(ev, 'renderer', None) is not fig.canvas.get_renderer():
                return
            buf = np.asarray(fig.canvas.buffer_rgba())
            st['base'] = buf[:, :, :3].copy()
            st['mask'] = np.all(st['base'] == bg_rgb, axis=2).astype(np.float32)
            st['wins'] = []
            fx_frame(False)
        except Exception:
            st['base'] = None
        return None

    def on_tick() -> None:
        fig = st['ax'].figure
        try:
            if not canvas_alive(fig):
                st['timer'].stop()
                return
            st['frame'] += 1
            if st['play'] is not None:
                if st['frame'] % 3 == 0:
                    st['play'] += 1.0
                    if st['play'] > 100:
                        st['play'] = None
                    render()
                    fig.canvas.draw_idle()
                    return
            elif st['frame'] % int(1000 / FPS_MS) == 0:
                update_live(st['art'], prof, view_date())
                st['beat'] ^= 1
                if 'glow' in st['art']:
                    st['art']['glow'].set_sizes([(150 if st['beat'] else 80) * UI['s'] ** 2])
                    st['art']['glow'].set_alpha(0.45 if st['beat'] else 1.0)
                fig.canvas.draw_idle()
                return
            if fx_frame(True):
                try:
                    fig.canvas.blit(fig.bbox)
                except Exception:
                    fig.canvas.draw_idle()
        except Exception:
            try:
                st['timer'].stop()
            except Exception:
                pass
        return None

    def on_click(ev) -> None:
        try:
            gax, cax = st['axes'][0], st['axes'][1]
            if ev.inaxes is cax:
                st['play'] = 0.0 if st['play'] is None else None
                st['pin'] = None
                render()
            elif ev.inaxes is gax and st['play'] is None:
                rc = xy_cell(ev.xdata, ev.ydata)
                if rc is None:
                    return
                d = min(cell_date(birth, rc), d_last)
                st['pin'] = None if week_pos(birth, d) == week_pos(birth, today) else d
                render()
            else:
                return
            ev.canvas.draw_idle()
        except Exception:
            pass
        return None

    def on_move(ev) -> None:
        try:
            gax = st['axes'][0]
            art = st['art']
            rc = xy_cell(ev.xdata, ev.ydata) if ev.inaxes is gax else None
            if rc == st['hover'] or 'hover_txt' not in art:
                return
            st['hover'] = rc
            if rc is None:
                art['hover_mark'].set_offsets([[np.nan, np.nan]])
                art['hover_txt'].set_text('달력에 마우스를 올려 보세요 · 클릭하면 그 주로 이동')
                art['hover_txt'].set_color(SUB)
                art['hover_txt2'].set_text('')
            else:
                l1, l2 = hover_info(prof, rc, view_date())
                art['hover_mark'].set_offsets([list(cell_xy(*rc))])
                art['hover_txt'].set_text(l1)
                art['hover_txt'].set_color(INK)
                art['hover_txt2'].set_text(l2)
            ev.canvas.draw_idle()
        except Exception:
            pass
        return None

    def on_resize(ev) -> None:
        try:
            if st['axes'] is not None and st['p'] is not None:
                render()
        except Exception:
            pass
        return None

    def plot_func(p: dict, ax) -> None:
        fig = ax.figure
        st['ax'], st['p'], st['pin'], st['play'] = ax, p, None, None
        if st['axes'] is None:
            try:
                fig.set_layout_engine('none')
            except Exception:
                fig.set_tight_layout(False)
            fig.patch.set_facecolor(BG)
            st['axes'] = (fig.add_axes([0.01, 0.02, 0.33, 0.96]),
                          fig.add_axes([0.345, 0.862, 0.085, 0.128]),
                          fig.add_axes([0.345, 0.648, 0.64, 0.052]),
                          fig.add_axes([0.345, 0.168, 0.30 * 0.64, 0.265]),
                          fig.add_axes([0.345 + 0.33 * 0.64, 0.330, 0.30 * 0.64, 0.105]))
            fig.canvas.mpl_connect('button_press_event', on_click)
            fig.canvas.mpl_connect('motion_notify_event', on_move)
            fig.canvas.mpl_connect('draw_event', on_draw)
            fig.canvas.mpl_connect('resize_event', on_resize)
            maximize_window(fig)
            try:
                tm = fig.canvas.new_timer(interval=FPS_MS)
                tm.add_callback(on_tick)
                tm.start()
                st['timer'] = tm
            except Exception:
                st['timer'] = None
        render()
        return None

    mIO.interactive_plot(
        params=[
            {"label": "하루 수면 (시간)", "min": 4, "max": 10, "default": 7, "step": 0.5, "decimals": 1},
            {"label": "하루 일 (시간, 평일)", "min": 0, "max": 14, "default": 9, "step": 0.5, "decimals": 1},
            {"label": "은퇴 나이", "min": 40, "max": 85, "default": 65, "step": 1, "decimals": 0},
            {"label": "하루 스마트폰 (시간)", "min": 0, "max": 10, "default": 3, "step": 0.5, "decimals": 1},
            {"label": "시간 여행 (년 뒤)", "min": 0, "max": max_travel, "default": 0, "step": 1, "decimals": 0},
            {"label": "부모님 만남 주기 (주)", "min": 1, "max": 52, "default": 4, "step": 1, "decimals": 0},
            {"label": "만날 때 함께하는 시간", "min": 1, "max": 99999, "default": 5, "step": 1, "decimals": 0},
            {"label": "부모님 기준 나이", "min": 70, "max": 110, "default": 90, "step": 1, "decimals": 0},
            {"label": "부모님과 여행 주기 (개월)", "min": 1, "max": 120, "default": 12, "step": 1, "decimals": 0},
            {"label": "여행 일정 (일)", "min": 1, "max": 9999, "default": 3, "step": 1, "decimals": 0},
        ],
        plot_func=plot_func,
        title="인생 100년 달력 — " + prof['name'],
        layout="horizontal",
        fig_width=11,
        fig_height=8,
        width=1300,
    )
    st['alive'] = False
    o = st['out'] or {}
    y = o.get('yrs', {})
    pt = o.get('pt') or {}
    nxt = [t for t in prof['trips'] if t[0] >= today]
    return pd.DataFrame([{
        "이름": prof['name'], "생년월일": birth.isoformat(),
        "나이": o.get('age'), "주차": o.get('week'), "살아온_날": o.get('days'),
        "인생_진행률_%": round(o.get('frac', 0) * 100, 2),
        "인생시계": life_clock(o.get('frac', 0)),
        "남은_내시간_년": round(y.get('내 시간', 0), 1),
        "부모님_남은_만남_회": int(pt.get('v_any', 0)),
        "부모님과_남은_여행_회": int(pt.get('trips', 0)),
        "부모님과_남은_여행_일": int(pt.get('trip_days', 0)),
        "부모님과_남은_시간_일": round(pt.get('fut_h', 0) / 24.0, 1),
        "부모님과_시간_사용률_%": round(pt.get('used', 0) * 100, 1),
        "다음_여행": ('%s %s (D-%d)' % (nxt[0][0].isoformat(), nxt[0][1], (nxt[0][0] - today).days)) if nxt else '',
    }])


result = run_life_calendar()

댓글