From eb5c1280b8e49341d662748fcb98d9e3ec38847a Mon Sep 17 00:00:00 2001 From: Alex Ramalho <47791391+alramalho@users.noreply.github.com> Date: Fri, 22 May 2026 22:18:24 +0200 Subject: [PATCH 01/23] fix: stop timeline infinite spinner on pagination errors (#22) --- .../src/components/TimelineRenderer.tsx | 28 +++++++++++++++++-- .../src/contexts/timeline/index.tsx | 22 ++++++++++++++- .../src/contexts/timeline/types.ts | 1 + 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/frontend-vite/src/components/TimelineRenderer.tsx b/apps/frontend-vite/src/components/TimelineRenderer.tsx index f6fa9839..976f107b 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(); @@ -271,7 +272,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 +286,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(() => { @@ -739,6 +745,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/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/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; } From 4e85d215cd572df08a04275944fdcb1c9905f0f8 Mon Sep 17 00:00:00 2001 From: Alex Ramalho <47791391+alramalho@users.noreply.github.com> Date: Fri, 22 May 2026 22:24:39 +0200 Subject: [PATCH 02/23] perf: speed up photo proof uploads (#23) --- apps/backend-node/src/routes/activities.ts | 122 ++++++++++++------ .../src/components/ActivityPhotoUploader.tsx | 21 ++- .../src/contexts/activities/provider.tsx | 12 +- .../src/contexts/activities/types.ts | 1 + 4 files changed, 114 insertions(+), 42 deletions(-) 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/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/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 { From 9b7f0b63ec3e1dc13bb8bb04f8f63abc30cd3f59 Mon Sep 17 00:00:00 2001 From: Alex Ramalho <47791391+alramalho@users.noreply.github.com> Date: Sun, 24 May 2026 08:28:36 +0000 Subject: [PATCH 03/23] fix: merge joint activity timeline cards --- .../src/components/ActivityEntryPhotoCard.tsx | 99 +++++++++++---- .../src/components/TimelineRenderer.tsx | 115 ++++++++++++++---- docs/qa/joint-activity-card-preview.png | Bin 0 -> 119296 bytes 3 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 docs/qa/joint-activity-card-preview.png diff --git a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx index a6c116f1..f7e370b4 100644 --- a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx +++ b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx @@ -58,6 +58,14 @@ const getFormattedDate = (date: Date) => { return format(date, "MMM d"); }; +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 & { @@ -75,6 +83,7 @@ interface ActivityEntryPhotoCardProps { }; user: { username: string; name: string; picture: string; planType: PlanType }; userPlansProgressData: PlanProgressData[]; + sharedActivityEntries?: SharedActivityCardEntry[]; editable?: boolean; onEditClick?: () => void; onAvatarClick?: () => void; @@ -106,6 +115,7 @@ const ActivityEntryPhotoCard: React.FC = ({ activityEntry, user, userPlansProgressData, + sharedActivityEntries = [], isCollapsed = false, onToggleCollapse, }) => { @@ -348,14 +358,26 @@ const ActivityEntryPhotoCard: React.FC = ({ }`; }; + 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 imageUrls = Array.from( - new Set( - [ - ...((activityEntry as typeof activityEntry & { imageUrls?: string[] }) - .imageUrls || []), - activityEntry.imageUrl, - ].filter((url): url is string => !!url) - ) + new Set([ + ...getEntryImageUrls(activityEntry as typeof activityEntry & { imageUrls?: string[] }), + ...sharedActivityEntries.flatMap(({ activityEntry }) => + getEntryImageUrls(activityEntry) + ), + ]) ); const hasImage = imageUrls.length > 0; const shouldShowNeonEffect = habitAchieved || lifestyleAchieved; @@ -370,17 +392,23 @@ const ActivityEntryPhotoCard: React.FC = ({ const trimmedActivityTitle = activity.title.length > 10 ? activity.title.slice(0, 10) + "..." : activity.title; - const sharedParticipants = - activityEntry.sharedActivityEntry?.sharedActivity?.entries + const sharedParticipants = [ + ...sharedActivityEntries.map(({ user }) => user), + ...(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(", "); + ?.map((entry) => entry.user) ?? []), + ].filter((participant) => participant.username); + const sharedParticipantLabel = Array.from( + new Set(sharedParticipants.map((participant) => `@${participant.username}`)) + ).join(", "); + const isMergedJointActivity = sharedActivityEntries.length > 0; + const activitySummaryRows = [ + { activityEntry, activity, user }, + ...sharedActivityEntries, + ]; // Collapsed minimal view for cards without images const collapsedCardContent = ( @@ -651,13 +679,42 @@ const ActivityEntryPhotoCard: React.FC = ({ className: "drop-shadow-sm", })} */} - - {activity.title} – {activityEntry.quantity} {activity.measure} - - {sharedParticipantLabel && ( - - with {sharedParticipantLabel} - + {isMergedJointActivity ? ( +
+
+ Joint activity +
+
+ {activitySummaryRows.map((row) => ( +
+ {row.activity.emoji} + + + @{row.user.username} + {" "} + + {row.activity.title} – {row.activityEntry.quantity}{" "} + {row.activity.measure} + + +
+ ))} +
+
+ ) : ( + <> + + {activity.title} – {activityEntry.quantity} {activity.measure} + + {sharedParticipantLabel && ( + + with {sharedParticipantLabel} + + )} + )} {getFormattedDate(activityEntry.datetime)}{" "} diff --git a/apps/frontend-vite/src/components/TimelineRenderer.tsx b/apps/frontend-vite/src/components/TimelineRenderer.tsx index 976f107b..a2e4c7bd 100644 --- a/apps/frontend-vite/src/components/TimelineRenderer.tsx +++ b/apps/frontend-vite/src/components/TimelineRenderer.tsx @@ -200,18 +200,44 @@ 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 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)); + items.push({ type: "activity", data: { primary: entries[0], entries } }); }); // Add achievement posts @@ -223,11 +249,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(); }); @@ -302,7 +328,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; }); @@ -311,7 +337,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; }); @@ -326,7 +352,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; @@ -604,7 +630,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; }); @@ -649,23 +675,63 @@ 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) => { + const hasImageExpired = + groupEntry.imageExpiresAt && + new Date(groupEntry.imageExpiresAt) < new Date(); + const imageUrls = (groupEntry as any).imageUrls || []; + return (groupEntry.imageUrl || imageUrls.length > 0) && !hasImageExpired; + }); + 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); @@ -684,14 +750,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 @@ -718,6 +786,7 @@ const TimelineRenderer: React.FC<{ } } userPlansProgressData={userPlansProgress} + sharedActivityEntries={sharedActivityEntries} isCollapsed={isCollapsed} onToggleCollapse={() => toggleEntryCollapse(entry.id)} onAvatarClick={() => { diff --git a/docs/qa/joint-activity-card-preview.png b/docs/qa/joint-activity-card-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..bd31102a91f35faaa981cee458fd7985476dd9f4 GIT binary patch literal 119296 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 Date: Mon, 25 May 2026 14:21:09 +0000 Subject: [PATCH 04/23] feat: merge joint activity cards --- apps/backend-node/src/routes/users.ts | 50 +++++++++++++++-- .../src/components/ActivityEntryPhotoCard.tsx | 56 +++++++++++++++---- .../src/components/TimelineRenderer.tsx | 7 ++- .../src/contexts/timeline/service.ts | 35 ++++++++++++ .../src/contexts/users/service.ts | 30 ++++++++++ apps/frontend-vite/src/lib/timelineItems.ts | 44 +++++++++++++++ .../src/routes/profile.$username.tsx | 8 ++- 7 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 apps/frontend-vite/src/lib/timelineItems.ts diff --git a/apps/backend-node/src/routes/users.ts b/apps/backend-node/src/routes/users.ts index cecb24f0..0673a98d 100644 --- a/apps/backend-node/src/routes/users.ts +++ b/apps/backend-node/src/routes/users.ts @@ -608,17 +608,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 +654,14 @@ usersRouter.post( }, }, activityEntry: { - select: { id: true, userId: true, deletedAt: true }, + select: { + id: true, + userId: true, + deletedAt: true, + imageUrl: true, + imageUrls: true, + imageExpiresAt: true, + }, }, }, }, @@ -690,6 +705,10 @@ 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), @@ -901,7 +920,7 @@ usersRouter.get( ...activityCursorWhere, }, orderBy: [{ datetime: "desc" }, { id: "desc" }], - take: limit + 1, + take: limit * 2 + 1, include: { comments: { where: { deletedAt: null }, @@ -943,7 +962,14 @@ usersRouter.get( }, }, activityEntry: { - select: { id: true, userId: true, deletedAt: true }, + select: { + id: true, + userId: true, + deletedAt: true, + imageUrl: true, + imageUrls: true, + imageExpiresAt: true, + }, }, }, }, @@ -1039,8 +1065,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({ diff --git a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx index a6c116f1..9651d0b7 100644 --- a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx +++ b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx @@ -64,11 +64,20 @@ interface ActivityEntryPhotoCardProps { reactions: (Reaction & { user: { username: string } })[]; comments: (Comment & { user: { username: string; picture: string } })[]; sharedActivityEntry?: { + sharedActivityId?: string | null; sharedActivity?: { + id?: string | null; entries?: { activityEntryId: string; user: { id: string; username: string | null; name?: string | null; picture?: string | null }; - activityEntry?: { id: string; userId: string; deletedAt?: Date | null }; + activityEntry?: { + id: string; + userId: string; + deletedAt?: Date | null; + imageUrl?: string | null; + imageUrls?: string[]; + imageExpiresAt?: Date | null; + } | null; }[]; }; } | null; @@ -354,10 +363,29 @@ const ActivityEntryPhotoCard: React.FC = ({ ...((activityEntry as typeof activityEntry & { imageUrls?: string[] }) .imageUrls || []), activityEntry.imageUrl, + ...( + activityEntry.sharedActivityEntry?.sharedActivity?.entries + ?.filter((entry) => + entry.activityEntryId !== activityEntry.id && + !entry.activityEntry?.deletedAt && + (!entry.activityEntry?.imageExpiresAt || + new Date(entry.activityEntry.imageExpiresAt) > new Date()) + ) + ?.flatMap((entry) => [ + ...(entry.activityEntry?.imageUrls || []), + entry.activityEntry?.imageUrl, + ]) || [] + ), ].filter((url): url is string => !!url) ) ); const hasImage = imageUrls.length > 0; + const hasSharedActivity = Boolean( + activityEntry.sharedActivityEntry?.sharedActivity?.entries?.some( + (entry) => entry.activityEntryId !== activityEntry.id && !entry.activityEntry?.deletedAt + ) + ); + const hasDenseMediaLayout = imageUrls.length > 1 || hasSharedActivity; const shouldShowNeonEffect = habitAchieved || lifestyleAchieved; // Extract first URL from description for link preview @@ -370,14 +398,14 @@ const ActivityEntryPhotoCard: React.FC = ({ 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 sharedEntries = + activityEntry.sharedActivityEntry?.sharedActivity?.entries?.filter( + (entry) => !entry.activityEntry?.deletedAt + ) ?? []; + const sharedParticipants = sharedEntries + .filter((entry) => entry.activityEntryId !== activityEntry.id) + .map((entry) => entry.user) + .filter((participant) => participant.username) ?? []; const sharedParticipantLabel = sharedParticipants .map((participant) => `@${participant.username}`) .join(", "); @@ -465,20 +493,24 @@ const ActivityEntryPhotoCard: React.FC = ({ ))}
)} -
+
{reactions && Object.entries(reactions).map(([emoji, usernames]) => { return ( + ); + }; + + // Collapsed minimal view for cards without images + const collapsedCardContent = ( +
+
+ {renderAvatarCluster("sm")}
{activity.emoji} @@ -641,29 +707,8 @@ const ActivityEntryPhotoCard: React.FC = ({
-
- - - - {(user.name || "U")[0]} - - +
+ {renderAvatarCluster("md")}
{activity.emoji} @@ -768,7 +813,7 @@ const ActivityEntryPhotoCard: React.FC = ({ )}
- {editable && onEditClick && ( + {editable && isOwnActivityEntry && onEditClick && ( + +
+
+
+ ); +} 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/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..3c8f78df 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; diff --git a/apps/frontend-vite/src/routes/message-ai.tsx b/apps/frontend-vite/src/routes/message-ai.tsx index 2d71a540..86eaf64c 100644 --- a/apps/frontend-vite/src/routes/message-ai.tsx +++ b/apps/frontend-vite/src/routes/message-ai.tsx @@ -4,6 +4,7 @@ import { MessageFeedback } from "@/components/MessageFeedback"; import { MetricSuggestion } from "@/components/MetricSuggestion"; import { PlanLink } from "@/components/PlanLink"; import { ActivityLogProposalCard } from "@/components/ActivityLogProposalCard"; +import { PlanCreationProposalCard } from "@/components/PlanCreationProposalCard"; import { PlanProposalCard } from "@/components/PlanProposalCard"; import { UserRecommendationCards } from "@/components/UserRecommendationCards"; import { Button } from "@/components/ui/button"; @@ -232,6 +233,8 @@ function MessageAIPage() { rejectMetric, acceptProposal, rejectProposal, + acceptPlanCreationProposal, + rejectPlanCreationProposal, acceptActivityLogProposal, rejectActivityLogProposal, createCoachChat, @@ -457,6 +460,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 }); @@ -909,6 +930,15 @@ function MessageAIPage() { if (op.type === "archive") { return { type: "archive" }; } + if (op.type === "update_plan") { + return { + type: "update_plan", + goal: op.goal, + goalReason: op.goalReason, + timesPerWeek: op.timesPerWeek, + isCoached: op.isCoached, + }; + } const activity = plan?.activities?.find((a: any) => a.id === op.activityId) || activities?.find((a: any) => a.id === op.activityId); @@ -940,6 +970,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) => ( From ed4966b25300e8a703047b3b9b67b8c136cb1865 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 26 May 2026 12:11:51 +0200 Subject: [PATCH 07/23] Refine merged activity card identity --- .../src/components/ActivityEntryPhotoCard.tsx | 117 ++++++++++++++---- 1 file changed, 94 insertions(+), 23 deletions(-) diff --git a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx index f7e370b4..e496d28a 100644 --- a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx +++ b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx @@ -58,6 +58,17 @@ 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 SharedActivityCardEntry = { activityEntry: ActivityEntry & { imageUrls?: string[]; @@ -390,25 +401,35 @@ const ActivityEntryPhotoCard: React.FC = ({ if (!activity || !activityEntry) return null; - const trimmedActivityTitle = activity.title.length > 10 ? activity.title.slice(0, 10) + "..." : activity.title; - - const sharedParticipants = [ - ...sharedActivityEntries.map(({ user }) => user), - ...(activityEntry.sharedActivityEntry?.sharedActivity?.entries - ?.filter( - (entry) => - entry.activityEntryId !== activityEntry.id && !entry.activityEntry?.deletedAt - ) - ?.map((entry) => entry.user) ?? []), - ].filter((participant) => participant.username); - const sharedParticipantLabel = Array.from( - new Set(sharedParticipants.map((participant) => `@${participant.username}`)) - ).join(", "); const isMergedJointActivity = sharedActivityEntries.length > 0; const activitySummaryRows = [ { activityEntry, activity, user }, ...sharedActivityEntries, ]; + const jointParticipantLabel = formatUsernameList( + activitySummaryRows.map((row) => `@${row.user.username}`) + ); + 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 = ( @@ -439,16 +460,42 @@ const ActivityEntryPhotoCard: React.FC = ({ {(user.name || "U")[0]} + {isMergedJointActivity && + sharedActivityEntries.slice(0, 2).map((row, index) => ( + + + {(row.user.name || row.user.username || "U")[0]} + + ))}
- {activity.emoji} + {isMergedJointActivity ? ( +
+ {mergedEmojiRows.map((row) => ( + + {row.activity.emoji} + + ))} +
+ ) : ( + {activity.emoji} + )}
- {activity.title} + {isMergedJointActivity ? jointParticipantLabel : activity.title} - {activityEntry.quantity} {activity.measure} + {isMergedJointActivity + ? mergedActivityLabel + : `${activityEntry.quantity} ${activity.measure}`} {sharedParticipantLabel && ( @@ -660,10 +707,34 @@ const ActivityEntryPhotoCard: React.FC = ({ {(user.name || "U")[0]} + {isMergedJointActivity && + sharedActivityEntries.slice(0, 2).map((row, index) => ( + + + {(row.user.name || row.user.username || "U")[0]} + + ))}
- - {activity.emoji} - + {isMergedJointActivity ? ( +
+ {mergedEmojiRows.map((row) => ( + + {row.activity.emoji} + + ))} +
+ ) : ( + + {activity.emoji} + + )}
= ({ onClick={onUsernameClick} style={{ color: accountLevel.currentLevel?.color }} > - @{user.username} + {isMergedJointActivity ? jointParticipantLabel : `@${user.username}`} {/* {accountLevel.atLeastBronze && accountLevel.currentLevel?.getIcon({ @@ -696,7 +767,7 @@ const ActivityEntryPhotoCard: React.FC = ({ @{row.user.username} {" "} - {row.activity.title} – {row.activityEntry.quantity}{" "} + {row.activity.title} - {row.activityEntry.quantity}{" "} {row.activity.measure} @@ -707,7 +778,7 @@ const ActivityEntryPhotoCard: React.FC = ({ ) : ( <> - {activity.title} – {activityEntry.quantity} {activity.measure} + {activity.title} - {activityEntry.quantity} {activity.measure} {sharedParticipantLabel && ( From b660b1a94ee5f48ac42719b484758bb63c8c57d1 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 26 May 2026 13:20:39 +0200 Subject: [PATCH 08/23] Polish merged activity card layout --- apps/backend-node/src/routes/users.ts | 8 +- .../src/components/ActivityEntryPhotoCard.tsx | 373 +++++++++++------- 2 files changed, 239 insertions(+), 142 deletions(-) diff --git a/apps/backend-node/src/routes/users.ts b/apps/backend-node/src/routes/users.ts index cecb24f0..42b18be9 100644 --- a/apps/backend-node/src/routes/users.ts +++ b/apps/backend-node/src/routes/users.ts @@ -646,7 +646,9 @@ usersRouter.post( }, }, activityEntry: { - select: { id: true, userId: true, deletedAt: true }, + include: { + activity: true, + }, }, }, }, @@ -943,7 +945,9 @@ usersRouter.get( }, }, activityEntry: { - select: { id: true, userId: true, deletedAt: true }, + include: { + activity: true, + }, }, }, }, diff --git a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx index e496d28a..8a36b81c 100644 --- a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx +++ b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx @@ -69,6 +69,86 @@ const formatUsernameList = (usernames: string[]) => { }`; }; +type ActivityCardUser = { + username: string; + name?: string | null; + picture?: string | null; + planType?: PlanType; +}; + +const ParticipantAvatar = ({ + user, + size = "md", + isLightMode, +}: { + user: ActivityCardUser; + size?: "sm" | "md"; + isLightMode: boolean; +}) => { + 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 ( + + + + {(user.name || user.username || "U")[0]} + + + ); +}; + +const ParticipantName = ({ user }: { user: ActivityCardUser }) => { + const accountLevel = useAccountLevel(user.username || undefined); + + return ( + + @{user.username} + + ); +}; + +const ParticipantNameList = ({ users }: { users: ActivityCardUser[] }) => { + 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[]; @@ -87,7 +167,11 @@ 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; @@ -382,10 +466,40 @@ const ActivityEntryPhotoCard: React.FC = ({ ].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[] }), - ...sharedActivityEntries.flatMap(({ activityEntry }) => + ...allSharedActivityEntries.flatMap(({ activityEntry }) => getEntryImageUrls(activityEntry) ), ]) @@ -401,14 +515,12 @@ const ActivityEntryPhotoCard: React.FC = ({ if (!activity || !activityEntry) return null; - const isMergedJointActivity = sharedActivityEntries.length > 0; + const isMergedJointActivity = allSharedActivityEntries.length > 0; const activitySummaryRows = [ { activityEntry, activity, user }, - ...sharedActivityEntries, + ...allSharedActivityEntries, ]; - const jointParticipantLabel = formatUsernameList( - activitySummaryRows.map((row) => `@${row.user.username}`) - ); + const jointParticipants = activitySummaryRows.map((row) => row.user); const sharedParticipants = ( isMergedJointActivity ? [] @@ -434,43 +546,26 @@ const ActivityEntryPhotoCard: React.FC = ({ // Collapsed minimal view for cards without images const collapsedCardContent = (
-
- - + {isMergedJointActivity ? ( + jointParticipants.slice(0, 3).map((participant) => ( + + )) + ) : ( +
{ e.stopPropagation(); onAvatarClick?.(); }} > - - {(user.name || "U")[0]} - - - {isMergedJointActivity && - sharedActivityEntries.slice(0, 2).map((row, index) => ( - - - {(row.user.name || row.user.username || "U")[0]} - - ))} + +
+ )}
{isMergedJointActivity ? ( @@ -478,7 +573,7 @@ const ActivityEntryPhotoCard: React.FC = ({ {mergedEmojiRows.map((row) => ( {row.activity.emoji} @@ -490,7 +585,11 @@ const ActivityEntryPhotoCard: React.FC = ({
- {isMergedJointActivity ? jointParticipantLabel : activity.title} + {isMergedJointActivity ? ( + + ) : ( + activity.title + )} {isMergedJointActivity @@ -683,116 +782,110 @@ const ActivityEntryPhotoCard: React.FC = ({ )}
-
-
- - +
+
+ {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}`} + +
+ ) : ( +
+
+ - - {(user.name || "U")[0]} - - - {isMergedJointActivity && - sharedActivityEntries.slice(0, 2).map((row, index) => ( - - {(row.user.name || row.user.username || "U")[0]} + + {(user.name || "U")[0]} - ))} -
- {isMergedJointActivity ? ( -
- {mergedEmojiRows.map((row) => ( - - {row.activity.emoji} - - ))} +
- ) : ( {activity.emoji} - )} -
-
- - {isMergedJointActivity ? jointParticipantLabel : `@${user.username}`} - - {/* {accountLevel.atLeastBronze && - accountLevel.currentLevel?.getIcon({ - size: 16, - className: "drop-shadow-sm", - })} */} -
- {isMergedJointActivity ? ( -
-
- Joint activity -
-
- {activitySummaryRows.map((row) => ( -
- {row.activity.emoji} - - - @{row.user.username} - {" "} - - {row.activity.title} - {row.activityEntry.quantity}{" "} - {row.activity.measure} - - -
- ))} -
+
+
+ + @{user.username} + + {/* {accountLevel.atLeastBronze && + accountLevel.currentLevel?.getIcon({ + size: 16, + className: "drop-shadow-sm", + })} */}
- ) : ( - <> - - {activity.title} - {activityEntry.quantity} {activity.measure} + + {activity.title} - {activityEntry.quantity} {activity.measure} + + {sharedParticipantLabel && ( + + with {sharedParticipantLabel} - {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) */} From 6d5518586c9eabc5858dd44a4fd42a7a292d96f1 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 26 May 2026 14:15:19 +0200 Subject: [PATCH 09/23] Improve coach activity grounding --- apps/backend-node/src/services/aiService.ts | 2 + .../src/services/coachAgentService.ts | 83 +++++- .../src/services/coachAssessmentService.ts | 28 ++ .../frontend-vite/src/components/PlanLink.tsx | 9 +- apps/frontend-vite/src/routes/message-ai.tsx | 261 +++++++++++++++++- 5 files changed, 374 insertions(+), 9 deletions(-) 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..f8089b2d 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,7 @@ interface CoachAgentContext { reminders: Reminder[]; conversationHistory: Array<{ role: "user" | "assistant"; content: string }>; memoriesContext?: string | null; + recentActivityContext?: string | null; } export class CoachAgentService { @@ -69,7 +70,7 @@ 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, reminders, memoriesContext, recentActivityContext } = context; const self = this; const coachPersonality = getCoachPersonalityConfig(user.coachPersonality); @@ -137,6 +138,8 @@ export class CoachAgentService { ${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}` : ""} @@ -147,6 +150,9 @@ export class CoachAgentService { - Be direct, succint 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). + - 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. RULES - Never modify sessions or reminders without user confirmation @@ -653,6 +659,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 @@ -745,6 +819,10 @@ export class CoachAgentService { skipReason?: string; }> { const { user, message, conversationHistory, plans, reminders, memoriesContext } = params; + const recentActivityContext = await this.buildRecentActivityContext( + user, + new Date() + ); const agent = this.createAgent({ user, @@ -752,6 +830,7 @@ export class CoachAgentService { reminders, conversationHistory, memoriesContext, + recentActivityContext, }); try { diff --git a/apps/backend-node/src/services/coachAssessmentService.ts b/apps/backend-node/src/services/coachAssessmentService.ts index e9bbd6bb..22ef32f3 100644 --- a/apps/backend-node/src/services/coachAssessmentService.ts +++ b/apps/backend-node/src/services/coachAssessmentService.ts @@ -528,6 +528,7 @@ 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. + - 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. - Keep messages to 2-3 sentences max. `; } @@ -945,9 +946,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) { 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/routes/message-ai.tsx b/apps/frontend-vite/src/routes/message-ai.tsx index 2d71a540..982ae318 100644 --- a/apps/frontend-vite/src/routes/message-ai.tsx +++ b/apps/frontend-vite/src/routes/message-ai.tsx @@ -19,8 +19,8 @@ import { getThemeVariants } from "@/utils/theme"; import { toDisplayErrorMessage } from "@/utils/errorMessage"; import { cn } from "@/lib/utils"; import { createFileRoute } from "@tanstack/react-router"; -import { Send, Loader2, ArrowLeft, Target, X, Settings, AlertCircle, EllipsisVertical, Trash2, MessageSquarePlus, Eraser, Sparkles } from "lucide-react"; -import { format } from "date-fns"; +import { Send, Loader2, ArrowLeft, Target, X, Settings, AlertCircle, EllipsisVertical, Trash2, MessageSquarePlus, Eraser, Sparkles, ChevronDown, Eye, CalendarDays } from "lucide-react"; +import { differenceInCalendarDays, format } from "date-fns"; import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useInView } from "react-intersection-observer"; import ReactMarkdown from "react-markdown"; @@ -67,6 +67,188 @@ function DateDivider({ date }: { date: Date }) { ); } +function formatCoachVisibleDate(date: Date): string { + const today = new Date(); + const diffDays = differenceInCalendarDays(today, date); + + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays > 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 VisibleActivity = { + id: string; + emoji: string; + title: string; + quantity: number; + measure: string; + datetime: Date; +}; + +type VisibleSession = { + id: string; + emoji: string; + title: string; + date: Date; +}; + +function CoachContextIsland({ + coachName, + expanded, + onToggle, + recentActivities, + coachedPlans, + upcomingSessions, +}: { + coachName: string; + expanded: boolean; + onToggle: () => void; + recentActivities: VisibleActivity[]; + coachedPlans: Array<{ id: string; goal: string; emoji?: string | null }>; + upcomingSessions: VisibleSession[]; +}) { + 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. +
+ )} +
+ +
+
+
+ Coached plans +
+
+ {coachedPlans.length > 0 ? ( + coachedPlans.slice(0, 5).map((plan) => ( + + {plan.emoji || "πŸ“‹"} + {plan.goal} + + )) + ) : ( + + No active coached plans. + + )} +
+
+ +
+
+ + Upcoming +
+
+ {upcomingSessions.length > 0 ? ( + upcomingSessions.slice(0, 3).map((session) => ( +
+ {session.emoji} + + {session.title} + + + {formatCoachVisibleDate(session.date)} + +
+ )) + ) : ( +
+ No scheduled sessions soon. +
+ )} +
+
+
+
+ )} +
+
+ ); +} + function MessageWithReadTracking({ message, isOwnMessage, @@ -208,7 +390,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 { @@ -242,6 +424,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 +480,64 @@ 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 coachedPlans = useMemo( + () => + (plans || []) + .filter((plan: any) => plan.isCoached && !plan.archivedAt && !plan.deletedAt) + .map((plan: any) => ({ + id: plan.id, + goal: plan.goal, + emoji: plan.emoji, + })), + [plans] + ); + + const upcomingSessions = useMemo(() => { + const now = new Date(); + const today = new Date(now); + today.setHours(0, 0, 0, 0); + const sevenDaysFromNow = new Date(now); + sevenDaysFromNow.setDate(now.getDate() + 7); + + return (plans || []) + .flatMap((plan: any) => + (plan.sessions || []).map((session: any) => { + const activity = plan.activities?.find((a: any) => a.id === session.activityId); + return { + id: session.id, + emoji: activity?.emoji || "πŸ“Œ", + title: activity?.title || plan.goal, + date: new Date(session.date), + }; + }) + ) + .filter((session) => session.date >= today && session.date <= sevenDaysFromNow) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(0, 8); + }, [plans]); + // Auto-select most recent coach chat or create one useEffect(() => { if (isLoadingChats) return; @@ -647,7 +888,10 @@ function MessageAIPage() { ), @@ -806,6 +1050,15 @@ function MessageAIPage() {
+ setShowCoachContext((value) => !value)} + recentActivities={recentActivities} + coachedPlans={coachedPlans} + upcomingSessions={upcomingSessions} + /> + {/* Messages */}
From 187df8c8b64fbafadfb89b9fe062ede268a3f117 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 26 May 2026 14:20:30 +0200 Subject: [PATCH 10/23] Fix merged activity profile navigation --- .../src/components/ActivityEntryPhotoCard.tsx | 59 ++++++++++++++++--- .../src/components/TimelineRenderer.tsx | 6 ++ .../src/routes/profile.$username.tsx | 18 ++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx index 8a36b81c..04f40527 100644 --- a/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx +++ b/apps/frontend-vite/src/components/ActivityEntryPhotoCard.tsx @@ -80,10 +80,12 @@ 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; @@ -102,12 +104,17 @@ const ParticipantAvatar = ({ badgeSize={ringSize} > { + if (!onClick) return; + event.stopPropagation(); + onClick(user); + }} > {(user.name || user.username || "U")[0]} @@ -116,17 +123,37 @@ const ParticipantAvatar = ({ ); }; -const ParticipantName = ({ user }: { user: ActivityCardUser }) => { +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 }: { users: ActivityCardUser[] }) => { +const ParticipantNameList = ({ + users, + onParticipantClick, +}: { + users: ActivityCardUser[]; + onParticipantClick?: (user: ActivityCardUser) => void; +}) => { const uniqueUsers = users.filter( (participant, index, list) => participant.username && @@ -142,7 +169,7 @@ const ParticipantNameList = ({ users }: { users: ActivityCardUser[] }) => { {index === uniqueUsers.length - 1 ? " and " : ", "} )} - + ))} @@ -183,6 +210,7 @@ interface ActivityEntryPhotoCardProps { onEditClick?: () => void; onAvatarClick?: () => void; onUsernameClick?: () => void; + onParticipantClick?: (username: string) => void; isCollapsed?: boolean; onToggleCollapse?: () => void; } @@ -206,6 +234,7 @@ const ActivityEntryPhotoCard: React.FC = ({ onAvatarClick, onEditClick, onUsernameClick, + onParticipantClick, activity, activityEntry, user, @@ -254,6 +283,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 { @@ -554,6 +591,7 @@ const ActivityEntryPhotoCard: React.FC = ({ user={participant} size="sm" isLightMode={isLightMode} + onClick={handleParticipantClick} /> )) ) : ( @@ -586,7 +624,10 @@ const ActivityEntryPhotoCard: React.FC = ({
{isMergedJointActivity ? ( - + ) : ( activity.title )} @@ -791,11 +832,15 @@ const ActivityEntryPhotoCard: React.FC = ({ key={participant.username} user={participant} isLightMode={isLightMode} + onClick={handleParticipantClick} /> ))}
- +
diff --git a/apps/frontend-vite/src/components/TimelineRenderer.tsx b/apps/frontend-vite/src/components/TimelineRenderer.tsx index a2e4c7bd..f15af279 100644 --- a/apps/frontend-vite/src/components/TimelineRenderer.tsx +++ b/apps/frontend-vite/src/components/TimelineRenderer.tsx @@ -801,6 +801,12 @@ const TimelineRenderer: React.FC<{ params: { username: user?.username || "" }, }); }} + onParticipantClick={(username) => { + navigate({ + to: `/profile/$username`, + params: { username }, + }); + }} />
diff --git a/apps/frontend-vite/src/routes/profile.$username.tsx b/apps/frontend-vite/src/routes/profile.$username.tsx index 6f41fe73..763ba158 100644 --- a/apps/frontend-vite/src/routes/profile.$username.tsx +++ b/apps/frontend-vite/src/routes/profile.$username.tsx @@ -934,6 +934,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 { From 76aabe63563e5853cf1154e5e86c1a6323cfc232 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 26 May 2026 14:32:47 +0200 Subject: [PATCH 11/23] Hide duplicate shared activity entries --- .../src/components/TimelineRenderer.tsx | 4 ++ .../src/routes/profile.$username.tsx | 37 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/frontend-vite/src/components/TimelineRenderer.tsx b/apps/frontend-vite/src/components/TimelineRenderer.tsx index f15af279..a69947f0 100644 --- a/apps/frontend-vite/src/components/TimelineRenderer.tsx +++ b/apps/frontend-vite/src/components/TimelineRenderer.tsx @@ -224,6 +224,9 @@ const TimelineRenderer: React.FC<{ 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[]; @@ -237,6 +240,7 @@ const TimelineRenderer: React.FC<{ }); entries.forEach((groupEntry) => seenEntryIds.add(groupEntry.id)); + sharedEntryIds.forEach((entryId) => seenEntryIds.add(entryId)); items.push({ type: "activity", data: { primary: entries[0], entries } }); }); diff --git a/apps/frontend-vite/src/routes/profile.$username.tsx b/apps/frontend-vite/src/routes/profile.$username.tsx index 763ba158..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]); From fd637ba4a644f02429a90f12dc0db37f89d8ade0 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 29 May 2026 11:26:29 +0200 Subject: [PATCH 12/23] Fix local auth redirect and profile heatmap scroll --- .../src/components/BaseHeatmapRenderer.tsx | 57 ++++--- apps/frontend-vite/src/services/supabase.ts | 17 ++ packages/prisma/migrate_prod_to_dev.ts | 150 +++++++++++++----- supabase/config.toml | 4 +- 4 files changed, 159 insertions(+), 69 deletions(-) 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/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/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. From a642b2d808c2fafe6532274ec0c31e4e8c2a86af Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 29 May 2026 11:33:47 +0200 Subject: [PATCH 13/23] Fix profile point totals for viewed users --- apps/backend-node/src/routes/users.ts | 58 +++++++++++++++++++ .../src/contexts/users/service.ts | 13 +++++ .../src/hooks/useAccountLevel.ts | 35 +++++++---- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/apps/backend-node/src/routes/users.ts b/apps/backend-node/src/routes/users.ts index 8c121c87..544ffa48 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" }); @@ -680,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) { @@ -708,6 +765,7 @@ usersRouter.post( ...plan, progress: progressMap.get(plan.id), })), + accountStats, // Extract the first (and only) human coach profile if exists coachProfile: user.coaches?.[0] || null, }; diff --git a/apps/frontend-vite/src/contexts/users/service.ts b/apps/frontend-vite/src/contexts/users/service.ts index 930eef96..c192e85c 100644 --- a/apps/frontend-vite/src/contexts/users/service.ts +++ b/apps/frontend-vite/src/contexts/users/service.ts @@ -177,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 @@ -202,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; From 605961ebf7f2184310ebdede1abe3356996dcbdb Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 29 May 2026 12:01:27 +0200 Subject: [PATCH 14/23] Fix coach plan visibility and citations --- .../src/services/coachAgentService.ts | 70 ++++++++--- .../src/services/coachAssessmentService.ts | 29 +++-- .../src/components/CoachToolCallsCard.tsx | 29 ++++- .../src/components/MessageBubble.tsx | 14 +++ apps/frontend-vite/src/routes/ai.tsx | 1 + apps/frontend-vite/src/routes/message-ai.tsx | 116 ++++++++++++++++-- .../src/routes/message.$userId.tsx | 1 + 7 files changed, 221 insertions(+), 39 deletions(-) diff --git a/apps/backend-node/src/services/coachAgentService.ts b/apps/backend-node/src/services/coachAgentService.ts index 322550c6..4bac53a2 100644 --- a/apps/backend-node/src/services/coachAgentService.ts +++ b/apps/backend-node/src/services/coachAgentService.ts @@ -25,6 +25,15 @@ interface CoachAgentContext { 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 { private perplexity: Perplexity | null = null; private telegram = new TelegramService(); @@ -70,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, recentActivityContext } = context; + const { + user, + plans: allPlans, + reminders, + memoriesContext, + recentActivityContext, + } = context; + const plans = allPlans.filter((plan) => isActiveCoachPlan(plan)); const self = this; const coachPersonality = getCoachPersonalityConfig(user.coachPersonality); @@ -98,7 +114,8 @@ 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"} @@ -125,6 +142,8 @@ export class CoachAgentService { .join("\n") : null; + let nextCitationIndex = 1; + const agent = new ToolLoopAgent({ model: this.getOpenRouterWithUserId().chat("google/gemini-3-flash-preview"), temperature: 0.8, @@ -149,7 +168,7 @@ 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 - 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. @@ -158,10 +177,16 @@ export class CoachAgentService { - 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, relaxed, and natural. Avoid stiff phrases like "concrete, measurable outcome" unless the user used them first. + - 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. - 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. @@ -173,8 +198,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 }), }), @@ -203,11 +228,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` @@ -216,6 +246,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) { @@ -674,7 +706,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: { @@ -889,6 +930,7 @@ 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() @@ -896,7 +938,7 @@ export class CoachAgentService { const agent = this.createAgent({ user, - plans, + plans: activePlans, reminders, conversationHistory, memoriesContext, @@ -997,7 +1039,7 @@ export class CoachAgentService { })); // 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; diff --git a/apps/backend-node/src/services/coachAssessmentService.ts b/apps/backend-node/src/services/coachAssessmentService.ts index 90d9c42b..06f2e2da 100644 --- a/apps/backend-node/src/services/coachAssessmentService.ts +++ b/apps/backend-node/src/services/coachAssessmentService.ts @@ -94,6 +94,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() @@ -141,9 +150,7 @@ export class CoachAssessmentService { plans: { some: { isCoached: true, - deletedAt: null, - archivedAt: null, - isPaused: false, + ...activePlanWhere(now), }, }, }, @@ -151,9 +158,7 @@ export class CoachAssessmentService { plans: { where: { isCoached: true, - deletedAt: null, - archivedAt: null, - isPaused: false, + ...activePlanWhere(now), }, include: { activities: true, sessions: true }, }, @@ -190,6 +195,7 @@ export class CoachAssessmentService { userId: string, options: { now?: Date } = {} ): Promise { + const now = options.now || new Date(); const user = await prisma.user.findFirst({ where: { id: userId, @@ -198,9 +204,7 @@ export class CoachAssessmentService { include: { plans: { where: { - deletedAt: null, - archivedAt: null, - isPaused: false, + ...activePlanWhere(now), }, include: { activities: true, sessions: true }, }, @@ -218,13 +222,13 @@ export class CoachAssessmentService { const coachedPlans = user.plans.filter((plan) => plan.isCoached); if (coachedPlans.length === 0) { - return this.runCoachSetupCheckin(user as CoachUser, options.now || new Date()); + return this.runCoachSetupCheckin(user as CoachUser, now); } return this.assessUser({ ...(user as CoachUser), plans: coachedPlans }, { dry_run: false, force: true, - now: options.now || new Date(), + now, bypassDuplicateCheck: true, fallbackCheckin: true, }); @@ -596,7 +600,8 @@ export class CoachAssessmentService { - 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. - 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. - - Keep messages to 2-3 sentences max. + - 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. `; } diff --git a/apps/frontend-vite/src/components/CoachToolCallsCard.tsx b/apps/frontend-vite/src/components/CoachToolCallsCard.tsx index 65cd242d..d5d56755 100644 --- a/apps/frontend-vite/src/components/CoachToolCallsCard.tsx +++ b/apps/frontend-vite/src/components/CoachToolCallsCard.tsx @@ -32,6 +32,8 @@ interface WebSearchResult { title: string; snippet: string; url: string; + citationIndex?: number; + citationLabel?: string; } interface OGData { @@ -51,20 +53,33 @@ interface CoachToolCallsCardProps { emoji?: string | null; }>; 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 citedLabels = new Set( + Array.from(content.matchAll(/\[(\d+)\]/g)).map((match) => `[${match[1]}]`) + ); + if (citedLabels.size === 0) return []; + 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 }); } } } @@ -123,6 +138,11 @@ const SourceCard: React.FC<{ source: WebSearchResult; ogData?: OGData }> = ({ so
{/* Site info */}
+ {source.citationLabel && ( + + {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 +283,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/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() {
new Date()) + ); +} + +type CitationSource = { + citationLabel: string; + title?: string; + url: string; +}; + +function getCitationSources(toolCalls?: any[] | null): CitationSource[] { + 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; + seenUrls.add(result.url); + sources.push({ + citationLabel, + title: result.title, + url: result.url, + }); + } + } + + return sources; +} + +function CitationPill({ source }: { source: CitationSource }) { + return ( + + {source.citationLabel.replace(/^\[|\]$/g, "")} + + ); +} + type VisibleActivity = { id: string; emoji: string; @@ -112,14 +169,19 @@ function CoachContextIsland({ expanded, onToggle, recentActivities, - coachedPlans, + activePlans, upcomingSessions, }: { coachName: string; expanded: boolean; onToggle: () => void; recentActivities: VisibleActivity[]; - coachedPlans: Array<{ id: string; goal: string; emoji?: string | null }>; + activePlans: Array<{ + id: string; + goal: string; + emoji?: string | null; + isCoached?: boolean; + }>; upcomingSessions: VisibleSession[]; }) { const preview = @@ -193,22 +255,31 @@ function CoachContextIsland({
- Coached plans + Active plans
- {coachedPlans.length > 0 ? ( - coachedPlans.slice(0, 5).map((plan) => ( + {activePlans.length > 0 ? ( + activePlans.slice(0, 5).map((plan) => ( {plan.emoji || "πŸ“‹"} {plan.goal} + {plan.isCoached ? ( + + + coached + + ) : null} )) ) : ( - No active coached plans. + No active plans. )}
@@ -505,14 +576,15 @@ function MessageAIPage() { .slice(0, 12); }, [activities, activityEntries]); - const coachedPlans = useMemo( + const activePlans = useMemo( () => (plans || []) - .filter((plan: any) => plan.isCoached && !plan.archivedAt && !plan.deletedAt) + .filter(isActiveVisiblePlan) .map((plan: any) => ({ id: plan.id, goal: plan.goal, emoji: plan.emoji, + isCoached: plan.isCoached, })), [plans] ); @@ -842,6 +914,7 @@ function MessageAIPage() { length: number; component: JSX.Element; }> = []; + const citationSources = getCitationSources(message.toolCalls); if (message.metricReplacement) { const index = content.indexOf(message.metricReplacement.textToReplace); @@ -882,6 +955,8 @@ function MessageAIPage() { `"${emoji} `, `"${emoji}`, `"`, + `${emoji} `, + `${emoji}`, ].filter(p => p.length > 0); for (const prefix of prefixPatterns) { @@ -921,6 +996,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; @@ -1076,7 +1172,7 @@ function MessageAIPage() { expanded={showCoachContext} onToggle={() => setShowCoachContext((value) => !value)} recentActivities={recentActivities} - coachedPlans={coachedPlans} + activePlans={activePlans} upcomingSessions={upcomingSessions} /> @@ -1157,6 +1253,7 @@ function MessageAIPage() {
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() {
From c3fa7446a579edc4a4ae604ef047a5cb9c05075a Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 29 May 2026 15:54:08 +0200 Subject: [PATCH 15/23] Polish coach citations and plan proposals --- .../src/services/coachAgentService.ts | 4 +++ .../src/components/CoachToolCallsCard.tsx | 26 ++++++++++++++----- apps/frontend-vite/src/routes/message-ai.tsx | 24 ++++++++++++++--- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/backend-node/src/services/coachAgentService.ts b/apps/backend-node/src/services/coachAgentService.ts index 4bac53a2..9b6d8389 100644 --- a/apps/backend-node/src/services/coachAgentService.ts +++ b/apps/backend-node/src/services/coachAgentService.ts @@ -187,6 +187,10 @@ export class CoachAgentService { - 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. diff --git a/apps/frontend-vite/src/components/CoachToolCallsCard.tsx b/apps/frontend-vite/src/components/CoachToolCallsCard.tsx index d5d56755..a55bbf1c 100644 --- a/apps/frontend-vite/src/components/CoachToolCallsCard.tsx +++ b/apps/frontend-vite/src/components/CoachToolCallsCard.tsx @@ -34,6 +34,7 @@ interface WebSearchResult { url: string; citationIndex?: number; citationLabel?: string; + displayCitationLabel?: string; } interface OGData { @@ -60,10 +61,17 @@ interface CoachToolCallsCardProps { function extractSources(toolCalls: ToolCall[], content?: string): WebSearchResult[] { if (!content) return []; - const citedLabels = new Set( - Array.from(content.matchAll(/\[(\d+)\]/g)).map((match) => `[${match[1]}]`) + const citedLabelsInOrder = Array.from(content.matchAll(/\[(\d+)\]/g)).map( + (match) => `[${match[1]}]` ); - if (citedLabels.size === 0) return []; + 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(); @@ -79,7 +87,11 @@ function extractSources(toolCalls: ToolCall[], content?: string): WebSearchResul if (!citedLabels.has(citationLabel)) continue; if (result.url && !seenUrls.has(result.url)) { seenUrls.add(result.url); - sources.push({ ...result, citationLabel }); + sources.push({ + ...result, + citationLabel, + displayCitationLabel: displayLabels.get(citationLabel) || citationLabel, + }); } } } @@ -138,9 +150,9 @@ const SourceCard: React.FC<{ source: WebSearchResult; ogData?: OGData }> = ({ so
{/* Site info */}
- {source.citationLabel && ( - - {source.citationLabel.replace(/^\[|\]$/g, "")} + {(source.displayCitationLabel || source.citationLabel) && ( + + {(source.displayCitationLabel || source.citationLabel || "").replace(/^\[|\]$/g, "")} )} `[${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; @@ -122,9 +136,11 @@ function getCitationSources(toolCalls?: any[] | null): CitationSource[] { 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, }); @@ -141,9 +157,9 @@ function CitationPill({ source }: { source: CitationSource }) { target="_blank" rel="noopener noreferrer" title={source.title || source.url} - className="mx-0.5 inline-flex h-5 min-w-5 translate-y-[-1px] items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold leading-none text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground" + className="mx-0.5 inline-flex h-5 min-w-5 translate-y-[-1px] items-center justify-center rounded-full bg-primary/15 px-1.5 text-[10px] font-semibold leading-none text-primary hover:bg-primary/25" > - {source.citationLabel.replace(/^\[|\]$/g, "")} + {source.displayCitationLabel.replace(/^\[|\]$/g, "")} ); } @@ -914,7 +930,7 @@ function MessageAIPage() { length: number; component: JSX.Element; }> = []; - const citationSources = getCitationSources(message.toolCalls); + const citationSources = getCitationSources(content, message.toolCalls); if (message.metricReplacement) { const index = content.indexOf(message.metricReplacement.textToReplace); From f59dce1fbd3bcd6d6d125390fea8c2154a7b6996 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 31 May 2026 09:39:55 +0200 Subject: [PATCH 16/23] Filter archived and paused plans from coach context --- apps/backend-node/src/routes/ai.ts | 3 +++ apps/backend-node/src/routes/chats.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/backend-node/src/routes/ai.ts b/apps/backend-node/src/routes/ai.ts index c1b1c475..34020d88 100644 --- a/apps/backend-node/src/routes/ai.ts +++ b/apps/backend-node/src/routes/ai.ts @@ -714,6 +714,8 @@ router.post( where: { userId: user.id, deletedAt: null, + archivedAt: null, + isPaused: false, OR: [{ finishingDate: null }, { finishingDate: { gt: new Date() } }], }, include: { @@ -2132,6 +2134,7 @@ router.post( userId: user.id, deletedAt: null, archivedAt: null, + isPaused: false, OR: [ { finishingDate: null }, { finishingDate: { gt: new Date() } }, diff --git a/apps/backend-node/src/routes/chats.ts b/apps/backend-node/src/routes/chats.ts index 78e5be1c..a2930654 100644 --- a/apps/backend-node/src/routes/chats.ts +++ b/apps/backend-node/src/routes/chats.ts @@ -435,6 +435,8 @@ router.post( where: { userId: user.id, deletedAt: null, + archivedAt: null, + isPaused: false, OR: [ { finishingDate: null }, { finishingDate: { gt: new Date() } }, From 8c6ca14b8a7d09be9cbd91a71651c0375490756f Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 31 May 2026 11:45:00 +0200 Subject: [PATCH 17/23] Add coach upcoming-sessions ghost grid, generalize coach to all plans, and rework manual assessment as a fresh status review --- apps/backend-node/src/routes/ai.ts | 33 +-- apps/backend-node/src/routes/chats.ts | 1 + apps/backend-node/src/routes/plans.ts | 33 +-- .../src/services/coachAgentService.ts | 5 +- .../src/services/coachAssessmentService.ts | 92 ++++++-- .../src/components/CalendarGrid.tsx | 111 +++++++++- .../src/components/CoachHomeSection.tsx | 24 +-- .../src/components/PlanProgressCard.tsx | 32 +-- .../src/contexts/messages/types.ts | 2 + apps/frontend-vite/src/routes/message-ai.tsx | 201 +++++++++--------- apps/frontend-vite/src/utils/ghostGrid.ts | 153 +++++++++++++ apps/frontend-vite/src/utils/planState.ts | 57 +++++ 12 files changed, 524 insertions(+), 220 deletions(-) create mode 100644 apps/frontend-vite/src/utils/ghostGrid.ts create mode 100644 apps/frontend-vite/src/utils/planState.ts diff --git a/apps/backend-node/src/routes/ai.ts b/apps/backend-node/src/routes/ai.ts index 34020d88..6f0f1648 100644 --- a/apps/backend-node/src/routes/ai.ts +++ b/apps/backend-node/src/routes/ai.ts @@ -33,24 +33,6 @@ function hasPendingCoachActions(metadata: unknown): boolean { ) || (data?.metricReplacement && !data.metricReplacement.status); } -async function ensureSingleCoachedPlan( - tx: any, - userId: string, - planId: string, - isCoached: boolean -): Promise { - if (!isCoached) return; - await tx.plan.updateMany({ - where: { - userId, - deletedAt: null, - id: { not: planId }, - isCoached: true, - }, - data: { isCoached: false }, - }); -} - async function concludeResolvedAutonomousCoachNotifications( userId: string, chatId: string, @@ -1446,17 +1428,9 @@ router.post( if (op.isCoached !== undefined) updateData.isCoached = op.isCoached; if (op.goal !== undefined) updateData.goalChanged = true; - await prisma.$transaction(async (tx) => { - await tx.plan.update({ - where: { id: proposal.planId }, - data: updateData, - }); - await ensureSingleCoachedPlan( - tx, - user.id, - proposal.planId, - op.isCoached === true - ); + await prisma.plan.update({ + where: { id: proposal.planId }, + data: updateData, }); changes.push({ @@ -1706,7 +1680,6 @@ router.post( include: { activities: true, sessions: true }, }); - await ensureSingleCoachedPlan(tx, user.id, newPlan.id, true); return newPlan; }); diff --git a/apps/backend-node/src/routes/chats.ts b/apps/backend-node/src/routes/chats.ts index a2930654..e871ec8b 100644 --- a/apps/backend-node/src/routes/chats.ts +++ b/apps/backend-node/src/routes/chats.ts @@ -301,6 +301,7 @@ router.get( userRecommendations: metadata.userRecommendations || null, toolCalls: metadata.toolCalls || null, error: metadata.error || false, + source: metadata.source || null, createdAt: msg.createdAt, feedback: msg.feedback, }; diff --git a/apps/backend-node/src/routes/plans.ts b/apps/backend-node/src/routes/plans.ts index 89dcbeed..4e270317 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( "/", @@ -1449,11 +1423,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 +1740,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/services/coachAgentService.ts b/apps/backend-node/src/services/coachAgentService.ts index 9b6d8389..d204d82a 100644 --- a/apps/backend-node/src/services/coachAgentService.ts +++ b/apps/backend-node/src/services/coachAgentService.ts @@ -144,6 +144,9 @@ export class CoachAgentService { 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, @@ -153,7 +156,7 @@ export class CoachAgentService { ${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) diff --git a/apps/backend-node/src/services/coachAssessmentService.ts b/apps/backend-node/src/services/coachAssessmentService.ts index 06f2e2da..d937a6e2 100644 --- a/apps/backend-node/src/services/coachAssessmentService.ts +++ b/apps/backend-node/src/services/coachAssessmentService.ts @@ -70,7 +70,8 @@ type CoachInterventionType = | "SESSION_PREP" | "WEEK_RECAP" | "INACTIVITY_CHECKIN" - | "CELEBRATION"; + | "CELEBRATION" + | "STATUS_REVIEW"; type CoachInterventionCandidate = { type: CoachInterventionType; @@ -149,7 +150,6 @@ export class CoachAssessmentService { : {}), plans: { some: { - isCoached: true, ...activePlanWhere(now), }, }, @@ -157,7 +157,6 @@ export class CoachAssessmentService { include: { plans: { where: { - isCoached: true, ...activePlanWhere(now), }, include: { activities: true, sessions: true }, @@ -220,18 +219,78 @@ export class CoachAssessmentService { }; } - const coachedPlans = user.plans.filter((plan) => plan.isCoached); - if (coachedPlans.length === 0) { - return this.runCoachSetupCheckin(user as CoachUser, now); + const coachUser = user as CoachUser; + if (coachUser.plans.length === 0) { + return this.runCoachSetupCheckin(coachUser, now); } - return this.assessUser({ ...(user as CoachUser), plans: coachedPlans }, { - dry_run: false, - force: true, - now, - bypassDuplicateCheck: true, - fallbackCheckin: true, + // 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( @@ -451,6 +510,12 @@ export class CoachAssessmentService { bypassDuplicateCheck, }); + 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, @@ -725,6 +790,7 @@ export class CoachAssessmentService { WEEK_RECAP: "Weekly recap", INACTIVITY_CHECKIN: "Coach check-in", CELEBRATION: "Nice work", + STATUS_REVIEW: "Coach assessment", }; return titles[type]; } @@ -1110,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/frontend-vite/src/components/CalendarGrid.tsx b/apps/frontend-vite/src/components/CalendarGrid.tsx index 38e853c6..c1ef1413 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,26 @@ 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 hasGhost = dayGhosts.length > 0; + const isGhostSelected = !!selectedGhostDay && isSameDay(selectedGhostDay, day); return (
hasSession && handleDayClick(day)} + onClick={() => { + if (hasSession) handleDayClick(day); + else if (hasGhost) 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", + isGhostSelected && !isToday && cn(variants.brightBorder, variants.veryFadedBg), + !isToday && !isGhostSelected && "border-border bg-card", isPast && !isToday && "opacity-50", - hasSession && !isPast && "cursor-pointer hover:border-muted-foreground/50" + (hasSession || hasGhost) && !isPast && "cursor-pointer hover:border-muted-foreground/50" )} > ); })} + {dayGhosts.map((cell, idx) => { + const activity = getActivity(cell.activityId); + const isOverflow = cell.kind === "overflow"; + + return ( + + {activity?.emoji || "πŸ“‹"} + + ); + })}
); @@ -205,6 +250,66 @@ export const CalendarGrid = ({ + {/* Ghost (suggested session) explanation */} + + {selectedGhostDay && + (() => { + const ghosts = getGhostsForDay(selectedGhostDay); + 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 (
= ({ }; 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/contexts/messages/types.ts b/apps/frontend-vite/src/contexts/messages/types.ts index 3c8f78df..baa1590d 100644 --- a/apps/frontend-vite/src/contexts/messages/types.ts +++ b/apps/frontend-vite/src/contexts/messages/types.ts @@ -65,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/routes/message-ai.tsx b/apps/frontend-vite/src/routes/message-ai.tsx index 7e993aeb..4de0ab98 100644 --- a/apps/frontend-vite/src/routes/message-ai.tsx +++ b/apps/frontend-vite/src/routes/message-ai.tsx @@ -3,6 +3,13 @@ import { MessageBubble } from "@/components/MessageBubble"; import { MessageFeedback } from "@/components/MessageFeedback"; import { MetricSuggestion } from "@/components/MetricSuggestion"; import { PlanLink } from "@/components/PlanLink"; +import { CalendarGrid } from "@/components/CalendarGrid"; +import { + computeGridCells, + isActiveVisiblePlan, + type GridData, +} from "@/utils/ghostGrid"; +import { AnimatePresence, motion } from "framer-motion"; import { ActivityLogProposalCard } from "@/components/ActivityLogProposalCard"; import { PlanCreationProposalCard } from "@/components/PlanCreationProposalCard"; import { PlanProposalCard } from "@/components/PlanProposalCard"; @@ -91,15 +98,6 @@ function sanitizePlanDisplayText(text: string, emoji?: string | null): string { return cleaned || text.trim(); } -function isActiveVisiblePlan(plan: any): boolean { - return Boolean( - !plan.deletedAt && - !plan.archivedAt && - !plan.isPaused && - (!plan.finishingDate || new Date(plan.finishingDate) > new Date()) - ); -} - type CitationSource = { citationLabel: string; displayCitationLabel: string; @@ -173,20 +171,14 @@ type VisibleActivity = { datetime: Date; }; -type VisibleSession = { - id: string; - emoji: string; - title: string; - date: Date; -}; - function CoachContextIsland({ coachName, expanded, onToggle, recentActivities, activePlans, - upcomingSessions, + gridData, + isCompletedOnDay, }: { coachName: string; expanded: boolean; @@ -198,7 +190,8 @@ function CoachContextIsland({ emoji?: string | null; isCoached?: boolean; }>; - upcomingSessions: VisibleSession[]; + gridData: GridData; + isCompletedOnDay: (activityId: string, day: Date) => boolean; }) { const preview = recentActivities.length > 0 @@ -238,8 +231,17 @@ function CoachContextIsland({
- {expanded && ( -
+ + {expanded && ( + +
Recent logs @@ -268,70 +270,63 @@ function CoachContextIsland({ )}
-
-
-
- Active plans -
-
- {activePlans.length > 0 ? ( - activePlans.slice(0, 5).map((plan) => ( - - {plan.emoji || "πŸ“‹"} - {plan.goal} - {plan.isCoached ? ( - - - coached - - ) : null} - - )) - ) : ( - - No active plans. +
+
+ Active plans +
+
+ {activePlans.length > 0 ? ( + activePlans.slice(0, 5).map((plan) => ( + + {plan.emoji || "πŸ“‹"} + {plan.goal} + {plan.isCoached ? ( + + + coached + + ) : null} - )} -
+ )) + ) : ( + + No active plans. + + )}
+
-
-
- - Upcoming -
-
- {upcomingSessions.length > 0 ? ( - upcomingSessions.slice(0, 3).map((session) => ( -
- {session.emoji} - - {session.title} - - - {formatCoachVisibleDate(session.date)} - -
- )) - ) : ( -
- No scheduled sessions soon. -
- )} -
+
+
+ + Upcoming sessions
+ {gridData.scheduledSessions.length > 0 || + gridData.ghostCells.length > 0 ? ( + + ) : ( +
+ No upcoming sessions. +
+ )}
-
- )} +
+ + )} +
); @@ -605,29 +600,20 @@ function MessageAIPage() { [plans] ); - const upcomingSessions = useMemo(() => { - const now = new Date(); - const today = new Date(now); - today.setHours(0, 0, 0, 0); - const sevenDaysFromNow = new Date(now); - sevenDaysFromNow.setDate(now.getDate() + 7); - - return (plans || []) - .flatMap((plan: any) => - (plan.sessions || []).map((session: any) => { - const activity = plan.activities?.find((a: any) => a.id === session.activityId); - return { - id: session.id, - emoji: activity?.emoji || "πŸ“Œ", - title: activity?.title || plan.goal, - date: new Date(session.date), - }; - }) - ) - .filter((session) => session.date >= today && session.date <= sevenDaysFromNow) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .slice(0, 8); - }, [plans]); + const gridData = useMemo( + () => computeGridCells(plans, new Date()), + [plans] + ); + + 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(() => { @@ -1189,7 +1175,8 @@ function MessageAIPage() { onToggle={() => setShowCoachContext((value) => !value)} recentActivities={recentActivities} activePlans={activePlans} - upcomingSessions={upcomingSessions} + gridData={gridData} + isCompletedOnDay={isCompletedOnDay} /> {/* Messages */} @@ -1267,6 +1254,14 @@ function MessageAIPage() { }`} >
+ {isCoachMessage && + message.source === "autonomous_coach" && + !prevIsCoach && ( +
+ + Coach assessment +
+ )} 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 +): 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); + } + } + + 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; +} From d042f1e80c3d6414bb06b841d122b05d4b5b2e42 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 1 Jun 2026 12:58:42 +0200 Subject: [PATCH 18/23] Speed up profile loading --- apps/backend-node/src/routes/plans.ts | 3 ++- apps/backend-node/src/routes/users.ts | 6 +++-- .../backend-node/src/services/plansService.ts | 27 ++++++++++++++++--- .../src/hooks/useUnifiedProfileData.tsx | 14 +++++----- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/backend-node/src/routes/plans.ts b/apps/backend-node/src/routes/plans.ts index 4e270317..5e0877d3 100644 --- a/apps/backend-node/src/routes/plans.ts +++ b/apps/backend-node/src/routes/plans.ts @@ -175,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 diff --git a/apps/backend-node/src/routes/users.ts b/apps/backend-node/src/routes/users.ts index 544ffa48..3eda63a0 100644 --- a/apps/backend-node/src/routes/users.ts +++ b/apps/backend-node/src/routes/users.ts @@ -748,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 @@ -1170,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/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/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, From e30ef1d6550e2dd2ee576c7c12e594318bfa3ebf Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 1 Jun 2026 13:22:57 +0200 Subject: [PATCH 19/23] Distinguish today from selected day and show completed activities in coach calendar --- .../src/components/CalendarGrid.tsx | 43 ++++++++++++++----- apps/frontend-vite/src/routes/message-ai.tsx | 4 +- apps/frontend-vite/src/utils/ghostGrid.ts | 36 ++++++++++++++-- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/apps/frontend-vite/src/components/CalendarGrid.tsx b/apps/frontend-vite/src/components/CalendarGrid.tsx index c1ef1413..544e949f 100644 --- a/apps/frontend-vite/src/components/CalendarGrid.tsx +++ b/apps/frontend-vite/src/components/CalendarGrid.tsx @@ -134,22 +134,27 @@ export const CalendarGrid = ({ const isToday = isSameDay(day, today); const isPast = isBefore(startOfDay(day), startOfDay(today)); const hasSession = daySessions.length > 0; - const hasGhost = dayGhosts.length > 0; + const hasSuggestion = dayGhosts.some( + (c) => c.kind === "ghost" || c.kind === "overflow" + ); const isGhostSelected = !!selectedGhostDay && isSameDay(selectedGhostDay, day); return (
{ if (hasSession) handleDayClick(day); - else if (hasGhost) handleGhostDayClick(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), - isGhostSelected && !isToday && cn(variants.brightBorder, variants.veryFadedBg), - !isToday && !isGhostSelected && "border-border bg-card", - isPast && !isToday && "opacity-50", - (hasSession || hasGhost) && !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" )} > { const activity = getActivity(cell.activityId); - const isOverflow = cell.kind === "overflow"; + const emoji = activity?.emoji || "πŸ“‹"; + + if (cell.kind === "completed") { + return ( + + {emoji} + + + + + ); + } + const isOverflow = cell.kind === "overflow"; return ( - {activity?.emoji || "πŸ“‹"} + {emoji} ); })} @@ -254,7 +275,9 @@ export const CalendarGrid = ({ {selectedGhostDay && (() => { - const ghosts = getGhostsForDay(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))) diff --git a/apps/frontend-vite/src/routes/message-ai.tsx b/apps/frontend-vite/src/routes/message-ai.tsx index 4de0ab98..881b0cc1 100644 --- a/apps/frontend-vite/src/routes/message-ai.tsx +++ b/apps/frontend-vite/src/routes/message-ai.tsx @@ -601,8 +601,8 @@ function MessageAIPage() { ); const gridData = useMemo( - () => computeGridCells(plans, new Date()), - [plans] + () => computeGridCells(plans, new Date(), activityEntries || []), + [plans, activityEntries] ); const isCompletedOnDay = useCallback( diff --git a/apps/frontend-vite/src/utils/ghostGrid.ts b/apps/frontend-vite/src/utils/ghostGrid.ts index ec635898..bea487ba 100644 --- a/apps/frontend-vite/src/utils/ghostGrid.ts +++ b/apps/frontend-vite/src/utils/ghostGrid.ts @@ -1,9 +1,14 @@ -import { addDays, isBefore, startOfDay, startOfWeek } from "date-fns"; +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"; +export type GhostCellKind = "ghost" | "overflow" | "completed"; + +export interface CompletedEntry { + activityId: string | null; + datetime: Date | string; +} export interface GhostCell { date: Date; @@ -82,7 +87,8 @@ function placeGhosts( */ export function computeGridCells( plans: CompletePlan[] | undefined, - today: Date + today: Date, + completedEntries: CompletedEntry[] = [] ): GridData { const weekStart = startOfWeek(today, { weekStartsOn: 0 }); const windowEnd = addDays(weekStart, 14); // exclusive @@ -145,6 +151,30 @@ export function computeGridCells( } } + // 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, From c0979591a608993a041cb851f54833d7934b9c36 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 1 Jun 2026 13:25:45 +0200 Subject: [PATCH 20/23] docs: clarify local readiness checks --- README.md | 61 +++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 413a4b7d..a146c566 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 - ``` +Backend health should be available at `http://localhost:3000/health`. 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. From e56ccf5d5f1de5b0e65e2ec626673a4533be3d10 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 2 Jun 2026 15:08:37 +0300 Subject: [PATCH 21/23] docs: align backend health check port --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a146c566..ff90aec3 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ pnpm --filter backend-node dev pnpm --filter frontend-vite dev ``` -Backend health should be available at `http://localhost:3000/health`. Vite will print the frontend URL, usually `http://localhost:5173`. +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`. ### Readiness checks vs. app startup From 1be877986dd8026ee4fb1846eb50426ca2aaacd8 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 10 Jun 2026 14:28:31 +0100 Subject: [PATCH 22/23] fix: configure Vercel monorepo output directory --- vercel.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..6d35e25a --- /dev/null +++ b/vercel.json @@ -0,0 +1,17 @@ +{ + "outputDirectory": "apps/frontend-vite/dist", + "rewrites": [ + { + "source": "/relay-ph/static/(.*)", + "destination": "https://eu-assets.i.posthog.com/static/$1" + }, + { + "source": "/relay-ph/(.*)", + "destination": "https://eu.i.posthog.com/$1" + }, + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} From 097268e95a5bf15bd9034989faaf89b3924dcdfc Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 12 Jun 2026 14:39:38 +0100 Subject: [PATCH 23/23] fix: align Vercel output config with app root --- apps/frontend-vite/vercel.json | 1 + vercel.json | 17 ----------------- 2 files changed, 1 insertion(+), 17 deletions(-) delete mode 100644 vercel.json 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/vercel.json b/vercel.json deleted file mode 100644 index 6d35e25a..00000000 --- a/vercel.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "outputDirectory": "apps/frontend-vite/dist", - "rewrites": [ - { - "source": "/relay-ph/static/(.*)", - "destination": "https://eu-assets.i.posthog.com/static/$1" - }, - { - "source": "/relay-ph/(.*)", - "destination": "https://eu.i.posthog.com/$1" - }, - { - "source": "/(.*)", - "destination": "/index.html" - } - ] -}