Implementation plan · Dự án cá nhân

Emotional Butterfly Effect

Idea App có "thời tiết cảm xúc" chung. AI phân tích sentiment của hàng loạt bài đăng theo cửa sổ 30–60 giây. Nếu cộng đồng buồn → UI mưa, tông màu hạ, nhạc sầu. Nếu vui → UI nở hoa, sáng hơn, motion nhẹ. Demo dùng event stream + seed data giả lập tải lớn — không hứa xử lý hàng triệu bài thật trong 3 ngày.
Effort
~7 ngày
Infra mới
Upstash · Ably
API bên ngoài
3 (Claude, Ably, Upstash)
Feature flag
mood_butterfly_v1
01

Milestones

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.

Ngày 1

Sentiment Adapter

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.

lib/sentimentAdapter.ts api/posts/create upstash · mood:events
Ngày 2–3

Mood Aggregator + Ably broadcast

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.

api/mood/aggregate lib/moodAggregator.ts ably · global:mood scripts/seedMood.ts
Ngày 3–4

Global Mood Provider + Theme Overlay

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.

contexts/MoodContext.tsx components/ThemeOverlay.tsx components/RainCanvas.tsx styles/moodTheme.css
Ngày 5

Ambient Audio Controller + Energy Pulse + Haptic

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.

lib/ambientAudio.ts lib/energyPulse.ts Web Audio API Vibration API
Ngày 6–7

Seed data, demo UI, polish

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.

scripts/demoScenario.ts components/MoodDebugPanel.tsx feature flag
02

Data flow

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.

<PostComposer> apps/web api/posts/create Next.js route handler Sentiment Adapter Claude API · async Upstash Redis ZADD mood:events Mood Aggregator Edge fn · every 5s Ably channel global:mood MoodContext all connected clients UI Effects CSS · Audio · Vibrate POST body fire-and-forget ZADD ts score ZRANGEBYSCORE publish if changed subscribe setState → render

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.

03

Mood threshold table

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.

Mood state
Score range
UI effect
VERY_SAD
Cộng đồng rất buồn — trận thua, tin xấu
≤ −0.6
Mưa nặng hạt (Canvas particles), tông màu desaturate −60%, nhạc buồn, vibrate([100,50,100]) nếu intensity > 0.9
SAD
Nhiều người đang buồn
−0.6 → −0.2
Mưa nhẹ (opacity 40%), tông màu hạ nhẹ, nhạc neutral-sad
NEUTRAL
Trạng thái mặc định
−0.2 → +0.2
Không có overlay, theme mặc định, nhạc ambient nhẹ
HAPPY
Nhiều người đang vui
+0.2 → +0.6
Hoa nở nhẹ (CSS keyframe), brightness +10%, nhạc upbeat
VERY_HAPPY
Cộng đồng đang hừng hực — tin thắng, lễ hội
≥ +0.6
Hoa nở nhiều + confetti particles, brightness +20%, saturation +30%, nhạc vui, Energy Pulse haptic
04

Mockups

Không pixel-final — chỉ đủ để hình dung UI state SAD vs HAPPY và cách Energy Pulse panel hiển thị live score.

A · Mood state — SAD (score −0.72)
VERY_SAD · score −0.72 · 23 events / 60s
Minh · 2s ago
Thua rồi... cảm giác trống rỗng quá 😞
Lan · 8s ago
Cả đội cố gắng mà vẫn không được, buồn lắm.
Khoa · 15s ago
Không biết nói gì nữa. Thôi ngủ cho quên.
B · Mood state — HAPPY (score +0.68)
VERY_HAPPY · score +0.68 · 31 events / 60s
🌸✨🌼
An · 3s ago
Tin vui quá trời ơi!!! Không ngủ được luôn 🎉
Trang · 9s ago
Hôm nay là ngày đẹp nhất năm nay rồi 🥰
Huy · 20s ago
Cả nhà ơi, mình vừa nhận offer!! Vui muốn khóc 😭🎊
C · Energy Pulse panel (debug / demo overlay)
Live mood distribution · cửa sổ 60s
SAD
72%
NEUTRAL
18%
HAPPY
10%
Energy Pulse threshold vượt ngưỡng
HAPTIC FIRED
D · Audio crossfade state machine
Audio state
sad_piano.mp3 → fade out 3s → neutral_ambient.mp3
Autoplay gate
AudioContext bị suspended cho đến khi có gesture đầu tiên. App hiển thị "Nhấn bất kỳ đâu để bật âm thanh" banner một lần duy nhất.
Tracks cần chuẩn bị
sad_piano.mp3 · neutral_ambient.mp3 · happy_upbeat.mp3 — royalty-free, loop liền mạch, preload khi app mount.
05

Key code

Ba đoạn code hay bị viết sai nhất: sliding window trên Redis, crossfade GainNode, và hysteresis để tránh mood flicker.

lib/sentimentAdapter.ts
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
  );
}
lib/moodAggregator.ts
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);
}
lib/ambientAudio.ts
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();
  }
}
contexts/MoodContext.tsx
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>;
}
06

Risks & mitigations

Risk
Sev
Mitigation
Web Audio API bị block bởi autoplay policy — AudioContext bị suspend trước khi có user gesture, audio không phát khi mood đầu tiên trigger.
HIGH
Hiển thị một-lần banner "Nhấn để bật âm thanh". Trong 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.
Mood flicker: score dao động ở ranh giới threshold (ví dụ −0.19, −0.21, −0.18) gây UI chuyển trạng thái liên tục trong vài giây.
HIGH
Hysteresis ±0.05: một khi đã ở SAD thì phải đạt −0.15 mới về NEUTRAL, không phải −0.20. Thêm debounce 3s trước khi emit state change lên Ably.
Claude API latency: sentiment call mất 0.5–2s, nếu await trực tiếp trong POST handler sẽ làm chậm UX tạo bài.
MED
Fire-and-forget: POST handler lưu bài xong trả về ngay, sentiment analysis chạy background bằng waitUntil() hoặc queue job riêng. UI không bao giờ đợi sentiment.
Upstash free tier rate limit: 10,000 commands/ngày. Seed data script 50 posts/phút = 3,000/giờ sẽ vượt nhanh.
MED
Seed script dùng pipeline (batch nhiều ZADD trong 1 request). Chỉ chạy seed trong demo session, không để chạy liên tục. Upgrade lên $10/tháng nếu cần test dài hơn.
Safari không hỗ trợ Vibration API — navigator.vibrateundefined, gọi sẽ throw.
LOW
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.
Ably free tier giới hạn 200 connections đồng thời — không đủ nếu demo có nhiều người xem cùng lúc.
LOW
Cho demo cá nhân thì 200 connections là quá đủ. Nếu show demo công khai, upgrade Ably ($29/tháng) hoặc dùng Pusher Channels free tier (100 connections).
07

Open questions

Threshold calibration: lấy số nào từ đâu?
Các threshold −0.6, −0.2, +0.2, +0.6 là ước tính ban đầu dựa trên cảm tính. Sau khi seed data chạy, plot distribution của score thật để xem các giá trị này có hợp lý không. Cân nhắc UI cho phép điều chỉnh threshold trong dev mode để calibrate nhanh.
Quyết định sau · ngày 6 khi có seed data thật
Có nên để user tắt mood overlay không?
Một số user có thể bị khó chịu bởi animation (motion sensitivity) hoặc âm thanh tự bật. Minimal viable: respect 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.
Quyết định trước slice 3 · ngày 3
Window 60 giây hay ngắn hơn cho demo?
60 giây cho cảm giác "cộng đồng" thật nhưng demo sẽ chậm — phải chờ 60s để thấy mood shift. Proposal: demo dùng window 15 giây để transition nhanh hơn, production dùng 60 giây. Thêm param ?window=15 vào aggregator endpoint.
Quyết định trước slice 2 · ngày 2
Graceful degradation khi Ably mất kết nối?
Nếu Ably disconnect (mạng yếu, free tier limit), MoodContext nên giữ nguyên trạng thái cuối hay về NEUTRAL? Giữ nguyên tốt hơn về UX (không flash đột ngột) nhưng có thể hiển thị mood cũ trong nhiều phút. Proposal: giữ nguyên, thêm subtle indicator "mood data paused" sau 30s không nhận update.
Quyết định trước slice 3 · ngày 3