feat(team-pulse): per-team Current Projects card, lazy + collapsible (GLOOK-11)#49
Conversation
Per-team LLM-clustered project card on the Team Pulse page, generated on-click alongside the team pulse (full report window). Mirrors the home-page Projects card pattern; reuses a single extracted component. Drift-vs-Projects-page comparison is intentionally out of scope.
Adds the canonical team_pulse_summaries CREATE TABLE to schema.sql (was created at runtime only) plus the new projects JSON column for per-team Current Projects caching. Idempotent ALTER for existing DBs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pulls commits + jira issues for team members across the full report window (implicit via report_id FK). Caps commits at 200 most recent.
Replace SUBSTRING_INDEX (MySQL-only) with SELECT commit_message and in-TS truncation to first line. Caps each at 500 chars. Tests updated to exercise the mapping path. Unblocks T7 service wiring on SQLite.
LLM-clusters team commits/jira into projects. Validates: - developers ⊆ team_members (strip hallucinated logins) - drops projects with empty developers list - overrides last_activity from actual committed_at - short-circuits to [] on empty input (no LLM call)
Adds projects step + JSON column persistence. Legacy cache rows with projects = NULL gracefully return []. Projects failure does not crash the pulse — logged and degraded to [].
Pulls the inline projects card JSX into src/components/ProjectsCard.tsx for reuse on the Team Pulse page. Untracked-work block moves to a sibling card. No behavior change on the home page.
…GLOOK-11) The validator in projects.ts intersects fixture developers with the requested team_members, so logins not in seed teams get filtered out. The original 'alice','bob' don't match scripts/mock-identities.ts — swap to '*-mock' names that span the seeded Frontend/Platform/Data teams. Smoke-confirmed: /api/report/.../team-pulse?team=Frontend returns 2 projects with correctly filtered developers.
… cache (GLOOK-11) Three related fixes for the per-team projects flow: 1. TEAM_NAME placeholder was being substituted with an empty string — prompt context now correctly includes the team name. 2. Bump PROMPT_VERSION 'v2-inflight' → 'v3-projects' so every legacy team_pulse_summaries row (cached before this feature, projects=NULL) gets force-regenerated on next visit. Spec said "no implicit regen" for legacy rows, but in practice that left the card stuck-empty. 3. Diagnostic logging: when generation ends with an empty result, log whether the LLM returned nothing vs. the validator dropped everything (developers not in team_members). Surfaces the failure mode for ops.
…d) (GLOOK-11) 'lines' is reserved in MySQL 8.x and was tripping every projects query with a syntax error. Diagnostic logging from the previous commit surfaced it cleanly. Unit tests passed because db.execute was mocked; runtime hit a real MySQL and choked. Renamed the alias + test fixtures.
…LOOK-11) MySQL driver returns TIMESTAMP columns as JS Date objects (SQLite returns strings). Downstream sort + last_activity override expected strings. Coerce to ISO at the extractor boundary so the rest of the pipeline operates on a single type.
UX: card is collapsed by default on the Team Pulse page; zero API calls and zero LLM calls until the user clicks to expand. - getTeamPulse() takes opts.withProjects (default false). Skips the projects LLM call on cache miss; tops up cached rows with NULL projects when explicitly requested. - API route reads ?withProjects=true and passes it through. - <ProjectsCard> gains a controlled `collapsible`/`expanded` mode with a chevron toggle. Home page renders unchanged. - Team page tracks projectsExpanded; SWR key gates on it, so no network or LLM activity until first expand.
…d (GLOOK-11) Collapsible mode now uses the same compact pulse-card chrome: chevron on the left, gray-900 fill, px-5 py-3 header, body separated by a top border. Home-page standalone mode unchanged. Header also surfaces a small 'N projects' count on the right when expanded + loaded, mirroring the pulse-card's right-side stats.
msogin
left a comment
There was a problem hiding this comment.
Review — GLOOK-11 per-team Current Projects
Reviewed by two personas (Sr. Architect, Sr. Backend Engineer) plus a Smartling code-review pass. Strong, well-tested feature; clean lazy/cached design and defensive LLM validation. Findings below — four actionable (inline), the rest are questions/deferrals.
Blocking / should-fix (inline comments)
- C1 —
service.ts:147: cache-miss upsert can clobber a realprojectsvalue back to NULL under awithProjects=false/withProjects=truerace. - C2 —
schema.sql:195:team_pulse_summariesunique key diverges from the runtime DB modules. - I1 —
service.ts:67: lazy projects top-up rewrites the pulse summary'sgenerated_at. - S1 —
team-projects-data.test.ts:25: fixture typo leaves thelinesmapping unasserted.
Questions (need a decision — not auto-fixing)
- Windowing (
data.ts:319):extractTeamProjectsDatascopes purely byreport_idFK with nocommitted_at >= now()-period_daysfilter (comment at data.ts:290 says the FK already scopes the window). Confirmreport_idis a hard 1:1 proxy for the report window — if commit/jira rows can ever outlive or be shared across windows, the card silently includes out-of-window activity. - Short-window gating (
team/page.tsx): the pulse card is gated onperiod_days >= 14but the projects card is not. Expanding it on a short-window report runs the full pulse-summary LLM call + clustering and persists a pulse row the UI never shows. Gate it the same way, or confirm projects should be available on short windows. - Silent failure UX (
team/page.tsxSWR +projects.ts): SWRerroris never read; on LLM parse/timeout the service returnsprojects: [](HTTP 200), so the card shows the empty-state message — indistinguishable from a team that genuinely has no projects. Confirm the silent-empty behavior is intended vs. surfacing an error/retry.
Deferred (acknowledged, non-blocking)
last_activityis computed per-developer (max of all that dev's commits), not per-cluster, so a dev on two projects stamps both with their latest commit — overstates recency for the quieter project. Already documented as a "best-effort proxy"; fine for v1.Number.isFinite(p.jira_count)rejects string-encoded numbers; low risk underresponse_format: json_object, but CLAUDE.md warns numeric columns/values can arrive as strings — considerNumber()coercion.- Redundant cap: SQL
LIMIT 200+.slice(0, 200)indata.ts. - Generator tests cover only well-formed responses — no case for unparseable JSON, missing
projectskey, or non-arraydevelopers(theJSON.parsecatch →[]path is untested). - Three
team_pulse_summariesDDL definitions (schema.sql, mysql.ts, sqlite.ts) can drift — a test asserting they agree on columns + keys would prevent recurrence (broader than this PR).
Reviews run via /sl-core-dev:review-loop (2 personas) + Smartling review, in parallel.
| [reportId, teamName, org, summary, JSON.stringify(health), PROMPT_VERSION], | ||
| `INSERT INTO team_pulse_summaries (report_id, team_name, org, summary_text, health_json, projects, prompt_version) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?) | ||
| ON DUPLICATE KEY UPDATE summary_text = VALUES(summary_text), health_json = VALUES(health_json), projects = VALUES(projects), prompt_version = VALUES(prompt_version), generated_at = NOW()`, |
There was a problem hiding this comment.
C1 (Critical) — concurrent write can clobber projects back to NULL.
On a cold cache, TeamPulseCard fetches team-pulse (no withProjects) while the expand action fetches the same endpoint with withProjects=true. If both take the cache-miss path concurrently, both run this upsert. projects = VALUES(projects) means the withProjects=false write (projectsForDb = null) — if it lands second — overwrites the real projects value with NULL. The persisted row is then NULL, so every later visit re-runs the lazy top-up LLM call.
Suggest: projects = COALESCE(VALUES(projects), projects) so a NULL write never overwrites an existing value. (The SQLite translator maps VALUES(projects) → excluded.projects, so COALESCE(VALUES(projects), projects) translates correctly.)
| projects = await generateTeamProjects(projectsInput, teamName); | ||
| await db.execute( | ||
| `UPDATE team_pulse_summaries | ||
| SET projects = ?, generated_at = NOW() |
There was a problem hiding this comment.
I1 (Important) — top-up clobbers the pulse summary's generated_at.
This UPDATE runs only to fill the lazily-generated projects, but it also sets generated_at = NOW(), making the pulse summary appear freshly regenerated when only the projects column was filled. Drop generated_at = NOW() here (set only projects = ?), or track projects timing in a separate column.
| prompt_version VARCHAR(50) NOT NULL, | ||
| generated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||
| FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE, | ||
| UNIQUE KEY uq_report_team_version (report_id, team_name, prompt_version) |
There was a problem hiding this comment.
C2 (Critical) — unique key diverges from the runtime DB modules.
schema.sql declares (report_id, team_name, prompt_version), but both runtime creators use (report_id, team_name) only — src/lib/db/mysql.ts:117 (uq_report_team_pulse) and src/lib/db/sqlite.ts:214. Production builds tables from the runtime modules, not this file, so getTeamPulse's version-keyed SELECT + ON DUPLICATE KEY UPDATE rely on the conflict target being (report_id, team_name) (one row per team, prompt_version as an invalidator). A fresh DB built from schema.sql would instead let v2/v3 rows coexist — different behavior.
The runtime "one row, replace-in-place" design is the intended one, so align this line to (report_id, team_name). The three definitions must agree.
| it('filters commits + jira to team members for this report', async () => { | ||
| exec | ||
| .mockResolvedValueOnce([[ | ||
| { sha: 'aaa', repo: 'svc', pr_number: 1, commit_message: 'fix bug', github_login: 'alice', total_total_lines: 50, committed_at: '2026-05-20T10:00:00Z' }, |
There was a problem hiding this comment.
S1 (Suggestion) — fixture typo masks the lines mapping.
total_total_lines: 50 (typo) — the extractor reads total_lines, so commits[0].lines is undefined here and the test passes only because it never asserts lines. Rename to total_lines: 50 and add expect(result.commits[0].lines).toBe(50); to actually lock in the mapping (relevant given numeric values can come back as strings per CLAUDE.md).
C1 [critical] — cache-miss upsert race. Concurrent withProjects=false + withProjects=true paths could clobber a successfully-generated projects value back to NULL. Switch ON DUPLICATE KEY UPDATE clause to `projects = COALESCE(VALUES(projects), projects)` so a NULL write never overwrites a real value. SQLite translator handles the COALESCE wrap correctly (VALUES(col) → excluded.col). C2 [critical] — schema.sql unique key diverged from the runtime DB modules. Schema.sql had (report_id, team_name, prompt_version); runtime (mysql.ts + sqlite.ts) has (report_id, team_name) only. Runtime is the intended "one row per team, prompt_version as invalidator" design. Aligned schema.sql + dropped ON UPDATE CURRENT_TIMESTAMP on generated_at to match runtime. I1 [important] — lazy projects top-up rewrote generated_at to NOW(), making a projects-only fill look like a fresh pulse regeneration. Dropped the `generated_at = NOW()` from that UPDATE so only `projects` is touched. (Runtime mysql.ts uses DEFAULT CURRENT_TIMESTAMP without ON UPDATE — generated_at stays at its original value naturally.) S1 [suggestion] — sed-replace typo `total_total_lines: 50` in the extractor test fixture left the total_lines → lines mapping unverified. Renamed to `total_lines` and added `expect(result.commits[0].lines). toBe(50)` to lock in the column-alias mapping. Also addresses Question 2 (short-window gating): projects card now hides entirely on period_days < 14 reports, matching the existing <TeamPulseCard> gate.
Summary
<ProjectsCard>component so the home page and the team page share one implementation.team_pulse_summaries.projects(new JSON column) keyed by the existingprompt_version. Legacy rows top up on next expand.<TeamPulseCard>(chevron-on-left, compactgray-900header, body separated by top border).Closes GLOOK-11.
Architecture (data flow)
getTeamPulse(reportId, teamName, org, members, { withProjects })— opt-in projects generation.extractTeamProjectsData()— pulls commit + jira rows filtered toteam_members, capped at 200 commits.generateTeamProjects()— LLM clusters into named projects; validatesdevelopers ⊆ team_members, drops projects with empty developer lists, overrideslast_activityfrom real commit timestamps.What's editable per Google-Slides-style preview (none — this is code)
N/A.
Test plan
npm test— 700/700 passing across 69 suites, 9 snapshots. Includes:extractTeamProjectsDatagenerateTeamProjects(short-circuit, well-formed parse, hallucinated-developer filtering, empty-cluster drop,last_activityoverride, fence-stripping)getTeamPulse(legacy NULL row → empty; populated row deserialized)tsc --noEmit— cleanFiles of note
src/lib/team-pulse/types.ts— newTeamProjectinterfacesrc/lib/team-pulse/data.ts—extractTeamProjectsData()src/lib/team-pulse/projects.ts—generateTeamProjects()+ LLM call + validationsrc/lib/team-pulse/service.ts—getTeamPulse(...)now accepts{ withProjects }; cache writes projects columnprompts/team-pulse-projects.txt— LLM prompt templatesrc/lib/llm-mock.ts— mock fixturesrc/components/ProjectsCard.tsx— extracted fromllm-findings.tsx, gainscollapsiblemodesrc/app/llm-findings.tsx— home page uses<ProjectsCard>(no behavior change)src/app/report/[id]/team/page.tsx— collapsed-by-default card with lazy SWRsrc/app/api/report/[id]/team-pulse/route.ts— reads?withProjects=trueschema.sql,src/lib/db/{mysql,sqlite}.ts—projects JSON NULLcolumn + idempotent migrationBug fixes discovered during local validation
The TDD passes hid three runtime issues that surfaced only when the extractor ran against a real MySQL with real Smartling LLM. All fixed in the same branch:
SUBSTRING_INDEX(MySQL-only) → moved first-line extraction to TS (cross-DB safe)(lines_added + lines_removed) AS lines→linesis a reserved word; renamed alias tototal_linesDate, not string → coerce to ISO at the extractor boundaryOut of scope (deliberately, per spec)
🤖 Generated with Claude Code