import { useState, useEffect, useRef } from "react"; // ─── CONSTANTS ──────────────────────────────────────────────────────────────── const TASK_COLORS = [ { name: "Blue", ring: "#4a90d9", glow: "rgba(74,144,217,0.5)" }, { name: "Purple", ring: "#9b59b6", glow: "rgba(155,89,182,0.5)" }, { name: "Teal", ring: "#56b4d3", glow: "rgba(86,180,211,0.5)" }, { name: "Amber", ring: "#e67e22", glow: "rgba(230,126,34,0.5)" }, { name: "Green", ring: "#2ecc71", glow: "rgba(46,204,113,0.5)" }, { name: "Pink", ring: "#e91e8c", glow: "rgba(233,30,140,0.5)" }, ]; const TASK_ICONS = ["🌅","👕","🦷","🎒","🥣","📚","👟","🧴","🌙","💊","🍎","🚿","🧠","✏️","💧","🎵"]; const DEFAULT_TASKS = [ { id: "t1", label: "Wake Up", icon: "🌅", duration: 60, colorIdx: 0 }, { id: "t2", label: "Get Dressed", icon: "👕", duration: 300, colorIdx: 1 }, { id: "t3", label: "Brush Teeth", icon: "🦷", duration: 120, colorIdx: 2 }, { id: "t4", label: "Pack Backpack", icon: "🎒", duration: 180, colorIdx: 3 }, { id: "t5", label: "Eat Breakfast", icon: "🥣", duration: 240, colorIdx: 4 }, ]; const CIRCUMFERENCE = 2 * Math.PI * 88; const C = { bg: "#070d14", card: "#0e1920", cardBorder: "#192635", text: "#e8edf2", muted: "#5a7590", accent: "#56b4d3", }; const genId = () => Date.now().toString(36) + Math.random().toString(36).slice(2); const fmtTime = s => `${Math.floor(s/60)}:${(s%60).toString().padStart(2,"0")}`; // ─── SHARED UI ──────────────────────────────────────────────────────────────── const Btn = ({ children, onClick, color = C.accent, outline, small, disabled, style: extra = {} }) => ( ); const IconBtn = ({ children, onClick, disabled, danger }) => ( ); const Field = ({ label, children }) => (
{label}
{children}
); const inputStyle = { width: "100%", background: "#080d14", border: `1px solid ${C.cardBorder}`, color: C.text, borderRadius: 8, padding: "10px 14px", fontSize: 14, fontFamily: "'DM Sans', sans-serif", outline: "none", boxSizing: "border-box", }; const Stat = ({ icon, value, label }) => (
{icon}
{value}
{label}
); // ─── DEVICE PUCK ───────────────────────────────────────────────────────────── function Puck({ task, progress, running, celebrating, done, animate }) { const color = TASK_COLORS[task?.colorIdx ?? 0]; const dashOffset = CIRCUMFERENCE * (1 - progress); const rainbowColors = ["#e74c3c","#e67e22","#f1c40f","#2ecc71","#3498db","#9b59b6"]; return (
{/* Outer glow */}
{/* Body */}
{/* SVG ring */} {!done && task && ( {celebrating ? ( rainbowColors.map((c, i) => { const seg = CIRCUMFERENCE / rainbowColors.length; return ( ); }) ) : ( )} )} {/* Haptic ripples */} {animate && running && !celebrating && [0,1,2].map(i => (
))} {/* Icon */}
{done ? "🌟" : celebrating ? "🎉" : task?.icon ?? "💤"}
); } // ─── DEVICE VIEW ────────────────────────────────────────────────────────────── function DeviceView({ tasks, voiceNotes, onSessionComplete }) { const [running, setRunning] = useState(false); const [taskIdx, setTaskIdx] = useState(0); const [timeLeft, setTimeLeft] = useState(tasks[0]?.duration ?? 60); const [nudges, setNudges] = useState(0); const [sessionLog, setSessionLog] = useState([]); const [done, setDone] = useState(false); const [celebrating, setCelebrating] = useState(false); const audioRef = useRef(null); const logRef = useRef([]); const task = tasks[taskIdx]; const color = TASK_COLORS[task?.colorIdx ?? 0]; const progress = task ? timeLeft / task.duration : 1; // countdown useEffect(() => { if (!running || !task || celebrating) return; const iv = setInterval(() => { setTimeLeft(prev => { if (prev <= 1) { clearInterval(iv); completeTask(); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(iv); }, [running, taskIdx, celebrating]); const completeTask = () => { setCelebrating(true); const entry = { taskId: task.id, taskLabel: task.label, icon: task.icon, totalTime: task.duration, nudges, completedAt: new Date().toLocaleTimeString() }; logRef.current = [...logRef.current, entry]; setSessionLog([...logRef.current]); setTimeout(() => { setCelebrating(false); const nextIdx = taskIdx + 1; if (nextIdx >= tasks.length) { setRunning(false); setDone(true); onSessionComplete([...logRef.current]); } else { setTaskIdx(nextIdx); setTimeLeft(tasks[nextIdx].duration); setNudges(0); } }, 2200); }; const handleStart = () => { if (!tasks.length) return; logRef.current = []; setRunning(true); setTaskIdx(0); setTimeLeft(tasks[0].duration); setNudges(0); setSessionLog([]); setDone(false); }; const handleNudge = () => { setNudges(n => n + 1); const note = voiceNotes[task?.id]; if (note?.url) { if (audioRef.current) { audioRef.current.pause(); } const a = new Audio(note.url); a.play().catch(() => {}); audioRef.current = a; } }; const handleSkip = () => completeTask(); // Progress bar width per task in queue const queueProgress = (t, i) => { if (i < taskIdx) return 100; if (i === taskIdx && running) return Math.round((1 - progress) * 100); return 0; }; return (
{/* Left: Puck + controls */}
{/* Time display */} {running && task && !celebrating && (
{fmtTime(timeLeft)}
remaining
)} {/* Label */}
{done ? "Routine Complete! 🌟" : celebrating ? "Task Done!" : running ? task?.label : "Ready to Begin"}
{running && !done && !celebrating ? `Step ${taskIdx + 1} of ${tasks.length}` : !running && !done ? "Start the morning routine below." : ""}
{/* Voice note preview */} {running && voiceNotes[task?.id] && (
🎙 Voice note ready for this task
)} {/* Controls */}
{!running && !done && ▶ Start Routine} {running && !celebrating && ( <> 🎙 Send Nudge Skip → )} {done && ↺ Run Again}
{/* Nudge count */} {running && nudges > 0 && (
{nudges} nudge{nudges > 1 ? "s" : ""} sent this task
)}
{/* Right: Queue */}
Today's Routine
{!tasks.length && (
No tasks. Add them in Routine Builder →
)} {tasks.map((t, i) => { const tc = TASK_COLORS[t.colorIdx]; const isDone = done || i < taskIdx || (i === taskIdx && celebrating); const isCurrent = running && i === taskIdx && !done; const pct = queueProgress(t, i); return (
{/* Progress fill */} {pct > 0 && !isDone && (
)}
{t.icon}
{t.label}
{fmtTime(t.duration)} · {voiceNotes[t.id] ? "🎙 voice ready" : "no voice note"}
{isCurrent &&
} {isDone && }
); })}
); } // ─── ROUTINE BUILDER ───────────────────────────────────────────────────────── function RoutineBuilder({ tasks, setTasks }) { const BLANK = { label: "", icon: "⭐", duration: 120, colorIdx: 0 }; const [form, setForm] = useState(BLANK); const [editing, setEditing] = useState(null); // id or "new" const openAdd = () => { setForm(BLANK); setEditing("new"); }; const openEdit = (t) => { setForm({ label: t.label, icon: t.icon, duration: t.duration, colorIdx: t.colorIdx }); setEditing(t.id); }; const cancel = () => setEditing(null); const move = (i, d) => { const arr = [...tasks]; const j = i + d; if (j < 0 || j >= arr.length) return; [arr[i], arr[j]] = [arr[j], arr[i]]; setTasks(arr); }; const save = () => { if (!form.label.trim()) return; if (editing === "new") setTasks(prev => [...prev, { id: genId(), ...form }]); else setTasks(prev => prev.map(t => t.id === editing ? { ...t, ...form } : t)); setEditing(null); }; const del = (id) => { setTasks(prev => prev.filter(t => t.id !== id)); if (editing === id) setEditing(null); }; return (
{/* Task list */}
Tasks ({tasks.length})
+ Add Task
{!tasks.length && (
No tasks yet. Add your first one.
)}
{tasks.map((t, i) => { const tc = TASK_COLORS[t.colorIdx]; const isEditing = editing === t.id; return (
{t.icon}
{t.label}
{fmtTime(t.duration)}
move(i, -1)} disabled={i === 0}>↑ move(i, 1)} disabled={i === tasks.length - 1}>↓ openEdit(t)}>✏ del(t.id)} danger>✕
); })}
{/* Summary bar */} {tasks.length > 0 && (
⏱ Total: {fmtTime(tasks.reduce((s,t) => s+t.duration, 0))} 📋 Steps: {tasks.length}
)}
{/* Edit / Add form */} {editing ? (
{editing === "new" ? "New Task" : "Edit Task"}
setForm(f => ({...f, label: e.target.value}))} placeholder="e.g. Brush Teeth" style={inputStyle} />
{TASK_ICONS.map(ic => ( ))}
setForm(f => ({...f, duration: +e.target.value}))} style={{ width: "100%", accentColor: C.accent, cursor: "pointer" }} />
30s30min
{TASK_COLORS.map((tc, idx) => (
Save Task Cancel
) : (
✏️
Click a task to edit it,
or add a new one above.
)}
); } // ─── VOICE STUDIO ───────────────────────────────────────────────────────────── function VoiceStudio({ tasks, voiceNotes, setVoiceNotes }) { const [recording, setRecording] = useState(null); const [playing, setPlaying] = useState(null); const [recTime, setRecTime] = useState(0); const mrRef = useRef(null); const chunksRef = useRef([]); const audioRef = useRef({}); const timerRef = useRef(null); const startRec = async (taskId) => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mr = new MediaRecorder(stream); chunksRef.current = []; mr.ondataavailable = e => chunksRef.current.push(e.data); mr.onstop = () => { const blob = new Blob(chunksRef.current, { type: "audio/webm" }); setVoiceNotes(prev => ({ ...prev, [taskId]: { url: URL.createObjectURL(blob) } })); stream.getTracks().forEach(t => t.stop()); }; mr.start(); mrRef.current = mr; setRecording(taskId); setRecTime(0); timerRef.current = setInterval(() => setRecTime(t => t + 1), 1000); } catch { alert("Microphone access denied. Please allow it in your browser settings."); } }; const stopRec = () => { mrRef.current?.stop(); mrRef.current = null; clearInterval(timerRef.current); setRecording(null); setRecTime(0); }; const playNote = (taskId) => { const note = voiceNotes[taskId]; if (!note?.url) return; if (audioRef.current[taskId]) { audioRef.current[taskId].pause(); delete audioRef.current[taskId]; setPlaying(null); return; } const a = new Audio(note.url); a.onended = () => { setPlaying(null); delete audioRef.current[taskId]; }; a.play().catch(() => {}); audioRef.current[taskId] = a; setPlaying(taskId); }; const deleteNote = (taskId) => { if (audioRef.current[taskId]) { audioRef.current[taskId].pause(); delete audioRef.current[taskId]; } setPlaying(p => p === taskId ? null : p); setVoiceNotes(prev => { const n = {...prev}; delete n[taskId]; return n; }); }; if (!tasks.length) return (
🎙
No Tasks Yet
Add tasks in the Routine Builder first.
); return (
Voice Studio
Record your voice for each task. These play when your child needs a nudge.
🔒 Stored locally · Never uploaded
{tasks.map(task => { const tc = TASK_COLORS[task.colorIdx]; const note = voiceNotes[task.id]; const isRec = recording === task.id; const isPlay = playing === task.id; return (
{/* Header */}
{task.icon}
{task.label}
{fmtTime(task.duration)}
{note &&
✓ Recorded
}
{/* Waveform */}
{(note || isRec) ? ( Array.from({ length: 28 }, (_, i) => (
)) ) : ( No recording yet )}
{/* Recording time */} {isRec && (
● Recording {fmtTime(recTime)}
)} {/* Controls */}
{!isRec ? ( startRec(task.id)} color={tc.ring} small>🎙 Record ) : ( ⏹ Stop )} {note && <> playNote(task.id)} color={tc.ring} outline small> {isPlay ? "⏸ Stop" : "▶ Play"} deleteNote(task.id)} color="#e74c3c" outline small>✕ }
); })}
); } // ─── DASHBOARD ─────────────────────────────────────────────────────────────── function Dashboard({ sessions }) { if (!sessions.length) return (
📊
No Sessions Yet
Run a routine from the Device tab to see results here.
); const allTasks = sessions.flat(); const totalNudges = allTasks.reduce((s, t) => s + t.nudges, 0); const zeroNudge = allTasks.filter(t => t.nudges === 0).length; const successRate = allTasks.length ? Math.round((zeroNudge / allTasks.length) * 100) : 0; // Tasks by nudge count const nudgeMap = {}; allTasks.forEach(t => { nudgeMap[t.taskLabel] = (nudgeMap[t.taskLabel] || 0) + t.nudges; }); const hardTasks = Object.entries(nudgeMap).sort((a, b) => b[1] - a[1]).slice(0, 3); return (
{/* Stats */}
{/* Insight */} {hardTasks.length > 0 && (
Tasks That Need Most Nudges
{hardTasks.map(([label, count]) => { const pct = hardTasks[0][1] > 0 ? (count / hardTasks[0][1]) * 100 : 0; return (
{label} 2 ? "#e67e22" : C.muted }}>{count} nudge{count !== 1 ? "s" : ""}
2 ? "#e67e22" : C.accent, borderRadius: 4, transition: "width 0.8s ease" }} />
); })}
)} {/* Session history */}
Session History
{[...sessions].reverse().map((session, si) => { const sessionNudges = session.reduce((s, t) => s + t.nudges, 0); return (
Session {sessions.length - si}
{sessionNudges === 0 ? "Perfect run ✓" : `${sessionNudges} nudge${sessionNudges > 1 ? "s" : ""}`}
{session[session.length - 1]?.completedAt}
{session.map((task, ti) => (
{task.icon} {task.taskLabel} {fmtTime(task.totalTime)} 0 ? "#e67e22" : "#2ecc71" }}> {task.nudges > 0 ? `${task.nudges} nudge${task.nudges > 1 ? "s" : ""}` : "No nudges ✓"}
))}
); })}
); } // ─── TABS ───────────────────────────────────────────────────────────────────── const TABS = [ { id: "device", label: "Device", icon: "⭕" }, { id: "builder", label: "Routine Builder", icon: "✏️" }, { id: "voice", label: "Voice Studio", icon: "🎙" }, { id: "dashboard",label: "Dashboard", icon: "📊" }, ]; // ─── ROOT ───────────────────────────────────────────────────────────────────── export default function WhisprroApp() { const [tab, setTab] = useState("device"); const [tasks, setTasks] = useState(DEFAULT_TASKS); const [voiceNotes, setVoiceNotes] = useState({}); const [sessions, setSessions] = useState([]); return (
{/* Header */}
whisprro Parent Hub · Prototype
Offline · Zero-Trace
📋 {tasks.length} tasks 🎙 {Object.keys(voiceNotes).length} voice notes 📊 {sessions.length} sessions
{/* Tabs */}
{TABS.map(t => ( ))}
{/* Content */}
{tab === "device" && setSessions(p => [...p, log])} />} {tab === "builder" && } {tab === "voice" && } {tab === "dashboard" && }
); }