Claude 7d7af8f3e9
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:12:08 +00:00

100 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>,
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<string>,
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;
}