title: OKR 메트릭 스냅샷 구현 플랜 date: 2026-02-25T14:30:00+09:00 type: implementation-plan layer: L2 status: draft author: okr-plan-pl reviewed_by: "" approved_by: ""
OKR 메트릭 스냅샷 구현 플랜
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: OKR 지표 시계열 히스토리 추적 + 자동 스냅샷 기록 운영 체계 구축
DB: Turso kanban (libsql://kanban-migkjy.aws-ap-northeast-1.turso.io)
프로젝트 경로: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard/
현황 분석
기존 구조
| 구성 요소 | 파일 | 상태 |
|---|---|---|
okr_snapshots 테이블 | src/models/Schema.ts:288 | 존재 -- per-KR 단일 값 저장, kr_id+value+collected_at |
POST /api/okr/actions | src/app/api/okr/actions/route.ts | 동작 중 -- O4 KR 8개 자동 수집 + okr_snapshots INSERT + okr_key_results.current_value UPDATE |
GET /api/okr/summary | src/app/api/okr/summary/route.ts | 동작 중 -- okr_snapshots 7일 평균으로 delta 계산 |
GET /api/okr/trend | src/app/api/okr/trend/route.ts | 스텁 -- 빈 배열 반환 (task_metric_snapshots table not available 주석) |
GET /api/okr/check | src/app/api/okr/check/route.ts | 스텁 -- 하드코딩된 빈 응답 |
scripts/okr-weekly-review.sh | scripts/okr-weekly-review.sh | 동작 중 -- 주간 리뷰 + 목표 상향 |
scripts/okr-scheduler.sh | scripts/okr-scheduler.sh | 동작 중 -- 6시간마다 /api/okr/check 호출 |
| OKR Dashboard UI | src/app/okr/page.tsx | 동작 중 -- Objective별 KR 카드 + Alerts + Escalations + Steering 탭 |
| OKR Config | src/config/okr-config.ts | O1~O4, KR 16개 정의, KR_ID_TO_KEY 매핑 |
| DB 연결 | src/utils/DBConnection.ts | @libsql/client + Drizzle ORM via db singleton |
핵심 발견
okr_snapshots테이블이 이미 시계열 히스토리 역할을 수행 중.kr_id,value,collected_at,period_type으로 KR별 시계열 데이터 저장.- VP 요구인
okr_metric_snapshots는 새 테이블이 아니라, 기존okr_snapshots활용을 확장하는 것이 효율적. 기존 테이블에objective_id,metric_name컬럼 추가로 충분. /api/okr/trend가 스텁 상태 -- 기존okr_snapshots데이터를 읽도록 구현하면 시계열 조회 완성./api/okr/check도 스텁 -- 실제 KR 상태를 읽도록 구현 필요.- O1~O3 KR은
manual타입으로, API/CLI에서 수동 입력 필요. O4 KR만 자동 수집.
설계 결정: 신규 테이블 vs 기존 테이블 확장
선택: okr_metric_snapshots 신규 테이블 생성 (VP 요구사항 준수)
이유:
- VP가 명시적으로
okr_metric_snapshots테이블 생성을 요구 - 기존
okr_snapshots와 역할 분리: 기존은 수집 로그, 신규는 정제된 시계열 히스토리 objective_id,metric_name컬럼으로 Objective 레벨 조회 가능- 기존 코드 호환성 유지 (기존
okr_snapshots사용 코드 변경 불필요)
Task 목록
Task 1: okr_metric_snapshots 테이블 생성 (DB 마이그레이션)
파일: src/models/Schema.ts
예상 시간: 2분
1-1. Schema.ts에 테이블 정의 추가
okrSnapshots 정의 아래 (Schema.ts:296 부근)에 추가:
// --- OKR: Metric Snapshots (time-series history) ---
export const okrMetricSnapshots = sqliteTable('okr_metric_snapshots', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
objectiveId: text('objective_id').references(() => okrObjectives.id).notNull(),
krId: text('kr_id').references(() => okrKeyResults.id).notNull(),
metricName: text('metric_name').notNull(), // okr-config.ts의 KR key (예: 'monthly_revenue_krw')
currentValue: real('current_value').notNull().default(0),
targetValue: real('target_value').notNull().default(0),
snapshotDate: text('snapshot_date').notNull(), // 'YYYY-MM-DD' format
createdAt: integer('created_at', { mode: 'timestamp_ms' }).$defaultFn(() => new Date()).notNull(),
});
1-2. Turso에 테이블 생성 SQL
CREATE TABLE IF NOT EXISTS okr_metric_snapshots (
id TEXT PRIMARY KEY,
objective_id TEXT NOT NULL REFERENCES okr_objectives(id),
kr_id TEXT NOT NULL REFERENCES okr_key_results(id),
metric_name TEXT NOT NULL,
current_value REAL NOT NULL DEFAULT 0,
target_value REAL NOT NULL DEFAULT 0,
snapshot_date TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_okr_metric_snapshots_kr_date ON okr_metric_snapshots(kr_id, snapshot_date);
CREATE INDEX idx_okr_metric_snapshots_obj_date ON okr_metric_snapshots(objective_id, snapshot_date);
검증
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard"
npx tsc --noEmit --pretty 2>&1 | head -20
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add projects/kanban-dashboard/src/models/Schema.ts
git pull --rebase origin main && git commit -m "feat(okr): add okr_metric_snapshots table schema" && git push origin main
Task 2: POST /api/okr/snapshot 엔드포인트
파일: src/app/api/okr/snapshot/route.ts (신규)
예상 시간: 5분
기능
- 모든 KR의 현재값을 읽어
okr_metric_snapshots에 일괄 INSERT - 수동 호출 또는 자동 호출 모두 대응
- 중복 방지: 동일
kr_id+snapshot_date조합이 이미 있으면 UPDATE
코드
import { eq, and, sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/libs/DB';
import { okrKeyResults, okrMetricSnapshots } from '@/models/Schema';
import { KR_ID_TO_KEY } from '@/config/okr-config';
// Reverse mapping: KR ID -> config key (metric_name)
const KR_ID_TO_METRIC: Record<string, string> = {};
for (const [krId, key] of Object.entries(KR_ID_TO_KEY)) {
KR_ID_TO_METRIC[krId] = key;
}
// POST /api/okr/snapshot -- Record current KR values as metric snapshots
export async function POST() {
try {
const now = new Date();
const snapshotDate = now.toISOString().split('T')[0]; // 'YYYY-MM-DD'
let recorded = 0;
// 1. Read all KR current values
const krRows = await db
.select({
id: okrKeyResults.id,
objectiveId: okrKeyResults.objectiveId,
currentValue: okrKeyResults.currentValue,
targetValue: okrKeyResults.targetValue,
})
.from(okrKeyResults);
// 2. Upsert each KR into okr_metric_snapshots
for (const kr of krRows) {
const metricName = KR_ID_TO_METRIC[kr.id] || kr.id;
// Check if snapshot already exists for this KR + date
const existing = await db
.select({ id: okrMetricSnapshots.id })
.from(okrMetricSnapshots)
.where(and(
eq(okrMetricSnapshots.krId, kr.id),
eq(okrMetricSnapshots.snapshotDate, snapshotDate),
))
.limit(1);
if (existing.length > 0) {
// Update existing snapshot
await db
.update(okrMetricSnapshots)
.set({
currentValue: kr.currentValue ?? 0,
targetValue: kr.targetValue,
})
.where(eq(okrMetricSnapshots.id, existing[0].id));
} else {
// Insert new snapshot
await db.insert(okrMetricSnapshots).values({
objectiveId: kr.objectiveId,
krId: kr.id,
metricName,
currentValue: kr.currentValue ?? 0,
targetValue: kr.targetValue,
snapshotDate,
createdAt: now,
});
}
recorded++;
}
return NextResponse.json({
data: {
recorded,
snapshotDate,
recordedAt: now.toISOString(),
},
ok: true,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to record snapshot', ok: false },
{ status: 500 },
);
}
}
검증
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard"
npx tsc --noEmit --pretty 2>&1 | head -20
# 로컬 테스트 (dev 서버 실행 중인 경우)
# curl -X POST http://localhost:5555/api/okr/snapshot
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add projects/kanban-dashboard/src/app/api/okr/snapshot/route.ts
git pull --rebase origin main && git commit -m "feat(okr): add POST /api/okr/snapshot endpoint" && git push origin main
Task 3: GET /api/okr/snapshots 조회 엔드포인트
파일: src/app/api/okr/snapshots/route.ts (신규)
예상 시간: 5분
기능
- 쿼리 파라미터:
objective(O1, O2 등),kr(KR1-1 등),period(weekly, monthly, all),days(숫자) okr_metric_snapshots테이블에서 시계열 데이터 조회- 기본값: 최근 30일
코드
import type { NextRequest } from 'next/server';
import { eq, and, sql, desc } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/libs/DB';
import { okrMetricSnapshots } from '@/models/Schema';
// GET /api/okr/snapshots?objective=O1&kr=KR1-1&period=weekly&days=30
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const objectiveId = searchParams.get('objective');
const krId = searchParams.get('kr');
const period = searchParams.get('period') || 'daily';
const daysParam = searchParams.get('days') || '30';
const days = Math.min(parseInt(daysParam, 10) || 30, 365);
// Calculate date cutoff
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
// Build conditions
const conditions = [
sql`${okrMetricSnapshots.snapshotDate} >= ${cutoffDateStr}`,
];
if (objectiveId) {
conditions.push(eq(okrMetricSnapshots.objectiveId, objectiveId));
}
if (krId) {
conditions.push(eq(okrMetricSnapshots.krId, krId));
}
const rows = await db
.select({
id: okrMetricSnapshots.id,
objectiveId: okrMetricSnapshots.objectiveId,
krId: okrMetricSnapshots.krId,
metricName: okrMetricSnapshots.metricName,
currentValue: okrMetricSnapshots.currentValue,
targetValue: okrMetricSnapshots.targetValue,
snapshotDate: okrMetricSnapshots.snapshotDate,
createdAt: okrMetricSnapshots.createdAt,
})
.from(okrMetricSnapshots)
.where(and(...conditions))
.orderBy(desc(okrMetricSnapshots.snapshotDate))
.limit(1000);
// Group by period if requested
let data = rows;
if (period === 'weekly') {
// Group by ISO week: keep only the latest entry per kr_id per week
const weekMap = new Map<string, typeof rows[0]>();
for (const row of rows) {
const d = new Date(row.snapshotDate);
const weekStart = new Date(d);
weekStart.setDate(d.getDate() - d.getDay()); // Sunday start
const weekKey = `${row.krId}-${weekStart.toISOString().split('T')[0]}`;
if (!weekMap.has(weekKey)) {
weekMap.set(weekKey, row);
}
}
data = Array.from(weekMap.values());
}
return NextResponse.json({
data: {
snapshots: data,
period,
days,
count: data.length,
...(objectiveId && { objective: objectiveId }),
...(krId && { kr: krId }),
},
ok: true,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch snapshots', ok: false },
{ status: 500 },
);
}
}
검증
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard"
npx tsc --noEmit --pretty 2>&1 | head -20
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add projects/kanban-dashboard/src/app/api/okr/snapshots/route.ts
git pull --rebase origin main && git commit -m "feat(okr): add GET /api/okr/snapshots query endpoint" && git push origin main
Task 4: scripts/okr-snapshot.sh CLI 스크립트
파일: scripts/okr-snapshot.sh (신규)
예상 시간: 3분
기능
- 커맨드라인에서 수동으로 스냅샷 기록
- Vercel 프로덕션 API 호출 (폴백: 로컬 dev)
- 결과 출력
코드
#!/bin/bash
set -euo pipefail
# OKR 메트릭 스냅샷 수동 기록
# 용도: POST /api/okr/snapshot 호출하여 현재 KR 값을 시계열 히스토리에 저장
# 실행: bash scripts/okr-snapshot.sh
PROD_URL="https://business-builder-kanban.vercel.app/api/okr/snapshot"
LOCAL_URL="http://localhost:5555/api/okr/snapshot"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
call_api() {
local url="$1"
curl -s --max-time 30 -X POST "$url" 2>/dev/null || return 1
}
log "OKR 메트릭 스냅샷 기록 시작"
# 프로덕션 우선, 로컬 폴백
RESPONSE=""
if RESPONSE=$(call_api "$PROD_URL"); then
OK=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ok','false'))" 2>/dev/null || echo "false")
if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then
log "프로덕션 API 성공"
else
log "프로덕션 API 실패, 로컬 폴백"
RESPONSE=""
fi
fi
if [ -z "$RESPONSE" ]; then
if RESPONSE=$(call_api "$LOCAL_URL"); then
OK=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ok','false'))" 2>/dev/null || echo "false")
if [ "$OK" = "True" ] || [ "$OK" = "true" ]; then
log "로컬 API 성공"
else
log "ERROR: 로컬 API도 실패"
echo "$RESPONSE"
exit 1
fi
else
log "ERROR: 모든 API 호출 실패"
exit 1
fi
fi
# 결과 출력
RECORDED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('recorded',0))" 2>/dev/null || echo "?")
SNAPSHOT_DATE=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('snapshotDate','?'))" 2>/dev/null || echo "?")
log "완료: ${RECORDED}개 KR 스냅샷 기록 (날짜: ${SNAPSHOT_DATE})"
검증
chmod +x "/Users/nbs22/(Claude)/(claude).projects/business-builder/scripts/okr-snapshot.sh"
# 테스트: bash scripts/okr-snapshot.sh
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add scripts/okr-snapshot.sh
git pull --rebase origin main && git commit -m "feat(okr): add okr-snapshot.sh CLI script" && git push origin main
Task 5: okr-weekly-review.sh에 스냅샷 단계 추가
파일: scripts/okr-weekly-review.sh
예상 시간: 2분
변경 내용
기존 주간 리뷰 스크립트 끝부분 (report.sh 호출 직전)에 스냅샷 API 호출 추가.
변경 위치: 82행 (JSEOF 종료) 이후, echo "$RESULT" 이전
기존 코드 (82~88행):
JSEOF
)
echo "$RESULT"
# report.sh로 보고
"$REPORT_SCRIPT" "$RESULT" "okr-weekly" "🚀 [머스크 VP]"
변경 후:
JSEOF
)
echo "$RESULT"
# 메트릭 스냅샷 기록 (POST /api/okr/snapshot)
SNAPSHOT_URL="https://business-builder-kanban.vercel.app/api/okr/snapshot"
SNAP_RESPONSE=$(curl -s --max-time 30 -X POST "$SNAPSHOT_URL" 2>/dev/null || echo '{"ok":false}')
SNAP_OK=$(echo "$SNAP_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ok','false'))" 2>/dev/null || echo "false")
if [ "$SNAP_OK" = "True" ] || [ "$SNAP_OK" = "true" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 메트릭 스냅샷 기록 완료"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: 메트릭 스냅샷 기록 실패"
fi
# report.sh로 보고
"$REPORT_SCRIPT" "$RESULT" "okr-weekly" "🚀 [머스크 VP]"
검증
bash -n "/Users/nbs22/(Claude)/(claude).projects/business-builder/scripts/okr-weekly-review.sh"
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add scripts/okr-weekly-review.sh
git pull --rebase origin main && git commit -m "feat(okr): add metric snapshot step to weekly review" && git push origin main
Task 6: /api/okr/trend 엔드포인트 구현 (스텁 교체)
파일: src/app/api/okr/trend/route.ts (기존 파일 수정)
예상 시간: 3분
현재 상태
스텁 응답 -- points: [] 반환
변경: okr_metric_snapshots 테이블에서 시계열 데이터 조회
import type { NextRequest } from 'next/server';
import { eq, and, sql, asc } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/libs/DB';
import { okrMetricSnapshots } from '@/models/Schema';
// GET /api/okr/trend?metric=monthly_revenue_krw&days=30
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const metric = searchParams.get('metric');
const krId = searchParams.get('kr');
const daysParam = searchParams.get('days') || '30';
const days = Math.min(parseInt(daysParam, 10) || 30, 365);
if (!metric && !krId) {
return NextResponse.json(
{ error: 'metric or kr parameter required', ok: false },
{ status: 400 },
);
}
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
const conditions = [
sql`${okrMetricSnapshots.snapshotDate} >= ${cutoffDateStr}`,
];
if (metric) {
conditions.push(eq(okrMetricSnapshots.metricName, metric));
}
if (krId) {
conditions.push(eq(okrMetricSnapshots.krId, krId));
}
const rows = await db
.select({
date: okrMetricSnapshots.snapshotDate,
value: okrMetricSnapshots.currentValue,
target: okrMetricSnapshots.targetValue,
krId: okrMetricSnapshots.krId,
metricName: okrMetricSnapshots.metricName,
})
.from(okrMetricSnapshots)
.where(and(...conditions))
.orderBy(asc(okrMetricSnapshots.snapshotDate))
.limit(500);
const points = rows.map(r => ({
date: r.date,
value: r.value,
target: r.target,
krId: r.krId,
metricName: r.metricName,
}));
return NextResponse.json({
data: { metric: metric || krId, points, days },
ok: true,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch trend', ok: false },
{ status: 500 },
);
}
}
검증
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard"
npx tsc --noEmit --pretty 2>&1 | head -20
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add projects/kanban-dashboard/src/app/api/okr/trend/route.ts
git pull --rebase origin main && git commit -m "feat(okr): implement /api/okr/trend with metric snapshots" && git push origin main
Task 7: /api/okr/check 엔드포인트 구현 (스텁 교체)
파일: src/app/api/okr/check/route.ts (기존 파일 수정)
예상 시간: 3분
현재 상태
스텁 응답 -- metricsCollected: false 반환
변경: 실제 KR 상태를 읽고, 자동으로 스냅샷 기록
import { eq, sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/libs/DB';
import { okrKeyResults, okrMetricSnapshots } from '@/models/Schema';
import { KR_DEFINITIONS, computeKRStatus } from '@/config/okr-config';
const KR_ID_TO_KEY: Record<string, string> = {
'KR1-1': 'monthly_revenue_krw', 'KR1-2': 'paid_customer_count', 'KR1-3': 'revenue_streams',
'KR2-1': 'email_subscribers', 'KR2-2': 'content_channels', 'KR2-3': 'monthly_visitors',
'KR3-1': 'content_published', 'KR3-2': 'organic_traffic', 'KR3-3': 'brand_channel_reach',
'KR4-1': 'autonomous_completion_rate', 'KR4-2': 'task_completion_rate', 'KR4-3': 'avg_task_cycle_days',
'KR4-4': 'automation_coverage', 'KR4-5': 'documentation_completeness',
'KR4-6': 'goal_set_rate', 'KR4-7': 'holding_rate',
};
// GET /api/okr/check -- Check OKR status + record daily snapshot
export async function GET() {
try {
const now = new Date();
const snapshotDate = now.toISOString().split('T')[0];
// 1. Read all KR values
const krRows = await db
.select({
id: okrKeyResults.id,
objectiveId: okrKeyResults.objectiveId,
currentValue: okrKeyResults.currentValue,
targetValue: okrKeyResults.targetValue,
})
.from(okrKeyResults);
// 2. Compute status for each KR
const krResults = krRows.map(kr => {
const configKey = KR_ID_TO_KEY[kr.id];
const krDef = configKey ? KR_DEFINITIONS.find(d => d.key === configKey) : undefined;
const current = kr.currentValue ?? 0;
const status = krDef ? computeKRStatus(current, krDef) : 'N/A';
return { krId: kr.id, objectiveId: kr.objectiveId, current, target: kr.targetValue, status };
});
// 3. Summary counts
const summary = { total: krResults.length, red: 0, orange: 0, yellow: 0, green: 0 };
for (const kr of krResults) {
if (kr.status === 'RED') summary.red++;
else if (kr.status === 'ORANGE') summary.orange++;
else if (kr.status === 'YELLOW') summary.yellow++;
else if (kr.status === 'GREEN') summary.green++;
}
const hasAlerts = summary.red > 0;
const alertParts: string[] = [];
if (summary.red > 0) alertParts.push(`RED: ${summary.red}`);
if (summary.orange > 0) alertParts.push(`ORANGE: ${summary.orange}`);
const alertMessage = hasAlerts
? `OKR Alert: ${alertParts.join(', ')} (${snapshotDate})`
: `OKR OK: ${summary.green} GREEN, ${summary.yellow} YELLOW (${snapshotDate})`;
// 4. Record daily snapshot (upsert)
let snapshotRecorded = 0;
for (const kr of krRows) {
const metricName = KR_ID_TO_KEY[kr.id] || kr.id;
const existing = await db
.select({ id: okrMetricSnapshots.id })
.from(okrMetricSnapshots)
.where(sql`${okrMetricSnapshots.krId} = ${kr.id} AND ${okrMetricSnapshots.snapshotDate} = ${snapshotDate}`)
.limit(1);
if (existing.length > 0) {
await db.update(okrMetricSnapshots)
.set({ currentValue: kr.currentValue ?? 0, targetValue: kr.targetValue })
.where(eq(okrMetricSnapshots.id, existing[0].id));
} else {
await db.insert(okrMetricSnapshots).values({
objectiveId: kr.objectiveId,
krId: kr.id,
metricName,
currentValue: kr.currentValue ?? 0,
targetValue: kr.targetValue,
snapshotDate,
createdAt: now,
});
}
snapshotRecorded++;
}
return NextResponse.json({
data: {
krResults,
summary,
alertMessage: `[자비스] ${alertMessage}`,
hasAlerts,
hasChanges: false,
changes: [],
metricsCollected: true,
snapshotRecorded,
checkedAt: now.toISOString(),
},
ok: true,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to check OKR', ok: false },
{ status: 500 },
);
}
}
검증
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard"
npx tsc --noEmit --pretty 2>&1 | head -20
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add projects/kanban-dashboard/src/app/api/okr/check/route.ts
git pull --rebase origin main && git commit -m "feat(okr): implement /api/okr/check with real KR status + auto snapshot" && git push origin main
Task 8: 대시보드 OKR 페이지 히스토리 테이블 추가
파일: src/app/okr/page.tsx (기존 파일 수정)
예상 시간: 5분
변경 내용
기존 Tabs 컴포넌트에 "History" 탭 추가. GET /api/okr/snapshots?days=30으로 시계열 데이터를 조회하여 테이블로 표시.
추가 위치: SteeringData 인터페이스 아래에 SnapshotData 인터페이스 추가
interface SnapshotEntry {
id: string;
objectiveId: string;
krId: string;
metricName: string;
currentValue: number;
targetValue: number;
snapshotDate: string;
}
interface SnapshotData {
snapshots: SnapshotEntry[];
period: string;
days: number;
count: number;
}
state 추가 (기존 state들 아래)
const [snapshots, setSnapshots] = useState<SnapshotData | null>(null);
fetchData 수정 (기존 3개 fetch에 4번째 추가)
const [summaryRes, escalationsRes, steeringRes, snapshotsRes] = await Promise.all([
fetch('/api/okr/summary?range=day'),
fetch('/api/okr/escalations?days=7'),
fetch('/api/okr/steering?days=7'),
fetch('/api/okr/snapshots?days=30'),
]);
// ... existing parsing ...
const snapshotsJson = await snapshotsRes.json();
if (snapshotsJson.ok) setSnapshots(snapshotsJson.data);
TabsTrigger 추가 (Steering 다음)
<TabsTrigger value="history">
<TrendingUp className="mr-1 size-4" />
History
</TabsTrigger>
TabsContent 추가 (Steering TabsContent 다음)
<TabsContent value="history" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="size-5" />
Metric Snapshot History
</CardTitle>
<CardDescription>Daily KR snapshots (last 30 days)</CardDescription>
</CardHeader>
<CardContent>
{!snapshots || snapshots.snapshots.length === 0 ? (
<p className="text-sm text-muted-foreground">No snapshot history available. Click Refresh to start recording.</p>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="px-2 py-2 text-left font-medium">Date</th>
<th className="px-2 py-2 text-left font-medium">Objective</th>
<th className="px-2 py-2 text-left font-medium">KR</th>
<th className="px-2 py-2 text-left font-medium">Metric</th>
<th className="px-2 py-2 text-right font-medium">Value</th>
<th className="px-2 py-2 text-right font-medium">Target</th>
<th className="px-2 py-2 text-right font-medium">%</th>
</tr>
</thead>
<tbody>
{snapshots.snapshots.slice(0, 100).map((s) => {
const pct = s.targetValue > 0 ? (s.currentValue / s.targetValue * 100) : 0;
return (
<tr key={s.id} className="border-b hover:bg-muted/50">
<td className="px-2 py-1.5 font-mono text-xs">{s.snapshotDate}</td>
<td className="px-2 py-1.5">
<Badge variant="outline" className="text-xs">{s.objectiveId}</Badge>
</td>
<td className="px-2 py-1.5 text-xs">{s.krId}</td>
<td className="px-2 py-1.5 text-xs">{s.metricName.replace(/_/g, ' ')}</td>
<td className="px-2 py-1.5 text-right font-mono text-xs">{s.currentValue}</td>
<td className="px-2 py-1.5 text-right font-mono text-xs text-muted-foreground">{s.targetValue}</td>
<td className={`px-2 py-1.5 text-right font-mono text-xs ${pct >= 70 ? 'text-green-600' : pct >= 40 ? 'text-yellow-600' : 'text-red-600'}`}>
{pct.toFixed(0)}%
</td>
</tr>
);
})}
</tbody>
</table>
{snapshots.count > 100 && (
<p className="mt-2 text-xs text-muted-foreground">Showing 100 of {snapshots.count} entries</p>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
검증
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard"
npx tsc --noEmit --pretty 2>&1 | head -20
커밋
cd "/Users/nbs22/(Claude)/(claude).projects/business-builder"
git add projects/kanban-dashboard/src/app/okr/page.tsx
git pull --rebase origin main && git commit -m "feat(okr): add History tab with metric snapshot table to OKR dashboard" && git push origin main
실행 순서 및 의존 관계
Task 1 (Schema + DB 테이블)
├── Task 2 (POST /api/okr/snapshot)
├── Task 3 (GET /api/okr/snapshots)
├── Task 6 (GET /api/okr/trend 구현)
└── Task 7 (GET /api/okr/check 구현)
├── Task 4 (CLI 스크립트 -- API 호출)
├── Task 5 (weekly-review 연동)
└── Task 8 (대시보드 UI)
Task 1이 선행 필수. Task 2, 3, 6, 7은 Task 1 완료 후 병렬 가능. Task 4, 5, 8은 API 완성 후.
배포 참고
- Vercel 자동 배포:
git push origin main시 kanban-dashboard 자동 빌드 - Turso DB DDL은
turso db shell kanban "SQL"또는 직접 CLI로 실행 - 프로덕션 URL:
https://business-builder-kanban.vercel.app
KR_ID 매핑 참조 (okr-config.ts)
| KR ID | Config Key | Objective |
|---|---|---|
| KR1-1 | monthly_revenue_krw | O1 |
| KR1-2 | paid_customer_count | O1 |
| KR1-3 | revenue_streams | O1 |
| KR2-1 | email_subscribers | O2 |
| KR2-2 | content_channels | O2 |
| KR2-3 | monthly_visitors | O2 |
| KR3-1 | content_published | O3 |
| KR3-2 | organic_traffic | O3 |
| KR3-3 | brand_channel_reach | O3 |
| KR4-1 | autonomous_completion_rate | O4 |
| KR4-2 | task_completion_rate | O4 |
| KR4-3 | avg_task_cycle_days | O4 |
| KR4-4 | automation_coverage | O4 |
| KR4-5 | documentation_completeness | O4 |
| KR4-6 | goal_set_rate | O4 |
| KR4-7 | holding_rate | O4 |