← 목록으로
2026-02-25plans

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/actionssrc/app/api/okr/actions/route.ts동작 중 -- O4 KR 8개 자동 수집 + okr_snapshots INSERT + okr_key_results.current_value UPDATE
GET /api/okr/summarysrc/app/api/okr/summary/route.ts동작 중 -- okr_snapshots 7일 평균으로 delta 계산
GET /api/okr/trendsrc/app/api/okr/trend/route.ts스텁 -- 빈 배열 반환 (task_metric_snapshots table not available 주석)
GET /api/okr/checksrc/app/api/okr/check/route.ts스텁 -- 하드코딩된 빈 응답
scripts/okr-weekly-review.shscripts/okr-weekly-review.sh동작 중 -- 주간 리뷰 + 목표 상향
scripts/okr-scheduler.shscripts/okr-scheduler.sh동작 중 -- 6시간마다 /api/okr/check 호출
OKR Dashboard UIsrc/app/okr/page.tsx동작 중 -- Objective별 KR 카드 + Alerts + Escalations + Steering 탭
OKR Configsrc/config/okr-config.tsO1~O4, KR 16개 정의, KR_ID_TO_KEY 매핑
DB 연결src/utils/DBConnection.ts@libsql/client + Drizzle ORM via db singleton

핵심 발견

  1. okr_snapshots 테이블이 이미 시계열 히스토리 역할을 수행 중. kr_id, value, collected_at, period_type으로 KR별 시계열 데이터 저장.
  2. VP 요구인 okr_metric_snapshots는 새 테이블이 아니라, 기존 okr_snapshots 활용을 확장하는 것이 효율적. 기존 테이블에 objective_id, metric_name 컬럼 추가로 충분.
  3. /api/okr/trend가 스텁 상태 -- 기존 okr_snapshots 데이터를 읽도록 구현하면 시계열 조회 완성.
  4. /api/okr/check도 스텁 -- 실제 KR 상태를 읽도록 구현 필요.
  5. 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 IDConfig KeyObjective
KR1-1monthly_revenue_krwO1
KR1-2paid_customer_countO1
KR1-3revenue_streamsO1
KR2-1email_subscribersO2
KR2-2content_channelsO2
KR2-3monthly_visitorsO2
KR3-1content_publishedO3
KR3-2organic_trafficO3
KR3-3brand_channel_reachO3
KR4-1autonomous_completion_rateO4
KR4-2task_completion_rateO4
KR4-3avg_task_cycle_daysO4
KR4-4automation_coverageO4
KR4-5documentation_completenessO4
KR4-6goal_set_rateO4
KR4-7holding_rateO4
plans/2026/02/25/okr-snapshot-impl.md