feat: ship buildpulse 0.3.0 phases and releases
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "buildpulse",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"api": "node --env-file=../.env server/index.mjs",
|
||||
|
||||
+907
-224
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,8 @@ export const createAgentSessionPrompt = (
|
||||
const grouped = groupFeatures(state.features)
|
||||
const target = options?.target || 'AI coding agent'
|
||||
const focusFeature = options?.featureId ? state.features.find((feature) => feature.id === options.featureId) ?? null : grouped.now[0] ?? null
|
||||
const focusRelease = focusFeature?.release_id ? state.releases.find((release) => release.id === focusFeature.release_id) ?? null : null
|
||||
const focusPhase = focusFeature?.phase_id ? state.phases.find((phase) => phase.id === focusFeature.phase_id) ?? null : null
|
||||
const relatedPulses = sortPulsesNewestFirst(state.pulses)
|
||||
.filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true))
|
||||
.slice(0, 6)
|
||||
@@ -62,6 +64,9 @@ export const createAgentSessionPrompt = (
|
||||
`- Column: ${columnLabels[feature.column]}`,
|
||||
`- Priority: ${feature.priority}`,
|
||||
`- Status: ${feature.status}`,
|
||||
`- Phase: ${focusPhase?.title || '—'}`,
|
||||
`- Release: ${focusRelease?.name || '—'}`,
|
||||
`- Release role: ${feature.release_role || '—'}`,
|
||||
`- Acceptance criteria: ${feature.acceptance_criteria.length ? feature.acceptance_criteria.join('; ') : 'None yet'}`,
|
||||
`- Scope notes: ${feature.scope_notes || '—'}`,
|
||||
].join('\n')
|
||||
@@ -92,6 +97,11 @@ export const createAgentSessionPrompt = (
|
||||
'## Next Up',
|
||||
grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.',
|
||||
'',
|
||||
'## Current Release Context',
|
||||
focusRelease
|
||||
? [`- Release: ${focusRelease.name}`, `- Goal: ${focusRelease.goal}`, `- Status: ${focusRelease.status}`, `- Definition of done: ${focusRelease.definition_of_done.length ? focusRelease.definition_of_done.join('; ') : '—'}`].join('\n')
|
||||
: '- No release linked to this feature yet.',
|
||||
'',
|
||||
'## Parking Lot / Do Not Implement Yet',
|
||||
state.parking_lot.length
|
||||
? state.parking_lot.map((item) => `- ${item.title} — ${item.reason_parked || item.description || 'Parked for later.'}`).join('\n')
|
||||
@@ -122,6 +132,8 @@ export const createJsonExport = (state: AppState) =>
|
||||
schema_version: state.schema_version,
|
||||
exported_at: new Date().toISOString(),
|
||||
project: state.project,
|
||||
phases: state.phases,
|
||||
releases: state.releases,
|
||||
features: state.features,
|
||||
parking_lot: state.parking_lot,
|
||||
pulses: state.pulses,
|
||||
@@ -141,6 +153,29 @@ export const createMarkdownPackage = (state: AppState) => {
|
||||
|
||||
const projectSummary = `# Project Summary\n\n## Name\n${state.project.name}\n\n## One-Line Pitch\n${state.project.one_line_pitch || '—'}\n\n## Description\n${state.project.description || '—'}\n\n## Current Goal\n${state.project.current_goal || '—'}\n\n## Notes\n${state.project.notes || '—'}\n`
|
||||
|
||||
const phasesAndReleases = [
|
||||
'# Phases and Releases',
|
||||
'',
|
||||
...state.phases
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.flatMap((phase) => {
|
||||
const releases = state.releases.filter((release) => release.phase_id === phase.id)
|
||||
return [
|
||||
`## ${phase.title}`,
|
||||
`- Goal: ${phase.goal}`,
|
||||
`- Status: ${phase.status}`,
|
||||
`- Notes: ${phase.notes || '—'}`,
|
||||
...(releases.length
|
||||
? releases.map(
|
||||
(release) =>
|
||||
` - **${release.name}** — ${release.status}\n - Goal: ${release.goal}\n - DoD: ${release.definition_of_done.length ? release.definition_of_done.join('; ') : '—'}\n - Required features: ${release.required_feature_ids.length ? release.required_feature_ids.join(', ') : '—'}\n - Optional features: ${release.optional_feature_ids.length ? release.optional_feature_ids.join(', ') : '—'}\n - Forbidden: ${release.forbidden_feature_titles.length ? release.forbidden_feature_titles.join('; ') : '—'}`,
|
||||
)
|
||||
: ['- No releases in this phase yet.']),
|
||||
'',
|
||||
]
|
||||
}),
|
||||
].join('\n')
|
||||
|
||||
const featurePlan = ['# Feature Plan']
|
||||
;(['now', 'next', 'later', 'done'] as FeatureColumn[]).forEach((column) => {
|
||||
featurePlan.push(`\n## ${columnLabels[column]}`)
|
||||
@@ -195,6 +230,11 @@ export const createMarkdownPackage = (state: AppState) => {
|
||||
'## Current Goal',
|
||||
state.project.current_goal || '—',
|
||||
'',
|
||||
'## Active Phase / Release Structure',
|
||||
state.releases.length
|
||||
? state.releases.map((release) => `- ${release.name} (${release.status}) — ${release.goal}`).join('\n')
|
||||
: '_No releases yet._',
|
||||
'',
|
||||
'## Active Features',
|
||||
grouped.now.length ? grouped.now.map(renderFeature).join('\n\n') : '_None yet._',
|
||||
'',
|
||||
@@ -237,6 +277,7 @@ export const createMarkdownPackage = (state: AppState) => {
|
||||
|
||||
return {
|
||||
'PROJECT_SUMMARY.md': projectSummary,
|
||||
'PHASES_AND_RELEASES.md': phasesAndReleases,
|
||||
'FEATURE_PLAN.md': featurePlan.join('\n'),
|
||||
'PARKING_LOT.md': parkingLot,
|
||||
'PULSE_LOG.md': pulseLog,
|
||||
|
||||
@@ -3,18 +3,127 @@ import type { AppState } from '../../store/types'
|
||||
const seedDate = '2026-05-06T00:00:00+02:00'
|
||||
|
||||
export const createSeedState = (): AppState => ({
|
||||
schema_version: '0.2.0',
|
||||
schema_version: '0.3.0',
|
||||
project: {
|
||||
id: 'project_buildpulse',
|
||||
name: 'BuildPulse',
|
||||
one_line_pitch: 'A calm planning cockpit for AI-assisted product building.',
|
||||
description:
|
||||
'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.',
|
||||
current_goal: 'Ship v0.1 with Feature Plan, Parking Lot, Pulse Log, and Export.',
|
||||
current_goal: 'Ship v0.3 with Phases, Releases, and clear release-readiness signals.',
|
||||
notes: 'First dogfood project: BuildPulse manages BuildPulse.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
phases: [
|
||||
{
|
||||
id: 'phase_manual_cockpit',
|
||||
title: 'Phase 1: Manual Cockpit',
|
||||
goal: 'Prove the small planning cockpit works without turning into fake enterprise sludge.',
|
||||
status: 'done',
|
||||
order: 1,
|
||||
notes: 'Feature Plan, Parking Lot, Pulse Log, and Export shipped first.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'phase_ai_idea_placement',
|
||||
title: 'Phase 2: AI Idea Placement',
|
||||
goal: 'Turn raw ideas into guided decisions with explicit accept, park, or reject outcomes.',
|
||||
status: 'done',
|
||||
order: 2,
|
||||
notes: 'AI triage and DECISION pulse logging are proven before release planning expands.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'phase_structured_release_planning',
|
||||
title: 'Phase 3: Structured Release Planning',
|
||||
goal: 'Make releases concrete: what is required, what is forbidden, and how close the build is to ready.',
|
||||
status: 'active',
|
||||
order: 3,
|
||||
notes: 'This is the v0.3 step.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'phase_session_handoff',
|
||||
title: 'Phase 4: Session Handoff',
|
||||
goal: 'Give agents cleaner, more targeted context packages for implementation sessions.',
|
||||
status: 'upcoming',
|
||||
order: 4,
|
||||
notes: 'Planned v0.4 direction.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'phase_local_cloud_ai_assistant',
|
||||
title: 'Phase 5: Local/Cloud AI Assistant',
|
||||
goal: 'Blend local and remote AI assistance without turning the cockpit into router spaghetti.',
|
||||
status: 'upcoming',
|
||||
order: 5,
|
||||
notes: 'Planned v0.5 direction.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'phase_agent_pulse_integration',
|
||||
title: 'Phase 6: Agent Pulse Integration',
|
||||
goal: 'Ingest agent activity only after the manual cockpit and release planning are stable.',
|
||||
status: 'upcoming',
|
||||
order: 6,
|
||||
notes: 'This stays out of v0.3 on purpose.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
],
|
||||
releases: [
|
||||
{
|
||||
id: 'release_v021_ai_triage',
|
||||
phase_id: 'phase_ai_idea_placement',
|
||||
name: 'v0.2.1 — AI Triage Flow Hardening',
|
||||
goal: 'Make AI triage feel like the default idea-intake path from raw idea to DECISION pulse.',
|
||||
definition_of_done: [
|
||||
'AI recommends a placement with risk and smallest safe version.',
|
||||
'User can accept, park, or reject without losing context.',
|
||||
'Accepted or rejected triage creates a DECISION pulse.',
|
||||
'Mobile layout stays readable during the triage flow.',
|
||||
],
|
||||
required_feature_ids: ['feature_plan_screen', 'parking_lot_screen', 'pulse_log_screen'],
|
||||
optional_feature_ids: ['export_screen'],
|
||||
forbidden_feature_titles: ['Live OpenClaw/Hermes Agent Status', 'WebSocket agent telemetry'],
|
||||
status: 'shipped',
|
||||
notes: 'This is the verified gate before v0.3 begins.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'release_v030_phases_releases',
|
||||
phase_id: 'phase_structured_release_planning',
|
||||
name: 'v0.3 — Phases and Releases',
|
||||
goal: 'Add release planning structure so the cockpit can answer what phase we are in, what release we are building, and what is forbidden until later.',
|
||||
definition_of_done: [
|
||||
'Phases exist as explicit project stages.',
|
||||
'Releases track goal, definition of done, required features, optional features, and forbidden work.',
|
||||
'Features can be linked to a phase and a release with required/optional role.',
|
||||
'A release readiness view shows progress, blockers, recent pulses, and forbidden warnings.',
|
||||
],
|
||||
required_feature_ids: ['feature_plan_screen', 'parking_lot_screen', 'pulse_log_screen', 'export_screen'],
|
||||
optional_feature_ids: [],
|
||||
forbidden_feature_titles: [
|
||||
'Live OpenClaw/Hermes Agent Status',
|
||||
'OpenClaw / Hermes integration',
|
||||
'WebSocket agent telemetry',
|
||||
'GitHub / Gitea sync',
|
||||
'Local/cloud model router',
|
||||
'Session prompt generator',
|
||||
],
|
||||
status: 'in_progress',
|
||||
notes: 'Keep this on planning structure only. No live integrations yet.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{
|
||||
id: 'feature_plan_screen',
|
||||
@@ -29,6 +138,9 @@ export const createSeedState = (): AppState => ({
|
||||
'User can edit feature details without clutter.',
|
||||
],
|
||||
scope_notes: 'This is the home screen. It should answer “what now?” immediately.',
|
||||
phase_id: 'phase_structured_release_planning',
|
||||
release_id: 'release_v030_phases_releases',
|
||||
release_role: 'required',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
@@ -44,6 +156,9 @@ export const createSeedState = (): AppState => ({
|
||||
'Risk and future placement are visible.',
|
||||
],
|
||||
scope_notes: 'Parking is success behavior, not failure.',
|
||||
phase_id: 'phase_structured_release_planning',
|
||||
release_id: 'release_v030_phases_releases',
|
||||
release_role: 'required',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
@@ -59,6 +174,9 @@ export const createSeedState = (): AppState => ({
|
||||
'Pulses can link to features optionally.',
|
||||
],
|
||||
scope_notes: 'Manual in v0.1. No live agent ingestion yet.',
|
||||
phase_id: 'phase_structured_release_planning',
|
||||
release_id: 'release_v030_phases_releases',
|
||||
release_role: 'required',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
@@ -74,6 +192,9 @@ export const createSeedState = (): AppState => ({
|
||||
'Markdown export includes CLAUDE_CONTEXT.md.',
|
||||
],
|
||||
scope_notes: 'Handoff quality matters more than bells and whistles.',
|
||||
phase_id: 'phase_structured_release_planning',
|
||||
release_id: 'release_v030_phases_releases',
|
||||
release_role: 'required',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
|
||||
+206
-8
@@ -418,6 +418,23 @@ pre {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.tap-hint {
|
||||
margin: 0.45rem 0 0;
|
||||
color: #8fb3cf;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tap-chip {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.15rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.65rem;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: #93c5fd;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.feature-signal-row {
|
||||
font-size: 0.82rem;
|
||||
color: #9fb4d9;
|
||||
@@ -1291,6 +1308,9 @@ body {
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(45, 212, 191, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, rgba(13, 19, 36, 0.98), rgba(11, 16, 32, 0.92));
|
||||
}
|
||||
|
||||
.mobile-shell-header h1 {
|
||||
@@ -1309,6 +1329,9 @@ body {
|
||||
gap: 0.45rem;
|
||||
color: #b7c4db;
|
||||
white-space: nowrap;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.status-dot,
|
||||
@@ -1454,6 +1477,29 @@ body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.triage-status-banner,
|
||||
.triage-meta-line,
|
||||
.triage-loading-note {
|
||||
color: #b7c4db;
|
||||
}
|
||||
|
||||
.triage-status-banner {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(3, 7, 18, 0.42);
|
||||
}
|
||||
|
||||
.triage-meta-line,
|
||||
.triage-loading-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.triage-step-card,
|
||||
.recommendation-card,
|
||||
.suggested-item-card,
|
||||
@@ -1578,9 +1624,49 @@ body {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.editor-sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 75;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
padding: 1rem;
|
||||
background: rgba(2, 6, 23, 0.64);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.editor-sheet {
|
||||
width: min(980px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(7, 11, 22, 0.98)),
|
||||
radial-gradient(circle at top, rgba(99, 102, 241, 0.12), transparent 40%);
|
||||
}
|
||||
|
||||
.sheet-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sheet-summary-grid.single-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding-top: 0.75rem;
|
||||
background: linear-gradient(180deg, rgba(11, 16, 32, 0), rgba(11, 16, 32, 0.94) 22%);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
width: min(100% - 1rem, 100%);
|
||||
padding-bottom: 5.5rem;
|
||||
}
|
||||
|
||||
.mobile-shell-header,
|
||||
@@ -1605,7 +1691,8 @@ body {
|
||||
|
||||
.triage-recommendation-grid,
|
||||
.accordion-board,
|
||||
.focus-grid {
|
||||
.focus-grid,
|
||||
.sheet-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1614,20 +1701,33 @@ body {
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
position: fixed;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
top: auto;
|
||||
z-index: 50;
|
||||
margin-top: 1rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(8, 12, 24, 0.94);
|
||||
box-shadow: 0 20px 48px rgba(2, 6, 23, 0.45);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.triage-modal-backdrop {
|
||||
.triage-modal-backdrop,
|
||||
.editor-sheet-backdrop {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.triage-modal {
|
||||
.triage-modal,
|
||||
.editor-sheet {
|
||||
max-height: calc(100vh - 1rem);
|
||||
}
|
||||
|
||||
.editor-sheet {
|
||||
width: 100%;
|
||||
border-radius: 24px 24px 18px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -1637,6 +1737,42 @@ body {
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
border-radius: 20px;
|
||||
background: rgba(10, 15, 28, 0.92);
|
||||
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.22);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.plan-home-card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(12, 18, 34, 0.98), rgba(10, 15, 28, 0.96)),
|
||||
radial-gradient(circle at top left, rgba(96, 165, 250, 0.1), transparent 34%);
|
||||
}
|
||||
|
||||
.parking-first-list {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(14, 21, 34, 0.98), rgba(10, 15, 28, 0.96)),
|
||||
radial-gradient(circle at top right, rgba(251, 146, 60, 0.08), transparent 28%);
|
||||
}
|
||||
|
||||
.pulse-timeline-card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(12, 18, 34, 0.98), rgba(10, 15, 28, 0.96)),
|
||||
radial-gradient(circle at top center, rgba(45, 212, 191, 0.08), transparent 28%);
|
||||
}
|
||||
|
||||
.item-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.item-card:nth-child(odd) {
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.tap-chip {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.button-row,
|
||||
@@ -1647,13 +1783,36 @@ body {
|
||||
|
||||
.tab-bar {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border-radius: 18px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.65rem 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.editor-sheet-backdrop,
|
||||
.triage-modal-backdrop {
|
||||
padding: 0;
|
||||
place-items: end stretch;
|
||||
}
|
||||
|
||||
.editor-sheet,
|
||||
.triage-modal {
|
||||
width: 100%;
|
||||
max-height: 100vh;
|
||||
border-radius: 22px 22px 0 0;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
padding: 0.9rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1721,3 +1880,42 @@ select {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
.roadmap-hero-card,
|
||||
.roadmap-phase-card,
|
||||
.roadmap-release-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.compact-roadmap-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.roadmap-phase-item,
|
||||
.roadmap-release-detail {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.roadmap-readiness-grid,
|
||||
.roadmap-release-feature-links {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.checklist-stack {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.roadmap-badges {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
+5
-4
@@ -2,10 +2,11 @@ import type { AiRecommendation, AppState } from './types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
|
||||
const REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_API_TIMEOUT_MS || 5000)
|
||||
const AI_REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_AI_TIMEOUT_MS || 45000)
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const request = async <T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> => {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -19,7 +20,7 @@ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error(`BuildPulse API request timed out after ${REQUEST_TIMEOUT_MS}ms`, { cause: error })
|
||||
throw new Error(`BuildPulse API request to ${path} timed out after ${timeoutMs}ms`, { cause: error })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
@@ -64,7 +65,7 @@ export const triageIdeaWithAi = async (payload: {
|
||||
const response = await request<{ ok: boolean; provider: string; recommendation: Omit<AiRecommendation, 'id' | 'created_at' | 'raw_idea' | 'optional_context' | 'context_summary' | 'user_decision' | 'created_feature_id' | 'created_parking_item_id' | 'decision_pulse_id'> }>('/api/ai/triage-idea', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}, AI_REQUEST_TIMEOUT_MS)
|
||||
|
||||
return response.recommendation
|
||||
}
|
||||
|
||||
+68
-1
@@ -3,13 +3,16 @@ import {
|
||||
AI_DECISIONS,
|
||||
AI_PLACEMENTS,
|
||||
FEATURE_COLUMNS,
|
||||
PHASE_STATUSES,
|
||||
PULSE_TYPES,
|
||||
RELEASE_ROLES,
|
||||
RELEASE_STATUSES,
|
||||
RISK_LEVELS,
|
||||
SCHEMA_VERSION,
|
||||
STORAGE_KEY,
|
||||
SUPPORTED_SCHEMA_VERSIONS,
|
||||
} from './types'
|
||||
import type { AiRecommendation, AppState } from './types'
|
||||
import type { AiRecommendation, AppState, ProjectPhase, Release } from './types'
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -59,6 +62,47 @@ const normalizeRecommendation = (value: unknown): AiRecommendation | null => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePhase = (value: unknown): ProjectPhase | null => {
|
||||
if (!isObject(value)) return null
|
||||
if (!hasRequiredStrings(value, ['id', 'title', 'goal'])) return null
|
||||
if (typeof value.status !== 'string' || !PHASE_STATUSES.includes(value.status as (typeof PHASE_STATUSES)[number])) return null
|
||||
|
||||
return {
|
||||
id: value.id as string,
|
||||
title: value.title as string,
|
||||
goal: value.goal as string,
|
||||
status: value.status as ProjectPhase['status'],
|
||||
order: typeof value.order === 'number' ? value.order : 0,
|
||||
notes: typeof value.notes === 'string' ? value.notes : '',
|
||||
created_at: typeof value.created_at === 'string' ? value.created_at : new Date().toISOString(),
|
||||
updated_at: typeof value.updated_at === 'string' ? value.updated_at : new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeRelease = (value: unknown): Release | null => {
|
||||
if (!isObject(value)) return null
|
||||
if (!hasRequiredStrings(value, ['id', 'phase_id', 'name', 'goal'])) return null
|
||||
if (typeof value.status !== 'string' || !RELEASE_STATUSES.includes(value.status as (typeof RELEASE_STATUSES)[number])) return null
|
||||
|
||||
const toStrings = (items: unknown) =>
|
||||
Array.isArray(items) ? items.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())) : []
|
||||
|
||||
return {
|
||||
id: value.id as string,
|
||||
phase_id: value.phase_id as string,
|
||||
name: value.name as string,
|
||||
goal: value.goal as string,
|
||||
definition_of_done: toStrings(value.definition_of_done),
|
||||
required_feature_ids: toStrings(value.required_feature_ids),
|
||||
optional_feature_ids: toStrings(value.optional_feature_ids),
|
||||
forbidden_feature_titles: toStrings(value.forbidden_feature_titles),
|
||||
status: value.status as Release['status'],
|
||||
notes: typeof value.notes === 'string' ? value.notes : '',
|
||||
created_at: typeof value.created_at === 'string' ? value.created_at : new Date().toISOString(),
|
||||
updated_at: typeof value.updated_at === 'string' ? value.updated_at : new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeAppState = (value: unknown): AppState | null => {
|
||||
if (!isObject(value)) return null
|
||||
if (!SUPPORTED_SCHEMA_VERSIONS.includes(value.schema_version as (typeof SUPPORTED_SCHEMA_VERSIONS)[number])) return null
|
||||
@@ -100,10 +144,33 @@ export const normalizeAppState = (value: unknown): AppState | null => {
|
||||
const recommendations = Array.isArray(value.ai_recommendations)
|
||||
? value.ai_recommendations.map(normalizeRecommendation).filter((entry): entry is AiRecommendation => Boolean(entry))
|
||||
: []
|
||||
const seedState = createSeedState()
|
||||
const phases = Array.isArray(value.phases)
|
||||
? value.phases.map(normalizePhase).filter((entry): entry is ProjectPhase => Boolean(entry))
|
||||
: seedState.phases
|
||||
const releases = Array.isArray(value.releases)
|
||||
? value.releases.map(normalizeRelease).filter((entry): entry is Release => Boolean(entry))
|
||||
: seedState.releases
|
||||
const normalizedFeatures = value.features.map((feature) => {
|
||||
if (!isObject(feature)) return feature
|
||||
const releaseRole = typeof feature.release_role === 'string' && RELEASE_ROLES.includes(feature.release_role as (typeof RELEASE_ROLES)[number])
|
||||
? feature.release_role
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...feature,
|
||||
phase_id: typeof feature.phase_id === 'string' ? feature.phase_id : undefined,
|
||||
release_id: typeof feature.release_id === 'string' ? feature.release_id : undefined,
|
||||
release_role: releaseRole,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...(value as unknown as AppState),
|
||||
schema_version: SCHEMA_VERSION,
|
||||
phases,
|
||||
releases,
|
||||
features: normalizedFeatures as AppState['features'],
|
||||
ai_recommendations: recommendations,
|
||||
}
|
||||
}
|
||||
|
||||
+49
-3
@@ -1,6 +1,6 @@
|
||||
export const STORAGE_KEY = 'buildpulse.v1'
|
||||
export const SCHEMA_VERSION = '0.2.0'
|
||||
export const SUPPORTED_SCHEMA_VERSIONS = ['0.1.0', '0.2.0'] as const
|
||||
export const SCHEMA_VERSION = '0.3.0'
|
||||
export const SUPPORTED_SCHEMA_VERSIONS = ['0.1.0', '0.2.0', '0.2.1', '0.3.0'] as const
|
||||
|
||||
export const FEATURE_COLUMNS = ['now', 'next', 'later', 'done'] as const
|
||||
export type FeatureColumn = (typeof FEATURE_COLUMNS)[number]
|
||||
@@ -23,6 +23,15 @@ export type FeatureStatus = (typeof FEATURE_STATUSES)[number]
|
||||
export const RISK_LEVELS = ['low', 'medium', 'high', 'dangerous'] as const
|
||||
export type RiskLevel = (typeof RISK_LEVELS)[number]
|
||||
|
||||
export const PHASE_STATUSES = ['upcoming', 'active', 'done'] as const
|
||||
export type PhaseStatus = (typeof PHASE_STATUSES)[number]
|
||||
|
||||
export const RELEASE_STATUSES = ['not_ready', 'in_progress', 'testing', 'ready_to_ship', 'shipped'] as const
|
||||
export type ReleaseStatus = (typeof RELEASE_STATUSES)[number]
|
||||
|
||||
export const RELEASE_ROLES = ['required', 'optional'] as const
|
||||
export type ReleaseRole = (typeof RELEASE_ROLES)[number]
|
||||
|
||||
export const PULSE_TYPES = [
|
||||
'INTENT',
|
||||
'ACTION',
|
||||
@@ -54,6 +63,32 @@ export interface Project {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProjectPhase {
|
||||
id: string
|
||||
title: string
|
||||
goal: string
|
||||
status: PhaseStatus
|
||||
order: number
|
||||
notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
id: string
|
||||
phase_id: string
|
||||
name: string
|
||||
goal: string
|
||||
definition_of_done: string[]
|
||||
required_feature_ids: string[]
|
||||
optional_feature_ids: string[]
|
||||
forbidden_feature_titles: string[]
|
||||
status: ReleaseStatus
|
||||
notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
id: string
|
||||
title: string
|
||||
@@ -63,6 +98,12 @@ export interface Feature {
|
||||
status: FeatureStatus
|
||||
acceptance_criteria: string[]
|
||||
scope_notes: string
|
||||
phase_id?: string
|
||||
release_id?: string
|
||||
release_role?: ReleaseRole
|
||||
triaged_at?: string
|
||||
triage_trace_id?: string
|
||||
triage_confidence_score?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -74,6 +115,9 @@ export interface ParkingLotItem {
|
||||
reason_parked: string
|
||||
possible_future_placement: string
|
||||
risk_level: RiskLevel
|
||||
triaged_at?: string
|
||||
triage_trace_id?: string
|
||||
triage_confidence_score?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -130,6 +174,8 @@ export interface Settings {
|
||||
export interface AppState {
|
||||
schema_version: string
|
||||
project: Project
|
||||
phases: ProjectPhase[]
|
||||
releases: Release[]
|
||||
features: Feature[]
|
||||
parking_lot: ParkingLotItem[]
|
||||
pulses: PulseEvent[]
|
||||
@@ -137,4 +183,4 @@ export interface AppState {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
export type TabKey = 'status' | 'feature-plan' | 'parking-lot' | 'pulse-log' | 'export'
|
||||
export type TabKey = 'status' | 'feature-plan' | 'roadmap' | 'parking-lot' | 'pulse-log' | 'export'
|
||||
|
||||
Reference in New Issue
Block a user