Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f1b7ffb3 | ||
|
|
6530b20172 |
+30
-30
@@ -3,105 +3,105 @@
|
|||||||
"type": "attention_training",
|
"type": "attention_training",
|
||||||
"difficulty": 1,
|
"difficulty": 1,
|
||||||
"duration": 3,
|
"duration": 3,
|
||||||
"description": "Breath counting: Count each exhale from 1 to 10, then start over. If your mind wanders, gently return to 1.",
|
"description": "数呼吸:从1数到10,每次呼气数一个数,然后重新开始。如果走神了,温柔地回到1。",
|
||||||
"reason": "Foundational attention exercise. Builds the skill of noticing when attention drifts and bringing it back without judgment."
|
"reason": "基础的注意力练习。培养觉察注意力游离并温柔带回的能力,不批判自己。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "attention_training",
|
"type": "attention_training",
|
||||||
"difficulty": 2,
|
"difficulty": 2,
|
||||||
"duration": 5,
|
"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.",
|
"description": "鼻端呼吸:专注感受空气经过鼻尖的感觉。留意温度、质感和节奏,不需要改变任何东西。",
|
||||||
"reason": "Narrower focus point than breath counting. Trains precision of attention by anchoring to a subtle physical sensation."
|
"reason": "比数呼吸更精细的专注锚点。通过聚焦微妙的生理感受来训练注意力的精准度。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "attention_training",
|
"type": "attention_training",
|
||||||
"difficulty": 3,
|
"difficulty": 3,
|
||||||
"duration": 10,
|
"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.",
|
"description": "开放觉知:安静地坐着,让注意力自然地停留在任何浮现的事物上——声音、身体感受、念头。不抓取,不排斥,只是观察。",
|
||||||
"reason": "Advanced attention exercise. Builds meta-awareness — the ability to observe the mind's movements without being carried away by them."
|
"reason": "进阶注意力练习。培养元觉知——观察心智活动而不被其带走的能力。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "emotion_awareness",
|
"type": "emotion_awareness",
|
||||||
"difficulty": 1,
|
"difficulty": 1,
|
||||||
"duration": 3,
|
"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.",
|
"description": "情绪气象站:闭上眼睛,问自己「此刻的情绪天气是什么?」简单地命名——晴朗、多云、暴风雨、平静。不用解释为什么,只需命名。",
|
||||||
"reason": "Builds the vocabulary and habit of checking in with emotional state. Naming emotions reduces their intensity and creates space for choice."
|
"reason": "建立情绪觉察的习惯和词汇。命名情绪可以降低其强度,为选择创造空间。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "emotion_awareness",
|
"type": "emotion_awareness",
|
||||||
"difficulty": 2,
|
"difficulty": 2,
|
||||||
"duration": 5,
|
"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.",
|
"description": "身体扫描:将注意力从头顶慢慢移到脚尖。在每个身体部位停留,留意任何感觉——紧绷、温暖、麻刺、空洞。不试图改变任何东西。",
|
||||||
"reason": "Emotions live in the body before they reach conscious awareness. Body scanning builds the sensory vocabulary that makes emotional signals legible."
|
"reason": "情绪在进入意识之前首先在身体中呈现。身体扫描建立了让情绪信号变得可读的感官词汇。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "emotion_awareness",
|
"type": "emotion_awareness",
|
||||||
"difficulty": 3,
|
"difficulty": 3,
|
||||||
"duration": 7,
|
"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.",
|
"description": "情绪追踪:设定7分钟计时器。每60秒暂停一次,写下一个描述你当前情绪状态的词。注意情绪如何每时每刻都在变化。",
|
||||||
"reason": "Reveals the impermanent nature of emotions. Watching emotions change undermines the belief that 'this feeling will last forever.'"
|
"reason": "揭示情绪的无常本质。观察情绪的变化会瓦解「这种感觉会永远持续下去」的信念。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "stress_regulation",
|
"type": "stress_regulation",
|
||||||
"difficulty": 1,
|
"difficulty": 1,
|
||||||
"duration": 3,
|
"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.",
|
"description": "4-4-6呼吸法:吸气4秒,屏息4秒,呼气6秒。延长的呼气激活副交感神经系统。",
|
||||||
"reason": "Physiological stress reduction through breath pattern. The extended exhale is one of the fastest ways to signal safety to the nervous system."
|
"reason": "通过呼吸模式进行生理层面的压力缓解。延长呼气是向神经系统发出安全信号最快速的方法之一。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "stress_regulation",
|
"type": "stress_regulation",
|
||||||
"difficulty": 2,
|
"difficulty": 2,
|
||||||
"duration": 5,
|
"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.",
|
"description": "渐进式放松:每个肌肉群收紧5秒,然后放松10秒。从脚开始,逐步到脸部。注意紧张与放松之间的对比。",
|
||||||
"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."
|
"reason": "在身体层面区分紧张与放松的感觉。很多人在刻意释放之前并不知道自己在紧绷着。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "stress_regulation",
|
"type": "stress_regulation",
|
||||||
"difficulty": 3,
|
"difficulty": 3,
|
||||||
"duration": 8,
|
"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.",
|
"description": "五感锚定:找出你能看到的5样东西、能触摸的4样东西、能听到的3种声音、能闻到的2种气味、能尝到的1种味道。",
|
||||||
"reason": "Full sensory grounding anchors you in the present moment. Effective for acute stress by redirecting attention from internal worry to external reality."
|
"reason": "全感官锚定让你回到当下。通过将注意力从内在担忧转向外部现实,有效应对急性压力。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "cognitive_clarity",
|
"type": "cognitive_clarity",
|
||||||
"difficulty": 1,
|
"difficulty": 1,
|
||||||
"duration": 3,
|
"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.",
|
"description": "念头标签:安静地坐着,观察升起的念头。默默地为每个念头贴上标签——「计划」「担忧」「回忆」「评判」。不参与念头,只标记和放下。",
|
||||||
"reason": "Creates distance between you and your thoughts. Labeling activates the prefrontal cortex and reduces amygdala reactivity."
|
"reason": "在你和你的念头之间创造距离。贴标签激活前额叶皮层,降低杏仁核的反应性。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "cognitive_clarity",
|
"type": "cognitive_clarity",
|
||||||
"difficulty": 2,
|
"difficulty": 2,
|
||||||
"duration": 5,
|
"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.",
|
"description": "重构练习:回想今天一个让你略感沮丧的情境。写下你的自动想法,然后写出三个同样合理的替代解释。",
|
||||||
"reason": "Cognitive reframing is the core skill of cognitive flexibility. Practicing with mild frustrations builds the muscle for harder situations."
|
"reason": "认知重构是认知灵活性的核心技能。用轻微的挫折来练习,为更大的挑战建立心理肌肉。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "cognitive_clarity",
|
"type": "cognitive_clarity",
|
||||||
"difficulty": 3,
|
"difficulty": 3,
|
||||||
"duration": 10,
|
"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?",
|
"description": "内在对话观察:用10分钟时间,像记录对话一样写下经过你脑海的每一个念头。不要编辑或评判。然后回头阅读。注意是哪个「声音」在说话——批评者?计划者?内在小孩?",
|
||||||
"reason": "Reveals the multiplicity of the inner world. Recognizing your inner voices is the first step toward choosing which ones to amplify."
|
"reason": "揭示内心世界的多元性。认识你的内在声音是选择放大哪些声音的第一步。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "integration",
|
"type": "integration",
|
||||||
"difficulty": 1,
|
"difficulty": 1,
|
||||||
"duration": 3,
|
"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.",
|
"description": "一分钟暂停:今天三次,每次暂停60秒。做三次有意识的呼吸。留意你的身体和心里正在发生什么。然后继续做你的事。",
|
||||||
"reason": "Integrates practice into daily life. Short, frequent pauses build the habit of mindful awareness without requiring dedicated sitting time."
|
"reason": "将练习融入日常生活。短暂而频繁的暂停建立了正念觉察的习惯,无需专门的静坐时间。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "integration",
|
"type": "integration",
|
||||||
"difficulty": 2,
|
"difficulty": 2,
|
||||||
"duration": 5,
|
"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.",
|
"description": "正念日常:选择一个日常活动(刷牙、洗碗、走到办公桌)。今天全程全心投入地做这件事。关注每一个感受、动作和声音。",
|
||||||
"reason": "Turns everyday activities into practice opportunities. The goal isn't more practice time — it's weaving practice into time you already have."
|
"reason": "将日常活动转化为练习机会。目标不是增加练习时间——而是将练习织入你已经拥有的时间中。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "integration",
|
"type": "integration",
|
||||||
"difficulty": 3,
|
"difficulty": 3,
|
||||||
"duration": 10,
|
"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.",
|
"description": "困难对话练习:在一场可能具有挑战性的对话之前,静坐5分钟设定一个意图。对话过程中,觉察自己的反应但不立即行动。结束后,用5分钟反思你注意到的事情。",
|
||||||
"reason": "The ultimate integration — bringing awareness into interpersonal dynamics. This is where practice meets real life."
|
"reason": "终极整合——将觉察带入人际互动中。这是练习与真实生活相遇的地方。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ const en: Dict = {
|
|||||||
begin: "Begin practice",
|
begin: "Begin practice",
|
||||||
why_prefix: "", // unused in English, kept for key parity with zh
|
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",
|
finish_early: "Finish early",
|
||||||
complete: "Practice complete",
|
complete: "Practice complete",
|
||||||
reflect: "Reflect on your practice",
|
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.",
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ const zh: Dict = {
|
|||||||
begin: "开始练习",
|
begin: "开始练习",
|
||||||
why_prefix: "",
|
why_prefix: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
session: {
|
||||||
|
completed: "练习完成!继续保持。",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -184,6 +188,10 @@ const zh: Dict = {
|
|||||||
finish_early: "提前结束",
|
finish_early: "提前结束",
|
||||||
complete: "练习完成",
|
complete: "练习完成",
|
||||||
reflect: "记录感受",
|
reflect: "记录感受",
|
||||||
|
mute: "静音",
|
||||||
|
unmute: "取消静音",
|
||||||
|
mid_guidance: "继续专注,你做得很好。",
|
||||||
|
end_guidance: "练习完成。请记录你的感受。",
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
@@ -57,6 +57,8 @@ model UserState {
|
|||||||
streakDays Int @default(0) @map("streak_days")
|
streakDays Int @default(0) @map("streak_days")
|
||||||
totalSessionsCompleted Int @default(0) @map("total_sessions_completed")
|
totalSessionsCompleted Int @default(0) @map("total_sessions_completed")
|
||||||
onboardingCompleted Boolean @default(false) @map("onboarding_completed")
|
onboardingCompleted Boolean @default(false) @map("onboarding_completed")
|
||||||
|
onboardingVariant String? @map("onboarding_variant")
|
||||||
|
timezone String @default("Asia/Shanghai")
|
||||||
lastSessionDate DateTime? @map("last_session_date")
|
lastSessionDate DateTime? @map("last_session_date")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -65,6 +67,18 @@ model UserState {
|
|||||||
@@map("user_states")
|
@@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 {
|
model Session {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+109
@@ -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"
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) + "…" })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import { openai } from "@/lib/openai";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { processReflection } from "@/lib/engines/reflection-analyzer";
|
import { processReflection } from "@/lib/engines/reflection-analyzer";
|
||||||
import { computeStateUpdate } from "@/lib/engines/progress-tracker";
|
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) {
|
export async function POST(req: Request) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
@@ -54,31 +55,58 @@ export async function POST(req: Request) {
|
|||||||
update: {},
|
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
|
// Empty / whitespace-only reflection — short-circuit, skip LLM
|
||||||
const cookieLang = req.headers
|
let reflection: {
|
||||||
.get("cookie")
|
focusScore: number;
|
||||||
?.split("; ")
|
emotionScore: number;
|
||||||
.find((c) => c.startsWith("inneros-lang="))
|
insight: string;
|
||||||
?.split("=")[1];
|
attentionDelta: number;
|
||||||
const locale: "zh" | "en" = cookieLang === "en" ? "en" : "zh";
|
emotionDelta: number;
|
||||||
|
stressDelta: number;
|
||||||
|
safety: { safe: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
// Process reflection through LLM #2
|
if (!trimmedNotes) {
|
||||||
const reflection = await processReflection(
|
reflection = {
|
||||||
{
|
focusScore: 3,
|
||||||
notes: notes || "",
|
emotionScore: 3,
|
||||||
durationSeconds: durationSeconds || task.duration * 60,
|
insight:
|
||||||
taskType: task.type,
|
locale === "zh"
|
||||||
taskDescription: task.description,
|
? "下次练习后花一点时间写下你的感受。"
|
||||||
},
|
: "Take a moment to reflect on your practice next time.",
|
||||||
{
|
attentionDelta: 0,
|
||||||
attentionLevel: currentState.attentionLevel,
|
emotionDelta: 0,
|
||||||
emotionStability: currentState.emotionStability,
|
stressDelta: 0,
|
||||||
stressLevel: currentState.stressLevel,
|
safety: { safe: true },
|
||||||
},
|
};
|
||||||
openai,
|
} else {
|
||||||
locale
|
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
|
// Get recent session dates for consistency computation
|
||||||
const recentSessions = await prisma.session.findMany({
|
const recentSessions = await prisma.session.findMany({
|
||||||
@@ -99,6 +127,11 @@ export async function POST(req: Request) {
|
|||||||
recentSessions.map((s) => s.createdAt)
|
recentSessions.map((s) => s.createdAt)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// First session should start streak at 1
|
||||||
|
if (currentState.totalSessionsCompleted === 0 && stateUpdate.streakDays === 0) {
|
||||||
|
stateUpdate.streakDays = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate rewards
|
// Generate rewards
|
||||||
const rewards = generateRewards({
|
const rewards = generateRewards({
|
||||||
isFirstSession: currentState.totalSessionsCompleted === 0,
|
isFirstSession: currentState.totalSessionsCompleted === 0,
|
||||||
@@ -109,15 +142,19 @@ export async function POST(req: Request) {
|
|||||||
totalSessionsCompleted: stateUpdate.totalSessionsCompleted,
|
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
|
// Perform all DB writes in a transaction
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
// Mark task as completed
|
|
||||||
prisma.task.update({
|
prisma.task.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: { status: "completed" },
|
data: { status: "completed" },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Create session record
|
|
||||||
prisma.session.create({
|
prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@@ -127,11 +164,10 @@ export async function POST(req: Request) {
|
|||||||
completed: true,
|
completed: true,
|
||||||
focusScore: reflection.focusScore,
|
focusScore: reflection.focusScore,
|
||||||
emotionScore: reflection.emotionScore,
|
emotionScore: reflection.emotionScore,
|
||||||
notes: notes || null,
|
notes: trimmedNotes || null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Update user state
|
|
||||||
prisma.userState.update({
|
prisma.userState.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: {
|
data: {
|
||||||
@@ -142,11 +178,10 @@ export async function POST(req: Request) {
|
|||||||
consistencyScore: stateUpdate.consistencyScore,
|
consistencyScore: stateUpdate.consistencyScore,
|
||||||
streakDays: stateUpdate.streakDays,
|
streakDays: stateUpdate.streakDays,
|
||||||
totalSessionsCompleted: stateUpdate.totalSessionsCompleted,
|
totalSessionsCompleted: stateUpdate.totalSessionsCompleted,
|
||||||
lastSessionDate: new Date(),
|
lastSessionDate: nowLocal,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Persist rewards
|
|
||||||
...rewards.map((r) =>
|
...rewards.map((r) =>
|
||||||
prisma.rewardEvent.create({
|
prisma.rewardEvent.create({
|
||||||
data: {
|
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({
|
return NextResponse.json({
|
||||||
reflection: {
|
reflection: {
|
||||||
focusScore: reflection.focusScore,
|
focusScore: reflection.focusScore,
|
||||||
@@ -176,5 +222,6 @@ export async function POST(req: Request) {
|
|||||||
rewards,
|
rewards,
|
||||||
stageAdvanced: stateUpdate.stageAdvanced,
|
stageAdvanced: stateUpdate.stageAdvanced,
|
||||||
streakMilestone: stateUpdate.streakMilestone,
|
streakMilestone: stateUpdate.streakMilestone,
|
||||||
|
streakIncremented,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,20 @@ export async function GET() {
|
|||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const userId = session.userId;
|
const userId = session.userId;
|
||||||
|
|
||||||
const today = new Date();
|
// Use user-local date for "today"
|
||||||
today.setHours(0, 0, 0, 0);
|
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({
|
const task = await prisma.task.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
createdAt: { gte: today },
|
createdAt: { gte: todayStart },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getSession } from "@/lib/auth";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { openai } from "@/lib/openai";
|
import { openai } from "@/lib/openai";
|
||||||
import { generateDailyTask } from "@/lib/engines/task-selector";
|
import { generateDailyTask } from "@/lib/engines/task-selector";
|
||||||
|
import { logEvent } from "@/lib/analytics";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
@@ -11,6 +12,13 @@ export async function POST() {
|
|||||||
try {
|
try {
|
||||||
const result = await generateDailyTask(userId, openai);
|
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({
|
return NextResponse.json({
|
||||||
task: result.task,
|
task: result.task,
|
||||||
fromTemplate: result.fromTemplate,
|
fromTemplate: result.fromTemplate,
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import { getSession } from "@/lib/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logEvent } from "@/lib/analytics";
|
||||||
|
|
||||||
|
const VARIANTS = ["3-questions", "narrative"] as const;
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const userId = session.userId;
|
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({
|
const state = await prisma.userState.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
|
onboardingVariant: nextVariant,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
@@ -26,8 +41,14 @@ export async function POST() {
|
|||||||
streakDays: true,
|
streakDays: true,
|
||||||
totalSessionsCompleted: true,
|
totalSessionsCompleted: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
|
onboardingVariant: true,
|
||||||
|
timezone: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logEvent(userId, "onboarding_completed", {
|
||||||
|
variant: state.onboardingVariant,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ state });
|
return NextResponse.json({ state });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export async function GET() {
|
|||||||
streakDays: true,
|
streakDays: true,
|
||||||
totalSessionsCompleted: true,
|
totalSessionsCompleted: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
|
onboardingVariant: true,
|
||||||
|
timezone: true,
|
||||||
lastSessionDate: true,
|
lastSessionDate: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -65,6 +67,8 @@ export async function PATCH(req: Request) {
|
|||||||
streakDays: true,
|
streakDays: true,
|
||||||
totalSessionsCompleted: true,
|
totalSessionsCompleted: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
|
onboardingVariant: true,
|
||||||
|
timezone: true,
|
||||||
lastSessionDate: true,
|
lastSessionDate: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function InternalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ padding: 40, fontFamily: "monospace" }}>
|
||||||
|
<h1>404 — Not Found</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, typeof allSessions>();
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 40, fontFamily: "system-ui, sans-serif", maxWidth: 960 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||||
|
<h1 style={{ fontSize: 24, fontWeight: 700, margin: "0 0 4px" }}>Inner OS — Retention Dashboard</h1>
|
||||||
|
<span style={{ color: "#888", fontSize: 13 }}>
|
||||||
|
{now.toISOString().slice(0, 10)} · {totalUsers} users · {analyticsCount} events
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, margin: "24px 0" }}>
|
||||||
|
<MetricBox label="DAU" value={dauCount} hint="sessions completed today" />
|
||||||
|
<MetricBox
|
||||||
|
label="Day-3 Return"
|
||||||
|
value={usersWithFirstSession > 0 ? Math.round((day3Return / usersWithFirstSession) * 100) + "%" : "—"}
|
||||||
|
hint={`${day3Return}/${usersWithFirstSession} eligible`}
|
||||||
|
/>
|
||||||
|
<MetricBox
|
||||||
|
label="Day-7 Retention"
|
||||||
|
value={typeof day7Retention === "number" ? day7Retention + "%" : String(day7Retention)}
|
||||||
|
hint={`${day7Returned}/${day7Eligible} eligible`}
|
||||||
|
/>
|
||||||
|
<MetricBox
|
||||||
|
label="Week-1 ≥5"
|
||||||
|
value={usersWithFirstSession > 0 ? Math.round((week1Completers / usersWithFirstSession) * 100) + "%" : "—"}
|
||||||
|
hint={`${week1Completers}/${usersWithFirstSession} users`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score averages */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, margin: "0 0 24px" }}>
|
||||||
|
<MetricBox label="Avg Focus" value={scoredSessions.length > 0 ? avgFocus.toFixed(1) : "—"} hint="1-5 scale" />
|
||||||
|
<MetricBox label="Avg Emotion" value={scoredSessions.length > 0 ? avgEmotion.toFixed(1) : "—"} hint="1-5 scale" />
|
||||||
|
<MetricBox label="Total Sessions" value={allSessions.length} hint="completed" />
|
||||||
|
<MetricBox label="Analytics Events" value={analyticsCount} hint="all event types" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-user session log */}
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "24px 0 12px" }}>Per-User Session Log</h2>
|
||||||
|
{totalUsers === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: "center", color: "#888" }}>No data yet — waiting for first user to complete a session.</div>
|
||||||
|
) : (
|
||||||
|
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={th}>User ID</th>
|
||||||
|
<th style={th}>Sessions</th>
|
||||||
|
<th style={th}>Streak</th>
|
||||||
|
<th style={th}>Avg Focus</th>
|
||||||
|
<th style={th}>Avg Emotion</th>
|
||||||
|
<th style={th}>Last Active</th>
|
||||||
|
<th style={th}>Days Ago</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={st.userId}>
|
||||||
|
<td style={td}>{st.userId.slice(0, 8)}…</td>
|
||||||
|
<td style={td}>{st.totalSessionsCompleted}</td>
|
||||||
|
<td style={td}>{st.streakDays}</td>
|
||||||
|
<td style={td}>{uAvgFocus?.toFixed(1) || "—"}</td>
|
||||||
|
<td style={td}>{uAvgEmotion?.toFixed(1) || "—"}</td>
|
||||||
|
<td style={td}>{lastSession ? new Date(lastSession).toISOString().slice(0, 10) : "never"}</td>
|
||||||
|
<td style={td}>{daysAgo !== null ? daysAgo : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analytics events log */}
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "32px 0 12px" }}>Recent Analytics Events</h2>
|
||||||
|
<AnalyticsLog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricBox({ label, value, hint }: { label: string; value: string | number; hint: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "16px 20px", background: "#fafaf5", borderRadius: 8, border: "1px solid #e5e5d5" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#888", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#aaa", marginTop: 2 }}>{hint}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div style={{ padding: 40, textAlign: "center", color: "#888" }}>No events yet.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={th}>Time</th>
|
||||||
|
<th style={th}>Event</th>
|
||||||
|
<th style={th}>User</th>
|
||||||
|
<th style={th}>Metadata</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td style={td}>{new Date(e.createdAt).toISOString().slice(11, 19)}</td>
|
||||||
|
<td style={td}>{e.event}</td>
|
||||||
|
<td style={td}>{e.userId.slice(0, 8)}…</td>
|
||||||
|
<td style={{ ...td, fontSize: 12, color: "#666", maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{e.metadata}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
+22
-3
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
import Script from "next/script";
|
||||||
import { DM_Sans, Fraunces } from "next/font/google";
|
import { DM_Sans, Fraunces } from "next/font/google";
|
||||||
import TabBar from "@/components/TabBar";
|
import TabBar from "@/components/TabBar";
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
@@ -32,18 +33,36 @@ export default async function RootLayout({
|
|||||||
const langCookie = cookieStore.get("inneros-lang")?.value;
|
const langCookie = cookieStore.get("inneros-lang")?.value;
|
||||||
const lang = langCookie === "zh" || langCookie === "en" ? langCookie : "zh";
|
const lang = langCookie === "zh" || langCookie === "en" ? langCookie : "zh";
|
||||||
|
|
||||||
|
const headersList = await headers();
|
||||||
|
const pathname = headersList.get("x-pathname") || "";
|
||||||
|
const isInternal = pathname.startsWith("/internal");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={lang}
|
lang={lang}
|
||||||
className={`${fraunces.variable} ${dmSans.variable} h-full antialiased`}
|
className={`${fraunces.variable} ${dmSans.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col pb-16 bg-cream dark:bg-[#1E1A14] text-ink dark:text-[#F3EFE8]">
|
<head>
|
||||||
|
<Script
|
||||||
|
src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
<Script id="google-analytics" strategy="afterInteractive">
|
||||||
|
{`
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-LVVXH3TL04');
|
||||||
|
`}
|
||||||
|
</Script>
|
||||||
|
</head>
|
||||||
|
<body className={`min-h-full flex flex-col ${isInternal ? "" : "pb-16"} bg-cream dark:bg-[#1E1A14] text-ink dark:text-[#F3EFE8]`}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<main className="flex-1 max-w-2xl mx-auto w-full px-5 pt-8 pb-4 relative">
|
<main className="flex-1 max-w-2xl mx-auto w-full px-5 pt-8 pb-4 relative">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<TabBar />
|
{!isInternal && <TabBar />}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+43
-5
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import StatBar from "@/components/StatBar";
|
import StatBar from "@/components/StatBar";
|
||||||
@@ -36,9 +36,33 @@ export default function HomePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [genError, setGenError] = useState<string | null>(null);
|
const [genError, setGenError] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!userLoaded) return;
|
if (!userLoaded || !user) return;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch("/api/user/state").then((r) => r.json()),
|
fetch("/api/user/state").then((r) => r.json()),
|
||||||
fetch("/api/task/daily").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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to settings for onboarding
|
// Show onboarding prompt for new users (state can be null)
|
||||||
if (state && !state.onboardingCompleted) {
|
if (!state || !state.onboardingCompleted) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Greeting firstName={null} />
|
<Greeting firstName={null} />
|
||||||
@@ -133,6 +164,13 @@ export default function HomePage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Greeting firstName={null} />
|
<Greeting firstName={null} />
|
||||||
|
|
||||||
|
{/* Post-session acknowledgment */}
|
||||||
|
{showAck && (
|
||||||
|
<div className="p-4 rounded-lg bg-amber/10 border border-amber/30 text-center animate-in fade-in">
|
||||||
|
<p className="text-amber font-medium text-sm">{t("home.session.completed")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* State overview */}
|
{/* State overview */}
|
||||||
{state && (
|
{state && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
+86
-14
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useEffect, useState, useCallback } from "react";
|
import { Suspense, useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useI18n } from "@/lib/i18n/context";
|
import { useI18n } from "@/lib/i18n/context";
|
||||||
import { formatTaskType } from "@/lib/i18n/utils";
|
import { formatTaskType } from "@/lib/i18n/utils";
|
||||||
|
import { playStartChime, playTick, playEndChime, speakText } from "@/lib/audio";
|
||||||
|
import { getMeditationPrompts } from "@/lib/meditation-guide";
|
||||||
|
|
||||||
interface TaskData {
|
interface TaskData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,7 +35,7 @@ function LoadingFallback() {
|
|||||||
function PracticeContent() {
|
function PracticeContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const taskId = searchParams.get("id");
|
const taskId = searchParams.get("id");
|
||||||
|
|
||||||
const [task, setTask] = useState<TaskData | null>(null);
|
const [task, setTask] = useState<TaskData | null>(null);
|
||||||
@@ -41,6 +43,12 @@ function PracticeContent() {
|
|||||||
const [phase, setPhase] = useState<"ready" | "active" | "done">("ready");
|
const [phase, setPhase] = useState<"ready" | "active" | "done">("ready");
|
||||||
const [secondsRemaining, setSecondsRemaining] = useState(0);
|
const [secondsRemaining, setSecondsRemaining] = useState(0);
|
||||||
const [elapsedSeconds, setElapsedSeconds] = 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(() => {
|
useEffect(() => {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
@@ -53,7 +61,9 @@ function PracticeContent() {
|
|||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (json.task && json.task.id === taskId) {
|
if (json.task && json.task.id === taskId) {
|
||||||
setTask(json.task);
|
setTask(json.task);
|
||||||
setSecondsRemaining(json.task.duration * 60);
|
const secs = json.task.duration * 60;
|
||||||
|
setSecondsRemaining(secs);
|
||||||
|
prompts.current = getMeditationPrompts(secs);
|
||||||
} else {
|
} else {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
@@ -62,21 +72,54 @@ function PracticeContent() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [taskId, router]);
|
}, [taskId, router]);
|
||||||
|
|
||||||
// Timer
|
// Timer + guided meditation prompts (sequenced, no overlap)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== "active") return;
|
if (phase !== "active") return;
|
||||||
if (secondsRemaining <= 0) {
|
if (secondsRemaining <= 0) {
|
||||||
setPhase("done");
|
setPhase("done");
|
||||||
return;
|
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(() => {
|
const interval = setInterval(() => {
|
||||||
setSecondsRemaining((s) => s - 1);
|
setSecondsRemaining((s) => s - 1);
|
||||||
setElapsedSeconds((s) => s + 1);
|
setElapsedSeconds((s) => s + 1);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearInterval(interval);
|
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 = () => {
|
const handleFinish = () => {
|
||||||
router.push(
|
router.push(
|
||||||
@@ -103,13 +146,23 @@ function PracticeContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Back */}
|
{/* Top bar: back + mute */}
|
||||||
<button
|
<div className="flex items-center justify-between">
|
||||||
onClick={() => router.push("/")}
|
<button
|
||||||
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
onClick={() => router.push("/")}
|
||||||
>
|
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
||||||
{t("shared.back")}
|
>
|
||||||
</button>
|
{t("shared.back")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className="p-2 rounded-lg text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
|
||||||
|
aria-label={muted ? t("practice.unmute") : t("practice.mute")}
|
||||||
|
>
|
||||||
|
{muted ? <SpeakerOffIcon /> : <SpeakerOnIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Task display */}
|
{/* Task display */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -144,7 +197,6 @@ function PracticeContent() {
|
|||||||
{phase === "active" && (
|
{phase === "active" && (
|
||||||
<div className="space-y-6 flex flex-col items-center">
|
<div className="space-y-6 flex flex-col items-center">
|
||||||
<div className="relative w-32 h-32 rounded-full bg-sand dark:bg-[#292418] border-4 border-stone dark:border-[#3D3528] flex items-center justify-center">
|
<div className="relative w-32 h-32 rounded-full bg-sand dark:bg-[#292418] border-4 border-stone dark:border-[#3D3528] flex items-center justify-center">
|
||||||
{/* Progress ring */}
|
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-0 w-full h-full -rotate-90"
|
className="absolute inset-0 w-full h-full -rotate-90"
|
||||||
viewBox="0 0 128 128"
|
viewBox="0 0 128 128"
|
||||||
@@ -206,3 +258,23 @@ function PracticeContent() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpeakerOnIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeakerOffIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
<line x1="23" y1="9" x2="17" y2="15" />
|
||||||
|
<line x1="17" y1="9" x2="23" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function ReflectForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/?justCompleted=true")}
|
||||||
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
{t("reflect.back_home")}
|
{t("reflect.back_home")}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const needsOnboarding = state && !state.onboardingCompleted;
|
const needsOnboarding = !state || !state.onboardingCompleted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
// Preferred voices by language — ordered by naturalness.
|
||||||
|
// Names are substrings that match across macOS / Windows / Chrome / Android.
|
||||||
|
const PREFERRED_ZH = [
|
||||||
|
"Tingting", // macOS / iOS
|
||||||
|
"Huihui", // Windows
|
||||||
|
"Google 普通话", // Android / Chrome
|
||||||
|
"Sin-Ji", // macOS Cantonese fallback
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREFERRED_EN = [
|
||||||
|
"Samantha", // macOS — very natural
|
||||||
|
"Daniel", // macOS UK
|
||||||
|
"Google UK English Female",
|
||||||
|
"Google US English",
|
||||||
|
"Zira", // Windows
|
||||||
|
"Mark", // macOS male
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickVoice(lang: string): SpeechSynthesisVoice | null {
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = lang.startsWith("zh") ? PREFERRED_ZH : PREFERRED_EN;
|
||||||
|
|
||||||
|
for (const name of candidates) {
|
||||||
|
const match = voices.find(
|
||||||
|
(v) => v.lang.startsWith(lang.split("-")[0]) && v.name.includes(name)
|
||||||
|
);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: any voice matching the language prefix
|
||||||
|
const langFallback = voices.find((v) => v.lang.startsWith(lang.split("-")[0]));
|
||||||
|
if (langFallback) return langFallback;
|
||||||
|
|
||||||
|
// Last resort: first available voice
|
||||||
|
return voices[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeech() {
|
||||||
|
const [voicesReady, setVoicesReady] = useState(false);
|
||||||
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
|
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||||
|
|
||||||
|
const isSupported = typeof window !== "undefined" && "speechSynthesis" in window;
|
||||||
|
|
||||||
|
// Voices load asynchronously on some browsers (Chrome). On others (Safari)
|
||||||
|
// they're available immediately. Cover both.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
setVoicesReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onVoicesChanged = () => setVoicesReady(true);
|
||||||
|
window.speechSynthesis.addEventListener("voiceschanged", onVoicesChanged);
|
||||||
|
return () =>
|
||||||
|
window.speechSynthesis.removeEventListener("voiceschanged", onVoicesChanged);
|
||||||
|
}, [isSupported]);
|
||||||
|
|
||||||
|
const speak = useCallback(
|
||||||
|
(text: string, lang: string = "zh-CN") => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utterance.lang = lang;
|
||||||
|
utterance.rate = 0.82;
|
||||||
|
utterance.pitch = 0.95;
|
||||||
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
|
const voice = pickVoice(lang);
|
||||||
|
if (voice) utterance.voice = voice;
|
||||||
|
|
||||||
|
utterance.onstart = () => setIsSpeaking(true);
|
||||||
|
utterance.onend = () => setIsSpeaking(false);
|
||||||
|
utterance.onerror = () => setIsSpeaking(false);
|
||||||
|
|
||||||
|
utteranceRef.current = utterance;
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
},
|
||||||
|
[isSupported]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
setIsSpeaking(false);
|
||||||
|
}, [isSupported]);
|
||||||
|
|
||||||
|
return { speak, stop, isSpeaking, isSupported, voicesReady };
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type EventName = "session_completed" | "task_generated" | "onboarding_completed";
|
||||||
|
|
||||||
|
export async function logEvent(
|
||||||
|
userId: string,
|
||||||
|
event: EventName,
|
||||||
|
metadata: Record<string, unknown> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.analyticsEvent.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
event,
|
||||||
|
metadata: JSON.stringify(metadata),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[analytics] Failed to log event:", event, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Audio playback using pre-generated files + server-side TTS API.
|
||||||
|
// All functions silently no-op when audio playback fails.
|
||||||
|
|
||||||
|
function playFile(path: string) {
|
||||||
|
try {
|
||||||
|
const audio = new Audio(path);
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playStartChime() {
|
||||||
|
playFile("/audio/start-chime.wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTick() {
|
||||||
|
playFile("/audio/tick.wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playEndChime() {
|
||||||
|
playFile("/audio/end-chime.wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playMidGuidance(lang: string) {
|
||||||
|
const file = lang === "zh" ? "mid-guidance-zh.mp3" : "mid-guidance-en.mp3";
|
||||||
|
playFile(`/audio/${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playEndGuidance(lang: string) {
|
||||||
|
const file = lang === "zh" ? "end-guidance-zh.mp3" : "end-guidance-en.mp3";
|
||||||
|
playFile(`/audio/${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic TTS via server-side Edge TTS (neural voices) API.
|
||||||
|
// Falls back silently on error (network issue, edge-tts not installed, etc.).
|
||||||
|
export async function speakText(text: string, lang: string = "zh"): Promise<void> {
|
||||||
|
try {
|
||||||
|
const url = `/api/audio/tts?text=${encodeURIComponent(text)}&lang=${lang}&rate=-40%25`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const audio = new Audio(blobUrl);
|
||||||
|
audio.onended = () => { URL.revokeObjectURL(blobUrl); resolve(); };
|
||||||
|
audio.onerror = () => { URL.revokeObjectURL(blobUrl); resolve(); };
|
||||||
|
audio.play().catch(() => { URL.revokeObjectURL(blobUrl); resolve(); });
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -38,6 +38,20 @@ export function computeStage(
|
|||||||
return { stage, advanced: stage > 0 };
|
return { stage, advanced: stage > 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timezone helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getUserToday(timezone: string): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toLocaleDateString("en-CA", { timeZone: timezone });
|
||||||
|
return new Date(dateStr + "T00:00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateToLocaleDateStr(date: Date, timezone: string): string {
|
||||||
|
return date.toLocaleDateString("en-CA", { timeZone: timezone });
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Streak computation
|
// Streak computation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -69,16 +83,13 @@ export function computeStreak(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (diffDays === 0) {
|
if (diffDays === 0) {
|
||||||
// Already completed today — maintain current streak
|
|
||||||
return { streakDays: previousStreak, streakBroken: false, lastSessionDate };
|
return { streakDays: previousStreak, streakBroken: false, lastSessionDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffDays === 1) {
|
if (diffDays === 1) {
|
||||||
// Yesterday — streak continues
|
|
||||||
return { streakDays: previousStreak + 1, streakBroken: false, lastSessionDate };
|
return { streakDays: previousStreak + 1, streakBroken: false, lastSessionDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
// More than 1 day gap — streak broken
|
|
||||||
return { streakDays: 1, streakBroken: true, lastSessionDate };
|
return { streakDays: 1, streakBroken: true, lastSessionDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,23 +99,22 @@ export function computeStreak(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Consistency score (0-100) reflects how regularly the user practices.
|
* Consistency score (0-100) reflects how regularly the user practices.
|
||||||
* - Each day with a session in the last 14 days contributes ~7.14 points
|
* Uses user-local dates to count unique practice days in the lookback window.
|
||||||
* - Capped at 100
|
|
||||||
*/
|
*/
|
||||||
export function computeConsistency(
|
export function computeConsistency(
|
||||||
sessionDates: Date[],
|
sessionDates: Date[],
|
||||||
|
timezone: string,
|
||||||
lookbackDays: number = 14
|
lookbackDays: number = 14
|
||||||
): number {
|
): number {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const cutoff = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
|
const todayStr = dateToLocaleDateStr(now, timezone);
|
||||||
|
const todayLocal = new Date(todayStr + "T00:00:00");
|
||||||
|
const cutoff = new Date(todayLocal.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const recentDates = new Set(
|
const recentDates = new Set(
|
||||||
sessionDates
|
sessionDates
|
||||||
.filter((d) => d >= cutoff)
|
.filter((d) => d >= cutoff)
|
||||||
.map((d) => {
|
.map((d) => dateToLocaleDateStr(d, timezone))
|
||||||
const local = new Date(d);
|
|
||||||
return `${local.getFullYear()}-${local.getMonth()}-${local.getDate()}`;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Math.min(Math.round((recentDates.size / lookbackDays) * 100), 100);
|
return Math.min(Math.round((recentDates.size / lookbackDays) * 100), 100);
|
||||||
@@ -136,7 +146,6 @@ export function computeStateUpdate(
|
|||||||
recentSessionDates: Date[],
|
recentSessionDates: Date[],
|
||||||
now: Date = new Date()
|
now: Date = new Date()
|
||||||
): StateUpdate {
|
): StateUpdate {
|
||||||
// Apply deltas with clamping
|
|
||||||
const attentionLevel = Math.max(
|
const attentionLevel = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(100, currentState.attentionLevel + deltas.attentionDelta)
|
Math.min(100, currentState.attentionLevel + deltas.attentionDelta)
|
||||||
@@ -150,22 +159,21 @@ export function computeStateUpdate(
|
|||||||
Math.min(100, currentState.stressLevel + deltas.stressDelta)
|
Math.min(100, currentState.stressLevel + deltas.stressDelta)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute streak
|
|
||||||
const streak = computeStreak(
|
const streak = computeStreak(
|
||||||
currentState.lastSessionDate,
|
currentState.lastSessionDate,
|
||||||
currentState.streakDays,
|
currentState.streakDays,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute consistency
|
const consistencyScore = computeConsistency(
|
||||||
const consistencyScore = computeConsistency(recentSessionDates);
|
recentSessionDates,
|
||||||
|
currentState.timezone || "Asia/Shanghai"
|
||||||
|
);
|
||||||
|
|
||||||
// Compute stage
|
|
||||||
const totalSessionsCompleted = currentState.totalSessionsCompleted + 1;
|
const totalSessionsCompleted = currentState.totalSessionsCompleted + 1;
|
||||||
const newStage = computeStage(totalSessionsCompleted, consistencyScore);
|
const newStage = computeStage(totalSessionsCompleted, consistencyScore);
|
||||||
const stageAdvanced = newStage.stage > currentState.practiceStage;
|
const stageAdvanced = newStage.stage > currentState.practiceStage;
|
||||||
|
|
||||||
// Check streak milestones (3, 7, 14, 30)
|
|
||||||
const streakMilestone =
|
const streakMilestone =
|
||||||
streak.streakDays > 0 &&
|
streak.streakDays > 0 &&
|
||||||
[3, 7, 14, 30].includes(streak.streakDays) &&
|
[3, 7, 14, 30].includes(streak.streakDays) &&
|
||||||
|
|||||||
@@ -82,10 +82,12 @@ Score calibration:
|
|||||||
- A reflection like "it was fine" with no detail → focus_score 2-3, emotion_score 2-3
|
- A reflection like "it was fine" with no detail → focus_score 2-3, emotion_score 2-3
|
||||||
- A reflection with specific observations and genuine curiosity → focus_score 4-5, emotion_score 4-5
|
- A reflection with specific observations and genuine curiosity → focus_score 4-5, emotion_score 4-5
|
||||||
- A reflection mentioning frustration or struggle → lower focus but don't punish emotion if they're honest about it
|
- A reflection mentioning frustration or struggle → lower focus but don't punish emotion if they're honest about it
|
||||||
- A completely empty or one-word reflection → focus_score 1, emotion_score 1
|
- A completely empty or one-word reflection → focus_score 3, emotion_score 3
|
||||||
- A reflection that shows they really tried but found it hard → focus_score 3-4, emotion_score 3-4 (effort counts)
|
- A reflection that shows they really tried but found it hard → focus_score 3-4, emotion_score 3-4 (effort counts)
|
||||||
|
|
||||||
Be generous but honest. These scores guide their development path.`;
|
Be generous but honest. These scores guide their development path.
|
||||||
|
|
||||||
|
IMPORTANT: The user interface is Chinese-only. The insight MUST be written in Simplified Chinese (简体中文). Use "你" (not "you").`;
|
||||||
|
|
||||||
export async function analyzeReflection(
|
export async function analyzeReflection(
|
||||||
input: ReflectionInput,
|
input: ReflectionInput,
|
||||||
@@ -120,25 +122,9 @@ export async function analyzeReflection(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response_format: {
|
response_format: { type: "json_object" },
|
||||||
type: "json_schema",
|
|
||||||
json_schema: {
|
|
||||||
name: "reflection",
|
|
||||||
strict: true,
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
focus_score: { type: "number", minimum: 1, maximum: 5 },
|
|
||||||
emotion_score: { type: "number", minimum: 1, maximum: 5 },
|
|
||||||
insight: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["focus_score", "emotion_score", "insight"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
max_tokens: 200,
|
max_tokens: 800,
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw = response.choices[0]?.message?.content;
|
const raw = response.choices[0]?.message?.content;
|
||||||
|
|||||||
@@ -23,56 +23,56 @@ interface RewardContext {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const FIRST_SESSION_MESSAGES = [
|
const FIRST_SESSION_MESSAGES = [
|
||||||
"Your first practice is complete. This is where the path begins — not with a destination, but with a single step.",
|
"第一次练习完成了。这条路始于足下,不在于终点。",
|
||||||
"Day one. You didn't just think about it — you did it. That's the difference that makes all the difference.",
|
"第一天。你不是只想了想,你做到了。这就是一切的区别。",
|
||||||
"Welcome to your practice. The door is open now. Walk through it again tomorrow.",
|
"欢迎开始你的练习。门已经打开了。明天,再走进来。",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STREAK_3_MESSAGES = [
|
const STREAK_3_MESSAGES = [
|
||||||
"Three days in a row. A pattern is forming. Your mind is starting to expect this — that's the habit taking root.",
|
"连续三天。一个模式正在形成。你的心智开始期待这一刻——习惯正在扎根。",
|
||||||
"Day three. The first two days were willpower. Today is the beginning of momentum.",
|
"第三天。前两次靠的是意志力。今天,是势头的开始。",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STREAK_7_MESSAGES = [
|
const STREAK_7_MESSAGES = [
|
||||||
"Seven days. A full week of showing up for yourself. Most people never make it this far. You did.",
|
"七天。一整周都在为自己出现。大多数人从未走到这么远。你做到了。",
|
||||||
"One week of daily practice. Your brain is literally rewiring. Neuroplasticity isn't a metaphor — it's what's happening right now.",
|
"一周的每日练习。你的大脑正在实实在在地重塑。神经可塑性不是比喻——它正在发生。",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STREAK_14_MESSAGES = [
|
const STREAK_14_MESSAGES = [
|
||||||
"Two weeks. Research says it takes 21 days to form a habit — you're two-thirds there, and it already feels different, doesn't it?",
|
"两周。研究表明形成习惯需要21天——你已经走了三分之二,感觉已经不一样了,不是吗?",
|
||||||
"Fourteen consecutive days. You're not 'trying to meditate' anymore. You're someone who practices. The identity has shifted.",
|
"连续十四天。你不再在「尝试冥想」。你是一个在练习的人。身份已经转变。",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STREAK_30_MESSAGES = [
|
const STREAK_30_MESSAGES = [
|
||||||
"Thirty days. A month of daily practice. Whatever you were before you started — anxious, scattered, reactive — you're becoming someone else now. Someone who can watch their own mind and choose.",
|
"三十天。一个月的每日练习。无论你开始前是什么样子——焦虑、散乱、反应——你正在变成另一个人。一个能观察自己的心并做出选择的人。",
|
||||||
"One month. This isn't a streak anymore. It's a practice. It's part of who you are. The question is no longer 'will I practice today?' but 'when?'",
|
"一个月。这不再是一个连续记录。这是一项练习。它是你的一部分。问题不再是「我今天要不要练习?」,而是「什么时候?」",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STAGE_UP_MESSAGES: Record<number, string[]> = {
|
const STAGE_UP_MESSAGES: Record<number, string[]> = {
|
||||||
1: [
|
1: [
|
||||||
"You've reached the Foundation stage. The basics are in place. Now we build depth — longer sessions, subtler awareness.",
|
"你已经进入基础阶段。基本功已经到位。现在开始深入——更长的练习,更细微的觉察。",
|
||||||
"Stage up: Foundation. You've moved from trying to training. The practice is becoming familiar territory.",
|
"阶段提升:基础。你从尝试进入了训练。练习正在成为熟悉的领域。",
|
||||||
],
|
],
|
||||||
2: [
|
2: [
|
||||||
"Growing stage reached. Your consistency is solid and your awareness is sharpening. This is where practice starts to change how you move through the world.",
|
"成长阶段已到。你的坚持是稳固的,你的觉知正在敏锐。这就是练习开始改变你如何在这个世界上行走的地方。",
|
||||||
"Stage up: Growing. You're past the awkward beginner phase. Your sessions have texture and depth now.",
|
"阶段提升:成长。你度过了笨拙的初学者阶段。你的练习现在有了质感和深度。",
|
||||||
],
|
],
|
||||||
3: [
|
3: [
|
||||||
"Established. Fifteen sessions and strong consistency. Your inner world is becoming legible to you. This is emotional literacy in action.",
|
"稳固阶段。十五次练习和强劲的坚持度。你的内心世界正在变得清晰可读。这就是情绪素养在行动。",
|
||||||
"Stage up: Established. Your practice has roots. What started as a daily exercise is becoming a way of being.",
|
"阶段提升:稳固。你的练习扎下了根。从每天的练习开始,逐渐成为一种存在方式。",
|
||||||
],
|
],
|
||||||
4: [
|
4: [
|
||||||
"Advanced. This is rare territory. Most people never develop this level of inner awareness. Your practice now informs everything you do.",
|
"进阶。这是稀有的领域。大多数人从未发展出这个层次的内心觉知。你的练习现在渗透到你做的每一件事中。",
|
||||||
"Stage up: Advanced. You're not just practicing awareness — you're living it. The boundary between 'practice' and 'life' is dissolving.",
|
"阶段提升:进阶。你不只是在练习觉知——你在活出它。「练习」和「生活」之间的界限正在消融。",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const GENERIC_COMPLETION_MESSAGES = [
|
const GENERIC_COMPLETION_MESSAGES = [
|
||||||
"Done. You showed up, you practiced, you reflected. That's the whole game — and you're playing it.",
|
"完成。你出现了,你练习了,你反思了。这就是全部——而你在做着。",
|
||||||
"Practice complete. Notice how you feel right now compared to before. That shift is the point.",
|
"练习完成。留意你现在的感受和之前的区别。那个转变就是意义所在。",
|
||||||
"Another session in the books. Not every practice will feel profound — but every practice counts.",
|
"又一次练习记录在册。不是每一次练习都会感觉深刻——但每一次练习都很重要。",
|
||||||
"You did the work today. Not tomorrow, not 'when things calm down' — today. That's discipline, and discipline is self-love.",
|
"你今天完成了功课。不是明天,不是「等事情平静下来的时候」——是今天。这就是自律,自律就是自爱。",
|
||||||
"Session finished. Your future self — the one with more clarity, more calm, more choice — thanks you.",
|
"练习结束。你未来的自己——那个更清晰、更平静、更有选择权的自己——在感谢你。",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ Voice rules:
|
|||||||
- Be honest. If it was a light week with only 1-2 sessions, acknowledge that without judgment.
|
- Be honest. If it was a light week with only 1-2 sessions, acknowledge that without judgment.
|
||||||
- Never diagnose, never claim medical benefits, never use self-help clichés.
|
- Never diagnose, never claim medical benefits, never use self-help clichés.
|
||||||
- Write in second person ("you").
|
- Write in second person ("you").
|
||||||
- Keep it to 3-4 paragraphs, 200-300 words total.`;
|
- Keep it to 3-4 paragraphs, 200-300 words total.
|
||||||
|
|
||||||
|
IMPORTANT: The user interface is Chinese-only. Write the entire summary in Simplified Chinese (简体中文). Use "你" (not "you").`;
|
||||||
|
|
||||||
export async function generateWeeklySummary(
|
export async function generateWeeklySummary(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -123,7 +125,7 @@ export async function generateWeeklySummary(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 500,
|
max_tokens: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary = response.choices[0]?.message?.content?.trim();
|
const summary = response.choices[0]?.message?.content?.trim();
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ Principles:
|
|||||||
- If the user has done the same type 2+ days in a row, vary it
|
- If the user has done the same type 2+ days in a row, vary it
|
||||||
- Match difficulty to practice_stage (0-1 → diff 1, 2-3 → diff 2, 4+ → diff 3)
|
- Match difficulty to practice_stage (0-1 → diff 1, 2-3 → diff 2, 4+ → diff 3)
|
||||||
- Every few days, include an integration task to connect practice to daily life
|
- Every few days, include an integration task to connect practice to daily life
|
||||||
- Be specific and concrete — never generic platitudes`
|
- Be specific and concrete — never generic platitudes
|
||||||
|
|
||||||
|
IMPORTANT: The user interface is Chinese-only. All user-facing text (description, reason) MUST be written in Simplified Chinese (简体中文). The type field must remain the English enum value.`
|
||||||
|
|
||||||
interface LLMTaskResponse {
|
interface LLMTaskResponse {
|
||||||
type: TaskType;
|
type: TaskType;
|
||||||
@@ -140,36 +142,9 @@ export async function generateTask(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response_format: {
|
response_format: { type: "json_object" },
|
||||||
type: "json_schema",
|
|
||||||
json_schema: {
|
|
||||||
name: "task",
|
|
||||||
strict: true,
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
type: {
|
|
||||||
type: "string",
|
|
||||||
enum: [
|
|
||||||
"attention_training",
|
|
||||||
"emotion_awareness",
|
|
||||||
"stress_regulation",
|
|
||||||
"cognitive_clarity",
|
|
||||||
"integration",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
difficulty: { type: "number", minimum: 1, maximum: 3 },
|
|
||||||
duration: { type: "number", minimum: 1, maximum: 15 },
|
|
||||||
description: { type: "string" },
|
|
||||||
reason: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["type", "difficulty", "duration", "description", "reason"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 400,
|
max_tokens: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw = response.choices[0]?.message?.content;
|
const raw = response.choices[0]?.message?.content;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Light-based mindfulness meditation guide — continuous, flowing narration.
|
||||||
|
// Designed for edge-tts zh-CN-XiaoxiaoNeural (warm, mature female) at -40% rate (~2 chars/sec).
|
||||||
|
// Covers ~98% of session time with near-continuous speech.
|
||||||
|
|
||||||
|
interface Prompt {
|
||||||
|
at: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPTS: Prompt[] = [
|
||||||
|
// ── Arrival (0–14%) ──
|
||||||
|
{
|
||||||
|
at: 0.00,
|
||||||
|
text:
|
||||||
|
"找一个舒适的姿势坐好,轻轻地闭上眼睛。双手自然地放在膝盖上,脊柱挺直但不僵硬。" +
|
||||||
|
"做一次深长的吸气,感受空气缓缓流入胸腔和腹部。然后缓慢地呼出去,把紧张都呼出体外。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.11,
|
||||||
|
text:
|
||||||
|
"再做两次深呼吸。吸气时,吸进平静和安宁。呼气时,呼出焦虑和杂念。" +
|
||||||
|
"不需要刻意控制,只是观察。吸气时知道自己在吸气,呼气时知道自己在呼气。把注意力完全放在当下。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.20,
|
||||||
|
text:
|
||||||
|
"很好。把注意力从呼吸上移开。在你的内心深处,有一片宁静的空间。" +
|
||||||
|
"那里没有压力,没有必须完成的事。只有你自己,最真实、最纯粹的自己。此刻,你就是完整的。",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Light appears (22–34%) ──
|
||||||
|
{
|
||||||
|
at: 0.28,
|
||||||
|
text:
|
||||||
|
"现在,在头顶上方大约三十厘米的地方,有一团温暖的白光。" +
|
||||||
|
"它就像一个温柔的太阳,纯净、柔和、温暖,包含了宇宙间所有的善意与平静。它在那里等待着你,从不催促,从不评判。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.36,
|
||||||
|
text:
|
||||||
|
"每一次吸气,这团光就变得更加明亮。每一次呼气,这团光就变得更加温暖。" +
|
||||||
|
"它不是刺眼的光,而是柔和的光芒,就像冬日清晨的第一缕阳光。你能感受到它的温度,感受到它想要与你连接的愿望。",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Light body scan — head to heart (38–56%) ──
|
||||||
|
{
|
||||||
|
at: 0.43,
|
||||||
|
text:
|
||||||
|
"让这团光慢慢地降下来,轻轻触碰你的头顶,就像母亲的手温柔地抚摸着你。" +
|
||||||
|
"光缓缓流进你的头部,放松每一寸皮肤。纷乱的念头像云雾一样慢慢散开。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.49,
|
||||||
|
text:
|
||||||
|
"光继续向下,来到你的额头和眉心。让光的温暖融化这里的紧绷。" +
|
||||||
|
"光流过你的双眼和脸颊,带走疲劳,松开不知不觉咬紧的牙关。整个面部都在光中放松了。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.55,
|
||||||
|
text:
|
||||||
|
"光流进脖子和肩膀。脖子连接着身体与心灵,肩膀承受着生活的压力。" +
|
||||||
|
"让光温柔地包裹它们,紧绷的肌肉像冰块在阳光下慢慢融化。所有的僵硬都在光中消散。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.61,
|
||||||
|
text:
|
||||||
|
"光从肩膀向下,流过手臂,一直到手指尖。同时,光充满你的胸腔,包围着心脏。" +
|
||||||
|
"你感受到一种温暖的悸动,那是生命的节奏。在这光中,心跳平稳而有力,不急不缓。",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Light body scan — core to feet (65–74%) ──
|
||||||
|
{
|
||||||
|
at: 0.67,
|
||||||
|
text:
|
||||||
|
"光继续向下,流过腹部,流过脊柱。腹部的器官在温暖中放松。" +
|
||||||
|
"脊柱一节一节地被光滋养,变得更加柔韧。一切都是那么自然,那么和谐。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.73,
|
||||||
|
text:
|
||||||
|
"光流进臀部和大腿。它们承载着你每天行走的重量,现在让光的温暖来回报它们。" +
|
||||||
|
"光流过膝盖、小腿、脚踝,一直到脚趾尖。所有的沉重都在光中消散。双腿变得轻盈而有力。",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Full immersion (76–86%) ──
|
||||||
|
{
|
||||||
|
at: 0.78,
|
||||||
|
text:
|
||||||
|
"现在,你的整个身体,从头顶到脚趾,都被这团温暖的白光充满了。" +
|
||||||
|
"每一个细胞都在发光。光也在身体外部形成柔和的光晕。你坐在一个温暖的光茧之中,安全、宁静、完整。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.84,
|
||||||
|
text:
|
||||||
|
"在这光中,你就是平静本身。不是在寻找,不是追求,你就是平静。" +
|
||||||
|
"所有的念头和感受,都像天空中的云,来了又走。而你是那片天空,广阔无垠,深邃宁静。",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Closing (88–99%) ──
|
||||||
|
{
|
||||||
|
at: 0.89,
|
||||||
|
text:
|
||||||
|
"这份平静,这份光,并不只存在于练习中。它一直在你的内心。" +
|
||||||
|
"无论今天要面对什么,它都会陪伴着你。任何时候闭上眼睛,做一次深呼吸,就能重新连接。平静就在这里。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.94,
|
||||||
|
text:
|
||||||
|
"现在,温柔地回到当下的空间。轻轻动一动手指和脚趾,转动一下脖子。" +
|
||||||
|
"感受椅子支撑着你的重量。你是踏实的,你是安稳的。带着这份光和这份平静,回到你所在的空间。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
at: 0.99,
|
||||||
|
text:
|
||||||
|
"这次练习结束了,但这份觉察可以一直延续。当你准备好了,慢慢地、温柔地睁开眼睛。欢迎回来。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getMeditationPrompts(totalSeconds: number): { atSecond: number; text: string }[] {
|
||||||
|
return PROMPTS.map((p) => ({
|
||||||
|
atSecond: Math.round(p.at * totalSeconds),
|
||||||
|
text: p.text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
+10
-2
@@ -8,6 +8,10 @@ const PUBLIC_PATHS = [
|
|||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/cron/weekly-summaries",
|
"/api/cron/weekly-summaries",
|
||||||
|
"/api/cron/reminders",
|
||||||
|
"/api/audio/tts",
|
||||||
|
"/audio",
|
||||||
|
"/internal",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isPublic(pathname: string): boolean {
|
function isPublic(pathname: string): boolean {
|
||||||
@@ -17,8 +21,12 @@ function isPublic(pathname: string): boolean {
|
|||||||
export default async function middleware(req: NextRequest) {
|
export default async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// Set pathname header for layout to use
|
||||||
|
const requestHeaders = new Headers(req.headers);
|
||||||
|
requestHeaders.set("x-pathname", pathname);
|
||||||
|
|
||||||
if (isPublic(pathname)) {
|
if (isPublic(pathname)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = req.cookies.get("inneros-auth")?.value;
|
const token = req.cookies.get("inneros-auth")?.value;
|
||||||
@@ -33,7 +41,7 @@ export default async function middleware(req: NextRequest) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
Reference in New Issue
Block a user