diff --git a/README.md b/README.md index 413a4b7d..ff90aec3 100644 --- a/README.md +++ b/README.md @@ -42,44 +42,43 @@ ## Running locally -To run the project locally, follow these steps: +This repository is a pnpm/Turbo monorepo. The current app lives in `apps/frontend-vite` and `apps/backend-node`; the old `frontend`/`backend` yarn + Python instructions no longer match the repo layout. -1. Navigate to the frontend directory and start the development server: +Install dependencies and generate Prisma client code from the repository root: - ```sh - cd frontend - yarn dev - ``` +```sh +pnpm install --frozen-lockfile +pnpm --filter @tsw/prisma db:generate +``` -2. Navigate to the backend directory and start the backend server: - ```sh - cd backend - python -m main - ``` +Create local env files from the examples and fill in the credentials needed for the flows you want to exercise: -## Installing dependencies +```sh +cp apps/backend-node/.env.example apps/backend-node/.env +cp apps/frontend-vite/.env.example apps/frontend-vite/.env +``` -1. Ensure all dependencies are installed beforehand. +For normal local development, run the database, backend, and frontend in separate terminals: - **frontend** +```sh +supabase start +pnpm --filter @tsw/prisma db:push +pnpm --filter backend-node dev +pnpm --filter frontend-vite dev +``` - ```sh - yarn - ``` +After copying `apps/backend-node/.env.example`, backend health should be available at `http://localhost:8000/health` (or the port you set in `apps/backend-node/.env`). Vite will print the frontend URL, usually `http://localhost:5173`. - **backend** We recommended to use a local virtual environment (.venv): +### Readiness checks vs. app startup - ```sh - python -m venv .venv - source .venv/bin/activate - pip install -r requirements.txt - ``` +The app can build and start locally even when optional quality commands are not clean. Use these checks for CI/readiness work, not as proof that local app startup is broken: -2. Make sure you have a [ngrok account and auth token setup](https://ngrok.com/docs/getting-started/) -3. Make sure you create and link your [clerk](https://clerk.com/) account and link the necessary env vars - - frontend - - `CLERK_SECRET_KEY` - - `CLERK_JWT_PUBLIC_KEY` - - backend - - `CLERK_JWT_PUBLIC_KEY` (get this in API Keys > Show JWT Public Key) -4. +```sh +pnpm build +pnpm --filter frontend-vite lint +pnpm --filter backend-node lint +pnpm --filter backend-node test:ci +pnpm --filter e2e-tests exec playwright test --list +``` + +At the time this note was added, `pnpm build` passes on `main`, while some lint/test/e2e checks still need separate cleanup or credentials. Treat those as CI hygiene items unless your current task is specifically to make those commands green. diff --git a/apps/backend-node/src/routes/activities.ts b/apps/backend-node/src/routes/activities.ts index 053e6e19..f04c0da3 100644 --- a/apps/backend-node/src/routes/activities.ts +++ b/apps/backend-node/src/routes/activities.ts @@ -330,6 +330,78 @@ const uploadActivityEntryPhotos = async ( ); }; +const notifyConnectionsAboutActivityPhotos = async ({ + user, + activity, + entry, + quantity, + uploadedImageCount, +}: { + user: NonNullable; + activity: { title: string; emoji: string; measure: string }; + entry: ActivityEntry; + quantity: string | number; + uploadedImageCount: number; +}) => { + const startedAt = Date.now(); + try { + const userWithConnections = await prisma.user.findUnique({ + where: { id: user.id }, + include: { + connectionsFrom: { + where: { status: "ACCEPTED" }, + include: { to: true }, + }, + connectionsTo: { + where: { status: "ACCEPTED" }, + include: { from: true }, + }, + }, + }); + + if (!userWithConnections) return; + + const connectedUsersById = new Map( + [ + ...userWithConnections.connectionsFrom.map((conn) => conn.to), + ...userWithConnections.connectionsTo.map((conn) => conn.from), + ].map((connectedUser) => [connectedUser.id, connectedUser]) + ); + const connectedUsers = Array.from(connectedUsersById.values()); + + if (connectedUsers.length === 0) return; + + const message = `${user.username} logged ${quantity} ${activity.measure} of ${activity.emoji} ${activity.title} with ${uploadedImageCount === 1 ? "a photo" : `${uploadedImageCount} photos`} ๐Ÿ“ธ!`; + + await Promise.all( + connectedUsers.map((connectedUser) => + notificationService.createAndProcessNotification({ + userId: connectedUser.id, + message, + type: "INFO", + relatedId: entry.id, + relatedData: { + activityEntryId: entry.id, + userPicture: user.picture, + userName: user.name, + userUsername: user.username, + }, + }) + ) + ); + + logger.info( + `Photo activity notifications processed for ${connectedUsers.length} connection(s) in ${Date.now() - startedAt}ms`, + { activityEntryId: entry.id } + ); + } catch (error) { + logger.error( + `Error processing photo activity notifications for entry ${entry.id}:`, + error + ); + } +}; + // Get all activities for user router.get( "/", @@ -436,6 +508,7 @@ router.post( ? timezoneFromCoords(latitude, longitude) ?? clientTimezone : clientTimezone; const photos = getUploadedActivityEntryPhotos(req); + let uploadedImageCount = 0; // Check if activity exists and belongs to user const activity = await prisma.activity.findFirst({ @@ -524,44 +597,7 @@ router.post( logger.info( `${uploadedImages.length} photo(s) uploaded successfully to S3 for activity entry ${entry.id}` ); - - // Create notifications for connected users about the photo - const userWithConnections = await prisma.user.findUnique({ - where: { id: req.user!.id }, - include: { - connectionsFrom: { - where: { status: "ACCEPTED" }, - include: { to: true }, - }, - connectionsTo: { - where: { status: "ACCEPTED" }, - include: { from: true }, - }, - }, - }); - - if (userWithConnections) { - const connectedUsers = [ - ...userWithConnections.connectionsFrom.map((conn) => conn.to), - ...userWithConnections.connectionsTo.map((conn) => conn.from), - ]; - - for (const connectedUser of connectedUsers) { - const message = `${req.user!.username} logged ${quantity} ${activity.measure} of ${activity.emoji} ${activity.title} with ${uploadedImages.length === 1 ? "a photo" : `${uploadedImages.length} photos`} ๐Ÿ“ธ!`; - await notificationService.createAndProcessNotification({ - userId: connectedUser.id, - message, - type: "INFO", - relatedId: entry.id, - relatedData: { - activityEntryId: entry.id, - userPicture: req.user!.picture, - userName: req.user!.name, - userUsername: req.user!.username, - }, - }); - } - } + uploadedImageCount = uploadedImages.length; } catch (error) { logger.error("Error uploading photo to S3:", error); // Continue without photo - don't fail the entire activity logging @@ -672,6 +708,16 @@ router.post( candidates: sharedActivityCandidates.map((c: any) => ({ id: c.activityEntryId, score: c.score })), }); res.json({ ...entry, entry, sharedActivityCandidates, sharedActivityInvite }); + + if (uploadedImageCount > 0) { + void notifyConnectionsAboutActivityPhotos({ + user: req.user!, + activity, + entry, + quantity, + uploadedImageCount, + }); + } } catch (error) { logger.error("Error logging activity:", error); res.status(500).json({ error: "Failed to log activity" }); diff --git a/apps/backend-node/src/routes/ai.ts b/apps/backend-node/src/routes/ai.ts index aa7c596c..6f0f1648 100644 --- a/apps/backend-node/src/routes/ai.ts +++ b/apps/backend-node/src/routes/ai.ts @@ -24,8 +24,11 @@ function hasPendingCoachActions(metadata: unknown): boolean { const activityLogProposals = Array.isArray(data?.activityLogProposals) ? data.activityLogProposals : []; + const planCreationProposals = Array.isArray(data?.planCreationProposals) + ? data.planCreationProposals + : []; - return [...planProposals, ...activityLogProposals].some( + return [...planProposals, ...activityLogProposals, ...planCreationProposals].some( (proposal) => !proposal.status ) || (data?.metricReplacement && !data.metricReplacement.status); } @@ -226,6 +229,7 @@ router.get( planReplacements, metricReplacement, planProposals: metadata.planProposals || [], + planCreationProposals: metadata.planCreationProposals || [], userRecommendations: metadata.userRecommendations || null, toolCalls: metadata.toolCalls || null, createdAt: msg.createdAt, @@ -292,6 +296,7 @@ router.get( planReplacements, metricReplacement, userRecommendations: parsed.userRecommendations || null, + planCreationProposals: parsed.planCreationProposals || [], toolCalls: parsed.toolCalls || null, createdAt: msg.createdAt, feedback: msg.feedback, @@ -691,6 +696,8 @@ router.post( where: { userId: user.id, deletedAt: null, + archivedAt: null, + isPaused: false, OR: [{ finishingDate: null }, { finishingDate: { gt: new Date() } }], }, include: { @@ -1412,6 +1419,24 @@ router.post( operation: "archive", success: true, }); + } else if (op.type === "update_plan") { + const updateData: Record = {}; + if (op.goal !== undefined) updateData.goal = op.goal; + if (op.goalReason !== undefined) updateData.goalReason = op.goalReason; + if (op.outlineType !== undefined) updateData.outlineType = op.outlineType; + if (op.timesPerWeek !== undefined) updateData.timesPerWeek = op.timesPerWeek; + if (op.isCoached !== undefined) updateData.isCoached = op.isCoached; + if (op.goal !== undefined) updateData.goalChanged = true; + + await prisma.plan.update({ + where: { id: proposal.planId }, + data: updateData, + }); + + changes.push({ + operation: "update_plan", + success: true, + }); } } catch (error) { logger.error("Proposal operation failed:", error); @@ -1568,6 +1593,189 @@ router.post( } ); +// Accept a plan creation proposal from AI coach +router.post( + "/messages/:messageId/accept-plan-creation-proposal", + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user!; + const { messageId } = req.params; + const { proposalIndex } = req.body; + + if (typeof proposalIndex !== "number") { + res.status(400).json({ error: "proposalIndex is required (number)" }); + return; + } + + const message = await prisma.message.findUnique({ + where: { id: messageId }, + include: { chat: true }, + }); + + if (!message) { + res.status(404).json({ error: "Message not found" }); + return; + } + + if (message.chat.userId !== user.id) { + res.status(403).json({ error: "Unauthorized" }); + return; + } + + const metadata = message.metadata as any; + if ( + !metadata?.planCreationProposals || + !metadata.planCreationProposals[proposalIndex] + ) { + res.status(400).json({ error: "Plan creation proposal not found" }); + return; + } + + const proposal = metadata.planCreationProposals[proposalIndex]; + + if (proposal.status) { + res.status(400).json({ + error: `Proposal already ${proposal.status}`, + }); + return; + } + + const plan = await prisma.$transaction(async (tx) => { + const activityIds: string[] = []; + for (const activity of proposal.activities || []) { + const existing = await tx.activity.findFirst({ + where: { + userId: user.id, + deletedAt: null, + title: { equals: activity.title, mode: "insensitive" }, + }, + }); + + const savedActivity = existing || await tx.activity.create({ + data: { + userId: user.id, + title: activity.title, + measure: activity.measure || "sessions", + emoji: activity.emoji || "๐Ÿ“‹", + kind: activity.kind || "other", + }, + }); + activityIds.push(savedActivity.id); + } + + const newPlan = await tx.plan.create({ + data: { + userId: user.id, + goal: proposal.goal, + goalReason: proposal.goalReason || null, + emoji: proposal.emoji || "๐ŸŽฏ", + outlineType: proposal.timesPerWeek ? "TIMES_PER_WEEK" : "SPECIFIC", + timesPerWeek: proposal.timesPerWeek || null, + isCoached: true, + activities: activityIds.length > 0 + ? { connect: activityIds.map((id) => ({ id })) } + : undefined, + }, + include: { activities: true, sessions: true }, + }); + + return newPlan; + }); + + metadata.planCreationProposals[proposalIndex].status = "accepted"; + metadata.planCreationProposals[proposalIndex].planId = plan.id; + await prisma.message.update({ + where: { id: messageId }, + data: { metadata }, + }); + await concludeResolvedAutonomousCoachNotifications( + user.id, + message.chatId, + messageId + ); + + logger.info( + `User ${user.username} accepted plan creation proposal: "${proposal.goal}"` + ); + + res.json({ success: true, plan }); + } catch (error) { + logger.error("Error accepting plan creation proposal:", error); + res.status(500).json({ error: "Failed to accept plan creation proposal" }); + } + } +); + +// Reject a plan creation proposal from AI coach +router.post( + "/messages/:messageId/reject-plan-creation-proposal", + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user!; + const { messageId } = req.params; + const { proposalIndex } = req.body; + + if (typeof proposalIndex !== "number") { + res.status(400).json({ error: "proposalIndex is required (number)" }); + return; + } + + const message = await prisma.message.findUnique({ + where: { id: messageId }, + include: { chat: true }, + }); + + if (!message) { + res.status(404).json({ error: "Message not found" }); + return; + } + + if (message.chat.userId !== user.id) { + res.status(403).json({ error: "Unauthorized" }); + return; + } + + const metadata = message.metadata as any; + if ( + !metadata?.planCreationProposals || + !metadata.planCreationProposals[proposalIndex] + ) { + res.status(400).json({ error: "Plan creation proposal not found" }); + return; + } + + if (metadata.planCreationProposals[proposalIndex].status) { + res.status(400).json({ + error: `Proposal already ${metadata.planCreationProposals[proposalIndex].status}`, + }); + return; + } + + metadata.planCreationProposals[proposalIndex].status = "rejected"; + await prisma.message.update({ + where: { id: messageId }, + data: { metadata }, + }); + await concludeResolvedAutonomousCoachNotifications( + user.id, + message.chatId, + messageId + ); + + logger.info( + `User ${user.username} rejected plan creation proposal: "${metadata.planCreationProposals[proposalIndex].goal}"` + ); + + res.json({ success: true }); + } catch (error) { + logger.error("Error rejecting plan creation proposal:", error); + res.status(500).json({ error: "Failed to reject plan creation proposal" }); + } + } +); + // Accept an activity log proposal from AI coach router.post( "/messages/:messageId/accept-activity-log-proposal", @@ -1899,6 +2107,7 @@ router.post( userId: user.id, deletedAt: null, archivedAt: null, + isPaused: false, OR: [ { finishingDate: null }, { finishingDate: { gt: new Date() } }, @@ -1957,6 +2166,7 @@ router.post( metadata: { planReplacements: draft.planReplacements || [], planProposals: JSON.parse(JSON.stringify(draft.planProposals || [])), + planCreationProposals: JSON.parse(JSON.stringify(draft.planCreationProposals || [])), notificationType: type, ...(draft.toolCalls && { toolCalls: JSON.parse(JSON.stringify(draft.toolCalls)), @@ -1995,6 +2205,7 @@ router.post( content: draft.content, planReplacements: draft.planReplacements || [], planProposals: draft.planProposals || [], + planCreationProposals: draft.planCreationProposals || [], toolCalls: draft.toolCalls || null, createdAt: coachMsg.createdAt, })); diff --git a/apps/backend-node/src/routes/chats.ts b/apps/backend-node/src/routes/chats.ts index 7c61c6ac..e871ec8b 100644 --- a/apps/backend-node/src/routes/chats.ts +++ b/apps/backend-node/src/routes/chats.ts @@ -296,10 +296,12 @@ router.get( planReplacements, metricReplacement, planProposals: metadata.planProposals || [], + planCreationProposals: metadata.planCreationProposals || [], activityLogProposals: metadata.activityLogProposals || [], userRecommendations: metadata.userRecommendations || null, toolCalls: metadata.toolCalls || null, error: metadata.error || false, + source: metadata.source || null, createdAt: msg.createdAt, feedback: msg.feedback, }; @@ -434,6 +436,8 @@ router.post( where: { userId: user.id, deletedAt: null, + archivedAt: null, + isPaused: false, OR: [ { finishingDate: null }, { finishingDate: { gt: new Date() } }, @@ -456,6 +460,7 @@ router.post( planReplacements?: Array<{ textToReplace: string; planGoal: string }>; metricReplacement?: { textToReplace: string; metricTitle: string; rating: number } | null; planProposals?: Array<{ planId: string; planGoal: string; planEmoji: string | null; description: string; operations: unknown[]; status: null }>; + planCreationProposals?: Array<{ goal: string; goalReason: string | null; emoji: string | null; timesPerWeek: number | null; activities: Array<{ title: string; measure: string; emoji: string; kind?: string | null }>; description: string; status: null }>; userRecommendations?: unknown; toolCalls?: Array<{ tool: string; args: unknown; result: unknown }>; }; @@ -495,6 +500,7 @@ router.post( metadata: { planReplacements: draft.planReplacements || [], planProposals: JSON.parse(JSON.stringify(draft.planProposals || [])), + planCreationProposals: JSON.parse(JSON.stringify(draft.planCreationProposals || [])), activityLogProposals: JSON.parse(JSON.stringify(draft.activityLogProposals || [])), ...(draft.toolCalls && { toolCalls: JSON.parse(JSON.stringify(draft.toolCalls)), @@ -576,6 +582,7 @@ router.post( content: draft.content, planReplacements: resolvedPlanReplacements, planProposals: draft.planProposals || [], + planCreationProposals: draft.planCreationProposals || [], activityLogProposals: draft.activityLogProposals || [], toolCalls: draft.toolCalls || null, error: draft.error || false, @@ -614,6 +621,7 @@ router.post( planReplacements: aiResponse.planReplacements || [], metricReplacement: aiResponse.metricReplacement || null, planProposals: JSON.parse(JSON.stringify(aiResponse.planProposals || [])), + planCreationProposals: JSON.parse(JSON.stringify(aiResponse.planCreationProposals || [])), userRecommendations: aiResponse.userRecommendations || null, ...(aiResponse.toolCalls && { toolCalls: JSON.parse(JSON.stringify(aiResponse.toolCalls)), @@ -726,6 +734,7 @@ router.post( planReplacements: resolvedPlanReplacements, metricReplacement: resolvedMetricReplacement, planProposals: aiResponse.planProposals || [], + planCreationProposals: aiResponse.planCreationProposals || [], userRecommendations: aiResponse.userRecommendations || null, toolCalls: aiResponse.toolCalls || null, createdAt: coachMessage.createdAt, diff --git a/apps/backend-node/src/routes/plans.ts b/apps/backend-node/src/routes/plans.ts index 89dcbeed..5e0877d3 100644 --- a/apps/backend-node/src/routes/plans.ts +++ b/apps/backend-node/src/routes/plans.ts @@ -122,32 +122,6 @@ async function markUserRecommendationsOutdated(userId: string): Promise { } } -/** - * Ensure only one plan is coached at a time - * If isCoached is being set to true, uncoach all other plans - */ -async function ensureSingleCoachedPlan( - tx: any, - userId: string, - planId: string, - isCoached: boolean -): Promise { - if (isCoached) { - // Uncoach all other plans for this user - await tx.plan.updateMany({ - where: { - userId, - deletedAt: null, - id: { not: planId }, - isCoached: true, - }, - data: { - isCoached: false, - }, - }); - } -} - // Get user's plans router.get( "/", @@ -201,7 +175,8 @@ router.get( const plansProgress = await plansService.getBatchPlanProgress( planIds, req.user!.id, - false // Use cache + false, // Use cache + { staleWhileRevalidate: true } ); // Create progress map for fast lookup @@ -1449,11 +1424,6 @@ router.post( }, }); - // Ensure only one plan is coached at a time - if (planData.isCoached) { - await ensureSingleCoachedPlan(tx, req.user!.id, newPlan.id, true); - } - return newPlan; }); @@ -1771,7 +1741,7 @@ router.post( }, }); - // Note: isCoached is false for plan group joins, no need to call ensureSingleCoachedPlan + // Note: joined plan-group plans start uncoached } // Create or update member record diff --git a/apps/backend-node/src/routes/users.ts b/apps/backend-node/src/routes/users.ts index cecb24f0..3eda63a0 100644 --- a/apps/backend-node/src/routes/users.ts +++ b/apps/backend-node/src/routes/users.ts @@ -82,6 +82,8 @@ const basicUserInclude = { const DEFAULT_TIMELINE_LIMIT = 20; const MAX_TIMELINE_LIMIT = 30; +const HABIT_BONUS_POINTS = 25; +const LIFESTYLE_BONUS_POINTS = 100; type TimelineCursor = { ts: string; @@ -116,6 +118,59 @@ const getTimelineLimit = (value: unknown) => { return Math.min(Math.floor(parsed), MAX_TIMELINE_LIMIT); }; +async function getAccountStats(userId: string) { + const [totalActivitiesLogged, plans] = await Promise.all([ + prisma.activityEntry.count({ + where: { + userId, + deletedAt: null, + activityId: { not: null }, + activity: { + deletedAt: null, + }, + }, + }), + prisma.plan.findMany({ + where: { + userId, + deletedAt: null, + }, + select: { + progressState: true, + }, + }), + ]); + + let habitCount = 0; + let lifestyleCount = 0; + let bestStreak = 0; + + for (const plan of plans) { + const progressState = plan.progressState as any; + if (!progressState) continue; + + if (progressState.habitAchievement?.isAchieved) habitCount += 1; + if (progressState.lifestyleAchievement?.isAchieved) lifestyleCount += 1; + + const streak = progressState.achievement?.streak || 0; + if (streak > bestStreak) bestStreak = streak; + } + + const habitBonus = habitCount * HABIT_BONUS_POINTS; + const lifestyleBonus = lifestyleCount * LIFESTYLE_BONUS_POINTS; + + return { + totalActivitiesLogged, + habitCount, + lifestyleCount, + habitBonus, + lifestyleBonus, + bonusPoints: habitBonus + lifestyleBonus, + totalPoints: totalActivitiesLogged + habitBonus + lifestyleBonus, + bestStreak, + }; +} + // Health check usersRouter.get("/user-health", (_req: Request, res: Response) => { res.json({ status: "ok" }); @@ -608,17 +663,25 @@ usersRouter.post( activityId: { not: null }, activity: { deletedAt: null }, }, + orderBy: [{ datetime: "desc" }, { id: "desc" }], + take: 40, include: { activity: true, comments: { where: { deletedAt: null }, - orderBy: { createdAt: "asc" }, + orderBy: { createdAt: "desc" }, + take: 2, include: { user: { select: { id: true, username: true, picture: true }, }, }, }, + _count: { + select: { + comments: true, + }, + }, reactions: { include: { user: { @@ -646,7 +709,9 @@ usersRouter.post( }, }, activityEntry: { - select: { id: true, userId: true, deletedAt: true }, + include: { + activity: true, + }, }, }, }, @@ -670,6 +735,8 @@ usersRouter.post( return; } + const accountStats = await getAccountStats(user.id); + // If viewing another user's profile, filter out private plans const isOwnProfile = user.id === req.user!.id; if (!isOwnProfile) { @@ -681,7 +748,8 @@ usersRouter.post( const plansProgress = await plansService.getBatchPlanProgress( planIds, req.user!.id, - false // Use cache + false, // Use cache + { staleWhileRevalidate: true } ); // Create progress map for fast lookup @@ -690,10 +758,15 @@ usersRouter.post( // Augment each plan with progress data and add coach profile const userWithProgress = { ...user, + activityEntries: user.activityEntries.map((entry) => ({ + ...entry, + comments: [...entry.comments].reverse(), + })), plans: user.plans.map((plan) => ({ ...plan, progress: progressMap.get(plan.id), })), + accountStats, // Extract the first (and only) human coach profile if exists coachProfile: user.coaches?.[0] || null, }; @@ -901,7 +974,7 @@ usersRouter.get( ...activityCursorWhere, }, orderBy: [{ datetime: "desc" }, { id: "desc" }], - take: limit + 1, + take: limit * 2 + 1, include: { comments: { where: { deletedAt: null }, @@ -943,7 +1016,9 @@ usersRouter.get( }, }, activityEntry: { - select: { id: true, userId: true, deletedAt: true }, + include: { + activity: true, + }, }, }, }, @@ -1039,8 +1114,20 @@ usersRouter.get( return b.id.localeCompare(a.id); }); - const pageItems = mergedTimelineItems.slice(0, limit); - const hasMoreItems = mergedTimelineItems.length > limit; + const renderedSharedActivityIds = new Set(); + const visibleMergedTimelineItems = mergedTimelineItems.filter((item) => { + if (item.type !== "activity") return true; + const sharedActivityId = + item.data.sharedActivityEntry?.sharedActivityId || + item.data.sharedActivityEntry?.sharedActivity?.id; + if (!sharedActivityId) return true; + if (renderedSharedActivityIds.has(sharedActivityId)) return false; + renderedSharedActivityIds.add(sharedActivityId); + return true; + }); + + const pageItems = visibleMergedTimelineItems.slice(0, limit); + const hasMoreItems = visibleMergedTimelineItems.length > limit; const lastPageItem = pageItems[pageItems.length - 1]; const nextCursor = hasMoreItems && lastPageItem ? encodeTimelineCursor({ @@ -1084,7 +1171,8 @@ usersRouter.get( const plansProgress = await plansService.getBatchPlanProgress( pagePlanIds, req.user!.id, - false // Use cache + false, // Use cache + { staleWhileRevalidate: true } ); // Create progress map for fast lookup diff --git a/apps/backend-node/src/services/aiService.ts b/apps/backend-node/src/services/aiService.ts index b5bea121..32dc9358 100644 --- a/apps/backend-node/src/services/aiService.ts +++ b/apps/backend-node/src/services/aiService.ts @@ -1428,6 +1428,8 @@ export class AIService { Write one short, useful proactive coach message. Use the user's name naturally if it helps: ${userName}. If the context includes a selected coach context brief insight, use at most that one personal insight and only if it naturally supports the intervention. + When saying the user logged, did, trained, or practiced something recently/lately, rely only on recent activity logs in the context. Active plans are goals, not proof of recent activity. + Do not call an activity recent if its plan context says zero entries in the last 30 days. Do not mention internal labels like intervention type, metadata, or scoring. `, prompt: dedent` diff --git a/apps/backend-node/src/services/coachAgentService.ts b/apps/backend-node/src/services/coachAgentService.ts index 3a4f263e..d204d82a 100644 --- a/apps/backend-node/src/services/coachAgentService.ts +++ b/apps/backend-node/src/services/coachAgentService.ts @@ -6,7 +6,7 @@ import { ToolLoopAgent, tool } from "ai"; import { z } from "zod/v4"; import { Plan, PlanSession, Activity, User, Reminder, RecurringType } from "@tsw/prisma"; import { prisma } from "../utils/prisma"; -import { format, endOfWeek, startOfWeek, addDays, subDays, startOfDay, endOfDay, parseISO } from "date-fns"; +import { differenceInCalendarDays, format, endOfWeek, startOfWeek, addDays, subDays, startOfDay, endOfDay, parseISO } from "date-fns"; import { activitySummarizer } from "./activitySummarizer"; import { toMidnightUTCDate } from "../utils/date"; import { logger } from "../utils/logger"; @@ -22,6 +22,16 @@ interface CoachAgentContext { reminders: Reminder[]; conversationHistory: Array<{ role: "user" | "assistant"; content: string }>; memoriesContext?: string | null; + recentActivityContext?: string | null; +} + +function isActiveCoachPlan(plan: Plan, now: Date = new Date()): boolean { + return ( + !plan.deletedAt && + !plan.archivedAt && + !plan.isPaused && + (!plan.finishingDate || plan.finishingDate > now) + ); } export class CoachAgentService { @@ -69,7 +79,14 @@ export class CoachAgentService { * Create the coach agent with tools bound to the current context */ createAgent(context: CoachAgentContext) { - const { user, plans, reminders, memoriesContext } = context; + const { + user, + plans: allPlans, + reminders, + memoriesContext, + recentActivityContext, + } = context; + const plans = allPlans.filter((plan) => isActiveCoachPlan(plan)); const self = this; const coachPersonality = getCoachPersonalityConfig(user.coachPersonality); @@ -97,8 +114,10 @@ export class CoachAgentService { const isTimesPerWeek = plan.outlineType === "TIMES_PER_WEEK"; return dedent` - Plan: ${plan.emoji || ""} ${plan.goal} [planId: ${plan.id}] + Plan: ${plan.goal} [planId: ${plan.id}] + Emoji: ${plan.emoji || "none"} ${plan.goalReason ? `Why: ${plan.goalReason}` : ""} + Coaching: ${plan.isCoached ? "coached" : "not coached yet"} Type: ${isTimesPerWeek ? `${plan.timesPerWeek}x per week (frequency-based, no scheduled sessions)` : "Specific scheduled sessions"} Activities: ${activities} ${isTimesPerWeek ? "" : `Sessions:\n ${sessionsStr || " No sessions scheduled"}`} @@ -123,20 +142,28 @@ export class CoachAgentService { .join("\n") : null; + let nextCitationIndex = 1; + + const userFirstName = + (user.name || "").trim().split(/\s+/)[0] || user.username; + const agent = new ToolLoopAgent({ model: this.getOpenRouterWithUserId().chat("google/gemini-3-flash-preview"), + temperature: 0.8, instructions: dedent` You are ${coachPersonality.displayName}, ${coachPersonality.title}, a knowledgeable fitness and habits coach that helps users achieve their fitness and habit goals through evidence-based coaching and plan adjustments. ${coachPersonality.systemPrompt} CONTEXT - User: ${user.name || user.username} + User: ${userFirstName} Today: ${format(today, "yyyy-MM-dd (EEEE)")} Current week ends: ${format(thisWeekEnd, "yyyy-MM-dd")} (Saturday) ${plansContext ? `USER'S PLANS:\n${plansContext}` : "No active plans."} + ${recentActivityContext ? `RECENT ACTIVITY FACTS:\n${recentActivityContext}` : ""} + ${remindersContext ? `USER'S REMINDERS:\n${remindersContext}` : "No active reminders."} ${memoriesContext ? `LONG-TERM MEMORY (key facts about this user):\n${memoriesContext}` : ""} @@ -144,16 +171,33 @@ export class CoachAgentService { GUIDELINES - Ask clarifying questions before making changes: current experience, constraints, things to avoid - Research first, then advise. Web search results override your assumptions - - Be direct, succint and realistic. If a goal is unrealistic given constraints, say so + - Be direct, concise, and realistic. If a goal is unrealistic given constraints, say so - Suggesting the user adjust their goal is better than setting them up to fail - - Any capability not provided by the available tools is not available to you (e.g. create a plan). + - If no plan is coached yet, treat that as setup, not an error. Ask whether the user wants to tighten an existing plan or create a new coached plan. + - When a plan goal is vague or purely frequency-based, mention that plan by its exact goal text from USER'S PLANS so it can be linked in the UI. + - If the user gives a concrete measurable goal, use proposePlanCreation for a new goal or proposePlanModification with update_plan for an existing plan. + - Any capability not provided by the available tools is not available to you. + - When saying the user logged, did, trained, or practiced something recently/lately, rely only on RECENT ACTIVITY FACTS or readActivities output. Active plans and long-term memory are not recent activity evidence. + - If an activity only appears in a plan, describe it as a goal/planned activity, not something the user has logged. + - "Recently" and "lately" mean inside the recent activity lookback unless the readActivities tool returns a different date range. + - Use webSearch only when fresh outside knowledge materially changes the answer. + - If you rely on a webSearch result, cite it inline with that result's citationLabel, like [1]. Cite only the sources you actually used. RULES - Never modify sessions or reminders without user confirmation - - Keep responses concise + - Sound like a sharp friend texting, not a report. Prefer plain words over coaching jargon. + - Default to 1-2 short messages. Only use a third message when a tool proposal needs a separate confirmation. + - Keep each message to 1-2 short sentences. Do not stack multiple critiques in one reply. + - Make one point, then ask one natural next-step question if needed. + - Avoid stiff phrases like "concrete, measurable outcome", "frequency alone is not a strategy", or "serious coached plan" unless the user used them first. + - Do not say you updated, switched, set, or changed a plan unless the user already accepted the proposal. Before acceptance, say "I can propose..." or "I'd make this..." + - Do not use update_plan as a cosmetic rename. It should represent a meaningful plan setup change, such as goal, reason, coaching status, outline type, or weekly frequency. + - For bigger rebuilds, especially new activity mixes like strength plus running, work in two stages: first confirm the target and weekly split, then propose the plan or sessions that actually encode it. + - If the existing plan cannot represent the new activity mix or schedule, prefer proposing a new coached plan after confirmation instead of only renaming the old plan. - You MUST use the draftMessages tool to send your response. Never respond with plain text. - Each message should focus on one topic/thought. Use multiple messages only when covering distinct points. - No markdown headers (#). No numbered lists. Keep it conversational like texting. + - Do not use em dashes. Use commas, periods, or parentheses instead. `, tools: { @@ -161,8 +205,8 @@ export class CoachAgentService { description: "Send your response as chat messages. Always use this to reply.", inputSchema: z.object({ messages: z.array(z.object({ - content: z.string().describe("A short chat message (1-3 sentences)"), - })).min(1).max(5), + content: z.string().describe("A short chat message (1-2 sentences)"), + })).min(1).max(3), }), execute: async ({ messages }) => ({ success: true, count: messages.length }), }), @@ -191,11 +235,16 @@ export class CoachAgentService { max_tokens_per_page: 512, }); - const results = searchResults.results.map((result) => ({ - title: result.title, - snippet: result.snippet?.substring(0, 300) || "", - url: result.url, - })); + const results = searchResults.results.map((result) => { + const citationIndex = nextCitationIndex++; + return { + citationIndex, + citationLabel: `[${citationIndex}]`, + title: result.title, + snippet: result.snippet?.substring(0, 300) || "", + url: result.url, + }; + }); logger.info( `Web search for "${query}" returned ${results.length} results` @@ -204,6 +253,8 @@ export class CoachAgentService { return { success: true as const, query, + citationInstruction: + "If you use a result in your final answer, cite it inline with its citationLabel.", results, }; } catch (error) { @@ -228,10 +279,11 @@ export class CoachAgentService { - Update existing sessions (provide sessionId and fields to update) - Remove sessions (provide sessionId) - Archive a dormant plan (no extra fields) + - Tighten plan setup (provide update_plan with a clearer goal, optional reason, optional frequency, and optional coaching flag) Use the planId, activityId, and sessionId values from the plan context above. IMPORTANT: Always provide a clear, short description of what the proposal does (e.g. "Archive gym for now", "Pause chess for next week", "Reduce gym to 2x/week"). - IMPORTANT: When adding sessions, always propose a COMPLETE week (Sun-Sat). Never propose sessions a partial week update (e.g for just from Monday-Wednesday) โ€” always cover the full week schedule. Discuss the full week with the user before proposing. + IMPORTANT: When adding sessions, always propose a COMPLETE week (Sun-Sat). Never propose a partial week update (e.g. just Monday-Wednesday). Always cover the full week schedule. Discuss the full week with the user before proposing. `, inputSchema: z.object({ planId: z.string().describe("The ID of the plan to modify"), @@ -278,6 +330,14 @@ export class CoachAgentService { z.object({ type: z.literal("archive"), }), + z.object({ + type: z.literal("update_plan"), + goal: z.string().optional().describe("Clearer measurable plan goal"), + goalReason: z.string().optional().nullable().describe("Why this plan matters to the user"), + outlineType: z.enum(["SPECIFIC", "TIMES_PER_WEEK"]).optional(), + timesPerWeek: z.number().positive().optional(), + isCoached: z.boolean().optional().describe("Set true when the user wants this plan coached"), + }), ]) ).min(1), }), @@ -299,6 +359,14 @@ export class CoachAgentService { }; } + const setupOps = operations.filter((op) => op.type === "update_plan"); + if (setupOps.length > 1 || (setupOps.length === 1 && operations.length > 1)) { + return { + success: false, + error: "Plan setup updates must be proposed as a standalone operation", + }; + } + const plan = plans.find((p) => p.id === planId); if (!plan) { return { @@ -363,6 +431,44 @@ export class CoachAgentService { }, }), + proposePlanCreation: tool({ + description: dedent` + Propose creating a new coached plan. The user can accept or reject the proposal with one click. + Use this only after the user gives a concrete goal or clearly asks for a new plan. + Prefer measurable goals with a clear outcome, e.g. "Run 20 km under 2 hours" or "Reach 80 kg with low body fat". + `, + inputSchema: z.object({ + goal: z.string().describe("Short, concrete, measurable goal"), + goalReason: z.string().optional().nullable().describe("Why the goal matters, if known"), + emoji: z.string().optional().describe("Single emoji for the plan"), + timesPerWeek: z.number().positive().optional().describe("Suggested weekly frequency, if known"), + activities: z.array(z.object({ + title: z.string().describe("Activity title, e.g. Easy run"), + measure: z.string().describe("Tracking unit, e.g. km, minutes, reps"), + emoji: z.string().describe("Single emoji for the activity"), + kind: z.string().optional().describe("Activity kind/category"), + })).min(1).max(5).optional(), + description: z.string().optional().describe("Short human-readable description"), + }), + execute: async ({ goal, goalReason, emoji, timesPerWeek, activities, description }) => { + logger.info( + `Plan creation proposed for ${user.id}: "${goal}" (${activities?.length || 0} activities)` + ); + + return { + success: true, + proposal: { + goal, + goalReason: goalReason || null, + emoji: emoji || "๐ŸŽฏ", + timesPerWeek: timesPerWeek || null, + activities: activities || [], + description: description || `Create coached plan: ${goal}`, + }, + }; + }, + }), + proposeActivityLog: tool({ description: dedent` Propose logging an activity entry for the user. The user will be able to accept or reject with one click. @@ -607,7 +713,16 @@ export class CoachAgentService { }), prisma.planSession.findMany({ where: { - plan: { userId: user.id, deletedAt: null, archivedAt: null }, + plan: { + userId: user.id, + deletedAt: null, + archivedAt: null, + isPaused: false, + OR: [ + { finishingDate: null }, + { finishingDate: { gt: now } }, + ], + }, date: { gte: from, lte: to }, }, include: { @@ -653,6 +768,74 @@ export class CoachAgentService { return agent; } + private async buildRecentActivityContext( + user: User, + now: Date, + days = 30 + ): Promise { + const from = startOfDay(subDays(now, days)); + const entries = await prisma.activityEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + activityId: { not: null }, + activity: { deletedAt: null }, + datetime: { gte: from, lte: now }, + }, + include: { + activity: { + select: { title: true, emoji: true, measure: true }, + }, + }, + orderBy: { datetime: "desc" }, + take: 20, + }); + + if (entries.length === 0) { + return `Lookback: last ${days} days. No activity entries were logged in this window. Do not call any plan activity recent unless readActivities returns newer data.`; + } + + const counts = new Map(); + for (const entry of entries) { + if (!entry.activity) continue; + const key = entry.activity.title.toLowerCase(); + const current = counts.get(key) || { + title: entry.activity.title, + emoji: entry.activity.emoji, + count: 0, + }; + current.count += 1; + counts.set(key, current); + } + + const summary = Array.from(counts.values()) + .map((activity) => `${activity.emoji} ${activity.title}: ${activity.count}`) + .join(", "); + const lines = entries.slice(0, 12).map((entry) => { + const activity = entry.activity; + const daysAgo = differenceInCalendarDays(now, entry.datetime); + const relativeLabel = + daysAgo >= 0 && daysAgo < 7 + ? daysAgo === 0 + ? "today" + : daysAgo === 1 + ? "yesterday" + : `${format(entry.datetime, "EEE")}, ${daysAgo} days ago` + : null; + const dateLabel = relativeLabel + ? `${format(entry.datetime, "yyyy-MM-dd")} (${relativeLabel})` + : format(entry.datetime, "yyyy-MM-dd"); + return `- ${dateLabel}: ${activity?.emoji || ""} ${activity?.title || "Unknown"} (${entry.quantity} ${activity?.measure || "units"})`; + }); + + return [ + `Lookback: last ${days} days.`, + `Logged activity counts: ${summary || "none"}.`, + "Most recent entries:", + ...lines, + ].join("\n"); + } + /** * Extract plan references from the message text * Looks for patterns like "plan goal" or emoji + text that match user's plans @@ -730,6 +913,15 @@ export class CoachAgentService { operations: unknown[]; status: null; }>; + planCreationProposals?: Array<{ + goal: string; + goalReason: string | null; + emoji: string | null; + timesPerWeek: number | null; + activities: Array<{ title: string; measure: string; emoji: string; kind?: string | null }>; + description: string; + status: null; + }>; activityLogProposals?: Array<{ activityId: string; activityName: string; @@ -745,13 +937,19 @@ export class CoachAgentService { skipReason?: string; }> { const { user, message, conversationHistory, plans, reminders, memoriesContext } = params; + const activePlans = plans.filter((plan) => isActiveCoachPlan(plan)); + const recentActivityContext = await this.buildRecentActivityContext( + user, + new Date() + ); const agent = this.createAgent({ user, - plans, + plans: activePlans, reminders, conversationHistory, memoriesContext, + recentActivityContext, }); try { @@ -833,8 +1031,22 @@ export class CoachAgentService { status: null as null, })); + const planCreationProposals = visibleToolCalls + .filter( + (tc) => + tc.tool === "proposePlanCreation" && + tc.result && + typeof tc.result === "object" && + (tc.result as any).success && + (tc.result as any).proposal + ) + .map((tc) => ({ + ...(tc.result as any).proposal, + status: null as null, + })); + // Build draft messages with metadata distributed across them - const plansList = plans.map((p) => ({ id: p.id, goal: p.goal, emoji: p.emoji })); + const plansList = activePlans.map((p) => ({ id: p.id, goal: p.goal, emoji: p.emoji })); const draftMessages = rawDrafts.map((draft, idx) => { const planReplacements = this.extractPlanReplacements(draft.content, plansList); const isFirst = idx === 0; @@ -845,6 +1057,8 @@ export class CoachAgentService { planReplacements: planReplacements.length > 0 ? planReplacements : undefined, // Plan proposals on the LAST message planProposals: isLast && planProposals.length > 0 ? planProposals : undefined, + // Plan creation proposals on the LAST message + planCreationProposals: isLast && planCreationProposals.length > 0 ? planCreationProposals : undefined, // Activity log proposals on the LAST message activityLogProposals: isLast && activityLogProposals.length > 0 ? activityLogProposals : undefined, // Tool calls on the FIRST message (excluding draftMessages) diff --git a/apps/backend-node/src/services/coachAssessmentService.ts b/apps/backend-node/src/services/coachAssessmentService.ts index e9bbd6bb..d937a6e2 100644 --- a/apps/backend-node/src/services/coachAssessmentService.ts +++ b/apps/backend-node/src/services/coachAssessmentService.ts @@ -65,11 +65,13 @@ type CoachInterventionType = | "INACTIVITY_ARCHIVE_PROPOSAL" | "INACTIVITY_PAUSE_PROPOSAL" | "PLAN_ADJUSTMENT" + | "COACH_SETUP" | "WEEK_PREP" | "SESSION_PREP" | "WEEK_RECAP" | "INACTIVITY_CHECKIN" - | "CELEBRATION"; + | "CELEBRATION" + | "STATUS_REVIEW"; type CoachInterventionCandidate = { type: CoachInterventionType; @@ -93,6 +95,15 @@ const INTERVENTION_PRIORITY: CoachInterventionType[] = [ "CELEBRATION", ]; +function activePlanWhere(now: Date) { + return { + deletedAt: null, + archivedAt: null, + isPaused: false, + OR: [{ finishingDate: null }, { finishingDate: { gt: now } }], + }; +} + export function isWithinPreferredCoachWindow( user: Pick, now: Date = new Date() @@ -139,20 +150,14 @@ export class CoachAssessmentService { : {}), plans: { some: { - isCoached: true, - deletedAt: null, - archivedAt: null, - isPaused: false, + ...activePlanWhere(now), }, }, }, include: { plans: { where: { - isCoached: true, - deletedAt: null, - archivedAt: null, - isPaused: false, + ...activePlanWhere(now), }, include: { activities: true, sessions: true }, }, @@ -189,26 +194,16 @@ export class CoachAssessmentService { userId: string, options: { now?: Date } = {} ): Promise { + const now = options.now || new Date(); const user = await prisma.user.findFirst({ where: { id: userId, deletedAt: null, - plans: { - some: { - isCoached: true, - deletedAt: null, - archivedAt: null, - isPaused: false, - }, - }, }, include: { plans: { where: { - isCoached: true, - deletedAt: null, - archivedAt: null, - isPaused: false, + ...activePlanWhere(now), }, include: { activities: true, sessions: true }, }, @@ -220,17 +215,152 @@ export class CoachAssessmentService { userId, username: null, action: "skipped", - reason: "No active coached plans", + reason: "User not found", }; } - return this.assessUser(user as CoachUser, { - dry_run: false, - force: true, - now: options.now || new Date(), - bypassDuplicateCheck: true, - fallbackCheckin: true, + const coachUser = user as CoachUser; + if (coachUser.plans.length === 0) { + return this.runCoachSetupCheckin(coachUser, now); + } + + // Manual assessment is a fresh introductory status review: call the coach + // brain directly instead of going through the proactive intervention picker. + const reminders = await prisma.reminder.findMany({ + where: { userId: user.id, status: "PENDING" }, + orderBy: { triggerAt: "asc" }, + }); + + const aiResponse = await coachAgentService.generateResponse({ + user, + message: this.buildStatusReviewPrompt(), + conversationHistory: [], + plans: coachUser.plans, + reminders, }); + + logger.info( + `[coach-assessment] manual status review user=${user.username} plans=${coachUser.plans.length} drafts=${aiResponse.draftMessages.length} skipped=${aiResponse.skipped}` + ); + + if (aiResponse.skipped || aiResponse.draftMessages.length === 0) { + return { + userId: user.id, + username: user.username, + action: "agent_skipped", + reason: aiResponse.skipReason || "Agent produced no assessment", + }; + } + + const candidate: CoachInterventionCandidate = { + type: "STATUS_REVIEW", + reason: "User requested a coach assessment.", + planIds: coachUser.plans.map((p) => p.id), + context: "", + usesAgent: true, + }; + + const sent = await this.dispatchCoachDrafts( + user, + candidate, + aiResponse.draftMessages, + "Coach assessment" + ); + + return { + userId: user.id, + username: user.username, + action: "sent", + reason: "Sent STATUS_REVIEW", + sentMessageIds: sent.messageIds, + notificationId: sent.notificationId, + }; + } + + private buildStatusReviewPrompt(): string { + return dedent` + You are doing an introductory status check-in with the user. This is a fresh + assessment โ€” do not treat it as a continuation of a prior conversation. + + Required: + - Open with a brief read on where they stand across their active plans, using + USER'S PLANS + RECENT ACTIVITY FACTS only. + - Give a one-line status-vs-goal take per meaningful plan (on track / slipping / strong). + - End with one concrete, realistic next step, or a single question to align focus. + - Use 2-3 short messages. Keep each to 1-2 short sentences. Sound like a sharp + friend texting, not a report. + - Don't invent activity the user hasn't logged. If a plan has no recent activity, say so plainly. + `; + } + + private async runCoachSetupCheckin( + user: CoachUser, + now: Date + ): Promise { + const recentMessages = await this.getRecentCoachMessages(user.id); + const reminders = await prisma.reminder.findMany({ + where: { userId: user.id, status: "PENDING" }, + orderBy: { triggerAt: "asc" }, + }); + + const activePlanSummary = user.plans.length > 0 + ? user.plans + .map((plan) => `- ${plan.emoji || ""} ${plan.goal}${plan.outlineType === "TIMES_PER_WEEK" && plan.timesPerWeek ? ` (${plan.timesPerWeek}x/week)` : ""}`) + .join("\n") + : "No active plans."; + + const aiResponse = await coachAgentService.generateResponse({ + user, + message: dedent` + The user manually ran a coach assessment, but they have no active coached plans. + + This is a setup moment, not an error. + + Active plans: + ${activePlanSummary} + + Required behavior: + - Speak as the coach in first person. Do not say "No active coach plans". + - If there are active plans, inspect whether any goal is vague, purely frequency-based, or missing a measurable outcome. + - When you critique or ask about an existing plan, include that plan's exact goal text from Active plans in your message so the UI can link it. + - If a plan is vague, ask whether they want to turn that exact plan into a clearer goal. + - Also offer creating a new coached plan if none of the existing plans should change. + - If there are no active plans, ask what measurable goal they want coached first. + - Sound natural and conversational. Avoid corporate phrases like "To coach you effectively". + - Do not use em dashes. Use commas, periods, or parentheses instead. + - Ask at most one crisp question. Do not propose a setup tool until the user gives a concrete target or confirms what to change. + `, + conversationHistory: recentMessages + .slice(0, 8) + .reverse() + .map((m) => ({ role: m.role === "USER" ? "user" as const : "assistant" as const, content: m.content })), + plans: user.plans, + reminders, + }); + + const candidate: CoachInterventionCandidate = { + type: "COACH_SETUP", + reason: "User requested a coach assessment without an active coached plan.", + planIds: user.plans.map((plan) => plan.id), + targetDate: format(new TZDate(now, user.timezone || "UTC"), "yyyy-MM-dd"), + context: activePlanSummary, + usesAgent: true, + }; + + const drafts = aiResponse.draftMessages.length > 0 + ? aiResponse.draftMessages + : [{ content: "I do not have a coached plan set up for you yet. Do you want to tighten one of your current plans into a measurable goal, or create a new coached plan?" }]; + + const sent = await this.dispatchCoachDrafts(user, candidate, drafts, "Coach setup"); + + return { + userId: user.id, + username: user.username, + action: "sent", + reason: "Sent coach setup check-in", + sentMessageIds: sent.messageIds, + notificationId: sent.notificationId, + }; } async autoAcceptExpiredProposals(now: Date = new Date()): Promise<{ @@ -380,14 +510,20 @@ export class CoachAssessmentService { bypassDuplicateCheck, }); - if (candidate) { + logger.info( + `[coach-assessment] user=${user.username} historyMessages=${recentMessages.length} bypassDuplicateCheck=${bypassDuplicateCheck} candidates=[${candidates + .map((c) => c.type) + .join(",")}] selected=${candidate?.type ?? "none"}` + ); + + if (candidate && candidate.type !== "COACH_SETUP") { const brief = await coachContextBriefService.buildCoachContextBrief({ user, plans: user.plans, now, }); const selectedInsight = coachContextBriefService.pickInsightForCandidate({ - candidate, + candidate: candidate as any, brief, }); candidate.context += coachContextBriefService.formatSelectedInsight(selectedInsight); @@ -528,7 +664,9 @@ export class CoachAssessmentService { - Use the available plan modification tool when proposing changes. - Mention that the user has 48 hours to decline before it applies automatically. - Use at most one personal insight from the coach context brief, and only if it makes the proposal clearer. - - Keep messages to 2-3 sentences max. + - When saying the user logged, did, trained, or practiced something recently/lately, rely only on explicit recent activity logs in the context or readActivities output. Active plans are not recent activity evidence. + - Default to 1-2 short messages. Keep each message to 1-2 short sentences. + - Sound natural, like a sharp friend texting. Avoid stacked critiques and coaching jargon. `; } @@ -546,6 +684,15 @@ export class CoachAssessmentService { operations: unknown[]; status: null; }>; + planCreationProposals?: Array<{ + goal: string; + goalReason: string | null; + emoji: string | null; + timesPerWeek: number | null; + activities: Array<{ title: string; measure: string; emoji: string; kind?: string | null }>; + description: string; + status: null; + }>; activityLogProposals?: Array<{ activityId: string; activityName: string; @@ -578,6 +725,7 @@ export class CoachAssessmentService { sessionIds: candidate.sessionIds || [], planReplacements: draft.planReplacements || [], planProposals: draft.planProposals || [], + planCreationProposals: draft.planCreationProposals || [], activityLogProposals: draft.activityLogProposals || [], ...(draft.toolCalls && { toolCalls: draft.toolCalls }), }) @@ -594,11 +742,13 @@ export class CoachAssessmentService { const hasProposal = drafts.some( (d) => (d.planProposals && d.planProposals.length > 0) || (d.activityLogProposals && d.activityLogProposals.length > 0) + || (d.planCreationProposals && d.planCreationProposals.length > 0) ); const pendingActionCount = drafts.reduce( (count, draft) => count + (draft.planProposals?.filter((proposal) => !proposal.status).length || 0) + + (draft.planCreationProposals?.filter((proposal) => !proposal.status).length || 0) + (draft.activityLogProposals?.filter((proposal) => !proposal.status).length || 0), 0 ); @@ -634,11 +784,13 @@ export class CoachAssessmentService { INACTIVITY_ARCHIVE_PROPOSAL: "Plan archive suggestion", INACTIVITY_PAUSE_PROPOSAL: "Plan pause suggestion", PLAN_ADJUSTMENT: "Plan adjustment", + COACH_SETUP: "Coach setup", WEEK_PREP: "Week prep", SESSION_PREP: "Tomorrow's session", WEEK_RECAP: "Weekly recap", INACTIVITY_CHECKIN: "Coach check-in", CELEBRATION: "Nice work", + STATUS_REVIEW: "Coach assessment", }; return titles[type]; } @@ -945,9 +1097,36 @@ export class CoachAssessmentService { const ninetyDaysAgo = subDays(now, 90); const userDayOfWeek = new TZDate(now, user.timezone || "UTC").getDay(); const lines: string[] = []; + const recentEntries = await prisma.activityEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + activityId: { not: null }, + activity: { deletedAt: null }, + datetime: { gte: thirtyDaysAgo, lte: now }, + }, + include: { + activity: { select: { title: true, emoji: true, measure: true } }, + }, + orderBy: { datetime: "desc" }, + take: 12, + }); lines.push(`Today: ${format(now, "yyyy-MM-dd (EEEE)")}`); lines.push(`Day of week: ${userDayOfWeek === 1 ? "Monday (recap day)" : format(now, "EEEE")}`); + lines.push("Grounding rule: Only activities listed under recent activity logs may be described as logged recently/lately. Active plans alone are not activity history."); + lines.push( + recentEntries.length > 0 + ? `Recent activity logs last 30 days: ${recentEntries + .map((entry) => + entry.activity + ? `${format(entry.datetime, "yyyy-MM-dd")} ${entry.activity.emoji} ${entry.activity.title}` + : null + ) + .filter(Boolean) + .join("; ")}` + : "Recent activity logs last 30 days: none" + ); lines.push(""); for (const plan of user.plans) { @@ -997,7 +1176,7 @@ export class CoachAssessmentService { private async getRecentCoachMessages(userId: string): Promise { const chats = await prisma.chat.findMany({ - where: { userId }, + where: { userId, type: "COACH" }, select: { id: true }, }); if (chats.length === 0) return []; diff --git a/apps/backend-node/src/services/plansService.ts b/apps/backend-node/src/services/plansService.ts index 1543dd7d..03e48a44 100644 --- a/apps/backend-node/src/services/plansService.ts +++ b/apps/backend-node/src/services/plansService.ts @@ -556,7 +556,8 @@ export class PlansService { async getBatchPlanProgress( planIds: string[], userId: string, - forceRecompute: boolean = false + forceRecompute: boolean = false, + options: { staleWhileRevalidate?: boolean } = {} ): Promise { const plans = await prisma.plan.findMany({ where: { id: { in: planIds } }, @@ -577,15 +578,35 @@ export class PlansService { let cachedCount = 0; let computedCount = 0; + let revalidatingCount = 0; const progressPromises = Promise.all( plans.map(async (plan) => { + const hasExpiredCache = + !!plan.progressCalculatedAt && is3DaysOld(plan.progressCalculatedAt); const shouldRecompute = !plan.progressCalculatedAt || forceRecompute || - is3DaysOld(plan.progressCalculatedAt); + hasExpiredCache; if (shouldRecompute) { + if ( + options.staleWhileRevalidate && + !forceRecompute && + hasExpiredCache && + plan.progressState + ) { + cachedCount++; + revalidatingCount++; + void this.computePlanProgress(plan, user).catch((error) => { + logger.error( + `Background progress refresh failed for plan ${plan.id}:`, + error + ); + }); + return this.getPlanProgress(plan, user); + } + computedCount++; return this.computePlanProgress(plan, user); } else { @@ -597,7 +618,7 @@ export class PlansService { const results = await progressPromises; logger.info( - `Batch progress: ${cachedCount} cached, ${computedCount} computed (${plans.length} total)` + `Batch progress: ${cachedCount} cached, ${computedCount} computed, ${revalidatingCount} revalidating (${plans.length} total)` ); return results; } diff --git a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx index 7de65521..91b77002 100644 --- a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx +++ b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx @@ -59,6 +59,132 @@ const getFormattedDate = (date: Date) => { return format(date, "MMM d"); }; + +const formatUsernameList = (usernames: string[]) => { + const uniqueNames = Array.from(new Set(usernames.filter(Boolean))); + if (uniqueNames.length === 0) return ""; + if (uniqueNames.length === 1) return uniqueNames[0]; + if (uniqueNames.length === 2) return `${uniqueNames[0]} and ${uniqueNames[1]}`; + return `${uniqueNames.slice(0, -1).join(", ")}, and ${ + uniqueNames[uniqueNames.length - 1] + }`; +}; + +type ActivityCardUser = { + username: string; + name?: string | null; + picture?: string | null; + planType?: PlanType; +}; + +const ParticipantAvatar = ({ + user, + size = "md", + isLightMode, + onClick, +}: { + user: ActivityCardUser; + size?: "sm" | "md"; + isLightMode: boolean; + onClick?: (user: ActivityCardUser) => void; +}) => { + const accountLevel = useAccountLevel(user.username || undefined); + const ringSize = size === "sm" ? 28 : 36; + const avatarSize = size === "sm" ? "w-6 h-6" : "w-8 h-8"; + const ringWidth = size === "sm" ? 1.5 : 2; + const outerRing = size === "sm" ? 3 : 5; + + return ( + + { + if (!onClick) return; + event.stopPropagation(); + onClick(user); + }} + > + + {(user.name || user.username || "U")[0]} + + + ); +}; + +const ParticipantName = ({ + user, + onClick, +}: { + user: ActivityCardUser; + onClick?: (user: ActivityCardUser) => void; +}) => { + const accountLevel = useAccountLevel(user.username || undefined); + + return ( + { + if (!onClick) return; + event.stopPropagation(); + onClick(user); + }} + > + @{user.username} + + ); +}; + +const ParticipantNameList = ({ + users, + onParticipantClick, +}: { + users: ActivityCardUser[]; + onParticipantClick?: (user: ActivityCardUser) => void; +}) => { + const uniqueUsers = users.filter( + (participant, index, list) => + participant.username && + list.findIndex((item) => item.username === participant.username) === index + ); + + return ( + <> + {uniqueUsers.map((participant, index) => ( + + {index > 0 && ( + + {index === uniqueUsers.length - 1 ? " and " : ", "} + + )} + + + ))} + + ); +}; + +type SharedActivityCardEntry = { + activityEntry: ActivityEntry & { + imageUrls?: string[]; + }; + activity: Activity; + user: { username: string; name: string | null; picture: string | null; planType: PlanType }; +}; + interface ActivityEntryPhotoCardProps { activity: Activity; activityEntry: ActivityEntry & { @@ -69,17 +195,23 @@ interface ActivityEntryPhotoCardProps { entries?: { activityEntryId: string; user: { id: string; username: string | null; name?: string | null; picture?: string | null }; - activityEntry?: { id: string; userId: string; deletedAt?: Date | null }; + activityEntry?: (ActivityEntry & { + imageUrls?: string[]; + activity?: Activity | null; + deletedAt?: Date | null; + }) | null; }[]; }; } | null; }; user: { username: string; name: string; picture: string; planType: PlanType }; userPlansProgressData: PlanProgressData[]; + sharedActivityEntries?: SharedActivityCardEntry[]; editable?: boolean; onEditClick?: () => void; onAvatarClick?: () => void; onUsernameClick?: () => void; + onParticipantClick?: (username: string) => void; isCollapsed?: boolean; onToggleCollapse?: () => void; } @@ -103,10 +235,12 @@ const ActivityEntryPhotoCard: React.FC = ({ onAvatarClick, onEditClick, onUsernameClick, + onParticipantClick, activity, activityEntry, user, userPlansProgressData, + sharedActivityEntries = [], isCollapsed = false, onToggleCollapse, }) => { @@ -151,6 +285,14 @@ const ActivityEntryPhotoCard: React.FC = ({ (plan) => plan.lifestyleAchievement.isAchieved ); const accountLevel = useAccountLevel(user.username || undefined); + const handleParticipantClick = useCallback( + (participant: ActivityCardUser) => { + if (participant.username) { + onParticipantClick?.(participant.username); + } + }, + [onParticipantClick] + ); const [showAllComments, setShowAllComments] = useState(false); const { @@ -350,14 +492,56 @@ const ActivityEntryPhotoCard: React.FC = ({ }`; }; - const imageUrls = Array.from( - new Set( - [ - ...((activityEntry as typeof activityEntry & { imageUrls?: string[] }) - .imageUrls || []), - activityEntry.imageUrl, - ].filter((url): url is string => !!url) + const getEntryImageUrls = ( + entry: ActivityEntry & { imageUrls?: string[] } + ) => { + if (entry.imageExpiresAt && new Date(entry.imageExpiresAt) < new Date()) { + return []; + } + + return [ + ...(entry.imageUrls || []), + entry.imageUrl, + ].filter((url): url is string => !!url); + }; + + const embeddedSharedActivityEntries = ( + activityEntry.sharedActivityEntry?.sharedActivity?.entries || [] + ) + .filter( + (entry) => + entry.activityEntryId !== activityEntry.id && + !entry.activityEntry?.deletedAt && + entry.user.username && + entry.activityEntry?.activity ) + .map((entry) => ({ + activityEntry: entry.activityEntry as ActivityEntry & { imageUrls?: string[] }, + activity: entry.activityEntry!.activity as Activity, + user: { + username: entry.user.username as string, + name: entry.user.name ?? null, + picture: entry.user.picture ?? null, + planType: user.planType, + }, + })); + + const allSharedActivityEntries = Array.from( + new Map( + [...sharedActivityEntries, ...embeddedSharedActivityEntries].map((entry) => [ + entry.activityEntry.id, + entry, + ]) + ).values() + ); + + const imageUrls = Array.from( + new Set([ + ...getEntryImageUrls(activityEntry as typeof activityEntry & { imageUrls?: string[] }), + ...allSharedActivityEntries.flatMap(({ activityEntry }) => + getEntryImageUrls(activityEntry) + ), + ]) ); const hasImage = imageUrls.length > 0; const shouldShowNeonEffect = habitAchieved || lifestyleAchieved; @@ -370,59 +554,90 @@ const ActivityEntryPhotoCard: React.FC = ({ if (!activity || !activityEntry) return null; - const trimmedActivityTitle = activity.title.length > 10 ? activity.title.slice(0, 10) + "..." : activity.title; - - const sharedParticipants = - activityEntry.sharedActivityEntry?.sharedActivity?.entries - ?.filter( - (entry) => - entry.activityEntryId !== activityEntry.id && !entry.activityEntry?.deletedAt - ) - ?.map((entry) => entry.user) - ?.filter((participant) => participant.username) ?? []; - const sharedParticipantLabel = sharedParticipants - .map((participant) => `@${participant.username}`) - .join(", "); + const isMergedJointActivity = allSharedActivityEntries.length > 0; + const activitySummaryRows = [ + { activityEntry, activity, user }, + ...allSharedActivityEntries, + ]; + const jointParticipants = activitySummaryRows.map((row) => row.user); + const sharedParticipants = ( + isMergedJointActivity + ? [] + : activityEntry.sharedActivityEntry?.sharedActivity?.entries + ?.filter( + (entry) => + entry.activityEntryId !== activityEntry.id && + !entry.activityEntry?.deletedAt + ) + ?.map((entry) => entry.user) ?? [] + ).filter((participant) => participant.username); + const sharedParticipantLabel = formatUsernameList( + sharedParticipants.map((participant) => `@${participant.username}`) + ); + const mergedActivityLabel = formatUsernameList( + activitySummaryRows.map( + (row) => + `${row.activity.emoji} ${row.activity.title} (${row.activityEntry.quantity} ${row.activity.measure})` + ) + ); + const mergedEmojiRows = activitySummaryRows.slice(0, 3); // Collapsed minimal view for cards without images const collapsedCardContent = (
-
- - + {isMergedJointActivity ? ( + jointParticipants.slice(0, 3).map((participant) => ( + + )) + ) : ( +
{ e.stopPropagation(); onAvatarClick?.(); }} > - - {(user.name || "U")[0]} - - + +
+ )}
- {activity.emoji} + {isMergedJointActivity ? ( +
+ {mergedEmojiRows.map((row) => ( + + {row.activity.emoji} + + ))} +
+ ) : ( + {activity.emoji} + )}
- {activity.title} + {isMergedJointActivity ? ( + + ) : ( + activity.title + )} - {activityEntry.quantity} {activity.measure} + {isMergedJointActivity + ? mergedActivityLabel + : `${activityEntry.quantity} ${activity.measure}`} {sharedParticipantLabel && ( @@ -636,63 +851,114 @@ const ActivityEntryPhotoCard: React.FC = ({ )}
-
-
- - - - {(user.name || "U")[0]} - - + {isMergedJointActivity ? ( +
+
+
+ {jointParticipants.slice(0, 3).map((participant) => ( + + ))} +
+
+ +
+
+
+
+ Joint activity +
+
+ {activitySummaryRows.map((row) => ( +
+ {row.activity.emoji} + + + @{row.user.username} + {" "} + + {row.activity.title} - {row.activityEntry.quantity}{" "} + {row.activity.measure} + + +
+ ))} +
+
+ + {getFormattedDate(activityEntry.datetime)}{" "} + {activityEntry.timezone && `- ๐Ÿ“ ${activityEntry.timezone}`} +
- - {activity.emoji} - -
-
- +
+ - @{user.username} - - {/* {accountLevel.atLeastBronze && - accountLevel.currentLevel?.getIcon({ - size: 16, - className: "drop-shadow-sm", - })} */} + + + {(user.name || "U")[0]} + +
- - {activity.title} โ€“ {activityEntry.quantity} {activity.measure} + + {activity.emoji} - {sharedParticipantLabel && ( +
+
+ + @{user.username} + + {/* {accountLevel.atLeastBronze && + accountLevel.currentLevel?.getIcon({ + size: 16, + className: "drop-shadow-sm", + })} */} +
+ + {activity.title} - {activityEntry.quantity} {activity.measure} + + {sharedParticipantLabel && ( + + with {sharedParticipantLabel} + + )} - with {sharedParticipantLabel} + {getFormattedDate(activityEntry.datetime)}{" "} + {activityEntry.timezone && `- ๐Ÿ“ ${activityEntry.timezone}`} - )} - - {getFormattedDate(activityEntry.datetime)}{" "} - {activityEntry.timezone && `โ€“ ๐Ÿ“ ${activityEntry.timezone}`} - +
-
+ )}
{/* Non-image posts: link preview (if link exists) */} diff --git a/apps/frontend-vite/src/components/ActivityPhotoUploader.tsx b/apps/frontend-vite/src/components/ActivityPhotoUploader.tsx index f1baf088..8d1b6a1f 100644 --- a/apps/frontend-vite/src/components/ActivityPhotoUploader.tsx +++ b/apps/frontend-vite/src/components/ActivityPhotoUploader.tsx @@ -35,11 +35,13 @@ const ActivityPhotoUploader: React.FC = ({ }) => { const [selectedFiles, setSelectedFiles] = useState([]); const [description, setDescription] = useState(""); + const [uploadProgress, setUploadProgress] = useState(null); const { logActivity: submitActivity, isLoggingActivity } = useActivities(); const { addToNotificationCount } = useNotifications(); const handleLogActivity = async () => { try { + setUploadProgress(selectedFiles.length > 0 ? 0 : null); const response = await submitActivity({ activityId: activityData.activityId, datetime: activityData.datetime, @@ -49,6 +51,7 @@ const ActivityPhotoUploader: React.FC = ({ withUserId: activityData.withUserId, latitude: activityData.latitude, longitude: activityData.longitude, + onUploadProgress: setUploadProgress, }); if (!response.entry?.id) { @@ -60,9 +63,23 @@ const ActivityPhotoUploader: React.FC = ({ } catch (error: any) { console.error("Error logging activity:", error); toast.error("Failed to log activity. Please try again."); + } finally { + setUploadProgress(null); } }; + const getSubmitLabel = () => { + if (isLoggingActivity && selectedFiles.length > 0) { + return uploadProgress != null && uploadProgress < 100 + ? `Uploading ${selectedFiles.length} photo${selectedFiles.length === 1 ? "" : "s"}โ€ฆ ${uploadProgress}%` + : "Saving activityโ€ฆ"; + } + + return selectedFiles.length > 0 + ? `Upload ${selectedFiles.length} photo${selectedFiles.length === 1 ? "" : "s"}` + : "Log without photo"; + }; + return (

๐Ÿ“ธ Add a proof!

@@ -96,9 +113,7 @@ const ActivityPhotoUploader: React.FC = ({ {isLoggingActivity ? ( ) : null} - {selectedFiles.length > 0 - ? `Upload ${selectedFiles.length} photo${selectedFiles.length === 1 ? "" : "s"}` - : "Log without photo"} + {getSubmitLabel()}
diff --git a/apps/frontend-vite/src/components/BaseHeatmapRenderer.tsx b/apps/frontend-vite/src/components/BaseHeatmapRenderer.tsx index a3d40fb6..99e430e0 100644 --- a/apps/frontend-vite/src/components/BaseHeatmapRenderer.tsx +++ b/apps/frontend-vite/src/components/BaseHeatmapRenderer.tsx @@ -8,7 +8,7 @@ import HeatMap from "@uiw/react-heat-map"; import { format, subDays, differenceInDays } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; import { Brush, ChevronDown, ChevronRight, Lock } from "lucide-react"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useUpgrade } from "@/contexts/upgrade/useUpgrade"; @@ -182,27 +182,48 @@ const BaseHeatmapRenderer: React.FC = ({ ) : 52; - // Show the current week by default without pinning it to the clipped edge. - useEffect(() => { - const timeoutId = setTimeout(() => { + const scrollTodayIntoHeatmapView = useCallback( + (behavior: ScrollBehavior = "auto") => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + const cellId = uniqueId ? `heatmap-today-cell-${uniqueId}` : "heatmap-today-cell"; const todayCell = document.getElementById(cellId); if (todayCell) { - todayCell.scrollIntoView({ - behavior: "auto", - block: "nearest", - inline: "center", + const cellRect = todayCell.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + const targetLeft = + scrollContainer.scrollLeft + + cellRect.left - + containerRect.left - + scrollContainer.clientWidth / 2 + + cellRect.width / 2; + const maxLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth; + + scrollContainer.scrollTo({ + left: Math.max(0, Math.min(targetLeft, maxLeft)), + behavior, + }); + } else { + scrollContainer.scrollTo({ + left: scrollContainer.scrollWidth, + behavior, }); - } else if (scrollContainerRef.current) { - scrollContainerRef.current.scrollLeft = - scrollContainerRef.current.scrollWidth; } + }, + [uniqueId] + ); + + // Show the current week by default without changing the page's vertical scroll. + useEffect(() => { + const timeoutId = setTimeout(() => { + scrollTodayIntoHeatmapView(); }, 100); return () => clearTimeout(timeoutId); - }, [heatmapData.length, uniqueId]); + }, [heatmapData.length, scrollTodayIntoHeatmapView]); // Use Intersection Observer to detect when today's cell is visible useEffect(() => { @@ -238,17 +259,7 @@ const BaseHeatmapRenderer: React.FC = ({ // Scroll to today's cell when user clicks the arrow button const scrollToToday = () => { - const cellId = uniqueId - ? `heatmap-today-cell-${uniqueId}` - : "heatmap-today-cell"; - const todayCell = document.getElementById(cellId); - if (todayCell) { - todayCell.scrollIntoView({ - behavior: "smooth", - block: "nearest", - inline: "center", - }); - } + scrollTodayIntoHeatmapView("smooth"); }; const renderActivityLegend = () => { const colorMatrix = getActivityColorMatrix(isLightMode); diff --git a/apps/frontend-vite/src/components/CalendarGrid.tsx b/apps/frontend-vite/src/components/CalendarGrid.tsx index 38e853c6..544e949f 100644 --- a/apps/frontend-vite/src/components/CalendarGrid.tsx +++ b/apps/frontend-vite/src/components/CalendarGrid.tsx @@ -5,6 +5,7 @@ import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from "d import { useState, useEffect } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { X, Check, Pencil } from "lucide-react"; +import type { GhostCell } from "@/utils/ghostGrid"; export interface CalendarSession { id?: string; @@ -26,6 +27,8 @@ export interface CalendarActivity { interface CalendarGridProps { sessions: CalendarSession[]; activities: CalendarActivity[]; + /** Non-committed cells (frequency-plan suggestions + overflow) rendered muted, by day. */ + ghostCells?: GhostCell[]; className?: string; /** Function to check if an activity is completed on a day. If not provided, completion indicators won't show */ isCompletedOnDay?: (activityId: string, day: Date) => boolean; @@ -42,6 +45,7 @@ interface CalendarGridProps { export const CalendarGrid = ({ sessions, activities, + ghostCells = [], className, isCompletedOnDay, onSessionSelect, @@ -50,6 +54,7 @@ export const CalendarGrid = ({ weekLabels = { week1: "This week", week2: "Next week" }, }: CalendarGridProps) => { const [selectedSessionId, setSelectedSessionId] = useState(null); + const [selectedGhostDay, setSelectedGhostDay] = useState(null); const [expandedImage, setExpandedImage] = useState(null); // Derive selectedSession from props to always have fresh data @@ -80,11 +85,16 @@ export const CalendarGrid = ({ }); }; + const getGhostsForDay = (day: Date) => { + return ghostCells.filter((cell) => isSameDay(cell.date, day)); + }; + const getActivity = (activityId: string) => { return activities.find((a) => a.id === activityId); }; const handleSessionClick = (session: CalendarSession, activity: CalendarActivity) => { + setSelectedGhostDay(null); const isCurrentlySelected = selectedSessionId === session.id; if (isCurrentlySelected) { @@ -95,6 +105,11 @@ export const CalendarGrid = ({ } }; + const handleGhostDayClick = (day: Date) => { + setSelectedSessionId(null); + setSelectedGhostDay((prev) => (prev && isSameDay(prev, day) ? null : day)); + }; + const handleDayClick = (day: Date) => { const daySessions = getSessionsForDay(day); if (daySessions.length > 0) { @@ -115,19 +130,31 @@ export const CalendarGrid = ({ const DayCell = ({ day }: { day: Date }) => { const daySessions = getSessionsForDay(day); + const dayGhosts = getGhostsForDay(day); const isToday = isSameDay(day, today); const isPast = isBefore(startOfDay(day), startOfDay(today)); const hasSession = daySessions.length > 0; + const hasSuggestion = dayGhosts.some( + (c) => c.kind === "ghost" || c.kind === "overflow" + ); + const isGhostSelected = !!selectedGhostDay && isSameDay(selectedGhostDay, day); return (
hasSession && handleDayClick(day)} + onClick={() => { + if (hasSession) handleDayClick(day); + else if (hasSuggestion) handleGhostDayClick(day); + }} className={cn( "flex flex-col items-center p-1 min-h-[72px] rounded-lg border transition-all", - isToday && cn(variants.brightBorder, variants.veryFadedBg), - !isToday && "border-border bg-card", - isPast && !isToday && "opacity-50", - hasSession && !isPast && "cursor-pointer hover:border-muted-foreground/50" + // Today: outline only (no fill) so it stays distinct from the selected day. + isToday ? variants.brightBorder : "border-border", + // Selected day: filled + ring. + isGhostSelected + ? cn(variants.veryFadedBg, "ring-2", variants.ring) + : !isToday && "bg-card", + isPast && !isToday && !isGhostSelected && "opacity-50", + (hasSession || hasSuggestion) && !isPast && "cursor-pointer hover:border-muted-foreground/50" )} > ); })} + {dayGhosts.map((cell, idx) => { + const activity = getActivity(cell.activityId); + const emoji = activity?.emoji || "๐Ÿ“‹"; + + if (cell.kind === "completed") { + return ( + + {emoji} + + + + + ); + } + + const isOverflow = cell.kind === "overflow"; + return ( + + {emoji} + + ); + })}
); @@ -205,6 +271,68 @@ export const CalendarGrid = ({ + {/* Ghost (suggested session) explanation */} + + {selectedGhostDay && + (() => { + const ghosts = getGhostsForDay(selectedGhostDay).filter( + (g) => g.kind === "ghost" || g.kind === "overflow" + ); + if (ghosts.length === 0) return null; + const hasOverflow = ghosts.some((g) => g.kind === "overflow"); + const acts = Array.from(new Set(ghosts.map((g) => g.activityId))) + .map(getActivity) + .filter(Boolean) as CalendarActivity[]; + + return ( + +
+
+ + {acts.map((a) => a.emoji || "๐Ÿ“‹").join(" ") || "๐Ÿ“‹"} + +
+

+ Suggested session +

+

+ {format(selectedGhostDay, "EEEE, MMM d")} +

+
+
+ +
+

+ {acts.map((a) => a.title).join(" & ") || "This plan"} has a weekly + target rather than fixed days, so it isn't tied to this date โ€” log + it whenever works. These dashed markers spread your remaining + sessions across the open days so you can see whether they fit. +

+ {hasOverflow && ( +

+ โš ๏ธ More sessions remain than days left this week โ€” some won't fit + unless you double up. +

+ )} +
+ ); + })()} +
+ {/* Selected session detail */} {selectedSession && ( diff --git a/apps/frontend-vite/src/components/CoachHomeSection.tsx b/apps/frontend-vite/src/components/CoachHomeSection.tsx index b952fc7b..e82c23ed 100644 --- a/apps/frontend-vite/src/components/CoachHomeSection.tsx +++ b/apps/frontend-vite/src/components/CoachHomeSection.tsx @@ -55,24 +55,24 @@ export const CoachHomeSection = () => { new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() )[0]; - const activePlans = plans?.filter( - (plan) => - plan.deletedAt === null && - (plan.finishingDate === null || isAfter(plan.finishingDate, new Date())) - ); - const coachedPlans = activePlans?.filter((plan) => plan.isCoached) ?? []; - const plansNeedingAttention = coachedPlans.filter((plan) => + const activePlans = + plans?.filter( + (plan) => + plan.deletedAt === null && + (plan.finishingDate === null || isAfter(plan.finishingDate, new Date())) + ) ?? []; + const plansNeedingAttention = activePlans.filter((plan) => ["AT_RISK", "FAILED"].includes(plan.currentWeekState || "") ); const notificationPlanId = getRelatedPlanId(latestCoachNotification); const reviewPlan = - coachedPlans.find((plan) => plan.id === notificationPlanId) || + activePlans.find((plan) => plan.id === notificationPlanId) || plansNeedingAttention[0] || - coachedPlans[0]; + activePlans[0]; const hasReviewAction = !!latestCoachNotification || plansNeedingAttention.length > 0; - if (coachedPlans.length === 0) { + if (activePlans.length === 0) { return null; } @@ -88,8 +88,8 @@ export const CoachHomeSection = () => { const coachSummary = plansNeedingAttention.length > 0 - ? `${coachedPlans.length} coached plans ยท ${plansNeedingAttention.length} needs attention` - : `${coachedPlans.length} coached plan${coachedPlans.length === 1 ? "" : "s"} ยท on track`; + ? `${activePlans.length} plans ยท ${plansNeedingAttention.length} needs attention` + : `${activePlans.length} plan${activePlans.length === 1 ? "" : "s"} ยท on track`; return (
; className?: string; + content?: string; } // Extract unique sources from web search tool calls -function extractSources(toolCalls: ToolCall[]): WebSearchResult[] { +function extractSources(toolCalls: ToolCall[], content?: string): WebSearchResult[] { + if (!content) return []; + + const citedLabelsInOrder = Array.from(content.matchAll(/\[(\d+)\]/g)).map( + (match) => `[${match[1]}]` + ); + const citedLabels = new Set(citedLabelsInOrder); + if (citedLabelsInOrder.length === 0) return []; + const displayLabels = new Map(); + citedLabelsInOrder.forEach((label) => { + if (!displayLabels.has(label)) { + displayLabels.set(label, `[${displayLabels.size + 1}]`); + } + }); + const sources: WebSearchResult[] = []; const seenUrls = new Set(); + let fallbackIndex = 1; for (const tc of toolCalls) { if (tc.tool === "webSearch" && tc.result?.results) { const results = tc.result.results as WebSearchResult[]; for (const result of results) { + const citationLabel = + result.citationLabel || (result.citationIndex ? `[${result.citationIndex}]` : `[${fallbackIndex}]`); + fallbackIndex += 1; + if (!citedLabels.has(citationLabel)) continue; if (result.url && !seenUrls.has(result.url)) { seenUrls.add(result.url); - sources.push(result); + sources.push({ + ...result, + citationLabel, + displayCitationLabel: displayLabels.get(citationLabel) || citationLabel, + }); } } } @@ -123,6 +150,11 @@ const SourceCard: React.FC<{ source: WebSearchResult; ogData?: OGData }> = ({ so
{/* Site info */}
+ {(source.displayCitationLabel || source.citationLabel) && ( + + {(source.displayCitationLabel || source.citationLabel || "").replace(/^\[|\]$/g, "")} + + )} = ({ sources }) => { > - {sources.length} source{sources.length !== 1 ? "s" : ""} + {sources.length} cited source{sources.length !== 1 ? "s" : ""} = ({ toolCalls, plans = [], className, + content, }) => { const themeColors = useThemeColors(); const variants = getThemeVariants(themeColors.raw); @@ -262,7 +295,7 @@ export const CoachToolCallsCard: React.FC = ({ const webSearches = toolCalls.filter((tc) => tc.tool === "webSearch"); // Extract sources from web searches - const sources = useMemo(() => extractSources(toolCalls), [toolCalls]); + const sources = useMemo(() => extractSources(toolCalls, content), [toolCalls, content]); if (planAdaptations.length === 0 && reminderOperations.length === 0 && sources.length === 0) { return null; diff --git a/apps/frontend-vite/src/components/MessageBubble.tsx b/apps/frontend-vite/src/components/MessageBubble.tsx index e15b10c4..ee36f65c 100644 --- a/apps/frontend-vite/src/components/MessageBubble.tsx +++ b/apps/frontend-vite/src/components/MessageBubble.tsx @@ -3,12 +3,21 @@ import { cn } from "@/lib/utils"; interface MessageBubbleProps { direction: "left" | "right"; className?: string; + timestamp?: Date | string | null; children: React.ReactNode; } +function formatMessageTime(timestamp: Date | string): string { + return new Date(timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + export function MessageBubble({ direction, className, + timestamp, children }: MessageBubbleProps) { return ( @@ -20,6 +29,11 @@ export function MessageBubble({ )} > {children} + {timestamp ? ( +
+ {formatMessageTime(timestamp)} +
+ ) : null}
); } diff --git a/apps/frontend-vite/src/components/PlanCreationProposalCard.tsx b/apps/frontend-vite/src/components/PlanCreationProposalCard.tsx new file mode 100644 index 00000000..38e56acd --- /dev/null +++ b/apps/frontend-vite/src/components/PlanCreationProposalCard.tsx @@ -0,0 +1,129 @@ +import { Button } from "@/components/ui/button"; +import { useThemeColors } from "@/hooks/useThemeColors"; +import { Check, Loader2, Plus, X } from "lucide-react"; +import { useState } from "react"; + +interface PlanCreationProposalCardProps { + messageId: string; + proposalIndex: number; + goal: string; + goalReason?: string | null; + emoji?: string | null; + timesPerWeek?: number | null; + activities?: Array<{ title: string; measure: string; emoji: string }>; + description?: string; + status?: "accepted" | "rejected" | null; + onAccept: (messageId: string, proposalIndex: number) => Promise; + onReject: (messageId: string, proposalIndex: number) => Promise; +} + +export function PlanCreationProposalCard({ + messageId, + proposalIndex, + goal, + goalReason, + emoji, + timesPerWeek, + activities = [], + description, + status, + onAccept, + onReject, +}: PlanCreationProposalCardProps) { + const themeColors = useThemeColors(); + const [isAccepted, setIsAccepted] = useState(status === "accepted"); + const [isRejected, setIsRejected] = useState(status === "rejected"); + const [isLoading, setIsLoading] = useState(false); + + const compactLabel = ( + + {emoji && {emoji}} + {goal} + + ); + + const handleAccept = async () => { + setIsLoading(true); + try { + await onAccept(messageId, proposalIndex); + setIsAccepted(true); + } finally { + setIsLoading(false); + } + }; + + const handleReject = async () => { + setIsLoading(true); + try { + await onReject(messageId, proposalIndex); + setIsRejected(true); + } finally { + setIsLoading(false); + } + }; + + if (isRejected) { + return ( +
+ {compactLabel} + +
+ ); + } + + if (isAccepted) { + return ( +
+ {compactLabel} + +
+ ); + } + + return ( +
+
+
+
+ + {compactLabel} +
+ {description && ( +
{description}
+ )} + {goalReason && ( +
{goalReason}
+ )} +
+ {timesPerWeek ? {timesPerWeek}x/week : null} + {activities.map((activity) => ( + + {activity.emoji} {activity.title} ยท {activity.measure} + + ))} +
+
+
+ + +
+
+
+ ); +} diff --git a/apps/frontend-vite/src/components/PlanLink.tsx b/apps/frontend-vite/src/components/PlanLink.tsx index cf76e83d..e8ef7f23 100644 --- a/apps/frontend-vite/src/components/PlanLink.tsx +++ b/apps/frontend-vite/src/components/PlanLink.tsx @@ -22,6 +22,9 @@ export function PlanLink({ planId, displayText, emoji }: PlanLinkProps) { const [showPreview, setShowPreview] = useState(false); const plan = useMemo(() => plans?.find((p) => p.id === planId), [plans, planId]); + const displayStartsWithEmoji = Boolean( + emoji && displayText.trim().startsWith(emoji) + ); // Get upcoming sessions (next 7 days) const upcomingSessions = useMemo(() => { @@ -68,11 +71,11 @@ export function PlanLink({ planId, displayText, emoji }: PlanLinkProps) { className={`inline-flex items-center gap-1.5 font-medium cursor-pointer rounded-md px-2 py-0.5 transition-all text-foreground/90 ${themeColors.fadedBg} hover:${themeColors.bg}`} onClick={handleClick} > - {emoji ? ( + {emoji && !displayStartsWithEmoji ? ( {emoji} - ) : ( + ) : !emoji ? ( - )} + ) : null} {displayText} diff --git a/apps/frontend-vite/src/components/PlanProgressCard.tsx b/apps/frontend-vite/src/components/PlanProgressCard.tsx index 0496f171..b0c3a7d9 100644 --- a/apps/frontend-vite/src/components/PlanProgressCard.tsx +++ b/apps/frontend-vite/src/components/PlanProgressCard.tsx @@ -7,14 +7,11 @@ import { useNavigate } from "@tanstack/react-router"; import { format, isSameWeek, isAfter, startOfDay, isSameDay } from "date-fns"; import { AnimatePresence } from "framer-motion"; import { - AlertTriangle, CircleCheck, Flame, MoveRight, Rocket, Sprout, - TrendingDown, - TrendingUp, } from "lucide-react"; import { motion } from "motion/react"; import React, { useRef, useState } from "react"; @@ -26,6 +23,7 @@ import { getCoachPlanInsight, getPlanDomainLabel, } from "@/utils/coachPlanDisplay"; +import { getPlanStateVisuals } from "@/utils/planState"; interface ComingUpSectionProps { sessions: any[]; @@ -146,37 +144,19 @@ const ComingUpSection: React.FC = ({ }; export const PlanStatus = ({ plan }: { plan: CompletePlan }) => { - if (!plan?.currentWeekState) { + const visuals = getPlanStateVisuals(plan?.currentWeekState); + if (!visuals) { return null; } - const statusConfig = { - ON_TRACK: { - icon: , - message: "On track!", - }, - AT_RISK: { - icon: , - message: "At risk", - }, - FAILED: { - icon: , - message: "Off track!", - }, - COMPLETED: { - icon: , - message: "Week completed!", - }, - }; - - const config = statusConfig[plan.currentWeekState]; + const { Icon } = visuals; return (
- {config.icon} + - {config.message} + {visuals.message}
); diff --git a/apps/frontend-vite/src/components/PlanProposalCard.tsx b/apps/frontend-vite/src/components/PlanProposalCard.tsx index d81cd865..0631cdbf 100644 --- a/apps/frontend-vite/src/components/PlanProposalCard.tsx +++ b/apps/frontend-vite/src/components/PlanProposalCard.tsx @@ -12,6 +12,10 @@ export interface ResolvedOperation { activityEmoji?: string; activityMeasure?: string; descriptiveGuide?: string; + goal?: string; + goalReason?: string | null; + timesPerWeek?: number; + isCoached?: boolean; } interface PlanProposalCardProps { @@ -114,6 +118,15 @@ export function PlanProposalCard({ Archive plan + ) : op.type === "update_plan" ? ( + <> + ๐ŸŽฏ + + {op.goal ? `Update goal to "${op.goal}"` : "Update plan setup"} + {op.timesPerWeek ? ` ยท ${op.timesPerWeek}x/week` : ""} + {op.isCoached ? " ยท coached" : ""} + + ) : ( <> {op.activityEmoji} diff --git a/apps/frontend-vite/src/components/TimelineRenderer.tsx b/apps/frontend-vite/src/components/TimelineRenderer.tsx index f6fa9839..b29db189 100644 --- a/apps/frontend-vite/src/components/TimelineRenderer.tsx +++ b/apps/frontend-vite/src/components/TimelineRenderer.tsx @@ -52,6 +52,7 @@ const TimelineRenderer: React.FC<{ timelineData, isLoadingTimeline, isFetchingNextTimelinePage, + isFetchNextTimelinePageError, hasMoreTimeline, fetchNextTimelinePage, } = useTimeline(); @@ -121,17 +122,33 @@ const TimelineRenderer: React.FC<{ } }, [dbLastSeenTimelineAt]); + const entryHasRenderableImage = (entry: TimelineActivityEntry) => { + const hasUnexpiredImage = ( + imageUrl?: string | null, + imageExpiresAt?: Date | string | null + ) => Boolean(imageUrl && (!imageExpiresAt || new Date(imageExpiresAt) > new Date())); + + return Boolean( + hasUnexpiredImage(entry.imageUrl, entry.imageExpiresAt) || + (entry.imageUrls && entry.imageUrls.length > 0) || + entry.sharedActivityEntry?.sharedActivity?.entries?.some( + (sharedEntry) => + !sharedEntry.activityEntry?.deletedAt && + (hasUnexpiredImage( + sharedEntry.activityEntry?.imageUrl, + sharedEntry.activityEntry?.imageExpiresAt + ) || + Boolean(sharedEntry.activityEntry?.imageUrls?.length)) + ) + ); + }; + // Initialize collapsed state for entries without images useEffect(() => { if (timelineData?.recommendedActivityEntries) { const entriesWithoutImages = new Set( timelineData.recommendedActivityEntries - .filter( - (entry) => - !entry.imageUrl || - (entry.imageExpiresAt && - new Date(entry.imageExpiresAt) < new Date()) - ) + .filter((entry) => !entryHasRenderableImage(entry)) .map((entry) => entry.id) ); setCollapsedEntries(entriesWithoutImages); @@ -199,18 +216,48 @@ const TimelineRenderer: React.FC<{ }, [currentUser?.connectionsFrom, currentUser?.connectionsTo]); // Merge achievement posts with activity entries and sort by date + type TimelineActivityGroup = { + primary: TimelineActivityEntry; + entries: TimelineActivityEntry[]; + }; + type TimelineItem = - | { type: "activity"; data: TimelineActivityEntry } + | { type: "activity"; data: TimelineActivityGroup } | { type: "achievement"; data: TimelineAchievementPost }; const mergedTimelineItems = useMemo(() => { if (!timelineData) return []; const items: TimelineItem[] = []; + const activityEntries = timelineData.recommendedActivityEntries || []; + const entryById = new Map(activityEntries.map((entry) => [entry.id, entry])); + const seenEntryIds = new Set(); + + // Add activity entries, collapsing visible entries from the same shared activity + // into one card. This mirrors the multi-photo card instead of rendering + // duplicate "with @..." cards back-to-back. + activityEntries.forEach((entry) => { + if (seenEntryIds.has(entry.id)) return; + + const sharedEntries = (entry as any).sharedActivityEntry?.sharedActivity?.entries || []; + const sharedEntryIds = sharedEntries + .map((sharedEntry: any) => sharedEntry.activityEntryId) + .filter(Boolean) as string[]; + const visibleGroupEntries = sharedEntries + .map((sharedEntry: any) => entryById.get(sharedEntry.activityEntryId)) + .filter(Boolean) as TimelineActivityEntry[]; + + const entries = Array.from( + new Map([entry, ...visibleGroupEntries].map((groupEntry) => [groupEntry.id, groupEntry])).values() + ).sort((a, b) => { + const timeDiff = new Date(b.datetime).getTime() - new Date(a.datetime).getTime(); + if (timeDiff !== 0) return timeDiff; + return b.id.localeCompare(a.id); + }); - // Add activity entries - (timelineData.recommendedActivityEntries || []).forEach((entry) => { - items.push({ type: "activity", data: entry }); + entries.forEach((groupEntry) => seenEntryIds.add(groupEntry.id)); + sharedEntryIds.forEach((entryId) => seenEntryIds.add(entryId)); + items.push({ type: "activity", data: { primary: entries[0], entries } }); }); // Add achievement posts @@ -222,11 +269,11 @@ const TimelineRenderer: React.FC<{ items.sort((a, b) => { const dateA = a.type === "activity" - ? new Date(a.data.datetime) + ? new Date(a.data.primary.datetime) : new Date(a.data.createdAt); const dateB = b.type === "activity" - ? new Date(b.data.datetime) + ? new Date(b.data.primary.datetime) : new Date(b.data.createdAt); return dateB.getTime() - dateA.getTime(); }); @@ -271,7 +318,7 @@ const TimelineRenderer: React.FC<{ }, [timelineData?.recommendedUsers]); useEffect(() => { - if (!loadMoreRef.current || !hasMoreTimeline) return; + if (!loadMoreRef.current || !hasMoreTimeline || isFetchNextTimelinePageError) return; const observer = new IntersectionObserver( (entries) => { @@ -285,7 +332,12 @@ const TimelineRenderer: React.FC<{ observer.observe(loadMoreRef.current); return () => observer.disconnect(); - }, [fetchNextTimelinePage, hasMoreTimeline, isFetchingNextTimelinePage]); + }, [ + fetchNextTimelinePage, + hasMoreTimeline, + isFetchingNextTimelinePage, + isFetchNextTimelinePageError, + ]); // Check if there will be a divider shown (new posts exist AND we have a lastViewedTimelineAt) const willShowDivider = useMemo(() => { @@ -296,7 +348,7 @@ const TimelineRenderer: React.FC<{ const hasNewItems = mergedTimelineItems.some((item) => { const itemDate = item.type === "activity" - ? new Date(item.data.datetime) + ? new Date(item.data.primary.datetime) : new Date(item.data.createdAt); return itemDate > lastViewed; }); @@ -305,7 +357,7 @@ const TimelineRenderer: React.FC<{ const hasOldItems = mergedTimelineItems.some((item) => { const itemDate = item.type === "activity" - ? new Date(item.data.datetime) + ? new Date(item.data.primary.datetime) : new Date(item.data.createdAt); return itemDate <= lastViewed; }); @@ -320,7 +372,7 @@ const TimelineRenderer: React.FC<{ return mergedTimelineItems.filter((item) => { const itemDate = item.type === "activity" - ? new Date(item.data.datetime) + ? new Date(item.data.primary.datetime) : new Date(item.data.createdAt); return itemDate > lastViewed; }).length; @@ -598,7 +650,7 @@ const TimelineRenderer: React.FC<{ mergedTimelineItems.some((item) => { const itemDate = item.type === "activity" - ? new Date(item.data.datetime) + ? new Date(item.data.primary.datetime) : new Date(item.data.createdAt); return itemDate > lastViewed; }); @@ -643,23 +695,59 @@ const TimelineRenderer: React.FC<{ ); } else { - // Render activity entry - const entry = item.data; + // Render activity entry or merged joint-activity group + const group = item.data; + const entry = group.primary; const activity = entry.activityId ? activityById.get(entry.activityId) : undefined; const user = activity ? userById.get(activity.userId) : undefined; if (!activity || !user || user.username === null) return null; + const sharedActivityEntries = group.entries + .filter((groupEntry) => groupEntry.id !== entry.id) + .map((groupEntry) => { + const groupActivity = groupEntry.activityId + ? activityById.get(groupEntry.activityId) + : undefined; + const groupUser = groupActivity + ? userById.get(groupActivity.userId) + : undefined; + + if (!groupActivity || !groupUser || groupUser.username === null) { + return null; + } + + return { + activityEntry: groupEntry as any, + activity: groupActivity, + user: groupUser as { + username: string; + name: string; + picture: string; + planType: PlanType; + }, + }; + }) + .filter(Boolean) as { + activityEntry: TimelineActivityEntry; + activity: Activity; + user: { username: string; name: string; picture: string; planType: PlanType }; + }[]; + const userPlansProgress = planProgressByUserAndActivity.get(`${user.id}:${activity.id}`) || []; - const hasImageExpired = - entry.imageExpiresAt && - new Date(entry.imageExpiresAt) < new Date(); - const hasImage = entry.imageUrl && !hasImageExpired; - const isCollapsed = collapsedEntries.has(entry.id); + const hasImage = group.entries.some((groupEntry) => + entryHasRenderableImage(groupEntry) + ); + const isCollapsed = group.entries.every((groupEntry) => + collapsedEntries.has(groupEntry.id) + ); + const isHighlighted = group.entries.some( + (groupEntry) => groupEntry.id === highlightedEntryId + ); // Check if we should show the divider before this entry const entryDatetime = new Date(entry.datetime); @@ -678,14 +766,16 @@ const TimelineRenderer: React.FC<{ {shouldShowDivider && }
{ - if (el) { - entryRefs.current.set(entry.id, el); - } else { - entryRefs.current.delete(entry.id); - } + group.entries.forEach((groupEntry) => { + if (el) { + entryRefs.current.set(groupEntry.id, el); + } else { + entryRefs.current.delete(groupEntry.id); + } + }); }} className={`transition-all duration-500 ${ - highlightedEntryId === entry.id + isHighlighted ? cn( "ring-4 ring-opacity-50 rounded-2xl", variants.ring @@ -712,6 +802,7 @@ const TimelineRenderer: React.FC<{ } } userPlansProgressData={userPlansProgress} + sharedActivityEntries={sharedActivityEntries} isCollapsed={isCollapsed} onToggleCollapse={() => toggleEntryCollapse(entry.id)} onAvatarClick={() => { @@ -726,6 +817,12 @@ const TimelineRenderer: React.FC<{ params: { username: user?.username || "" }, }); }} + onParticipantClick={(username) => { + navigate({ + to: `/profile/$username`, + params: { username }, + }); + }} />
@@ -739,6 +836,24 @@ const TimelineRenderer: React.FC<{
)} + {isFetchNextTimelinePageError && !isFetchingNextTimelinePage && ( +
+ Couldn't load more timeline items. + +
+ )} + {!hasMoreTimeline && !isFetchingNextTimelinePage && mergedTimelineItems.length > 0 && ( +
+ You're all caught up +
+ )}
); }; diff --git a/apps/frontend-vite/src/contexts/activities/provider.tsx b/apps/frontend-vite/src/contexts/activities/provider.tsx index 1ef44ce2..1204d1bc 100644 --- a/apps/frontend-vite/src/contexts/activities/provider.tsx +++ b/apps/frontend-vite/src/contexts/activities/provider.tsx @@ -128,7 +128,17 @@ export const ActivitiesProvider: React.FC<{ children: React.ReactNode }> = ({ formData.append("photos", photo); } - const response = await api.post("/activities/log-activity", formData); + const response = await api.post("/activities/log-activity", formData, { + onUploadProgress: (progressEvent) => { + if (!data.onUploadProgress || !progressEvent.total) return; + data.onUploadProgress( + Math.min( + 100, + Math.round((progressEvent.loaded / progressEvent.total) * 100) + ) + ); + }, + }); return response.data; }, onSuccess: async (response: LogActivityResponse, variables) => { diff --git a/apps/frontend-vite/src/contexts/activities/types.ts b/apps/frontend-vite/src/contexts/activities/types.ts index bb4bd1d3..071831c1 100644 --- a/apps/frontend-vite/src/contexts/activities/types.ts +++ b/apps/frontend-vite/src/contexts/activities/types.ts @@ -36,6 +36,7 @@ export interface ActivityLogData { withUserId?: string; latitude?: number; longitude?: number; + onUploadProgress?: (percent: number) => void; } export interface ActivitiesContextType { diff --git a/apps/frontend-vite/src/contexts/ai/provider.tsx b/apps/frontend-vite/src/contexts/ai/provider.tsx index 6926464a..0bf6f2a3 100644 --- a/apps/frontend-vite/src/contexts/ai/provider.tsx +++ b/apps/frontend-vite/src/contexts/ai/provider.tsx @@ -13,6 +13,8 @@ import { rejectMetric, acceptProposal, rejectProposal, + acceptPlanCreationProposal, + rejectPlanCreationProposal, acceptActivityLogProposal, rejectActivityLogProposal, submitAISatisfaction, @@ -203,6 +205,41 @@ export const AIProvider: React.FC<{ children: React.ReactNode }> = ({ }, }); + const acceptPlanCreationProposalMutation = useMutation({ + mutationFn: async (data: { messageId: string; proposalIndex: number }) => { + return await acceptPlanCreationProposal(api, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["messages", messagesContext.currentChatId], + }); + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + queryClient.invalidateQueries({ queryKey: ["plans"] }); + queryClient.invalidateQueries({ queryKey: ["current-user"] }); + toast.success("Plan created!"); + }, + onError: (error) => { + handleQueryError(error, "Failed to create plan"); + toast.error("Failed to create plan"); + }, + }); + + const rejectPlanCreationProposalMutation = useMutation({ + mutationFn: async (data: { messageId: string; proposalIndex: number }) => { + return await rejectPlanCreationProposal(api, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["messages", messagesContext.currentChatId], + }); + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + }, + onError: (error) => { + handleQueryError(error, "Failed to reject plan creation"); + toast.error("Failed to reject plan creation"); + }, + }); + const acceptActivityLogProposalMutation = useMutation({ mutationFn: async (data: { messageId: string; proposalIndex: number }) => { return await acceptActivityLogProposal(api, data); @@ -281,6 +318,8 @@ export const AIProvider: React.FC<{ children: React.ReactNode }> = ({ isRejectingMetric: rejectMetricMutation.isPending, acceptProposal: acceptProposalMutation.mutateAsync, rejectProposal: rejectProposalMutation.mutateAsync, + acceptPlanCreationProposal: acceptPlanCreationProposalMutation.mutateAsync, + rejectPlanCreationProposal: rejectPlanCreationProposalMutation.mutateAsync, acceptActivityLogProposal: acceptActivityLogProposalMutation.mutateAsync, rejectActivityLogProposal: rejectActivityLogProposalMutation.mutateAsync, submitAISatisfaction: submitAISatisfactionMutation.mutateAsync, diff --git a/apps/frontend-vite/src/contexts/ai/service.ts b/apps/frontend-vite/src/contexts/ai/service.ts index 63ea9e41..8e6749b4 100644 --- a/apps/frontend-vite/src/contexts/ai/service.ts +++ b/apps/frontend-vite/src/contexts/ai/service.ts @@ -113,6 +113,24 @@ export async function rejectProposal( }); } +export async function acceptPlanCreationProposal( + api: AxiosInstance, + data: { messageId: string; proposalIndex: number } +): Promise { + await api.post(`/ai/messages/${data.messageId}/accept-plan-creation-proposal`, { + proposalIndex: data.proposalIndex, + }); +} + +export async function rejectPlanCreationProposal( + api: AxiosInstance, + data: { messageId: string; proposalIndex: number } +): Promise { + await api.post(`/ai/messages/${data.messageId}/reject-plan-creation-proposal`, { + proposalIndex: data.proposalIndex, + }); +} + // Accept an activity log proposal from AI export async function acceptActivityLogProposal( api: AxiosInstance, diff --git a/apps/frontend-vite/src/contexts/ai/types.ts b/apps/frontend-vite/src/contexts/ai/types.ts index 3e31f191..341413fa 100644 --- a/apps/frontend-vite/src/contexts/ai/types.ts +++ b/apps/frontend-vite/src/contexts/ai/types.ts @@ -51,6 +51,8 @@ export interface AIContextType extends MessagesContextType { // Plan proposals acceptProposal: (data: { messageId: string; proposalIndex: number }) => Promise; rejectProposal: (data: { messageId: string; proposalIndex: number }) => Promise; + acceptPlanCreationProposal: (data: { messageId: string; proposalIndex: number }) => Promise; + rejectPlanCreationProposal: (data: { messageId: string; proposalIndex: number }) => Promise; // Activity log proposals acceptActivityLogProposal: (data: { messageId: string; proposalIndex: number }) => Promise; diff --git a/apps/frontend-vite/src/contexts/messages/types.ts b/apps/frontend-vite/src/contexts/messages/types.ts index 5c7624bb..baa1590d 100644 --- a/apps/frontend-vite/src/contexts/messages/types.ts +++ b/apps/frontend-vite/src/contexts/messages/types.ts @@ -44,6 +44,15 @@ export interface Message { operations: unknown[]; status: "accepted" | "rejected" | null; }>; + planCreationProposals?: Array<{ + goal: string; + goalReason: string | null; + emoji: string | null; + timesPerWeek: number | null; + activities: Array<{ title: string; measure: string; emoji: string; kind?: string | null }>; + description: string; + status: "accepted" | "rejected" | null; + }>; activityLogProposals?: Array<{ activityId: string; activityName: string; @@ -56,6 +65,8 @@ export interface Message { }>; toolCalls?: ToolCall[] | null; error?: boolean; + /** Origin tag, e.g. "autonomous_coach" for proactive coach assessment messages. */ + source?: string | null; } export type ChatType = "COACH" | "DIRECT" | "GROUP"; diff --git a/apps/frontend-vite/src/contexts/timeline/index.tsx b/apps/frontend-vite/src/contexts/timeline/index.tsx index 53ea66fd..69bc08fb 100644 --- a/apps/frontend-vite/src/contexts/timeline/index.tsx +++ b/apps/frontend-vite/src/contexts/timeline/index.tsx @@ -8,6 +8,25 @@ import React, { useMemo } from "react"; import { getTimelineData, type TimelineData } from "./service"; import { TimelineContext, type TimelineContextType } from "./types"; +export const getTimelineNextPageParam = ( + lastPage: TimelineData, + allPages: TimelineData[] +) => { + const nextCursor = lastPage.nextCursor || undefined; + if (!nextCursor) return undefined; + + const returnedItems = + lastPage.recommendedActivityEntries.length + lastPage.achievementPosts.length; + if (returnedItems === 0) return undefined; + + const previousPages = allPages.slice(0, -1); + if (previousPages.some((page) => page.nextCursor === nextCursor)) { + return undefined; + } + + return nextCursor; +}; + export const TimelineProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { @@ -18,7 +37,7 @@ export const TimelineProvider: React.FC<{ children: React.ReactNode }> = ({ queryKey: ["timeline"], queryFn: ({ pageParam }) => getTimelineData(api, pageParam), initialPageParam: null as string | null, - getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, + getNextPageParam: getTimelineNextPageParam, enabled: isLoaded && isSignedIn, }); @@ -53,6 +72,7 @@ export const TimelineProvider: React.FC<{ children: React.ReactNode }> = ({ isLoadingTimeline: timelineQuery.isLoading, timelineError: timelineQuery.error, isFetchingNextTimelinePage: timelineQuery.isFetchingNextPage, + isFetchNextTimelinePageError: timelineQuery.isFetchNextPageError, hasMoreTimeline: timelineQuery.hasNextPage, fetchNextTimelinePage: timelineQuery.fetchNextPage, }; diff --git a/apps/frontend-vite/src/contexts/timeline/service.ts b/apps/frontend-vite/src/contexts/timeline/service.ts index c73149da..aacf51d3 100644 --- a/apps/frontend-vite/src/contexts/timeline/service.ts +++ b/apps/frontend-vite/src/contexts/timeline/service.ts @@ -36,6 +36,36 @@ export type TimelineActivityEntry = Prisma.ActivityEntryGetPayload<{ }; }; }; + sharedActivityEntry: { + include: { + sharedActivity: { + include: { + entries: { + include: { + user: { + select: { + id: true; + username: true; + name: true; + picture: true; + }; + }; + activityEntry: { + select: { + id: true; + userId: true; + deletedAt: true; + imageUrl: true; + imageUrls: true; + imageExpiresAt: true; + }; + }; + }; + }; + }; + }; + }; + }; }; }>; @@ -140,6 +170,11 @@ export function normalizeActivityEntry( "comments.createdAt", "comments.deletedAt", "reactions.createdAt", + "sharedActivityEntry.createdAt", + "sharedActivityEntry.sharedActivity.createdAt", + "sharedActivityEntry.sharedActivity.entries.createdAt", + "sharedActivityEntry.sharedActivity.entries.activityEntry.deletedAt", + "sharedActivityEntry.sharedActivity.entries.activityEntry.imageExpiresAt", ]); } diff --git a/apps/frontend-vite/src/contexts/timeline/types.ts b/apps/frontend-vite/src/contexts/timeline/types.ts index 2db665f4..8193ab8c 100644 --- a/apps/frontend-vite/src/contexts/timeline/types.ts +++ b/apps/frontend-vite/src/contexts/timeline/types.ts @@ -6,6 +6,7 @@ export interface TimelineContextType { timelineError: Error | null; isLoadingTimeline: boolean; isFetchingNextTimelinePage: boolean; + isFetchNextTimelinePageError: boolean; hasMoreTimeline: boolean; fetchNextTimelinePage: () => Promise; } diff --git a/apps/frontend-vite/src/contexts/users/service.ts b/apps/frontend-vite/src/contexts/users/service.ts index 0ef53bd0..c192e85c 100644 --- a/apps/frontend-vite/src/contexts/users/service.ts +++ b/apps/frontend-vite/src/contexts/users/service.ts @@ -94,6 +94,36 @@ type FullUserApiResponseBase = Prisma.UserGetPayload<{ }; }; }; + sharedActivityEntry: { + include: { + sharedActivity: { + include: { + entries: { + include: { + user: { + select: { + id: true; + username: true; + name: true; + picture: true; + }; + }; + activityEntry: { + select: { + id: true; + userId: true; + deletedAt: true; + imageUrl: true; + imageUrls: true; + imageExpiresAt: true; + }; + }; + }; + }; + }; + }; + }; + }; }; }; achievementPosts: { @@ -147,6 +177,18 @@ type FullUserApiResponseBase = Prisma.UserGetPayload<{ type FullUserApiResponse = Omit & { plans: Array; + accountStats?: AccountStats; +}; + +export type AccountStats = { + totalActivitiesLogged: number; + habitCount: number; + lifestyleCount: number; + habitBonus: number; + lifestyleBonus: number; + bonusPoints: number; + totalPoints: number; + bestStreak: number; }; // Coach profile type for human coaches @@ -172,6 +214,7 @@ export type HydratedUser = Omit & { plans: Array< FullUserApiResponseBase["plans"][number] & { progress: PlanProgressData } >; + accountStats?: AccountStats; coachProfile?: CoachProfile | null; }; diff --git a/apps/frontend-vite/src/hooks/useAccountLevel.ts b/apps/frontend-vite/src/hooks/useAccountLevel.ts index 1e723655..db01d86d 100644 --- a/apps/frontend-vite/src/hooks/useAccountLevel.ts +++ b/apps/frontend-vite/src/hooks/useAccountLevel.ts @@ -100,24 +100,35 @@ export function useAccountLevel(username?: string) { return useMemo(() => { const ACCOUNT_LEVELS = getAccountLevels(isDarkMode); + const accountStats = (profileData as any)?.accountStats; // Calculate total activities logged - const totalActivitiesLogged = profileData?.activityEntries?.length || 0; + const totalActivitiesLogged = + accountStats?.totalActivitiesLogged ?? + profileData?.activityEntries?.length ?? + 0; // Calculate bonus points from plans const activePlans = profileData?.plans?.filter((plan) => !plan.deletedAt) || []; - const habitCount = activePlans.filter( - (plan) => plan.progress.habitAchievement.isAchieved - ).length; - const lifestyleCount = activePlans.filter( - (plan) => plan.progress.lifestyleAchievement.isAchieved - ).length; - const habitBonus = habitCount * HABIT_BONUS_POINTS; - const lifestyleBonus = lifestyleCount * LIFESTYLE_BONUS_POINTS; - const bonusPoints = habitBonus + lifestyleBonus; - - const totalPoints = totalActivitiesLogged + bonusPoints; + const habitCount = + accountStats?.habitCount ?? + activePlans.filter((plan) => plan.progress.habitAchievement.isAchieved) + .length; + const lifestyleCount = + accountStats?.lifestyleCount ?? + activePlans.filter( + (plan) => plan.progress.lifestyleAchievement.isAchieved + ).length; + const habitBonus = + accountStats?.habitBonus ?? habitCount * HABIT_BONUS_POINTS; + const lifestyleBonus = + accountStats?.lifestyleBonus ?? + lifestyleCount * LIFESTYLE_BONUS_POINTS; + const bonusPoints = accountStats?.bonusPoints ?? habitBonus + lifestyleBonus; + + const totalPoints = + accountStats?.totalPoints ?? totalActivitiesLogged + bonusPoints; // Find current level let currentLevel: AccountLevel | null = null; diff --git a/apps/frontend-vite/src/hooks/useUnifiedProfileData.tsx b/apps/frontend-vite/src/hooks/useUnifiedProfileData.tsx index 2bd0c7bd..610ef6e3 100644 --- a/apps/frontend-vite/src/hooks/useUnifiedProfileData.tsx +++ b/apps/frontend-vite/src/hooks/useUnifiedProfileData.tsx @@ -8,11 +8,13 @@ export const useUnifiedProfileData = (username?: string) => { const { currentUser, isLoadingCurrentUser, sendFriendRequest, acceptFriendRequest, rejectFriendRequest } = useCurrentUser(); const { activities, activityEntries, isLoadingActivities, isLoadingActivityEntries } = useActivities(); const { plans, isLoadingPlans } = usePlans(); - const { timelineData, isLoadingTimeline } = useTimeline(); + const { timelineData } = useTimeline(); // For external users, use the existing useUser hook - // Only call useUser when we have a username and it's not the current user - const shouldFetchExternalUser = !!username && username !== currentUser?.username; + // Only call useUser after the current user has loaded, otherwise /profile/:username + // briefly treats the signed-in user's own profile as an external profile. + const shouldFetchExternalUser = + !!username && !!currentUser && username !== currentUser.username; const { data: externalUserData, isLoading: isLoadingExternalUser } = useUser( shouldFetchExternalUser ? { username } : { username: "" } ); @@ -44,11 +46,11 @@ export const useUnifiedProfileData = (username?: string) => { // Loading states const isLoading = useMemo(() => { if (isOwnProfile) { - return isLoadingCurrentUser || isLoadingActivities || isLoadingActivityEntries || isLoadingPlans || isLoadingTimeline; + return isLoadingCurrentUser || isLoadingActivities || isLoadingActivityEntries || isLoadingPlans; } else { - return isLoadingExternalUser; + return isLoadingCurrentUser || isLoadingExternalUser; } - }, [isOwnProfile, isLoadingCurrentUser, isLoadingActivities, isLoadingActivityEntries, isLoadingPlans, isLoadingTimeline, isLoadingExternalUser]); + }, [isOwnProfile, isLoadingCurrentUser, isLoadingActivities, isLoadingActivityEntries, isLoadingPlans, isLoadingExternalUser]); return { profileData, diff --git a/apps/frontend-vite/src/routes/ai.tsx b/apps/frontend-vite/src/routes/ai.tsx index d71c1ec8..dab75214 100644 --- a/apps/frontend-vite/src/routes/ai.tsx +++ b/apps/frontend-vite/src/routes/ai.tsx @@ -434,6 +434,7 @@ function AICoachPage() {
1 && diffDays <= 6) { + return `${format(date, "EEE")}, ${diffDays} days ago`; + } + if (date.getFullYear() === today.getFullYear()) { + return format(date, "d MMM"); + } + return format(date, "d MMMM yyyy"); +} + +function sanitizePlanDisplayText(text: string, emoji?: string | null): string { + let cleaned = text.trim(); + while (emoji && cleaned.startsWith(emoji)) { + cleaned = cleaned.slice(emoji.length).trimStart(); + } + return cleaned || text.trim(); +} + +type CitationSource = { + citationLabel: string; + displayCitationLabel: string; + title?: string; + url: string; +}; + +function getCitationSources(content: string, toolCalls?: any[] | null): CitationSource[] { + const citedLabelsInOrder = Array.from(content.matchAll(/\[(\d+)\]/g)).map( + (match) => `[${match[1]}]` + ); + if (citedLabelsInOrder.length === 0) return []; + + const citedLabels = new Set(citedLabelsInOrder); + const displayLabels = new Map(); + citedLabelsInOrder.forEach((label) => { + if (!displayLabels.has(label)) { + displayLabels.set(label, `[${displayLabels.size + 1}]`); + } + }); + + const sources: CitationSource[] = []; + const seenUrls = new Set(); + let fallbackIndex = 1; + + for (const toolCall of toolCalls || []) { + if (toolCall.tool !== "webSearch" || !toolCall.result?.results) continue; + for (const result of toolCall.result.results as any[]) { + if (!result.url || seenUrls.has(result.url)) { + fallbackIndex += 1; + continue; + } + const citationLabel = + result.citationLabel || + (result.citationIndex ? `[${result.citationIndex}]` : `[${fallbackIndex}]`); + fallbackIndex += 1; + if (!citedLabels.has(citationLabel)) continue; + seenUrls.add(result.url); + sources.push({ + citationLabel, + displayCitationLabel: displayLabels.get(citationLabel) || citationLabel, + title: result.title, + url: result.url, + }); + } + } + + return sources; +} + +function CitationPill({ source }: { source: CitationSource }) { + return ( + + {source.displayCitationLabel.replace(/^\[|\]$/g, "")} + + ); +} + +type VisibleActivity = { + id: string; + emoji: string; + title: string; + quantity: number; + measure: string; + datetime: Date; +}; + +function CoachContextIsland({ + coachName, + expanded, + onToggle, + recentActivities, + activePlans, + gridData, + isCompletedOnDay, +}: { + coachName: string; + expanded: boolean; + onToggle: () => void; + recentActivities: VisibleActivity[]; + activePlans: Array<{ + id: string; + goal: string; + emoji?: string | null; + isCoached?: boolean; + }>; + gridData: GridData; + isCompletedOnDay: (activityId: string, day: Date) => boolean; +}) { + const preview = + recentActivities.length > 0 + ? recentActivities + .slice(0, 3) + .map((activity) => `${activity.emoji} ${activity.title}`) + .join(" ยท ") + : "No recent activity logs"; + + return ( +
+
+ + + + {expanded && ( + +
+
+
+ Recent logs +
+ {recentActivities.length > 0 ? ( +
+ {recentActivities.slice(0, 8).map((activity) => ( +
+
{activity.emoji}
+
+ {activity.title} +
+
+ {formatCoachVisibleDate(activity.datetime)} +
+
+ ))} +
+ ) : ( +
+ No recent activity logs are available. +
+ )} +
+ +
+
+ Active plans +
+
+ {activePlans.length > 0 ? ( + activePlans.slice(0, 5).map((plan) => ( + + {plan.emoji || "๐Ÿ“‹"} + {plan.goal} + {plan.isCoached ? ( + + + coached + + ) : null} + + )) + ) : ( + + No active plans. + + )} +
+
+ +
+
+ + Upcoming sessions +
+ {gridData.scheduledSessions.length > 0 || + gridData.ghostCells.length > 0 ? ( + + ) : ( +
+ No upcoming sessions. +
+ )} +
+
+
+ )} +
+
+
+ ); +} + function MessageWithReadTracking({ message, isOwnMessage, @@ -208,7 +473,7 @@ function MessageAIPage() { const navigate = useNavigate(); const api = useApiWithAuth(); const { plans } = usePlans(); - const { activities } = useActivities(); + const { activities, activityEntries } = useActivities(); const themeColors = useThemeColors(); const variants = getThemeVariants(themeColors.raw); const { @@ -232,6 +497,8 @@ function MessageAIPage() { rejectMetric, acceptProposal, rejectProposal, + acceptPlanCreationProposal, + rejectPlanCreationProposal, acceptActivityLogProposal, rejectActivityLogProposal, createCoachChat, @@ -242,6 +509,7 @@ function MessageAIPage() { const { pendingSession, clearPendingSession } = useSessionMessage(); const [inputValue, setInputValue] = useState(""); const [showMenu, setShowMenu] = useState(false); + const [showCoachContext, setShowCoachContext] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false); const menuRef = useRef(null); const messagesContainerRef = useRef(null); @@ -297,6 +565,56 @@ function MessageAIPage() { const currentChat = chats?.find((chat) => chat.id === currentChatId); + const recentActivities = useMemo(() => { + const activitiesById = new Map( + (activities || []).map((activity: any) => [activity.id, activity]) + ); + return (activityEntries || []) + .map((entry: any) => { + const activity = activitiesById.get(entry.activityId); + if (!activity) return null; + return { + id: entry.id, + emoji: activity.emoji || "๐Ÿ“Œ", + title: activity.title, + quantity: entry.quantity, + measure: activity.measure, + datetime: new Date(entry.datetime), + }; + }) + .filter((activity): activity is VisibleActivity => activity !== null) + .sort((a, b) => b.datetime.getTime() - a.datetime.getTime()) + .slice(0, 12); + }, [activities, activityEntries]); + + const activePlans = useMemo( + () => + (plans || []) + .filter(isActiveVisiblePlan) + .map((plan: any) => ({ + id: plan.id, + goal: plan.goal, + emoji: plan.emoji, + isCoached: plan.isCoached, + })), + [plans] + ); + + const gridData = useMemo( + () => computeGridCells(plans, new Date(), activityEntries || []), + [plans, activityEntries] + ); + + const isCompletedOnDay = useCallback( + (activityId: string, day: Date) => + (activityEntries || []).some( + (entry) => + entry.activityId === activityId && + isSameDay(new Date(entry.datetime), day) + ), + [activityEntries] + ); + // Auto-select most recent coach chat or create one useEffect(() => { if (isLoadingChats) return; @@ -457,6 +775,24 @@ function MessageAIPage() { } }; + const handleAcceptPlanCreationProposal = async (messageId: string, proposalIndex: number) => { + try { + await acceptPlanCreationProposal({ messageId, proposalIndex }); + } catch (error) { + console.error("Failed to accept plan creation proposal:", error); + throw error; + } + }; + + const handleRejectPlanCreationProposal = async (messageId: string, proposalIndex: number) => { + try { + await rejectPlanCreationProposal({ messageId, proposalIndex }); + } catch (error) { + console.error("Failed to reject plan creation proposal:", error); + throw error; + } + }; + const handleAcceptActivityLogProposal = async (messageId: string, proposalIndex: number) => { try { await acceptActivityLogProposal({ messageId, proposalIndex }); @@ -580,6 +916,7 @@ function MessageAIPage() { length: number; component: JSX.Element; }> = []; + const citationSources = getCitationSources(content, message.toolCalls); if (message.metricReplacement) { const index = content.indexOf(message.metricReplacement.textToReplace); @@ -620,6 +957,8 @@ function MessageAIPage() { `"${emoji} `, `"${emoji}`, `"`, + `${emoji} `, + `${emoji}`, ].filter(p => p.length > 0); for (const prefix of prefixPatterns) { @@ -647,7 +986,10 @@ function MessageAIPage() { ), @@ -656,6 +998,27 @@ function MessageAIPage() { }); } + citationSources.forEach((source, idx) => { + let searchFrom = 0; + while (searchFrom < content.length) { + const index = content.indexOf(source.citationLabel, searchFrom); + if (index === -1) break; + const overlaps = replacements.some( + (replacement) => + index < replacement.index + replacement.length && + index + source.citationLabel.length > replacement.index + ); + if (!overlaps) { + replacements.push({ + index, + length: source.citationLabel.length, + component: , + }); + } + searchFrom = index + source.citationLabel.length; + } + }); + replacements.sort((a, b) => a.index - b.index); let lastIndex = 0; @@ -806,6 +1169,16 @@ function MessageAIPage() {
+ setShowCoachContext((value) => !value)} + recentActivities={recentActivities} + activePlans={activePlans} + gridData={gridData} + isCompletedOnDay={isCompletedOnDay} + /> + {/* Messages */}
@@ -881,8 +1254,17 @@ function MessageAIPage() { }`} >
+ {isCoachMessage && + message.source === "autonomous_coach" && + !prevIsCoach && ( +
+ + Coach assessment +
+ )} a.id === op.activityId) || activities?.find((a: any) => a.id === op.activityId); @@ -940,6 +1331,27 @@ function MessageAIPage() {
)} + {isCoachMessage && message.planCreationProposals && message.planCreationProposals.length > 0 && ( +
+ {message.planCreationProposals.map((proposal: any, idx: number) => ( + + ))} +
+ )} + {isCoachMessage && message.activityLogProposals && message.activityLogProposals.length > 0 && (
{message.activityLogProposals.map((proposal: any, idx: number) => ( @@ -964,6 +1376,7 @@ function MessageAIPage() { {isCoachMessage && message.toolCalls && message.toolCalls.length > 0 && ( ({ id: p.id, goal: p.goal, emoji: p.emoji }))} /> )} diff --git a/apps/frontend-vite/src/routes/message.$userId.tsx b/apps/frontend-vite/src/routes/message.$userId.tsx index a34b5653..71c9c6f6 100644 --- a/apps/frontend-vite/src/routes/message.$userId.tsx +++ b/apps/frontend-vite/src/routes/message.$userId.tsx @@ -388,6 +388,7 @@ function MessageUserPage() {
diff --git a/apps/frontend-vite/src/routes/profile.$username.tsx b/apps/frontend-vite/src/routes/profile.$username.tsx index 6f41fe73..5d2f1b76 100644 --- a/apps/frontend-vite/src/routes/profile.$username.tsx +++ b/apps/frontend-vite/src/routes/profile.$username.tsx @@ -162,10 +162,39 @@ function ProfilePage() { const achievementPosts = profileData?.achievementPosts || []; const historyItems = useMemo(() => { - const items = [ - ...activityEntries.map(entry => ({ type: 'activity' as const, date: new Date(entry.datetime), data: entry })), - ...achievementPosts.map(post => ({ type: 'achievement' as const, date: new Date(post.createdAt), data: post })) - ]; + const items: Array< + | { type: "activity"; date: Date; data: (typeof activityEntries)[number] } + | { type: "achievement"; date: Date; data: (typeof achievementPosts)[number] } + > = []; + const seenActivityEntryIds = new Set(); + + activityEntries.forEach((entry) => { + if (seenActivityEntryIds.has(entry.id)) return; + + seenActivityEntryIds.add(entry.id); + const sharedEntries = + (entry as any).sharedActivityEntry?.sharedActivity?.entries || []; + sharedEntries.forEach((sharedEntry: any) => { + if (sharedEntry.activityEntryId) { + seenActivityEntryIds.add(sharedEntry.activityEntryId); + } + }); + + items.push({ + type: "activity", + date: new Date(entry.datetime), + data: entry, + }); + }); + + achievementPosts.forEach((post) => { + items.push({ + type: "achievement", + date: new Date(post.createdAt), + data: post, + }); + }); + return items.sort((a, b) => b.date.getTime() - a.date.getTime()); }, [activityEntries, achievementPosts]); @@ -934,6 +963,24 @@ function ProfilePage() { userPlansProgressData={profileData.plans.map( (plan) => plan.progress )} + onAvatarClick={() => { + navigate({ + to: `/profile/$username`, + params: { username: profileData.username || "" }, + }); + }} + onUsernameClick={() => { + navigate({ + to: `/profile/$username`, + params: { username: profileData.username || "" }, + }); + }} + onParticipantClick={(username) => { + navigate({ + to: `/profile/$username`, + params: { username }, + }); + }} /> ); } else { diff --git a/apps/frontend-vite/src/services/supabase.ts b/apps/frontend-vite/src/services/supabase.ts index 14fe7b35..cdba71b2 100644 --- a/apps/frontend-vite/src/services/supabase.ts +++ b/apps/frontend-vite/src/services/supabase.ts @@ -3,6 +3,23 @@ import { createClient } from "@supabase/supabase-js"; const supabaseUrl = import.meta.env.VITE_SUPABASE_API_URL; const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const legacyLocalOrigins = new Set([ + "http://localhost:5173", + "http://127.0.0.1:5173", +]); +const portlessLocalOrigin = "https://tracking-so.localhost"; + +if ( + typeof window !== "undefined" && + import.meta.env.DEV && + legacyLocalOrigins.has(window.location.origin) && + window.location.hash.includes("access_token=") +) { + window.location.replace( + `${portlessLocalOrigin}${window.location.pathname}${window.location.search}${window.location.hash}` + ); +} + export const supabase = createClient(supabaseUrl, supabaseKey, { auth: { autoRefreshToken: true, diff --git a/apps/frontend-vite/src/utils/ghostGrid.ts b/apps/frontend-vite/src/utils/ghostGrid.ts new file mode 100644 index 00000000..bea487ba --- /dev/null +++ b/apps/frontend-vite/src/utils/ghostGrid.ts @@ -0,0 +1,183 @@ +import { addDays, format, isBefore, startOfDay, startOfWeek } from "date-fns"; +import type { PlanState } from "@tsw/prisma"; +import type { CompletePlan } from "@/contexts/plans"; +import type { CalendarActivity, CalendarSession } from "@/components/CalendarGrid"; + +export type GhostCellKind = "ghost" | "overflow" | "completed"; + +export interface CompletedEntry { + activityId: string | null; + datetime: Date | string; +} + +export interface GhostCell { + date: Date; + activityId: string; + planId: string; + kind: GhostCellKind; + state: PlanState | null; +} + +export interface GridData { + /** SPECIFIC plans' real sessions โ€” flow through CalendarGrid's `sessions` prop. */ + scheduledSessions: CalendarSession[]; + /** TIMES_PER_WEEK ghosts + overflow โ€” flow through CalendarGrid's `ghostCells` prop. */ + ghostCells: GhostCell[]; + /** Union of activities across all plans, for emoji/title lookup. */ + activities: CalendarActivity[]; +} + +/** Active, visible (non-archived, non-paused, not finished) plan. */ +export function isActiveVisiblePlan(plan: CompletePlan): boolean { + return Boolean( + !plan.deletedAt && + !plan.archivedAt && + !plan.isPaused && + (!plan.finishingDate || new Date(plan.finishingDate) > new Date()) + ); +} + +/** + * Pick `n` evenly-spread indices in `[0, m-1]`. Deterministic for a given (n, m). + * Distinct when `n <= m`. + */ +function evenSpreadIndices(n: number, m: number): number[] { + const indices: number[] = []; + for (let i = 0; i < n; i++) { + indices.push(Math.min(m - 1, Math.floor(((i + 0.5) * m) / n))); + } + return indices; +} + +function placeGhosts( + openDays: Date[], + count: number, + base: Omit, + out: GhostCell[] +): void { + const m = openDays.length; + if (count <= 0 || m === 0) return; + + if (count <= m) { + for (const idx of evenSpreadIndices(count, m)) { + out.push({ ...base, date: openDays[idx], kind: "ghost" }); + } + return; + } + + // count > m: every open day gets a ghost, the surplus stacks on the last day. + for (const day of openDays) { + out.push({ ...base, date: day, kind: "ghost" }); + } + for (let i = 0; i < count - m; i++) { + out.push({ ...base, date: openDays[m - 1], kind: "overflow" }); + } +} + +/** + * Build the 2-week grid view across all active plans. Pure & client-side. + * + * - SPECIFIC plans contribute their real dated sessions (scheduled). + * - TIMES_PER_WEEK plans contribute ghost cells spread across the remaining open + * days. This week's count comes from the server-computed + * `currentWeekStats.numActiveDaysLeftInTheWeek` (already nets out completed + * days); next week's count is the full `timesPerWeek`. When more ghosts are + * needed than open days remain, the surplus renders as `overflow` โ€” which is + * exactly the condition the backend reports as `FAILED`. + */ +export function computeGridCells( + plans: CompletePlan[] | undefined, + today: Date, + completedEntries: CompletedEntry[] = [] +): GridData { + const weekStart = startOfWeek(today, { weekStartsOn: 0 }); + const windowEnd = addDays(weekStart, 14); // exclusive + const todayStart = startOfDay(today); + + const week1Days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); + const week2Days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, 7 + i)); + const week1Open = week1Days.filter((d) => !isBefore(startOfDay(d), todayStart)); + + const scheduledSessions: CalendarSession[] = []; + const ghostCells: GhostCell[] = []; + const activityMap = new Map(); + + for (const plan of (plans ?? []).filter(isActiveVisiblePlan)) { + for (const a of plan.activities ?? []) { + if (!activityMap.has(a.id)) { + activityMap.set(a.id, { + id: a.id, + title: a.title, + emoji: a.emoji ?? undefined, + measure: a.measure ?? undefined, + }); + } + } + + if (plan.outlineType === "SPECIFIC") { + for (const s of plan.sessions ?? []) { + const date = new Date(s.date); + if (date >= weekStart && date < windowEnd) { + scheduledSessions.push({ + id: s.id, + date, + activityId: s.activityId, + quantity: s.quantity, + descriptiveGuide: s.descriptiveGuide ?? undefined, + imageUrls: s.imageUrls ?? undefined, + }); + } + } + continue; + } + + if (plan.outlineType === "TIMES_PER_WEEK") { + const activityId = plan.activities?.[0]?.id; + if (!activityId || !plan.timesPerWeek) continue; + + const base = { + activityId, + planId: plan.id, + state: plan.currentWeekState ?? null, + }; + + const stats = plan.progress?.currentWeekStats; + const week1Count = + stats?.numActiveDaysLeftInTheWeek ?? + Math.max(0, plan.timesPerWeek - (stats?.daysCompletedThisWeek ?? 0)); + + placeGhosts(week1Open, week1Count, base, ghostCells); + placeGhosts(week2Days, plan.timesPerWeek, base, ghostCells); + } + } + + // Completed activity logs โ€” show what was actually done. Skip days that already + // have a scheduled session for that activity (those render their own check). + const dayKey = (d: Date) => format(d, "yyyy-MM-dd"); + const scheduledKeys = new Set( + scheduledSessions.map((s) => `${s.activityId}|${dayKey(new Date(s.date))}`) + ); + const completedKeys = new Set(); + for (const entry of completedEntries) { + if (!entry.activityId) continue; + const date = new Date(entry.datetime); + if (date < weekStart || date >= windowEnd) continue; + if (!activityMap.has(entry.activityId)) continue; + const key = `${entry.activityId}|${dayKey(date)}`; + if (scheduledKeys.has(key) || completedKeys.has(key)) continue; + completedKeys.add(key); + ghostCells.push({ + date, + activityId: entry.activityId, + planId: "completed", + kind: "completed", + state: null, + }); + } + + return { + scheduledSessions, + ghostCells, + activities: Array.from(activityMap.values()), + }; +} diff --git a/apps/frontend-vite/src/utils/planState.ts b/apps/frontend-vite/src/utils/planState.ts new file mode 100644 index 00000000..dc466e69 --- /dev/null +++ b/apps/frontend-vite/src/utils/planState.ts @@ -0,0 +1,57 @@ +import { + AlertTriangle, + CircleCheck, + TrendingDown, + TrendingUp, + type LucideIcon, +} from "lucide-react"; +import type { PlanState } from "@tsw/prisma"; + +export interface PlanStateVisuals { + Icon: LucideIcon; + message: string; + /** color for the status icon, e.g. "text-green-500" */ + colorClass: string; + /** muted background tint for ghost cells of this state */ + tintBgClass: string; + /** border tint for ghost cells of this state */ + tintBorderClass: string; +} + +const VISUALS: Record = { + ON_TRACK: { + Icon: TrendingUp, + message: "On track!", + colorClass: "text-green-500", + tintBgClass: "bg-green-100 dark:bg-green-900/30", + tintBorderClass: "border-green-400/60", + }, + AT_RISK: { + Icon: AlertTriangle, + message: "At risk", + colorClass: "text-amber-500", + tintBgClass: "bg-amber-100 dark:bg-amber-900/30", + tintBorderClass: "border-amber-400/60", + }, + FAILED: { + Icon: TrendingDown, + message: "Off track!", + colorClass: "text-red-500", + tintBgClass: "bg-red-100 dark:bg-red-900/30", + tintBorderClass: "border-red-400/60", + }, + COMPLETED: { + Icon: CircleCheck, + message: "Week completed!", + colorClass: "text-green-500", + tintBgClass: "bg-green-100 dark:bg-green-900/30", + tintBorderClass: "border-green-400/60", + }, +}; + +export function getPlanStateVisuals( + state: PlanState | null | undefined +): PlanStateVisuals | null { + if (!state) return null; + return VISUALS[state] ?? null; +} diff --git a/apps/frontend-vite/vercel.json b/apps/frontend-vite/vercel.json index 78509da8..c7621ab9 100644 --- a/apps/frontend-vite/vercel.json +++ b/apps/frontend-vite/vercel.json @@ -1,4 +1,5 @@ { + "outputDirectory": "dist", "rewrites": [ { "source": "/relay-ph/static/(.*)", diff --git a/docs/qa/joint-activity-card-preview.png b/docs/qa/joint-activity-card-preview.png new file mode 100644 index 00000000..bd31102a Binary files /dev/null and b/docs/qa/joint-activity-card-preview.png differ diff --git a/packages/prisma/migrate_prod_to_dev.ts b/packages/prisma/migrate_prod_to_dev.ts index 548dea64..a1719d8f 100644 --- a/packages/prisma/migrate_prod_to_dev.ts +++ b/packages/prisma/migrate_prod_to_dev.ts @@ -53,6 +53,22 @@ async function getTableColumns( return new Set(columns.map((col) => col.column_name)); } +async function tableExists( + prismaClient: PrismaClient, + tableName: string +): Promise { + const result = await prismaClient.$queryRaw>` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${tableName} + ) as "exists" + `; + + return result[0]?.exists ?? false; +} + /** * Filter an object to only include keys that exist as columns in the database */ @@ -71,6 +87,14 @@ function filterToExistingColumns>( return filtered; } +function hasUsableString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function makeFallbackEmail(userId: string): string { + return `${userId}@missing-email.tracking-so.local`; +} + /** * Build a Prisma select object that only includes fields that exist in the database */ @@ -137,6 +161,12 @@ async function confirmMigration(): Promise { async function clearTargetDatabase() { console.info("Clearing target database..."); + if (!(await tableExists(targetPrisma, "users"))) { + throw new Error( + "Target database schema is missing. Run `pnpm --dir packages/prisma db:push` before migrating data." + ); + } + // Clear in reverse dependency order to avoid foreign key constraints const tables = [ "message_feedback", @@ -167,6 +197,11 @@ async function clearTargetDatabase() { for (const table of tables) { try { + if (!(await tableExists(targetPrisma, table))) { + console.info(`Skipping ${table}; table does not exist in target`); + continue; + } + await targetPrisma.$executeRawUnsafe(`DELETE FROM "${table}"`); console.info(`Cleared ${table}`); } catch (error) { @@ -198,23 +233,35 @@ async function migrateData() { // First pass: Upsert all users without referral relationships for (const user of users) { - const { id, referredById, ...userData } = user; + const { id, referredById, ...userData } = user as Record; // Filter userData to only include fields that exist in both source and target const commonColumns = new Set( [...sourceUserColumns].filter((col) => targetUserColumns.has(col)) ); const filteredData = filterToExistingColumns(userData, commonColumns); + const createData: Record = { ...filteredData }; + const updateData: Record = { ...filteredData }; + + if (targetUserColumns.has("email")) { + if (!hasUsableString(createData.email)) { + createData.email = makeFallbackEmail(id); + } + + if (!hasUsableString(updateData.email)) { + delete updateData.email; + } + } await targetPrisma.user.upsert({ where: { id }, create: { id, - ...filteredData, + ...createData, ...(commonColumns.has("referredById") ? { referredById: null } : {}), // Will be updated in second pass } as any, update: { - ...filteredData, + ...updateData, ...(commonColumns.has("referredById") ? { referredById: null } : {}), // Will be updated in second pass } as any, }); @@ -223,10 +270,11 @@ async function migrateData() { // Second pass: Update referral relationships (only if the field exists) if (targetUserColumns.has("referredById")) { for (const user of users) { - if ((user as any).referredById) { + const { id, referredById } = user as Record; + if (referredById) { await targetPrisma.user.update({ where: { id }, - data: { referredById: (user as any).referredById }, + data: { referredById }, }); } } @@ -359,7 +407,7 @@ async function migrateData() { }); for (const entry of activityEntries) { - const { id, ...entryData } = entry; + const { id, ...entryData } = entry as Record; const filteredData = filterToExistingColumns(entryData, commonActivityEntryColumns); await targetPrisma.activityEntry.upsert({ @@ -517,7 +565,7 @@ async function migrateData() { }); for (const achievementPost of achievementPosts) { - const { id, ...achievementPostData } = achievementPost; + const { id, ...achievementPostData } = achievementPost as Record; // Filter data to only include fields that exist in both source and target const commonColumns = new Set( @@ -674,7 +722,7 @@ async function migrateData() { }); for (const chat of chats) { - const { id, ...chatData } = chat; + const { id, ...chatData } = chat as Record; // Filter chatData to only include fields that exist in both source and target const commonColumns = new Set( @@ -776,47 +824,61 @@ async function migrateData() { console.info(`Migrated ${recommendations.length} recommendations`); console.info("Migrating feedback..."); - const feedbacks = await sourcePrisma.feedback.findMany(); - - for (const feedback of feedbacks) { - const { id, ...feedbackData } = feedback; - await targetPrisma.feedback.upsert({ - where: { id }, - create: { - id, - ...feedbackData, - metadata: feedback.metadata as any, // Handle JsonValue type - }, - update: { - ...feedbackData, - metadata: feedback.metadata as any, // Handle JsonValue type - }, - }); + const canMigrateFeedback = + (await tableExists(sourcePrisma, "feedback")) && + (await tableExists(targetPrisma, "feedback")); + if (canMigrateFeedback) { + const feedbacks = await sourcePrisma.feedback.findMany(); + + for (const feedback of feedbacks) { + const { id, ...feedbackData } = feedback; + await targetPrisma.feedback.upsert({ + where: { id }, + create: { + id, + ...feedbackData, + metadata: feedback.metadata as any, // Handle JsonValue type + }, + update: { + ...feedbackData, + metadata: feedback.metadata as any, // Handle JsonValue type + }, + }); + } + console.info(`Migrated ${feedbacks.length} feedback entries`); + } else { + console.info("Skipping feedback; source or target table does not exist"); } - console.info(`Migrated ${feedbacks.length} feedback entries`); // Step 19: Migrate Job Runs console.info("Migrating job runs..."); - const jobRuns = await sourcePrisma.jobRun.findMany(); - - for (const jobRun of jobRuns) { - const { id, ...jobRunData } = jobRun; - await targetPrisma.jobRun.upsert({ - where: { id }, - create: { - id, - ...jobRunData, - input: jobRun.input as any, // Handle JsonValue type - output: jobRun.output as any, // Handle JsonValue type - }, - update: { - ...jobRunData, - input: jobRun.input as any, // Handle JsonValue type - output: jobRun.output as any, // Handle JsonValue type - }, - }); + const canMigrateJobRuns = + (await tableExists(sourcePrisma, "job_runs")) && + (await tableExists(targetPrisma, "job_runs")); + if (canMigrateJobRuns) { + const jobRuns = await sourcePrisma.jobRun.findMany(); + + for (const jobRun of jobRuns) { + const { id, ...jobRunData } = jobRun; + await targetPrisma.jobRun.upsert({ + where: { id }, + create: { + id, + ...jobRunData, + input: jobRun.input as any, // Handle JsonValue type + output: jobRun.output as any, // Handle JsonValue type + }, + update: { + ...jobRunData, + input: jobRun.input as any, // Handle JsonValue type + output: jobRun.output as any, // Handle JsonValue type + }, + }); + } + console.info(`Migrated ${jobRuns.length} job runs`); + } else { + console.info("Skipping job runs; source or target table does not exist"); } - console.info(`Migrated ${jobRuns.length} job runs`); // Post-processing: Impersonate user by swapping supabaseAuthId const { impersonateUser } = parseArgs(); diff --git a/supabase/config.toml b/supabase/config.toml index db13f5ef..9bde2144 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -117,9 +117,9 @@ file_size_limit = "50MiB" enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://localhost:5173" +site_url = "https://tracking-so.localhost" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["http://127.0.0.1:5173", "http://localhost:5173"] +additional_redirect_urls = ["https://tracking-so.localhost", "https://tracking-so.localhost/", "http://127.0.0.1:5173", "http://localhost:5173"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # Path to JWT signing key. DO NOT commit your signing keys file to git.