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 }) => (
);
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 }) => (
);
// ─── 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 && (
)}
{/* 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
📋 {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" && }
);
}