Frontend: - migrate from Vite to Astro (astro.config.mjs, pages/, layouts/) - add admin panel, settings page, command palette, error boundary - refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout - update API client, WebSocket, auth, dashboard features Backend: - add admin module and config routes - refactor middlewares, Redis connection, WebSocket server/bridge - add runtime config loader Discord Gateway: - refactor AI moderation: circuit breaker, concurrency limiter, fallback processor - add media analysis client, Seaxng search, user profile learner - add new drizzle migration Shared: - extend database schema, add new config fields
8.3 KiB
8.3 KiB
State Machines — The Flow of Data
"All happy families are alike; each unhappy family is unhappy in its own way." — Tolstoy, adapted for component states.
🎯 Filosofi State Machine
Setiap komponen data-driven di BETE memiliki 4 state fundamental:
IDLE → LOADING → SUCCESS
↘ ERROR
↘ EMPTY (conditional, jika data.length === 0)
State machine memastikan tidak ada kondisi yang terlewat — setiap kemungkinan state visual memiliki representasi.
🎮 The Quad-State Pattern
type DataState<T> =
| { status: 'idle' }
| { status: 'loading'; progress?: number }
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: Error; retryCount?: number }
| { status: 'empty'; message?: string };
Generic State Machine Hook
// shared/hooks/useDataState.ts
function useDataState<T>(
fetcher: () => Promise<T>,
options?: {
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
retry?: number;
cacheKey?: string;
}
): {
state: DataState<T>;
execute: () => Promise<void>;
reset: () => void;
retry: () => Promise<void>;
setData: (data: T) => void;
} {
const [state, setState] = useState<DataState<T>>({ status: 'idle' });
const execute = useCallback(async () => {
setState({ status: 'loading' });
try {
const data = await fetcher();
if (Array.isArray(data) && data.length === 0) {
setState({ status: 'empty', message: 'No data available' });
} else {
setState({ status: 'success', data, timestamp: Date.now() });
options?.onSuccess?.(data);
}
} catch (error) {
setState({ status: 'error', error: error as Error });
options?.onError?.(error as Error);
}
}, [fetcher]);
return { state, execute, reset, retry: execute, setData };
}
Component Rendering
function DataPanel() {
const { state, execute, retry } = useDataState(fetchMessages);
useEffect(() => { execute(); }, []);
switch (state.status) {
case 'idle':
case 'loading':
return <LoadingSkeleton />;
case 'error':
return (
<ErrorState
message={state.error.message}
onRetry={retry}
retryCount={state.retryCount}
/>
);
case 'empty':
return <EmptyState message={state.message ?? 'Nothing here'} />;
case 'success':
return <DataView data={state.data} />;
}
}
🖼️ Visual Representations
Loading State
interface LoadingSkeletonProps {
variant?: 'card' | 'list' | 'detail' | 'table' | 'chart';
count?: number; // Jumlah skeleton items
}
/* Contoh variant 'card' */
function CardSkeleton() {
return (
<div className="card animate-shimmer" aria-busy="true" aria-label="Loading...">
<Skeleton className="h-4 w-3/4 mb-3" />
<Skeleton className="h-3 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
</div>
);
}
Error State
interface ErrorStateProps {
error: Error;
onRetry: () => void;
retryCount?: number;
variant?: 'inline' | 'full-page' | 'toast';
}
function ErrorState({ error, onRetry, retryCount }: ErrorStateProps) {
const isRetryExhausted = (retryCount ?? 0) >= 3;
return (
<div className="flex flex-col items-center gap-4 py-12" role="alert">
<div className="rounded-full bg-destructive/10 p-3">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<p className="text-sm font-medium text-foreground">Something went wrong</p>
<p className="text-xs text-muted-foreground">{error.message}</p>
{!isRetryExhausted ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Try Again
</Button>
) : (
<p className="text-xs text-muted-foreground">
Still failing after multiple attempts. Please try again later.
</p>
)}
</div>
);
}
Empty State
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
variant?: 'mascot' | 'icon' | 'minimal';
}
const EMPTY_STATES = {
messages: { icon: MessageSquare, title: 'No messages yet', description: 'Messages will appear here once they are captured.' },
speakers: { icon: Mic, title: 'No active speakers', description: 'Quiet in here...' },
recordings: { icon: Radio, title: 'No recordings', description: 'Join a voice channel to start recording.' },
analytics: { icon: BarChart3, title: 'Not enough data', description: 'Analytics will populate as data accumulates.' },
users: { icon: Users, title: 'No users found', description: 'Try adjusting your filters.' },
};
♻️ State Transition Diagram
┌──────────┐
│ IDLE │
└────┬─────┘
│ execute()
↓
┌──────────┐
│ LOADING │◄────── retry()
└────┬─────┘
│
┌───────┴───────────┐
│ │
↓ ↓
┌──────────┐ ┌──────────┐
│ SUCCESS │ │ ERROR │
│ data: T │ │ err: E │
└────┬─────┘ └────┬─────┘
│ │
│ (data.length │ retry()
│ === 0) │
↓ │
┌──────────┐ │
│ EMPTY │ │
│ msg: str │ │
└──────────┘ │
│ │
└──────┬───────────┘
│ reset()
↓
┌──────────┐
│ IDLE │
└──────────┘
🔄 Retry Strategy
const RETRY_CONFIG = {
maxAttempts: 3,
baseDelay: 1000, // 1s
maxDelay: 10000, // 10s
backoff: 'exponential' as const,
onRetry: (attempt: number, error: Error) => {
logger.warn(`Retry attempt ${attempt}`, { error: error.message });
},
};
Exponential Backoff
function calculateDelay(attempt: number): number {
return Math.min(
1000 * Math.pow(2, attempt - 1), // 1s, 2s, 4s
10000 // cap at 10s
);
}
📦 Component State Map
| Component | Loading | Error | Empty | Success |
|---|---|---|---|---|
| MessageFeed | Card skeletons | ErrorState + retry | Mascot "No messages" | Message list |
| VoiceCards | Card skeletons | ErrorState | "No connected channels" | VoiceCard list |
| ActiveSpeakers | Dot skeletons | Silent fallback | "No speakers" | Speaker list |
| Analytics | Skeleton grid | ErrorState | "Not enough data" | Charts |
| Recordings | List skeletons | ErrorState | "No recordings" | Recording list |
| UserList | List skeletons | ErrorState + retry | "No users found" | User list |
| DashboardStats | Stat skeletons | ErrorState | "No data available" | Stat grid |
⚠️ Anti-Patterns State
❌ Missing state handling
// ❌ JANGAN — hanya handle SUCCESS
function Panel() {
const { data, isLoading } = useQuery(...);
if (isLoading) return <Spinner />;
return <DataView data={data} />; // ERROR? EMPTY?
}
❌ Loading state after error
// ❌ JANGAN — loading infinite loop setelah error
function Panel() {
const { data, isLoading } = useQuery(..., { retry: true });
if (isLoading) return <Spinner />;
// ERROR: retry=true + error = loading terus
}
❌ Empty state default terlalu generic
// ❌ JANGAN — tidak helpful
<div>No data</div>
// ✅ Kontekstual dengan action
<EmptyState icon={MessageSquare} title="No messages" action={{ label: "Refresh", onClick: refetch }} />
🔗 Referensi
| Sumber | Konsep |
|---|---|
| State Reducer Pattern | Advanced state management |
| XState | Visual state machines |
| React useReducer | Built-in state management |
"Setiap state adalah babak dalam cerita data — dari sunyi hingga berbicara." ❄️🩵