2ea5185cd6
- /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>
494 lines
26 KiB
TypeScript
494 lines
26 KiB
TypeScript
/**
|
||
* 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 });
|