File size: 9,741 Bytes
f667d47 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | 'use client'
import { useState, useEffect, useCallback } from 'react'
import { Sliders, RotateCcw, Save, CheckCircle2, Users, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { wallets } from '@/lib/mock-data'
import { calculateChurnScore, DEFAULT_THRESHOLDS, ScoringThresholds } from '@/lib/agent-engine'
import type { ChurnRisk } from '@/lib/types'
const STORAGE_KEY = 'flowstate_thresholds'
function loadThresholds(): ScoringThresholds {
if (typeof window === 'undefined') return DEFAULT_THRESHOLDS
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? { ...DEFAULT_THRESHOLDS, ...JSON.parse(raw) } : DEFAULT_THRESHOLDS
} catch {
return DEFAULT_THRESHOLDS
}
}
function walletToSignals(w: typeof wallets[0]) {
const daysMatch = w.lastActive.match(/(\d+)d/)
const daysInactive = daysMatch ? parseInt(daysMatch[1]) : 0
return {
daysInactive,
volumeDropPct: w.streak === 0 ? 80 : Math.max(0, (10 - Math.min(w.streak, 10)) * 8),
uniqueProtocols: w.protocols.length,
currentStreak: w.streak,
hasLiquidation: false,
}
}
const RISK_COLORS: Record<ChurnRisk, string> = {
critical: 'bg-trading-down/20 text-trading-down',
high: 'bg-[#ff9500]/20 text-[#ff9500]',
medium: 'bg-brand-yellow/20 text-brand-yellow',
low: 'bg-trading-up/20 text-trading-up',
safe: 'bg-muted/20 text-muted',
}
interface SliderRowProps {
label: string; desc: string; value: number; min: number; max: number; unit: string
onChange: (v: number) => void
}
function SliderRow({ label, desc, value, min, max, unit, onChange }: SliderRowProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-body-sm font-medium text-[#eaecef]">{label}</p>
<p className="text-caption text-muted">{desc}</p>
</div>
<span className="font-mono text-num-sm text-brand-yellow tabular-nums w-16 text-right">{value}{unit}</span>
</div>
<input
type="range" min={min} max={max} value={value}
onChange={e => onChange(parseInt(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none bg-surface-elevated cursor-pointer accent-brand-yellow"
/>
<div className="flex justify-between text-[10px] text-muted">
<span>{min}{unit}</span><span>{max}{unit}</span>
</div>
</div>
)
}
export default function SettingsPage() {
const [thresholds, setThresholds] = useState<ScoringThresholds>(DEFAULT_THRESHOLDS)
const [saved, setSaved] = useState(false)
useEffect(() => { setThresholds(loadThresholds()) }, [])
const set = useCallback(<K extends keyof ScoringThresholds>(key: K, value: number) => {
setThresholds(t => ({ ...t, [key]: value }))
}, [])
const preview = wallets.map(w => {
const signals = walletToSignals(w)
const { score, risk } = calculateChurnScore(signals, thresholds)
return { ...w, score, risk }
})
const riskCounts = preview.reduce<Record<string, number>>((acc, w) => {
acc[w.risk] = (acc[w.risk] || 0) + 1
return acc
}, {})
const handleSave = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(thresholds))
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleReset = () => {
setThresholds(DEFAULT_THRESHOLDS)
localStorage.removeItem(STORAGE_KEY)
}
return (
<div className="p-6 space-y-6 max-w-4xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-display-sm text-[#eaecef]">Threshold Editor</h1>
<p className="text-body-md text-muted mt-1">Tune the 5-signal AI scoring model β live preview updates wallet risk tiers</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleReset} className="flex items-center gap-2 px-3 py-2 rounded-md border border-hairline-dark text-muted hover:text-[#eaecef] text-button transition">
<RotateCcw className="w-3.5 h-3.5" />Reset
</button>
<button onClick={handleSave} className="flex items-center gap-2 px-4 py-2 rounded-md bg-brand-yellow text-ink text-button font-semibold hover:bg-brand-yellow-active transition">
{saved ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Save className="w-3.5 h-3.5" />}
{saved ? 'Saved!' : 'Save Thresholds'}
</button>
</div>
</div>
{/* Live preview */}
<div className="rounded-xl bg-surface-card border border-hairline-dark p-5">
<div className="flex items-center gap-2 mb-4">
<Users className="w-4 h-4 text-brand-yellow" />
<h3 className="text-title-sm">Live Preview β Wallet Distribution</h3>
<span className="text-caption text-muted ml-1">updates as you drag</span>
</div>
<div className="flex gap-3 flex-wrap">
{(['critical','high','medium','low','safe'] as ChurnRisk[]).map(r => (
<div key={r} className={cn('flex-1 min-w-[80px] rounded-xl border p-4 text-center', r === 'critical' ? 'border-trading-down/30' : r === 'high' ? 'border-[#ff9500]/30' : r === 'medium' ? 'border-brand-yellow/30' : r === 'low' ? 'border-trading-up/30' : 'border-hairline-dark')}>
<p className="text-caption text-muted capitalize mb-1">{r}</p>
<p className="font-mono text-title-lg text-[#eaecef] tabular-nums">{riskCounts[r] || 0}</p>
</div>
))}
</div>
<div className="mt-4 space-y-1">
{preview.map(w => (
<div key={w.address} className="flex items-center gap-3 py-1.5 px-3 rounded-lg hover:bg-surface-elevated/50">
<span className="font-mono text-caption text-muted w-36 truncate">{w.address.slice(0,8)}...{w.address.slice(-4)}</span>
<span className={cn('text-[10px] font-semibold uppercase px-2 py-0.5 rounded-pill', RISK_COLORS[w.risk as ChurnRisk])}>{w.risk}</span>
<div className="flex-1 h-1 rounded-full bg-surface-elevated overflow-hidden">
<div className="h-full rounded-full bg-brand-yellow/60 transition-all duration-300" style={{width: w.score + '%'}} />
</div>
<span className="font-mono text-num-sm text-muted tabular-nums w-8">{w.score}</span>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Inactivity thresholds */}
<div className="rounded-xl bg-surface-card border border-hairline-dark p-5 space-y-6">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-trading-down" />
<h3 className="text-title-sm">Inactivity Signals</h3>
</div>
<SliderRow label="Critical threshold" desc="Days inactive β critical risk signal" value={thresholds.inactivityCritical} min={7} max={60} unit="d" onChange={v => set('inactivityCritical', v)} />
<SliderRow label="High threshold" desc="Days inactive β high risk signal" value={thresholds.inactivityHigh} min={3} max={30} unit="d" onChange={v => set('inactivityHigh', v)} />
<SliderRow label="Medium threshold" desc="Days inactive β medium risk signal" value={thresholds.inactivityMedium} min={1} max={14} unit="d" onChange={v => set('inactivityMedium', v)} />
</div>
{/* Volume drop thresholds */}
<div className="rounded-xl bg-surface-card border border-hairline-dark p-5 space-y-6">
<div className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-brand-yellow" />
<h3 className="text-title-sm">Volume Drop Signals</h3>
</div>
<SliderRow label="Critical drop" desc="Volume decline % β critical signal" value={thresholds.volumeDropCritical} min={50} max={100} unit="%" onChange={v => set('volumeDropCritical', v)} />
<SliderRow label="High drop" desc="Volume decline % β high signal" value={thresholds.volumeDropHigh} min={25} max={90} unit="%" onChange={v => set('volumeDropHigh', v)} />
<SliderRow label="Medium drop" desc="Volume decline % β medium signal" value={thresholds.volumeDropMedium} min={10} max={60} unit="%" onChange={v => set('volumeDropMedium', v)} />
</div>
{/* Pre-churn early warning */}
<div className="rounded-xl bg-surface-card border border-hairline-dark p-5 space-y-6 lg:col-span-2">
<div>
<div className="flex items-center gap-2">
<span className="text-sm">β‘</span>
<h3 className="text-title-sm">Pre-Churn Early Warning</h3>
<span className="text-[10px] px-2 py-0.5 rounded-pill bg-trading-up/10 text-trading-up font-semibold border border-trading-up/20">3Γ more effective than win-back</span>
</div>
<p className="text-caption text-muted mt-1">Fire <code className="text-brand-yellow/80">inactivity_detected</code> before wallets reach churn threshold. Different sensitivity per wallet type.</p>
</div>
<div className="grid grid-cols-3 gap-6">
<SliderRow label="Trader" desc="High-frequency DEX users" value={thresholds.preChurnDaysTrader} min={1} max={7} unit="d" onChange={v => set('preChurnDaysTrader', v)} />
<SliderRow label="LP / Lender" desc="Kamino, Marginfi users" value={thresholds.preChurnDaysLP} min={2} max={10} unit="d" onChange={v => set('preChurnDaysLP', v)} />
<SliderRow label="Staker" desc="Low-frequency holders" value={thresholds.preChurnDaysStaker} min={3} max={14} unit="d" onChange={v => set('preChurnDaysStaker', v)} />
</div>
</div>
</div>
</div>
)
}
|