feat: wire BuildPulse to Appwrite-backed persistence
This commit is contained in:
+53
-18
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, 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 { fetchRemoteState, pushRemoteState } from './store/remote'
|
||||
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'
|
||||
@@ -41,7 +42,6 @@ const initialPulseDraft = {
|
||||
confidence: '0.9',
|
||||
evidenceRefs: '',
|
||||
traceId: '',
|
||||
structuredPayload: '{}',
|
||||
}
|
||||
|
||||
const columnLabels: Record<FeatureColumn, string> = {
|
||||
@@ -74,11 +74,61 @@ function App() {
|
||||
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
|
||||
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
|
||||
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
|
||||
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
|
||||
const hasHydratedRemote = useRef(false)
|
||||
const initialLocalStateRef = useRef(appState)
|
||||
|
||||
useEffect(() => {
|
||||
saveAppState(appState)
|
||||
}, [appState])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
const remoteState = await fetchRemoteState()
|
||||
if (cancelled) return
|
||||
|
||||
if (remoteState && validateAppState(remoteState)) {
|
||||
setAppState(remoteState)
|
||||
saveAppState(remoteState)
|
||||
setStatusMessage('Loaded state from Appwrite on the Unraid server.')
|
||||
} else {
|
||||
await pushRemoteState(initialLocalStateRef.current)
|
||||
if (cancelled) return
|
||||
setStatusMessage('Seeded Appwrite on Unraid from the local BuildPulse state.')
|
||||
}
|
||||
setBackendMode('appwrite')
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
setBackendMode('local-cache')
|
||||
setStatusMessage('Appwrite backend unavailable, so BuildPulse is using the local cache for now.')
|
||||
} finally {
|
||||
hasHydratedRemote.current = true
|
||||
}
|
||||
}
|
||||
|
||||
void hydrate()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydratedRemote.current || backendMode !== 'appwrite') return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void pushRemoteState(appState).catch(() => {
|
||||
setBackendMode('local-cache')
|
||||
setStatusMessage('Saved locally. Appwrite sync tripped over itself, so the cache is carrying the load.')
|
||||
})
|
||||
}, 350)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [appState, backendMode])
|
||||
|
||||
const groupedFeatures = useMemo(
|
||||
() =>
|
||||
FEATURE_COLUMNS.reduce<Record<FeatureColumn, Feature[]>>((acc, column) => {
|
||||
@@ -324,7 +374,6 @@ function App() {
|
||||
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'],
|
||||
},
|
||||
@@ -346,7 +395,6 @@ function App() {
|
||||
confidence: String(pulse.confidence_score),
|
||||
evidenceRefs: arrayToLines(pulse.evidence_refs),
|
||||
traceId: pulse.trace_id ?? '',
|
||||
structuredPayload: JSON.stringify(pulse.structured_payload ?? {}, null, 2),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -361,14 +409,6 @@ function App() {
|
||||
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)}`,
|
||||
@@ -379,7 +419,6 @@ function App() {
|
||||
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,
|
||||
@@ -834,10 +873,6 @@ function App() {
|
||||
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>
|
||||
@@ -971,7 +1006,7 @@ function App() {
|
||||
|
||||
<footer className="status-bar">
|
||||
<span>{statusMessage}</span>
|
||||
<span>Stored locally · schema {appState.schema_version}</span>
|
||||
<span>{backendMode === 'appwrite' ? 'Appwrite backend · Unraid server' : backendMode === 'connecting' ? 'Connecting to Appwrite…' : 'Local cache fallback'} · schema {appState.schema_version}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ export const createSeedState = (): AppState => ({
|
||||
project: {
|
||||
id: 'project_buildpulse',
|
||||
name: 'BuildPulse',
|
||||
one_line_pitch: 'A local-first planning cockpit for AI-assisted product building.',
|
||||
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.',
|
||||
@@ -120,7 +120,6 @@ export const createSeedState = (): AppState => ({
|
||||
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',
|
||||
@@ -133,7 +132,6 @@ export const createSeedState = (): AppState => ({
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AppState } from './types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
...init,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || `Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const fetchRemoteState = async () => {
|
||||
const payload = await request<{ ok: boolean; state: AppState | null }>('/api/state')
|
||||
return payload.state
|
||||
}
|
||||
|
||||
export const pushRemoteState = async (state: AppState) => {
|
||||
await request<{ ok: boolean }>('/api/state', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ state }),
|
||||
})
|
||||
}
|
||||
|
||||
export const fetchBackendHealth = async () => request<{ ok: boolean; backend: string; rows: number }>('/api/health')
|
||||
@@ -80,7 +80,6 @@ export interface PulseEvent {
|
||||
agent_id: string
|
||||
pulse_type: PulseType
|
||||
message: string
|
||||
structured_payload: Record<string, unknown>
|
||||
confidence_score: number
|
||||
evidence_refs: string[]
|
||||
trace_id?: string
|
||||
|
||||
Reference in New Issue
Block a user