The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
149 lines
6.3 KiB
TypeScript
149 lines
6.3 KiB
TypeScript
import type { JobPublisher } from "@rakazo/adapter-kit";
|
|
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
|
import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts";
|
|
import {
|
|
COACH_RHYTHM_OFF,
|
|
COACH_ROUTINE_KEYS,
|
|
COACH_ROUTINE_NAMES,
|
|
clockTimeToMinutes,
|
|
minutesToClockTime,
|
|
} 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 = COACH_ROUTINE_KEYS;
|
|
export type CoachKey = CoachRoutineKey;
|
|
|
|
/**
|
|
* 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<CoachKey, { name: string; prompt: string }> = {
|
|
morning: {
|
|
name: COACH_ROUTINE_NAMES.morning,
|
|
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: COACH_ROUTINE_NAMES.evening,
|
|
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: COACH_ROUTINE_NAMES.weekly,
|
|
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: Pick<JobPublisher, "enqueue" | "cancel">;
|
|
}
|
|
|
|
/** 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 minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time);
|
|
return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`;
|
|
}
|
|
|
|
/** 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 = minutesToClockTime(Number(match[2]) * 60 + Number(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<CoachRhythm> {
|
|
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<CoachRhythm> {
|
|
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);
|
|
}
|