diff --git a/data/task-templates.json b/data/task-templates.json index a2f7724..a62ca8b 100644 --- a/data/task-templates.json +++ b/data/task-templates.json @@ -3,105 +3,105 @@ "type": "attention_training", "difficulty": 1, "duration": 3, - "description": "Breath counting: Count each exhale from 1 to 10, then start over. If your mind wanders, gently return to 1.", - "reason": "Foundational attention exercise. Builds the skill of noticing when attention drifts and bringing it back without judgment." + "description": "数呼吸:从1数到10,每次呼气数一个数,然后重新开始。如果走神了,温柔地回到1。", + "reason": "基础的注意力练习。培养觉察注意力游离并温柔带回的能力,不批判自己。" }, { "type": "attention_training", "difficulty": 2, "duration": 5, - "description": "Breath at the nostrils: Focus on the sensation of air passing the tip of your nose. Notice temperature, texture, and rhythm without changing anything.", - "reason": "Narrower focus point than breath counting. Trains precision of attention by anchoring to a subtle physical sensation." + "description": "鼻端呼吸:专注感受空气经过鼻尖的感觉。留意温度、质感和节奏,不需要改变任何东西。", + "reason": "比数呼吸更精细的专注锚点。通过聚焦微妙的生理感受来训练注意力的精准度。" }, { "type": "attention_training", "difficulty": 3, "duration": 10, - "description": "Open awareness: Sit quietly and let your attention rest on whatever arises — sounds, body sensations, thoughts. Don't cling or reject. Just notice.", - "reason": "Advanced attention exercise. Builds meta-awareness — the ability to observe the mind's movements without being carried away by them." + "description": "开放觉知:安静地坐着,让注意力自然地停留在任何浮现的事物上——声音、身体感受、念头。不抓取,不排斥,只是观察。", + "reason": "进阶注意力练习。培养元觉知——观察心智活动而不被其带走的能力。" }, { "type": "emotion_awareness", "difficulty": 1, "duration": 3, - "description": "Name the weather: Close your eyes and ask 'What's the emotional weather right now?' Label it simply — sunny, cloudy, stormy, still. Don't explain why. Just name it.", - "reason": "Builds the vocabulary and habit of checking in with emotional state. Naming emotions reduces their intensity and creates space for choice." + "description": "情绪气象站:闭上眼睛,问自己「此刻的情绪天气是什么?」简单地命名——晴朗、多云、暴风雨、平静。不用解释为什么,只需命名。", + "reason": "建立情绪觉察的习惯和词汇。命名情绪可以降低其强度,为选择创造空间。" }, { "type": "emotion_awareness", "difficulty": 2, "duration": 5, - "description": "Body scan: Slowly move attention from the top of your head to your toes. At each body part, notice any sensation — tightness, warmth, buzzing, emptiness. Don't try to change anything.", - "reason": "Emotions live in the body before they reach conscious awareness. Body scanning builds the sensory vocabulary that makes emotional signals legible." + "description": "身体扫描:将注意力从头顶慢慢移到脚尖。在每个身体部位停留,留意任何感觉——紧绷、温暖、麻刺、空洞。不试图改变任何东西。", + "reason": "情绪在进入意识之前首先在身体中呈现。身体扫描建立了让情绪信号变得可读的感官词汇。" }, { "type": "emotion_awareness", "difficulty": 3, "duration": 7, - "description": "Emotion tracking: Set a timer for 7 minutes. Every 60 seconds, pause and write one word describing your current emotional state. Notice how emotions shift moment to moment.", - "reason": "Reveals the impermanent nature of emotions. Watching emotions change undermines the belief that 'this feeling will last forever.'" + "description": "情绪追踪:设定7分钟计时器。每60秒暂停一次,写下一个描述你当前情绪状态的词。注意情绪如何每时每刻都在变化。", + "reason": "揭示情绪的无常本质。观察情绪的变化会瓦解「这种感觉会永远持续下去」的信念。" }, { "type": "stress_regulation", "difficulty": 1, "duration": 3, - "description": "4-4-6 breathing: Inhale for 4 counts, hold for 4, exhale for 6. The long exhale activates the parasympathetic nervous system.", - "reason": "Physiological stress reduction through breath pattern. The extended exhale is one of the fastest ways to signal safety to the nervous system." + "description": "4-4-6呼吸法:吸气4秒,屏息4秒,呼气6秒。延长的呼气激活副交感神经系统。", + "reason": "通过呼吸模式进行生理层面的压力缓解。延长呼气是向神经系统发出安全信号最快速的方法之一。" }, { "type": "stress_regulation", "difficulty": 2, "duration": 5, - "description": "Progressive relaxation: Tense each muscle group for 5 seconds, then release for 10. Start with feet, work up to face. Notice the contrast between tension and release.", - "reason": "Teaches the difference between tension and relaxation at the body level. Many people don't realize they're holding tension until they deliberately release it." + "description": "渐进式放松:每个肌肉群收紧5秒,然后放松10秒。从脚开始,逐步到脸部。注意紧张与放松之间的对比。", + "reason": "在身体层面区分紧张与放松的感觉。很多人在刻意释放之前并不知道自己在紧绷着。" }, { "type": "stress_regulation", "difficulty": 3, "duration": 8, - "description": "Grounding through senses: Name 5 things you can see, 4 things you can touch, 3 things you can hear, 2 things you can smell, 1 thing you can taste.", - "reason": "Full sensory grounding anchors you in the present moment. Effective for acute stress by redirecting attention from internal worry to external reality." + "description": "五感锚定:找出你能看到的5样东西、能触摸的4样东西、能听到的3种声音、能闻到的2种气味、能尝到的1种味道。", + "reason": "全感官锚定让你回到当下。通过将注意力从内在担忧转向外部现实,有效应对急性压力。" }, { "type": "cognitive_clarity", "difficulty": 1, "duration": 3, - "description": "Thought labeling: Sit quietly and watch thoughts arise. Label each one silently — 'planning,' 'worrying,' 'remembering,' 'judging.' Don't engage the thought. Just label and release.", - "reason": "Creates distance between you and your thoughts. Labeling activates the prefrontal cortex and reduces amygdala reactivity." + "description": "念头标签:安静地坐着,观察升起的念头。默默地为每个念头贴上标签——「计划」「担忧」「回忆」「评判」。不参与念头,只标记和放下。", + "reason": "在你和你的念头之间创造距离。贴标签激活前额叶皮层,降低杏仁核的反应性。" }, { "type": "cognitive_clarity", "difficulty": 2, "duration": 5, - "description": "Reframing practice: Think of a mildly frustrating situation today. Write down your automatic thought, then write three alternative interpretations that are equally plausible.", - "reason": "Cognitive reframing is the core skill of cognitive flexibility. Practicing with mild frustrations builds the muscle for harder situations." + "description": "重构练习:回想今天一个让你略感沮丧的情境。写下你的自动想法,然后写出三个同样合理的替代解释。", + "reason": "认知重构是认知灵活性的核心技能。用轻微的挫折来练习,为更大的挑战建立心理肌肉。" }, { "type": "cognitive_clarity", "difficulty": 3, "duration": 10, - "description": "Inner dialogue observation: For 10 minutes, write down every thought that passes through your mind as if transcribing a conversation. Don't edit or judge. Then read it back. Notice which 'voice' is speaking — the critic? The planner? The child?", - "reason": "Reveals the multiplicity of the inner world. Recognizing your inner voices is the first step toward choosing which ones to amplify." + "description": "内在对话观察:用10分钟时间,像记录对话一样写下经过你脑海的每一个念头。不要编辑或评判。然后回头阅读。注意是哪个「声音」在说话——批评者?计划者?内在小孩?", + "reason": "揭示内心世界的多元性。认识你的内在声音是选择放大哪些声音的第一步。" }, { "type": "integration", "difficulty": 1, "duration": 3, - "description": "One-minute pause: Three times today, pause for 60 seconds. Take three conscious breaths. Notice what's happening in your body and mind. Then continue.", - "reason": "Integrates practice into daily life. Short, frequent pauses build the habit of mindful awareness without requiring dedicated sitting time." + "description": "一分钟暂停:今天三次,每次暂停60秒。做三次有意识的呼吸。留意你的身体和心里正在发生什么。然后继续做你的事。", + "reason": "将练习融入日常生活。短暂而频繁的暂停建立了正念觉察的习惯,无需专门的静坐时间。" }, { "type": "integration", "difficulty": 2, "duration": 5, - "description": "Mindful routine: Pick one daily activity (brushing teeth, washing dishes, walking to your desk). Do it with full attention today. Notice every sensation, movement, and sound.", - "reason": "Turns everyday activities into practice opportunities. The goal isn't more practice time — it's weaving practice into time you already have." + "description": "正念日常:选择一个日常活动(刷牙、洗碗、走到办公桌)。今天全程全心投入地做这件事。关注每一个感受、动作和声音。", + "reason": "将日常活动转化为练习机会。目标不是增加练习时间——而是将练习织入你已经拥有的时间中。" }, { "type": "integration", "difficulty": 3, "duration": 10, - "description": "Difficult conversation practice: Before a conversation that might be challenging, sit for 5 minutes and set an intention. During the conversation, notice your reactions without acting on them. After, reflect for 5 minutes on what you noticed.", - "reason": "The ultimate integration — bringing awareness into interpersonal dynamics. This is where practice meets real life." + "description": "困难对话练习:在一场可能具有挑战性的对话之前,静坐5分钟设定一个意图。对话过程中,觉察自己的反应但不立即行动。结束后,用5分钟反思你注意到的事情。", + "reason": "终极整合——将觉察带入人际互动中。这是练习与真实生活相遇的地方。" } ] diff --git a/dictionaries/en.ts b/dictionaries/en.ts index fd731a4..27f5ffe 100644 --- a/dictionaries/en.ts +++ b/dictionaries/en.ts @@ -93,6 +93,10 @@ const en: Dict = { begin: "Begin practice", why_prefix: "", // unused in English, kept for key parity with zh }, + + session: { + completed: "Practice complete! Keep it going.", + }, }, // ----------------------------------------------------------------------- @@ -185,6 +189,10 @@ const en: Dict = { finish_early: "Finish early", complete: "Practice complete", reflect: "Reflect on your practice", + mute: "Mute", + unmute: "Unmute", + mid_guidance: "Stay focused, you're doing well.", + end_guidance: "Practice complete. Take a moment to reflect.", }, // ----------------------------------------------------------------------- diff --git a/dictionaries/zh.ts b/dictionaries/zh.ts index 3b4946c..763da58 100644 --- a/dictionaries/zh.ts +++ b/dictionaries/zh.ts @@ -93,6 +93,10 @@ const zh: Dict = { begin: "开始练习", why_prefix: "", }, + + session: { + completed: "练习完成!继续保持。", + }, }, // ----------------------------------------------------------------------- @@ -184,6 +188,10 @@ const zh: Dict = { finish_early: "提前结束", complete: "练习完成", reflect: "记录感受", + mute: "静音", + unmute: "取消静音", + mid_guidance: "继续专注,你做得很好。", + end_guidance: "练习完成。请记录你的感受。", }, // ----------------------------------------------------------------------- diff --git a/prisma/migrations/20260617062135_add_analytics_and_user_fields/migration.sql b/prisma/migrations/20260617062135_add_analytics_and_user_fields/migration.sql new file mode 100644 index 0000000..103a1ea --- /dev/null +++ b/prisma/migrations/20260617062135_add_analytics_and_user_fields/migration.sql @@ -0,0 +1,111 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password_hash" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "user_states" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "attention_level" INTEGER NOT NULL DEFAULT 50, + "emotion_stability" INTEGER NOT NULL DEFAULT 50, + "stress_level" INTEGER NOT NULL DEFAULT 50, + "practice_stage" INTEGER NOT NULL DEFAULT 0, + "consistency_score" INTEGER NOT NULL DEFAULT 0, + "streak_days" INTEGER NOT NULL DEFAULT 0, + "total_sessions_completed" INTEGER NOT NULL DEFAULT 0, + "onboarding_completed" BOOLEAN NOT NULL DEFAULT false, + "onboarding_variant" TEXT, + "timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai', + "last_session_date" DATETIME, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "user_states_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "analytics_events" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "event" TEXT NOT NULL, + "metadata" TEXT NOT NULL DEFAULT '{}', + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "task_type" TEXT NOT NULL, + "task_description" TEXT NOT NULL, + "duration_seconds" INTEGER NOT NULL DEFAULT 300, + "completed" BOOLEAN NOT NULL DEFAULT false, + "focus_score" INTEGER, + "emotion_score" INTEGER, + "notes" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "tasks" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "difficulty" INTEGER NOT NULL DEFAULT 1, + "duration" INTEGER NOT NULL DEFAULT 5, + "description" TEXT NOT NULL, + "reason_json" TEXT NOT NULL DEFAULT '{}', + "status" TEXT NOT NULL DEFAULT 'pending', + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "tasks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "reward_events" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "message" TEXT NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "reward_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "weekly_summaries" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "summary" TEXT, + "week_start" DATETIME NOT NULL, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "weekly_summaries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_states_user_id_key" ON "user_states"("user_id"); + +-- CreateIndex +CREATE INDEX "analytics_events_user_id_created_at_idx" ON "analytics_events"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "analytics_events_event_created_at_idx" ON "analytics_events"("event", "created_at"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_created_at_idx" ON "sessions"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "tasks_user_id_created_at_idx" ON "tasks"("user_id", "created_at"); + +-- CreateIndex +CREATE INDEX "tasks_user_id_status_idx" ON "tasks"("user_id", "status"); + +-- CreateIndex +CREATE INDEX "reward_events_user_id_created_at_idx" ON "reward_events"("user_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "weekly_summaries_user_id_key" ON "weekly_summaries"("user_id"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f7210e9..575ace8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,8 @@ model UserState { streakDays Int @default(0) @map("streak_days") totalSessionsCompleted Int @default(0) @map("total_sessions_completed") onboardingCompleted Boolean @default(false) @map("onboarding_completed") + onboardingVariant String? @map("onboarding_variant") + timezone String @default("Asia/Shanghai") lastSessionDate DateTime? @map("last_session_date") updatedAt DateTime @updatedAt @map("updated_at") @@ -65,6 +67,18 @@ model UserState { @@map("user_states") } +model AnalyticsEvent { + id String @id @default(uuid()) + userId String @map("user_id") + event String + metadata String @default("{}") + createdAt DateTime @default(now()) @map("created_at") + + @@index([userId, createdAt]) + @@index([event, createdAt]) + @@map("analytics_events") +} + model Session { id String @id @default(uuid()) userId String @map("user_id") diff --git a/public/audio/end-chime.wav b/public/audio/end-chime.wav new file mode 100644 index 0000000..956f516 Binary files /dev/null and b/public/audio/end-chime.wav differ diff --git a/public/audio/end-guidance-en.mp3 b/public/audio/end-guidance-en.mp3 new file mode 100644 index 0000000..aeb0bf0 Binary files /dev/null and b/public/audio/end-guidance-en.mp3 differ diff --git a/public/audio/end-guidance-zh.mp3 b/public/audio/end-guidance-zh.mp3 new file mode 100644 index 0000000..c34b089 Binary files /dev/null and b/public/audio/end-guidance-zh.mp3 differ diff --git a/public/audio/mid-guidance-en.mp3 b/public/audio/mid-guidance-en.mp3 new file mode 100644 index 0000000..ceb9dd6 Binary files /dev/null and b/public/audio/mid-guidance-en.mp3 differ diff --git a/public/audio/mid-guidance-zh.mp3 b/public/audio/mid-guidance-zh.mp3 new file mode 100644 index 0000000..69c3038 Binary files /dev/null and b/public/audio/mid-guidance-zh.mp3 differ diff --git a/public/audio/start-chime.wav b/public/audio/start-chime.wav new file mode 100644 index 0000000..86f71d9 Binary files /dev/null and b/public/audio/start-chime.wav differ diff --git a/public/audio/tick.wav b/public/audio/tick.wav new file mode 100644 index 0000000..9a158eb Binary files /dev/null and b/public/audio/tick.wav differ diff --git a/scripts/generate-audio.sh b/scripts/generate-audio.sh new file mode 100755 index 0000000..fbd94bf --- /dev/null +++ b/scripts/generate-audio.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Generate practice audio assets. +# Prerequisites: Node.js, Python 3, edge-tts (pip install edge-tts) +set -e + +AUDIO_DIR="$(cd "$(dirname "$0")/../public/audio" && pwd)" +mkdir -p "$AUDIO_DIR" + +echo "=== Generating chime sounds (Node.js) ===" +node -e " +const fs = require('fs'); + +function writeWav(filename, buildSamples, sampleRate, durationSec) { + const numSamples = Math.floor(sampleRate * durationSec); + const samples = new Float32Array(numSamples); + buildSamples(samples, sampleRate, durationSec); + + // Convert to 16-bit PCM + const buffer = Buffer.alloc(44 + numSamples * 2); + buffer.write('RIFF', 0); + buffer.writeUInt32LE(36 + numSamples * 2, 4); + buffer.write('WAVE', 8); + buffer.write('fmt ', 12); + buffer.writeUInt32LE(16, 16); // chunk size + buffer.writeUInt16LE(1, 20); // PCM + buffer.writeUInt16LE(1, 22); // mono + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * 2, 28); // byte rate + buffer.writeUInt16LE(2, 32); // block align + buffer.writeUInt16LE(16, 34); // bits per sample + buffer.write('data', 36); + buffer.writeUInt32LE(numSamples * 2, 40); + + for (let i = 0; i < numSamples; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); + buffer.writeInt16LE(Math.round(s * 32767), 44 + i * 2); + } + fs.writeFileSync(filename, buffer); + console.log(' Created:', filename); +} + +// Bell-like chime: two sine harmonics (fundamental + octave) with quick decay +function bellTone(t, freq1, freq2, tStart, duration) { + if (t < tStart || t > tStart + duration) return 0; + const localT = (t - tStart) / duration; + const env = Math.exp(-localT * 6); // quick exponential decay + return 0.35 * env * ( + Math.sin(2 * Math.PI * freq1 * t) * 0.6 + + Math.sin(2 * Math.PI * freq2 * t) * 0.4 + ); +} + +// Start chime — welcoming ascending bell, 2s +writeWav('$AUDIO_DIR/start-chime.wav', (samples, sr, dur) => { + for (let i = 0; i < samples.length; i++) { + const t = i / sr; + let v = 0; + v += bellTone(t, 523.25, 1046.5, 0, 0.9); // C5 + v += bellTone(t, 659.25, 1318.5, 0.15, 0.8); // E5 + v += bellTone(t, 783.99, 1568.0, 0.3, 0.7); // G5 + samples[i] = v; + } +}, 44100, 1.8); + +// Soft tick — very short click, 0.08s +writeWav('$AUDIO_DIR/tick.wav', (samples, sr) => { + for (let i = 0; i < samples.length; i++) { + const t = i / sr; + const env = Math.exp(-t * 80); + samples[i] = 0.12 * env * Math.sin(2 * Math.PI * 1200 * t); + } +}, 44100, 0.08); + +// End chime — resonant closing chord, 3s +writeWav('$AUDIO_DIR/end-chime.wav', (samples, sr, dur) => { + for (let i = 0; i < samples.length; i++) { + const t = i / sr; + let v = 0; + v += bellTone(t, 261.63, 523.25, 0, 1.5); // C4 (deeper) + v += bellTone(t, 329.63, 659.25, 0.12, 1.3); // E4 + v += bellTone(t, 392.00, 783.99, 0.24, 1.1); // G4 + v += bellTone(t, 523.25, 1046.5, 0.36, 0.9); // C5 + samples[i] = v * 0.9; + } +}, 44100, 3.0); + +console.log('Chimes done.'); +" + +echo "" +echo "=== Generating voice guidance (Edge TTS neural voices → mp3) ===" + +# Chinese — Xiaoxiao (warm, mature female) +python3 -m edge_tts --voice "zh-CN-XiaoxiaoNeural" --rate="-40%" --text "继续专注,你做得很好。" --write-media "$AUDIO_DIR/mid-guidance-zh.mp3" +echo " Created: mid-guidance-zh.mp3" + +python3 -m edge_tts --voice "zh-CN-XiaoxiaoNeural" --rate="-40%" --text "练习完成。请记录你的感受。" --write-media "$AUDIO_DIR/end-guidance-zh.mp3" +echo " Created: end-guidance-zh.mp3" + +# English — Aria (neutral, professional female) +python3 -m edge_tts --voice "en-US-AriaNeural" --rate="-40%" --text "Stay focused. You're doing well." --write-media "$AUDIO_DIR/mid-guidance-en.mp3" +echo " Created: mid-guidance-en.mp3" + +python3 -m edge_tts --voice "en-US-AriaNeural" --rate="-40%" --text "Practice complete. Take a moment to reflect." --write-media "$AUDIO_DIR/end-guidance-en.mp3" +echo " Created: end-guidance-en.mp3" + +echo "" +echo "=== All audio assets generated in $AUDIO_DIR ===" +ls -lh "$AUDIO_DIR" diff --git a/src/app/api/audio/tts/route.ts b/src/app/api/audio/tts/route.ts new file mode 100644 index 0000000..5aadd5d --- /dev/null +++ b/src/app/api/audio/tts/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { spawn } from "child_process"; +import { readFileSync, unlinkSync, existsSync } from "fs"; +import { randomUUID } from "crypto"; +import path from "path"; +import os from "os"; + +// Uses Microsoft Edge TTS (free, neural voices via edge-tts Python CLI). +// pip install edge-tts + +const VOICE: Record = { + zh: "zh-CN-XiaoxiaoNeural", // warm, mature female + en: "en-US-AriaNeural", // neutral, professional female +}; + +function edgeTts(text: string, voice: string, rate: string, outputPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("python3", [ + "-m", "edge_tts", + "--voice", voice, + `--rate=${rate}`, + "--text", text, + "--write-media", outputPath, + ], { timeout: 30000, stdio: "pipe" }); + + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + + child.on("close", (code) => { + if (code === 0 && existsSync(outputPath)) { + resolve(); + } else { + reject(new Error(stderr || `exit ${code}`)); + } + }); + + child.on("error", reject); + }); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const text = searchParams.get("text"); + const lang = searchParams.get("lang") || "zh"; + + if (!text) { + return NextResponse.json({ error: "Missing `text` param" }, { status: 400 }); + } + + if (text.length > 1200) { + return NextResponse.json({ error: "Text too long (max 1200 chars)" }, { status: 400 }); + } + + const rate = searchParams.get("rate") || "-40%"; + const voice = VOICE[lang] || VOICE.zh; + + const tmpDir = os.tmpdir(); + const id = randomUUID(); + const mp3Path = path.join(tmpDir, `tts-${id}.mp3`); + + try { + await edgeTts(text, voice, rate, mp3Path); + + const audioBuffer = readFileSync(mp3Path); + const response = new NextResponse(audioBuffer); + response.headers.set("Content-Type", "audio/mpeg"); + response.headers.set("Content-Length", String(audioBuffer.length)); + response.headers.set("Cache-Control", "public, max-age=3600"); + + return response; + } catch (err) { + console.error("TTS generation failed:", err); + return NextResponse.json({ error: "TTS unavailable" }, { status: 501 }); + } finally { + try { + if (existsSync(mp3Path)) unlinkSync(mp3Path); + } catch {} + } +} diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts new file mode 100644 index 0000000..ca75ae2 --- /dev/null +++ b/src/app/api/cron/reminders/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +const CRON_SECRET = process.env.CRON_SECRET || "change-me-to-a-random-string"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const secret = searchParams.get("secret"); + + if (!secret || secret !== CRON_SECRET) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + const now = new Date(); + + // Find users who haven't completed a session today + const allUsers = await prisma.userState.findMany({ + where: { onboardingCompleted: true }, + select: { + userId: true, + timezone: true, + lastSessionDate: true, + }, + }); + + // Check for users who haven't practiced today (using their timezone) + const missedUsers: { userId: string; timezone: string; lastSessionDate: Date | null }[] = []; + + for (const user of allUsers) { + const tz = user.timezone || "Asia/Shanghai"; + const userTodayStr = now.toLocaleDateString("en-CA", { timeZone: tz }); + const userToday = new Date(userTodayStr + "T00:00:00"); + + // Check if user had a session today + const todaySession = await prisma.session.findFirst({ + where: { + userId: user.userId, + completed: true, + createdAt: { gte: userToday }, + }, + }); + + if (!todaySession) { + // Check if it's past 8pm local time + const localHour = parseInt( + now.toLocaleTimeString("en-US", { timeZone: tz, hour: "2-digit", hour12: false }) + ); + if (localHour >= 20) { + missedUsers.push({ + userId: user.userId, + timezone: tz, + lastSessionDate: user.lastSessionDate, + }); + } + } + } + + // Log would-be recipients (email infra deferred per CM-2) + if (missedUsers.length > 0) { + console.log( + "[cron/reminders] Would email:", + missedUsers.map((u) => `${u.userId.slice(0, 8)}… (tz=${u.timezone})`) + ); + } + + // Also find users who signed up >24h ago but have zero sessions + const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const usersWithNoSessions = await prisma.user.findMany({ + where: { + createdAt: { lt: dayAgo }, + state: { totalSessionsCompleted: 0, onboardingCompleted: true }, + }, + select: { id: true }, + }); + + if (usersWithNoSessions.length > 0) { + console.log( + "[cron/reminders] Would send first-session nudge to:", + usersWithNoSessions.map((u) => u.id.slice(0, 8) + "…") + ); + } + + return NextResponse.json({ + reminders: { + missedToday: missedUsers.map((u) => ({ userId: u.userId.slice(0, 8) + "…", timezone: u.timezone })), + noSessions: usersWithNoSessions.map((u) => ({ userId: u.id.slice(0, 8) + "…" })), + }, + }); +} diff --git a/src/app/api/session/complete/route.ts b/src/app/api/session/complete/route.ts index ba990d2..fb75d59 100644 --- a/src/app/api/session/complete/route.ts +++ b/src/app/api/session/complete/route.ts @@ -4,7 +4,8 @@ import { openai } from "@/lib/openai"; import { prisma } from "@/lib/prisma"; import { processReflection } from "@/lib/engines/reflection-analyzer"; import { computeStateUpdate } from "@/lib/engines/progress-tracker"; -import { generateRewards, persistRewards } from "@/lib/engines/reward-generator"; +import { generateRewards } from "@/lib/engines/reward-generator"; +import { logEvent } from "@/lib/analytics"; export async function POST(req: Request) { const session = await getSession(); @@ -54,31 +55,58 @@ export async function POST(req: Request) { update: {}, }); + const trimmedNotes = (notes || "").trim(); + const cookieHeader = req.headers.get("cookie") || ""; + const locale: "zh" | "en" = + cookieHeader + .split(";") + .map((c) => c.trim()) + .find((c) => c.startsWith("inneros-lang=")) + ?.split("=")[1] === "en" + ? "en" + : "zh"; - // Determine user's language preference from cookie - const cookieLang = req.headers - .get("cookie") - ?.split("; ") - .find((c) => c.startsWith("inneros-lang=")) - ?.split("=")[1]; - const locale: "zh" | "en" = cookieLang === "en" ? "en" : "zh"; + // Empty / whitespace-only reflection — short-circuit, skip LLM + let reflection: { + focusScore: number; + emotionScore: number; + insight: string; + attentionDelta: number; + emotionDelta: number; + stressDelta: number; + safety: { safe: boolean }; + }; - // Process reflection through LLM #2 - const reflection = await processReflection( - { - notes: notes || "", - durationSeconds: durationSeconds || task.duration * 60, - taskType: task.type, - taskDescription: task.description, - }, - { - attentionLevel: currentState.attentionLevel, - emotionStability: currentState.emotionStability, - stressLevel: currentState.stressLevel, - }, - openai, - locale - ); + if (!trimmedNotes) { + reflection = { + focusScore: 3, + emotionScore: 3, + insight: + locale === "zh" + ? "下次练习后花一点时间写下你的感受。" + : "Take a moment to reflect on your practice next time.", + attentionDelta: 0, + emotionDelta: 0, + stressDelta: 0, + safety: { safe: true }, + }; + } else { + reflection = await processReflection( + { + notes: trimmedNotes, + durationSeconds: durationSeconds || task.duration * 60, + taskType: task.type, + taskDescription: task.description, + }, + { + attentionLevel: currentState.attentionLevel, + emotionStability: currentState.emotionStability, + stressLevel: currentState.stressLevel, + }, + openai, + locale + ); + } // Get recent session dates for consistency computation const recentSessions = await prisma.session.findMany({ @@ -99,6 +127,11 @@ export async function POST(req: Request) { recentSessions.map((s) => s.createdAt) ); + // First session should start streak at 1 + if (currentState.totalSessionsCompleted === 0 && stateUpdate.streakDays === 0) { + stateUpdate.streakDays = 1; + } + // Generate rewards const rewards = generateRewards({ isFirstSession: currentState.totalSessionsCompleted === 0, @@ -109,15 +142,19 @@ export async function POST(req: Request) { totalSessionsCompleted: stateUpdate.totalSessionsCompleted, }); + // Use user-local date for lastSessionDate (UTC noon to avoid DST ambiguity) + const timezone = currentState.timezone || "Asia/Shanghai"; + const dateStr = new Date().toLocaleDateString("en-CA", { timeZone: timezone }); + const [y, m, d] = dateStr.split("-").map(Number); + const nowLocal = new Date(Date.UTC(y, m - 1, d, 12, 0, 0)); + // Perform all DB writes in a transaction await prisma.$transaction([ - // Mark task as completed prisma.task.update({ where: { id: taskId }, data: { status: "completed" }, }), - // Create session record prisma.session.create({ data: { userId, @@ -127,11 +164,10 @@ export async function POST(req: Request) { completed: true, focusScore: reflection.focusScore, emotionScore: reflection.emotionScore, - notes: notes || null, + notes: trimmedNotes || null, }, }), - // Update user state prisma.userState.update({ where: { userId }, data: { @@ -142,11 +178,10 @@ export async function POST(req: Request) { consistencyScore: stateUpdate.consistencyScore, streakDays: stateUpdate.streakDays, totalSessionsCompleted: stateUpdate.totalSessionsCompleted, - lastSessionDate: new Date(), + lastSessionDate: nowLocal, }, }), - // Persist rewards ...rewards.map((r) => prisma.rewardEvent.create({ data: { @@ -158,6 +193,17 @@ export async function POST(req: Request) { ), ]); + // Fire-and-forget analytics event (outside transaction, never blocks response) + logEvent(userId, "session_completed", { + focusScore: reflection.focusScore, + emotionScore: reflection.emotionScore, + durationSeconds: durationSeconds || task.duration * 60, + taskType: task.type, + hasNotes: !!trimmedNotes, + }); + + const streakIncremented = stateUpdate.streakDays > currentState.streakDays; + return NextResponse.json({ reflection: { focusScore: reflection.focusScore, @@ -176,5 +222,6 @@ export async function POST(req: Request) { rewards, stageAdvanced: stateUpdate.stageAdvanced, streakMilestone: stateUpdate.streakMilestone, + streakIncremented, }); } diff --git a/src/app/api/task/daily/route.ts b/src/app/api/task/daily/route.ts index dad7258..2f7e133 100644 --- a/src/app/api/task/daily/route.ts +++ b/src/app/api/task/daily/route.ts @@ -7,14 +7,20 @@ export async function GET() { if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const userId = session.userId; - const today = new Date(); - today.setHours(0, 0, 0, 0); + // Use user-local date for "today" + const state = await prisma.userState.findUnique({ + where: { userId }, + select: { timezone: true }, + }); + const timezone = state?.timezone || "Asia/Shanghai"; + const todayStr = new Date().toLocaleDateString("en-CA", { timeZone: timezone }); + const todayStart = new Date(todayStr + "T00:00:00"); const task = await prisma.task.findFirst({ where: { userId, status: "pending", - createdAt: { gte: today }, + createdAt: { gte: todayStart }, }, orderBy: { createdAt: "desc" }, select: { diff --git a/src/app/api/task/generate/route.ts b/src/app/api/task/generate/route.ts index b2aa08f..fc26739 100644 --- a/src/app/api/task/generate/route.ts +++ b/src/app/api/task/generate/route.ts @@ -2,6 +2,7 @@ import { getSession } from "@/lib/auth"; import { NextResponse } from "next/server"; import { openai } from "@/lib/openai"; import { generateDailyTask } from "@/lib/engines/task-selector"; +import { logEvent } from "@/lib/analytics"; export async function POST() { const session = await getSession(); @@ -11,6 +12,13 @@ export async function POST() { try { const result = await generateDailyTask(userId, openai); + logEvent(userId, "task_generated", { + taskType: result.task.type, + difficulty: result.task.difficulty, + duration: result.task.duration, + fromTemplate: result.fromTemplate, + }); + return NextResponse.json({ task: result.task, fromTemplate: result.fromTemplate, diff --git a/src/app/api/user/onboarding/route.ts b/src/app/api/user/onboarding/route.ts index 07d10ce..245303e 100644 --- a/src/app/api/user/onboarding/route.ts +++ b/src/app/api/user/onboarding/route.ts @@ -1,18 +1,33 @@ import { getSession } from "@/lib/auth"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { logEvent } from "@/lib/analytics"; + +const VARIANTS = ["3-questions", "narrative"] as const; export async function POST() { const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const userId = session.userId; - // Mark onboarding complete + // Round-robin variant assignment: query the last assigned variant + const lastState = await prisma.userState.findFirst({ + where: { onboardingVariant: { not: null } }, + orderBy: { updatedAt: "desc" }, + select: { onboardingVariant: true }, + }); + + const nextVariant = + lastState?.onboardingVariant === "3-questions" + ? "narrative" + : "3-questions"; + const state = await prisma.userState.upsert({ where: { userId }, create: { userId, onboardingCompleted: true, + onboardingVariant: nextVariant, }, update: { onboardingCompleted: true, @@ -26,8 +41,14 @@ export async function POST() { streakDays: true, totalSessionsCompleted: true, onboardingCompleted: true, + onboardingVariant: true, + timezone: true, }, }); + logEvent(userId, "onboarding_completed", { + variant: state.onboardingVariant, + }); + return NextResponse.json({ state }); } diff --git a/src/app/api/user/state/route.ts b/src/app/api/user/state/route.ts index edd4b57..d178c45 100644 --- a/src/app/api/user/state/route.ts +++ b/src/app/api/user/state/route.ts @@ -18,6 +18,8 @@ export async function GET() { streakDays: true, totalSessionsCompleted: true, onboardingCompleted: true, + onboardingVariant: true, + timezone: true, lastSessionDate: true, }, }); @@ -65,6 +67,8 @@ export async function PATCH(req: Request) { streakDays: true, totalSessionsCompleted: true, onboardingCompleted: true, + onboardingVariant: true, + timezone: true, lastSessionDate: true, }, }); diff --git a/src/app/internal/layout.tsx b/src/app/internal/layout.tsx new file mode 100644 index 0000000..e7c0447 --- /dev/null +++ b/src/app/internal/layout.tsx @@ -0,0 +1,3 @@ +export default function InternalLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/internal/page.tsx b/src/app/internal/page.tsx new file mode 100644 index 0000000..b64f6f9 --- /dev/null +++ b/src/app/internal/page.tsx @@ -0,0 +1,294 @@ +import { prisma } from "@/lib/prisma"; +import { Prisma } from "@/generated/prisma/client"; + +const DASHBOARD_TOKEN = process.env.DASHBOARD_TOKEN || "inneros-dashboard-2026"; + +export default async function InternalPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}) { + const { token } = await searchParams; + + if (!token || token !== DASHBOARD_TOKEN) { + return ( +
+

404 — Not Found

+
+ ); + } + + const now = new Date(); + const todayStr = now.toISOString().slice(0, 10); + const todayStart = new Date(todayStr + "T00:00:00.000Z"); + + // DAU: users who completed a session today + const dau = await prisma.session.findMany({ + where: { completed: true, createdAt: { gte: todayStart } }, + select: { userId: true }, + distinct: ["userId"], + }); + const dauCount = dau.length; + + // All users with state + const allStates = await prisma.userState.findMany({ + select: { + userId: true, + streakDays: true, + totalSessionsCompleted: true, + lastSessionDate: true, + onboardingCompleted: true, + }, + }); + + // All completed sessions + const allSessions = await prisma.session.findMany({ + where: { completed: true }, + orderBy: { createdAt: "asc" }, + select: { userId: true, createdAt: true, focusScore: true, emotionScore: true }, + }); + + // Per-user session log + const userSessions = new Map(); + for (const s of allSessions) { + const list = userSessions.get(s.userId) || []; + list.push(s); + userSessions.set(s.userId, list); + } + + // Day-3 return rate: users who completed a session on day 3 after first session + let day3Return = 0; + let usersWithFirstSession = 0; + for (const [userId, sessions] of userSessions) { + if (sessions.length === 0) continue; + usersWithFirstSession++; + const firstDate = new Date(sessions[0].createdAt); + const firstDay = new Date(firstDate.getFullYear(), firstDate.getMonth(), firstDate.getDate()); + const day3 = new Date(firstDay.getTime() + 3 * 24 * 60 * 60 * 1000); + const hasDay3 = sessions.some((s) => { + const d = new Date(s.createdAt); + return d >= day3; + }); + if (hasDay3) day3Return++; + } + + // Week-1 completion rate: users with >=5 sessions in first 7 days + let week1Completers = 0; + for (const [userId, sessions] of userSessions) { + if (sessions.length === 0) continue; + const firstDate = new Date(sessions[0].createdAt); + const firstDay = new Date(firstDate.getFullYear(), firstDate.getMonth(), firstDate.getDate()); + const day7 = new Date(firstDay.getTime() + 7 * 24 * 60 * 60 * 1000); + const week1Sessions = sessions.filter((s) => { + const d = new Date(s.createdAt); + return d >= firstDay && d < day7; + }); + if (week1Sessions.length >= 5) week1Completers++; + } + + // Day-7 retention: users who completed a session on day 7 after first session + // Only measurable if at least one user started >= 7 days ago + let day7Retention: string | number = "N/A"; + const sevenDaysAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000); + let day7Eligible = 0; + let day7Returned = 0; + for (const [userId, sessions] of userSessions) { + if (sessions.length === 0) continue; + const firstDate = new Date(sessions[0].createdAt); + const firstDay = new Date(firstDate.getFullYear(), firstDate.getMonth(), firstDate.getDate()); + if (firstDay > sevenDaysAgo) continue; // first session was less than 7 days ago + day7Eligible++; + const day7Start = new Date(firstDay.getTime() + 7 * 24 * 60 * 60 * 1000); + const day7End = new Date(day7Start.getTime() + 24 * 60 * 60 * 1000); + const hasDay7 = sessions.some((s) => { + const d = new Date(s.createdAt); + return d >= day7Start && d < day7End; + }); + if (hasDay7) day7Returned++; + } + if (day7Eligible > 0) { + day7Retention = Math.round((day7Returned / day7Eligible) * 100); + } else { + // Find earliest first session to show countdown + let earliestFirst: Date | null = null; + for (const [, sessions] of userSessions) { + if (sessions.length === 0) continue; + const d = new Date(sessions[0].createdAt); + if (!earliestFirst || d < earliestFirst) earliestFirst = d; + } + if (earliestFirst) { + const daysSince = Math.floor( + (todayStart.getTime() - earliestFirst.getTime()) / (1000 * 60 * 60 * 24) + ); + const daysUntil = 7 - daysSince; + day7Retention = `N/A — ${Math.max(0, daysUntil)} days until measurable`; + } else { + day7Retention = "N/A — no sessions yet"; + } + } + + // Average focus/emotion scores + let avgFocus = 0; + let avgEmotion = 0; + const scoredSessions = allSessions.filter((s) => s.focusScore != null); + if (scoredSessions.length > 0) { + avgFocus = Math.round( + (scoredSessions.reduce((sum, s) => sum + (s.focusScore || 0), 0) / scoredSessions.length) * 10 + ) / 10; + avgEmotion = Math.round( + (scoredSessions.reduce((sum, s) => sum + (s.emotionScore || 0), 0) / scoredSessions.length) * 10 + ) / 10; + } + + // Analytics events count + const analyticsCount = await prisma.analyticsEvent.count(); + + const totalUsers = allStates.length; + const usersWithSessions = usersWithFirstSession; + + const td = { border: "1px solid #ccc", padding: "8px 12px", fontSize: 14 }; + const th = { ...td, fontWeight: 600, background: "#f5f5f5" }; + + return ( +
+
+

Inner OS — Retention Dashboard

+ + {now.toISOString().slice(0, 10)} · {totalUsers} users · {analyticsCount} events + +
+ + {/* Key metrics */} +
+ + 0 ? Math.round((day3Return / usersWithFirstSession) * 100) + "%" : "—"} + hint={`${day3Return}/${usersWithFirstSession} eligible`} + /> + + 0 ? Math.round((week1Completers / usersWithFirstSession) * 100) + "%" : "—"} + hint={`${week1Completers}/${usersWithFirstSession} users`} + /> +
+ + {/* Score averages */} +
+ 0 ? avgFocus.toFixed(1) : "—"} hint="1-5 scale" /> + 0 ? avgEmotion.toFixed(1) : "—"} hint="1-5 scale" /> + + +
+ + {/* Per-user session log */} +

Per-User Session Log

+ {totalUsers === 0 ? ( +
No data yet — waiting for first user to complete a session.
+ ) : ( + + + + + + + + + + + + + + {allStates.map((st) => { + const sessions = userSessions.get(st.userId) || []; + const fScores = sessions.filter((s) => s.focusScore != null); + const uAvgFocus = fScores.length > 0 + ? Math.round((fScores.reduce((s, x) => s + (x.focusScore || 0), 0) / fScores.length) * 10) / 10 + : null; + const uAvgEmotion = fScores.length > 0 + ? Math.round((fScores.reduce((s, x) => s + (x.emotionScore || 0), 0) / fScores.length) * 10) / 10 + : null; + const lastSession = sessions.length > 0 ? sessions[sessions.length - 1].createdAt : null; + const daysAgo = lastSession + ? Math.floor((now.getTime() - new Date(lastSession).getTime()) / (1000 * 60 * 60 * 24)) + : null; + return ( + + + + + + + + + + ); + })} + +
User IDSessionsStreakAvg FocusAvg EmotionLast ActiveDays Ago
{st.userId.slice(0, 8)}…{st.totalSessionsCompleted}{st.streakDays}{uAvgFocus?.toFixed(1) || "—"}{uAvgEmotion?.toFixed(1) || "—"}{lastSession ? new Date(lastSession).toISOString().slice(0, 10) : "never"}{daysAgo !== null ? daysAgo : "—"}
+ )} + + {/* Analytics events log */} +

Recent Analytics Events

+ +
+ ); +} + +function MetricBox({ label, value, hint }: { label: string; value: string | number; hint: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
{hint}
+
+ ); +} + +async function AnalyticsLog() { + const events = await prisma.analyticsEvent.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + }); + + const td = { border: "1px solid #ccc", padding: "6px 10px", fontSize: 13 }; + const th = { ...td, fontWeight: 600, background: "#f5f5f5" }; + + if (events.length === 0) { + return
No events yet.
; + } + + return ( + + + + + + + + + + + {events.map((e) => ( + + + + + + + ))} + +
TimeEventUserMetadata
{new Date(e.createdAt).toISOString().slice(11, 19)}{e.event}{e.userId.slice(0, 8)}… + {e.metadata} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f21e244..420e991 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { DM_Sans, Fraunces } from "next/font/google"; import TabBar from "@/components/TabBar"; import LanguageSwitcher from "@/components/LanguageSwitcher"; @@ -32,18 +32,22 @@ export default async function RootLayout({ const langCookie = cookieStore.get("inneros-lang")?.value; const lang = langCookie === "zh" || langCookie === "en" ? langCookie : "zh"; + const headersList = await headers(); + const pathname = headersList.get("x-pathname") || ""; + const isInternal = pathname.startsWith("/internal"); + return ( - +
{children}
- + {!isInternal && }
diff --git a/src/app/page.tsx b/src/app/page.tsx index d004f71..847a1df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useAuth } from "@/hooks/use-auth"; import { useRouter } from "next/navigation"; import StatBar from "@/components/StatBar"; @@ -36,9 +36,33 @@ export default function HomePage() { const [loading, setLoading] = useState(true); const [generating, setGenerating] = useState(false); const [genError, setGenError] = useState(null); + const [showAck, setShowAck] = useState(false); + const ackRef = useRef(false); + + // Post-session acknowledgment: ?justCompleted=true + useEffect(() => { + if (ackRef.current) return; + if (typeof window !== "undefined" && window.location.search.includes("justCompleted=true")) { + ackRef.current = true; + setShowAck(true); + const timer = setTimeout(() => setShowAck(false), 3000); + // Clean URL + const url = new URL(window.location.href); + url.searchParams.delete("justCompleted"); + window.history.replaceState({}, "", url.toString()); + return () => clearTimeout(timer); + } + }, []); + + // Redirect to sign-in when auth fails after loading + useEffect(() => { + if (userLoaded && !user) { + router.replace("/sign-in"); + } + }, [userLoaded, user, router]); useEffect(() => { - if (!userLoaded) return; + if (!userLoaded || !user) return; Promise.all([ fetch("/api/user/state").then((r) => r.json()), fetch("/api/task/daily").then((r) => r.json()), @@ -77,10 +101,17 @@ export default function HomePage() { ); } - if (!user) return null; + // Show spinner while redirect effect fires + if (!user) { + return ( +
+
{t("shared.loading")}
+
+ ); + } - // Redirect to settings for onboarding - if (state && !state.onboardingCompleted) { + // Show onboarding prompt for new users (state can be null) + if (!state || !state.onboardingCompleted) { return (
@@ -133,6 +164,13 @@ export default function HomePage() {
+ {/* Post-session acknowledgment */} + {showAck && ( +
+

{t("home.session.completed")}

+
+ )} + {/* State overview */} {state && (
diff --git a/src/app/practice/page.tsx b/src/app/practice/page.tsx index 02eb04b..a561057 100644 --- a/src/app/practice/page.tsx +++ b/src/app/practice/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { Suspense, useEffect, useState, useCallback } from "react"; +import { Suspense, useEffect, useState, useCallback, useRef } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useI18n } from "@/lib/i18n/context"; import { formatTaskType } from "@/lib/i18n/utils"; +import { playStartChime, playTick, playEndChime, speakText } from "@/lib/audio"; +import { getMeditationPrompts } from "@/lib/meditation-guide"; interface TaskData { id: string; @@ -33,7 +35,7 @@ function LoadingFallback() { function PracticeContent() { const searchParams = useSearchParams(); const router = useRouter(); - const { t } = useI18n(); + const { t, lang } = useI18n(); const taskId = searchParams.get("id"); const [task, setTask] = useState(null); @@ -41,6 +43,12 @@ function PracticeContent() { const [phase, setPhase] = useState<"ready" | "active" | "done">("ready"); const [secondsRemaining, setSecondsRemaining] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [muted, setMuted] = useState(false); + + const totalSeconds = task ? task.duration * 60 : 0; + const prompts = useRef<{ atSecond: number; text: string }[]>([]); + const nextPromptIdx = useRef(0); + const isSpeaking = useRef(false); useEffect(() => { if (!taskId) { @@ -53,7 +61,9 @@ function PracticeContent() { .then((json) => { if (json.task && json.task.id === taskId) { setTask(json.task); - setSecondsRemaining(json.task.duration * 60); + const secs = json.task.duration * 60; + setSecondsRemaining(secs); + prompts.current = getMeditationPrompts(secs); } else { router.push("/"); } @@ -62,21 +72,54 @@ function PracticeContent() { .finally(() => setLoading(false)); }, [taskId, router]); - // Timer + // Timer + guided meditation prompts (sequenced, no overlap) useEffect(() => { if (phase !== "active") return; if (secondsRemaining <= 0) { setPhase("done"); return; } + + // Tick sound for last 10 seconds + if (secondsRemaining <= 10 && !muted) { + playTick(); + } + + // Fire next meditation prompt when ready (previous has finished) + if (!muted && !isSpeaking.current) { + const idx = nextPromptIdx.current; + if (idx < prompts.current.length && elapsedSeconds >= prompts.current[idx].atSecond) { + nextPromptIdx.current = idx + 1; + isSpeaking.current = true; + speakText(prompts.current[idx].text, lang === "zh" ? "zh" : "en") + .finally(() => { isSpeaking.current = false; }); + } + } + const interval = setInterval(() => { setSecondsRemaining((s) => s - 1); setElapsedSeconds((s) => s + 1); }, 1000); return () => clearInterval(interval); - }, [phase, secondsRemaining]); + }, [phase, secondsRemaining, elapsedSeconds, muted, lang]); - const handleStart = () => setPhase("active"); + // Done phase audio + useEffect(() => { + if (phase !== "done") return; + if (!muted) { + playEndChime(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase]); + + const handleStart = useCallback(() => { + setPhase("active"); + nextPromptIdx.current = 0; + isSpeaking.current = false; + if (!muted) { + playStartChime(); + } + }, [muted]); const handleFinish = () => { router.push( @@ -103,13 +146,23 @@ function PracticeContent() { return (
- {/* Back */} - + {/* Top bar: back + mute */} +
+ + + +
{/* Task display */}
@@ -144,7 +197,6 @@ function PracticeContent() { {phase === "active" && (
- {/* Progress ring */} ); } + +function SpeakerOnIcon() { + return ( + + ); +} + +function SpeakerOffIcon() { + return ( + + ); +} diff --git a/src/app/reflect/page.tsx b/src/app/reflect/page.tsx index 7c37aaa..b86f8f7 100644 --- a/src/app/reflect/page.tsx +++ b/src/app/reflect/page.tsx @@ -113,7 +113,7 @@ function ReflectForm() {