import * as z from "zod"; import { ClockTimeSchema } from "./clock-time.js"; 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". */ /** 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 }; /** The main bot's check-in routines, one per key; a routine carries its key as `coachKey`. */ export const COACH_ROUTINE_KEYS = ["morning", "evening", "weekly"] as const; export const CoachRoutineKeySchema = z.enum(COACH_ROUTINE_KEYS); export type CoachRoutineKey = z.infer; /** Stored names of the check-in routines; the web shows them in the user's language. */ export const COACH_ROUTINE_NAMES: Record = { morning: "Morning brief", evening: "Evening check-in", weekly: "Weekly review", }; 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(), });