From d0c310b0f81f53eb7f1ccde056be18d24f3af9d9 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Mon, 9 Mar 2026 19:26:07 -0500 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20goals=20PR2=20companion=20=E2=80=94?= =?UTF-8?q?=20relationship-scoped=20goals=20with=20per-session=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Goal type from session-scoped (coaching_session_id) to relationship-scoped (coaching_relationship_id, created_in_session_id nullable, target_date nullable). Use GET /coaching_sessions/{id}/goals join table endpoint for per-session goal display in dashboard cards and coaching session goal drawer. Send created_in_session_id on goal creation to trigger backend auto-link. --- .../ui/coaching-session-selector.test.tsx | 2 +- __tests__/lib/api/goals.test.ts | 226 ++++++++++++++++++ __tests__/test-utils.ts | 21 ++ __tests__/types/goal.test.ts | 199 +++++++++++++++ .../ui/coaching-session-selector.tsx | 4 +- src/components/ui/coaching-session.tsx | 18 +- .../ui/coaching-sessions/goal-container.tsx | 67 ++++-- src/lib/api/goals.ts | 75 +++++- src/types/goal.ts | 31 +-- 9 files changed, 580 insertions(+), 63 deletions(-) create mode 100644 __tests__/lib/api/goals.test.ts create mode 100644 __tests__/types/goal.test.ts diff --git a/__tests__/components/ui/coaching-session-selector.test.tsx b/__tests__/components/ui/coaching-session-selector.test.tsx index 5f3e2ae2..76c9bce5 100644 --- a/__tests__/components/ui/coaching-session-selector.test.tsx +++ b/__tests__/components/ui/coaching-session-selector.test.tsx @@ -28,7 +28,7 @@ vi.mock('@/lib/hooks/use-current-coaching-session', () => ({ })) vi.mock('@/lib/api/goals', () => ({ - useGoalBySession: vi.fn(() => ({ + useGoalByRelationship: vi.fn(() => ({ goal: { title: 'Test Goal' }, isLoading: false, isError: false, diff --git a/__tests__/lib/api/goals.test.ts b/__tests__/lib/api/goals.test.ts new file mode 100644 index 00000000..e9b47493 --- /dev/null +++ b/__tests__/lib/api/goals.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { GoalApi, useGoalList, useGoalsBySession } from '@/lib/api/goals' +import { EntityApi } from '@/lib/api/entity-api' +import { renderHook } from '@testing-library/react' +import { TestProviders } from '@/test-utils/providers' + +// Mock EntityApi +vi.mock('@/lib/api/entity-api', () => ({ + EntityApi: { + listFn: vi.fn(), + getFn: vi.fn(), + createFn: vi.fn(), + updateFn: vi.fn(), + deleteFn: vi.fn(), + useEntityList: vi.fn(), + useEntityMutation: vi.fn(), + }, +})) + +// Mock site config to provide a proper base URL +vi.mock('@/site.config', () => ({ + siteConfig: { + env: { + backendServiceURL: 'http://localhost:3000', + }, + }, +})) + +describe('GoalApi.list', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends coaching_relationship_id as query param', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123') + + expect(EntityApi.listFn).toHaveBeenCalledWith('http://localhost:3000/goals', { + params: { + coaching_relationship_id: 'rel-123', + }, + }) + }) + + it('does not send coaching_session_id', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123') + + const callArgs = vi.mocked(EntityApi.listFn).mock.calls[0] + expect(callArgs[1].params).not.toHaveProperty('coaching_session_id') + }) + + it('includes sort params when provided', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123', 'title' as any, 'asc' as any) + + expect(EntityApi.listFn).toHaveBeenCalledWith('http://localhost:3000/goals', { + params: { + coaching_relationship_id: 'rel-123', + sort_by: 'title', + sort_order: 'asc', + }, + }) + }) + + it('omits sort params when not provided', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.list('rel-123') + + const callArgs = vi.mocked(EntityApi.listFn).mock.calls[0] + expect(callArgs[1].params).not.toHaveProperty('sort_by') + expect(callArgs[1].params).not.toHaveProperty('sort_order') + }) +}) + +describe('useGoalList hook', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes coaching_relationship_id to EntityApi.useEntityList', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalList('rel-456'), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/goals', + expect.any(Function), + 'rel-456' + ) + }) + + it('passes null to EntityApi.useEntityList when relationship ID is null', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalList(null), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/goals', + expect.any(Function), + null + ) + }) +}) + +describe('GoalApi.listBySession', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetches goals from the coaching_sessions/:id/goals endpoint', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.listBySession('session-123') + + expect(EntityApi.listFn).toHaveBeenCalledWith( + 'http://localhost:3000/coaching_sessions/session-123/goals', + {} + ) + }) + + it('does not include coaching_relationship_id param', async () => { + vi.mocked(EntityApi.listFn).mockResolvedValue([]) + + await GoalApi.listBySession('session-123') + + const callArgs = vi.mocked(EntityApi.listFn).mock.calls[0] + expect(callArgs[1]).toEqual({}) + }) +}) + +describe('useGoalsBySession hook', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes session-scoped URL to EntityApi.useEntityList', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalsBySession('session-789'), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/coaching_sessions/session-789/goals', + expect.any(Function), + 'session-789' + ) + }) + + it('passes null to EntityApi.useEntityList when session ID is null', () => { + const mockReturn = { + entities: [], + isLoading: false, + isError: false, + refresh: vi.fn(), + } + + vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) + + renderHook( + () => useGoalsBySession(null), + { wrapper: TestProviders } + ) + + expect(EntityApi.useEntityList).toHaveBeenCalledWith( + 'http://localhost:3000/coaching_sessions/null/goals', + expect.any(Function), + null + ) + }) + + it('returns goals array from entities', () => { + const mockGoals = [ + { id: 'goal-1', title: 'Test Goal' }, + { id: 'goal-2', title: 'Another Goal' }, + ] + + vi.mocked(EntityApi.useEntityList).mockReturnValue({ + entities: mockGoals as any, + isLoading: false, + isError: false, + refresh: vi.fn(), + }) + + const { result } = renderHook( + () => useGoalsBySession('session-789'), + { wrapper: TestProviders } + ) + + expect(result.current.goals).toEqual(mockGoals) + expect(result.current.isLoading).toBe(false) + }) +}) diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts index 980e94d0..f432c7c7 100644 --- a/__tests__/test-utils.ts +++ b/__tests__/test-utils.ts @@ -1,6 +1,8 @@ import { DateTime } from "ts-luxon"; import { CoachingSession } from "@/types/coaching-session"; import { CoachingRelationshipWithUserNames } from "@/types/coaching_relationship"; +import { Goal } from "@/types/goal"; +import { ItemStatus } from "@/types/general"; import { Organization } from "@/types/organization"; import { User } from "@/types/user"; import { OAuthConnection } from "@/types/oauth-connection"; @@ -84,6 +86,25 @@ export function createSessionAt(minutesFromNow: number): CoachingSession { }); } +export function createMockGoal(overrides?: Partial): Goal { + const now = DateTime.now(); + return { + id: "goal-1", + coaching_relationship_id: "rel-1", + created_in_session_id: "session-1", + user_id: "user-1", + title: "Improve communication", + body: "Work on active listening", + status: ItemStatus.NotStarted, + status_changed_at: now, + completed_at: now, + target_date: null, + created_at: now, + updated_at: now, + ...overrides, + }; +} + export function createMockGoogleOAuthConnectionState( overrides?: Partial ): OAuthConnection { diff --git a/__tests__/types/goal.test.ts b/__tests__/types/goal.test.ts new file mode 100644 index 00000000..98720cbe --- /dev/null +++ b/__tests__/types/goal.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest' +import { DateTime } from 'ts-luxon' +import { ItemStatus } from '@/types/general' +import { + isGoal, + isGoalArray, + parseGoal, + defaultGoal, + defaultGoals, + getGoalById, + goalToString, + goalsToString, +} from '@/types/goal' +import type { Goal } from '@/types/goal' + +/** Factory for creating test Goal data matching the PR2 schema */ +function makeGoalData(overrides?: Partial>): Record { + return { + id: 'goal-1', + coaching_relationship_id: 'rel-1', + created_in_session_id: 'session-1', + user_id: 'user-1', + title: 'Improve communication', + body: 'Work on active listening', + status: 'NotStarted', + status_changed_at: '2026-03-01T00:00:00Z', + completed_at: '2026-03-10T00:00:00Z', + target_date: '2026-06-15', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', + ...overrides, + } +} + +describe('isGoal', () => { + it('returns true for a valid goal with all required fields', () => { + expect(isGoal(makeGoalData())).toBe(true) + }) + + it('returns true when created_in_session_id is null', () => { + expect(isGoal(makeGoalData({ created_in_session_id: null }))).toBe(true) + }) + + it('returns true when target_date is null', () => { + expect(isGoal(makeGoalData({ target_date: null }))).toBe(true) + }) + + it('returns false for null', () => { + expect(isGoal(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isGoal(undefined)).toBe(false) + }) + + it('returns false for a non-object', () => { + expect(isGoal('not a goal')).toBe(false) + }) + + it('returns false when id is missing', () => { + const { id: _, ...rest } = makeGoalData() + expect(isGoal(rest)).toBe(false) + }) + + it('returns false when coaching_relationship_id is missing', () => { + const { coaching_relationship_id: _, ...rest } = makeGoalData() + expect(isGoal(rest)).toBe(false) + }) + + it('returns false when user_id is missing', () => { + const { user_id: _, ...rest } = makeGoalData() + expect(isGoal(rest)).toBe(false) + }) + + it('does not require the old coaching_session_id field', () => { + // PR2 removed coaching_session_id — goals should validate without it + const data = makeGoalData() + expect(data).not.toHaveProperty('coaching_session_id') + expect(isGoal(data)).toBe(true) + }) +}) + +describe('isGoalArray', () => { + it('returns true for an array of valid goals', () => { + expect(isGoalArray([makeGoalData(), makeGoalData({ id: 'goal-2' })])).toBe(true) + }) + + it('returns true for an empty array', () => { + expect(isGoalArray([])).toBe(true) + }) + + it('returns false for a non-array', () => { + expect(isGoalArray(makeGoalData())).toBe(false) + }) +}) + +describe('parseGoal', () => { + it('parses valid goal data into a Goal object', () => { + const data = makeGoalData() + const goal = parseGoal(data) + + expect(goal.id).toBe('goal-1') + expect(goal.coaching_relationship_id).toBe('rel-1') + expect(goal.created_in_session_id).toBe('session-1') + expect(goal.user_id).toBe('user-1') + expect(goal.title).toBe('Improve communication') + expect(goal.body).toBe('Work on active listening') + expect(goal.status).toBe('NotStarted') + expect(goal.target_date).toBe('2026-06-15') + }) + + it('parses goal with null created_in_session_id', () => { + const goal = parseGoal(makeGoalData({ created_in_session_id: null })) + expect(goal.created_in_session_id).toBeNull() + }) + + it('parses goal with null target_date', () => { + const goal = parseGoal(makeGoalData({ target_date: null })) + expect(goal.target_date).toBeNull() + }) + + it('throws on invalid data', () => { + expect(() => parseGoal({ id: 123 })).toThrow() + }) +}) + +describe('defaultGoal', () => { + it('returns a goal with the PR2 field shape', () => { + const goal = defaultGoal() + + expect(goal).toHaveProperty('id') + expect(goal).toHaveProperty('coaching_relationship_id') + expect(goal).toHaveProperty('created_in_session_id') + expect(goal).toHaveProperty('target_date') + expect(goal).toHaveProperty('user_id') + expect(goal).toHaveProperty('title') + expect(goal).toHaveProperty('body') + expect(goal).toHaveProperty('status') + }) + + it('does not have the old coaching_session_id field', () => { + const goal = defaultGoal() + expect(goal).not.toHaveProperty('coaching_session_id') + }) + + it('has null for nullable fields', () => { + const goal = defaultGoal() + expect(goal.created_in_session_id).toBeNull() + expect(goal.target_date).toBeNull() + }) + + it('has NotStarted status', () => { + const goal = defaultGoal() + expect(goal.status).toBe(ItemStatus.NotStarted) + }) +}) + +describe('defaultGoals', () => { + it('returns an array with one default goal', () => { + const goals = defaultGoals() + expect(goals).toHaveLength(1) + expect(goals[0]).toHaveProperty('coaching_relationship_id') + }) +}) + +describe('getGoalById', () => { + it('returns the matching goal', () => { + const now = DateTime.now() + const goals: Goal[] = [ + { ...defaultGoal(), id: 'a', coaching_relationship_id: 'rel-1', created_at: now, updated_at: now }, + { ...defaultGoal(), id: 'b', coaching_relationship_id: 'rel-1', created_at: now, updated_at: now }, + ] + expect(getGoalById('b', goals).id).toBe('b') + }) + + it('returns a default goal when not found', () => { + const goal = getGoalById('nonexistent', []) + expect(goal.id).toBe('') + expect(goal).toHaveProperty('coaching_relationship_id') + }) +}) + +describe('goalToString / goalsToString', () => { + it('serializes a goal to JSON', () => { + const goal = defaultGoal() + const json = goalToString(goal) + expect(json).toContain('coaching_relationship_id') + expect(json).not.toContain('coaching_session_id') + }) + + it('serializes undefined to "undefined"', () => { + expect(goalToString(undefined)).toBe(undefined) + }) + + it('serializes a goal array to JSON', () => { + const json = goalsToString([defaultGoal()]) + expect(json).toContain('coaching_relationship_id') + }) +}) diff --git a/src/components/ui/coaching-session-selector.tsx b/src/components/ui/coaching-session-selector.tsx index 12fb8170..5398f20a 100644 --- a/src/components/ui/coaching-session-selector.tsx +++ b/src/components/ui/coaching-session-selector.tsx @@ -17,7 +17,7 @@ import { useEnrichedCoachingSessionsForUser, CoachingSessionInclude, } from "@/lib/api/coaching-sessions"; -import { useGoalBySession } from "@/lib/api/goals"; +import { useGoalByRelationship } from "@/lib/api/goals"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; import { DateTime } from "ts-luxon"; import type { EnrichedCoachingSession } from "@/types/coaching-session"; @@ -172,7 +172,7 @@ export default function CoachingSessionSelector({ const { userSession } = useAuthStore((state) => state); const { goal, isLoading: isLoadingGoal } = - useGoalBySession(currentCoachingSessionId || ""); + useGoalByRelationship(relationshipId); const handleSetCoachingSession = (coachingSessionId: Id) => { // Navigate to the coaching session page diff --git a/src/components/ui/coaching-session.tsx b/src/components/ui/coaching-session.tsx index 0a1dc8f2..643f44c6 100644 --- a/src/components/ui/coaching-session.tsx +++ b/src/components/ui/coaching-session.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Card, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { useGoalBySession } from "@/lib/api/goals"; +import { useGoalsBySession } from "@/lib/api/goals"; import { Id } from "@/types/general"; import { DropdownMenu, @@ -101,19 +101,21 @@ const SessionGoal: React.FC = ({ coachingSessionId, }) => { const { - goal, - isLoading: isLoadingGoal, - isError: isErrorGoal, - } = useGoalBySession(coachingSessionId); + goals, + isLoading: isLoadingGoals, + isError: isErrorGoals, + } = useGoalsBySession(coachingSessionId); let titleText: string; - if (isLoadingGoal) { + if (isLoadingGoals) { titleText = "Loading..."; - } else if (isErrorGoal) { + } else if (isErrorGoals) { titleText = "Error loading goal"; } else { - titleText = goal?.title || "No goal set"; + titleText = goals.length > 0 + ? goals[0].title + : "No goal set"; } return
{titleText}
; diff --git a/src/components/ui/coaching-sessions/goal-container.tsx b/src/components/ui/coaching-sessions/goal-container.tsx index 51609d36..a988ad2a 100644 --- a/src/components/ui/coaching-sessions/goal-container.tsx +++ b/src/components/ui/coaching-sessions/goal-container.tsx @@ -6,43 +6,43 @@ import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { GoalComponent } from "./goal"; import { - useGoalBySession, + useGoalsBySession, useGoalMutation, } from "@/lib/api/goals"; -import { Goal } from "@/types/goal"; +import { defaultGoal, Goal } from "@/types/goal"; +import { Id } from "@/types/general"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; +import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship"; -const GoalContainer: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); +interface GoalContainerInnerProps { + coachingSessionId: Id; + coachingRelationshipId: Id; +} - // Get coaching session ID from URL - const { currentCoachingSessionId } = useCurrentCoachingSession(); +const GoalContainerInner: React.FC = ({ + coachingSessionId, + coachingRelationshipId, +}) => { + const [isOpen, setIsOpen] = useState(false); - const { goal, refresh } = - useGoalBySession(currentCoachingSessionId || ""); + const { goals, refresh } = + useGoalsBySession(coachingSessionId); + const goal = goals.length > 0 ? goals[0] : defaultGoal(); const { create: createGoal, update: updateGoal } = useGoalMutation(); const handleGoalChange = async (newGoal: Goal) => { try { - if (currentCoachingSessionId) { - if (goal.id) { - await updateGoal( - goal.id, - newGoal - ); - } else if (!goal.id) { - newGoal.coaching_session_id = currentCoachingSessionId; - await createGoal(newGoal); - - // Manually trigger a local refresh of the cached Goal data such that - // any other local code using the KeyedMutator will also update with this new data. - refresh(); - } + if (goal.id) { + await updateGoal(goal.id, newGoal); } else { - console.error( - "Could not update or create a Goal since coachingSessionId or userId are not set." - ); + newGoal.coaching_relationship_id = coachingRelationshipId; + newGoal.created_in_session_id = coachingSessionId; + await createGoal(newGoal); + + // Manually trigger a local refresh of the cached Goal data such that + // any other local code using the KeyedMutator will also update with this new data. + refresh(); } } catch (err) { console.error("Failed to update or create Goal: " + err); @@ -89,4 +89,21 @@ const GoalContainer: React.FC = () => { ); }; +const GoalContainer: React.FC = () => { + const { currentCoachingSessionId } = useCurrentCoachingSession(); + const { currentCoachingRelationshipId } = useCurrentCoachingRelationship(); + + // Guard: only render when both session and relationship IDs are available + if (!currentCoachingSessionId || !currentCoachingRelationshipId) { + return null; + } + + return ( + + ); +}; + export { GoalContainer }; diff --git a/src/lib/api/goals.ts b/src/lib/api/goals.ts index a9367453..c08df2a3 100644 --- a/src/lib/api/goals.ts +++ b/src/lib/api/goals.ts @@ -10,6 +10,7 @@ import { ApiSortOrder, GoalSortField } from "@/types/sorting"; import { EntityApi } from "./entity-api"; const GOALS_BASEURL: string = `${siteConfig.env.backendServiceURL}/goals`; +const COACHING_SESSIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/coaching_sessions`; /** * API client for goal-related operations. @@ -19,20 +20,20 @@ const GOALS_BASEURL: string = `${siteConfig.env.backendServiceURL}/goals`; */ export const GoalApi = { /* - * Fetches a list of goals associated with a specific coaching session. + * Fetches a list of goals associated with a specific coaching relationship. * - * @param coachingSessionId The ID of the coaching session whose goals should be retrieved + * @param coachingRelationshipId The ID of the coaching relationship whose goals should be retrieved * @param sortBy Optional field to sort by. * @param sortOrder Optional sort order. * @returns Promise resolving to an array of Goal objects */ list: async ( - coachingSessionId: Id, + coachingRelationshipId: Id, sortBy?: GoalSortField, sortOrder?: ApiSortOrder ): Promise => { const params: Record = { - coaching_session_id: coachingSessionId, + coaching_relationship_id: coachingRelationshipId, }; if (sortBy) { @@ -45,6 +46,20 @@ export const GoalApi = { return EntityApi.listFn(GOALS_BASEURL, { params }); }, + /** + * Fetches goals linked to a specific coaching session via the join table. + * Uses GET /coaching_sessions/{session_id}/goals which returns full Goal models. + * + * @param coachingSessionId The ID of the coaching session + * @returns Promise resolving to an array of Goal objects linked to the session + */ + listBySession: async (coachingSessionId: Id): Promise => { + return EntityApi.listFn( + `${COACHING_SESSIONS_BASEURL}/${coachingSessionId}/goals`, + {} + ); + }, + /** * Fetches a single goal by its ID. * @@ -116,12 +131,12 @@ export const GoalApi = { }; /** - * A custom React hook that fetches a list of goals for a specific coaching session. + * A custom React hook that fetches a list of goals for a specific coaching relationship. * * This hook uses SWR to efficiently fetch, cache, and revalidate goal data. * It automatically refreshes data when the component mounts. * - * @param coachingSessionId The ID of the coaching session whose goals should be fetched + * @param coachingRelationshipId The ID of the coaching relationship whose goals should be fetched * @returns An object containing: * * * goals: Array of Goal objects (empty array if data is not yet loaded) @@ -129,12 +144,13 @@ export const GoalApi = { * * isError: Error object if the fetch operation failed, undefined otherwise * * refresh: Function to manually trigger a refresh of the data */ -export const useGoalList = (coachingSessionId: Id) => { +export const useGoalList = (coachingRelationshipId: Id | null) => { const { entities, isLoading, isError, refresh } = EntityApi.useEntityList( GOALS_BASEURL, - () => GoalApi.list(coachingSessionId), - coachingSessionId + // SWR skips this fetcher when params are falsy (null key = no fetch) + () => GoalApi.list(coachingRelationshipId!), + coachingRelationshipId ); return { @@ -177,10 +193,10 @@ export const useGoal = (id: Id) => { }; /** - * A custom React hook that fetches a single goal by coaching session ID. + * A custom React hook that fetches the first goal for a coaching relationship. * This hook uses SWR to efficiently fetch and cache goal data. * - * @param coachingSessionId The coaching session ID of the goal to fetch. + * @param coachingRelationshipId The coaching relationship ID whose first goal should be fetched. * @returns An object containing: * * * goal: The fetched Goal object, or a default goal if not yet loaded @@ -188,9 +204,9 @@ export const useGoal = (id: Id) => { * * isError: Error object if the fetch operation failed, undefined otherwise * * refresh: Function to manually trigger a refresh of the data */ -export const useGoalBySession = (coachingSessionId: Id) => { +export const useGoalByRelationship = (coachingRelationshipId: Id | null) => { const { goals, isLoading, isError, refresh } = - useGoalList(coachingSessionId); + useGoalList(coachingRelationshipId); return { goal: goals.length @@ -202,6 +218,39 @@ export const useGoalBySession = (coachingSessionId: Id) => { }; }; +/** + * A custom React hook that fetches goals linked to a specific coaching session + * via the coaching_sessions_goals join table. + * + * Uses GET /coaching_sessions/{session_id}/goals which returns full Goal models. + * + * @param coachingSessionId The coaching session ID whose linked goals should be fetched. + * @returns An object containing: + * + * * goals: Array of Goal objects linked to the session (empty array if not loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useGoalsBySession = (coachingSessionId: Id | null) => { + const url = `${COACHING_SESSIONS_BASEURL}/${coachingSessionId}/goals`; + + const { entities, isLoading, isError, refresh } = + EntityApi.useEntityList( + url, + // SWR skips this fetcher when params are falsy (null key = no fetch) + () => GoalApi.listBySession(coachingSessionId!), + coachingSessionId + ); + + return { + goals: entities, + isLoading, + isError, + refresh, + }; +}; + /** * Hook for goal mutations. * Provides methods to create, update, and delete goals. diff --git a/src/types/goal.ts b/src/types/goal.ts index b6de1cc0..e97343a0 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -5,30 +5,34 @@ import { Id, ItemStatus } from "@/types/general"; // entity::goals::Model export interface Goal { id: Id; - coaching_session_id: Id; + coaching_relationship_id: Id; + created_in_session_id: Id | null; user_id: Id; title: string; body: string; status: ItemStatus; status_changed_at: DateTime; completed_at: DateTime; + target_date: string | null; created_at: DateTime; updated_at: DateTime; } export function parseGoal(data: any): Goal { if (!isGoal(data)) { - throw new Error("Invalid CoachingSession data"); + throw new Error("Invalid Goal data"); } return { id: data.id, - coaching_session_id: data.coaching_session_id, + coaching_relationship_id: data.coaching_relationship_id, + created_in_session_id: data.created_in_session_id ?? null, user_id: data.user_id, title: data.title, body: data.body, status: data.status, status_changed_at: data.status_changed_at, completed_at: data.completed_at, + target_date: data.target_date ?? null, created_at: data.created_at, updated_at: data.updated_at, }; @@ -41,16 +45,13 @@ export function isGoal(value: unknown): value is Goal { const object = value as Record; return ( - (typeof object.id === "string" && - typeof object.coaching_session_id === "string" && - typeof object.user_id === "string" && - typeof object.status === "string" && - typeof object.created_at === "string" && - typeof object.updated_at === "string") || - typeof object.title === "string" || - typeof object.body === "string" || - typeof object.status_changed_at === "string" || - typeof object.completed_at === "string" + typeof object.id === "string" && + typeof object.coaching_relationship_id === "string" && + (object.created_in_session_id === null || typeof object.created_in_session_id === "string") && + typeof object.user_id === "string" && + typeof object.status === "string" && + typeof object.created_at === "string" && + typeof object.updated_at === "string" ); } @@ -72,13 +73,15 @@ export function defaultGoal(): Goal { const now = DateTime.now(); return { id: "", - coaching_session_id: "", + coaching_relationship_id: "", + created_in_session_id: null, user_id: "", title: "", body: "", status: ItemStatus.NotStarted, status_changed_at: now, completed_at: now, + target_date: null, created_at: now, updated_at: now, }; From b74b2d0094ee62e10eae5328c6b39c2ed30ca6c3 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Mon, 9 Mar 2026 19:38:30 -0500 Subject: [PATCH 2/9] fix: invalidate session-scoped goal caches on goal SSE events Goal SSE events (created/updated/deleted) now also invalidate /coaching_sessions SWR keys, covering the GET /coaching_sessions/{id}/goals join table endpoint used for per-session goal display. --- src/lib/hooks/use-sse-cache-invalidation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/hooks/use-sse-cache-invalidation.ts b/src/lib/hooks/use-sse-cache-invalidation.ts index 466ae42d..1a7ed0f3 100644 --- a/src/lib/hooks/use-sse-cache-invalidation.ts +++ b/src/lib/hooks/use-sse-cache-invalidation.ts @@ -62,16 +62,19 @@ export function useSSECacheInvalidation(eventSource: EventSource | null) { invalidateEndpoint('/agreements', 'agreement_deleted'); }); - // GOAL EVENTS - Invalidate only /goals endpoint + // GOAL EVENTS - Invalidate /goals and /coaching_sessions (session-scoped goals via join table) useSSEEventHandler(eventSource, 'goal_created', () => { invalidateEndpoint('/goals', 'goal_created'); + invalidateEndpoint('/coaching_sessions', 'goal_created'); }); useSSEEventHandler(eventSource, 'goal_updated', () => { invalidateEndpoint('/goals', 'goal_updated'); + invalidateEndpoint('/coaching_sessions', 'goal_updated'); }); useSSEEventHandler(eventSource, 'goal_deleted', () => { invalidateEndpoint('/goals', 'goal_deleted'); + invalidateEndpoint('/coaching_sessions', 'goal_deleted'); }); } From 092e0d0fca26bb3ccd3cbffffaa7c69c3c90d3b7 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Mon, 9 Mar 2026 19:57:23 -0500 Subject: [PATCH 3/9] refactor: standardize goal title display with goalTitle helper Extract DEFAULT_GOAL_TITLE constant and goalTitle() helper in goal.ts to consistently handle empty/null goal titles across 6 UI call sites. --- src/components/ui/coaching-session-selector.tsx | 5 +++-- src/components/ui/coaching-session.tsx | 5 +++-- src/components/ui/coaching-sessions/goal.tsx | 3 ++- src/components/ui/dashboard/today-session-card.tsx | 3 ++- src/components/ui/join-session-popover.tsx | 3 ++- src/lib/utils/session.ts | 3 ++- src/types/goal.ts | 13 +++++++++++++ 7 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/components/ui/coaching-session-selector.tsx b/src/components/ui/coaching-session-selector.tsx index 5398f20a..ed7d644d 100644 --- a/src/components/ui/coaching-session-selector.tsx +++ b/src/components/ui/coaching-session-selector.tsx @@ -18,6 +18,7 @@ import { CoachingSessionInclude, } from "@/lib/api/coaching-sessions"; import { useGoalByRelationship } from "@/lib/api/goals"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; import { DateTime } from "ts-luxon"; import type { EnrichedCoachingSession } from "@/types/coaching-session"; @@ -143,7 +144,7 @@ function SessionItem({

- {session.goal?.title || "No goal set"} + {session.goal ? goalTitle(session.goal) : DEFAULT_GOAL_TITLE}

{formatDateInUserTimezone( @@ -199,7 +200,7 @@ export default function CoachingSessionSelector({ Loading goal... ) : ( - goal?.title || "No goal set" + goalTitle(goal) )} diff --git a/src/components/ui/coaching-session.tsx b/src/components/ui/coaching-session.tsx index 643f44c6..05e8fcc5 100644 --- a/src/components/ui/coaching-session.tsx +++ b/src/components/ui/coaching-session.tsx @@ -5,6 +5,7 @@ import { Card, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { useGoalsBySession } from "@/lib/api/goals"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { Id } from "@/types/general"; import { DropdownMenu, @@ -114,8 +115,8 @@ const SessionGoal: React.FC = ({ titleText = "Error loading goal"; } else { titleText = goals.length > 0 - ? goals[0].title - : "No goal set"; + ? goalTitle(goals[0]) + : DEFAULT_GOAL_TITLE; } return

{titleText}
; diff --git a/src/components/ui/coaching-sessions/goal.tsx b/src/components/ui/coaching-sessions/goal.tsx index 53e64374..113d93f6 100644 --- a/src/components/ui/coaching-sessions/goal.tsx +++ b/src/components/ui/coaching-sessions/goal.tsx @@ -14,6 +14,7 @@ import { cn } from "@/components/lib/utils"; import { defaultGoal, Goal, + goalTitle, } from "@/types/goal"; const GoalComponent: React.FC<{ @@ -77,7 +78,7 @@ const GoalComponent: React.FC<{ onClick={(e) => e.stopPropagation()} > Goal: - {goal.title} + {goalTitle(goal)}
)} diff --git a/src/components/ui/dashboard/today-session-card.tsx b/src/components/ui/dashboard/today-session-card.tsx index df924853..def4d4bb 100644 --- a/src/components/ui/dashboard/today-session-card.tsx +++ b/src/components/ui/dashboard/today-session-card.tsx @@ -13,6 +13,7 @@ import { copyCoachingSessionLinkWithToast } from "@/components/ui/share-session- import { cn } from "@/components/lib/utils"; import { PulsingDot } from "@/components/ui/pulsing-dot"; import { SessionUrgency } from "@/types/session-display"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { RelationshipRole } from "@/types/relationship-role"; import type { AssignedActionWithContext } from "@/types/assigned-actions"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; @@ -209,7 +210,7 @@ export function TodaySessionCard({ timezone ); - const goalText = session.goal?.title || "No goal set"; + const goalText = session.goal ? goalTitle(session.goal) : DEFAULT_GOAL_TITLE; const organizationName = session.organization?.name || "Unknown organization"; /** diff --git a/src/components/ui/join-session-popover.tsx b/src/components/ui/join-session-popover.tsx index 0d287f29..5b9854ee 100644 --- a/src/components/ui/join-session-popover.tsx +++ b/src/components/ui/join-session-popover.tsx @@ -17,6 +17,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { DEFAULT_GOAL_TITLE, goalTitle } from "@/types/goal"; import { Select, SelectContent, @@ -364,7 +365,7 @@ function SessionGroup({ className="flex flex-col items-start w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" > - {session.goal?.title || "No goal set"} + {session.goal ? goalTitle(session.goal) : DEFAULT_GOAL_TITLE} {formatDateInUserTimezone(session.date, timezone)} diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index 1fe5567c..6f5534fe 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -12,6 +12,7 @@ import { getUserRoleInRelationship, } from "@/lib/utils/relationship"; import { getBrowserTimezone } from "@/lib/timezone-utils"; +import { goalTitle } from "@/types/goal"; /** * Session Utility Functions @@ -224,7 +225,7 @@ export function enrichSessionForDisplay( return { id: session.id, - goalTitle: goal?.title || "Coaching Session", + goalTitle: goal ? goalTitle(goal, "Coaching Session") : "Coaching Session", participantName: getOtherParticipantName(relationship, user), userRole: getUserRoleInRelationship(relationship, user), dateTime: formatSessionDateTime(session.date, timezone), diff --git a/src/types/goal.ts b/src/types/goal.ts index e97343a0..8e5d969e 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -91,6 +91,19 @@ export function defaultGoals(): Goal[] { return [defaultGoal()]; } +export const DEFAULT_GOAL_TITLE = "No goal set"; + +/** + * Returns the goal's title if non-empty, otherwise a default fallback. + * Handles empty string titles consistently across the UI. + */ +export function goalTitle( + goal: Goal, + fallback: string = DEFAULT_GOAL_TITLE +): string { + return goal.title || fallback; +} + export function goalToString( goal: Goal | undefined ): string { From f30ef885a7bb55d63b165c63e9fa296d24ca92b2 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 10 Mar 2026 10:42:50 -0500 Subject: [PATCH 4/9] fix: harden isGoal guard, clean up null URL handling in useGoalsBySession - Add missing field checks (title, body, target_date, status_changed_at, completed_at) to isGoal type guard to prevent malformed data at boundary - Conditionally construct URL in useGoalsBySession to avoid embedding "null" string when session ID is absent - Remove as-any casts in goal API tests, remove test that asserted null-in-URL implementation detail --- __tests__/lib/api/goals.test.ts | 24 +----------------------- src/lib/api/goals.ts | 4 +++- src/types/goal.ts | 5 +++++ 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/__tests__/lib/api/goals.test.ts b/__tests__/lib/api/goals.test.ts index e9b47493..4d7ac888 100644 --- a/__tests__/lib/api/goals.test.ts +++ b/__tests__/lib/api/goals.test.ts @@ -55,7 +55,7 @@ describe('GoalApi.list', () => { it('includes sort params when provided', async () => { vi.mocked(EntityApi.listFn).mockResolvedValue([]) - await GoalApi.list('rel-123', 'title' as any, 'asc' as any) + await GoalApi.list('rel-123', 'title', 'asc') expect(EntityApi.listFn).toHaveBeenCalledWith('http://localhost:3000/goals', { params: { @@ -180,28 +180,6 @@ describe('useGoalsBySession hook', () => { ) }) - it('passes null to EntityApi.useEntityList when session ID is null', () => { - const mockReturn = { - entities: [], - isLoading: false, - isError: false, - refresh: vi.fn(), - } - - vi.mocked(EntityApi.useEntityList).mockReturnValue(mockReturn) - - renderHook( - () => useGoalsBySession(null), - { wrapper: TestProviders } - ) - - expect(EntityApi.useEntityList).toHaveBeenCalledWith( - 'http://localhost:3000/coaching_sessions/null/goals', - expect.any(Function), - null - ) - }) - it('returns goals array from entities', () => { const mockGoals = [ { id: 'goal-1', title: 'Test Goal' }, diff --git a/src/lib/api/goals.ts b/src/lib/api/goals.ts index c08df2a3..5f12a319 100644 --- a/src/lib/api/goals.ts +++ b/src/lib/api/goals.ts @@ -233,7 +233,9 @@ export const useGoalByRelationship = (coachingRelationshipId: Id | null) => { * * refresh: Function to manually trigger a refresh of the data */ export const useGoalsBySession = (coachingSessionId: Id | null) => { - const url = `${COACHING_SESSIONS_BASEURL}/${coachingSessionId}/goals`; + const url = coachingSessionId + ? `${COACHING_SESSIONS_BASEURL}/${coachingSessionId}/goals` + : COACHING_SESSIONS_BASEURL; const { entities, isLoading, isError, refresh } = EntityApi.useEntityList( diff --git a/src/types/goal.ts b/src/types/goal.ts index 8e5d969e..5f110056 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -49,7 +49,12 @@ export function isGoal(value: unknown): value is Goal { typeof object.coaching_relationship_id === "string" && (object.created_in_session_id === null || typeof object.created_in_session_id === "string") && typeof object.user_id === "string" && + typeof object.title === "string" && + typeof object.body === "string" && typeof object.status === "string" && + (object.target_date === null || typeof object.target_date === "string") && + typeof object.status_changed_at === "string" && + typeof object.completed_at === "string" && typeof object.created_at === "string" && typeof object.updated_at === "string" ); From 2f097bcf49a46e1623688fcbb0bcb38379851d2c Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 10 Mar 2026 11:04:01 -0500 Subject: [PATCH 5/9] fix: narrow goal SSE cache invalidation to session-scoped goal keys only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invalidateEndpoint('/coaching_sessions') was too broad — it revalidated every SWR key containing that path, causing the coaching session title to flash on goal updates. Replace with a targeted invalidator that only matches /coaching_sessions/{id}/goals keys. --- src/lib/hooks/use-sse-cache-invalidation.ts | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/lib/hooks/use-sse-cache-invalidation.ts b/src/lib/hooks/use-sse-cache-invalidation.ts index 1a7ed0f3..c10a4e8c 100644 --- a/src/lib/hooks/use-sse-cache-invalidation.ts +++ b/src/lib/hooks/use-sse-cache-invalidation.ts @@ -36,6 +36,23 @@ export function useSSECacheInvalidation(eventSource: EventSource | null) { console.log(`[SSE] Revalidated ${endpointPath} cache after ${eventName}`); }, [mutate, baseUrl]); + /** + * Invalidates only the session-scoped goal caches (e.g. /coaching_sessions/{id}/goals) + * without touching other coaching_sessions caches (session list, enriched sessions, etc.). + */ + const invalidateSessionGoals = useCallback((eventName: string) => { + const sessionGoalsPattern = `${baseUrl}/coaching_sessions/`; + mutate( + (key) => { + const url = typeof key === 'string' ? key : Array.isArray(key) ? key[0] : null; + return typeof url === 'string' && url.startsWith(sessionGoalsPattern) && url.endsWith('/goals'); + }, + undefined, + { revalidate: true } + ); + console.log(`[SSE] Revalidated session-scoped goal caches after ${eventName}`); + }, [mutate, baseUrl]); + // ACTION EVENTS - Invalidate only /actions endpoint useSSEEventHandler(eventSource, 'action_created', () => { invalidateEndpoint('/actions', 'action_created'); @@ -62,19 +79,19 @@ export function useSSECacheInvalidation(eventSource: EventSource | null) { invalidateEndpoint('/agreements', 'agreement_deleted'); }); - // GOAL EVENTS - Invalidate /goals and /coaching_sessions (session-scoped goals via join table) + // GOAL EVENTS - Invalidate /goals and session-scoped goal caches (join table) useSSEEventHandler(eventSource, 'goal_created', () => { invalidateEndpoint('/goals', 'goal_created'); - invalidateEndpoint('/coaching_sessions', 'goal_created'); + invalidateSessionGoals('goal_created'); }); useSSEEventHandler(eventSource, 'goal_updated', () => { invalidateEndpoint('/goals', 'goal_updated'); - invalidateEndpoint('/coaching_sessions', 'goal_updated'); + invalidateSessionGoals('goal_updated'); }); useSSEEventHandler(eventSource, 'goal_deleted', () => { invalidateEndpoint('/goals', 'goal_deleted'); - invalidateEndpoint('/coaching_sessions', 'goal_deleted'); + invalidateSessionGoals('goal_deleted'); }); } From 600f309cd702a5b19853cbf3f03ed13ed641311f Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 10 Mar 2026 11:15:33 -0500 Subject: [PATCH 6/9] fix: widen goalTitle param to Pick for partial goal shapes --- src/types/goal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/goal.ts b/src/types/goal.ts index 5f110056..1b79be0d 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -103,7 +103,7 @@ export const DEFAULT_GOAL_TITLE = "No goal set"; * Handles empty string titles consistently across the UI. */ export function goalTitle( - goal: Goal, + goal: Pick, fallback: string = DEFAULT_GOAL_TITLE ): string { return goal.title || fallback; From 49e71a98d555adcaaa9641020cf5aaae09124d19 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 10 Mar 2026 17:55:17 -0500 Subject: [PATCH 7/9] refactor: rename listBySession to listNested, pass goal as parameter Address PR review feedback: use listNested convention with EntityApi.listNestedFn and make handleGoalChange accept the current goal explicitly instead of closing over component scope. --- __tests__/lib/api/goals.test.ts | 24 +++++++------------ .../ui/coaching-sessions/goal-container.tsx | 8 +++---- src/lib/api/goals.ts | 14 ++++++----- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/__tests__/lib/api/goals.test.ts b/__tests__/lib/api/goals.test.ts index 4d7ac888..8753e56d 100644 --- a/__tests__/lib/api/goals.test.ts +++ b/__tests__/lib/api/goals.test.ts @@ -8,6 +8,7 @@ import { TestProviders } from '@/test-utils/providers' vi.mock('@/lib/api/entity-api', () => ({ EntityApi: { listFn: vi.fn(), + listNestedFn: vi.fn(), getFn: vi.fn(), createFn: vi.fn(), updateFn: vi.fn(), @@ -127,30 +128,23 @@ describe('useGoalList hook', () => { }) }) -describe('GoalApi.listBySession', () => { +describe('GoalApi.listNested', () => { beforeEach(() => { vi.clearAllMocks() }) - it('fetches goals from the coaching_sessions/:id/goals endpoint', async () => { - vi.mocked(EntityApi.listFn).mockResolvedValue([]) + it('fetches goals nested under the coaching session endpoint', async () => { + vi.mocked(EntityApi.listNestedFn).mockResolvedValue([]) - await GoalApi.listBySession('session-123') + await GoalApi.listNested('session-123') - expect(EntityApi.listFn).toHaveBeenCalledWith( - 'http://localhost:3000/coaching_sessions/session-123/goals', + expect(EntityApi.listNestedFn).toHaveBeenCalledWith( + 'http://localhost:3000/coaching_sessions', + 'session-123', + 'goals', {} ) }) - - it('does not include coaching_relationship_id param', async () => { - vi.mocked(EntityApi.listFn).mockResolvedValue([]) - - await GoalApi.listBySession('session-123') - - const callArgs = vi.mocked(EntityApi.listFn).mock.calls[0] - expect(callArgs[1]).toEqual({}) - }) }) describe('useGoalsBySession hook', () => { diff --git a/src/components/ui/coaching-sessions/goal-container.tsx b/src/components/ui/coaching-sessions/goal-container.tsx index a988ad2a..e190b06e 100644 --- a/src/components/ui/coaching-sessions/goal-container.tsx +++ b/src/components/ui/coaching-sessions/goal-container.tsx @@ -31,10 +31,10 @@ const GoalContainerInner: React.FC = ({ const { create: createGoal, update: updateGoal } = useGoalMutation(); - const handleGoalChange = async (newGoal: Goal) => { + const handleGoalChange = async (currentGoal: Goal, newGoal: Goal) => { try { - if (goal.id) { - await updateGoal(goal.id, newGoal); + if (currentGoal.id) { + await updateGoal(currentGoal.id, newGoal); } else { newGoal.coaching_relationship_id = coachingRelationshipId; newGoal.created_in_session_id = coachingSessionId; @@ -60,7 +60,7 @@ const GoalContainerInner: React.FC = ({ setIsOpen(open)} - onGoalChange={(g: Goal) => handleGoalChange(g)} + onGoalChange={(g: Goal) => handleGoalChange(goal, g)} >
diff --git a/src/lib/api/goals.ts b/src/lib/api/goals.ts index 5f12a319..2eda5bf0 100644 --- a/src/lib/api/goals.ts +++ b/src/lib/api/goals.ts @@ -47,15 +47,17 @@ export const GoalApi = { }, /** - * Fetches goals linked to a specific coaching session via the join table. + * Fetches goals nested under a coaching session via the join table. * Uses GET /coaching_sessions/{session_id}/goals which returns full Goal models. * - * @param coachingSessionId The ID of the coaching session + * @param coachingSessionId The ID of the parent coaching session * @returns Promise resolving to an array of Goal objects linked to the session */ - listBySession: async (coachingSessionId: Id): Promise => { - return EntityApi.listFn( - `${COACHING_SESSIONS_BASEURL}/${coachingSessionId}/goals`, + listNested: async (coachingSessionId: Id): Promise => { + return EntityApi.listNestedFn( + COACHING_SESSIONS_BASEURL, + coachingSessionId, + 'goals', {} ); }, @@ -241,7 +243,7 @@ export const useGoalsBySession = (coachingSessionId: Id | null) => { EntityApi.useEntityList( url, // SWR skips this fetcher when params are falsy (null key = no fetch) - () => GoalApi.listBySession(coachingSessionId!), + () => GoalApi.listNested(coachingSessionId!), coachingSessionId ); From 38bb41be05abe73d4f30988f2f3168bd60aa6d05 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Wed, 11 Mar 2026 15:47:55 -0500 Subject: [PATCH 8/9] feat: handle 409 active-goal-limit error on goal creation Surface a destructive toast when the backend returns HTTP 409 because the coaching relationship already has 3 active goals. Adds ActiveGoalLimitError types and extractActiveGoalLimitError helper for reuse in future swap dialog work. --- .../ui/coaching-sessions/goal-container.tsx | 19 ++++++- src/types/goal.ts | 50 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/components/ui/coaching-sessions/goal-container.tsx b/src/components/ui/coaching-sessions/goal-container.tsx index e190b06e..096b4bd4 100644 --- a/src/components/ui/coaching-sessions/goal-container.tsx +++ b/src/components/ui/coaching-sessions/goal-container.tsx @@ -9,10 +9,16 @@ import { useGoalsBySession, useGoalMutation, } from "@/lib/api/goals"; -import { defaultGoal, Goal } from "@/types/goal"; +import { + defaultGoal, + Goal, + extractActiveGoalLimitError, + ACTIVE_GOAL_LIMIT, +} from "@/types/goal"; import { Id } from "@/types/general"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship"; +import { toast } from "@/components/ui/use-toast"; interface GoalContainerInnerProps { coachingSessionId: Id; @@ -45,7 +51,16 @@ const GoalContainerInner: React.FC = ({ refresh(); } } catch (err) { - console.error("Failed to update or create Goal: " + err); + const activeGoals = extractActiveGoalLimitError(err); + if (activeGoals) { + toast({ + variant: "destructive", + title: "Goal limit reached", + description: `You already have ${ACTIVE_GOAL_LIMIT} active goals for this coaching relationship. Please complete or put one on hold before adding another.`, + }); + } else { + console.error("Failed to update or create Goal: " + err); + } } }; diff --git a/src/types/goal.ts b/src/types/goal.ts index 1b79be0d..faee996b 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -1,5 +1,53 @@ import { DateTime } from "ts-luxon"; -import { Id, ItemStatus } from "@/types/general"; +import { Id, ItemStatus, EntityApiError } from "@/types/general"; + +// ─── Active Goal Limit (409 Conflict) ─────────────────────────────── + +const ACTIVE_GOAL_LIMIT = 3; + +/** Summary of an active goal returned in the 409 response body. */ +export interface ActiveGoalSummary { + id: Id; + title: string; +} + +/** Shape of the 409 response body when the active-goal limit is exceeded. */ +export interface ActiveGoalLimitErrorData { + status_code: 409; + error: "active_goal_limit_reached"; + message: string; + active_goals: ActiveGoalSummary[]; +} + +/** + * Extracts active goals from an EntityApiError if it represents a 409 + * active-goal-limit error. Returns the active goals array on match, + * or null if the error is something else. + */ +export function extractActiveGoalLimitError( + err: unknown +): ActiveGoalSummary[] | null { + if ( + !(err instanceof EntityApiError) || + err.status !== 409 + ) { + return null; + } + + const data = err.data; + if ( + data && + typeof data === "object" && + data.error === "active_goal_limit_reached" && + Array.isArray(data.active_goals) + ) { + return data.active_goals as ActiveGoalSummary[]; + } + + return null; +} + +export { ACTIVE_GOAL_LIMIT }; // This must always reflect the Rust struct on the backend // entity::goals::Model From 3638b37b8af784732df18fe82c4ee030174f3927 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Wed, 11 Mar 2026 16:00:36 -0500 Subject: [PATCH 9/9] fix: align active-goal-limit types with v4 contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Active" means InProgress only — NotStarted does not count. Use max_active_goals from 409 response instead of hardcoding. Rename ACTIVE_GOAL_LIMIT to DEFAULT_MAX_ACTIVE_GOALS. Return ActiveGoalLimitInfo (with limit + goals) from the extractor. --- .../ui/coaching-sessions/goal-container.tsx | 7 ++--- src/types/goal.ts | 30 ++++++++++++++----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/components/ui/coaching-sessions/goal-container.tsx b/src/components/ui/coaching-sessions/goal-container.tsx index 096b4bd4..ab1faf6b 100644 --- a/src/components/ui/coaching-sessions/goal-container.tsx +++ b/src/components/ui/coaching-sessions/goal-container.tsx @@ -13,7 +13,6 @@ import { defaultGoal, Goal, extractActiveGoalLimitError, - ACTIVE_GOAL_LIMIT, } from "@/types/goal"; import { Id } from "@/types/general"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; @@ -51,12 +50,12 @@ const GoalContainerInner: React.FC = ({ refresh(); } } catch (err) { - const activeGoals = extractActiveGoalLimitError(err); - if (activeGoals) { + const limitInfo = extractActiveGoalLimitError(err); + if (limitInfo) { toast({ variant: "destructive", title: "Goal limit reached", - description: `You already have ${ACTIVE_GOAL_LIMIT} active goals for this coaching relationship. Please complete or put one on hold before adding another.`, + description: `You already have ${limitInfo.maxActiveGoals} goals in progress for this coaching relationship. Please complete or change the status of one before starting another.`, }); } else { console.error("Failed to update or create Goal: " + err); diff --git a/src/types/goal.ts b/src/types/goal.ts index faee996b..21be9082 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -2,31 +2,41 @@ import { DateTime } from "ts-luxon"; import { Id, ItemStatus, EntityApiError } from "@/types/general"; // ─── Active Goal Limit (409 Conflict) ─────────────────────────────── +// "Active" means InProgress ONLY — NotStarted does not count. +// See ActiveGoalLimitError contract v4 on the coordination board. -const ACTIVE_GOAL_LIMIT = 3; +/** Default limit used when no 409 response has been received yet. */ +const DEFAULT_MAX_ACTIVE_GOALS = 3; -/** Summary of an active goal returned in the 409 response body. */ +/** Summary of an InProgress goal returned in the 409 response body. */ export interface ActiveGoalSummary { id: Id; title: string; } +/** Parsed result from a 409 active-goal-limit error. */ +export interface ActiveGoalLimitInfo { + maxActiveGoals: number; + activeGoals: ActiveGoalSummary[]; +} + /** Shape of the 409 response body when the active-goal limit is exceeded. */ export interface ActiveGoalLimitErrorData { status_code: 409; error: "active_goal_limit_reached"; message: string; + max_active_goals: number; active_goals: ActiveGoalSummary[]; } /** - * Extracts active goals from an EntityApiError if it represents a 409 - * active-goal-limit error. Returns the active goals array on match, + * Extracts active-goal-limit info from an EntityApiError if it represents + * a 409 active-goal-limit error. Returns the limit and active goals on match, * or null if the error is something else. */ export function extractActiveGoalLimitError( err: unknown -): ActiveGoalSummary[] | null { +): ActiveGoalLimitInfo | null { if ( !(err instanceof EntityApiError) || err.status !== 409 @@ -39,15 +49,19 @@ export function extractActiveGoalLimitError( data && typeof data === "object" && data.error === "active_goal_limit_reached" && - Array.isArray(data.active_goals) + Array.isArray(data.active_goals) && + typeof data.max_active_goals === "number" ) { - return data.active_goals as ActiveGoalSummary[]; + return { + maxActiveGoals: data.max_active_goals, + activeGoals: data.active_goals as ActiveGoalSummary[], + }; } return null; } -export { ACTIVE_GOAL_LIMIT }; +export { DEFAULT_MAX_ACTIVE_GOALS }; // This must always reflect the Rust struct on the backend // entity::goals::Model