From 7d7af8f3e946d9e9cd2552a8f943a4b73251a509 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:12:08 +0000 Subject: [PATCH 1/2] feat(coach): goals, habits, check-ins, daily briefs and check-in rhythm The main agent should coach the user, but nothing stored what the user wants to achieve or keep doing, and no routine ever checked in. This adds the data model and the interfaces the onboarding intake and the coach tools build on. - Schema: UserGoal, Habit, CheckIn (one per habit, goal or day and date) and DailyBrief, all keyed to the user's local calendar date, plus a coachKey on Routine that marks the main bot's coach check-ins. - Contracts (coach.ts): CoachRhythm, Goal, Habit, CheckIn, DailyBrief and CoachToday DTOs and the RPC inputs. - Repository (@rakazo/db coach.ts): goal, habit and check-in CRUD with per-user limits, upsertDailyBrief and loadCoachToday with streaks in the user's time zone. - coach-rhythm.ts: setCoachRhythm/getCoachRhythm keep up to three main bot routines (morning brief, evening check-in, weekly review) in the main conversation in step with the user's chosen times. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/coach-rhythm.ts | 145 ++++ packages/adapters/src/index.ts | 1 + packages/contracts/src/coach.ts | 212 ++++++ packages/contracts/src/index.ts | 1 + packages/core/src/coach.ts | 99 +++ packages/core/src/index.ts | 1 + .../migration.sql | 144 ++++ .../migration.sql | 3 + packages/db/prisma/schema.prisma | 103 +++ packages/db/src/coach.ts | 634 ++++++++++++++++++ packages/db/src/index.ts | 1 + 11 files changed, 1344 insertions(+) create mode 100644 packages/adapters/src/coach-rhythm.ts create mode 100644 packages/contracts/src/coach.ts create mode 100644 packages/core/src/coach.ts create mode 100644 packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql create mode 100644 packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql create mode 100644 packages/db/src/coach.ts diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts new file mode 100644 index 0000000..f9ee580 --- /dev/null +++ b/packages/adapters/src/coach-rhythm.ts @@ -0,0 +1,145 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; +import type { CoachRhythm } from "@rakazo/contracts"; +import { COACH_RHYTHM_OFF } from "@rakazo/contracts"; +import { nextCronDate } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; + +export const COACH_KEYS = ["morning", "evening", "weekly"] as const; +export type CoachKey = (typeof COACH_KEYS)[number]; + +/** + * The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when + * a check-in fires. Product text in English: the main bot answers in the user's language, and + * its instructions name the coach tools it has. + */ +export const COACH_ROUTINES: Record = { + morning: { + name: "Morning brief", + prompt: + "Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.", + }, + evening: { + name: "Evening check-in", + prompt: + "Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.", + }, + weekly: { + name: "Weekly review", + prompt: + "Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.", + }, +}; + +export interface CoachRhythmDeps { + prisma: PrismaClient; + jobs: JobPublisher; +} + +/** The cron for one check-in, or null when it is off. */ +export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null { + const slot = key === "weekly" ? rhythm.weekly : rhythm[key]; + if (!slot) return null; + const time = typeof slot === "string" ? slot : slot.time; + const [hour, minute] = time.split(":").map(Number); + return `${minute} ${hour} * * ${typeof slot === "string" ? "*" : slot.day}`; +} + +const pad = (value: string) => value.padStart(2, "0"); + +/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */ +function rhythmSlot(key: CoachKey, crons: readonly string[]) { + const match = + crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null; + if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null; + const time = `${pad(match[2]!)}:${pad(match[1]!)}`; + if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time }; + return match[3] === "*" ? time : null; +} + +function isCoachKey(value: string | null): value is CoachKey { + return (COACH_KEYS as readonly string[]).includes(value ?? ""); +} + +/** The user's check-in times, read from the main bot's active coach routines. */ +export async function getCoachRhythm(prisma: PrismaClient, userId: string): Promise { + const botId = await findMainBotId(prisma, userId); + if (!botId) return { ...COACH_RHYTHM_OFF }; + const routines = await prisma.routine.findMany({ + where: { botId, userId, active: true, coachKey: { not: null } }, + select: { coachKey: true, crons: true }, + }); + const rhythm: CoachRhythm = { ...COACH_RHYTHM_OFF }; + for (const routine of routines) { + if (!isCoachKey(routine.coachKey)) continue; + const slot = rhythmSlot(routine.coachKey, routine.crons); + if (routine.coachKey === "weekly") rhythm.weekly = slot as CoachRhythm["weekly"]; + else rhythm[routine.coachKey] = slot as string | null; + } + return rhythm; +} + +/** + * Makes the main bot's coach routines match `rhythm`: one routine per check-in that is on, + * waking in the main conversation in the user's time zone. Check-ins that are off are deleted. + * Calling it again with the same rhythm changes nothing. + */ +export async function setCoachRhythm( + deps: CoachRhythmDeps, + input: { userId: string; rhythm: CoachRhythm }, +): Promise { + const { prisma, jobs } = deps; + const { userId, rhythm } = input; + const anyOn = COACH_KEYS.some((key) => coachCron(key, rhythm) !== null); + const conversation = anyOn ? await ensureMainConversation(prisma, userId) : null; + const botId = conversation?.leadBotId ?? (await findMainBotId(prisma, userId)); + if (!botId) return { ...COACH_RHYTHM_OFF }; + const existing = await prisma.routine.findMany({ + where: { botId, userId, coachKey: { not: null } }, + }); + const timezone = anyOn ? await coachTimeZone(prisma, userId) : "UTC"; + for (const key of COACH_KEYS) { + const cron = coachCron(key, rhythm); + const current = existing.find((routine) => routine.coachKey === key); + if (!cron || !conversation) { + if (current) { + await prisma.routine.deleteMany({ where: { id: current.id } }); + await jobs.cancel(routineJobKey(current.id)); + } + continue; + } + const { name, prompt } = COACH_ROUTINES[key]; + const unchanged = + current?.active && + current.nextRunAt && + current.crons.length === 1 && + current.crons[0] === cron && + current.timezone === timezone && + current.threadId === conversation.threadId && + current.spaceId === conversation.spaceId && + current.name === name && + current.prompt === prompt; + if (unchanged) continue; + const nextRunAt = nextCronDate(cron, new Date(), timezone); + const fields = { + spaceId: conversation.spaceId, + threadId: conversation.threadId, + name, + prompt, + crons: [cron], + timezone, + active: true, + notify: true, + nextRunAt, + }; + const saved = await prisma.routine.upsert({ + where: { botId_coachKey: { botId, coachKey: key } }, + create: { ...fields, botId, userId, coachKey: key }, + update: fields, + }); + // The job reconciler re-arms an active routine whose wakeup could not be enqueued. + await jobs.enqueue(routineWakeupJob(saved.id, nextRunAt)); + } + return getCoachRhythm(prisma, userId); +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7b4ecd0..effc7bf 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -23,6 +23,7 @@ export * from "./cloud-agent-provider-env.js"; export * from "./cloud-agent-service.js"; export * from "./cloud-agent-tools.js"; export * from "./cloud-agent-tools-select.js"; +export * from "./coach-rhythm.js"; export * from "./composio-catalog-cache.js"; export * from "./composio-connector.js"; export * from "./composio-emulator.js"; diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts new file mode 100644 index 0000000..011944e --- /dev/null +++ b/packages/contracts/src/coach.ts @@ -0,0 +1,212 @@ +import * as z from "zod"; +import { Id } from "./ids.js"; + +export const COACH_TITLE_MAX_LENGTH = 200; +export const GOAL_DETAIL_MAX_LENGTH = 2_000; +export const CHECK_IN_NOTE_MAX_LENGTH = 1_000; +export const DAILY_BRIEF_MAX_LENGTH = 4_000; +/** Upper bounds per user, so a runaway writer cannot grow the coach data without end. */ +export const COACH_ACTIVE_GOAL_LIMIT = 100; +export const COACH_ACTIVE_HABIT_LIMIT = 100; + +export const GOAL_STATUSES = ["active", "done", "dropped"] as const; +export const GoalStatusSchema = z.enum(GOAL_STATUSES); +export type GoalStatus = z.infer; + +/** Who started tracking a goal or habit: the user, onboarding, or the main bot in a chat. */ +export const COACH_SOURCES = ["user", "onboarding", "chat"] as const; +export const CoachSourceSchema = z.enum(COACH_SOURCES); +export type CoachSource = z.infer; + +export const HABIT_CADENCES = ["daily", "weekdays", "weekly"] as const; +export const HabitCadenceSchema = z.enum(HABIT_CADENCES); +export type HabitCadence = z.infer; + +export const CHECK_IN_KINDS = ["habit", "goal", "daily"] as const; +export const CheckInKindSchema = z.enum(CHECK_IN_KINDS); +export type CheckInKind = z.infer; + +function isCalendarDate(value: string): boolean { + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +/** A calendar date in the user's time zone, e.g. "2026-10-03". */ +export const LocalDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD") + .refine(isCalendarDate, "Invalid date"); + +/** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */ +export const ClockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM"); + +/** When the main bot checks in with the user; null turns a check-in off. */ +export const CoachRhythmSchema = z.object({ + morning: ClockTimeSchema.nullable(), + evening: ClockTimeSchema.nullable(), + /** day: 0 = Sunday … 6 = Saturday. */ + weekly: z.object({ day: z.number().int().min(0).max(6), time: ClockTimeSchema }).nullable(), +}); +export type CoachRhythm = z.infer; + +export const COACH_RHYTHM_OFF: CoachRhythm = { morning: null, evening: null, weekly: null }; + +export const GoalSchema = z.object({ + id: Id, + title: z.string(), + detail: z.string(), + status: GoalStatusSchema, + targetDate: LocalDateSchema.nullable(), + spaceId: Id.nullable(), + source: CoachSourceSchema, + createdByBotId: Id.nullable(), + completedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type Goal = z.infer; + +export const HabitSchema = z.object({ + id: Id, + title: z.string(), + cadence: HabitCadenceSchema, + goalId: Id.nullable(), + active: z.boolean(), + source: CoachSourceSchema, + createdByBotId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type Habit = z.infer; + +const Rating = z.number().int().min(1).max(5); + +export const CheckInSchema = z.object({ + id: Id, + kind: CheckInKindSchema, + habitId: Id.nullable(), + goalId: Id.nullable(), + date: LocalDateSchema, + done: z.boolean().nullable(), + mood: Rating.nullable(), + energy: Rating.nullable(), + note: z.string(), + createdByBotId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type CheckIn = z.infer; + +export const DailyBriefSchema = z.object({ + id: Id, + date: LocalDateSchema, + text: z.string(), + botId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type DailyBrief = z.infer; + +export const HabitTodaySchema = HabitSchema.extend({ + /** Done in the current period: today, or this week for a weekly habit. */ + doneToday: z.boolean(), + /** Periods in a row the habit was done, counting the current one only once it is done. */ + streak: z.number().int().min(0), +}); +export type HabitToday = z.infer; + +/** The user's day as the coach sees it, in the user's time zone. */ +export const CoachTodaySchema = z.object({ + date: LocalDateSchema, + timeZone: z.string(), + brief: DailyBriefSchema.nullable(), + goals: z.array(GoalSchema), + habits: z.array(HabitTodaySchema), + /** Today's daily check-in (how the day went), if any. */ + checkIn: CheckInSchema.nullable(), +}); +export type CoachToday = z.infer; + +const Title = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const Detail = z.string().trim().max(GOAL_DETAIL_MAX_LENGTH); + +export const CreateGoalInput = z.object({ + title: Title, + detail: Detail.optional(), + targetDate: LocalDateSchema.nullable().optional(), + /** A space the goal belongs to; null or omitted for a personal goal. */ + spaceId: Id.nullable().optional(), +}); + +export const UpdateGoalInput = z.object({ + goalId: Id, + title: Title.optional(), + detail: Detail.optional(), + status: GoalStatusSchema.optional(), + targetDate: LocalDateSchema.nullable().optional(), + spaceId: Id.nullable().optional(), +}); + +export const ListGoalsInput = z.object({ status: GoalStatusSchema.optional() }); + +export const CreateHabitInput = z.object({ + title: Title, + cadence: HabitCadenceSchema.default("daily"), + goalId: Id.nullable().optional(), +}); + +export const UpdateHabitInput = z.object({ + habitId: Id, + title: Title.optional(), + cadence: HabitCadenceSchema.optional(), + goalId: Id.nullable().optional(), + active: z.boolean().optional(), +}); + +export const ListHabitsInput = z.object({ includeInactive: z.boolean().optional() }); + +/** + * Logs or updates the check-in for one day: a habit or goal check-in needs its id, the daily + * one needs neither. Fields left out keep their stored value. + */ +export const LogCheckInInput = z + .object({ + kind: CheckInKindSchema, + habitId: Id.optional(), + goalId: Id.optional(), + /** Defaults to today in the user's time zone. */ + date: LocalDateSchema.optional(), + done: z.boolean().nullable().optional(), + mood: Rating.nullable().optional(), + energy: Rating.nullable().optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), + }) + .superRefine((input, ctx) => { + const wantsHabit = input.kind === "habit"; + const wantsGoal = input.kind === "goal"; + if (wantsHabit !== Boolean(input.habitId)) { + ctx.addIssue({ + code: "custom", + message: wantsHabit + ? "A habit check-in needs habitId" + : "Only a habit check-in takes habitId", + path: ["habitId"], + }); + } + if (wantsGoal !== Boolean(input.goalId)) { + ctx.addIssue({ + code: "custom", + message: wantsGoal ? "A goal check-in needs goalId" : "Only a goal check-in takes goalId", + path: ["goalId"], + }); + } + }); +export type LogCheckIn = z.infer; + +export const ListCheckInsInput = z.object({ + from: LocalDateSchema.optional(), + to: LocalDateSchema.optional(), + kind: CheckInKindSchema.optional(), + habitId: Id.optional(), + goalId: Id.optional(), +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..5f37595 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./ai-consent.js"; export * from "./attachments.js"; export * from "./bot-secrets.js"; +export * from "./coach.js"; export * from "./desktop.js"; export * from "./domain.js"; export * from "./events.js"; diff --git a/packages/core/src/coach.ts b/packages/core/src/coach.ts new file mode 100644 index 0000000..caddf24 --- /dev/null +++ b/packages/core/src/coach.ts @@ -0,0 +1,99 @@ +import type { HabitCadence } from "@rakazo/contracts"; + +const DAY_MS = 86_400_000; + +function utcMidnight(date: string): number { + return Date.parse(`${date}T00:00:00Z`); +} + +/** The calendar date at `now` in `timeZone`, e.g. "2026-10-03". */ +export function localDateIn(timeZone: string, now: Date = new Date()): string { + const parts = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .formatToParts(now) + .map((part) => [part.type, part.value]), + ); + return `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`; +} + +/** `date` moved by `days` calendar days. */ +export function addDays(date: string, days: number): string { + return new Date(utcMidnight(date) + days * DAY_MS).toISOString().slice(0, 10); +} + +/** Day of the week, 0 = Sunday … 6 = Saturday. */ +export function weekdayOf(date: string): number { + return new Date(utcMidnight(date)).getUTCDay(); +} + +/** The Monday that starts the week containing `date`. */ +export function weekStartOf(date: string): string { + return addDays(date, -((weekdayOf(date) + 6) % 7)); +} + +function isWeekday(date: string): boolean { + const day = weekdayOf(date); + return day >= 1 && day <= 5; +} + +/** The latest Monday–Friday date strictly before `date`. */ +function previousWeekday(date: string): string { + let cursor = addDays(date, -1); + while (!isWeekday(cursor)) cursor = addDays(cursor, -1); + return cursor; +} + +/** Whether the habit is done in the period that contains `today`. */ +export function habitDoneInPeriod( + cadence: HabitCadence, + doneDates: ReadonlySet, + today: string, +): boolean { + if (cadence !== "weekly") return doneDates.has(today); + const start = weekStartOf(today); + for (let date = start; date <= today; date = addDays(date, 1)) { + if (doneDates.has(date)) return true; + } + return false; +} + +/** + * Periods in a row the habit was done, ending today. The current period counts once it is done; + * until then it stays open and does not break the streak. Weekday habits skip weekends, weekly + * habits count Monday-to-Sunday weeks with at least one done day. + */ +export function habitStreak( + cadence: HabitCadence, + doneDates: ReadonlySet, + today: string, +): number { + let streak = 0; + if (cadence === "weekly") { + const weeks = new Set([...doneDates].map(weekStartOf)); + const current = weekStartOf(today); + for (let week = weeks.has(current) ? current : addDays(current, -7); weeks.has(week); ) { + streak += 1; + week = addDays(week, -7); + } + return streak; + } + if (cadence === "weekdays") { + let cursor = isWeekday(today) && doneDates.has(today) ? today : previousWeekday(today); + while (doneDates.has(cursor)) { + streak += 1; + cursor = previousWeekday(cursor); + } + return streak; + } + let cursor = doneDates.has(today) ? today : addDays(today, -1); + while (doneDates.has(cursor)) { + streak += 1; + cursor = addDays(cursor, -1); + } + return streak; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c870847..c2cbc44 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from "./bot-messages.js"; export * from "./bot-reply-sanitize.js"; export * from "./bot-sections.js"; export * from "./cloud-agent.js"; +export * from "./coach.js"; export * from "./compose-update.js"; export * from "./composer-mention-picker.js"; export * from "./composer-mentions.js"; diff --git a/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql b/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql new file mode 100644 index 0000000..4be9942 --- /dev/null +++ b/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql @@ -0,0 +1,144 @@ +-- The main bot coaches the user: goals, habits, daily check-ins and a short brief per day. Dates +-- are the user's local calendar dates. Like the other provenance ids, createdByBotId and botId +-- have no foreign key. + +-- AlterTable +ALTER TABLE "routines" ADD COLUMN "coachKey" TEXT; + +-- CreateTable +CREATE TABLE "user_goals" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "detail" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'active', + "targetDate" DATE, + "spaceId" TEXT, + "source" TEXT NOT NULL, + "createdByBotId" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_goals_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "habits" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "cadence" TEXT NOT NULL DEFAULT 'daily', + "goalId" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "source" TEXT NOT NULL, + "createdByBotId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "habits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "check_ins" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "habitId" TEXT, + "goalId" TEXT, + "date" DATE NOT NULL, + "done" BOOLEAN, + "mood" INTEGER, + "energy" INTEGER, + "note" TEXT NOT NULL DEFAULT '', + "createdByBotId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "daily_briefs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "date" DATE NOT NULL, + "text" TEXT NOT NULL, + "botId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "daily_briefs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "routines_botId_coachKey_key" ON "routines"("botId", "coachKey"); + +-- CreateIndex +CREATE INDEX "user_goals_userId_status_createdAt_idx" ON "user_goals"("userId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "user_goals_spaceId_idx" ON "user_goals"("spaceId"); + +-- CreateIndex +CREATE INDEX "habits_userId_active_createdAt_idx" ON "habits"("userId", "active", "createdAt"); + +-- CreateIndex +CREATE INDEX "habits_goalId_idx" ON "habits"("goalId"); + +-- CreateIndex +CREATE INDEX "check_ins_userId_date_idx" ON "check_ins"("userId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "check_ins_habitId_date_key" ON "check_ins"("habitId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "check_ins_goalId_date_key" ON "check_ins"("goalId", "date"); + +-- One daily check-in per user and date. +CREATE UNIQUE INDEX "check_ins_userId_date_daily_key" ON "check_ins"("userId", "date") WHERE "kind" = 'daily'; + +-- CreateIndex +CREATE UNIQUE INDEX "daily_briefs_userId_date_key" ON "daily_briefs"("userId", "date"); + +-- AddForeignKey +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "habits" ADD CONSTRAINT "habits_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "habits" ADD CONSTRAINT "habits_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "user_goals"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "habits"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "user_goals"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "daily_briefs" ADD CONSTRAINT "daily_briefs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The CHECK constraint on the existing routines table is added NOT VALID and validated in the +-- next migration. +ALTER TABLE "routines" ADD CONSTRAINT "routines_coachKey_chk" CHECK ("coachKey" IS NULL OR "coachKey" IN ('morning', 'evening', 'weekly')) NOT VALID; + +-- New tables are empty, so their constraints apply immediately. +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_status_chk" CHECK ("status" IN ('active', 'done', 'dropped')); +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_source_chk" CHECK ("source" IN ('user', 'onboarding', 'chat')); +ALTER TABLE "habits" ADD CONSTRAINT "habits_cadence_chk" CHECK ("cadence" IN ('daily', 'weekdays', 'weekly')); +ALTER TABLE "habits" ADD CONSTRAINT "habits_source_chk" CHECK ("source" IN ('user', 'onboarding', 'chat')); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_kind_chk" CHECK ("kind" IN ('habit', 'goal', 'daily')); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_target_chk" CHECK ( + ("kind" = 'habit' AND "habitId" IS NOT NULL AND "goalId" IS NULL) OR + ("kind" = 'goal' AND "goalId" IS NOT NULL AND "habitId" IS NULL) OR + ("kind" = 'daily' AND "habitId" IS NULL AND "goalId" IS NULL) +); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_mood_chk" CHECK ("mood" IS NULL OR ("mood" >= 1 AND "mood" <= 5)); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_energy_chk" CHECK ("energy" IS NULL OR ("energy" >= 1 AND "energy" <= 5)); diff --git a/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql b/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql new file mode 100644 index 0000000..dbadb5b --- /dev/null +++ b/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql @@ -0,0 +1,3 @@ +-- Kept separate from creation so PostgreSQL validates existing rows with a SHARE UPDATE +-- EXCLUSIVE lock (reads and writes continue) after the NOT VALID constraint has committed. +ALTER TABLE "routines" VALIDATE CONSTRAINT "routines_coachKey_chk"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..b1dd0f9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -47,6 +47,10 @@ model User { agentSecrets AgentSecret[] profile UserProfile? facts UserFact[] + goals UserGoal[] + habits Habit[] + checkIns CheckIn[] + dailyBriefs DailyBrief[] @@unique([email]) @@map("user") @@ -214,6 +218,7 @@ model Space { externalConversations ExternalConversation[] agentSecrets AgentSecret[] userFactVisibility UserFactSpace[] + userGoals UserGoal[] delegatedTasks DelegatedTask[] semanticMemoryItems SemanticMemoryItem[] @@ -766,15 +771,19 @@ model Routine { webhookEnabled Boolean @default(false) githubEnabled Boolean @default(false) messageProvider String? + /// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise. + coachKey String? lastRunAt DateTime? nextRunAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt runs Run[] + @@unique([botId, coachKey]) @@index([active, nextRunAt]) @@index([spaceId, botId]) @@index([createdByBotId]) + // CHECK constraint routines_coachKey_chk is created in SQL. @@map("routines") } @@ -1468,6 +1477,100 @@ model UserFactSpace { @@map("user_fact_spaces") } +/// Something the user wants to get done; the main bot coaches them toward it. +model UserGoal { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + detail String @default("") + status String @default("active") + /// The user's local date the goal is due, if any. + targetDate DateTime? @db.Date + spaceId String? + space Space? @relation(fields: [spaceId], references: [id], onDelete: SetNull) + source String + /// Like the other provenance ids it has no foreign key. + createdByBotId String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + habits Habit[] + checkIns CheckIn[] + + @@index([userId, status, createdAt]) + @@index([spaceId]) + // CHECK constraints user_goals_status_chk and user_goals_source_chk are created in SQL. + @@map("user_goals") +} + +/// A recurring practice the user wants to keep, checked off per day or week. +model Habit { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + cadence String @default("daily") + goalId String? + goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: SetNull) + active Boolean @default(true) + source String + createdByBotId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checkIns CheckIn[] + + @@index([userId, active, createdAt]) + @@index([goalId]) + // CHECK constraints habits_cadence_chk and habits_source_chk are created in SQL. + @@map("habits") +} + +/// What the user reported for one day: a habit kept or missed, goal progress, or how the day went. +model CheckIn { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + kind String + habitId String? + habit Habit? @relation(fields: [habitId], references: [id], onDelete: Cascade) + goalId String? + goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: Cascade) + /// The user's local date. + date DateTime @db.Date + done Boolean? + mood Int? + energy Int? + note String @default("") + createdByBotId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([habitId, date]) + @@unique([goalId, date]) + @@index([userId, date]) + // CHECK constraints check_ins_kind_chk, check_ins_target_chk, check_ins_mood_chk and + // check_ins_energy_chk and the partial unique index check_ins_userId_date_daily_key are + // created in SQL. + @@map("check_ins") +} + +/// The main bot's short brief for one of the user's days. +model DailyBrief { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// The user's local date. + date DateTime @db.Date + text String + botId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, date]) + @@map("daily_briefs") +} + /// Work the main bot hands to a specialist, reported back in the main conversation. model DelegatedTask { id String @id @default(cuid()) diff --git a/packages/db/src/coach.ts b/packages/db/src/coach.ts new file mode 100644 index 0000000..69438d0 --- /dev/null +++ b/packages/db/src/coach.ts @@ -0,0 +1,634 @@ +import type { + CheckIn, + CheckInKind, + CoachSource, + CoachToday, + DailyBrief, + Goal, + GoalStatus, + Habit, + HabitCadence, + HabitToday, +} from "@rakazo/contracts"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_ACTIVE_GOAL_LIMIT, + COACH_ACTIVE_HABIT_LIMIT, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + LocalDateSchema, +} from "@rakazo/contracts"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + knownTimeZone, + localDateIn, + weekStartOf, +} from "@rakazo/core"; +import type { PrismaClient } from "./client.js"; +import { Prisma } from "./client.js"; + +/** How far back a check-in may be logged, and how far back streaks are counted. */ +export const CHECK_IN_MAX_AGE_DAYS = 366; + +export class CoachNotFoundError extends Error { + constructor(what: "Goal" | "Habit") { + super(`${what} not found`); + this.name = "CoachNotFoundError"; + } +} + +export class InvalidCoachInputError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidCoachInputError"; + } +} + +export interface NewGoal { + title: string; + detail?: string; + /** The user's local date, "YYYY-MM-DD". */ + targetDate?: string | null; + spaceId?: string | null; + source: CoachSource; + createdByBotId?: string | null; +} + +export interface GoalPatch { + title?: string; + detail?: string; + status?: GoalStatus; + targetDate?: string | null; + spaceId?: string | null; +} + +export interface NewHabit { + title: string; + cadence?: HabitCadence; + goalId?: string | null; + source: CoachSource; + createdByBotId?: string | null; +} + +export interface HabitPatch { + title?: string; + cadence?: HabitCadence; + goalId?: string | null; + active?: boolean; +} + +export interface CheckInEntry { + kind: CheckInKind; + habitId?: string; + goalId?: string; + /** The user's local date; defaults to today in their time zone. */ + date?: string; + done?: boolean | null; + mood?: number | null; + energy?: number | null; + note?: string; + createdByBotId?: string | null; +} + +export interface CheckInFilter { + from?: string; + to?: string; + kind?: CheckInKind; + habitId?: string; + goalId?: string; +} + +export interface DailyBriefEntry { + text: string; + /** The user's local date; defaults to today in their time zone. */ + date?: string; + botId?: string | null; +} + +/** Active goals and habits as the main bot sees them in its prompt. */ +export interface CoachSummary { + date: string; + timeZone: string; + goals: Goal[]; + habits: HabitToday[]; +} + +const CHECK_IN_LIST_LIMIT = 500; + +type Tx = Prisma.TransactionClient; +type GoalRow = Prisma.UserGoalGetPayload; +type HabitRow = Prisma.HabitGetPayload; +type CheckInRow = Prisma.CheckInGetPayload; +type BriefRow = Prisma.DailyBriefGetPayload; + +function dateString(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function dateValue(value: string): Date { + return new Date(`${value}T00:00:00Z`); +} + +function goalDto(row: GoalRow): Goal { + return { + id: row.id, + title: row.title, + detail: row.detail, + status: row.status as GoalStatus, + targetDate: row.targetDate ? dateString(row.targetDate) : null, + spaceId: row.spaceId, + source: row.source as CoachSource, + createdByBotId: row.createdByBotId, + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function habitDto(row: HabitRow): Habit { + return { + id: row.id, + title: row.title, + cadence: row.cadence as HabitCadence, + goalId: row.goalId, + active: row.active, + source: row.source as CoachSource, + createdByBotId: row.createdByBotId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function checkInDto(row: CheckInRow): CheckIn { + return { + id: row.id, + kind: row.kind as CheckInKind, + habitId: row.habitId, + goalId: row.goalId, + date: dateString(row.date), + done: row.done, + mood: row.mood, + energy: row.energy, + note: row.note, + createdByBotId: row.createdByBotId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function briefDto(row: BriefRow): DailyBrief { + return { + id: row.id, + date: dateString(row.date), + text: row.text, + botId: row.botId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function title(value: string): string { + const text = value.trim(); + if (!text) throw new InvalidCoachInputError("Title is required"); + if (text.length > COACH_TITLE_MAX_LENGTH) { + throw new InvalidCoachInputError(`Title is longer than ${COACH_TITLE_MAX_LENGTH} characters`); + } + return text; +} + +function boundedText(value: string, max: number, label: string): string { + const text = value.trim(); + if (text.length > max) + throw new InvalidCoachInputError(`${label} is longer than ${max} characters`); + return text; +} + +function localDate(value: string): string { + if (!LocalDateSchema.safeParse(value).success) { + throw new InvalidCoachInputError("Dates use YYYY-MM-DD"); + } + return value; +} + +function rating(value: number | null | undefined, label: string): number | null | undefined { + if (value === null || value === undefined) return value; + if (!Number.isInteger(value) || value < 1 || value > 5) { + throw new InvalidCoachInputError(`${label} is a whole number from 1 to 5`); + } + return value; +} + +/** + * Serializes one user's coach writes so the active limits and the one-check-in-per-day rule hold + * under concurrency. + */ +async function lockCoach(tx: Tx, userId: string): Promise { + const key = `coach:${userId}`; + await tx.$queryRaw(Prisma.sql` + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock" + `); +} + +/** The user's time zone from their profile, UTC when unknown. Every coach date uses it. */ +export async function coachTimeZone( + prisma: Pick, + userId: string, +): Promise { + const profile = await prisma.userProfile.findUnique({ + where: { userId }, + select: { timeZone: true }, + }); + return knownTimeZone(profile?.timeZone) ?? "UTC"; +} + +async function assertMemberSpace(tx: Tx, userId: string, spaceId: string | null | undefined) { + if (!spaceId) return; + const member = await tx.spaceMember.findFirst({ + where: { userId, spaceId }, + select: { id: true }, + }); + if (!member) throw new InvalidCoachInputError("Space not found"); +} + +async function ownedGoal(tx: Tx, userId: string, goalId: string): Promise { + const goal = await tx.userGoal.findFirst({ where: { id: goalId, userId } }); + if (!goal) throw new CoachNotFoundError("Goal"); + return goal; +} + +async function ownedHabit(tx: Tx, userId: string, habitId: string): Promise { + const habit = await tx.habit.findFirst({ where: { id: habitId, userId } }); + if (!habit) throw new CoachNotFoundError("Habit"); + return habit; +} + +async function assertActiveGoalRoom(tx: Tx, userId: string): Promise { + const active = await tx.userGoal.count({ where: { userId, status: "active" } }); + if (active >= COACH_ACTIVE_GOAL_LIMIT) { + throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`); + } +} + +async function assertActiveHabitRoom(tx: Tx, userId: string): Promise { + const active = await tx.habit.count({ where: { userId, active: true } }); + if (active >= COACH_ACTIVE_HABIT_LIMIT) { + throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + } +} + +export async function createGoal( + prisma: PrismaClient, + userId: string, + input: NewGoal, +): Promise { + const data = { + title: title(input.title), + detail: boundedText(input.detail ?? "", GOAL_DETAIL_MAX_LENGTH, "Detail"), + targetDate: input.targetDate ? dateValue(localDate(input.targetDate)) : null, + }; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + await assertActiveGoalRoom(tx, userId); + await assertMemberSpace(tx, userId, input.spaceId); + const row = await tx.userGoal.create({ + data: { + ...data, + userId, + spaceId: input.spaceId ?? null, + source: input.source, + createdByBotId: input.createdByBotId ?? null, + }, + }); + return goalDto(row); + }); +} + +/** Changes a goal; marking it done stamps completedAt, any other status clears it. */ +export async function updateGoal( + prisma: PrismaClient, + userId: string, + goalId: string, + patch: GoalPatch, +): Promise { + const data: Prisma.UserGoalUncheckedUpdateInput = {}; + if (patch.title !== undefined) data.title = title(patch.title); + if (patch.detail !== undefined) { + data.detail = boundedText(patch.detail, GOAL_DETAIL_MAX_LENGTH, "Detail"); + } + if (patch.targetDate !== undefined) { + data.targetDate = patch.targetDate ? dateValue(localDate(patch.targetDate)) : null; + } + if (patch.spaceId !== undefined) data.spaceId = patch.spaceId; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const goal = await ownedGoal(tx, userId, goalId); + if (patch.spaceId !== undefined) await assertMemberSpace(tx, userId, patch.spaceId); + if (patch.status !== undefined && patch.status !== goal.status) { + if (patch.status === "active") await assertActiveGoalRoom(tx, userId); + data.status = patch.status; + data.completedAt = patch.status === "done" ? new Date() : null; + } + const row = await tx.userGoal.update({ where: { id: goal.id }, data }); + return goalDto(row); + }); +} + +/** Deletes a goal with its check-ins; habits that served it stay, unlinked. */ +export async function removeGoal(prisma: PrismaClient, userId: string, goalId: string) { + const { count } = await prisma.userGoal.deleteMany({ where: { id: goalId, userId } }); + if (count === 0) throw new CoachNotFoundError("Goal"); +} + +export async function listGoals( + prisma: PrismaClient, + userId: string, + filter: { status?: GoalStatus } = {}, +): Promise { + const rows = await prisma.userGoal.findMany({ + where: { userId, ...(filter.status ? { status: filter.status } : {}) }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + }); + return rows.map(goalDto); +} + +export async function createHabit( + prisma: PrismaClient, + userId: string, + input: NewHabit, +): Promise { + const habitTitle = title(input.title); + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + await assertActiveHabitRoom(tx, userId); + if (input.goalId) await ownedGoal(tx, userId, input.goalId); + const row = await tx.habit.create({ + data: { + userId, + title: habitTitle, + cadence: input.cadence ?? "daily", + goalId: input.goalId ?? null, + source: input.source, + createdByBotId: input.createdByBotId ?? null, + }, + }); + return habitDto(row); + }); +} + +/** Changes a habit; `active: false` stops tracking it but keeps its history. */ +export async function updateHabit( + prisma: PrismaClient, + userId: string, + habitId: string, + patch: HabitPatch, +): Promise { + const data: Prisma.HabitUncheckedUpdateInput = {}; + if (patch.title !== undefined) data.title = title(patch.title); + if (patch.cadence !== undefined) data.cadence = patch.cadence; + if (patch.goalId !== undefined) data.goalId = patch.goalId; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const habit = await ownedHabit(tx, userId, habitId); + if (patch.goalId) await ownedGoal(tx, userId, patch.goalId); + if (patch.active !== undefined && patch.active !== habit.active) { + if (patch.active) await assertActiveHabitRoom(tx, userId); + data.active = patch.active; + } + const row = await tx.habit.update({ where: { id: habit.id }, data }); + return habitDto(row); + }); +} + +export async function removeHabit(prisma: PrismaClient, userId: string, habitId: string) { + const { count } = await prisma.habit.deleteMany({ where: { id: habitId, userId } }); + if (count === 0) throw new CoachNotFoundError("Habit"); +} + +export async function listHabits( + prisma: PrismaClient, + userId: string, + filter: { includeInactive?: boolean } = {}, +): Promise { + const rows = await prisma.habit.findMany({ + where: { userId, ...(filter.includeInactive ? {} : { active: true }) }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + return rows.map(habitDto); +} + +/** + * Logs or updates one day's check-in: one per habit, one per goal and one daily check-in per + * date. Fields left out keep their stored value. Unchecking a weekly habit clears the whole + * week, so the habit reads as not done this week. + */ +export async function logCheckIn( + prisma: PrismaClient, + userId: string, + entry: CheckInEntry, + now: Date = new Date(), +): Promise { + if ((entry.kind === "habit") !== Boolean(entry.habitId)) { + throw new InvalidCoachInputError("A habit check-in needs habitId, and only it"); + } + if ((entry.kind === "goal") !== Boolean(entry.goalId)) { + throw new InvalidCoachInputError("A goal check-in needs goalId, and only it"); + } + const fields: Prisma.CheckInUncheckedUpdateInput = {}; + if (entry.done !== undefined) fields.done = entry.done; + const mood = rating(entry.mood, "Mood"); + if (mood !== undefined) fields.mood = mood; + const energy = rating(entry.energy, "Energy"); + if (energy !== undefined) fields.energy = energy; + if (entry.note !== undefined) { + fields.note = boundedText(entry.note, CHECK_IN_NOTE_MAX_LENGTH, "Note"); + } + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const today = localDateIn(await coachTimeZone(tx, userId), now); + const date = entry.date ? localDate(entry.date) : today; + if (date > today) throw new InvalidCoachInputError("Check-ins cannot be in the future"); + if (date < addDays(today, -CHECK_IN_MAX_AGE_DAYS)) { + throw new InvalidCoachInputError(`Check-ins go back at most ${CHECK_IN_MAX_AGE_DAYS} days`); + } + const habit = entry.habitId ? await ownedHabit(tx, userId, entry.habitId) : null; + if (entry.goalId) await ownedGoal(tx, userId, entry.goalId); + const day = dateValue(date); + const existing = habit + ? await tx.checkIn.findUnique({ where: { habitId_date: { habitId: habit.id, date: day } } }) + : entry.goalId + ? await tx.checkIn.findUnique({ + where: { goalId_date: { goalId: entry.goalId, date: day } }, + }) + : await tx.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }); + const row = existing + ? await tx.checkIn.update({ where: { id: existing.id }, data: fields }) + : await tx.checkIn.create({ + data: { + userId, + kind: entry.kind, + habitId: habit?.id ?? null, + goalId: entry.goalId ?? null, + date: day, + done: entry.done ?? null, + mood: mood ?? null, + energy: energy ?? null, + note: typeof fields.note === "string" ? fields.note : "", + createdByBotId: entry.createdByBotId ?? null, + }, + }); + if (habit && entry.done === false && habit.cadence === "weekly") { + await tx.checkIn.updateMany({ + where: { + habitId: habit.id, + done: true, + date: { + gte: dateValue(weekStartOf(date)), + lte: dateValue(addDays(weekStartOf(date), 6)), + }, + }, + data: { done: false }, + }); + } + return checkInDto(row); + }); +} + +export async function listCheckIns( + prisma: PrismaClient, + userId: string, + filter: CheckInFilter = {}, +): Promise { + const rows = await prisma.checkIn.findMany({ + where: { + userId, + ...(filter.kind ? { kind: filter.kind } : {}), + ...(filter.habitId ? { habitId: filter.habitId } : {}), + ...(filter.goalId ? { goalId: filter.goalId } : {}), + ...(filter.from || filter.to + ? { + date: { + ...(filter.from ? { gte: dateValue(localDate(filter.from)) } : {}), + ...(filter.to ? { lte: dateValue(localDate(filter.to)) } : {}), + }, + } + : {}), + }, + orderBy: [{ date: "desc" }, { createdAt: "desc" }, { id: "asc" }], + take: CHECK_IN_LIST_LIMIT, + }); + return rows.map(checkInDto); +} + +/** Saves the brief for one of the user's days, replacing an earlier one for that date. */ +export async function upsertDailyBrief( + prisma: PrismaClient, + userId: string, + entry: DailyBriefEntry, + now: Date = new Date(), +): Promise { + const text = boundedText(entry.text, DAILY_BRIEF_MAX_LENGTH, "Brief"); + if (!text) throw new InvalidCoachInputError("Brief is empty"); + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const today = localDateIn(await coachTimeZone(tx, userId), now); + const date = entry.date ? localDate(entry.date) : today; + if (date > addDays(today, 1)) { + throw new InvalidCoachInputError("A brief is for today or tomorrow at the latest"); + } + const row = await tx.dailyBrief.upsert({ + where: { userId_date: { userId, date: dateValue(date) } }, + create: { userId, date: dateValue(date), text, botId: entry.botId ?? null }, + update: { text, botId: entry.botId ?? null }, + }); + return briefDto(row); + }); +} + +async function activeGoals(prisma: PrismaClient, userId: string): Promise { + const rows = await prisma.userGoal.findMany({ + where: { userId, status: "active" }, + orderBy: [{ targetDate: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, { id: "asc" }], + }); + return rows.map(goalDto); +} + +async function activeHabitsOn( + prisma: PrismaClient, + userId: string, + today: string, +): Promise { + const habits = await prisma.habit.findMany({ + where: { userId, active: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + if (habits.length === 0) return []; + const done = await prisma.checkIn.findMany({ + where: { + userId, + kind: "habit", + done: true, + habitId: { in: habits.map((habit) => habit.id) }, + date: { gte: dateValue(addDays(today, -CHECK_IN_MAX_AGE_DAYS)), lte: dateValue(today) }, + }, + select: { habitId: true, date: true }, + }); + const doneDates = new Map>(); + for (const row of done) { + if (!row.habitId) continue; + const dates = doneDates.get(row.habitId) ?? new Set(); + dates.add(dateString(row.date)); + doneDates.set(row.habitId, dates); + } + return habits.map((row) => { + const habit = habitDto(row); + const dates = doneDates.get(row.id) ?? new Set(); + return { + ...habit, + doneToday: habitDoneInPeriod(habit.cadence, dates, today), + streak: habitStreak(habit.cadence, dates, today), + }; + }); +} + +/** Active goals and habits with streaks, for the main bot's prompt. */ +export async function loadCoachSummary( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const timeZone = await coachTimeZone(prisma, userId); + const date = localDateIn(timeZone, now); + const [goals, habits] = await Promise.all([ + activeGoals(prisma, userId), + activeHabitsOn(prisma, userId, date), + ]); + return { date, timeZone, goals, habits }; +} + +/** The user's day: today's brief, active goals, habits with streaks and the daily check-in. */ +export async function loadCoachToday( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const timeZone = await coachTimeZone(prisma, userId); + const date = localDateIn(timeZone, now); + const day = dateValue(date); + const [brief, goals, habits, checkIn] = await Promise.all([ + prisma.dailyBrief.findUnique({ where: { userId_date: { userId, date: day } } }), + activeGoals(prisma, userId), + activeHabitsOn(prisma, userId, date), + prisma.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }), + ]); + return { + date, + timeZone, + brief: brief ? briefDto(brief) : null, + goals, + habits, + checkIn: checkIn ? checkInDto(checkIn) : null, + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..79a5b9a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,6 +1,7 @@ export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; export * from "./client.js"; +export * from "./coach.js"; export * from "./computers.js"; export * from "./credential-secrets.js"; export * from "./events.js"; From 74503ad663019788a4270a7d7de0516c6b058829 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:16:27 +0000 Subject: [PATCH 2/2] refactor(contracts): share one clock-time schema The coach rhythm and the quiet hours both parse "HH:MM" wall-clock times and each defined its own ClockTimeSchema, which the contracts index cannot export twice. Both now import it from one module. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/contracts/src/clock-time.ts | 19 +++++++++++++++++++ packages/contracts/src/coach.ts | 2 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/notifications.ts | 19 +------------------ 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 packages/contracts/src/clock-time.ts diff --git a/packages/contracts/src/clock-time.ts b/packages/contracts/src/clock-time.ts new file mode 100644 index 0000000..e8d1464 --- /dev/null +++ b/packages/contracts/src/clock-time.ts @@ -0,0 +1,19 @@ +import * as z from "zod"; + +const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** A 24-hour wall-clock time, e.g. "22:30". */ +export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); + +/** Minutes after midnight for a "HH:MM" time. */ +export function clockTimeToMinutes(value: string): number { + const match = CLOCK_TIME.exec(value); + if (!match) throw new Error("Use HH:MM"); + return Number(match[1]) * 60 + Number(match[2]); +} + +/** "HH:MM" for minutes after midnight. */ +export function minutesToClockTime(minutes: number): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts index 011944e..17f777e 100644 --- a/packages/contracts/src/coach.ts +++ b/packages/contracts/src/coach.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { ClockTimeSchema } from "./clock-time.js"; import { Id } from "./ids.js"; export const COACH_TITLE_MAX_LENGTH = 200; @@ -38,7 +39,6 @@ export const LocalDateSchema = z .refine(isCalendarDate, "Invalid date"); /** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */ -export const ClockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM"); /** When the main bot checks in with the user; null turns a check-in off. */ export const CoachRhythmSchema = z.object({ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 034dcd5..197dfd5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./ai-consent.js"; export * from "./attachments.js"; export * from "./bot-secrets.js"; +export * from "./clock-time.js"; export * from "./coach.js"; export * from "./desktop.js"; export * from "./domain.js"; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts index 5cb6287..5b09a13 100644 --- a/packages/contracts/src/notifications.ts +++ b/packages/contracts/src/notifications.ts @@ -1,26 +1,9 @@ import * as z from "zod"; +import { ClockTimeSchema } from "./clock-time.js"; /** How many nudges the main agent may send per day in the user's time zone. */ export const NUDGES_PER_DAY = 3; -const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; - -/** A 24-hour wall-clock time, e.g. "22:30". */ -export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); - -/** Minutes after midnight for a "HH:MM" time. */ -export function clockTimeToMinutes(value: string): number { - const match = CLOCK_TIME.exec(value); - if (!match) throw new Error("Use HH:MM"); - return Number(match[1]) * 60 + Number(match[2]); -} - -/** "HH:MM" for minutes after midnight. */ -export function minutesToClockTime(minutes: number): string { - const pad = (value: number) => String(value).padStart(2, "0"); - return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; -} - /** A daily range in the user's profile time zone; it may cross midnight ("22:00"–"07:00"). */ export const QuietHoursSchema = z .object({ start: ClockTimeSchema, end: ClockTimeSchema })