feat: implement user limits and onboarding features

- Added user limits management with daily can and spend limits.
- Integrated onboarding flow to guide users through setting limits.
- Enhanced greeting messages to reflect user limits and violations.
- Updated CSS for new limit-related components and improved UI consistency.
- Refactored coach session to utilize user limits in interactions.
This commit is contained in:
Ned Halksworth
2026-05-23 21:17:36 +01:00
parent 2be7609172
commit 0db81bddd5
9 changed files with 1683 additions and 86 deletions
+31 -3
View File
@@ -1,3 +1,5 @@
import type { LimitCheckResult } from "../types";
import { formatStopTimeLabel } from "./userLimits";
import { groupByFlavour } from "./metrics";
type GreetingInput = {
@@ -7,6 +9,8 @@ type GreetingInput = {
currentStreak: number;
todayCaffeineMg: number;
allTimeCans: number;
dailyCanLimit?: number;
limitCheck?: LimitCheckResult;
};
type GreetingResult = {
@@ -42,6 +46,14 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
: `${input.name}, no Red Bulls logged yet this ${hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"}.`;
} else if (cans === 1) {
headline = `${input.name}, one Red Bull in so far today.`;
} else if (input.dailyCanLimit != null) {
if (cans >= input.dailyCanLimit) {
headline = `${input.name}, you're at your ${input.dailyCanLimit}-can daily limit.`;
} else if (cans >= input.dailyCanLimit - 1) {
headline = `${input.name}, ${cans} Red Bulls today — one under your limit.`;
} else {
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
}
} else if (cans <= 3) {
headline = `${input.name}, ${cans} Red Bulls today — steady pace.`;
} else {
@@ -54,22 +66,38 @@ export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
: `All-time favourite: ${favourite} (${input.allTimeCans} cans logged).`
: "Your flavour story is just getting started.";
const stopLine =
input.limitCheck?.pastStopTime && input.limitCheck?.violations.includes("stopTime")
? "You're past your stop time for today."
: null;
const caffeineLine =
cans > 0 && input.todayCaffeineMg > 0
stopLine ??
(cans > 0 && input.todayCaffeineMg > 0
? `~${Math.round(input.todayCaffeineMg)}mg caffeine so far.`
: hour >= 17 && cans === 0
? "Evening reset — clean slate if you want it."
: hour >= 22
? "Late night — pace yourself if you're still going."
: "Log an intake to unlock today's signals.";
: "Log an intake to unlock today's signals.");
const limitLine =
input.dailyCanLimit != null && cans > 0
? `${cans}/${input.dailyCanLimit} cans toward your daily limit.`
: null;
return {
badge,
headline,
subline: [flavourLine, caffeineLine].join(" "),
subline: [flavourLine, limitLine ?? caffeineLine].filter(Boolean).join(" "),
};
}
export function stopTimeGreetingHint(stopTime?: string, pastStopTime?: boolean) {
if (!stopTime || !pastStopTime) return null;
return `Past your ${formatStopTimeLabel(stopTime)} stop time.`;
}
export function buildFlavourHistorySummary(entries: Parameters<typeof groupByFlavour>[0]) {
const breakdown = groupByFlavour(entries);
if (!breakdown.length) return "No flavour history yet.";
+18 -4
View File
@@ -18,7 +18,8 @@ import {
sugarFor,
wholeNumber,
} from "./metrics";
import type { CoachChat, CoachMessage, RedBullEntry } from "../types";
import type { CoachChat, CoachMessage, LimitCheckResult, RedBullEntry, UserLimits } from "../types";
import { limitsSummaryForCoach } from "./userLimits";
type AuthUser = Models.User<Models.Preferences>;
@@ -38,7 +39,13 @@ type OllamaStreamChunk = { error?: string; message?: { content?: string; thinkin
export type CoachSession = ReturnType<typeof useCoachSession>;
export function useCoachSession(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) {
export function useCoachSession(
user: AuthUser,
dashboard: Dashboard,
entries: RedBullEntry[],
userLimits: UserLimits = {},
limitCheck?: LimitCheckResult,
) {
const [chats, setChats] = useState<CoachChat[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [savedChatIds, setSavedChatIds] = useState<Set<string>>(() => new Set());
@@ -165,7 +172,7 @@ export function useCoachSession(user: AuthUser, dashboard: Dashboard, entries: R
try {
const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [
{ role: "system", content: buildCoachSystemPrompt(user, dashboard, entries) },
{ role: "system", content: buildCoachSystemPrompt(user, dashboard, entries, userLimits, limitCheck) },
...conversation
.filter((message) => message.content.trim().length > 0)
.map((message) => ({
@@ -333,7 +340,13 @@ function titleForChat(currentTitle: string, prompt: string) {
return cleaned.length > 48 ? `${cleaned.slice(0, 45)}...` : cleaned || "today";
}
function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: RedBullEntry[]) {
function buildCoachSystemPrompt(
user: AuthUser,
dashboard: Dashboard,
entries: RedBullEntry[],
userLimits: UserLimits,
limitCheck?: LimitCheckResult,
) {
const recent = entries
.slice(0, 12)
.map(
@@ -351,6 +364,7 @@ function buildCoachSystemPrompt(user: AuthUser, dashboard: Dashboard, entries: R
`User: ${user.name || user.email || "Appwrite user"}`,
`Current time (BST): ${getBstHour()}:00.`,
`Today: ${dashboard.todayCans} cans, ${dashboard.todayCaffeine} caffeine, ${dashboard.todaySugar} sugar.`,
`Personal limits: ${limitsSummaryForCoach(userLimits, limitCheck ?? { violations: [], projectedCans: 0, projectedSpend: 0, todayCans: 0, todaySpend: 0, pastStopTime: false })}`,
`All-time favourite: ${dashboard.favouriteFlavour}. Streak: ${dashboard.currentStreak} day(s). Spend: ${dashboard.totalSpend}.`,
`Flavour history:\n${buildFlavourHistorySummary(entries)}`,
`Recent entries:\n${recent || "No entries logged yet."}`,
+204
View File
@@ -0,0 +1,204 @@
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
import { getBstHour } from "./greeting";
import { currency, spendFor, sum } from "./metrics";
export const DEFAULT_LIMITS: UserLimits = {};
const PREFS_CAN_KEY = "dailyCanLimit";
const PREFS_SPEND_KEY = "dailySpendLimit";
const PREFS_STOP_KEY = "stopTime";
export function parseUserLimits(prefs: Record<string, unknown> | null | undefined): UserLimits {
if (!prefs) return { ...DEFAULT_LIMITS };
const limits: UserLimits = {};
const canLimit = Number(prefs[PREFS_CAN_KEY]);
const spendLimit = Number(prefs[PREFS_SPEND_KEY]);
const stopTime = typeof prefs[PREFS_STOP_KEY] === "string" ? prefs[PREFS_STOP_KEY] : undefined;
if (Number.isFinite(canLimit) && canLimit > 0) limits.dailyCanLimit = canLimit;
if (Number.isFinite(spendLimit) && spendLimit >= 0) limits.dailySpendLimit = spendLimit;
if (stopTime && /^\d{2}:\d{2}$/.test(stopTime)) limits.stopTime = stopTime;
return limits;
}
export function serializeUserLimits(limits: UserLimits): Record<string, unknown> {
const data: Record<string, unknown> = {};
if (limits.dailyCanLimit != null && limits.dailyCanLimit > 0) {
data[PREFS_CAN_KEY] = limits.dailyCanLimit;
}
if (limits.dailySpendLimit != null && limits.dailySpendLimit >= 0) {
data[PREFS_SPEND_KEY] = limits.dailySpendLimit;
}
if (limits.stopTime) {
data[PREFS_STOP_KEY] = limits.stopTime;
}
return data;
}
export function mergePrefsWithLimits(
existing: Record<string, unknown> | null | undefined,
limits: UserLimits,
): Record<string, unknown> {
const next = { ...(existing ?? {}) };
delete next[PREFS_CAN_KEY];
delete next[PREFS_SPEND_KEY];
delete next[PREFS_STOP_KEY];
return { ...next, ...serializeUserLimits(limits) };
}
export function formatBstDateKey(date = new Date()) {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/London",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
export function getBstMinutes(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/London",
hour: "numeric",
minute: "numeric",
hour12: false,
}).formatToParts(date);
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
return hour * 60 + minute;
}
export function parseStopTimeMinutes(stopTime: string) {
const [hours, minutes] = stopTime.split(":").map((value) => Number(value));
return hours * 60 + minutes;
}
export function isPastStopTime(stopTime: string | undefined, date = new Date()) {
if (!stopTime) return false;
return getBstMinutes(date) >= parseStopTimeMinutes(stopTime);
}
export function formatStopTimeLabel(stopTime: string) {
const [hours, minutes] = stopTime.split(":").map((value) => Number(value));
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) {
const key = formatBstDateKey(ref);
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
}
function spendForDraft(draft: EntryDraft) {
return draft.cans * draft.pricePerCan;
}
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId);
return {
todayCans: sum(todayEntries, (entry) => entry.cans),
todaySpend: sum(todayEntries, spendFor),
};
}
export function evaluateLimits(
limits: UserLimits,
entries: RedBullEntry[],
options?: { draft?: EntryDraft; excludeEntryId?: string; at?: Date },
): LimitCheckResult {
const ref = options?.at ?? new Date();
const { todayCans, todaySpend } = todayTotals(entries, options?.excludeEntryId, ref);
const draft = options?.draft;
const projectedCans = draft ? todayCans + draft.cans : todayCans;
const projectedSpend = draft ? todaySpend + spendForDraft(draft) : todaySpend;
const checkTime = draft?.dateTime ? new Date(draft.dateTime) : ref;
const pastStopTime = limits.stopTime ? isPastStopTime(limits.stopTime, checkTime) : false;
const violations: LimitViolation[] = [];
if (limits.dailyCanLimit != null) {
const over = draft ? projectedCans > limits.dailyCanLimit : todayCans >= limits.dailyCanLimit;
if (over) violations.push("cans");
}
if (limits.dailySpendLimit != null) {
const over = draft ? projectedSpend > limits.dailySpendLimit : todaySpend >= limits.dailySpendLimit;
if (over) violations.push("spend");
}
if (limits.stopTime && pastStopTime) {
violations.push("stopTime");
}
return {
violations,
projectedCans,
projectedSpend,
todayCans,
todaySpend,
pastStopTime,
};
}
export function limitProgress(current: number, limit?: number) {
if (!limit || limit <= 0) return 0;
return Math.min(100, Math.round((current / limit) * 100));
}
export function limitStatusMessage(
violations: LimitViolation[],
check: LimitCheckResult,
limits: UserLimits,
): string {
const lines: string[] = [];
if (violations.includes("cans") && limits.dailyCanLimit != null) {
lines.push(
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today (BST).`,
);
}
if (violations.includes("spend") && limits.dailySpendLimit != null) {
lines.push(
`This would bring today's spend to ${currency.format(check.projectedSpend)} of your ${currency.format(limits.dailySpendLimit)} limit.`,
);
}
if (violations.includes("stopTime") && limits.stopTime) {
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)} BST).`);
}
return lines.join(" ");
}
export function limitsSummaryForCoach(limits: UserLimits, check: LimitCheckResult): string {
const parts: string[] = [];
if (limits.dailyCanLimit != null) {
parts.push(`daily can limit: ${limits.dailyCanLimit} (${check.todayCans} logged today)`);
}
if (limits.dailySpendLimit != null) {
parts.push(`daily spend limit: ${currency.format(limits.dailySpendLimit)} (${currency.format(check.todaySpend)} today)`);
}
if (limits.stopTime) {
parts.push(
`stop drinking by: ${formatStopTimeLabel(limits.stopTime)} bst (${check.pastStopTime ? "past stop time now" : "before stop time"})`,
);
}
if (!parts.length) return "no personal daily limits configured yet.";
return parts.join(". ");
}
export function hasAnyLimit(limits: UserLimits) {
return Boolean(limits.dailyCanLimit != null || limits.dailySpendLimit != null || limits.stopTime);
}
export { getBstHour };