Compare commits
10 Commits
980297ea92
...
9d61d2c8ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d61d2c8ca | |||
| fbfd25ffff | |||
| f38e67252b | |||
| 0c87d9f5a4 | |||
| 8830c223aa | |||
| 52ee98d8aa | |||
| 03b4c6cafb | |||
| 9ed77f99a8 | |||
| afc3876210 | |||
| c37fdab8fa |
+587
-587
File diff suppressed because it is too large
Load Diff
@@ -8,3 +8,16 @@ Stories: 32 (US-001 through US-032)
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
No iterations completed yet.
|
No iterations completed yet.
|
||||||
|
2026-02-13 22:57 | PASS | US-001: Clean up unused legacy components and hooks | model=opus elapsed=01:58 tools=18
|
||||||
|
2026-02-13 22:59 | PASS | US-002: Add new TypeScript types and CSS custom properties for depth features | model=sonnet elapsed=01:54 tools=11
|
||||||
|
2026-02-13 23:03 | PASS | US-003: Create DetailPanelContext, DetailPanel component, and useFocusTrap hook | model=sonnet elapsed=03:39 tools=22
|
||||||
|
2026-02-13 23:06 | PASS | US-004: Create SubNav component and useActiveSection hook | model=sonnet elapsed=02:54 tools=18
|
||||||
|
2026-02-13 23:08 | PASS | US-005: Expand skills data from 5 to ~20 with three categories | model=sonnet elapsed=01:58 tools=11
|
||||||
|
2026-02-13 23:10 | PASS | US-006: Add KPI story data and update 4th KPI | model=sonnet elapsed=01:59 tools=9
|
||||||
|
2026-02-13 23:11 | PASS | US-007: Create education extras data file | model=sonnet elapsed=01:25 tools=10
|
||||||
|
2026-02-13 23:15 | PASS | US-008: Restructure DashboardLayout with SubNav, new tile order, and DetailPanel | model=sonnet elapsed=03:10 tools=27
|
||||||
|
2026-02-13 23:17 | PASS | US-009: Create constellation data mapping file | model=sonnet elapsed=02:20 tools=10
|
||||||
|
2026-02-13 23:50 | PASS | US-011: Modify CoreSkillsTile: full width, categorised groups, panel triggers | model=opus elapsed=02:54 tools=22
|
||||||
|
2026-02-13 23:52 | PASS | US-012: Modify ProjectsTile: half width, compact card grid, panel trigger | model=sonnet elapsed=02:16 tools=11
|
||||||
|
2026-02-13 23:55 | PASS | US-013: Modify LastConsultationTile: add panel trigger | model=sonnet elapsed=02:20 tools=15
|
||||||
|
2026-02-13 23:58 | PASS | US-014: Modify CareerActivityTile: panel triggers and hover preview | model=sonnet elapsed=02:49 tools=14
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Ralph Wiggum Loop - PRD-driven variant.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Iterates through user stories in prd.json, spawning a fresh `claude --print`
|
||||||
|
invocation for each story. Memory persists via filesystem only: git commits,
|
||||||
|
prd.json (passes field), and progress.txt.
|
||||||
|
|
||||||
|
Each iteration works on ONE user story (in priority order).
|
||||||
|
When all stories pass, the loop completes.
|
||||||
|
|
||||||
|
Circuit breakers prevent runaway costs:
|
||||||
|
- No git changes for N consecutive iterations (stalled)
|
||||||
|
- Same error repeated N consecutive iterations (stuck)
|
||||||
|
|
||||||
|
.PARAMETER Model
|
||||||
|
Initial Claude model to use. Default: "opus". The agent can dynamically switch
|
||||||
|
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||||
|
|
||||||
|
.PARAMETER MaxNoProgress
|
||||||
|
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
|
||||||
|
|
||||||
|
.PARAMETER MaxSameError
|
||||||
|
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
|
||||||
|
|
||||||
|
.PARAMETER StartFrom
|
||||||
|
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Model = "opus",
|
||||||
|
[int]$MaxNoProgress = 3,
|
||||||
|
[int]$MaxSameError = 3,
|
||||||
|
[string]$StartFrom = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$prdFile = Join-Path $scriptDir "prd.json"
|
||||||
|
$progressFile = Join-Path $scriptDir "progress.txt"
|
||||||
|
$logDir = Join-Path $scriptDir "logs"
|
||||||
|
|
||||||
|
# --- Find project root (git repo root) ---
|
||||||
|
|
||||||
|
$projectRoot = git rev-parse --show-toplevel 2>$null
|
||||||
|
if (-not $projectRoot) {
|
||||||
|
Write-Error "Not inside a git repository. Run from the project directory."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$projectRoot = (Resolve-Path $projectRoot).Path
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
|
||||||
|
if (-not (Test-Path $prdFile)) {
|
||||||
|
Write-Error "prd.json not found at $prdFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure logs directory exists
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir | Out-Null
|
||||||
|
Write-Host "Created logs directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- PRD Read/Write ---
|
||||||
|
|
||||||
|
function Read-Prd {
|
||||||
|
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-Prd {
|
||||||
|
param($prdObj)
|
||||||
|
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# --- Git Setup ---
|
||||||
|
|
||||||
|
$BranchName = $prd.branchName
|
||||||
|
|
||||||
|
if ($BranchName) {
|
||||||
|
$currentBranch = git branch --show-current
|
||||||
|
if ($currentBranch -ne $BranchName) {
|
||||||
|
$branchExists = git branch --list $BranchName
|
||||||
|
if ($branchExists) {
|
||||||
|
Write-Host "Switching to existing branch: $BranchName"
|
||||||
|
git checkout $BranchName
|
||||||
|
} else {
|
||||||
|
Write-Host "Creating branch: $BranchName"
|
||||||
|
git checkout -b $BranchName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Handle StartFrom: mark earlier stories as passed ---
|
||||||
|
|
||||||
|
if ($StartFrom) {
|
||||||
|
$startPriority = [int]($StartFrom -replace 'US-0*', '')
|
||||||
|
$skippedCount = 0
|
||||||
|
foreach ($story in $prd.userStories) {
|
||||||
|
$storyPriority = [int]($story.id -replace 'US-0*', '')
|
||||||
|
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
|
||||||
|
$story.passes = $true
|
||||||
|
$story.notes = "Skipped (--StartFrom $StartFrom)"
|
||||||
|
$skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($skippedCount -gt 0) {
|
||||||
|
Save-Prd $prd
|
||||||
|
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker State ---
|
||||||
|
|
||||||
|
$noProgressCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
$sameErrorCount = 0
|
||||||
|
|
||||||
|
# --- Prompt Generation ---
|
||||||
|
|
||||||
|
function Build-StoryPrompt {
|
||||||
|
param(
|
||||||
|
$story,
|
||||||
|
$prdObj,
|
||||||
|
[array]$completedStories
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build completed list
|
||||||
|
$completedSection = ""
|
||||||
|
if ($completedStories.Count -gt 0) {
|
||||||
|
$completedLines = ($completedStories | ForEach-Object {
|
||||||
|
"- $($_.id): $($_.title)"
|
||||||
|
}) -join "`n"
|
||||||
|
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build criteria list
|
||||||
|
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
|
||||||
|
|
||||||
|
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
|
||||||
|
$sid = $story.id
|
||||||
|
$stitle = $story.title
|
||||||
|
$sdesc = $story.description
|
||||||
|
$pdesc = $prdObj.description
|
||||||
|
|
||||||
|
$prompt = @(
|
||||||
|
"# Ralph Iteration: $sid - $stitle"
|
||||||
|
""
|
||||||
|
"## Project"
|
||||||
|
"$pdesc"
|
||||||
|
""
|
||||||
|
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
|
||||||
|
""
|
||||||
|
"## Your Task"
|
||||||
|
""
|
||||||
|
"**${sid}: $stitle**"
|
||||||
|
""
|
||||||
|
"$sdesc"
|
||||||
|
""
|
||||||
|
"## Acceptance Criteria"
|
||||||
|
""
|
||||||
|
"$criteriaLines"
|
||||||
|
""
|
||||||
|
"## Reference Documents"
|
||||||
|
""
|
||||||
|
"Read these as needed for implementation detail:"
|
||||||
|
""
|
||||||
|
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
|
||||||
|
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
|
||||||
|
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
|
||||||
|
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
|
||||||
|
"$completedSection"
|
||||||
|
"## Workflow"
|
||||||
|
""
|
||||||
|
"1. Read CLAUDE.md to understand project conventions"
|
||||||
|
"2. Read Ralph/depth-design.md sections relevant to this story"
|
||||||
|
"3. Read existing source files you will modify to understand current patterns"
|
||||||
|
"4. Implement ALL acceptance criteria"
|
||||||
|
"5. Run npm run typecheck - fix any type errors"
|
||||||
|
"6. Run npm run build - fix any build errors"
|
||||||
|
"7. Stage and commit your changes:"
|
||||||
|
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
|
||||||
|
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
|
||||||
|
""
|
||||||
|
"## Rules"
|
||||||
|
""
|
||||||
|
"- Work ONLY on $sid. Do not modify code for other stories."
|
||||||
|
"- Read files before modifying them."
|
||||||
|
"- Follow existing patterns and conventions in the codebase."
|
||||||
|
"- Use lucide-react for icons, never unicode symbols."
|
||||||
|
"- Use the project's CSS custom properties and Tailwind tokens."
|
||||||
|
"- Commit specific files, not git add -A."
|
||||||
|
"- Do NOT start a dev server (npm run dev). One is already running on port $devServerPort. Do NOT run any background tasks."
|
||||||
|
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
|
||||||
|
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
|
||||||
|
) -join "`n"
|
||||||
|
|
||||||
|
return $prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Banner ---
|
||||||
|
|
||||||
|
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$totalCount = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
|
||||||
|
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
|
||||||
|
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Dev server port (assumed to be running externally)
|
||||||
|
$devServerPort = 5173
|
||||||
|
Write-Host "Dev server assumed running on port $devServerPort" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Story Loop ---
|
||||||
|
|
||||||
|
$iterationCount = 0
|
||||||
|
$originalDir = Get-Location
|
||||||
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
# Re-read PRD each iteration (in case previous iteration updated it)
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# Partition stories
|
||||||
|
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
|
||||||
|
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
|
||||||
|
|
||||||
|
# Check if all done
|
||||||
|
if ($pendingStories.Count -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
|
||||||
|
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
|
||||||
|
Write-Host "Branch: $BranchName" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentStory = $pendingStories[0]
|
||||||
|
$iterationCount++
|
||||||
|
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
|
||||||
|
|
||||||
|
$storyLabel = "$($currentStory.id): $($currentStory.title)"
|
||||||
|
$pctStr = "${pctComplete}%"
|
||||||
|
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
|
||||||
|
Write-Host $progressMsg -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# Record HEAD before this iteration
|
||||||
|
$headBefore = git rev-parse HEAD 2>$null
|
||||||
|
|
||||||
|
$iterStart = Get-Date
|
||||||
|
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate prompt for this story
|
||||||
|
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
|
||||||
|
|
||||||
|
# --- Spawn Claude ---
|
||||||
|
|
||||||
|
$logFile = Join-Path $logDir "$($currentStory.id).log"
|
||||||
|
$rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl"
|
||||||
|
$maxRetries = 10
|
||||||
|
$retryCount = 0
|
||||||
|
$outputString = ""
|
||||||
|
$apiOverloaded = $false
|
||||||
|
|
||||||
|
do {
|
||||||
|
$apiOverloaded = $false
|
||||||
|
$textBuilder = [System.Text.StringBuilder]::new()
|
||||||
|
$toolCount = 0
|
||||||
|
|
||||||
|
# Clear raw log file for this attempt
|
||||||
|
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
|
||||||
|
|
||||||
|
if ($retryCount -gt 0) {
|
||||||
|
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
|
||||||
|
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
|
||||||
|
Start-Sleep -Seconds $backoffSeconds
|
||||||
|
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Spawn Claude via Process.Start for clean shutdown control ---
|
||||||
|
# Using Process.Start instead of pipeline so we can break on the result
|
||||||
|
# event and force-kill the process tree. The pipeline approach hangs when
|
||||||
|
# Claude spawns background tasks (e.g. npm run dev) that keep stdout open.
|
||||||
|
|
||||||
|
$promptTempFile = Join-Path $logDir "$($currentStory.id).prompt.tmp"
|
||||||
|
$promptContent | Set-Content -Path $promptTempFile -Encoding UTF8
|
||||||
|
|
||||||
|
$claudeArgs = "--print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json"
|
||||||
|
$psi = [System.Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$psi.FileName = "cmd.exe"
|
||||||
|
$psi.Arguments = "/c type `"$promptTempFile`" | claude $claudeArgs"
|
||||||
|
$psi.UseShellExecute = $false
|
||||||
|
$psi.RedirectStandardOutput = $true
|
||||||
|
$psi.RedirectStandardError = $true
|
||||||
|
$psi.CreateNoWindow = $true
|
||||||
|
$psi.WorkingDirectory = $projectRoot
|
||||||
|
|
||||||
|
$claudeProc = [System.Diagnostics.Process]::Start($psi)
|
||||||
|
|
||||||
|
# Drain stderr async to prevent buffer deadlock
|
||||||
|
$claudeProc.add_ErrorDataReceived({ param($s,$e) })
|
||||||
|
$claudeProc.BeginErrorReadLine()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while ($null -ne ($line = $claudeProc.StandardOutput.ReadLine())) {
|
||||||
|
$line = $line.Trim()
|
||||||
|
if (-not $line) { continue }
|
||||||
|
|
||||||
|
# Save raw event for debugging
|
||||||
|
try {
|
||||||
|
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
$isResultEvent = $false
|
||||||
|
try {
|
||||||
|
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
|
||||||
|
# --- Tool use start ---
|
||||||
|
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
|
||||||
|
$toolCount++
|
||||||
|
$toolName = $evt.content_block.name
|
||||||
|
Write-Host " [$toolName]" -ForegroundColor DarkCyan
|
||||||
|
}
|
||||||
|
# --- Streaming text ---
|
||||||
|
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
|
||||||
|
Write-Host -NoNewline $evt.delta.text
|
||||||
|
[void]$textBuilder.Append($evt.delta.text)
|
||||||
|
}
|
||||||
|
# --- Result event (terminal — stop reading after this) ---
|
||||||
|
elseif ($evt.type -eq 'result') {
|
||||||
|
if ($evt.subtype -eq 'error_result' -and $evt.error) {
|
||||||
|
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
|
||||||
|
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
|
||||||
|
}
|
||||||
|
elseif ($evt.result) {
|
||||||
|
[void]$textBuilder.AppendLine($evt.result)
|
||||||
|
}
|
||||||
|
$isResultEvent = $true
|
||||||
|
}
|
||||||
|
# --- Message-level content ---
|
||||||
|
elseif ($evt.message -and $evt.message.content) {
|
||||||
|
foreach ($block in $evt.message.content) {
|
||||||
|
if ($block.type -eq 'text' -and $block.text) {
|
||||||
|
Write-Host $block.text
|
||||||
|
[void]$textBuilder.AppendLine($block.text)
|
||||||
|
}
|
||||||
|
elseif ($block.type -eq 'tool_use') {
|
||||||
|
$toolCount++
|
||||||
|
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if ($line -and $line -notmatch '^\s*["\{\[\}\]]') {
|
||||||
|
Write-Host $line -ForegroundColor DarkYellow
|
||||||
|
[void]$textBuilder.AppendLine($line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Result is always the final stream event — stop reading
|
||||||
|
if ($isResultEvent) { break }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
# Kill the Claude process tree to prevent orphaned cmd.exe/node processes
|
||||||
|
if ($claudeProc -and -not $claudeProc.HasExited) {
|
||||||
|
try {
|
||||||
|
taskkill /T /F /PID $claudeProc.Id 2>$null | Out-Null
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
Remove-Item -Path $promptTempFile -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputString = $textBuilder.ToString()
|
||||||
|
|
||||||
|
# Check for 529 overloaded error
|
||||||
|
if ($outputString -match "529.*overloaded|overloaded_error") {
|
||||||
|
$apiOverloaded = $true
|
||||||
|
$retryCount++
|
||||||
|
if ($retryCount -ge $maxRetries) {
|
||||||
|
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Check for usage limit with cooldown
|
||||||
|
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
|
||||||
|
$resetHour = [int]$Matches[1]
|
||||||
|
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
|
||||||
|
$resetAmPm = $Matches[3]
|
||||||
|
|
||||||
|
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
|
||||||
|
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
|
||||||
|
|
||||||
|
$now = Get-Date
|
||||||
|
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
|
||||||
|
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
|
||||||
|
$resetTime = $resetTime.AddMinutes(2)
|
||||||
|
|
||||||
|
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
|
||||||
|
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds $waitSeconds
|
||||||
|
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green
|
||||||
|
|
||||||
|
$apiOverloaded = $true
|
||||||
|
}
|
||||||
|
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
||||||
|
|
||||||
|
# Save log
|
||||||
|
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
||||||
|
|
||||||
|
# Show elapsed time
|
||||||
|
$elapsed = (Get-Date) - $iterStart
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# --- Detect signals ---
|
||||||
|
|
||||||
|
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
|
||||||
|
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
|
||||||
|
$headAfter = git rev-parse HEAD 2>$null
|
||||||
|
$hasGitChanges = $headAfter -ne $headBefore
|
||||||
|
|
||||||
|
# --- Update story status ---
|
||||||
|
|
||||||
|
if ($storyComplete) {
|
||||||
|
# Mark story as passed in prd.json
|
||||||
|
$prd = Read-Prd
|
||||||
|
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
|
||||||
|
if ($storyToUpdate) {
|
||||||
|
$alreadyDone = if (-not $hasGitChanges) { " (already committed)" } else { "" }
|
||||||
|
$storyToUpdate.passes = $true
|
||||||
|
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model.$alreadyDone"
|
||||||
|
}
|
||||||
|
Save-Prd $prd
|
||||||
|
|
||||||
|
# Append to progress.txt
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$el = $elapsed.ToString('mm\:ss')
|
||||||
|
$tag = if ($hasGitChanges) { "PASS" } else { "PASS (no new commits)" }
|
||||||
|
$progressEntry = "$ts | $tag | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
|
||||||
|
if (-not $hasGitChanges) {
|
||||||
|
Write-Host " (Work was already committed)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
$noProgressCount = 0
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
elseif ($storyBlocked) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | BLOCKED | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
|
||||||
|
# Blocked counts as no progress
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# No completion signal
|
||||||
|
if ($hasGitChanges) {
|
||||||
|
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | PARTIAL | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
$noProgressCount = 0
|
||||||
|
} else {
|
||||||
|
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker: No Progress ---
|
||||||
|
|
||||||
|
if ($noProgressCount -ge $MaxNoProgress) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||||
|
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
|
||||||
|
Write-Host "Stuck on: $($currentStory.id) - $($currentStory.title)" -ForegroundColor Red
|
||||||
|
Write-Host "Check $logFile for details." -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker: Repeated Error ---
|
||||||
|
|
||||||
|
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
|
||||||
|
if ($errorLines) {
|
||||||
|
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
|
||||||
|
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
|
||||||
|
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
|
||||||
|
$sameErrorCount++
|
||||||
|
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
|
||||||
|
if ($sameErrorCount -ge $MaxSameError) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
|
||||||
|
Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||||
|
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} elseif ($currentErrorSignature) {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
}
|
||||||
|
$lastErrorSignature = $currentErrorSignature
|
||||||
|
} else {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Dynamic Model Selection ---
|
||||||
|
|
||||||
|
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||||
|
$nextModel = $Matches[1]
|
||||||
|
if ($nextModel -ne $Model) {
|
||||||
|
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||||
|
$Model = $nextModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Brief pause between iterations
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
Set-Location $originalDir
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Final Summary ---
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$finalTotal = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
|
||||||
|
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
|
||||||
|
Write-Host " Logs: $logDir" -ForegroundColor Cyan
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($finalPassed -eq $finalTotal) {
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Ralph Wiggum Loop — PRD-driven variant.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Iterates through user stories in prd.json, spawning a fresh `claude --print`
|
||||||
|
invocation for each story. Memory persists via filesystem only: git commits,
|
||||||
|
prd.json (passes field), and progress.txt.
|
||||||
|
|
||||||
|
Each iteration works on ONE user story (in priority order).
|
||||||
|
When all stories pass, the loop completes.
|
||||||
|
|
||||||
|
Circuit breakers prevent runaway costs:
|
||||||
|
- No git changes for N consecutive iterations (stalled)
|
||||||
|
- Same error repeated N consecutive iterations (stuck)
|
||||||
|
|
||||||
|
.PARAMETER Model
|
||||||
|
Initial Claude model to use. Default: "opus". The agent can dynamically switch
|
||||||
|
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||||
|
|
||||||
|
.PARAMETER MaxNoProgress
|
||||||
|
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
|
||||||
|
|
||||||
|
.PARAMETER MaxSameError
|
||||||
|
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
|
||||||
|
|
||||||
|
.PARAMETER StartFrom
|
||||||
|
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
|
||||||
|
|
||||||
|
.PARAMETER SkipVerify
|
||||||
|
Skip post-iteration typecheck verification. Faster but less safe.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Model = "opus",
|
||||||
|
[int]$MaxNoProgress = 3,
|
||||||
|
[int]$MaxSameError = 3,
|
||||||
|
[string]$StartFrom = "",
|
||||||
|
[switch]$SkipVerify
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$prdFile = Join-Path $scriptDir "prd.json"
|
||||||
|
$progressFile = Join-Path $scriptDir "progress.txt"
|
||||||
|
$logDir = Join-Path $scriptDir "logs"
|
||||||
|
|
||||||
|
# --- Find project root (git repo root) ---
|
||||||
|
|
||||||
|
$projectRoot = git rev-parse --show-toplevel 2>$null
|
||||||
|
if (-not $projectRoot) {
|
||||||
|
Write-Error "Not inside a git repository. Run from the project directory."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$projectRoot = (Resolve-Path $projectRoot).Path
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
|
||||||
|
if (-not (Test-Path $prdFile)) {
|
||||||
|
Write-Error "prd.json not found at $prdFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure logs directory exists
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir | Out-Null
|
||||||
|
Write-Host "Created logs directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- PRD Read/Write ---
|
||||||
|
|
||||||
|
function Read-Prd {
|
||||||
|
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-Prd {
|
||||||
|
param($prdObj)
|
||||||
|
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# --- Git Setup ---
|
||||||
|
|
||||||
|
$BranchName = $prd.branchName
|
||||||
|
|
||||||
|
if ($BranchName) {
|
||||||
|
$currentBranch = git branch --show-current
|
||||||
|
if ($currentBranch -ne $BranchName) {
|
||||||
|
$branchExists = git branch --list $BranchName
|
||||||
|
if ($branchExists) {
|
||||||
|
Write-Host "Switching to existing branch: $BranchName"
|
||||||
|
git checkout $BranchName
|
||||||
|
} else {
|
||||||
|
Write-Host "Creating branch: $BranchName"
|
||||||
|
git checkout -b $BranchName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Handle StartFrom: mark earlier stories as passed ---
|
||||||
|
|
||||||
|
if ($StartFrom) {
|
||||||
|
$startPriority = [int]($StartFrom -replace 'US-0*', '')
|
||||||
|
$skippedCount = 0
|
||||||
|
foreach ($story in $prd.userStories) {
|
||||||
|
$storyPriority = [int]($story.id -replace 'US-0*', '')
|
||||||
|
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
|
||||||
|
$story.passes = $true
|
||||||
|
$story.notes = "Skipped (--StartFrom $StartFrom)"
|
||||||
|
$skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($skippedCount -gt 0) {
|
||||||
|
Save-Prd $prd
|
||||||
|
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker State ---
|
||||||
|
|
||||||
|
$noProgressCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
$sameErrorCount = 0
|
||||||
|
|
||||||
|
# --- Prompt Generation ---
|
||||||
|
|
||||||
|
function Build-StoryPrompt {
|
||||||
|
param(
|
||||||
|
$story,
|
||||||
|
$prdObj,
|
||||||
|
[array]$completedStories
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build completed list
|
||||||
|
$completedSection = ""
|
||||||
|
if ($completedStories.Count -gt 0) {
|
||||||
|
$completedLines = ($completedStories | ForEach-Object {
|
||||||
|
"- $($_.id): $($_.title)"
|
||||||
|
}) -join "`n"
|
||||||
|
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build criteria list
|
||||||
|
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
|
||||||
|
|
||||||
|
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
|
||||||
|
$sid = $story.id
|
||||||
|
$stitle = $story.title
|
||||||
|
$sdesc = $story.description
|
||||||
|
$pdesc = $prdObj.description
|
||||||
|
|
||||||
|
$prompt = @(
|
||||||
|
"# Ralph Iteration: $sid - $stitle"
|
||||||
|
""
|
||||||
|
"## Project"
|
||||||
|
"$pdesc"
|
||||||
|
""
|
||||||
|
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
|
||||||
|
""
|
||||||
|
"## Your Task"
|
||||||
|
""
|
||||||
|
"**${sid}: $stitle**"
|
||||||
|
""
|
||||||
|
"$sdesc"
|
||||||
|
""
|
||||||
|
"## Acceptance Criteria"
|
||||||
|
""
|
||||||
|
"$criteriaLines"
|
||||||
|
""
|
||||||
|
"## Reference Documents"
|
||||||
|
""
|
||||||
|
"Read these as needed for implementation detail:"
|
||||||
|
""
|
||||||
|
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
|
||||||
|
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
|
||||||
|
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
|
||||||
|
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
|
||||||
|
"$completedSection"
|
||||||
|
"## Workflow"
|
||||||
|
""
|
||||||
|
"1. Read CLAUDE.md to understand project conventions"
|
||||||
|
"2. Read Ralph/depth-design.md sections relevant to this story"
|
||||||
|
"3. Read existing source files you will modify to understand current patterns"
|
||||||
|
"4. Implement ALL acceptance criteria"
|
||||||
|
"5. Run npm run typecheck - fix any type errors"
|
||||||
|
"6. Run npm run build - fix any build errors"
|
||||||
|
"7. Stage and commit your changes:"
|
||||||
|
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
|
||||||
|
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
|
||||||
|
""
|
||||||
|
"## Rules"
|
||||||
|
""
|
||||||
|
"- Work ONLY on $sid. Do not modify code for other stories."
|
||||||
|
"- Read files before modifying them."
|
||||||
|
"- Follow existing patterns and conventions in the codebase."
|
||||||
|
"- Use lucide-react for icons, never unicode symbols."
|
||||||
|
"- Use the project's CSS custom properties and Tailwind tokens."
|
||||||
|
"- Commit specific files, not git add -A."
|
||||||
|
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
|
||||||
|
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
|
||||||
|
) -join "`n"
|
||||||
|
|
||||||
|
return $prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Banner ---
|
||||||
|
|
||||||
|
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$totalCount = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
|
||||||
|
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
|
||||||
|
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||||
|
if (-not $SkipVerify) { Write-Host "Post-iteration typecheck verification: ON" -ForegroundColor Cyan }
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Dev Server ---
|
||||||
|
|
||||||
|
$devServerPort = 5173
|
||||||
|
$devServerPid = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
Write-Host "Dev server detected on port $devServerPort" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "Starting dev server (port $devServerPort)..." -ForegroundColor Cyan
|
||||||
|
$devProc = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -WorkingDirectory $projectRoot -PassThru -WindowStyle Minimized
|
||||||
|
$devServerPid = $devProc.Id
|
||||||
|
|
||||||
|
for ($w = 1; $w -le 20; $w++) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
Write-Host "Dev server ready on port $devServerPort" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
if ($w -eq 20) {
|
||||||
|
Write-Warning "Dev server may not be ready — visual review steps may fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Story Loop ---
|
||||||
|
|
||||||
|
$iterationCount = 0
|
||||||
|
$originalDir = Get-Location
|
||||||
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
# Re-read PRD each iteration (in case previous iteration updated it)
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# Partition stories
|
||||||
|
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
|
||||||
|
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
|
||||||
|
|
||||||
|
# Check if all done
|
||||||
|
if ($pendingStories.Count -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
|
||||||
|
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
|
||||||
|
Write-Host "Branch: $BranchName" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentStory = $pendingStories[0]
|
||||||
|
$iterationCount++
|
||||||
|
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
|
||||||
|
|
||||||
|
$storyLabel = "$($currentStory.id): $($currentStory.title)"
|
||||||
|
$pctStr = "${pctComplete}%"
|
||||||
|
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
|
||||||
|
Write-Host $progressMsg -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# Record HEAD before this iteration
|
||||||
|
$headBefore = git rev-parse HEAD 2>$null
|
||||||
|
|
||||||
|
$iterStart = Get-Date
|
||||||
|
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate prompt for this story
|
||||||
|
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
|
||||||
|
|
||||||
|
# --- Spawn Claude ---
|
||||||
|
|
||||||
|
$logFile = Join-Path $logDir "$($currentStory.id).log"
|
||||||
|
$rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl"
|
||||||
|
$maxRetries = 10
|
||||||
|
$retryCount = 0
|
||||||
|
$outputString = ""
|
||||||
|
$apiOverloaded = $false
|
||||||
|
|
||||||
|
do {
|
||||||
|
$apiOverloaded = $false
|
||||||
|
$textBuilder = [System.Text.StringBuilder]::new()
|
||||||
|
$toolCount = 0
|
||||||
|
|
||||||
|
# Clear raw log file for this attempt
|
||||||
|
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
|
||||||
|
|
||||||
|
if ($retryCount -gt 0) {
|
||||||
|
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
|
||||||
|
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
|
||||||
|
Start-Sleep -Seconds $backoffSeconds
|
||||||
|
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
$promptContent | claude --print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json 2>&1 | ForEach-Object {
|
||||||
|
$line = $_.ToString().Trim()
|
||||||
|
if (-not $line) { return }
|
||||||
|
|
||||||
|
# Save raw event for debugging
|
||||||
|
try {
|
||||||
|
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
|
||||||
|
# --- Tool use start ---
|
||||||
|
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
|
||||||
|
$toolCount++
|
||||||
|
$toolName = $evt.content_block.name
|
||||||
|
Write-Host " [$toolName]" -ForegroundColor DarkCyan
|
||||||
|
}
|
||||||
|
# --- Streaming text ---
|
||||||
|
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
|
||||||
|
Write-Host -NoNewline $evt.delta.text
|
||||||
|
[void]$textBuilder.Append($evt.delta.text)
|
||||||
|
}
|
||||||
|
# --- Result event ---
|
||||||
|
elseif ($evt.type -eq 'result') {
|
||||||
|
if ($evt.subtype -eq 'error_result' -and $evt.error) {
|
||||||
|
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
|
||||||
|
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
|
||||||
|
}
|
||||||
|
elseif ($evt.result) {
|
||||||
|
[void]$textBuilder.AppendLine($evt.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# --- Message-level content ---
|
||||||
|
elseif ($evt.message -and $evt.message.content) {
|
||||||
|
foreach ($block in $evt.message.content) {
|
||||||
|
if ($block.type -eq 'text' -and $block.text) {
|
||||||
|
Write-Host $block.text
|
||||||
|
[void]$textBuilder.AppendLine($block.text)
|
||||||
|
}
|
||||||
|
elseif ($block.type -eq 'tool_use') {
|
||||||
|
$toolCount++
|
||||||
|
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if ($line -and $line -notmatch '^\s*[\{\[\}\]"]') {
|
||||||
|
Write-Host $line -ForegroundColor DarkYellow
|
||||||
|
[void]$textBuilder.AppendLine($line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputString = $textBuilder.ToString()
|
||||||
|
|
||||||
|
# Check for 529 overloaded error
|
||||||
|
if ($outputString -match "529.*overloaded|overloaded_error") {
|
||||||
|
$apiOverloaded = $true
|
||||||
|
$retryCount++
|
||||||
|
if ($retryCount -ge $maxRetries) {
|
||||||
|
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Check for usage limit with cooldown
|
||||||
|
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
|
||||||
|
$resetHour = [int]$Matches[1]
|
||||||
|
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
|
||||||
|
$resetAmPm = $Matches[3]
|
||||||
|
|
||||||
|
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
|
||||||
|
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
|
||||||
|
|
||||||
|
$now = Get-Date
|
||||||
|
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
|
||||||
|
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
|
||||||
|
$resetTime = $resetTime.AddMinutes(2)
|
||||||
|
|
||||||
|
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
|
||||||
|
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
|
||||||
|
Start-Sleep -Seconds $waitSeconds
|
||||||
|
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green
|
||||||
|
|
||||||
|
$apiOverloaded = $true
|
||||||
|
}
|
||||||
|
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
||||||
|
|
||||||
|
# Save log
|
||||||
|
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
||||||
|
|
||||||
|
# Show elapsed time
|
||||||
|
$elapsed = (Get-Date) - $iterStart
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# --- Detect signals ---
|
||||||
|
|
||||||
|
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
|
||||||
|
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
|
||||||
|
$headAfter = git rev-parse HEAD 2>$null
|
||||||
|
$hasGitChanges = $headAfter -ne $headBefore
|
||||||
|
|
||||||
|
# --- Post-iteration typecheck verification ---
|
||||||
|
|
||||||
|
$typecheckPassed = $true
|
||||||
|
if ($storyComplete -and $hasGitChanges -and -not $SkipVerify) {
|
||||||
|
Write-Host " Verifying typecheck..." -ForegroundColor DarkGray
|
||||||
|
$typecheckOutput = npm run typecheck 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " [VERIFY FAIL] Typecheck failed after completion signal. Not marking as passed." -ForegroundColor Red
|
||||||
|
$typecheckPassed = $false
|
||||||
|
} else {
|
||||||
|
Write-Host " [VERIFY OK] Typecheck passed." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Update story status ---
|
||||||
|
|
||||||
|
if ($storyComplete -and $hasGitChanges -and $typecheckPassed) {
|
||||||
|
# Mark story as passed in prd.json
|
||||||
|
$prd = Read-Prd
|
||||||
|
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
|
||||||
|
if ($storyToUpdate) {
|
||||||
|
$storyToUpdate.passes = $true
|
||||||
|
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model."
|
||||||
|
}
|
||||||
|
Save-Prd $prd
|
||||||
|
|
||||||
|
# Append to progress.txt
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$el = $elapsed.ToString('mm\:ss')
|
||||||
|
$progressEntry = "$ts | PASS | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
|
||||||
|
$noProgressCount = 0
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
elseif ($storyBlocked) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | BLOCKED | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
|
||||||
|
# Blocked counts as no progress
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
elseif ($storyComplete -and -not $hasGitChanges) {
|
||||||
|
Write-Host " [WARNING] Completion signaled but no git commits. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
elseif ($storyComplete -and -not $typecheckPassed) {
|
||||||
|
Write-Host " [WARNING] Completion signaled but typecheck failed. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | TYPECHECK_FAIL | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
# Has git changes, so not stalled — but not passed either
|
||||||
|
$noProgressCount = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# No completion signal
|
||||||
|
if ($hasGitChanges) {
|
||||||
|
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | PARTIAL | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
$noProgressCount = 0
|
||||||
|
} else {
|
||||||
|
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker: No Progress ---
|
||||||
|
|
||||||
|
if ($noProgressCount -ge $MaxNoProgress) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||||
|
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
|
||||||
|
Write-Host "Stuck on: $($currentStory.id) — $($currentStory.title)" -ForegroundColor Red
|
||||||
|
Write-Host "Check $logFile for details." -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker: Repeated Error ---
|
||||||
|
|
||||||
|
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
|
||||||
|
if ($errorLines) {
|
||||||
|
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
|
||||||
|
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
|
||||||
|
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
|
||||||
|
$sameErrorCount++
|
||||||
|
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
|
||||||
|
if ($sameErrorCount -ge $MaxSameError) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
|
||||||
|
Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||||
|
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} elseif ($currentErrorSignature) {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
}
|
||||||
|
$lastErrorSignature = $currentErrorSignature
|
||||||
|
} else {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Dynamic Model Selection ---
|
||||||
|
|
||||||
|
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||||
|
$nextModel = $Matches[1]
|
||||||
|
if ($nextModel -ne $Model) {
|
||||||
|
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||||
|
$Model = $nextModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Brief pause between iterations
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
# Cleanup: restore directory, kill dev server
|
||||||
|
Set-Location $originalDir
|
||||||
|
if ($devServerPid) {
|
||||||
|
Write-Host "Stopping dev server (PID $devServerPid)..." -ForegroundColor DarkGray
|
||||||
|
taskkill /T /F /PID $devServerPid 2>$null | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Final Summary ---
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$finalTotal = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
|
||||||
|
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
|
||||||
|
Write-Host " Logs: $logDir" -ForegroundColor Cyan
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($finalPassed -eq $finalTotal) {
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
|||||||
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
||||||
import { DetailPanelContent } from '@/types/pmr'
|
import { DetailPanelContent } from '@/types/pmr'
|
||||||
import type { CardHeaderProps } from './Card'
|
import type { CardHeaderProps } from './Card'
|
||||||
|
import { KPIDetail } from './detail/KPIDetail'
|
||||||
|
import { ConsultationDetail } from './detail/ConsultationDetail'
|
||||||
|
|
||||||
// Width mapping from content type
|
// Width mapping from content type
|
||||||
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
|
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
|
||||||
@@ -207,21 +209,31 @@ export function DetailPanel() {
|
|||||||
padding: '24px',
|
padding: '24px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Placeholder content - actual renderers will be added in later stories */}
|
{/* Render content based on type */}
|
||||||
<div
|
{content.type === 'kpi' && <KPIDetail kpi={content.kpi} />}
|
||||||
style={{
|
{(content.type === 'consultation' || content.type === 'career-role') && (
|
||||||
fontFamily: 'var(--font-ui)',
|
<ConsultationDetail consultation={content.consultation} />
|
||||||
color: 'var(--text-secondary)',
|
)}
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
{/* Other content types - placeholder for future stories */}
|
||||||
>
|
{content.type !== 'kpi' &&
|
||||||
<p>
|
content.type !== 'consultation' &&
|
||||||
Detail panel for: <strong>{content.type}</strong>
|
content.type !== 'career-role' && (
|
||||||
</p>
|
<div
|
||||||
<p style={{ marginTop: '8px', fontSize: '12px' }}>
|
style={{
|
||||||
Content renderers will be implemented in subsequent user stories.
|
fontFamily: 'var(--font-ui)',
|
||||||
</p>
|
color: 'var(--text-secondary)',
|
||||||
</div>
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Detail panel for: <strong>{content.type}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: '8px', fontSize: '12px' }}>
|
||||||
|
Content renderers will be implemented in subsequent user stories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import type { Consultation } from '@/types/pmr'
|
||||||
|
|
||||||
|
interface ConsultationDetailProps {
|
||||||
|
consultation: Consultation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsultationDetail({ consultation }: ConsultationDetailProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Role header */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.role}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: consultation.orgColor,
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.organization}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{consultation.duration}</span>
|
||||||
|
{consultation.isCurrent && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
backgroundColor: 'var(--success-light)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History (presenting complaint) */}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.history}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Examination (achievements) */}
|
||||||
|
{consultation.examination && consultation.examination.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Key Achievements
|
||||||
|
</h3>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.examination.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan (outcomes) */}
|
||||||
|
{consultation.plan && consultation.plan.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Outcomes & Impact
|
||||||
|
</h3>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.plan.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coded entries (technical environment / tags) */}
|
||||||
|
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Coded Entries
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.codedEntries.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: 'var(--bg-dashboard)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.code}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { KPI } from '@/types/pmr'
|
||||||
|
|
||||||
|
interface KPIDetailProps {
|
||||||
|
kpi: KPI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color map for KPI values
|
||||||
|
const colorMap: Record<KPI['colorVariant'], string> = {
|
||||||
|
green: '#059669',
|
||||||
|
amber: '#D97706',
|
||||||
|
teal: '#0D6E6E',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KPIDetail({ kpi }: KPIDetailProps) {
|
||||||
|
// If story exists, render rich content; otherwise fallback to explanation
|
||||||
|
if (!kpi.story) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: colorMap[kpi.colorVariant],
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.value}
|
||||||
|
</div>
|
||||||
|
<p>{kpi.explanation}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { context, role, outcomes, period } = kpi.story
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Headline number */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '48px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: colorMap[kpi.colorVariant],
|
||||||
|
lineHeight: '1',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.value}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kpi.sub}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period badge (if present) */}
|
||||||
|
{period && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '4px 10px',
|
||||||
|
backgroundColor: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontFamily: 'var(--font-geist)',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context paragraph */}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Context
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{context}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your role paragraph */}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your Role
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outcome bullets */}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Key Outcomes
|
||||||
|
</h3>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{outcomes.map((outcome, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{outcome}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
import { documents } from '@/data/documents'
|
import { documents } from '@/data/documents'
|
||||||
import { consultations } from '@/data/consultations'
|
import { consultations } from '@/data/consultations'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
|
|
||||||
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
||||||
|
|
||||||
@@ -140,49 +138,46 @@ const dotColorMap: Record<ActivityType, string> = {
|
|||||||
edu: '#7C3AED',
|
edu: '#7C3AED',
|
||||||
}
|
}
|
||||||
|
|
||||||
const borderColorMap: Record<ActivityType, string> = {
|
|
||||||
role: '#0D6E6E',
|
|
||||||
project: '#D97706',
|
|
||||||
cert: '#059669',
|
|
||||||
edu: '#7C3AED',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
entry: ActivityEntry
|
entry: ActivityEntry
|
||||||
isExpanded: boolean
|
onItemClick: () => void
|
||||||
onToggle: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle }) => {
|
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, onItemClick }) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const dotColor = dotColorMap[entry.type]
|
const dotColor = dotColorMap[entry.type]
|
||||||
const isExpandable = entry.type === 'role' && entry.consultationId
|
const isClickable = entry.type === 'role' && entry.consultationId
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (!isExpandable) return
|
if (!isClickable) return
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onToggle()
|
onItemClick()
|
||||||
} else if (e.key === 'Escape' && isExpanded) {
|
|
||||||
e.preventDefault()
|
|
||||||
onToggle()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isExpandable, isExpanded, onToggle],
|
[isClickable, onItemClick],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get consultation data for expanded content
|
// Get consultation data for preview text
|
||||||
const consultation = isExpandable
|
const consultation = isClickable
|
||||||
? consultations.find((c) => c.id === entry.consultationId)
|
? consultations.find((c) => c.id === entry.consultationId)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Get preview text (first 1-2 lines from examination)
|
||||||
|
const previewText =
|
||||||
|
consultation && consultation.examination.length > 0
|
||||||
|
? consultation.examination[0]
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={isExpandable ? 'button' : undefined}
|
role={isClickable ? 'button' : undefined}
|
||||||
tabIndex={isExpandable ? 0 : undefined}
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
aria-expanded={isExpandable ? isExpanded : undefined}
|
onClick={isClickable ? onItemClick : undefined}
|
||||||
onClick={isExpandable ? onToggle : undefined}
|
onKeyDown={isClickable ? handleKeyDown : undefined}
|
||||||
onKeyDown={isExpandable ? handleKeyDown : undefined}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -190,21 +185,13 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle
|
|||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border-light)',
|
border: '1px solid var(--border-light)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'all 0.15s ease-out',
|
||||||
cursor: isExpandable ? 'pointer' : 'default',
|
cursor: isClickable ? 'pointer' : 'default',
|
||||||
...(isExpanded && {
|
transform: isHovered && isClickable ? 'translateY(-1px)' : 'none',
|
||||||
borderColor: 'var(--accent-border)',
|
boxShadow: isHovered && isClickable
|
||||||
}),
|
? '0 2px 8px rgba(26,43,42,0.08)'
|
||||||
}}
|
: '0 1px 2px rgba(26,43,42,0.05)',
|
||||||
onMouseEnter={(e) => {
|
borderColor: isHovered && isClickable ? 'var(--accent-border)' : 'var(--border-light)',
|
||||||
if (isExpandable) {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (isExpandable && !isExpanded) {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Item header row */}
|
{/* Item header row */}
|
||||||
@@ -249,142 +236,76 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle
|
|||||||
>
|
>
|
||||||
{entry.date}
|
{entry.date}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
{/* Hover preview text for roles */}
|
||||||
<AnimatePresence initial={false}>
|
{isHovered && previewText && (
|
||||||
{isExpanded && consultation && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0 }}
|
|
||||||
animate={{ height: 'auto' }}
|
|
||||||
exit={{ height: 0 }}
|
|
||||||
transition={
|
|
||||||
prefersReducedMotion
|
|
||||||
? { duration: 0 }
|
|
||||||
: { duration: 0.2, ease: 'easeOut' }
|
|
||||||
}
|
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `2px solid ${borderColorMap[entry.type]}`,
|
fontSize: '11px',
|
||||||
marginLeft: '16px',
|
color: 'var(--text-secondary)',
|
||||||
marginRight: '12px',
|
marginTop: '6px',
|
||||||
marginBottom: '12px',
|
lineHeight: 1.4,
|
||||||
paddingLeft: '14px',
|
overflow: 'hidden',
|
||||||
paddingTop: '4px',
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Role title */}
|
{previewText}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12.5px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--accent)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{consultation.role}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Achievement bullets */}
|
|
||||||
{consultation.examination.length > 0 && (
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
listStyle: 'none',
|
|
||||||
padding: 0,
|
|
||||||
margin: '0 0 10px 0',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '5px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{consultation.examination.map((item, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '11.5px',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
lineHeight: 1.45,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: 'var(--accent)',
|
|
||||||
opacity: 0.5,
|
|
||||||
flexShrink: 0,
|
|
||||||
marginTop: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Coded entries */}
|
|
||||||
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '6px',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{consultation.codedEntries.map((entry) => (
|
|
||||||
<span
|
|
||||||
key={entry.code}
|
|
||||||
style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '3px',
|
|
||||||
background: 'var(--accent-light)',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
border: '1px solid var(--accent-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.code}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CareerActivityTile: React.FC = () => {
|
export const CareerActivityTile: React.FC = () => {
|
||||||
const timeline = buildTimeline()
|
const timeline = buildTimeline()
|
||||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleItemClick = useCallback(
|
||||||
(id: string) => {
|
(entry: ActivityEntry) => {
|
||||||
setExpandedItemId((prev) => (prev === id ? null : id))
|
if (entry.type === 'role' && entry.consultationId) {
|
||||||
|
const consultation = consultations.find((c) => c.id === entry.consultationId)
|
||||||
|
if (consultation) {
|
||||||
|
openPanel({ type: 'career-role', consultation })
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[openPanel],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card full tileId="career-activity">
|
<Card full tileId="career-activity">
|
||||||
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
||||||
|
|
||||||
|
{/* Placeholder for CareerConstellation component (to be added later) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '200px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-dashboard)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px dashed var(--border-light)',
|
||||||
|
marginBottom: '20px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Career Constellation visualization (to be implemented)
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="activity-grid">
|
<div className="activity-grid">
|
||||||
{timeline.map((entry) => (
|
{timeline.map((entry) => (
|
||||||
<ActivityItem
|
<ActivityItem
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
isExpanded={expandedItemId === entry.id}
|
onItemClick={() => handleItemClick(entry)}
|
||||||
onToggle={() => handleToggle(entry.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,158 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { documents } from '@/data/documents'
|
||||||
|
import { educationExtras } from '@/data/educationExtras'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Education tile - displays academic qualifications
|
* Education tile - displays academic qualifications
|
||||||
* Full-width card below Career Activity
|
* Full-width card below Career Activity
|
||||||
|
* Each entry is clickable to open detail panel
|
||||||
*/
|
*/
|
||||||
export function EducationTile() {
|
export function EducationTile() {
|
||||||
// Education entries from CV, presented in reverse chronological order
|
const { openPanel } = useDetailPanel()
|
||||||
const educationEntries = [
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||||
{
|
|
||||||
degree: 'MPharm (Hons) — 2:1',
|
// Filter to main education entries in reverse chronological order
|
||||||
detail: 'University of East Anglia · 2015',
|
const educationDocuments = [
|
||||||
},
|
documents.find((d) => d.id === 'doc-mary-seacole')!,
|
||||||
{
|
documents.find((d) => d.id === 'doc-mpharm')!,
|
||||||
degree: 'NHS Leadership Academy — Mary Seacole Programme',
|
documents.find((d) => d.id === 'doc-alevels')!,
|
||||||
detail: '2018 · 78%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
degree: 'A-Levels: Mathematics (A*), Chemistry (B), Politics (C)',
|
|
||||||
detail: 'Highworth Grammar School · 2009–2011',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Look up education extras by document ID
|
||||||
|
const getExtras = (docId: string) =>
|
||||||
|
educationExtras.find((e) => e.documentId === docId)
|
||||||
|
|
||||||
|
// Build rich inline content for each entry
|
||||||
|
const getInlineDetails = (doc: (typeof educationDocuments)[0]) => {
|
||||||
|
const extras = getExtras(doc.id)
|
||||||
|
|
||||||
|
switch (doc.id) {
|
||||||
|
case 'doc-mpharm':
|
||||||
|
return {
|
||||||
|
title: 'MPharm (Hons) — 2:1',
|
||||||
|
institution: 'University of East Anglia',
|
||||||
|
year: '2011–2015',
|
||||||
|
details: [
|
||||||
|
`Research project: Drug delivery & cocrystals, 75.1% (Distinction)`,
|
||||||
|
...(extras?.osceScore ? [`4th year OSCE: ${extras.osceScore}`] : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'doc-mary-seacole':
|
||||||
|
return {
|
||||||
|
title: 'NHS Leadership Academy — Mary Seacole Programme',
|
||||||
|
institution: 'NHS Leadership Academy',
|
||||||
|
year: '2018',
|
||||||
|
details: [
|
||||||
|
`Programme score: 78%`,
|
||||||
|
...(extras?.programmeDetail ? [extras.programmeDetail] : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'doc-alevels':
|
||||||
|
return {
|
||||||
|
title: 'A-Levels',
|
||||||
|
institution: 'Highworth Grammar School',
|
||||||
|
year: '2009–2011',
|
||||||
|
details: ['Mathematics (A*) · Chemistry (B) · Politics (C)'],
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: doc.title,
|
||||||
|
institution: doc.institution,
|
||||||
|
year: doc.date,
|
||||||
|
details: doc.classification ? [doc.classification] : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card full tileId="education">
|
<Card full tileId="education">
|
||||||
<CardHeader dotColor="purple" title="EDUCATION" />
|
<CardHeader dotColor="purple" title="EDUCATION" />
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{educationEntries.map((entry, index) => (
|
{educationDocuments.map((doc, index) => {
|
||||||
<div
|
const content = getInlineDetails(doc)
|
||||||
key={index}
|
const isHovered = hoveredIndex === index
|
||||||
style={{
|
|
||||||
padding: '7px 10px',
|
return (
|
||||||
background: 'var(--surface)',
|
<button
|
||||||
border: '1px solid var(--border-light)',
|
key={doc.id}
|
||||||
borderRadius: 'var(--radius-sm)',
|
onClick={() => openPanel({ type: 'education', document: doc })}
|
||||||
fontSize: '11.5px',
|
onMouseEnter={() => setHoveredIndex(index)}
|
||||||
color: 'var(--text-primary)',
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
padding: '10px 12px',
|
||||||
fontWeight: 600,
|
background: 'var(--surface)',
|
||||||
|
border: `1px solid ${isHovered ? 'var(--accent)' : 'var(--border-light)'}`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? '0 2px 8px rgba(26,43,42,0.08)'
|
||||||
|
: '0 1px 2px rgba(26,43,42,0.05)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entry.degree}
|
<div
|
||||||
</span>
|
style={{
|
||||||
<span
|
display: 'flex',
|
||||||
style={{
|
justifyContent: 'space-between',
|
||||||
color: 'var(--text-secondary)',
|
alignItems: 'baseline',
|
||||||
fontSize: '11px',
|
gap: '12px',
|
||||||
marginTop: '2px',
|
marginBottom: '4px',
|
||||||
display: 'block',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<span style={{ fontWeight: 600, fontSize: '12.5px' }}>
|
||||||
{entry.detail}
|
{content.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span
|
||||||
))}
|
style={{
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content.year}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: '11px',
|
||||||
|
marginBottom: content.details.length > 0 ? '6px' : '0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content.institution}
|
||||||
|
</div>
|
||||||
|
{content.details.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content.details.map((detail, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
fontSize: '10.5px',
|
||||||
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
import { consultations } from '@/data/consultations'
|
import { consultations } from '@/data/consultations'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
export const LastConsultationTile: React.FC = () => {
|
export const LastConsultationTile: React.FC = () => {
|
||||||
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
// Use the most recent consultation (first in array)
|
// Use the most recent consultation (first in array)
|
||||||
const consultation = consultations[0]
|
const consultation = consultations[0]
|
||||||
|
|
||||||
|
const handleOpenPanel = () => {
|
||||||
|
openPanel({ type: 'consultation', consultation })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Format date to "May 2025" format
|
// Format date to "May 2025" format
|
||||||
const formatDate = (dateStr: string): string => {
|
const formatDate = (dateStr: string): string => {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
@@ -33,8 +48,12 @@ export const LastConsultationTile: React.FC = () => {
|
|||||||
<Card full tileId="last-consultation">
|
<Card full tileId="last-consultation">
|
||||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
|
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
|
||||||
|
|
||||||
{/* Header info row */}
|
{/* Header info row - clickable */}
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleOpenPanel}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@@ -42,7 +61,19 @@ export const LastConsultationTile: React.FC = () => {
|
|||||||
marginBottom: '14px',
|
marginBottom: '14px',
|
||||||
paddingBottom: '14px',
|
paddingBottom: '14px',
|
||||||
borderBottom: '1px solid var(--border-light)',
|
borderBottom: '1px solid var(--border-light)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '8px',
|
||||||
|
margin: '-8px -8px 14px -8px',
|
||||||
|
transition: 'background-color 150ms ease-out',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(10,128,128,0.04)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
}}
|
||||||
|
aria-label={`View full details for ${consultation.role}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -158,6 +189,7 @@ export const LastConsultationTile: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '7px',
|
gap: '7px',
|
||||||
|
marginBottom: '16px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{consultation.examination.map((bullet, index) => (
|
{consultation.examination.map((bullet, index) => (
|
||||||
@@ -188,6 +220,35 @@ export const LastConsultationTile: React.FC = () => {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{/* View full record button */}
|
||||||
|
<button
|
||||||
|
onClick={handleOpenPanel}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: '6px 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'color 150ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--accent-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = 'var(--accent)'
|
||||||
|
}}
|
||||||
|
aria-label="View full consultation record"
|
||||||
|
>
|
||||||
|
<span>View full record</span>
|
||||||
|
<ChevronRight size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,82 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
import { personalStatement } from '@/data/profile'
|
|
||||||
|
|
||||||
export function PatientSummaryTile() {
|
export function PatientSummaryTile() {
|
||||||
const bodyStyles: React.CSSProperties = {
|
// Key statistics from CV_v4.md
|
||||||
|
const highlights = [
|
||||||
|
{ label: '9+ Years', sublabel: 'Professional Experience' },
|
||||||
|
{ label: '1.2M', sublabel: 'Population Served' },
|
||||||
|
{ label: '£220M', sublabel: 'Budget Managed' },
|
||||||
|
{ label: '£14.6M+', sublabel: 'Savings Identified' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const highlightStripStyles: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
paddingBottom: '20px',
|
||||||
|
borderBottom: '1px solid var(--border-light)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightItemStyles: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightValueStyles: React.CSSProperties = {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightLabelStyles: React.CSSProperties = {
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileTextStyles: React.CSSProperties = {
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split profile text into structured sections with bold key phrases
|
||||||
|
const renderProfileWithHierarchy = () => {
|
||||||
|
return (
|
||||||
|
<div style={profileTextStyles}>
|
||||||
|
<strong>Healthcare leader</strong> combining clinical pharmacy expertise with proficiency in{' '}
|
||||||
|
<strong>Python, SQL, and data analytics</strong>, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently{' '}
|
||||||
|
<strong>leading population health analytics for NHS Norfolk & Waveney ICB</strong>, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights—from{' '}
|
||||||
|
<strong>financial scenario modelling</strong> and <strong>pharmaceutical rebate negotiation</strong> to{' '}
|
||||||
|
<strong>algorithm design</strong> and <strong>population-level pathway development</strong>. Proven track record of identifying and prioritising efficiency programmes worth{' '}
|
||||||
|
<strong>£14.6M+</strong> through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for{' '}
|
||||||
|
<strong>executive stakeholders</strong>.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card full tileId="patient-summary">
|
<Card full tileId="patient-summary">
|
||||||
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
|
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
|
||||||
<div style={bodyStyles}>{personalStatement}</div>
|
|
||||||
|
{/* Highlight strip with key stats */}
|
||||||
|
<div style={highlightStripStyles}>
|
||||||
|
{highlights.map((highlight, idx) => (
|
||||||
|
<div key={idx} style={highlightItemStyles}>
|
||||||
|
<div style={highlightValueStyles}>{highlight.label}</div>
|
||||||
|
<div style={highlightLabelStyles}>{highlight.sublabel}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile text with visual hierarchy through bold key phrases */}
|
||||||
|
{renderProfileWithHierarchy()}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
|
||||||
import { ExternalLink } from 'lucide-react'
|
|
||||||
import { investigations } from '@/data/investigations'
|
import { investigations } from '@/data/investigations'
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
|
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||||
import type { Investigation } from '@/types/pmr'
|
import type { Investigation } from '@/types/pmr'
|
||||||
|
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
|
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
Complete: '#059669',
|
Complete: '#059669',
|
||||||
Ongoing: '#0D6E6E',
|
Ongoing: '#0D6E6E',
|
||||||
@@ -15,11 +12,10 @@ const statusColorMap: Record<string, string> = {
|
|||||||
|
|
||||||
interface ProjectItemProps {
|
interface ProjectItemProps {
|
||||||
project: Investigation
|
project: Investigation
|
||||||
isExpanded: boolean
|
onClick: () => void
|
||||||
onToggle: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
function ProjectItem({ project, onClick }: ProjectItemProps) {
|
||||||
const dotColor = statusColorMap[project.status] || '#0D6E6E'
|
const dotColor = statusColorMap[project.status] || '#0D6E6E'
|
||||||
const isLive = project.status === 'Live'
|
const isLive = project.status === 'Live'
|
||||||
|
|
||||||
@@ -27,21 +23,17 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
|||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onToggle()
|
onClick()
|
||||||
} else if (e.key === 'Escape' && isExpanded) {
|
|
||||||
e.preventDefault()
|
|
||||||
onToggle()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isExpanded, onToggle],
|
[onClick],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-expanded={isExpanded}
|
onClick={onClick}
|
||||||
onClick={onToggle}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -49,30 +41,28 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
|||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
border: '1px solid var(--border-light)',
|
border: '1px solid var(--border-light)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '10px 12px',
|
||||||
fontSize: '11.5px',
|
fontSize: '11.5px',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
...(isExpanded && {
|
|
||||||
borderColor: 'var(--accent-border)',
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!isExpanded) {
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
e.currentTarget.style.boxShadow = 'none'
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Item header row */}
|
{/* Row: status dot + name + year */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
padding: '7px 10px',
|
marginBottom: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -87,13 +77,12 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
|||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1 }}>{project.name}</span>
|
<span style={{ flex: 1, fontWeight: 500 }}>{project.name}</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontFamily: "'Geist Mono', monospace",
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
color: 'var(--text-tertiary)',
|
color: 'var(--text-tertiary)',
|
||||||
marginLeft: 'auto',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -101,161 +90,39 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded content */}
|
{/* Tech stack tags */}
|
||||||
<AnimatePresence initial={false}>
|
{project.techStack && project.techStack.length > 0 && (
|
||||||
{isExpanded && (
|
<div
|
||||||
<motion.div
|
style={{
|
||||||
initial={{ height: 0 }}
|
display: 'flex',
|
||||||
animate={{ height: 'auto' }}
|
flexWrap: 'wrap',
|
||||||
exit={{ height: 0 }}
|
gap: '4px',
|
||||||
transition={
|
}}
|
||||||
prefersReducedMotion
|
>
|
||||||
? { duration: 0 }
|
{project.techStack.map((tech) => (
|
||||||
: { duration: 0.2, ease: 'easeOut' }
|
<span
|
||||||
}
|
key={tech}
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
borderLeft: '2px solid #D97706',
|
fontSize: '9px',
|
||||||
marginLeft: '14px',
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
marginRight: '10px',
|
padding: '2px 6px',
|
||||||
marginBottom: '10px',
|
borderRadius: '3px',
|
||||||
paddingLeft: '12px',
|
background: 'var(--amber-light)',
|
||||||
paddingTop: '4px',
|
color: '#92400E',
|
||||||
|
border: '1px solid var(--amber-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Methodology */}
|
{tech}
|
||||||
{project.methodology && (
|
</span>
|
||||||
<p
|
))}
|
||||||
style={{
|
</div>
|
||||||
fontSize: '11.5px',
|
)}
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
margin: '0 0 10px 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.methodology}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tech stack tags */}
|
|
||||||
{project.techStack && project.techStack.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '5px',
|
|
||||||
marginBottom: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.techStack.map((tech) => (
|
|
||||||
<span
|
|
||||||
key={tech}
|
|
||||||
style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
padding: '2px 7px',
|
|
||||||
borderRadius: '3px',
|
|
||||||
background: 'var(--amber-light)',
|
|
||||||
color: '#92400E',
|
|
||||||
border: '1px solid var(--amber-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tech}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{project.results && project.results.length > 0 && (
|
|
||||||
<ul
|
|
||||||
style={{
|
|
||||||
listStyle: 'none',
|
|
||||||
padding: 0,
|
|
||||||
margin: '0 0 8px 0',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.results.map((result, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: '#D97706',
|
|
||||||
opacity: 0.6,
|
|
||||||
flexShrink: 0,
|
|
||||||
marginTop: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</span>
|
|
||||||
{result}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* External link */}
|
|
||||||
{project.externalUrl && (
|
|
||||||
<a
|
|
||||||
href={project.externalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '5px',
|
|
||||||
fontSize: '10.5px',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--accent)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: 'var(--accent-light)',
|
|
||||||
border: '1px solid var(--accent-border)',
|
|
||||||
transition: 'background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(10,128,128,0.14)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--accent-light)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink size={11} />
|
|
||||||
View Results
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsTile() {
|
export function ProjectsTile() {
|
||||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
setExpandedItemId((prev) => (prev === id ? null : id))
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card tileId="projects">
|
<Card tileId="projects">
|
||||||
@@ -266,8 +133,7 @@ export function ProjectsTile() {
|
|||||||
<ProjectItem
|
<ProjectItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
isExpanded={expandedItemId === project.id}
|
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||||
onToggle={() => handleToggle(project.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const educationExtras: EducationExtra[] = [
|
|||||||
'Publicity Officer for UEA Alzheimer\'s Society',
|
'Publicity Officer for UEA Alzheimer\'s Society',
|
||||||
],
|
],
|
||||||
researchDescription: 'Final year research project investigating cocrystal formation for improved drug delivery properties. Awarded Distinction grade (75.1%).',
|
researchDescription: 'Final year research project investigating cocrystal formation for improved drug delivery properties. Awarded Distinction grade (75.1%).',
|
||||||
|
osceScore: '80%',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
documentId: 'doc-mary-seacole',
|
documentId: 'doc-mary-seacole',
|
||||||
|
|||||||
@@ -193,4 +193,5 @@ export interface EducationExtra {
|
|||||||
extracurriculars?: string[]
|
extracurriculars?: string[]
|
||||||
researchDescription?: string
|
researchDescription?: string
|
||||||
programmeDetail?: string
|
programmeDetail?: string
|
||||||
|
osceScore?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user