Skip to content

feat(team-pulse): per-team Current Projects card, lazy + collapsible (GLOOK-11)#49

Merged
msogin merged 19 commits into
mainfrom
spec/glook-11-team-projects
May 29, 2026
Merged

feat(team-pulse): per-team Current Projects card, lazy + collapsible (GLOOK-11)#49
msogin merged 19 commits into
mainfrom
spec/glook-11-team-projects

Conversation

@msogin

@msogin msogin commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a Current Projects card on the Team Pulse page, derived from each team's GitHub + Jira activity, generated via LLM clustering over the full report window (not just the pulse 4-day window).
  • Card is collapsed by default and lazy — no API call, no LLM call until the user clicks the chevron to expand.
  • Reuses the existing org-level projects pattern: extracts the inline card into a shared <ProjectsCard> component so the home page and the team page share one implementation.
  • Caches projects on team_pulse_summaries.projects (new JSON column) keyed by the existing prompt_version. Legacy rows top up on next expand.
  • Style of the collapsible card matches <TeamPulseCard> (chevron-on-left, compact gray-900 header, body separated by top border).

Closes GLOOK-11.

Architecture (data flow)

User clicks team → TeamPulseCard fetches /api/.../team-pulse  (pulse only, no projects)
User clicks ▶ to expand → SWR fetches  /api/.../team-pulse?withProjects=true
                            → cache row exists? top up projects field
                            → cache miss? generate both pulse + projects
                            → return { summary, health, projects[], ... }
  • getTeamPulse(reportId, teamName, org, members, { withProjects }) — opt-in projects generation.
  • extractTeamProjectsData() — pulls commit + jira rows filtered to team_members, capped at 200 commits.
  • generateTeamProjects() — LLM clusters into named projects; validates developers ⊆ team_members, drops projects with empty developer lists, overrides last_activity from 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:
    • 3 unit tests for extractTeamProjectsData
    • 6 unit tests for generateTeamProjects (short-circuit, well-formed parse, hallucinated-developer filtering, empty-cluster drop, last_activity override, fence-stripping)
    • 2 unit tests for the cache-hit path on getTeamPulse (legacy NULL row → empty; populated row deserialized)
    • Snapshot test for the prompt template
  • tsc --noEmit — clean
  • Local smoke (podman, real MySQL + Smartling LLM): card collapses by default, expands on click, generates projects via real LLM, populates cache row, subsequent expansions instant; home page Top Projects card unchanged.

Files of note

  • src/lib/team-pulse/types.ts — new TeamProject interface
  • src/lib/team-pulse/data.tsextractTeamProjectsData()
  • src/lib/team-pulse/projects.tsgenerateTeamProjects() + LLM call + validation
  • src/lib/team-pulse/service.tsgetTeamPulse(...) now accepts { withProjects }; cache writes projects column
  • prompts/team-pulse-projects.txt — LLM prompt template
  • src/lib/llm-mock.ts — mock fixture
  • src/components/ProjectsCard.tsx — extracted from llm-findings.tsx, gains collapsible mode
  • src/app/llm-findings.tsx — home page uses <ProjectsCard> (no behavior change)
  • src/app/report/[id]/team/page.tsx — collapsed-by-default card with lazy SWR
  • src/app/api/report/[id]/team-pulse/route.ts — reads ?withProjects=true
  • schema.sql, src/lib/db/{mysql,sqlite}.tsprojects JSON NULL column + idempotent migration

Bug 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:

  1. SUBSTRING_INDEX (MySQL-only) → moved first-line extraction to TS (cross-DB safe)
  2. (lines_added + lines_removed) AS lineslines is a reserved word; renamed alias to total_lines
  3. MySQL returns TIMESTAMP as JS Date, not string → coerce to ISO at the extractor boundary

Out of scope (deliberately, per spec)

  • Comparison with the curated Projects page (drift badges). Different unit (LLM cluster vs Jira epic) — separate concern, separate ticket.
  • Refactoring the Projects page itself.

🤖 Generated with Claude Code

msogin and others added 18 commits May 28, 2026 09:11
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 msogin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 real projects value back to NULL under a withProjects=false / withProjects=true race.
  • C2 — schema.sql:195: team_pulse_summaries unique key diverges from the runtime DB modules.
  • I1 — service.ts:67: lazy projects top-up rewrites the pulse summary's generated_at.
  • S1 — team-projects-data.test.ts:25: fixture typo leaves the lines mapping unasserted.

Questions (need a decision — not auto-fixing)

  • Windowing (data.ts:319): extractTeamProjectsData scopes purely by report_id FK with no committed_at >= now()-period_days filter (comment at data.ts:290 says the FK already scopes the window). Confirm report_id is 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 on period_days >= 14 but 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.tsx SWR + projects.ts): SWR error is never read; on LLM parse/timeout the service returns projects: [] (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_activity is 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 under response_format: json_object, but CLAUDE.md warns numeric columns/values can arrive as strings — consider Number() coercion.
  • Redundant cap: SQL LIMIT 200 + .slice(0, 200) in data.ts.
  • Generator tests cover only well-formed responses — no case for unparseable JSON, missing projects key, or non-array developers (the JSON.parse catch → [] path is untested).
  • Three team_pulse_summaries DDL 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.

Comment thread src/lib/team-pulse/service.ts Outdated
[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()`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread src/lib/team-pulse/service.ts Outdated
projects = await generateTeamProjects(projectsInput, teamName);
await db.execute(
`UPDATE team_pulse_summaries
SET projects = ?, generated_at = NOW()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread schema.sql Outdated
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@msogin msogin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate submission (tooling double-posted). See the full review here: #pullrequestreview-4386259140. This entry left intentionally empty.

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.
@msogin msogin merged commit d8f72b9 into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant