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
100 lines
3.0 KiB
TypeScript
100 lines
3.0 KiB
TypeScript
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;
|
||
}
|