Ship theo 5 slice, mỗi slice có thể test độc lập. Không có gì user-visible cho đến slice 3. Slice 5 chỉ là polish và seed data cho demo.
Khi user tạo post, gọi Claude API để lấy điểm cảm xúc { score: -1..1, label, intensity }.
Ghi kết quả vào Upstash Redis bằng sorted set với timestamp làm score — cơ sở cho sliding window.
Không block UI: sentiment call là fire-and-forget sau khi POST đã được lưu.
Một Vercel Edge Function chạy mỗi 5 giây: đọc tất cả events trong cửa sổ 60 giây từ Redis
(ZRANGEBYSCORE), tính weighted average, so sánh với threshold bảng dưới.
Nếu mood state thay đổi → publish lên Ably channel global:mood.
Seed data script cũng gọi endpoint này để giả lập 50 posts/phút.
React Context subscribe Ably, expose { state, score, intensity } cho toàn app.
Khi mood thay đổi: cập nhật CSS custom properties (--bg-hue, --brightness, --saturation),
trigger overlay animation (rain particles với Canvas hoặc pure CSS, bloom với keyframe),
transition thực hiện trong 2–4 giây để tránh flash đột ngột.
Web Audio API: load 3 audio buffers (sad / neutral / happy) khi app mount, nhưng
không play cho đến khi có user gesture đầu tiên (autoplay policy).
Crossfade 3 giây giữa tracks dùng GainNode. Energy Pulse: khi intensity > 0.85
trong 3 lần liên tiếp → navigator.vibrate([80, 40, 80]) trên mobile.
Script giả lập kịch bản demo: bắt đầu neutral → flood sad posts (trận thua bóng đá) → rain bật →
gradually shift sang happy (tin vui) → bloom. UI hiển thị live score và mood log để
người xem demo hiểu điều gì đang xảy ra. Feature flag mood_butterfly_v1 bật.
Write path (trái → xuống) tách biệt hoàn toàn với broadcast path (phải). Aggregator là nhịp tim duy nhất poll Redis — các client không bao giờ đọc Redis trực tiếp.
Solid = write path đồng bộ. Clay dashed = aggregator loop (mỗi 5s). Olive dashed = reactive broadcast xuống UI. Client không bao giờ đọc Redis trực tiếp.
Weighted average của tất cả events trong cửa sổ 60 giây. Weight = intensity của từng event (0–1). Hysteresis ±0.05 để tránh flicker ở ranh giới.
≤ −0.6vibrate([100,50,100]) nếu intensity > 0.9−0.6 → −0.2−0.2 → +0.2+0.2 → +0.6≥ +0.6Không pixel-final — chỉ đủ để hình dung UI state SAD vs HAPPY và cách Energy Pulse panel hiển thị live score.
sad_piano.mp3 · neutral_ambient.mp3 · happy_upbeat.mp3 —
royalty-free, loop liền mạch, preload khi app mount.
Ba đoạn code hay bị viết sai nhất: sliding window trên Redis, crossfade GainNode, và hysteresis để tránh mood flicker.
export async function analyzeSentiment( text: string ): Promise<SentimentResult> { const res = await fetch('/api/sentiment', { method: 'POST', body: JSON.stringify({ text }), }); // returns { score: -1..1, label, intensity } return res.json(); } // Gọi fire-and-forget sau khi post đã lưu export async function recordMoodEvent( score: number, intensity: number ) { const ts = Date.now(); // ZADD mood:events <timestamp> <score:intensity> await redis.zadd('mood:events', { score: ts, member: `${score}:${intensity}:${ts}`, }); // tự dọn events cũ hơn 120s await redis.zremrangebyscore( 'mood:events', 0, ts - 120_000 ); }
export async function computeMood(): Promise<MoodState> { const now = Date.now(); const members = await redis.zrangebyscore( 'mood:events', now - 60_000, now ); if (!members.length) return NEUTRAL; let weightedSum = 0, totalWeight = 0; for (const m of members) { const [score, intensity] = m.split(':').map(Number); weightedSum += score * intensity; totalWeight += intensity; } const avg = weightedSum / totalWeight; // hysteresis: chỉ đổi state nếu vượt ngưỡng ±0.05 return applyHysteresis(avg, currentState); }
class AmbientAudioController { private ctx: AudioContext; private gains: Record<MoodState, GainNode>; private current: MoodState = 'NEUTRAL'; crossfadeTo(next: MoodState, dur = 3) { const now = this.ctx.currentTime; // fade out current this.gains[this.current].gain .linearRampToValueAtTime(0, now + dur); // fade in next this.gains[next].gain .linearRampToValueAtTime(1, now + dur); this.current = next; } // Gọi sau gesture đầu tiên async resume() { await this.ctx.resume(); } }
export function MoodProvider({ children }) { const [mood, setMood] = useState<MoodState>('NEUTRAL'); useEffect(() => { const ch = ably.channels.get('global:mood'); ch.subscribe('update', ({ data }) => { setMood(data.state); // cập nhật CSS vars document.documentElement.style .setProperty('--mood-brightness', THEME[data.state].brightness); // crossfade audio audio.crossfadeTo(data.state); // haptic nếu Energy Pulse if (data.energyPulse) navigator.vibrate([80, 40, 80]); }); return () => ch.unsubscribe(); }, []); return <MoodCtx.Provider value={mood}>{children}</MoodCtx.Provider>; }
AudioController.resume(), gọi ctx.resume() sau bất kỳ click nào. Không cố autoplay trước gesture — browser sẽ chặn im lặng.waitUntil() hoặc queue job riêng. UI không bao giờ đợi sentiment.navigator.vibrate là undefined, gọi sẽ throw.navigator.vibrate?.(pattern) — optional chaining, không cần polyfill. Energy Pulse vẫn hiển thị visual indicator trên Safari dù không có haptic.prefers-reduced-motion media query để tắt animation.
Full: toggle trong Settings để tắt hoàn toàn overlay + audio. Cần quyết định trước slice 3 vì ảnh hưởng cách viết ThemeOverlay component.?window=15 vào aggregator endpoint.