muthuk1's picture
feat: recovery attribution, pre-churn warnings, recovery card, threshold editor, telegram alerts
f667d47
'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>
)
}