feat: scaffold BuildPulse v0.1 cockpit
This commit is contained in:
+980
@@ -0,0 +1,980 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import './index.css'
|
||||
import { createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
|
||||
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
|
||||
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
|
||||
import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
|
||||
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string }> = [
|
||||
{ key: 'feature-plan', label: 'Feature Plan' },
|
||||
{ key: 'parking-lot', label: 'Parking Lot' },
|
||||
{ key: 'pulse-log', label: 'Pulse Log' },
|
||||
{ key: 'export', label: 'Export' },
|
||||
]
|
||||
|
||||
const initialFeatureDraft = {
|
||||
title: '',
|
||||
description: '',
|
||||
column: 'now' as FeatureColumn,
|
||||
priority: 'must' as (typeof FEATURE_PRIORITIES)[number],
|
||||
status: 'idea' as (typeof FEATURE_STATUSES)[number],
|
||||
acceptanceCriteria: '',
|
||||
scopeNotes: '',
|
||||
}
|
||||
|
||||
const initialParkingDraft = {
|
||||
title: '',
|
||||
description: '',
|
||||
reasonParked: '',
|
||||
futurePlacement: '',
|
||||
riskLevel: 'medium' as RiskLevel,
|
||||
}
|
||||
|
||||
const initialPulseDraft = {
|
||||
featureId: '',
|
||||
source: 'manual',
|
||||
agentId: 'jimmi',
|
||||
pulseType: 'INTENT' as (typeof PULSE_TYPES)[number],
|
||||
message: '',
|
||||
confidence: '0.9',
|
||||
evidenceRefs: '',
|
||||
traceId: '',
|
||||
structuredPayload: '{}',
|
||||
}
|
||||
|
||||
const columnLabels: Record<FeatureColumn, string> = {
|
||||
now: 'Now',
|
||||
next: 'Next',
|
||||
later: 'Later',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
const downloadText = (filename: string, text: string, contentType = 'text/plain;charset=utf-8') => {
|
||||
const blob = new Blob([text], { type: contentType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [appState, setAppState] = useState<AppState>(() => loadAppState())
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('feature-plan')
|
||||
const [statusMessage, setStatusMessage] = useState('Seeded with BuildPulse so you can dogfood it immediately.')
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null)
|
||||
const [selectedParkingId, setSelectedParkingId] = useState<string | null>(null)
|
||||
const [selectedPulseId, setSelectedPulseId] = useState<string | null>(null)
|
||||
const [featureDraft, setFeatureDraft] = useState(initialFeatureDraft)
|
||||
const [parkingDraft, setParkingDraft] = useState(initialParkingDraft)
|
||||
const [pulseDraft, setPulseDraft] = useState(initialPulseDraft)
|
||||
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
|
||||
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
|
||||
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
saveAppState(appState)
|
||||
}, [appState])
|
||||
|
||||
const groupedFeatures = useMemo(
|
||||
() =>
|
||||
FEATURE_COLUMNS.reduce<Record<FeatureColumn, Feature[]>>((acc, column) => {
|
||||
acc[column] = appState.features.filter((feature) => feature.column === column)
|
||||
return acc
|
||||
}, { now: [], next: [], later: [], done: [] }),
|
||||
[appState.features],
|
||||
)
|
||||
|
||||
const selectedFeature = useMemo(
|
||||
() => appState.features.find((feature) => feature.id === selectedFeatureId) ?? null,
|
||||
[appState.features, selectedFeatureId],
|
||||
)
|
||||
|
||||
const selectedParkingItem = useMemo(
|
||||
() => appState.parking_lot.find((item) => item.id === selectedParkingId) ?? null,
|
||||
[appState.parking_lot, selectedParkingId],
|
||||
)
|
||||
|
||||
const selectedPulse = useMemo(
|
||||
() => appState.pulses.find((pulse) => pulse.id === selectedPulseId) ?? null,
|
||||
[appState.pulses, selectedPulseId],
|
||||
)
|
||||
|
||||
const filteredPulses = useMemo(() => {
|
||||
return [...appState.pulses]
|
||||
.filter((pulse) => (pulseTypeFilter === 'all' ? true : pulse.pulse_type === pulseTypeFilter))
|
||||
.filter((pulse) => (pulseFeatureFilter === 'all' ? true : pulse.feature_id === pulseFeatureFilter))
|
||||
.filter((pulse) => (pulseSourceFilter === 'all' ? true : pulse.source === pulseSourceFilter || pulse.agent_id === pulseSourceFilter))
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
}, [appState.pulses, pulseFeatureFilter, pulseSourceFilter, pulseTypeFilter])
|
||||
|
||||
const uniqueSources = useMemo(
|
||||
() => Array.from(new Set(appState.pulses.flatMap((pulse) => [pulse.source, pulse.agent_id]).filter(Boolean))).sort(),
|
||||
[appState.pulses],
|
||||
)
|
||||
|
||||
const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState])
|
||||
|
||||
const updateProject = (field: keyof AppState['project'], value: string) => {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
project: {
|
||||
...current.project,
|
||||
[field]: value,
|
||||
updated_at: nowIso(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const beginFeatureEdit = (feature: Feature) => {
|
||||
setSelectedFeatureId(feature.id)
|
||||
setFeatureDraft({
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
column: feature.column,
|
||||
priority: feature.priority,
|
||||
status: feature.status,
|
||||
acceptanceCriteria: arrayToLines(feature.acceptance_criteria),
|
||||
scopeNotes: feature.scope_notes,
|
||||
})
|
||||
}
|
||||
|
||||
const resetFeatureDraft = () => {
|
||||
setSelectedFeatureId(null)
|
||||
setFeatureDraft(initialFeatureDraft)
|
||||
}
|
||||
|
||||
const saveFeature = () => {
|
||||
if (!featureDraft.title.trim()) {
|
||||
setStatusMessage('Feature title is required. Tiny cockpit, tiny guardrails.')
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = nowIso()
|
||||
const acceptanceCriteria = linesToArray(featureDraft.acceptanceCriteria)
|
||||
|
||||
if (selectedFeatureId) {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
features: current.features.map((feature) =>
|
||||
feature.id === selectedFeatureId
|
||||
? {
|
||||
...feature,
|
||||
title: featureDraft.title.trim(),
|
||||
description: featureDraft.description.trim(),
|
||||
column: featureDraft.column,
|
||||
priority: featureDraft.priority,
|
||||
status: featureDraft.status,
|
||||
acceptance_criteria: acceptanceCriteria,
|
||||
scope_notes: featureDraft.scopeNotes.trim(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
: feature,
|
||||
),
|
||||
}))
|
||||
setStatusMessage('Feature updated.')
|
||||
} else {
|
||||
const title = featureDraft.title.trim()
|
||||
const id = `feature_${slugify(title)}_${Date.now().toString(36)}`
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
features: [
|
||||
{
|
||||
id,
|
||||
title,
|
||||
description: featureDraft.description.trim(),
|
||||
column: featureDraft.column,
|
||||
priority: featureDraft.priority,
|
||||
status: featureDraft.status,
|
||||
acceptance_criteria: acceptanceCriteria,
|
||||
scope_notes: featureDraft.scopeNotes.trim(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
...current.features,
|
||||
],
|
||||
}))
|
||||
setStatusMessage(`Feature “${title}” added.`)
|
||||
}
|
||||
|
||||
resetFeatureDraft()
|
||||
}
|
||||
|
||||
const deleteFeature = (featureId: string) => {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
features: current.features.filter((feature) => feature.id !== featureId),
|
||||
}))
|
||||
if (selectedFeatureId === featureId) resetFeatureDraft()
|
||||
setStatusMessage('Feature removed. Related pulses stay intact with a graceful missing-feature label.')
|
||||
}
|
||||
|
||||
const quickMoveFeature = (featureId: string, column: FeatureColumn) => {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
features: current.features.map((feature) =>
|
||||
feature.id === featureId ? { ...feature, column, updated_at: nowIso() } : feature,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const beginParkingEdit = (item: ParkingLotItem) => {
|
||||
setSelectedParkingId(item.id)
|
||||
setParkingDraft({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
reasonParked: item.reason_parked,
|
||||
futurePlacement: item.possible_future_placement,
|
||||
riskLevel: item.risk_level,
|
||||
})
|
||||
}
|
||||
|
||||
const resetParkingDraft = () => {
|
||||
setSelectedParkingId(null)
|
||||
setParkingDraft(initialParkingDraft)
|
||||
}
|
||||
|
||||
const saveParkingItem = () => {
|
||||
if (!parkingDraft.title.trim()) {
|
||||
setStatusMessage('Parking Lot items need a title.')
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = nowIso()
|
||||
if (selectedParkingId) {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
parking_lot: current.parking_lot.map((item) =>
|
||||
item.id === selectedParkingId
|
||||
? {
|
||||
...item,
|
||||
title: parkingDraft.title.trim(),
|
||||
description: parkingDraft.description.trim(),
|
||||
reason_parked: parkingDraft.reasonParked.trim(),
|
||||
possible_future_placement: parkingDraft.futurePlacement.trim(),
|
||||
risk_level: parkingDraft.riskLevel,
|
||||
updated_at: timestamp,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}))
|
||||
setStatusMessage('Parking Lot item updated.')
|
||||
} else {
|
||||
const title = parkingDraft.title.trim()
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
parking_lot: [
|
||||
{
|
||||
id: `parked_${slugify(title)}_${Date.now().toString(36)}`,
|
||||
title,
|
||||
description: parkingDraft.description.trim(),
|
||||
reason_parked: parkingDraft.reasonParked.trim(),
|
||||
possible_future_placement: parkingDraft.futurePlacement.trim(),
|
||||
risk_level: parkingDraft.riskLevel,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
...current.parking_lot,
|
||||
],
|
||||
}))
|
||||
setStatusMessage(`Parked “${title}” safely.`)
|
||||
}
|
||||
|
||||
resetParkingDraft()
|
||||
}
|
||||
|
||||
const deleteParkingItem = (itemId: string) => {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
parking_lot: current.parking_lot.filter((item) => item.id !== itemId),
|
||||
}))
|
||||
if (selectedParkingId === itemId) resetParkingDraft()
|
||||
setStatusMessage('Parking Lot item removed.')
|
||||
}
|
||||
|
||||
const convertParkingItemToFeature = (item: ParkingLotItem) => {
|
||||
const timestamp = nowIso()
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
features: [
|
||||
{
|
||||
id: `feature_${slugify(item.title)}_${Date.now().toString(36)}`,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
column: 'next',
|
||||
priority: 'could',
|
||||
status: 'idea',
|
||||
acceptance_criteria: [],
|
||||
scope_notes: `Converted from Parking Lot. Original reason parked: ${item.reason_parked}`,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
...current.features,
|
||||
],
|
||||
parking_lot: current.parking_lot.filter((entry) => entry.id !== item.id),
|
||||
pulses: [
|
||||
{
|
||||
id: `pulse_${Date.now().toString(36)}`,
|
||||
timestamp,
|
||||
project_id: current.project.id,
|
||||
source: 'manual',
|
||||
agent_id: current.settings.default_agent_id,
|
||||
pulse_type: 'PARKED_IDEA',
|
||||
message: `Converted parked idea “${item.title}” into a Next feature.`,
|
||||
structured_payload: {},
|
||||
confidence_score: 0.8,
|
||||
evidence_refs: ['Converted from Parking Lot'],
|
||||
},
|
||||
...current.pulses,
|
||||
],
|
||||
}))
|
||||
setStatusMessage(`Converted “${item.title}” into a feature.`)
|
||||
if (selectedParkingId === item.id) resetParkingDraft()
|
||||
}
|
||||
|
||||
const beginPulseEdit = (pulse: PulseEvent) => {
|
||||
setSelectedPulseId(pulse.id)
|
||||
setPulseDraft({
|
||||
featureId: pulse.feature_id ?? '',
|
||||
source: pulse.source,
|
||||
agentId: pulse.agent_id,
|
||||
pulseType: pulse.pulse_type,
|
||||
message: pulse.message,
|
||||
confidence: String(pulse.confidence_score),
|
||||
evidenceRefs: arrayToLines(pulse.evidence_refs),
|
||||
traceId: pulse.trace_id ?? '',
|
||||
structuredPayload: JSON.stringify(pulse.structured_payload ?? {}, null, 2),
|
||||
})
|
||||
}
|
||||
|
||||
const resetPulseDraft = () => {
|
||||
setSelectedPulseId(null)
|
||||
setPulseDraft(initialPulseDraft)
|
||||
}
|
||||
|
||||
const savePulse = () => {
|
||||
if (!pulseDraft.message.trim()) {
|
||||
setStatusMessage('Pulse message is required.')
|
||||
return
|
||||
}
|
||||
|
||||
let parsedPayload: Record<string, unknown>
|
||||
try {
|
||||
parsedPayload = pulseDraft.structuredPayload.trim() ? JSON.parse(pulseDraft.structuredPayload) : {}
|
||||
} catch {
|
||||
setStatusMessage('Structured payload must be valid JSON.')
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = nowIso()
|
||||
const pulse: PulseEvent = {
|
||||
id: selectedPulseId ?? `pulse_${Date.now().toString(36)}`,
|
||||
timestamp: selectedPulse?.timestamp ?? timestamp,
|
||||
project_id: appState.project.id,
|
||||
feature_id: pulseDraft.featureId || undefined,
|
||||
source: pulseDraft.source.trim() || 'manual',
|
||||
agent_id: pulseDraft.agentId.trim() || appState.settings.default_agent_id,
|
||||
pulse_type: pulseDraft.pulseType,
|
||||
message: pulseDraft.message.trim(),
|
||||
structured_payload: parsedPayload,
|
||||
confidence_score: Number(pulseDraft.confidence) || 0,
|
||||
evidence_refs: linesToArray(pulseDraft.evidenceRefs),
|
||||
trace_id: pulseDraft.traceId.trim() || undefined,
|
||||
}
|
||||
|
||||
if (selectedPulseId) {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
pulses: current.pulses.map((entry) => (entry.id === selectedPulseId ? pulse : entry)),
|
||||
}))
|
||||
setStatusMessage('Pulse updated.')
|
||||
} else {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
pulses: [pulse, ...current.pulses],
|
||||
}))
|
||||
setStatusMessage('Pulse added.')
|
||||
}
|
||||
|
||||
resetPulseDraft()
|
||||
}
|
||||
|
||||
const deletePulse = (pulseId: string) => {
|
||||
setAppState((current) => ({
|
||||
...current,
|
||||
pulses: current.pulses.filter((pulse) => pulse.id !== pulseId),
|
||||
}))
|
||||
if (selectedPulseId === pulseId) resetPulseDraft()
|
||||
setStatusMessage('Pulse deleted.')
|
||||
}
|
||||
|
||||
const copyMarkdown = async (filename: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage])
|
||||
setStatusMessage(`${filename} copied to clipboard.`)
|
||||
} catch {
|
||||
setStatusMessage('Clipboard copy failed. Browser said no.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const parsed = JSON.parse(text)
|
||||
if (!validateAppState(parsed)) {
|
||||
setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.')
|
||||
return
|
||||
}
|
||||
setAppState(replaceAppState(parsed))
|
||||
setStatusMessage('Import complete. State replaced cleanly.')
|
||||
} catch {
|
||||
setStatusMessage('Import failed: invalid JSON.')
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const currentFeatureCount = groupedFeatures.now.length
|
||||
const recentPulsePreview = [...appState.pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp)).slice(0, 3)
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="hero-card">
|
||||
<div>
|
||||
<p className="eyebrow">BuildPulse v0.1</p>
|
||||
<h1>{appState.project.name}</h1>
|
||||
<p className="hero-copy">{appState.project.one_line_pitch}</p>
|
||||
<p className="hero-goal">
|
||||
<strong>Current goal:</strong> {appState.project.current_goal || 'Set a goal so the cockpit has a heading.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div>
|
||||
<span>Now</span>
|
||||
<strong>{currentFeatureCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Parked</span>
|
||||
<strong>{appState.parking_lot.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Pulses</span>
|
||||
<strong>{appState.pulses.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="project-card card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h2>Project Summary</h2>
|
||||
<p>Keep the mission clear without turning this into enterprise theater.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid project-grid">
|
||||
<label>
|
||||
Project name
|
||||
<input value={appState.project.name} onChange={(event) => updateProject('name', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
One-line pitch
|
||||
<input
|
||||
value={appState.project.one_line_pitch}
|
||||
onChange={(event) => updateProject('one_line_pitch', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Description
|
||||
<textarea
|
||||
rows={3}
|
||||
value={appState.project.description}
|
||||
onChange={(event) => updateProject('description', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Current goal
|
||||
<input value={appState.project.current_goal} onChange={(event) => updateProject('current_goal', event.target.value)} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Notes
|
||||
<textarea rows={3} value={appState.project.notes} onChange={(event) => updateProject('notes', event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="tab-bar" aria-label="Main views">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={tab.key === activeTab ? 'tab active' : 'tab'}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="quick-actions card">
|
||||
<button type="button" onClick={() => { setActiveTab('feature-plan'); resetFeatureDraft() }}>
|
||||
Add Feature
|
||||
</button>
|
||||
<button type="button" onClick={() => { setActiveTab('parking-lot'); resetParkingDraft() }}>
|
||||
Park Idea
|
||||
</button>
|
||||
<button type="button" onClick={() => { setActiveTab('pulse-log'); resetPulseDraft() }}>
|
||||
Add Pulse
|
||||
</button>
|
||||
<button type="button" onClick={() => setActiveTab('export')}>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'feature-plan' && (
|
||||
<section className="view-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Feature Plan</h2>
|
||||
<p>Lead with focus, not overview. This is the calm “what now?” screen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="board-grid">
|
||||
{FEATURE_COLUMNS.map((column) => (
|
||||
<article key={column} className="column card">
|
||||
<div className="column-header">
|
||||
<div>
|
||||
<h3>{columnLabels[column]}</h3>
|
||||
<p>{groupedFeatures[column].length} feature{groupedFeatures[column].length === 1 ? '' : 's'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column-body">
|
||||
{groupedFeatures[column].length ? (
|
||||
groupedFeatures[column].map((feature) => (
|
||||
<button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}>
|
||||
<div className="item-card-header">
|
||||
<strong>{feature.title}</strong>
|
||||
<span className={`pill ${feature.priority}`}>{feature.priority}</span>
|
||||
</div>
|
||||
<p>{feature.description || 'No description yet.'}</p>
|
||||
<div className="meta-row">
|
||||
<span>{feature.acceptance_criteria.length} criteria</span>
|
||||
<label>
|
||||
<span className="sr-only">Move feature</span>
|
||||
<select
|
||||
value={feature.column}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
quickMoveFeature(feature.id, event.target.value as FeatureColumn)
|
||||
}}
|
||||
>
|
||||
{FEATURE_COLUMNS.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{columnLabels[entry]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">No features here yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="editor-grid">
|
||||
<section className="card editor-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>{selectedFeature ? 'Edit Feature' : 'Add Feature'}</h3>
|
||||
<p>Keep it small, clear, and testable.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Title
|
||||
<input value={featureDraft.title} onChange={(event) => setFeatureDraft((current) => ({ ...current, title: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
Column
|
||||
<select value={featureDraft.column} onChange={(event) => setFeatureDraft((current) => ({ ...current, column: event.target.value as FeatureColumn }))}>
|
||||
{FEATURE_COLUMNS.map((column) => (
|
||||
<option key={column} value={column}>
|
||||
{columnLabels[column]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Priority
|
||||
<select value={featureDraft.priority} onChange={(event) => setFeatureDraft((current) => ({ ...current, priority: event.target.value as (typeof FEATURE_PRIORITIES)[number] }))}>
|
||||
{FEATURE_PRIORITIES.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select value={featureDraft.status} onChange={(event) => setFeatureDraft((current) => ({ ...current, status: event.target.value as (typeof FEATURE_STATUSES)[number] }))}>
|
||||
{FEATURE_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Description
|
||||
<textarea rows={3} value={featureDraft.description} onChange={(event) => setFeatureDraft((current) => ({ ...current, description: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Acceptance criteria (one per line)
|
||||
<textarea rows={5} value={featureDraft.acceptanceCriteria} onChange={(event) => setFeatureDraft((current) => ({ ...current, acceptanceCriteria: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Scope notes
|
||||
<textarea rows={3} value={featureDraft.scopeNotes} onChange={(event) => setFeatureDraft((current) => ({ ...current, scopeNotes: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={saveFeature}>{selectedFeature ? 'Save Changes' : 'Add Feature'}</button>
|
||||
<button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button>
|
||||
{selectedFeature && (
|
||||
<button type="button" className="danger" onClick={() => deleteFeature(selectedFeature.id)}>
|
||||
Delete Feature
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card preview-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>Recent Pulse</h3>
|
||||
<p>Just enough movement to stay grounded.</p>
|
||||
</div>
|
||||
</div>
|
||||
{recentPulsePreview.length ? (
|
||||
<div className="list-stack">
|
||||
{recentPulsePreview.map((pulse) => (
|
||||
<div key={pulse.id} className="mini-pulse">
|
||||
<strong>{pulse.pulse_type}</strong>
|
||||
<span>{formatDateTime(pulse.timestamp)}</span>
|
||||
<p>{pulse.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">No pulses yet. Add an INTENT or DECISION to start the log.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'parking-lot' && (
|
||||
<section className="view-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Parking Lot</h2>
|
||||
<p>This is where useful distractions go so they do not hijack the build.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-grid">
|
||||
<section className="card editor-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>{selectedParkingItem ? 'Edit Parked Idea' : 'Park a New Idea'}</h3>
|
||||
<p>Capture it cleanly, then get back to the real work.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Title
|
||||
<input value={parkingDraft.title} onChange={(event) => setParkingDraft((current) => ({ ...current, title: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
Risk level
|
||||
<select value={parkingDraft.riskLevel} onChange={(event) => setParkingDraft((current) => ({ ...current, riskLevel: event.target.value as RiskLevel }))}>
|
||||
{RISK_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Description
|
||||
<textarea rows={3} value={parkingDraft.description} onChange={(event) => setParkingDraft((current) => ({ ...current, description: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Reason parked
|
||||
<textarea rows={3} value={parkingDraft.reasonParked} onChange={(event) => setParkingDraft((current) => ({ ...current, reasonParked: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Possible future placement
|
||||
<input value={parkingDraft.futurePlacement} onChange={(event) => setParkingDraft((current) => ({ ...current, futurePlacement: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={saveParkingItem}>{selectedParkingItem ? 'Save Changes' : 'Park Idea'}</button>
|
||||
<button type="button" className="ghost" onClick={resetParkingDraft}>Clear</button>
|
||||
{selectedParkingItem && (
|
||||
<>
|
||||
<button type="button" className="ghost" onClick={() => convertParkingItemToFeature(selectedParkingItem)}>
|
||||
Convert to Feature
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => deleteParkingItem(selectedParkingItem.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card list-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>Parked Ideas</h3>
|
||||
<p>Visible enough to trust, quiet enough not to derail you.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{appState.parking_lot.length ? (
|
||||
appState.parking_lot.map((item) => (
|
||||
<button key={item.id} type="button" className="item-card parking-card" onClick={() => beginParkingEdit(item)}>
|
||||
<div className="item-card-header">
|
||||
<strong>{item.title}</strong>
|
||||
<span className={`pill risk-${item.risk_level}`}>{item.risk_level}</span>
|
||||
</div>
|
||||
<p>{item.description || 'No description yet.'}</p>
|
||||
<small>{item.reason_parked || 'No reason parked yet.'}</small>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-state">No parked ideas yet. That probably means you haven’t had a normal distracted brain day yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'pulse-log' && (
|
||||
<section className="view-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Pulse Log</h2>
|
||||
<p>Manual now, future-compatible later. Keep the history honest.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-grid">
|
||||
<section className="card editor-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>{selectedPulse ? 'Edit Pulse' : 'Add Pulse'}</h3>
|
||||
<p>Log what happened, not a fantasy backlog status.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Pulse type
|
||||
<select value={pulseDraft.pulseType} onChange={(event) => setPulseDraft((current) => ({ ...current, pulseType: event.target.value as (typeof PULSE_TYPES)[number] }))}>
|
||||
{PULSE_TYPES.map((pulseType) => (
|
||||
<option key={pulseType} value={pulseType}>
|
||||
{pulseType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Feature link
|
||||
<select value={pulseDraft.featureId} onChange={(event) => setPulseDraft((current) => ({ ...current, featureId: event.target.value }))}>
|
||||
<option value="">No linked feature</option>
|
||||
{appState.features.map((feature) => (
|
||||
<option key={feature.id} value={feature.id}>
|
||||
{feature.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Source
|
||||
<input value={pulseDraft.source} onChange={(event) => setPulseDraft((current) => ({ ...current, source: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
Agent ID
|
||||
<input value={pulseDraft.agentId} onChange={(event) => setPulseDraft((current) => ({ ...current, agentId: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
Confidence
|
||||
<input value={pulseDraft.confidence} onChange={(event) => setPulseDraft((current) => ({ ...current, confidence: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
Trace ID
|
||||
<input value={pulseDraft.traceId} onChange={(event) => setPulseDraft((current) => ({ ...current, traceId: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Message
|
||||
<textarea rows={4} value={pulseDraft.message} onChange={(event) => setPulseDraft((current) => ({ ...current, message: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Evidence refs (one per line)
|
||||
<textarea rows={4} value={pulseDraft.evidenceRefs} onChange={(event) => setPulseDraft((current) => ({ ...current, evidenceRefs: event.target.value }))} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
Structured payload JSON
|
||||
<textarea rows={4} value={pulseDraft.structuredPayload} onChange={(event) => setPulseDraft((current) => ({ ...current, structuredPayload: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={savePulse}>{selectedPulse ? 'Save Changes' : 'Add Pulse'}</button>
|
||||
<button type="button" className="ghost" onClick={resetPulseDraft}>Clear</button>
|
||||
{selectedPulse && (
|
||||
<button type="button" className="danger" onClick={() => deletePulse(selectedPulse.id)}>
|
||||
Delete Pulse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card list-card">
|
||||
<div className="section-heading compact filters-heading">
|
||||
<div>
|
||||
<h3>Pulse Timeline</h3>
|
||||
<p>Newest first, with just enough filtering to stay useful.</p>
|
||||
</div>
|
||||
<div className="filter-row">
|
||||
<select value={pulseTypeFilter} onChange={(event) => setPulseTypeFilter(event.target.value)}>
|
||||
<option value="all">All types</option>
|
||||
{PULSE_TYPES.map((pulseType) => (
|
||||
<option key={pulseType} value={pulseType}>
|
||||
{pulseType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={pulseFeatureFilter} onChange={(event) => setPulseFeatureFilter(event.target.value)}>
|
||||
<option value="all">All features</option>
|
||||
{appState.features.map((feature) => (
|
||||
<option key={feature.id} value={feature.id}>
|
||||
{feature.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={pulseSourceFilter} onChange={(event) => setPulseSourceFilter(event.target.value)}>
|
||||
<option value="all">All sources</option>
|
||||
{uniqueSources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{filteredPulses.length ? (
|
||||
filteredPulses.map((pulse) => {
|
||||
const linkedFeature = appState.features.find((feature) => feature.id === pulse.feature_id)
|
||||
return (
|
||||
<button key={pulse.id} type="button" className="item-card pulse-card" onClick={() => beginPulseEdit(pulse)}>
|
||||
<div className="item-card-header">
|
||||
<strong>{pulse.pulse_type}</strong>
|
||||
<span>{formatDateTime(pulse.timestamp)}</span>
|
||||
</div>
|
||||
<p>{pulse.message}</p>
|
||||
<small>
|
||||
{linkedFeature?.title ?? (pulse.feature_id ? `${pulse.feature_id} (missing feature)` : 'No linked feature')} · {pulse.source} / {pulse.agent_id}
|
||||
</small>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="empty-state">No pulses match the current filters.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'export' && (
|
||||
<section className="view-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Export</h2>
|
||||
<p>Ship clean context packages, not rambling AI bait.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-grid">
|
||||
<section className="card editor-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>Data Exports</h3>
|
||||
<p>Backups and handoffs first. Fancy multi-file packaging can wait.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-stack">
|
||||
<button type="button" onClick={() => downloadText('buildpulse-export.json', createJsonExport(appState), 'application/json;charset=utf-8')}>
|
||||
Download Full JSON
|
||||
</button>
|
||||
<button type="button" onClick={() => downloadText('pulses.jsonl', createPulseJsonl(appState), 'application/x-ndjson;charset=utf-8')}>
|
||||
Download Pulse JSONL
|
||||
</button>
|
||||
<label className="import-label">
|
||||
Import JSON
|
||||
<input type="file" accept="application/json" onChange={handleImport} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card list-card">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<h3>Markdown Package</h3>
|
||||
<p>`CLAUDE_CONTEXT.md` is the decision-boundary file. Keep it sharp.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-stack markdown-list">
|
||||
{Object.entries(markdownPackage).map(([filename, content]) => (
|
||||
<div key={filename} className="markdown-card">
|
||||
<div className="item-card-header">
|
||||
<strong>{filename}</strong>
|
||||
<div className="button-inline-row">
|
||||
<button type="button" className="ghost small" onClick={() => copyMarkdown(filename)}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" className="ghost small" onClick={() => downloadText(filename, content)}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{content}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="status-bar">
|
||||
<span>{statusMessage}</span>
|
||||
<span>Stored locally · schema {appState.schema_version}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { AppState, Feature, FeatureColumn, PulseEvent } from '../../store/types'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
const columnLabels: Record<FeatureColumn, string> = {
|
||||
now: 'Now',
|
||||
next: 'Next',
|
||||
later: 'Later',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
const groupFeatures = (features: Feature[]) => ({
|
||||
now: features.filter((feature) => feature.column === 'now'),
|
||||
next: features.filter((feature) => feature.column === 'next'),
|
||||
later: features.filter((feature) => feature.column === 'later'),
|
||||
done: features.filter((feature) => feature.column === 'done'),
|
||||
})
|
||||
|
||||
const getFeatureLabel = (features: Feature[], featureId?: string) => {
|
||||
if (!featureId) return '—'
|
||||
return features.find((feature) => feature.id === featureId)?.title ?? `${featureId} (missing feature)`
|
||||
}
|
||||
|
||||
const renderFeature = (feature: Feature) => {
|
||||
const criteria = feature.acceptance_criteria.length
|
||||
? feature.acceptance_criteria.map((item) => ` - ${item}`).join('\n')
|
||||
: ' - None yet'
|
||||
|
||||
return [
|
||||
`- **${feature.title}**`,
|
||||
` - Description: ${feature.description || '—'}`,
|
||||
` - Priority: ${feature.priority}`,
|
||||
` - Status: ${feature.status}`,
|
||||
' - Acceptance Criteria:',
|
||||
criteria,
|
||||
` - Scope Notes: ${feature.scope_notes || '—'}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
|
||||
[...pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
|
||||
export const createJsonExport = (state: AppState) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: state.schema_version,
|
||||
exported_at: new Date().toISOString(),
|
||||
project: state.project,
|
||||
features: state.features,
|
||||
parking_lot: state.parking_lot,
|
||||
pulses: state.pulses,
|
||||
settings: state.settings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
|
||||
export const createPulseJsonl = (state: AppState) => state.pulses.map((pulse) => JSON.stringify(pulse)).join('\n')
|
||||
|
||||
export const createMarkdownPackage = (state: AppState) => {
|
||||
const grouped = groupFeatures(state.features)
|
||||
const recentPulses = sortPulsesNewestFirst(state.pulses).slice(0, 8)
|
||||
|
||||
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 featurePlan = ['# Feature Plan']
|
||||
;(['now', 'next', 'later', 'done'] as FeatureColumn[]).forEach((column) => {
|
||||
featurePlan.push(`\n## ${columnLabels[column]}`)
|
||||
const features = grouped[column]
|
||||
if (!features.length) {
|
||||
featurePlan.push('\n_No features in this column yet._')
|
||||
return
|
||||
}
|
||||
|
||||
featurePlan.push('', ...features.map(renderFeature))
|
||||
})
|
||||
|
||||
const parkingLot = [
|
||||
'# Parking Lot',
|
||||
'',
|
||||
...(state.parking_lot.length
|
||||
? state.parking_lot.map(
|
||||
(item) =>
|
||||
`- **${item.title}**\n - Description: ${item.description || '—'}\n - Reason parked: ${item.reason_parked || '—'}\n - Possible future placement: ${item.possible_future_placement || '—'}\n - Risk level: ${item.risk_level}`,
|
||||
)
|
||||
: ['_No parked ideas yet._']),
|
||||
].join('\n')
|
||||
|
||||
const pulseLog = [
|
||||
'# Pulse Log',
|
||||
'',
|
||||
...(sortPulsesNewestFirst(state.pulses).length
|
||||
? sortPulsesNewestFirst(state.pulses).map(
|
||||
(pulse) =>
|
||||
`- **${formatDateTime(pulse.timestamp)}** — ${pulse.pulse_type}\n - Feature: ${getFeatureLabel(state.features, pulse.feature_id)}\n - Source/Agent: ${pulse.source} / ${pulse.agent_id}\n - Message: ${pulse.message}\n - Confidence: ${pulse.confidence_score}\n - Evidence: ${pulse.evidence_refs.length ? pulse.evidence_refs.join('; ') : '—'}`,
|
||||
)
|
||||
: ['_No pulse events yet._']),
|
||||
].join('\n')
|
||||
|
||||
const claudeContext = [
|
||||
'# AI Coding Context',
|
||||
'',
|
||||
'## Project',
|
||||
`${state.project.name} — ${state.project.one_line_pitch || '—'}`,
|
||||
'',
|
||||
'## Current Goal',
|
||||
state.project.current_goal || '—',
|
||||
'',
|
||||
'## Active Features',
|
||||
grouped.now.length ? grouped.now.map(renderFeature).join('\n\n') : '_None yet._',
|
||||
'',
|
||||
'## Next Features',
|
||||
grouped.next.length ? grouped.next.map(renderFeature).join('\n\n') : '_None yet._',
|
||||
'',
|
||||
'## Done Features',
|
||||
grouped.done.length ? grouped.done.map(renderFeature).join('\n\n') : '_None 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')
|
||||
: '_No parked ideas yet._',
|
||||
'',
|
||||
'## Recent Pulse Events',
|
||||
recentPulses.length
|
||||
? recentPulses
|
||||
.map(
|
||||
(pulse) =>
|
||||
`- ${formatDateTime(pulse.timestamp)} — ${pulse.pulse_type} — ${pulse.message} (${getFeatureLabel(state.features, pulse.feature_id)})`,
|
||||
)
|
||||
.join('\n')
|
||||
: '_No recent pulse events yet._',
|
||||
'',
|
||||
'## Instructions for AI Developer',
|
||||
'- Only work on the selected feature.',
|
||||
'- Do not implement Parking Lot items.',
|
||||
'- Preserve working behavior.',
|
||||
'- Report changes and test steps.',
|
||||
].join('\n')
|
||||
|
||||
return {
|
||||
'PROJECT_SUMMARY.md': projectSummary,
|
||||
'FEATURE_PLAN.md': featurePlan.join('\n'),
|
||||
'PARKING_LOT.md': parkingLot,
|
||||
'PULSE_LOG.md': pulseLog,
|
||||
'CLAUDE_CONTEXT.md': claudeContext,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { AppState } from '../../store/types'
|
||||
|
||||
const seedDate = '2026-05-06T00:00:00+02:00'
|
||||
|
||||
export const createSeedState = (): AppState => ({
|
||||
schema_version: '0.1.0',
|
||||
project: {
|
||||
id: 'project_buildpulse',
|
||||
name: 'BuildPulse',
|
||||
one_line_pitch: 'A local-first 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.',
|
||||
notes: 'First dogfood project: BuildPulse manages BuildPulse.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
features: [
|
||||
{
|
||||
id: 'feature_plan_screen',
|
||||
title: 'Feature Plan screen',
|
||||
description: 'Show the active build plan in calm Now / Next / Later / Done columns.',
|
||||
column: 'now',
|
||||
priority: 'must',
|
||||
status: 'ready',
|
||||
acceptance_criteria: [
|
||||
'User can create a feature card.',
|
||||
'User can move a feature between columns.',
|
||||
'User can edit feature details without clutter.',
|
||||
],
|
||||
scope_notes: 'This is the home screen. It should answer “what now?” immediately.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'parking_lot_screen',
|
||||
title: 'Parking Lot screen',
|
||||
description: 'Capture useful distractions without letting them hijack the build.',
|
||||
column: 'now',
|
||||
priority: 'must',
|
||||
status: 'ready',
|
||||
acceptance_criteria: [
|
||||
'User can add parked ideas quickly.',
|
||||
'Risk and future placement are visible.',
|
||||
],
|
||||
scope_notes: 'Parking is success behavior, not failure.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'pulse_log_screen',
|
||||
title: 'Pulse Log screen',
|
||||
description: 'Log intent, action, results, blockers, and decisions in a future-compatible pulse shape.',
|
||||
column: 'now',
|
||||
priority: 'must',
|
||||
status: 'ready',
|
||||
acceptance_criteria: [
|
||||
'User can add manual pulse events.',
|
||||
'Pulses can link to features optionally.',
|
||||
],
|
||||
scope_notes: 'Manual in v0.1. No live agent ingestion yet.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'export_screen',
|
||||
title: 'Export screen',
|
||||
description: 'Generate clean JSON, JSONL, and Markdown handoff context for AI developers.',
|
||||
column: 'now',
|
||||
priority: 'must',
|
||||
status: 'ready',
|
||||
acceptance_criteria: [
|
||||
'JSON export works.',
|
||||
'Markdown export includes CLAUDE_CONTEXT.md.',
|
||||
],
|
||||
scope_notes: 'Handoff quality matters more than bells and whistles.',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
],
|
||||
parking_lot: [
|
||||
{
|
||||
id: 'parked_ai_triage',
|
||||
title: 'AI idea triage',
|
||||
description: 'Use AI to classify new ideas into Now, Next, Later, Parking Lot, or Reject.',
|
||||
reason_parked: 'Manual workflow must prove useful first.',
|
||||
possible_future_placement: 'v0.2',
|
||||
risk_level: 'medium',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'parked_multi_project',
|
||||
title: 'Multi-project support',
|
||||
description: 'Track several products from one BuildPulse instance.',
|
||||
reason_parked: 'Single-project discipline is the whole point of v0.1.',
|
||||
possible_future_placement: 'v0.6+',
|
||||
risk_level: 'medium',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
{
|
||||
id: 'parked_openclaw_integration',
|
||||
title: 'OpenClaw / Hermes integration',
|
||||
description: 'Ingest agent events, task output, and status directly into BuildPulse.',
|
||||
reason_parked: 'Way too spicy for v0.1. Manual pulse logging first.',
|
||||
possible_future_placement: 'v1.0+',
|
||||
risk_level: 'high',
|
||||
created_at: seedDate,
|
||||
updated_at: seedDate,
|
||||
},
|
||||
],
|
||||
pulses: [
|
||||
{
|
||||
id: 'pulse_seed_001',
|
||||
timestamp: seedDate,
|
||||
project_id: 'project_buildpulse',
|
||||
feature_id: 'feature_plan_screen',
|
||||
source: 'manual',
|
||||
agent_id: 'jimmi',
|
||||
pulse_type: 'INTENT',
|
||||
message: 'Start BuildPulse as a calm single-project cockpit, not a full agent framework.',
|
||||
structured_payload: {},
|
||||
confidence_score: 0.95,
|
||||
evidence_refs: ['docs/PRODUCT_BRIEF.md', 'docs/SCOPE.md'],
|
||||
trace_id: 'session_seed',
|
||||
},
|
||||
{
|
||||
id: 'pulse_seed_002',
|
||||
timestamp: seedDate,
|
||||
project_id: 'project_buildpulse',
|
||||
source: 'manual',
|
||||
agent_id: 'jimmi',
|
||||
pulse_type: 'DECISION',
|
||||
message: 'Park AI triage, releases, and integrations until the manual workflow proves itself.',
|
||||
structured_payload: {},
|
||||
confidence_score: 0.9,
|
||||
evidence_refs: ['docs/DECISIONS.md', 'docs/PARKING_LOT.md'],
|
||||
trace_id: 'session_seed',
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
theme: 'light',
|
||||
default_agent_id: 'jimmi',
|
||||
},
|
||||
})
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #e7ecf5;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(122, 162, 247, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #0b1020 0%, #0f172a 100%);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1320px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
box-shadow: 0 22px 60px rgba(2, 6, 23, 0.25);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.6rem;
|
||||
border-radius: 28px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero-card h1,
|
||||
.section-heading h2,
|
||||
.section-heading h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: #9fb5ff;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
margin: 0.6rem 0;
|
||||
max-width: 52rem;
|
||||
color: #c9d4ea;
|
||||
}
|
||||
|
||||
.hero-goal {
|
||||
margin: 0;
|
||||
color: #dbe7ff;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(90px, 1fr));
|
||||
gap: 0.75rem;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.hero-stats div,
|
||||
.quick-actions,
|
||||
.status-bar,
|
||||
.tab {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.hero-stats div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
background: rgba(30, 41, 59, 0.72);
|
||||
}
|
||||
|
||||
.hero-stats span,
|
||||
.meta-row,
|
||||
small,
|
||||
.status-bar {
|
||||
color: #b7c4db;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
font-size: 1.8rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.project-card,
|
||||
.quick-actions,
|
||||
.tab-bar,
|
||||
.status-bar,
|
||||
.view-stack,
|
||||
.editor-grid,
|
||||
.board-grid {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 24px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-heading p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #9fb0c9;
|
||||
}
|
||||
|
||||
.section-heading.compact {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.project-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.full-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
color: #d9e2f4;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: #f8fbff;
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.66);
|
||||
color: #dbe7ff;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: linear-gradient(135deg, rgba(96, 165, 250, 0.35), rgba(129, 140, 248, 0.3));
|
||||
border-color: rgba(129, 140, 248, 0.6);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.button-row,
|
||||
.button-inline-row,
|
||||
.button-stack,
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button,
|
||||
.import-label {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #60a5fa, #818cf8);
|
||||
color: white;
|
||||
padding: 0.8rem 1rem;
|
||||
transition: transform 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.import-label:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button.ghost,
|
||||
.import-label {
|
||||
background: rgba(30, 41, 59, 0.84);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.import-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.import-label input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-grid,
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.column {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.column-header,
|
||||
.item-card-header,
|
||||
.meta-row,
|
||||
.status-bar,
|
||||
.filters-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column-header p,
|
||||
.item-card p,
|
||||
.item-card small,
|
||||
.mini-pulse p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.column-body,
|
||||
.list-stack,
|
||||
.markdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-card,
|
||||
.markdown-card,
|
||||
.empty-state,
|
||||
.mini-pulse {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(30, 41, 59, 0.58);
|
||||
color: inherit;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.item-card p,
|
||||
.mini-pulse p,
|
||||
.empty-state {
|
||||
color: #c9d4ea;
|
||||
}
|
||||
|
||||
.item-card.feature-card,
|
||||
.item-card.parking-card,
|
||||
.item-card.pulse-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.item-card select {
|
||||
min-width: 120px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: capitalize;
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.pill.must {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
|
||||
.pill.should {
|
||||
background: rgba(52, 211, 153, 0.16);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.pill.could {
|
||||
background: rgba(250, 204, 21, 0.16);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.pill.later,
|
||||
.pill.risk-medium {
|
||||
background: rgba(244, 114, 182, 0.16);
|
||||
color: #fbcfe8;
|
||||
}
|
||||
|
||||
.pill.risk-low {
|
||||
background: rgba(52, 211, 153, 0.16);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.pill.risk-high {
|
||||
background: rgba(251, 146, 60, 0.16);
|
||||
color: #fed7aa;
|
||||
}
|
||||
|
||||
.pill.risk-dangerous {
|
||||
background: rgba(248, 113, 113, 0.18);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
font-size: 0.88rem;
|
||||
color: #dbe7ff;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 0.95rem 1.1rem;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.board-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.editor-grid,
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell {
|
||||
width: min(100% - 1rem, 100%);
|
||||
padding: 1rem 0 2rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-stats,
|
||||
.board-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions,
|
||||
.tab-bar,
|
||||
.button-row,
|
||||
.filter-row,
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filters-heading {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createSeedState } from '../features/project/projectDefaults'
|
||||
import { FEATURE_COLUMNS, PULSE_TYPES, RISK_LEVELS, SCHEMA_VERSION, STORAGE_KEY } from './types'
|
||||
import type { AppState } from './types'
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const hasRequiredStrings = (value: Record<string, unknown>, keys: string[]) =>
|
||||
keys.every((key) => typeof value[key] === 'string' && value[key])
|
||||
|
||||
export const validateAppState = (value: unknown): value is AppState => {
|
||||
if (!isObject(value)) return false
|
||||
if (value.schema_version !== SCHEMA_VERSION) return false
|
||||
if (!isObject(value.project) || !hasRequiredStrings(value.project, ['id', 'name'])) return false
|
||||
if (!Array.isArray(value.features) || !Array.isArray(value.parking_lot) || !Array.isArray(value.pulses)) return false
|
||||
if (
|
||||
value.features.some(
|
||||
(feature) =>
|
||||
!isObject(feature) ||
|
||||
!hasRequiredStrings(feature, ['id', 'title', 'column']) ||
|
||||
!FEATURE_COLUMNS.includes(feature.column as (typeof FEATURE_COLUMNS)[number]),
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
value.parking_lot.some(
|
||||
(item) =>
|
||||
!isObject(item) ||
|
||||
!hasRequiredStrings(item, ['id', 'title']) ||
|
||||
(typeof item.risk_level === 'string' && !RISK_LEVELS.includes(item.risk_level as (typeof RISK_LEVELS)[number])),
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
value.pulses.some(
|
||||
(pulse) =>
|
||||
!isObject(pulse) ||
|
||||
!hasRequiredStrings(pulse, ['id', 'timestamp', 'project_id', 'pulse_type', 'message']) ||
|
||||
!PULSE_TYPES.includes(pulse.pulse_type as (typeof PULSE_TYPES)[number]),
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const loadAppState = (): AppState => {
|
||||
if (typeof window === 'undefined') return createSeedState()
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return createSeedState()
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (validateAppState(parsed)) return parsed
|
||||
} catch {
|
||||
// fall through to seed state
|
||||
}
|
||||
|
||||
return createSeedState()
|
||||
}
|
||||
|
||||
export const saveAppState = (state: AppState) => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
export const replaceAppState = (state: AppState) => {
|
||||
saveAppState(state)
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export const STORAGE_KEY = 'buildpulse.appState.v1'
|
||||
export const SCHEMA_VERSION = '0.1.0'
|
||||
|
||||
export const FEATURE_COLUMNS = ['now', 'next', 'later', 'done'] as const
|
||||
export type FeatureColumn = (typeof FEATURE_COLUMNS)[number]
|
||||
|
||||
export const FEATURE_PRIORITIES = ['must', 'should', 'could', 'later'] as const
|
||||
export type FeaturePriority = (typeof FEATURE_PRIORITIES)[number]
|
||||
|
||||
export const FEATURE_STATUSES = [
|
||||
'idea',
|
||||
'shaping',
|
||||
'ready',
|
||||
'building',
|
||||
'testing',
|
||||
'done',
|
||||
'parked',
|
||||
'rejected',
|
||||
] as const
|
||||
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 PULSE_TYPES = [
|
||||
'INTENT',
|
||||
'ACTION',
|
||||
'RESULT',
|
||||
'BLOCKER',
|
||||
'DECISION',
|
||||
'PARKED_IDEA',
|
||||
'TEST_RESULT',
|
||||
'SESSION_START',
|
||||
'SESSION_END',
|
||||
'REFLECTION',
|
||||
] as const
|
||||
export type PulseType = (typeof PULSE_TYPES)[number]
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
one_line_pitch: string
|
||||
description: string
|
||||
current_goal: string
|
||||
notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
column: FeatureColumn
|
||||
priority: FeaturePriority
|
||||
status: FeatureStatus
|
||||
acceptance_criteria: string[]
|
||||
scope_notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ParkingLotItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
reason_parked: string
|
||||
possible_future_placement: string
|
||||
risk_level: RiskLevel
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PulseEvent {
|
||||
id: string
|
||||
timestamp: string
|
||||
project_id: string
|
||||
feature_id?: string
|
||||
source: string
|
||||
agent_id: string
|
||||
pulse_type: PulseType
|
||||
message: string
|
||||
structured_payload: Record<string, unknown>
|
||||
confidence_score: number
|
||||
evidence_refs: string[]
|
||||
trace_id?: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
theme: 'light'
|
||||
default_agent_id: string
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
schema_version: string
|
||||
project: Project
|
||||
features: Feature[]
|
||||
parking_lot: ParkingLotItem[]
|
||||
pulses: PulseEvent[]
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
export type TabKey = 'feature-plan' | 'parking-lot' | 'pulse-log' | 'export'
|
||||
@@ -0,0 +1,26 @@
|
||||
export const nowIso = () => new Date().toISOString()
|
||||
|
||||
export const formatDateTime = (value: string) => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export const linesToArray = (value: string) =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export const arrayToLines = (value: string[]) => value.join('\n')
|
||||
|
||||
export const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'item'
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user