feat: tighten feature pulse flow and backend resilience
This commit is contained in:
@@ -11,17 +11,34 @@ const baseHeaders = {
|
|||||||
'X-Appwrite-Project': projectId,
|
'X-Appwrite-Project': projectId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = Number(process.env.BUILDPULSE_APPWRITE_TIMEOUT_MS || 4500)
|
||||||
const documentUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents/${BUILDPULSE_DOCUMENT_ID}`
|
const documentUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents/${BUILDPULSE_DOCUMENT_ID}`
|
||||||
const collectionUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents`
|
const collectionUrl = `${endpoint}/databases/${BUILDPULSE_DATABASE_ID}/collections/${BUILDPULSE_COLLECTION_ID}/documents`
|
||||||
|
|
||||||
async function appwriteFetch(url, init = {}) {
|
async function appwriteFetch(url, init = {}) {
|
||||||
const response = await fetch(url, {
|
const controller = new AbortController()
|
||||||
...init,
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
headers: {
|
|
||||||
...baseHeaders,
|
let response
|
||||||
...(init.headers || {}),
|
try {
|
||||||
},
|
response = await fetch(url, {
|
||||||
})
|
...init,
|
||||||
|
signal: init.signal || controller.signal,
|
||||||
|
headers: {
|
||||||
|
...baseHeaders,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
const timeoutError = new Error(`Appwrite request timed out after ${REQUEST_TIMEOUT_MS}ms`)
|
||||||
|
timeoutError.status = 504
|
||||||
|
throw timeoutError
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 204) return null
|
if (response.status === 204) return null
|
||||||
|
|
||||||
|
|||||||
+80
-28
@@ -157,6 +157,24 @@ function App() {
|
|||||||
[appState.pulses, selectedPulseId],
|
[appState.pulses, selectedPulseId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const featurePulseMeta = useMemo(() => {
|
||||||
|
const meta = new Map<string, { count: number; latest: PulseEvent | null }>()
|
||||||
|
|
||||||
|
for (const pulse of [...appState.pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))) {
|
||||||
|
if (!pulse.feature_id) continue
|
||||||
|
|
||||||
|
const current = meta.get(pulse.feature_id)
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.set(pulse.feature_id, { count: 1, latest: pulse })
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}, [appState.pulses])
|
||||||
|
|
||||||
const selectedFeaturePulses = useMemo(() => {
|
const selectedFeaturePulses = useMemo(() => {
|
||||||
if (!selectedFeature) return []
|
if (!selectedFeature) return []
|
||||||
|
|
||||||
@@ -297,6 +315,20 @@ function App() {
|
|||||||
setStatusMessage(feature ? `Prepared AI handoff for “${feature.title}”.` : 'Prepared AI handoff prompt.')
|
setStatusMessage(feature ? `Prepared AI handoff for “${feature.title}”.` : 'Prepared AI handoff prompt.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openFeaturePulse = (featureId: string) => {
|
||||||
|
const feature = appState.features.find((entry) => entry.id === featureId)
|
||||||
|
setSelectedPulseId(null)
|
||||||
|
setPulseDraft((current) => ({
|
||||||
|
...initialPulseDraft,
|
||||||
|
source: current.source,
|
||||||
|
agentId: current.agentId,
|
||||||
|
featureId,
|
||||||
|
pulseType: 'ACTION',
|
||||||
|
}))
|
||||||
|
setActiveTab('pulse-log')
|
||||||
|
setStatusMessage(feature ? `Pulse composer aimed at “${feature.title}”.` : 'Pulse composer ready.')
|
||||||
|
}
|
||||||
|
|
||||||
const beginParkingEdit = (item: ParkingLotItem) => {
|
const beginParkingEdit = (item: ParkingLotItem) => {
|
||||||
setSelectedParkingId(item.id)
|
setSelectedParkingId(item.id)
|
||||||
setParkingDraft({
|
setParkingDraft({
|
||||||
@@ -626,6 +658,9 @@ function App() {
|
|||||||
<p>Selected and ready. Shape it here, then kick a clean brief to your agent.</p>
|
<p>Selected and ready. Shape it here, then kick a clean brief to your agent.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-inline-row">
|
<div className="button-inline-row">
|
||||||
|
<button type="button" className="ghost" onClick={() => openFeaturePulse(selectedFeature.id)}>
|
||||||
|
Log Feature Pulse
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
<button type="button" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
||||||
Prepare AI Handoff
|
Prepare AI Handoff
|
||||||
</button>
|
</button>
|
||||||
@@ -693,34 +728,48 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="column-body">
|
<div className="column-body">
|
||||||
{groupedFeatures[column].length ? (
|
{groupedFeatures[column].length ? (
|
||||||
groupedFeatures[column].map((feature) => (
|
groupedFeatures[column].map((feature) => {
|
||||||
<button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}>
|
const pulseMeta = featurePulseMeta.get(feature.id)
|
||||||
<div className="item-card-header">
|
|
||||||
<strong>{feature.title}</strong>
|
return (
|
||||||
<span className={`pill ${feature.priority}`}>{feature.priority}</span>
|
<button key={feature.id} type="button" className="item-card feature-card" onClick={() => beginFeatureEdit(feature)}>
|
||||||
</div>
|
<div className="item-card-header">
|
||||||
<p>{feature.description || 'No description yet.'}</p>
|
<strong>{feature.title}</strong>
|
||||||
<div className="meta-row">
|
<span className={`pill ${feature.priority}`}>{feature.priority}</span>
|
||||||
<span>{feature.acceptance_criteria.length} criteria</span>
|
</div>
|
||||||
<label>
|
<p>{feature.description || 'No description yet.'}</p>
|
||||||
<span className="sr-only">Move feature</span>
|
<div className="feature-signal-row">
|
||||||
<select
|
<span>{feature.acceptance_criteria.length} criteria</span>
|
||||||
value={feature.column}
|
{pulseMeta ? (
|
||||||
onChange={(event) => {
|
<span className="feature-signal">
|
||||||
event.stopPropagation()
|
{pulseMeta.count} pulse{pulseMeta.count === 1 ? '' : 's'} · last {pulseMeta.latest?.pulse_type ?? 'event'}
|
||||||
quickMoveFeature(feature.id, event.target.value as FeatureColumn)
|
</span>
|
||||||
}}
|
) : (
|
||||||
>
|
<span className="feature-signal quiet">No linked pulses yet</span>
|
||||||
{FEATURE_COLUMNS.map((entry) => (
|
)}
|
||||||
<option key={entry} value={entry}>
|
</div>
|
||||||
{columnLabels[entry]}
|
<div className="meta-row">
|
||||||
</option>
|
<span className="pill">{feature.status}</span>
|
||||||
))}
|
<label>
|
||||||
</select>
|
<span className="sr-only">Move feature</span>
|
||||||
</label>
|
<select
|
||||||
</div>
|
value={feature.column}
|
||||||
</button>
|
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 className="empty-state">No features here yet.</div>
|
||||||
)}
|
)}
|
||||||
@@ -790,6 +839,9 @@ function App() {
|
|||||||
<button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button>
|
<button type="button" className="ghost" onClick={resetFeatureDraft}>Clear</button>
|
||||||
{selectedFeature && (
|
{selectedFeature && (
|
||||||
<>
|
<>
|
||||||
|
<button type="button" className="ghost" onClick={() => openFeaturePulse(selectedFeature.id)}>
|
||||||
|
Log Feature Pulse
|
||||||
|
</button>
|
||||||
<button type="button" className="ghost" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
<button type="button" className="ghost" onClick={() => openFeatureHandoff(selectedFeature.id)}>
|
||||||
Prepare AI Handoff
|
Prepare AI Handoff
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+23
-1
@@ -354,6 +354,7 @@ button.small {
|
|||||||
.column-header,
|
.column-header,
|
||||||
.item-card-header,
|
.item-card-header,
|
||||||
.meta-row,
|
.meta-row,
|
||||||
|
.feature-signal-row,
|
||||||
.status-bar,
|
.status-bar,
|
||||||
.filters-heading {
|
.filters-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -405,6 +406,26 @@ pre {
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-signal-row {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #9fb4d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-signal {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
background: rgba(96, 165, 250, 0.12);
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-signal.quiet {
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
.item-card select {
|
.item-card select {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 0.7rem;
|
||||||
@@ -516,7 +537,8 @@ pre {
|
|||||||
.tab-bar,
|
.tab-bar,
|
||||||
.button-row,
|
.button-row,
|
||||||
.filter-row,
|
.filter-row,
|
||||||
.status-bar {
|
.status-bar,
|
||||||
|
.feature-signal-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-7
@@ -1,15 +1,30 @@
|
|||||||
import type { AppState } from './types'
|
import type { AppState } from './types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
|
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 request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const controller = new AbortController()
|
||||||
headers: {
|
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(init?.headers || {}),
|
let response: Response
|
||||||
},
|
try {
|
||||||
...init,
|
response = await fetch(`${API_BASE}${path}`, {
|
||||||
})
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
...init,
|
||||||
|
signal: init?.signal || controller.signal,
|
||||||
|
})
|
||||||
|
} 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 error
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text()
|
const message = await response.text()
|
||||||
|
|||||||
Reference in New Issue
Block a user