/** * TeamOps Dashboards — /morning · /evening · /cohort (CEO 일·월 리듬). * * v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers + * hire + runway + Chronicle ADR) 으로 일/월 단위 시야 제공. * (구 /weekly CEO 리뷰 카드는 v2.2.204 에서 제거 — /weekly 는 task 기반 금주/차주 * 보고서로 일원화, coordination.ts 참조.) * * 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_morningActions 등) 는 * 이 파일 안에. */ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import { registerSlashCommand, chunk } from '../../datacollect/slashRouter'; import { fmtKrw as _fmtKrw, daysUntil as _daysUntil, parseTaskOwner, stageEmoji as _stageEmoji, STAGE_ORDER, TERMINAL_STAGES, } from './_shared'; import { listTasks } from '../../calendar'; import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore'; import { computeRunwayStatus, readRunway, type RunwayEntry, } from '../../runway/runwayStore'; import { computeCustomerStates, readEvents as readCustomerEvents, type CustomerEvent, type CustomerState, } from '../../customers/customersStore'; import { computeCandidateStates, readHireEvents, type HireEvent, type CandidateState, } from '../../hire/hireStore'; // ─── /morning — 매일 아침 통합 대시보드 ────────────────────────────────── async function runMorning(arg: string, view: any, context?: vscode.ExtensionContext): Promise { const mode = (arg.trim().split(/\s+/)[0] || '').toLowerCase(); const brief = mode === 'brief' || mode === 'short'; const today = new Date().toISOString().slice(0, 10); chunk(view, `\n☀️ **오늘 (${today}) — 통합 대시보드**\n`); const runway = computeRunwayStatus(); const customerStates = computeCustomerStates(); const customers = Array.from(customerStates.values()); const candidateStates = computeCandidateStates(); const candidates = Array.from(candidateStates.values()); let tasks: any[] = []; let tasksError: string | undefined; if (context) { try { const res = await listTasks(context, { showCompleted: false, maxResults: 300 }); if (res.ok) tasks = res.tasks; else tasksError = res.error; } catch (e: any) { tasksError = e?.message || String(e); } } const urgent: string[] = []; if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) { urgent.push(`🔴 **런웨이 ${runway.runwayMonths.toFixed(1)}개월** — 즉시 자금 조달/절감 필요`); } else if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 6) { urgent.push(`🟡 런웨이 ${runway.runwayMonths.toFixed(1)}개월 — 자금 계획 점검 권장`); } const atRiskCustomers = customers.filter((c) => c.status === 'at-risk'); const atRiskMrr = atRiskCustomers.reduce((s, c) => s + c.mrr, 0); if (atRiskCustomers.length > 0) { urgent.push(`⚠️ 위험 고객 **${atRiskCustomers.length}곳** (MRR ${_fmtKrw(atRiskMrr)}원/월 노출)`); } const upcomingRenewals = [...customers].filter((c) => c.status !== 'churned') .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7); if (upcomingRenewals.length > 0) urgent.push(`🔔 7일 내 갱신 **${upcomingRenewals.length}건**`); const overdueTasks = tasks.filter((t) => t.due && t.due < today); if (overdueTasks.length > 0) urgent.push(`🚧 지연 작업 **${overdueTasks.length}건**`); const activeCandidate = candidates.filter((c) => !TERMINAL_STAGES.has(c.stage)); const stalled = activeCandidate.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000); if (stalled.length > 0) urgent.push(`⏰ 정체 후보 **${stalled.length}명** (7일+ 미변동)`); if (urgent.length === 0) chunk(view, '\n## ✅ 긴급 알림 없음\n'); else { chunk(view, `\n## 🚨 긴급 (${urgent.length}건)\n`); for (const u of urgent) chunk(view, `- ${u}\n`); } if (brief) { chunk(view, '\n## 📋 오늘의 액션 (top 3)\n'); const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates); for (const a of actions.slice(0, 3)) chunk(view, `- ${a}\n`); return true; } chunk(view, '\n## 💰 재무\n'); if (runway.latestCash === null) { chunk(view, '- _데이터 없음_ — `/runway cash <금액>` 으로 시작\n'); } else { chunk(view, `- 현금 **${_fmtKrw(runway.latestCash)}원** _(${(runway.latestCashAt || '').slice(0, 10)})_\n`); if (runway.effectiveBurn !== null) { chunk(view, `- 월 burn ${_fmtKrw(runway.effectiveBurn)}원 ${runway.explicitBurn !== null ? '_(수동)_' : '_(30일 실적)_'}\n`); } if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths)) { const emoji = runway.runwayMonths < 3 ? '🔴' : runway.runwayMonths < 6 ? '🟡' : '🟢'; chunk(view, `- 런웨이 ${emoji} **${runway.runwayMonths.toFixed(1)}개월**\n`); } else if (runway.runwayMonths !== null) { chunk(view, '- 런웨이 ♾️ 흑자 운영\n'); } } chunk(view, '\n## 📒 고객\n'); if (customers.length === 0) { chunk(view, '- _데이터 없음_ — `/customers add` 로 시작\n'); } else { const active = customers.filter((c) => c.status === 'active'); const totalMrr = [...active, ...atRiskCustomers].reduce((s, c) => s + c.mrr, 0); chunk(view, `- 총 MRR **${_fmtKrw(totalMrr)}원/월** _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`); chunk(view, `- 활성 ${active.length} · 위험 ${atRiskCustomers.length} · 이탈 ${customers.length - active.length - atRiskCustomers.length}\n`); if (upcomingRenewals.length > 0) { for (const { c, days } of upcomingRenewals.slice(0, 5)) { const emoji = (days as number) <= 3 ? '🔴' : '🟡'; chunk(view, ` - ${emoji} ${c.customerName} — D-${days} · ${_fmtKrw(c.mrr)}원/월\n`); } } } chunk(view, '\n## 👥 팀\n'); if (tasksError) { chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`); } else if (tasks.length === 0) { chunk(view, '- _Tasks 없음_ — `/task` 로 등록 시작\n'); } else { const memberOverdue = new Map(); const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); let weekCount = 0; for (const t of tasks) { const { owner } = parseTaskOwner(t.title, t.notes); const k = owner || '(미지정)'; if (t.due && t.due < today) memberOverdue.set(k, (memberOverdue.get(k) || 0) + 1); if (t.due && t.due >= today && t.due <= weekLater) weekCount++; } chunk(view, `- 지연 ${overdueTasks.length}건 · 이번 주 ${weekCount}건 (전체 ${tasks.length})\n`); if (memberOverdue.size > 0) { const ranked = [...memberOverdue.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4); for (const [member, n] of ranked) chunk(view, ` - **@${member}** 지연 ${n}건\n`); } } chunk(view, '\n## 🎯 채용\n'); if (candidates.length === 0) { chunk(view, '- _데이터 없음_\n'); } else { const hired = candidates.filter((c) => c.stage === 'hired').length; chunk(view, `- 진행 중 ${activeCandidate.length}명 · 합격 ${hired}\n`); const stageCount = new Map(); for (const c of activeCandidate) stageCount.set(c.stage, (stageCount.get(c.stage) || 0) + 1); const stages = [...stageCount.entries()].sort((a, b) => (STAGE_ORDER[a[0]] ?? 50) - (STAGE_ORDER[b[0]] ?? 50)); if (stages.length > 0) { const parts = stages.map(([s, n]) => `${_stageEmoji(s)} ${s} ${n}`); chunk(view, ` - ${parts.join(' · ')}\n`); } if (stalled.length > 0) { chunk(view, `- ⏰ 정체 ${stalled.length}명:\n`); for (const c of stalled.slice(0, 3)) { const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); chunk(view, ` - ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`); } } } chunk(view, '\n## 📋 오늘의 액션\n'); const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates); if (actions.length === 0) { chunk(view, '- ✨ 특별한 조치 필요 없음. 깊은 작업 시간 확보 권장.\n'); } else { for (const a of actions.slice(0, 5)) chunk(view, `- ${a}\n`); } return true; } function _morningActions( runway: ReturnType, customers: CustomerState[], upcomingRenewals: Array<{ c: CustomerState; days: number | null }>, overdueTasks: any[], stalled: CandidateState[], candidates: CandidateState[], ): string[] { const actions: string[] = []; if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) { actions.push(`💸 **자금 조달 계획** — 런웨이 ${runway.runwayMonths.toFixed(1)}개월. 투자자 미팅 / 비용 절감 즉시.`); } const atRisk = customers.filter((c) => c.status === 'at-risk').sort((a, b) => b.mrr - a.mrr); if (atRisk.length > 0) { const top = atRisk[0]; actions.push(`📞 **${top.customerName}** 위험 대응 — MRR ${_fmtKrw(top.mrr)}원. 사유 점검 후 액션.`); } if (upcomingRenewals.length > 0) { const next = upcomingRenewals[0]; actions.push(`📨 **${next.c.customerName}** 갱신 D-${next.days} — 갱신 의사 확인 / 가격 협의.`); } if (overdueTasks.length >= 5) { actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 멤버별 확인 후 우선순위 재조정.`); } else if (overdueTasks.length > 0) { actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 확인.`); } if (stalled.length > 0) { const next = stalled[0]; const days = Math.floor((Date.now() - Date.parse(next.lastEventAt)) / (24 * 60 * 60 * 1000)); actions.push(`👥 **${next.candidateName}** 채용 후속 — ${next.stage} 단계 ${days}일 정체.`); } const inboxCount = candidates.filter((c) => c.stage === 'inbox').length; if (inboxCount >= 5) actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`); return actions; } // ─── /evening — 하루 마무리 카드 ───────────────────────────────────────── async function runEvening(arg: string, view: any, context?: vscode.ExtensionContext): Promise { const today = new Date().toISOString().slice(0, 10); const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const dayStartMs = Date.parse(today + 'T00:00:00'); chunk(view, `\n🌙 **오늘 (${today}) — 마무리 카드**\n`); let completedTasks: any[] = []; let tasksError: string | undefined; if (context) { try { const res = await listTasks(context, { showCompleted: true, maxResults: 300 }); if (res.ok) { completedTasks = res.tasks.filter((t: any) => t.status === 'completed' && t.completed && Date.parse(t.completed) >= dayStartMs); } else { tasksError = res.error; } } catch (e: any) { tasksError = e?.message || String(e); } } const customerEvents = readCustomerEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs); const hireEvents = readHireEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs); const runwayToday = readRunway().filter((e) => Date.parse(e.timestamp) >= dayStartMs); chunk(view, '\n## ✅ 오늘의 진척\n'); const progressEmpty = completedTasks.length === 0 && customerEvents.length === 0 && hireEvents.length === 0 && runwayToday.length === 0; if (progressEmpty) { chunk(view, '- _기록된 진척 없음._ (작업 완료 / 고객 이벤트 / 채용 이동 등이 오늘 입력되지 않음)\n'); if (tasksError) chunk(view, ` _Tasks 조회 실패: ${tasksError}_\n`); } else { if (completedTasks.length > 0) { chunk(view, `\n### 작업 완료 (${completedTasks.length}건)\n`); const byOwner = new Map(); for (const t of completedTasks) { const { owner, displayTitle } = parseTaskOwner(t.title, t.notes); const k = owner || '(미지정)'; if (!byOwner.has(k)) byOwner.set(k, []); byOwner.get(k)!.push({ title: displayTitle }); } const ranked = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length); for (const [owner, list] of ranked) { chunk(view, `- **@${owner}** (${list.length}건)\n`); for (const t of list.slice(0, 5)) chunk(view, ` - ${t.title}\n`); if (list.length > 5) chunk(view, ` - _…+${list.length - 5}건_\n`); } } if (customerEvents.length > 0) { chunk(view, `\n### 📒 고객 이벤트 (${customerEvents.length}건)\n`); for (const e of customerEvents.slice(0, 10)) { const tagEmoji = e.type === 'add' ? '➕' : e.type === 'renew' ? '🔄' : e.type === 'risk' ? '⚠️' : e.type === 'churn' ? '💀' : '📝'; const detail = e.type === 'add' || e.type === 'renew' || e.type === 'update' ? (e.mrr !== undefined ? ` MRR ${_fmtKrw(e.mrr)}원` : '') : (e.memo ? ` — ${e.memo.slice(0, 60)}` : ''); chunk(view, `- ${tagEmoji} ${e.customerName} ${e.type}${detail}\n`); } } if (hireEvents.length > 0) { chunk(view, `\n### 🎯 채용 이벤트 (${hireEvents.length}건)\n`); for (const e of hireEvents.slice(0, 10)) { const stageNote = e.stage ? ` → ${e.stage}` : ''; const memo = e.memo ? ` — ${e.memo.slice(0, 60)}` : ''; chunk(view, `- ${e.candidateName} ${e.type}${stageNote}${memo}\n`); } } if (runwayToday.length > 0) { chunk(view, `\n### 💰 재무 기록 (${runwayToday.length}건)\n`); for (const e of runwayToday) { const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn'; chunk(view, `- ${typeLabel}: ${_fmtKrw(e.amount)}원${e.memo ? ` — ${e.memo}` : ''}\n`); } } } chunk(view, '\n## 🌅 내일 준비\n'); let tomorrowTasks: any[] = []; if (context && !tasksError) { try { const res = await listTasks(context, { showCompleted: false, maxResults: 300 }); if (res.ok) tomorrowTasks = res.tasks.filter((t: any) => t.due === tomorrow); } catch { /* ignore */ } } if (tomorrowTasks.length > 0) { chunk(view, `\n### 내일 마감 (${tomorrowTasks.length}건)\n`); for (const t of tomorrowTasks.slice(0, 8)) { const { owner, displayTitle } = parseTaskOwner(t.title, t.notes); chunk(view, `- **@${owner || '(미지정)'}** — ${displayTitle}\n`); } } const customers = Array.from(computeCustomerStates().values()); const upcomingRenewals = customers.filter((c) => c.status !== 'churned') .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7); if (upcomingRenewals.length > 0) { chunk(view, `\n### 🔔 7일 내 갱신 (${upcomingRenewals.length}건)\n`); for (const { c, days } of upcomingRenewals.slice(0, 5)) { const emoji = (days as number) <= 3 ? '🔴' : '🟡'; chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`); } } const stalled = Array.from(computeCandidateStates().values()) .filter((c) => !TERMINAL_STAGES.has(c.stage)) .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000); if (stalled.length > 0) { chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`); for (const c of stalled.slice(0, 3)) { const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`); } } if (tomorrowTasks.length === 0 && upcomingRenewals.length === 0 && stalled.length === 0) { chunk(view, '- _내일 마감·갱신 임박·정체 후보 모두 없음._ ✨\n'); } const reflections = [ '오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?', '내일 무엇을 안 하기로 했나? 안 할 일을 정해야 할 일이 또렷해진다.', '오늘 한 결정 중 일주일 뒤에도 옳을 결정은 어느 것인가?', '시간이 가장 많이 든 활동은 가장 영향력 있는 활동과 일치했나?', '오늘 멤버들에게 충분한 명확함을 줬나? 무엇을 미루지 않고 답해야 하나?', '에너지가 가장 좋았던 30분은 무엇을 하던 때였나?', '오늘 안 한 일 중 내일도 안 해도 되는 일은 무엇인가?', '리스크 한 가지를 꼽는다면? 그것에 대해 누구와 이야기해야 하나?', ]; const idx = (Date.parse(today) / (24 * 60 * 60 * 1000)) % reflections.length; chunk(view, `\n## 🧭 회고\n> ${reflections[idx]}\n`); chunk(view, '\n_명령 한 줄로 기록 남기기:_ `/decisions` · `/feedback` · `/customers note` · `/hire note`\n'); return true; } // ─── /cohort — MoM 추세 분석 ───────────────────────────────────────────── interface MonthlyBucket { yearMonth: string; newCustomers: number; churnedCustomers: number; renewals: number; mrrDelta: number; expenseTotal: number; revenueTotal: number; cashSnapshots: number[]; } function _yearMonth(iso: string): string { return (iso || '').slice(0, 7); } function _buildMonthlyBuckets(monthsBack: number): Map { const map = new Map(); const now = new Date(); for (let i = monthsBack - 1; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const ym = d.toISOString().slice(0, 7); map.set(ym, { yearMonth: ym, newCustomers: 0, churnedCustomers: 0, renewals: 0, mrrDelta: 0, expenseTotal: 0, revenueTotal: 0, cashSnapshots: [], }); } for (const e of readCustomerEvents()) { const ym = _yearMonth(e.timestamp); const b = map.get(ym); if (!b) continue; if (e.type === 'add') { b.newCustomers++; if (e.mrr) b.mrrDelta += e.mrr; } else if (e.type === 'churn') b.churnedCustomers++; else if (e.type === 'renew') b.renewals++; } for (const e of readRunway()) { const ym = _yearMonth(e.timestamp); const b = map.get(ym); if (!b) continue; if (e.type === 'expense') b.expenseTotal += e.amount; else if (e.type === 'revenue') b.revenueTotal += e.amount; else if (e.type === 'snapshot') b.cashSnapshots.push(e.amount); } return map; } function _cohortDashboard(view: any, monthsBack: number): void { const buckets = _buildMonthlyBuckets(monthsBack); if (buckets.size === 0) { chunk(view, '\nℹ️ 데이터 없음. `/customers add` / `/runway cash` 로 시작.\n'); return; } const rows = Array.from(buckets.values()); chunk(view, `\n📈 **/cohort — 최근 ${monthsBack}개월 추세**\n`); chunk(view, '\n## 고객 & MRR 추이\n'); chunk(view, '| 월 | 신규 | 갱신 | 이탈 | MRR Δ |\n'); chunk(view, '|---|---:|---:|---:|---:|\n'); for (const r of rows) { chunk(view, `| ${r.yearMonth} | ${r.newCustomers} | ${r.renewals} | ${r.churnedCustomers} | ${r.mrrDelta > 0 ? '+' : ''}${_fmtKrw(r.mrrDelta)} |\n`); } const totNew = rows.reduce((s, r) => s + r.newCustomers, 0); const totChurn = rows.reduce((s, r) => s + r.churnedCustomers, 0); const totMrr = rows.reduce((s, r) => s + r.mrrDelta, 0); chunk(view, `\n- **누적 ${monthsBack}개월**: 신규 +${totNew} · 이탈 -${totChurn} · 순 ${totNew - totChurn >= 0 ? '+' : ''}${totNew - totChurn}\n`); chunk(view, `- **MRR 순증**: ${totMrr >= 0 ? '+' : ''}${_fmtKrw(totMrr)}원/월 _(추가된 신규 MRR 만, 이탈로 인한 감소는 history 부재로 미반영)_\n`); const avgNew = totNew / monthsBack; const avgChurn = totChurn / monthsBack; if (avgNew > 0 || avgChurn > 0) { chunk(view, `- 월평균 신규 ${avgNew.toFixed(1)}곳, 월평균 이탈 ${avgChurn.toFixed(1)}곳`); if (totNew > 0) chunk(view, ` (이탈/신규 비율 ${((totChurn / totNew) * 100).toFixed(0)}%)\n`); else chunk(view, '\n'); } chunk(view, '\n## 재무 추이\n'); chunk(view, '| 월 | 지출 | 수입 | 순 burn | 월말 잔고 |\n'); chunk(view, '|---|---:|---:|---:|---:|\n'); for (const r of rows) { const netBurn = r.expenseTotal - r.revenueTotal; const lastCash = r.cashSnapshots.length > 0 ? r.cashSnapshots[r.cashSnapshots.length - 1] : null; const cashCell = lastCash !== null ? _fmtKrw(lastCash) : '-'; chunk(view, `| ${r.yearMonth} | ${_fmtKrw(r.expenseTotal)} | ${_fmtKrw(r.revenueTotal)} | ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)} | ${cashCell} |\n`); } const totExp = rows.reduce((s, r) => s + r.expenseTotal, 0); const totRev = rows.reduce((s, r) => s + r.revenueTotal, 0); const totBurn = totExp - totRev; const avgBurn = totBurn / monthsBack; chunk(view, `\n- **${monthsBack}개월 누계**: 지출 ${_fmtKrw(totExp)} · 수입 ${_fmtKrw(totRev)} · 순 burn ${totBurn > 0 ? '+' : ''}${_fmtKrw(totBurn)}\n`); chunk(view, `- **월평균 burn**: ${_fmtKrw(avgBurn)}원/월\n`); chunk(view, '\n## 💡 인사이트\n'); const insights: string[] = []; if (monthsBack >= 6) { const recent3 = rows.slice(-3); const prior3 = rows.slice(-6, -3); const recentNew = recent3.reduce((s, r) => s + r.newCustomers, 0); const priorNew = prior3.reduce((s, r) => s + r.newCustomers, 0); if (recentNew > priorNew * 1.2) insights.push('🟢 최근 3개월 신규 획득 가속 (이전 3개월 대비 +20%↑)'); else if (recentNew < priorNew * 0.8 && priorNew >= 2) insights.push('🟡 최근 3개월 신규 획득 둔화 (이전 3개월 대비 -20%↓)'); const recentBurn = recent3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0); const priorBurn = prior3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0); if (priorBurn > 0 && recentBurn > priorBurn * 1.3) insights.push('🔴 최근 3개월 burn 가속 (이전 3개월 대비 +30%↑) — 비용 점검 권장'); } if (avgBurn > 0 && totRev > 0) { const coverage = totRev / totExp; if (coverage > 0.8) insights.push(`🟢 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 흑자 진입 임박`); else if (coverage < 0.2 && totExp > 0) insights.push(`🟡 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 매출 기반 약함`); } if (insights.length === 0) chunk(view, '- _데이터 부족 또는 추세 신호 약함._ 더 누적되면 인사이트 표시.\n'); else for (const i of insights) chunk(view, `- ${i}\n`); chunk(view, '\n_데이터 출처: `.astra/customers.jsonl` + `.astra/runway.jsonl`. 더 많은 이벤트 누적 시 추세 정확도↑._\n'); } async function runCohort(arg: string, view: any): Promise { const trimmed = arg.trim().toLowerCase(); if (trimmed === 'help' || trimmed === '?') { chunk(view, [ '\n📈 **/cohort — MoM 추세 분석**', '', '사용법:', ' `/cohort` — 최근 6개월 추세 (기본)', ' `/cohort yearly` — 최근 12개월', ' `/cohort ` — 최근 N개월 (1~24)', '', '데이터 출처: `/customers` events + `/runway` events 의 timestamp 월별 그룹핑.', '표시 항목: 신규/갱신/이탈 + MRR 변화 + 지출/수입/burn + 월말 잔고 + 인사이트 한 줄.\n', ].join('\n')); return true; } let monthsBack = 6; if (trimmed === 'yearly' || trimmed === 'year') monthsBack = 12; else if (/^\d+$/.test(trimmed)) monthsBack = Math.max(1, Math.min(24, parseInt(trimmed, 10))); _cohortDashboard(view, monthsBack); return true; } // ─── 등록 ───────────────────────────────────────────────────────────────── registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning }); registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening }); registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort });