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
+132 -30
View File
@@ -3152,7 +3152,31 @@ def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: l
return out 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별 순자산 시계열을 추출. """portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리. 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
unit으로 /// 단위 집계 ( 구간 마지막 영업일 ). 기간(days) 필터는 단위 집계 적용. 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 별도 안 넘김 # filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
baseline_prepended = False 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') prefix = datetime.now(KST).strftime('%Y-%m')
range_start = f'{prefix}-01' range_start = f'{prefix}-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)] 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 묶음. */ /* 차트 컨트롤 자산정보 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; } .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-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 .cc-label { color: #8b8f9a; font-size: 11px; }
.chart-controls .nw-select { .chart-controls .nw-select {
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
@@ -5111,17 +5139,42 @@ def _owner_full_title(owner: str) -> str:
return OWNER_LABELS.get(owner, f'{owner} 자산') 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: 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 3개. """차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select.
자산정보 상단에 1세트만 노출, 본인·가희 차트 동시 컨트롤. 기간에 '연도별'(연도 선택월별집계)·'월별'(연도+ 선택일별) 추가. period(chart_ym) 활성 단위는 강제·비활성.
의미없는 단위(이번주+월별 ) disabled.""" 의미없는 단위(이번주+월별 ) 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( allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS) selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
) )
range_opts = ''.join( 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 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( unit_opts = ''.join(
f'<option value="{u}"' f'<option value="{u}"'
f'{" selected" if u == selected_unit else ""}' 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>' f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
for lbl, m in NET_WORTH_MODE_PRESETS for lbl, m in NET_WORTH_MODE_PRESETS
) )
_unit_dis = ' disabled' if period_active else ''
return ( 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'<label class="cc-field"><span class="cc-label">기간</span>'
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>' 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'<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>' f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
'</div>' '</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(자산보기/차트보기/시장정보)로 분리. """자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글) - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글) - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
@@ -5697,9 +5756,13 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
balances = balances or {} balances = balances or {}
journal_by_label = journal_by_label 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] = [] consolidated_parts: list[str] = []
by_account_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: for owner in ordered_owners:
d = owner_data.get(owner) d = owner_data.get(owner)
if not d: 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, has_prev_snap=has_prev_snap,
label_disp=label_disp, 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: if chart_series:
# 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum. # 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
daily_cf = _load_cash_flow_by_date(owner) 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에 이미 합산되어 있음. # 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
today_cf = None today_cf = None
if d.get('cash_in', 0) or d.get('cash_out', 0): 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, pnl_series,
current_net=current_pnl, current_net=current_pnl,
selected_days=chart_days, selected_days=chart_days,
selected_unit=chart_unit, selected_unit=eff_unit,
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김 cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
today_cash_flow=None, today_cash_flow=None,
mode=chart_mode, mode=chart_mode,
@@ -5761,7 +5824,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
chart_series, chart_series,
current_net=d.get('total_net'), current_net=d.get('total_net'),
selected_days=chart_days, selected_days=chart_days,
selected_unit=chart_unit, selected_unit=eff_unit,
cash_flow_by_date=cf_by_date, cash_flow_by_date=cf_by_date,
today_cash_flow=today_cf, today_cash_flow=today_cf,
mode='net', mode='net',
@@ -6190,7 +6253,7 @@ def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'} 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와 캐시의 단위. """fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
owner 지정 : 해당 owner 계좌만 호출하고 응답 tabs는 owner 하나만 (summary·wl 제외). owner 지정 : 해당 owner 계좌만 호출하고 응답 tabs는 owner 하나만 (summary·wl 제외).
클라이언트가 partial swap으로 사용 보지 않는 owner 호출 폭주 방지. 클라이언트가 partial swap으로 사용 보지 않는 owner 호출 폭주 방지.
@@ -6220,7 +6283,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
# 전체 — 모든 탭 채움. 모두 동일 fetched_at. # 전체 — 모든 탭 채움. 모두 동일 fetched_at.
wl_body, wl_count = _render_watchlist_panel(wl_cards) wl_body, wl_count = _render_watchlist_panel(wl_cards)
in_body, in_count = _render_interests_panel(in_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}) tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
for o in ordered_owners: for o in ordered_owners:
d = owner_data[o] 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;' 'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
'return v;' '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_days=chartDays;'
'window.__behive_chart_unit=chartUnit;' 'window.__behive_chart_unit=chartUnit;'
'window.__behive_chart_mode=chartMode;' 'window.__behive_chart_mode=chartMode;'
'window.__behive_adr_days=adrDays;' 'window.__behive_adr_days=adrDays;'
'window.__behive_chart_ym=chartYM;'
'window.__behive_set_ym=setYM;'
'function load(owner,fresh){' 'function load(owner,fresh){'
# 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap). # 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap).
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}' '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 cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);' 'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);' '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 url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})' '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();})' '.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){' 'document.addEventListener("change",function(e){'
'var sel=e.target;' 'var sel=e.target;'
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;' '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")){' 'if(sel.hasAttribute("data-nw-range")){'
'try{localStorage.setItem("behive.chartDaysV2",sel.value);}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와 일치). # 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};' '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 allowed=allowedMap[rv]||["day","week","month","year"];'
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');' 'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
'var curU=unitSel?unitSel.value:"day";' 'var curU=unitSel?unitSel.value:"day";'
'if(allowed.indexOf(curU)<0){' 'if(allowed.indexOf(curU)<0){try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}}'
'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")){' '}else if(sel.hasAttribute("data-nw-unit")){'
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}' 'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
'}else if(sel.hasAttribute("data-nw-mode")){' '}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로 갈아끼운다. """partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
다른 owner와 watchlist/interests 슬라이스는 cached 'all' 있던 그대로 TTL 만료 full fetch로 동기화. 다른 owner와 watchlist/interests 슬라이스는 cached 'all' 있던 그대로 TTL 만료 full fetch로 동기화.
핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록.""" 핵심 목표: 자산정보 탭의 본인 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) all_lock = _panels_lock_for(all_key)
with all_lock: with all_lock:
slot = _panels_cache.get(all_key) 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(): for e in _owner_data_cache.values():
merged_balances.update(e.get('balances') or {}) merged_balances.update(e.get('balances') or {})
merged_journal.update(e.get('journal_by_label') 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) entry = _owner_data_cache.get(owner)
if not entry: if not entry:
return 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']} _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)에서 호출. """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만 갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
있으면 재렌더 가능 다시 fetch할 필요 없음. 패치 실패 안전망으로 'all' 캐시 비워 있으면 재렌더 가능 다시 fetch할 필요 없음. 패치 실패 안전망으로 'all' 캐시 비워
다음 자산정보 진입에서 full fetch.""" 다음 자산정보 진입에서 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: if not fresh:
slot = _panels_cache.get(key) slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time(): 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) slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time(): if slot and slot['expires_at'] > time.time():
return slot['json'] 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') body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
_panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL} _panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
if owner is not None: if owner is not None:
try: 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: except Exception:
traceback.print_exc() 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 return body
@@ -10082,8 +10175,17 @@ class Handler(BaseHTTPRequestHandler):
adr_days = ADR_TREND_DAYS adr_days = ADR_TREND_DAYS
if adr_days not in {d for _, d in ADR_TREND_PRESETS}: if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
adr_days = ADR_TREND_DAYS 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: 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: except Exception:
traceback.print_exc() traceback.print_exc()
self.send_error(500, 'panels failed') self.send_error(500, 'panels failed')