Files
connectai/src/features/teamops/handlers/dashboards.ts
T
koriweb 2ea5185cd6 feat(weekly): /weekly 주간 보고서(금주/차주) 추가 + /meet 정확도 개선 (v2.2.204)
- /weekly: 차주 날짜 입력→금주 자동 역산, Google Tasks 기반 금주/차주 보고서.
  버킷팅은 코드(예측 가능), 포맷팅만 LLM. 신규 weeklyPrompt.ts + coordination.ts runWeekly.
- 기존 CEO /weekly 리뷰 카드(dashboards.ts) 제거 — 이름 충돌 해소, /weekly 일원화.
- /meet: 액션아이템에 '작업 상세' 열 추가, 캘린더 notes 가 실제 작업 내용을 담도록 재구성.
- /meet: 발언자 추적 복원 + 비선형 회의 재조립 + 근거/할루시네이션 억제 규칙으로 오귀속 감소.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:12:33 +09:00

494 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<boolean> {
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<string, number>();
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<string, number>();
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<typeof computeRunwayStatus>,
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<boolean> {
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<string, any[]>();
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<string, MonthlyBucket> {
const map = new Map<string, MonthlyBucket>();
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<boolean> {
const trimmed = arg.trim().toLowerCase();
if (trimmed === 'help' || trimmed === '?') {
chunk(view, [
'\n📈 **/cohort — MoM 추세 분석**',
'',
'사용법:',
' `/cohort` — 최근 6개월 추세 (기본)',
' `/cohort yearly` — 최근 12개월',
' `/cohort <N>` — 최근 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 });