Goal Measurement — Implementation Spec
Problem
Goals created by the deliverable planner store a human-readable metric string and
targetValue string (e.g., “organic sessions”, “+30%”). There is no automated measurement —
progress is shown only as an activity completion percentage, not an actual business outcome.
This spec adds automated metric snapshots sourced from connected channels, so the Goals page can show real progress: baseline → current vs target.
Schema Changes
New enum: GoalMetricType
Machine-readable metric identifiers. Each value maps to a specific provider API call.
enum GoalMetricType {
// Google Search Console
GSC_CLICKS
GSC_IMPRESSIONS
GSC_AVG_POSITION
// Google Analytics
GA_SESSIONS
GA_USERS
GA_PAGEVIEWS
GA_CONVERSIONS
// Google Ads
GOOGLE_ADS_CLICKS
GOOGLE_ADS_IMPRESSIONS
GOOGLE_ADS_CONVERSIONS
GOOGLE_ADS_SPEND
// Meta / Facebook / Instagram
META_REACH
META_IMPRESSIONS
META_FOLLOWERS
// LinkedIn
LINKEDIN_IMPRESSIONS
LINKEDIN_CLICKS
LINKEDIN_FOLLOWERS
}Updated Goal model
New fields added alongside the existing metric (kept as human-readable display label):
| Field | Type | Purpose |
|---|---|---|
metricType | GoalMetricType? | Machine-readable metric enum; set by strategy worker |
targetNumericValue | Float? | Machine-readable target (e.g. 15000 for “15,000 sessions”) |
connectedChannelId | String? | Which channel to pull metrics from |
baselineValue | Float? | Metric value at the time the plan was approved |
snapshots | GoalSnapshot[] | Weekly measurement history |
New model: GoalSnapshot
One row per goal per weekly measurement run.
model GoalSnapshot {
id String @id @default(cuid())
goalId String
tenantId String
connectedChannelId String?
snapshotDate DateTime
metricValue Float
isBaseline Boolean @default(false)
createdAt DateTime @default(now())
goal Goal @relation(...)
tenant Tenant @relation(...)
connectedChannel ConnectedChannel? @relation(...)
@@index([goalId])
@@index([goalId, snapshotDate])
@@map("goal_snapshot")
}Queue
Job data
interface GoalTrackerJobData {
goalId: string;
tenantId: string;
}Queue name
agent__goal-tracker
Enqueue functions
enqueueGoalTracker({ goalId, tenantId })— single goal snapshotenqueueGoalTrackerBaseline({ planId, tenantId })— enqueues one job per active goal on a plan
Worker
File: packages/agents/src/workers/jobs/goal-tracker.job.ts
Pattern: Same as gsc-keywords-snapshot.job.ts — no Claude call, no AgentConfig. Pure
data fetch + DB write.
Per-job flow
- Load
Goal— if nometricTypeor noconnectedChannelId, skip silently - Load
ConnectedChannel— decrypttokenInfo - Refresh OAuth token if within 5 min of expiry (same helper as GSC snapshot worker)
- Compute date range: last 30 days (for trend), point-in-time yesterday for the value
- Call the appropriate provider method based on
metricType - Write
GoalSnapshot { metricValue, snapshotDate: yesterday, isBaseline: false } - If
Goal.baselineValueis null → also setbaselineValueandisBaseline: trueon this snapshot
MetricType → Provider mapping
| MetricType | Provider | Method | Value extracted |
|---|---|---|---|
GSC_CLICKS | GoogleSearchConsoleService | getStats() | sum of clicks |
GSC_IMPRESSIONS | GoogleSearchConsoleService | getStats() | sum of impressions |
GSC_AVG_POSITION | GoogleSearchConsoleService | getStats() | avg of position |
GA_SESSIONS | GoogleAnalyticsService | getOrganicTrafficMetrics() | sessions |
GA_USERS | GoogleAnalyticsService | getOrganicTrafficMetrics() | users |
GA_PAGEVIEWS | GoogleAnalyticsService | getOrganicTrafficMetrics() | pageviews |
GA_CONVERSIONS | GoogleAnalyticsService | runReport() | conversions metric |
GOOGLE_ADS_CLICKS | GoogleAdsService | getCampaignMetrics() | clicks |
GOOGLE_ADS_IMPRESSIONS | GoogleAdsService | getCampaignMetrics() | impressions |
GOOGLE_ADS_CONVERSIONS | GoogleAdsService | getCampaignMetrics() | conversions |
GOOGLE_ADS_SPEND | GoogleAdsService | getCampaignMetrics() | spend |
META_REACH | MetaService | page insights | reach |
META_IMPRESSIONS | MetaService | page insights | impressions |
META_FOLLOWERS | MetaService | page insights | followers |
LINKEDIN_IMPRESSIONS | LinkedInService | org analytics | impressions |
LINKEDIN_CLICKS | LinkedInService | org analytics | clicks |
LINKEDIN_FOLLOWERS | LinkedInService | org analytics | followerCount |
Schedule
Weekly, Monday 03:30 UTC — 30 min after the insights job to avoid token refresh conflicts.
Trigger on plan approval
When a client approves a plan (POST /tenant/v1/deliverable-plan/:planId/approve),
enqueueGoalTrackerBaseline() is called for that plan to capture the baseline value
immediately.
Strategy Worker Changes
The deliverable planner prompt is updated to require metricType and targetNumericValue
in the goals JSON output, alongside the existing human-readable fields.
Prompt addition (in buildPlannerPrompt)
"metricType": "one of: GSC_CLICKS | GSC_IMPRESSIONS | GSC_AVG_POSITION | GA_SESSIONS |
GA_USERS | GA_PAGEVIEWS | GA_CONVERSIONS | GOOGLE_ADS_CLICKS | GOOGLE_ADS_IMPRESSIONS |
GOOGLE_ADS_CONVERSIONS | GOOGLE_ADS_SPEND | META_REACH | META_IMPRESSIONS |
META_FOLLOWERS | LINKEDIN_IMPRESSIONS | LINKEDIN_CLICKS | LINKEDIN_FOLLOWERS",
"targetNumericValue": number — the absolute target value (e.g. 15000 for '15k sessions'),PlannerOutput interface update
interface PlannerGoal {
title: string;
metric: string; // human-readable label (kept)
metricType: string; // GoalMetricType enum value
targetValue: string; // human-readable (kept)
targetNumericValue: number; // machine-readable
timeframeMonths: number;
channel?: string | null;
linkedDeliverableTypes: string[];
}API Changes
New endpoint: GET /tenant/v1/goals/:goalId/progress
Returns current metric value, baseline, target, and time-series snapshots.
Response:
{
goal: {
id, title, metric, metricType, targetValue, targetNumericValue,
baselineValue, timeframeMonths, status
},
progress: {
currentValue: number | null, // latest snapshot value
baselineValue: number | null, // value at plan approval
changeAbsolute: number | null, // currentValue - baselineValue
changePercent: number | null, // (change / baseline) × 100
targetPercent: number | null, // changePercent / target% (if targetNumericValue set)
isAchieved: boolean
},
snapshots: { snapshotDate: string; metricValue: number; isBaseline: boolean }[]
}New endpoint: GET /tenant/v1/goals
Lists all active goals for the tenant’s approved plan, with progress summary embedded.
UI Changes
Goals page (dashboard)
Each goal card shows two progress tracks:
- Activity progress (existing) — deliverables done / planned this period
- Metric progress (new) — real channel metric vs baseline vs target
New elements per card:
- Progress bar:
changePercent / targetPercent × 100% - Metric line: “14,200 sessions — up 25.1% from 11,350 baseline”
- Target line: “Target: +30% (15,355 sessions) by month 6”
- Status badge:
On Track/Ahead/Behind/Achieved - Sparkline: weekly snapshot values (last 8 weeks)
DM portal parity
/dm/goals shows the same data read-only (no actions). Calls the same
/tenant/v1/goals/:goalId/progress endpoint proxied through the DM proxy routes.
Progress Calculation
changeAbsolute = currentValue - baselineValue
changePercent = (changeAbsolute / baselineValue) × 100
// For a "+30%" target expressed as targetNumericValue:
// targetNumericValue = baselineValue × 1.30 (set by Claude at plan time)
targetChangeNeeded = targetNumericValue - baselineValue
progressToTarget = (changeAbsolute / targetChangeNeeded) × 100 // capped at 100
isAchieved = currentValue >= targetNumericValueStatus badge logic:
progressToTarget >= 100→AchievedprogressToTarget >= 80→On TrackprogressToTarget >= 50→BehindprogressToTarget < 50→At Risk
Implementation Order
- Schema migration (
GoalMetricTypeenum +GoalSnapshotmodel + Goal fields) packages/queue—GoalTrackerJobDatatype +enqueueGoalTracker/enqueueGoalTrackerBaselinepackages/agents—goal-tracker.job.tsworkerapps/servers/agents— register worker + weekly scheduleapps/api— goals progress API endpointsapps/api/src/routers/tenant/main.ts— callenqueueGoalTrackerBaselineon plan approvalpackages/agents/src/workers/strategy.worker.ts— update prompt + parser- Dashboard goals page — metric progress UI