feat: 자산 차트보기에 연도별·월별 절대 선택 추가

기간 드롭다운에 '연도별'(연도 선택→월별 집계)·'월별'(연도+월 선택→일별) 추가.
- _available_chart_periods(): 스냅샷에서 데이터 있는 연/월만 추출(드롭다운 채움)
- _load_net_worth_series(period=): 'YYYY'/'YYYY-MM' 절대 필터
- _render_summary_panel: chart_ym → 단위 강제(연도별=월별집계/월별=일별)
- _render_chart_controls: 연·월 보조 드롭다운 + months map(JS용) + period 시 단위 disable
- chart_ym 파라미터 체인·캐시키·GET 핸들러(형식검증) 전반 연결
- JS: chartYM 저장/전송 + 연도별/월별/연·월 셀렉터 핸들러
- CSS: .cc-ym[hidden] 숨김(flex가 [hidden] UA스타일 덮지 않도록)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-10 10:42:25 +09:00
parent f8cd71713b
commit 7bb07ea3ad
+136 -34
View File
@@ -3152,7 +3152,31 @@ def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: l
return out
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False):
def _available_chart_periods() -> dict:
"""스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞.
반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}"""
empty = {'years': [], 'months': {}}
if not SNAPSHOT_FILE.exists():
return empty
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception:
return empty
years: set = set()
months: dict = {}
for date_key in snap.keys():
if len(date_key) < 7 or date_key[4] != '-':
continue
y, m = date_key[:4], date_key[5:7]
years.add(y)
months.setdefault(y, set()).add(m)
return {
'years': sorted(years, reverse=True),
'months': {y: sorted(ms, reverse=True) for y, ms in months.items()},
}
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''):
"""portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
unit으로 /// 단위 집계 ( 구간 마지막 영업일 ). 기간(days) 필터는 단위 집계 적용.
@@ -3188,7 +3212,10 @@ def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: s
# filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
baseline_prepended = False
if days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
if period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출.
range_start = (period + '-01-01') if len(period) == 4 else (period + '-01')
filtered = [(d, v) for d, v in aggregated if d.startswith(period)]
elif days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
prefix = datetime.now(KST).strftime('%Y-%m')
range_start = f'{prefix}-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
@@ -4954,6 +4981,7 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
/* 차트 컨트롤 자산정보 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; }
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
.chart-controls .cc-ym[hidden] { display: none; } /* 연도별/월별 아닐 · 드롭다운 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
.chart-controls .nw-select {
appearance: none; -webkit-appearance: none;
@@ -5111,17 +5139,42 @@ def _owner_full_title(owner: str) -> str:
return OWNER_LABELS.get(owner, f'{owner} 자산')
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE) -> str:
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select 3개.
자산정보 상단에 1세트만 노출, 본인·가희 차트 동시 컨트롤.
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str:
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select.
기간에 '연도별'(연도 선택월별집계)·'월별'(연도+ 선택일별) 추가. period(chart_ym) 활성 단위는 강제·비활성.
의미없는 단위(이번주+월별 ) disabled."""
avail = avail or {'years': [], 'months': {}}
period = (chart_ym or '').strip()
is_year = len(period) == 4
is_month = len(period) == 7
period_active = is_year or is_month
years = avail.get('years') or []
months_map = avail.get('months') or {}
allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
)
range_opts = ''.join(
f'<option value="{d}"{" selected" if d == selected_days else ""}>{html.escape(lbl)}</option>'
f'<option value="{d}"{" selected" if (not period_active and d == selected_days) else ""}>{html.escape(lbl)}</option>'
for lbl, d in NET_WORTH_CHART_PRESETS
)
range_opts += f'<option value="year"{" selected" if is_year else ""}>연도별</option>'
range_opts += f'<option value="month"{" selected" if is_month else ""}>월별</option>'
cur_year = period[:4] if period_active else (years[0] if years else '')
cur_month = period[5:7] if is_month else ''
year_opts = ''.join(
f'<option value="{y}"{" selected" if y == cur_year else ""}>{html.escape(y)}년</option>'
for y in years
)
month_opts = ''.join(
f'<option value="{m}"{" selected" if m == cur_month else ""}>{int(m)}월</option>'
for m in months_map.get(cur_year, [])
)
_yhide = '' if period_active else ' hidden'
_mhide = '' if is_month else ' hidden'
months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True)
unit_opts = ''.join(
f'<option value="{u}"'
f'{" selected" if u == selected_unit else ""}'
@@ -5133,13 +5186,19 @@ def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode
f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
for lbl, m in NET_WORTH_MODE_PRESETS
)
_unit_dis = ' disabled' if period_active else ''
return (
'<div class="chart-controls">'
f'<div class="chart-controls" data-ym-months="{months_json}">'
f'<label class="cc-field"><span class="cc-label">기간</span>'
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
# 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치.
# 연도별/월별 선택 시 노출되는 연·월 드롭다운 (데이터 있는 기간만).
f'<label class="cc-field cc-ym" data-ym-year-field{_yhide}><span class="cc-label">연도</span>'
f'<select class="nw-select" data-nw-year>{year_opts}</select></label>'
f'<label class="cc-field cc-ym" data-ym-month-field{_mhide}><span class="cc-label">월</span>'
f'<select class="nw-select" data-nw-month>{month_opts}</select></label>'
# 단위·모드 한 cc-field 묶음. period 활성 시 단위 강제(disabled).
f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
f'<select class="nw-select" data-nw-unit>{unit_opts}</select>'
f'<select class="nw-select" data-nw-unit{_unit_dis}>{unit_opts}</select>'
f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
'</div>'
)
@@ -5685,7 +5744,7 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
)
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> str:
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str:
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
@@ -5697,9 +5756,13 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
balances = balances or {}
journal_by_label = journal_by_label or {}
# 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터.
period = (chart_ym or '').strip()
eff_unit = ('day' if len(period) > 4 else 'month') if period else chart_unit
consolidated_parts: list[str] = []
by_account_parts: list[str] = []
chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)]
chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())]
for owner in ordered_owners:
d = owner_data.get(owner)
if not d:
@@ -5718,11 +5781,11 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
has_prev_snap=has_prev_snap,
label_disp=label_disp,
))
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=chart_unit, with_prev_baseline=True)
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=eff_unit, with_prev_baseline=True, period=period)
if chart_series:
# 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
daily_cf = _load_cash_flow_by_date(owner)
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], chart_unit) if daily_cf else {}
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], eff_unit) if daily_cf else {}
# 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
today_cf = None
if d.get('cash_in', 0) or d.get('cash_out', 0):
@@ -5748,7 +5811,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
pnl_series,
current_net=current_pnl,
selected_days=chart_days,
selected_unit=chart_unit,
selected_unit=eff_unit,
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
today_cash_flow=None,
mode=chart_mode,
@@ -5761,7 +5824,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
chart_series,
current_net=d.get('total_net'),
selected_days=chart_days,
selected_unit=chart_unit,
selected_unit=eff_unit,
cash_flow_by_date=cf_by_date,
today_cash_flow=today_cf,
mode='net',
@@ -6190,7 +6253,7 @@ def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'}
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> dict:
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> dict:
"""fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
owner 지정 : 해당 owner 계좌만 호출하고 응답 tabs는 owner 하나만 (summary·wl 제외).
클라이언트가 partial swap으로 사용 보지 않는 owner 호출 폭주 방지.
@@ -6220,7 +6283,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
# 전체 — 모든 탭 채움. 모두 동일 fetched_at.
wl_body, wl_count = _render_watchlist_panel(wl_cards)
in_body, in_count = _render_interests_panel(in_cards)
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
for o in ordered_owners:
d = owner_data[o]
@@ -6487,10 +6550,21 @@ def render_html() -> str:
'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
'return v;'
'}'
# chart_ym — 연도별('YYYY')/월별('YYYY-MM') 절대 선택. 빈값이면 프리셋(chartDays) 사용.
'function chartYM(){'
'var v=localStorage.getItem("behive.chartYM")||"";'
'var p=v.split("-");'
'if(p.length===1&&/^\\d{4}$/.test(p[0]))return v;'
'if(p.length===2&&/^\\d{4}$/.test(p[0])&&/^\\d{2}$/.test(p[1]))return v;'
'return "";'
'}'
'function setYM(v){try{if(v)localStorage.setItem("behive.chartYM",v);else localStorage.removeItem("behive.chartYM");}catch(_){}}'
'window.__behive_chart_days=chartDays;'
'window.__behive_chart_unit=chartUnit;'
'window.__behive_chart_mode=chartMode;'
'window.__behive_adr_days=adrDays;'
'window.__behive_chart_ym=chartYM;'
'window.__behive_set_ym=setYM;'
'function load(owner,fresh){'
# 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap).
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}'
@@ -6501,6 +6575,7 @@ def render_html() -> str:
'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
'var ym=chartYM();if(ym)qs.push("chart_ym="+encodeURIComponent(ym));'
'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
@@ -6849,16 +6924,34 @@ def render_html() -> str:
'document.addEventListener("change",function(e){'
'var sel=e.target;'
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
'function ymFirstMonth(y){var cc=document.querySelector(".chart-controls");try{var mp=JSON.parse(cc.getAttribute("data-ym-months")||"{}");var a=mp[y]||[];return a.length?a[0]:"";}catch(_){return "";}}'
'if(sel.hasAttribute("data-nw-range")){'
'try{localStorage.setItem("behive.chartDaysV2",sel.value);}catch(_){}'
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
'var allowed=allowedMap[sel.value]||["day","week","month","year"];'
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
'var curU=unitSel?unitSel.value:"day";'
'if(allowed.indexOf(curU)<0){'
'try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}'
'var rv=sel.value;'
'if(rv==="year"||rv==="month"){'
# 연도별/월별 진입 — 현재 연도 드롭다운 값(기본=최신연도)으로 chartYM 세팅.
'var yEl=document.querySelector(\'select.nw-select[data-nw-year]\');'
'var y=yEl&&yEl.value?yEl.value:"";'
'if(!y)return;' # 데이터 없음
'if(rv==="year"){window.__behive_set_ym(y);}'
'else{var mm=ymFirstMonth(y);window.__behive_set_ym(mm?(y+"-"+mm):y);}'
'}else{'
'window.__behive_set_ym("");' # 프리셋 → 절대선택 해제
'try{localStorage.setItem("behive.chartDaysV2",rv);}catch(_){}'
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
'var allowed=allowedMap[rv]||["day","week","month","year"];'
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
'var curU=unitSel?unitSel.value:"day";'
'if(allowed.indexOf(curU)<0){try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}}'
'}'
'}else if(sel.hasAttribute("data-nw-year")){'
'var rEl=document.querySelector(\'select.nw-select[data-nw-range]\');'
'var mode=rEl?rEl.value:"year";var yy=sel.value;'
'if(mode==="month"){var m2=ymFirstMonth(yy);window.__behive_set_ym(m2?(yy+"-"+m2):yy);}'
'else{window.__behive_set_ym(yy);}'
'}else if(sel.hasAttribute("data-nw-month")){'
'var ry=document.querySelector(\'select.nw-select[data-nw-year]\');'
'window.__behive_set_ym((ry?ry.value:"")+"-"+sel.value);'
'}else if(sel.hasAttribute("data-nw-unit")){'
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
'}else if(sel.hasAttribute("data-nw-mode")){'
@@ -9329,11 +9422,11 @@ def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict
}
def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> None:
def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> None:
"""partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
다른 owner와 watchlist/interests 슬라이스는 cached 'all' 있던 그대로 TTL 만료 full fetch로 동기화.
핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록."""
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}'
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
all_lock = _panels_lock_for(all_key)
with all_lock:
slot = _panels_cache.get(all_key)
@@ -9354,7 +9447,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY
for e in _owner_data_cache.values():
merged_balances.update(e.get('balances') or {})
merged_journal.update(e.get('journal_by_label') or {})
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
entry = _owner_data_cache.get(owner)
if not entry:
return
@@ -9378,7 +9471,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY
_panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']}
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> bytes:
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> bytes:
"""fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출.
응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 자동 갱신은 캐시를 본다.
@@ -9388,7 +9481,7 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart
갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
있으면 재렌더 가능 다시 fetch할 필요 없음. 패치 실패 안전망으로 'all' 캐시 비워
다음 자산정보 진입에서 full fetch."""
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}'
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
if not fresh:
slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time():
@@ -9399,15 +9492,15 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart
slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time():
return slot['json']
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
_panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
if owner is not None:
try:
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
except Exception:
traceback.print_exc()
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', None)
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', None)
return body
@@ -10082,8 +10175,17 @@ class Handler(BaseHTTPRequestHandler):
adr_days = ADR_TREND_DAYS
if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
adr_days = ADR_TREND_DAYS
# chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋).
chart_ym = (qs.get('chart_ym') or [''])[0]
_ymp = (chart_ym or '').split('-')
_ym_ok = (
(len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit())
or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit())
)
if not _ym_ok:
chart_ym = ''
try:
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
except Exception:
traceback.print_exc()
self.send_error(500, 'panels failed')