merge: gitea/main — resolve pitch-deck conflicts (accept theirs)
Some checks failed
CI / test-go-consent (push) Successful in 45s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Some checks failed
CI / test-go-consent (push) Successful in 45s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface AuditTrackerOptions {
|
||||
investorId: string | null
|
||||
currentSlide: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function useAuditTracker({ investorId, currentSlide, enabled }: AuditTrackerOptions) {
|
||||
const lastSlide = useRef<string>('')
|
||||
const slideTimestamps = useRef<Map<string, number>>(new Map())
|
||||
const pendingEvents = useRef<Array<{ action: string; details: Record<string, unknown>; slide_id?: string }>>([])
|
||||
const flushTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (pendingEvents.current.length === 0) return
|
||||
const events = [...pendingEvents.current]
|
||||
pendingEvents.current = []
|
||||
|
||||
// Send events one at a time (they're debounced so there shouldn't be many)
|
||||
for (const event of events) {
|
||||
try {
|
||||
await fetch('/api/audit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail - audit should not block UX
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useCallback((action: string, details: Record<string, unknown> = {}, slideId?: string) => {
|
||||
if (!enabled || !investorId) return
|
||||
|
||||
pendingEvents.current.push({ action, details, slide_id: slideId })
|
||||
|
||||
// Debounce flush by 500ms
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushTimer.current = setTimeout(flush, 500)
|
||||
}, [enabled, investorId, flush])
|
||||
|
||||
// Track slide views
|
||||
useEffect(() => {
|
||||
if (!enabled || !investorId || !currentSlide) return
|
||||
if (currentSlide === lastSlide.current) return
|
||||
|
||||
const now = Date.now()
|
||||
const prevTimestamp = slideTimestamps.current.get(lastSlide.current)
|
||||
const dwellTime = prevTimestamp ? now - prevTimestamp : 0
|
||||
|
||||
lastSlide.current = currentSlide
|
||||
slideTimestamps.current.set(currentSlide, now)
|
||||
|
||||
track('slide_viewed', {
|
||||
slide_id: currentSlide,
|
||||
previous_dwell_ms: dwellTime,
|
||||
}, currentSlide)
|
||||
}, [currentSlide, enabled, investorId, track])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flush()
|
||||
}
|
||||
}, [flush])
|
||||
|
||||
return { track }
|
||||
}
|
||||
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: string
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [investor, setInvestor] = useState<Investor | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInvestor(data.investor)
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchMe()
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/auth'
|
||||
}, [])
|
||||
|
||||
return { investor, loading, logout }
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { FMScenario, FMResult, FMComputeResponse } from '../types'
|
||||
import { FMScenario, FMResult, FMComputeResponse, InvestorSnapshot } from '../types'
|
||||
|
||||
export function useFinancialModel() {
|
||||
export function useFinancialModel(investorId?: string | null) {
|
||||
const [scenarios, setScenarios] = useState<FMScenario[]>([])
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [computing, setComputing] = useState(false)
|
||||
const [snapshotStatus, setSnapshotStatus] = useState<'default' | 'saving' | 'saved' | 'restored'>('default')
|
||||
const computeTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const snapshotTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const snapshotsLoaded = useRef(false)
|
||||
|
||||
// Load scenarios on mount
|
||||
// Load scenarios on mount, then apply snapshots if investor is logged in
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
let data: FMScenario[] = await res.json()
|
||||
|
||||
// If investor is logged in, restore their snapshots
|
||||
if (investorId && !snapshotsLoaded.current) {
|
||||
try {
|
||||
const snapRes = await fetch('/api/snapshots')
|
||||
if (snapRes.ok) {
|
||||
const { snapshots } = await snapRes.json() as { snapshots: InvestorSnapshot[] }
|
||||
if (snapshots.length > 0) {
|
||||
data = data.map(scenario => {
|
||||
const snapshot = snapshots.find(s => s.scenario_id === scenario.id)
|
||||
if (!snapshot) return scenario
|
||||
return {
|
||||
...scenario,
|
||||
assumptions: scenario.assumptions.map(a => {
|
||||
const savedValue = snapshot.assumptions[a.key]
|
||||
return savedValue !== undefined ? { ...a, value: savedValue } : a
|
||||
}),
|
||||
}
|
||||
})
|
||||
setSnapshotStatus('restored')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Snapshot restore failed — use defaults
|
||||
}
|
||||
snapshotsLoaded.current = true
|
||||
}
|
||||
|
||||
setScenarios(data)
|
||||
const defaultScenario = data.find(s => s.is_default) || data[0]
|
||||
if (defaultScenario) {
|
||||
@@ -32,7 +63,7 @@ export function useFinancialModel() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
}, [investorId])
|
||||
|
||||
// Compute when active scenario changes
|
||||
useEffect(() => {
|
||||
@@ -41,17 +72,17 @@ export function useFinancialModel() {
|
||||
}
|
||||
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const compute = useCallback(async (scenarioId: string, source?: string) => {
|
||||
const compute = useCallback(async (scenarioId: string) => {
|
||||
setComputing(true)
|
||||
try {
|
||||
const res = await fetch('/api/financial-model/compute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenarioId, source }),
|
||||
body: JSON.stringify({ scenarioId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data: FMComputeResponse = await res.json()
|
||||
setResults(prev => new Map(prev).set(source === 'finanzplan' ? 'finanzplan' : scenarioId, data))
|
||||
setResults(prev => new Map(prev).set(scenarioId, data))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Compute failed:', err)
|
||||
@@ -60,9 +91,27 @@ export function useFinancialModel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const computeFromFinanzplan = useCallback(async () => {
|
||||
await compute('', 'finanzplan')
|
||||
}, [compute])
|
||||
// Auto-save snapshot (debounced)
|
||||
const saveSnapshot = useCallback(async (scenarioId: string) => {
|
||||
if (!investorId) return
|
||||
const scenario = scenarios.find(s => s.id === scenarioId)
|
||||
if (!scenario) return
|
||||
|
||||
const assumptions: Record<string, number | number[]> = {}
|
||||
scenario.assumptions.forEach(a => { assumptions[a.key] = a.value })
|
||||
|
||||
setSnapshotStatus('saving')
|
||||
try {
|
||||
await fetch('/api/snapshots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenario_id: scenarioId, assumptions }),
|
||||
})
|
||||
setSnapshotStatus('saved')
|
||||
} catch {
|
||||
setSnapshotStatus('default')
|
||||
}
|
||||
}, [investorId, scenarios])
|
||||
|
||||
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
||||
// Optimistic update in local state
|
||||
@@ -84,7 +133,33 @@ export function useFinancialModel() {
|
||||
// Debounced recompute
|
||||
if (computeTimer.current) clearTimeout(computeTimer.current)
|
||||
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
|
||||
}, [compute])
|
||||
|
||||
// Debounced snapshot save (2s after last change)
|
||||
if (snapshotTimer.current) clearTimeout(snapshotTimer.current)
|
||||
snapshotTimer.current = setTimeout(() => saveSnapshot(scenarioId), 2000)
|
||||
}, [compute, saveSnapshot])
|
||||
|
||||
const resetToDefaults = useCallback(async (scenarioId: string) => {
|
||||
// Reload from server (without snapshots)
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
const defaultScenario = data.find(s => s.id === scenarioId)
|
||||
if (defaultScenario) {
|
||||
setScenarios(prev => prev.map(s => s.id === scenarioId ? defaultScenario : s))
|
||||
// Delete snapshot
|
||||
if (investorId) {
|
||||
await fetch(`/api/snapshots?id=${scenarioId}`, { method: 'DELETE' })
|
||||
}
|
||||
setSnapshotStatus('default')
|
||||
compute(scenarioId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [compute, investorId])
|
||||
|
||||
const computeAll = useCallback(async () => {
|
||||
for (const s of scenarios) {
|
||||
@@ -94,7 +169,6 @@ export function useFinancialModel() {
|
||||
|
||||
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
|
||||
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
|
||||
const finanzplanResults = results.get('finanzplan') || null
|
||||
|
||||
return {
|
||||
scenarios,
|
||||
@@ -102,7 +176,6 @@ export function useFinancialModel() {
|
||||
activeScenarioId,
|
||||
setActiveScenarioId,
|
||||
activeResults,
|
||||
finanzplanResults,
|
||||
results,
|
||||
loading,
|
||||
computing,
|
||||
@@ -110,7 +183,8 @@ export function useFinancialModel() {
|
||||
setCompareMode,
|
||||
compute,
|
||||
computeAll,
|
||||
computeFromFinanzplan,
|
||||
updateAssumption,
|
||||
resetToDefaults,
|
||||
snapshotStatus,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user