Full-stack inner development operating system built on Next.js 16 + Prisma 7 + PostgreSQL + OpenAI + Clerk. Generates personalized daily mental training tasks, collects reflection, updates user state, and provides internal reward feedback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
186 lines
5.3 KiB
TypeScript
186 lines
5.3 KiB
TypeScript
import type { UserState } from "@/generated/prisma/client";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T3: Stage advancement rules
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Practice stages:
|
|
* 0 — Beginner (just started, < 5 sessions)
|
|
* 1 — Foundation (5-14 sessions, building habit)
|
|
* 2 — Growing (15-29 sessions, consistent practice)
|
|
* 3 — Established (30-59 sessions, deepening)
|
|
* 4 — Advanced (60+ sessions, integration focus)
|
|
*/
|
|
|
|
const STAGE_THRESHOLDS: Record<number, { sessions: number; consistency: number }> = {
|
|
0: { sessions: 0, consistency: 0 },
|
|
1: { sessions: 5, consistency: 20 },
|
|
2: { sessions: 15, consistency: 35 },
|
|
3: { sessions: 30, consistency: 50 },
|
|
4: { sessions: 60, consistency: 65 },
|
|
};
|
|
|
|
export function computeStage(
|
|
totalSessions: number,
|
|
consistencyScore: number
|
|
): { stage: number; advanced: boolean } {
|
|
let stage = 0;
|
|
for (const [s, threshold] of Object.entries(STAGE_THRESHOLDS)) {
|
|
const stageNum = parseInt(s);
|
|
if (
|
|
totalSessions >= threshold.sessions &&
|
|
consistencyScore >= threshold.consistency
|
|
) {
|
|
stage = Math.max(stage, stageNum);
|
|
}
|
|
}
|
|
return { stage, advanced: stage > 0 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Streak computation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface StreakResult {
|
|
streakDays: number;
|
|
streakBroken: boolean;
|
|
lastSessionDate: Date | null;
|
|
}
|
|
|
|
export function computeStreak(
|
|
lastSessionDate: Date | null,
|
|
previousStreak: number,
|
|
now: Date = new Date()
|
|
): StreakResult {
|
|
if (!lastSessionDate) {
|
|
return { streakDays: 0, streakBroken: false, lastSessionDate: null };
|
|
}
|
|
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const lastDate = new Date(
|
|
lastSessionDate.getFullYear(),
|
|
lastSessionDate.getMonth(),
|
|
lastSessionDate.getDate()
|
|
);
|
|
|
|
const diffDays = Math.floor(
|
|
(today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
);
|
|
|
|
if (diffDays === 0) {
|
|
// Already completed today — maintain current streak
|
|
return { streakDays: previousStreak, streakBroken: false, lastSessionDate };
|
|
}
|
|
|
|
if (diffDays === 1) {
|
|
// Yesterday — streak continues
|
|
return { streakDays: previousStreak + 1, streakBroken: false, lastSessionDate };
|
|
}
|
|
|
|
// More than 1 day gap — streak broken
|
|
return { streakDays: 1, streakBroken: true, lastSessionDate };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Consistency score
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Consistency score (0-100) reflects how regularly the user practices.
|
|
* - Each day with a session in the last 14 days contributes ~7.14 points
|
|
* - Capped at 100
|
|
*/
|
|
export function computeConsistency(
|
|
sessionDates: Date[],
|
|
lookbackDays: number = 14
|
|
): number {
|
|
const now = new Date();
|
|
const cutoff = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
|
|
|
|
const recentDates = new Set(
|
|
sessionDates
|
|
.filter((d) => d >= cutoff)
|
|
.map((d) => {
|
|
const local = new Date(d);
|
|
return `${local.getFullYear()}-${local.getMonth()}-${local.getDate()}`;
|
|
})
|
|
);
|
|
|
|
return Math.min(Math.round((recentDates.size / lookbackDays) * 100), 100);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full state update after session completion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface StateUpdate {
|
|
attentionLevel: number;
|
|
emotionStability: number;
|
|
stressLevel: number;
|
|
practiceStage: number;
|
|
consistencyScore: number;
|
|
streakDays: number;
|
|
totalSessionsCompleted: number;
|
|
stageAdvanced: boolean;
|
|
streakMilestone: boolean;
|
|
}
|
|
|
|
export function computeStateUpdate(
|
|
currentState: UserState,
|
|
deltas: {
|
|
attentionDelta: number;
|
|
emotionDelta: number;
|
|
stressDelta: number;
|
|
},
|
|
recentSessionDates: Date[],
|
|
now: Date = new Date()
|
|
): StateUpdate {
|
|
// Apply deltas with clamping
|
|
const attentionLevel = Math.max(
|
|
0,
|
|
Math.min(100, currentState.attentionLevel + deltas.attentionDelta)
|
|
);
|
|
const emotionStability = Math.max(
|
|
0,
|
|
Math.min(100, currentState.emotionStability + deltas.emotionDelta)
|
|
);
|
|
const stressLevel = Math.max(
|
|
0,
|
|
Math.min(100, currentState.stressLevel + deltas.stressDelta)
|
|
);
|
|
|
|
// Compute streak
|
|
const streak = computeStreak(
|
|
currentState.lastSessionDate,
|
|
currentState.streakDays,
|
|
now
|
|
);
|
|
|
|
// Compute consistency
|
|
const consistencyScore = computeConsistency(recentSessionDates);
|
|
|
|
// Compute stage
|
|
const totalSessionsCompleted = currentState.totalSessionsCompleted + 1;
|
|
const newStage = computeStage(totalSessionsCompleted, consistencyScore);
|
|
const stageAdvanced = newStage.stage > currentState.practiceStage;
|
|
|
|
// Check streak milestones (3, 7, 14, 30)
|
|
const streakMilestone =
|
|
streak.streakDays > 0 &&
|
|
[3, 7, 14, 30].includes(streak.streakDays) &&
|
|
streak.streakDays > currentState.streakDays;
|
|
|
|
return {
|
|
attentionLevel,
|
|
emotionStability,
|
|
stressLevel,
|
|
practiceStage: newStage.stage,
|
|
consistencyScore,
|
|
streakDays: streak.streakDays,
|
|
totalSessionsCompleted,
|
|
stageAdvanced,
|
|
streakMilestone,
|
|
};
|
|
}
|