Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 134 additions & 79 deletions src/app/api/dashboard/team/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@ type Insights = {
};

export async function GET() {
try {
return await buildTeamResponse();
} catch (err) {
// Auth/org lookups (Clerk) failed unexpectedly. Log the real cause so it
// is visible in runtime logs — before #358 this surfaced as an opaque 500
// with no server-side trace, which made the Redis/KV misconfig invisible.
console.error("[dashboard/team] fetch failed:", err);
return NextResponse.json({ error: "Failed to load team" }, { status: 500 });
}
}

async function buildTeamResponse(): Promise<NextResponse> {
const { userId, orgId, orgRole } = await auth();

if (!userId) {
Expand Down Expand Up @@ -261,6 +273,9 @@ export async function GET() {
};
let totalScores = 0;
let totalScoreSum = 0;
// Flipped true if any Redis-backed enrichment fails. The roster (from Clerk)
// still renders; the client shows a "live data unavailable" notice (#358).
let degraded = false;

// Hide the provisioning service account (the Drawbackwards owner) from the
// client's roster and counts — it owns the org but must be invisible to the
Expand Down Expand Up @@ -295,59 +310,84 @@ export async function GET() {
};
}

const [stats, recent, monthlyScans] = await Promise.all([
getUserStats(memberUserId),
readRecentScores(memberUserId, activityWindowStart),
getMonthlyScans(memberUserId),
]);

// Performance metrics (recentScans, team avg, rung insights) and the
// row heatmap reflect *design* sessions only, by Ward's product call.
// Evaluations are tracked separately so audit/research work is visible
// without diluting craft signals.
const designRecent = recent.filter(
(s) => effectiveSessionType(s) === "design",
);
const evaluationsInWindow = recent.filter(
(s) =>
effectiveSessionType(s) === "evaluation" &&
typeof s.timestamp === "number" &&
s.timestamp >= activityWindowStart,
).length;

let memberRecentCount = 0;
for (const s of designRecent) {
if (typeof s.score !== "number" || !Number.isFinite(s.score)) continue;
if (typeof s.timestamp !== "number") continue;
if (s.timestamp < insightsWindowStart) continue;
memberRecentCount += 1;
totalScores += 1;
totalScoreSum += s.score;
const rs = parseRungScores(s.rungs);
if (rs) {
for (const name of RUNG_NAMES) {
rungSums[name] += rs[name].score;
rungCounts[name] += 1;
try {
const [stats, recent, monthlyScans] = await Promise.all([
getUserStats(memberUserId),
readRecentScores(memberUserId, activityWindowStart),
getMonthlyScans(memberUserId),
]);

// Performance metrics (recentScans, team avg, rung insights) and the
// row heatmap reflect *design* sessions only, by Ward's product call.
// Evaluations are tracked separately so audit/research work is visible
// without diluting craft signals.
const designRecent = recent.filter(
(s) => effectiveSessionType(s) === "design",
);
const evaluationsInWindow = recent.filter(
(s) =>
effectiveSessionType(s) === "evaluation" &&
typeof s.timestamp === "number" &&
s.timestamp >= activityWindowStart,
).length;

let memberRecentCount = 0;
for (const s of designRecent) {
if (typeof s.score !== "number" || !Number.isFinite(s.score))
continue;
if (typeof s.timestamp !== "number") continue;
if (s.timestamp < insightsWindowStart) continue;
memberRecentCount += 1;
totalScores += 1;
totalScoreSum += s.score;
const rs = parseRungScores(s.rungs);
if (rs) {
for (const name of RUNG_NAMES) {
rungSums[name] += rs[name].score;
rungCounts[name] += 1;
}
}
}
}

return {
...base,
stats,
recentScans: memberRecentCount,
monthlyScans,
activity: bucketActivity(designRecent, ACTIVITY_WINDOW_DAYS),
evaluationsInWindow,
};
return {
...base,
stats,
recentScans: memberRecentCount,
monthlyScans,
activity: bucketActivity(designRecent, ACTIVITY_WINDOW_DAYS),
evaluationsInWindow,
};
} catch (err) {
// Redis/KV read failed for this member — degrade to roster-only
// rather than failing the whole team page (#358).
console.error(
`[dashboard/team] stats unavailable for ${memberUserId}:`,
err,
);
degraded = true;
return {
...base,
stats: null,
recentScans: 0,
monthlyScans: 0,
activity: [],
evaluationsInWindow: 0,
};
}
}),
);

// Archived members — historical, no longer in the org but their work still
// counts toward team insights. Manager-only.
let archived: ArchivedSummary[] = [];
if (isManager) {
const archivedIds = await listArchivedMembers(orgId);
let archivedIds: string[] = [];
try {
archivedIds = await listArchivedMembers(orgId);
} catch (err) {
console.error("[dashboard/team] archived list unavailable:", err);
degraded = true;
}
archived = await Promise.all(
archivedIds.map(async (memberUserId): Promise<ArchivedSummary> => {
const base: ArchivedSummary = {
Expand Down Expand Up @@ -375,45 +415,57 @@ export async function GET() {
// history under the userId, so let the row render with the unknown name.
}

const [stats, recent] = await Promise.all([
getUserStats(memberUserId),
readRecentScores(memberUserId, activityWindowStart),
]);

const designRecent = recent.filter(
(s) => effectiveSessionType(s) === "design",
);
const evaluationsInWindow = recent.filter(
(s) =>
effectiveSessionType(s) === "evaluation" &&
typeof s.timestamp === "number" &&
s.timestamp >= activityWindowStart,
).length;

let recentScans = 0;
for (const s of designRecent) {
if (typeof s.score !== "number" || !Number.isFinite(s.score)) continue;
if (typeof s.timestamp !== "number") continue;
if (s.timestamp < insightsWindowStart) continue;
recentScans += 1;
totalScores += 1;
totalScoreSum += s.score;
const rs = parseRungScores(s.rungs);
if (rs) {
for (const name of RUNG_NAMES) {
rungSums[name] += rs[name].score;
rungCounts[name] += 1;
try {
const [stats, recent] = await Promise.all([
getUserStats(memberUserId),
readRecentScores(memberUserId, activityWindowStart),
]);

const designRecent = recent.filter(
(s) => effectiveSessionType(s) === "design",
);
const evaluationsInWindow = recent.filter(
(s) =>
effectiveSessionType(s) === "evaluation" &&
typeof s.timestamp === "number" &&
s.timestamp >= activityWindowStart,
).length;

let recentScans = 0;
for (const s of designRecent) {
if (typeof s.score !== "number" || !Number.isFinite(s.score))
continue;
if (typeof s.timestamp !== "number") continue;
if (s.timestamp < insightsWindowStart) continue;
recentScans += 1;
totalScores += 1;
totalScoreSum += s.score;
const rs = parseRungScores(s.rungs);
if (rs) {
for (const name of RUNG_NAMES) {
rungSums[name] += rs[name].score;
rungCounts[name] += 1;
}
}
}
}

return {
...base,
stats,
recentScans,
activity: bucketActivity(designRecent, ACTIVITY_WINDOW_DAYS),
evaluationsInWindow,
};
return {
...base,
stats,
recentScans,
activity: bucketActivity(designRecent, ACTIVITY_WINDOW_DAYS),
evaluationsInWindow,
};
} catch (err) {
// Degrade this archived row to name-only rather than failing the
// page (#358). `base` already carries null stats / empty activity.
console.error(
`[dashboard/team] archived stats unavailable for ${memberUserId}:`,
err,
);
degraded = true;
return base;
}
}),
);
}
Expand Down Expand Up @@ -459,6 +511,9 @@ export async function GET() {
members,
archived,
insights,
// True when some Redis-backed performance data couldn't be loaded; the
// roster still renders and the client shows a non-blocking notice (#358).
degraded,
activityWindowDays: ACTIVITY_WINDOW_DAYS,
pool: {
used: poolUsed,
Expand Down
11 changes: 11 additions & 0 deletions src/app/dashboard/team/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export type TeamData = {
insights: Insights | null;
activityWindowDays: number;
pool: TeamPool;
/** True when live performance data couldn't be loaded; roster still shows (#358). */
degraded?: boolean;
};

function fmtDate(input: number | string | Date | null | undefined): string {
Expand Down Expand Up @@ -988,6 +990,15 @@ export default function TeamPage() {
</div>
)}

{!teamErrEff && teamDataEff?.degraded && (
<div className="mb-6 border border-[#3a3a2a] bg-[#1f1d12] text-yellow-500/90 text-xs font-sans p-3">
Live performance data is temporarily unavailable, so scores and
activity may be missing. Your team roster is current and scoring
still works — this will refresh automatically once the data service
recovers.
</div>
)}

<div className="border-b border-[#2a2a2a] flex items-center gap-2 mb-8 overflow-x-auto">
<TabButton
label="Team"
Expand Down
6 changes: 3 additions & 3 deletions src/content/hq/api.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: API Protocols
updatedAt: 2026-06-09
updatedAt: 2026-06-24
updatedBy: Sara
lastPr: 332
lastPr: 359
---

## Two surfaces, one to come
Expand All @@ -25,7 +25,7 @@ All endpoints below are real routes in this repo. Find them under `src/app/api/`
| `/api/dashboard/scores` | GET | Clerk session | User's score history. |
| `/api/dashboard/scores/[id]` | GET | Clerk session | Individual score detail. Owner by default; a Team Lead can pass `?member=<userId>` to read a score owned by a member of their active org (authorized as `org:admin` + member-in-org, mirroring the member-detail endpoint). Team Leads also receive soft-deleted scores for audit (#300). |
| `/api/dashboard/scores/[id]/annotations` | GET, POST | Clerk session | Reviews pin annotations. |
| `/api/dashboard/team` | GET | Clerk Org (team) | Team dashboard data. |
| `/api/dashboard/team` | GET | Clerk Org (team) | Team dashboard data. On a data-store (Redis/KV) failure, degrades to the Clerk member roster with `degraded: true` instead of 500ing; only auth/Clerk failures return 500 (#358). |
| `/api/skill/token` | POST | Clerk session | Issue a Skill token for use in Claude.ai. |
| `/api/skill/score` | POST | Skill token | Scoring callable from the Ladder Skill. |
| `/api/plugin/issue-token` | POST | Clerk session | Issue a plugin token for use in the Figma plugin. |
Expand Down
Loading