From d47525704adb21aa56620fd9e049a8a292812b00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:19:20 +0000 Subject: [PATCH 01/12] Initial plan From 1d66989454024cb9ab1b3cace0650f7e6cb8abc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:27:21 +0000 Subject: [PATCH 02/12] Add React contexts and page structure for frontend migration Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- apps/web/app/build-overview/page.tsx | 54 +++++++ apps/web/app/dashboard/page.tsx | 92 +++++------- apps/web/app/dream-book/page.tsx | 35 +++++ apps/web/app/dream-connect/page.tsx | 48 ++++++ apps/web/app/dream-team/page.tsx | 61 ++++++++ apps/web/app/health/page.tsx | 54 +++++++ apps/web/app/labs/adaptive-cards/page.tsx | 45 ++++++ apps/web/app/layout.tsx | 5 +- apps/web/app/people/page.tsx | 52 +++++++ apps/web/app/scorecard/page.tsx | 54 +++++++ .../dashboard/DashboardDreamCard.tsx | 36 +++++ .../components/dashboard/DashboardHeader.tsx | 34 +++++ .../components/dashboard/WeekGoalsWidget.tsx | 38 +++++ apps/web/components/dashboard/index.ts | 7 + apps/web/components/shared/Navigation.tsx | 40 +++++ apps/web/components/shared/index.ts | 5 + apps/web/lib/contexts/AppProviders.tsx | 33 +++++ apps/web/lib/contexts/ConnectContext.tsx | 92 ++++++++++++ apps/web/lib/contexts/DreamContext.tsx | 137 ++++++++++++++++++ apps/web/lib/contexts/GoalContext.tsx | 104 +++++++++++++ apps/web/lib/contexts/ScoringContext.tsx | 80 ++++++++++ apps/web/lib/contexts/TeamContext.tsx | 116 +++++++++++++++ apps/web/lib/contexts/UserContext.tsx | 88 +++++++++++ apps/web/lib/contexts/index.ts | 11 ++ 24 files changed, 1264 insertions(+), 57 deletions(-) create mode 100644 apps/web/app/build-overview/page.tsx create mode 100644 apps/web/app/dream-book/page.tsx create mode 100644 apps/web/app/dream-connect/page.tsx create mode 100644 apps/web/app/dream-team/page.tsx create mode 100644 apps/web/app/health/page.tsx create mode 100644 apps/web/app/labs/adaptive-cards/page.tsx create mode 100644 apps/web/app/people/page.tsx create mode 100644 apps/web/app/scorecard/page.tsx create mode 100644 apps/web/components/dashboard/DashboardDreamCard.tsx create mode 100644 apps/web/components/dashboard/DashboardHeader.tsx create mode 100644 apps/web/components/dashboard/WeekGoalsWidget.tsx create mode 100644 apps/web/components/dashboard/index.ts create mode 100644 apps/web/components/shared/Navigation.tsx create mode 100644 apps/web/components/shared/index.ts create mode 100644 apps/web/lib/contexts/AppProviders.tsx create mode 100644 apps/web/lib/contexts/ConnectContext.tsx create mode 100644 apps/web/lib/contexts/DreamContext.tsx create mode 100644 apps/web/lib/contexts/GoalContext.tsx create mode 100644 apps/web/lib/contexts/ScoringContext.tsx create mode 100644 apps/web/lib/contexts/TeamContext.tsx create mode 100644 apps/web/lib/contexts/UserContext.tsx create mode 100644 apps/web/lib/contexts/index.ts diff --git a/apps/web/app/build-overview/page.tsx b/apps/web/app/build-overview/page.tsx new file mode 100644 index 0000000..af22405 --- /dev/null +++ b/apps/web/app/build-overview/page.tsx @@ -0,0 +1,54 @@ +import { auth } from '@/lib/auth'; + +/** + * Build Overview page + * Stakeholder and team information hub + */ +export default async function BuildOverviewPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Build Overview
; + } + + return ( +
+
+

Build Overview

+

Team and organization insights

+
+ +
+

Organization Structure

+
+

Organizational chart will appear here

+
+
+ +
+

Team Analytics

+
+
+

Total Teams

+

0

+
+
+

Total Members

+

0

+
+
+

Active Projects

+

0

+
+
+
+ +
+

Stakeholder Information

+
+

Stakeholder details will appear here

+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 3511a21..0b18813 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -1,5 +1,5 @@ import { auth } from '@/lib/auth'; -import { getUserProfile } from '@/services/users'; +import { DashboardHeader, WeekGoalsWidget, DashboardDreamCard } from '@/components/dashboard'; /** * Dashboard page - Main application dashboard @@ -12,65 +12,45 @@ export default async function DashboardPage() { return
Unauthorized
; } - const result = await getUserProfile(session.user.id); - - if (result.failed) { - return
Error loading profile: {result.errors?._errors?.join(', ') || 'Unknown error'}
; - } - - const profile = result.data; - - if (!profile) { - return
Profile not found
; - } - return ( -
-
-
-

- Welcome back, {profile.displayName || profile.name || 'Dreamer'}! -

-

- Track your dreams and achieve your goals -

-
- -
- {/* Dashboard widgets will go here */} -
-

Current Week

-

Your weekly goals will appear here

+
+ + + + +
+ +
+ +
+ +
+ +
+

Quick Stats

+
+
+

This Week

+

Goals completed: 0/0

- -
-

Dream Book

-

Your dreams will appear here

+
+

This Month

+

Dreams updated: 0

- -
-

Progress

-

Your progress will appear here

+
+

Progress

+

On track

- -
-

- 🚧 Monorepo Migration in Progress -

-

- The application is being migrated to a modern NextJS monorepo architecture. - This dashboard is a placeholder that will be fully implemented as we migrate - the remaining Azure Functions to server actions. -

-

- User ID: {session.user.id} -

-

- Email: {session.user.email} -

-
-
-
+
+
); } diff --git a/apps/web/app/dream-book/page.tsx b/apps/web/app/dream-book/page.tsx new file mode 100644 index 0000000..5fec2b3 --- /dev/null +++ b/apps/web/app/dream-book/page.tsx @@ -0,0 +1,35 @@ +import { auth } from '@/lib/auth'; + +/** + * Dream Book page + * Manage dreams, goals, and year vision + */ +export default async function DreamBookPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Dream Book
; + } + + return ( +
+
+

Dream Book

+

Create and manage your dreams

+
+ +
+

Year Vision

+ +
+ +
+

My Dreams

+ +
+

Your dreams will appear here

+
+
+
+ ); +} diff --git a/apps/web/app/dream-connect/page.tsx b/apps/web/app/dream-connect/page.tsx new file mode 100644 index 0000000..12d2787 --- /dev/null +++ b/apps/web/app/dream-connect/page.tsx @@ -0,0 +1,48 @@ +import { auth } from '@/lib/auth'; + +/** + * Dream Connect page + * Network with others who share similar goals and interests + */ +export default async function DreamConnectPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Dream Connect
; + } + + return ( +
+
+

Dream Connect

+

Connect with others who share your dreams

+
+ +
+

Connection Filters

+
+ +
+
+ +
+

Suggested Connections

+
+

Connection suggestions will appear here

+
+
+ +
+

Recent Connections

+
+

Your recent connections will appear here

+
+
+
+ ); +} diff --git a/apps/web/app/dream-team/page.tsx b/apps/web/app/dream-team/page.tsx new file mode 100644 index 0000000..1a6b8ca --- /dev/null +++ b/apps/web/app/dream-team/page.tsx @@ -0,0 +1,61 @@ +import { auth } from '@/lib/auth'; + +/** + * Dream Team page + * Team collaboration, meetings, and progress tracking + */ +export default async function DreamTeamPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Dream Team
; + } + + return ( +
+
+

Dream Team

+

Collaborate with your team

+
+ +
+

Team Information

+
+

Team Name

+ +

Team Mission

+ +
+
+ +
+

Team Members

+
+

Team members will appear here

+
+
+ +
+

Meeting Schedule

+
+

Next meeting: Not scheduled

+ +
+
+ +
+

Meeting Attendance

+
+

Attendance records will appear here

+
+
+ +
+

Recently Completed Dreams

+
+

Team achievements will appear here

+
+
+
+ ); +} diff --git a/apps/web/app/health/page.tsx b/apps/web/app/health/page.tsx new file mode 100644 index 0000000..ec2fbcd --- /dev/null +++ b/apps/web/app/health/page.tsx @@ -0,0 +1,54 @@ +import { auth } from '@/lib/auth'; + +/** + * Health Check page + * System diagnostics and monitoring + */ +export default async function HealthCheckPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Health Check
; + } + + return ( +
+
+

Health Check

+

System status and diagnostics

+
+ +
+

System Status

+
+
+

Database

+

Status: Unknown

+
+
+

API

+

Status: Unknown

+
+
+

Storage

+

Status: Unknown

+
+
+
+ +
+

Recent Errors

+
+

Error log will appear here

+
+
+ +
+

Performance Metrics

+
+

Performance data will appear here

+
+
+
+ ); +} diff --git a/apps/web/app/labs/adaptive-cards/page.tsx b/apps/web/app/labs/adaptive-cards/page.tsx new file mode 100644 index 0000000..b37b2aa --- /dev/null +++ b/apps/web/app/labs/adaptive-cards/page.tsx @@ -0,0 +1,45 @@ +import { auth } from '@/lib/auth'; + +/** + * Adaptive Cards Lab page + * Experimental features and testing + */ +export default async function AdaptiveCardsLabPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Labs
; + } + + return ( +
+
+

Adaptive Cards Lab

+

Experimental features and UI components

+
+ +
+

Card Templates

+
+

Adaptive card templates will appear here

+
+
+ +
+

Testing Area

+
+ + + +
+
+ +
+

Preview

+
+

Card preview will appear here

+
+
+
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 4a608dd..0b58b57 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { AppProviders } from "@/lib/contexts/AppProviders"; export const metadata: Metadata = { title: "Dreamspace - Turn Dreams into Reality", @@ -14,7 +15,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); diff --git a/apps/web/app/people/page.tsx b/apps/web/app/people/page.tsx new file mode 100644 index 0000000..44576ae --- /dev/null +++ b/apps/web/app/people/page.tsx @@ -0,0 +1,52 @@ +import { auth } from '@/lib/auth'; + +/** + * People Dashboard page + * Admin view for managing coaches and users + */ +export default async function PeoplePage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access People Dashboard
; + } + + return ( +
+
+

People Dashboard

+

Manage coaches and team members

+
+ + + +
+

Coaches

+
+

Coach list will appear here

+
+
+ +
+

Team Metrics

+
+
+

Total Users

+

0

+
+
+

Active Dreams

+

0

+
+
+

Completed This Week

+

0

+
+
+
+
+ ); +} diff --git a/apps/web/app/scorecard/page.tsx b/apps/web/app/scorecard/page.tsx new file mode 100644 index 0000000..e1eccf6 --- /dev/null +++ b/apps/web/app/scorecard/page.tsx @@ -0,0 +1,54 @@ +import { auth } from '@/lib/auth'; + +/** + * Scorecard page + * Track activity points and progress history + */ +export default async function ScorecardPage() { + const session = await auth(); + + if (!session?.user) { + return
Please log in to access Scorecard
; + } + + return ( +
+
+

Scorecard

+

Track your activity and progress

+
+ +
+

Summary

+
+
+

Total Score

+

0 points

+
+
+

This Week

+

0 points

+
+
+

This Month

+

0 points

+
+
+
+ +
+

Activity History

+
+

Your activity history will appear here

+
+
+ +
+

Year Breakdown

+
+

Yearly trends will appear here

+
+
+
+ ); +} diff --git a/apps/web/components/dashboard/DashboardDreamCard.tsx b/apps/web/components/dashboard/DashboardDreamCard.tsx new file mode 100644 index 0000000..44ffac4 --- /dev/null +++ b/apps/web/components/dashboard/DashboardDreamCard.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useDreams } from '@/lib/contexts'; + +/** + * Dashboard Dream Card + * Displays dream overview on dashboard + */ +export function DashboardDreamCard() { + const { dreams } = useDreams(); + + return ( +
+

My Dreams

+
+ {dreams.length === 0 ? ( +

No dreams yet. Start building your dream book!

+ ) : ( +
    + {dreams.map((dream) => ( +
  • +

    {dream.title}

    +

    {dream.category}

    +
    + Progress: {dream.progress}% + +
    +
  • + ))} +
+ )} +
+ View All Dreams → +
+ ); +} diff --git a/apps/web/components/dashboard/DashboardHeader.tsx b/apps/web/components/dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..cdb4e22 --- /dev/null +++ b/apps/web/components/dashboard/DashboardHeader.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useUser } from '@/lib/contexts'; + +/** + * Dashboard Header + * Displays user greeting and quick stats + */ +export function DashboardHeader() { + const { currentUser } = useUser(); + + return ( +
+

Welcome back, {currentUser?.displayName || currentUser?.name || 'Dreamer'}!

+

Track your dreams and achieve your goals

+ {currentUser && ( +
+
+ Score: + {currentUser.score || 0} +
+
+ Dreams: + {currentUser.dreamsCount || 0} +
+
+ Connections: + {currentUser.connectsCount || 0} +
+
+ )} +
+ ); +} diff --git a/apps/web/components/dashboard/WeekGoalsWidget.tsx b/apps/web/components/dashboard/WeekGoalsWidget.tsx new file mode 100644 index 0000000..03d30ca --- /dev/null +++ b/apps/web/components/dashboard/WeekGoalsWidget.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useGoals } from '@/lib/contexts'; + +/** + * Week Goals Widget + * Displays and manages goals for the current week + */ +export function WeekGoalsWidget() { + const { weeklyGoals, toggleWeeklyGoal } = useGoals(); + + return ( +
+

This Week's Goals

+
+ {weeklyGoals.length === 0 ? ( +

No goals for this week. Add a goal to get started!

+ ) : ( +
    + {weeklyGoals.map((goal) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ ); +} diff --git a/apps/web/components/dashboard/index.ts b/apps/web/components/dashboard/index.ts new file mode 100644 index 0000000..a9b0b06 --- /dev/null +++ b/apps/web/components/dashboard/index.ts @@ -0,0 +1,7 @@ +/** + * Dashboard component exports + */ + +export * from './WeekGoalsWidget'; +export * from './DashboardDreamCard'; +export * from './DashboardHeader'; diff --git a/apps/web/components/shared/Navigation.tsx b/apps/web/components/shared/Navigation.tsx new file mode 100644 index 0000000..893dd02 --- /dev/null +++ b/apps/web/components/shared/Navigation.tsx @@ -0,0 +1,40 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +/** + * Main navigation component + * Displays primary navigation links + */ +export function Navigation() { + const pathname = usePathname(); + + const links = [ + { href: '/dashboard', label: 'Dashboard' }, + { href: '/dream-book', label: 'Dream Book' }, + { href: '/dream-connect', label: 'Dream Connect' }, + { href: '/scorecard', label: 'Scorecard' }, + { href: '/dream-team', label: 'Dream Team' }, + { href: '/people', label: 'People' }, + { href: '/build-overview', label: 'Build Overview' }, + { href: '/health', label: 'Health' }, + ]; + + return ( + + ); +} diff --git a/apps/web/components/shared/index.ts b/apps/web/components/shared/index.ts new file mode 100644 index 0000000..aebde32 --- /dev/null +++ b/apps/web/components/shared/index.ts @@ -0,0 +1,5 @@ +/** + * Shared component exports + */ + +export * from './Navigation'; diff --git a/apps/web/lib/contexts/AppProviders.tsx b/apps/web/lib/contexts/AppProviders.tsx new file mode 100644 index 0000000..624e0b5 --- /dev/null +++ b/apps/web/lib/contexts/AppProviders.tsx @@ -0,0 +1,33 @@ +'use client'; + +import React, { ReactNode } from 'react'; +import { + DreamProvider, + GoalProvider, + UserProvider, + ConnectProvider, + TeamProvider, + ScoringProvider, +} from './'; + +/** + * Root app providers + * Wraps all context providers for the application + */ +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + + + + + {children} + + + + + + + ); +} diff --git a/apps/web/lib/contexts/ConnectContext.tsx b/apps/web/lib/contexts/ConnectContext.tsx new file mode 100644 index 0000000..55ff5a1 --- /dev/null +++ b/apps/web/lib/contexts/ConnectContext.tsx @@ -0,0 +1,92 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +/** + * Connect data type + */ +export type Connect = { + id: string; + userId: string; + dreamId?: string; + withWhom: string; + when?: string; + notes?: string; + status?: 'pending' | 'completed'; + agenda?: string; + proposedWeeks?: string[]; + schedulingMethod?: string; + createdAt?: string; + updatedAt?: string; +}; + +/** + * Connect context state + */ +type ConnectContextState = { + connects: Connect[]; + isLoading: boolean; + setConnects: (connects: Connect[]) => void; + addConnect: (connect: Connect) => void; + updateConnect: (id: string, updates: Partial) => void; + deleteConnect: (id: string) => void; + setLoading: (loading: boolean) => void; +}; + +const ConnectContext = createContext(undefined); + +/** + * Connect context provider + * Manages dream connect/networking state + */ +export function ConnectProvider({ children }: { children: ReactNode }) { + const [connects, setConnects] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const addConnect = (connect: Connect) => { + setConnects((prev) => [...prev, connect]); + }; + + const updateConnect = (id: string, updates: Partial) => { + setConnects((prev) => + prev.map((connect) => + connect.id === id ? { ...connect, ...updates } : connect + ) + ); + }; + + const deleteConnect = (id: string) => { + setConnects((prev) => prev.filter((connect) => connect.id !== id)); + }; + + const setLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use connect context + */ +export function useConnects() { + const context = useContext(ConnectContext); + if (context === undefined) { + throw new Error('useConnects must be used within a ConnectProvider'); + } + return context; +} diff --git a/apps/web/lib/contexts/DreamContext.tsx b/apps/web/lib/contexts/DreamContext.tsx new file mode 100644 index 0000000..630b791 --- /dev/null +++ b/apps/web/lib/contexts/DreamContext.tsx @@ -0,0 +1,137 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +/** + * Dream data type + */ +export type Dream = { + id: string; + title: string; + category: string; + description?: string; + progress: number; + image?: string; + goals?: Goal[]; + notes?: Note[]; + coachNotes?: CoachNote[]; + history?: HistoryEntry[]; + createdAt?: string; + updatedAt?: string; +}; + +export type Goal = { + id: string; + dreamId: string; + title: string; + description?: string; + type: 'consistency' | 'deadline'; + recurrence?: 'weekly' | 'once'; + targetWeeks?: number; + targetDate?: string; + startDate?: string; + weekId?: string; + active: boolean; + completed: boolean; + completedAt?: string; +}; + +export type Note = { + id: string; + text: string; + createdAt: string; +}; + +export type CoachNote = { + id: string; + text: string; + sender: string; + createdAt: string; +}; + +export type HistoryEntry = { + id: string; + action: string; + details: string; + timestamp: string; +}; + +/** + * Dream context state + */ +type DreamContextState = { + dreams: Dream[]; + yearVision: string; + isLoading: boolean; + setDreams: (dreams: Dream[]) => void; + setYearVision: (vision: string) => void; + addDream: (dream: Dream) => void; + updateDream: (id: string, updates: Partial) => void; + deleteDream: (id: string) => void; + reorderDreams: (dreams: Dream[]) => void; + setLoading: (loading: boolean) => void; +}; + +const DreamContext = createContext(undefined); + +/** + * Dream context provider + * Manages dream book state (dreams, year vision) + */ +export function DreamProvider({ children }: { children: ReactNode }) { + const [dreams, setDreams] = useState([]); + const [yearVision, setYearVision] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const addDream = (dream: Dream) => { + setDreams((prev) => [...prev, dream]); + }; + + const updateDream = (id: string, updates: Partial) => { + setDreams((prev) => + prev.map((dream) => (dream.id === id ? { ...dream, ...updates } : dream)) + ); + }; + + const deleteDream = (id: string) => { + setDreams((prev) => prev.filter((dream) => dream.id !== id)); + }; + + const reorderDreams = (newDreams: Dream[]) => { + setDreams(newDreams); + }; + + const setLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use dream context + */ +export function useDreams() { + const context = useContext(DreamContext); + if (context === undefined) { + throw new Error('useDreams must be used within a DreamProvider'); + } + return context; +} diff --git a/apps/web/lib/contexts/GoalContext.tsx b/apps/web/lib/contexts/GoalContext.tsx new file mode 100644 index 0000000..cd80323 --- /dev/null +++ b/apps/web/lib/contexts/GoalContext.tsx @@ -0,0 +1,104 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +/** + * Weekly goal data type + */ +export type WeeklyGoal = { + id: string; + title: string; + dreamId?: string; + goalId?: string; + completed: boolean; + completedAt?: string; + weekId: string; + recurrence: 'weekly' | 'once'; + active: boolean; + weekLog?: Record; +}; + +/** + * Goal context state + */ +type GoalContextState = { + weeklyGoals: WeeklyGoal[]; + isLoading: boolean; + setWeeklyGoals: (goals: WeeklyGoal[]) => void; + addWeeklyGoal: (goal: WeeklyGoal) => void; + updateWeeklyGoal: (id: string, updates: Partial) => void; + deleteWeeklyGoal: (id: string) => void; + toggleWeeklyGoal: (id: string) => void; + setLoading: (loading: boolean) => void; +}; + +const GoalContext = createContext(undefined); + +/** + * Goal context provider + * Manages weekly goals state + */ +export function GoalProvider({ children }: { children: ReactNode }) { + const [weeklyGoals, setWeeklyGoals] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const addWeeklyGoal = (goal: WeeklyGoal) => { + setWeeklyGoals((prev) => [...prev, goal]); + }; + + const updateWeeklyGoal = (id: string, updates: Partial) => { + setWeeklyGoals((prev) => + prev.map((goal) => (goal.id === id ? { ...goal, ...updates } : goal)) + ); + }; + + const deleteWeeklyGoal = (id: string) => { + setWeeklyGoals((prev) => prev.filter((goal) => goal.id !== id)); + }; + + const toggleWeeklyGoal = (id: string) => { + setWeeklyGoals((prev) => + prev.map((goal) => + goal.id === id + ? { + ...goal, + completed: !goal.completed, + completedAt: !goal.completed ? new Date().toISOString() : undefined, + } + : goal + ) + ); + }; + + const setLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use goal context + */ +export function useGoals() { + const context = useContext(GoalContext); + if (context === undefined) { + throw new Error('useGoals must be used within a GoalProvider'); + } + return context; +} diff --git a/apps/web/lib/contexts/ScoringContext.tsx b/apps/web/lib/contexts/ScoringContext.tsx new file mode 100644 index 0000000..117cc61 --- /dev/null +++ b/apps/web/lib/contexts/ScoringContext.tsx @@ -0,0 +1,80 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +/** + * Scoring entry data type + */ +export type ScoringEntry = { + id: string; + date: string; + score: number; + activity: string; + points: number; + category?: string; +}; + +/** + * Scoring context state + */ +type ScoringContextState = { + scoringHistory: ScoringEntry[]; + allYearsScoring: ScoringEntry[]; + allTimeScore: number; + isLoading: boolean; + setScoringHistory: (history: ScoringEntry[]) => void; + setAllYearsScoring: (scoring: ScoringEntry[]) => void; + setAllTimeScore: (score: number) => void; + addScoringEntry: (entry: ScoringEntry) => void; + setLoading: (loading: boolean) => void; +}; + +const ScoringContext = createContext(undefined); + +/** + * Scoring context provider + * Manages scorecard and activity scoring state + */ +export function ScoringProvider({ children }: { children: ReactNode }) { + const [scoringHistory, setScoringHistory] = useState([]); + const [allYearsScoring, setAllYearsScoring] = useState([]); + const [allTimeScore, setAllTimeScore] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + const addScoringEntry = (entry: ScoringEntry) => { + setScoringHistory((prev) => [entry, ...prev]); + }; + + const setLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use scoring context + */ +export function useScoring() { + const context = useContext(ScoringContext); + if (context === undefined) { + throw new Error('useScoring must be used within a ScoringProvider'); + } + return context; +} diff --git a/apps/web/lib/contexts/TeamContext.tsx b/apps/web/lib/contexts/TeamContext.tsx new file mode 100644 index 0000000..76842ba --- /dev/null +++ b/apps/web/lib/contexts/TeamContext.tsx @@ -0,0 +1,116 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +/** + * Team member data type + */ +export type TeamMember = { + id: string; + name: string; + email?: string; + avatar?: string; + role?: string; + dreamsCompleted?: number; + meetingAttendance?: number; +}; + +/** + * Meeting data type + */ +export type Meeting = { + id: string; + date: string; + attendees: string[]; + notes?: string; + teamId?: string; +}; + +/** + * Team info data type + */ +export type TeamInfo = { + id: string; + name: string; + mission?: string; + coachId?: string; + members?: TeamMember[]; +}; + +/** + * Team context state + */ +type TeamContextState = { + teamInfo: TeamInfo | null; + meetings: Meeting[]; + isLoading: boolean; + setTeamInfo: (info: TeamInfo | null) => void; + updateTeamInfo: (updates: Partial) => void; + setMeetings: (meetings: Meeting[]) => void; + addMeeting: (meeting: Meeting) => void; + updateMeeting: (id: string, updates: Partial) => void; + setLoading: (loading: boolean) => void; +}; + +const TeamContext = createContext(undefined); + +/** + * Team context provider + * Manages team collaboration state + */ +export function TeamProvider({ children }: { children: ReactNode }) { + const [teamInfo, setTeamInfo] = useState(null); + const [meetings, setMeetings] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const updateTeamInfo = (updates: Partial) => { + if (teamInfo) { + setTeamInfo({ ...teamInfo, ...updates }); + } + }; + + const addMeeting = (meeting: Meeting) => { + setMeetings((prev) => [...prev, meeting]); + }; + + const updateMeeting = (id: string, updates: Partial) => { + setMeetings((prev) => + prev.map((meeting) => + meeting.id === id ? { ...meeting, ...updates } : meeting + ) + ); + }; + + const setLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use team context + */ +export function useTeam() { + const context = useContext(TeamContext); + if (context === undefined) { + throw new Error('useTeam must be used within a TeamProvider'); + } + return context; +} diff --git a/apps/web/lib/contexts/UserContext.tsx b/apps/web/lib/contexts/UserContext.tsx new file mode 100644 index 0000000..b25e504 --- /dev/null +++ b/apps/web/lib/contexts/UserContext.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +/** + * User data type + */ +export type User = { + id: string; + email: string; + name: string; + displayName?: string; + office?: string; + avatar?: string; + jobTitle?: string; + department?: string; + role?: 'user' | 'coach' | 'admin'; + isCoach?: boolean; + score?: number; + dreamsCount?: number; + connectsCount?: number; + dreamCategories?: string[]; +}; + +/** + * User context state + */ +type UserContextState = { + currentUser: User | null; + isLoading: boolean; + setCurrentUser: (user: User | null) => void; + updateUserProfile: (updates: Partial) => void; + updateScore: (score: number) => void; + setLoading: (loading: boolean) => void; +}; + +const UserContext = createContext(undefined); + +/** + * User context provider + * Manages current user profile state + */ +export function UserProvider({ children }: { children: ReactNode }) { + const [currentUser, setCurrentUser] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const updateUserProfile = (updates: Partial) => { + if (currentUser) { + setCurrentUser({ ...currentUser, ...updates }); + } + }; + + const updateScore = (score: number) => { + if (currentUser) { + setCurrentUser({ ...currentUser, score }); + } + }; + + const setLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use user context + */ +export function useUser() { + const context = useContext(UserContext); + if (context === undefined) { + throw new Error('useUser must be used within a UserProvider'); + } + return context; +} diff --git a/apps/web/lib/contexts/index.ts b/apps/web/lib/contexts/index.ts new file mode 100644 index 0000000..b829bc0 --- /dev/null +++ b/apps/web/lib/contexts/index.ts @@ -0,0 +1,11 @@ +/** + * Context exports + * Barrel export for all context providers and hooks + */ + +export * from './DreamContext'; +export * from './GoalContext'; +export * from './UserContext'; +export * from './ConnectContext'; +export * from './TeamContext'; +export * from './ScoringContext'; From b893cbb51fa012f72f757a42f563e65d0178cf30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:31:06 +0000 Subject: [PATCH 03/12] Add comprehensive frontend migration documentation Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- COMPONENT_STUBBING_GUIDE.md | 606 ++++++++++++++++++++++++++++++++++ FRONTEND_MIGRATION_PLAN.md | 523 +++++++++++++++++++++++++++++ FRONTEND_MIGRATION_SUMMARY.md | 294 +++++++++++++++++ 3 files changed, 1423 insertions(+) create mode 100644 COMPONENT_STUBBING_GUIDE.md create mode 100644 FRONTEND_MIGRATION_PLAN.md create mode 100644 FRONTEND_MIGRATION_SUMMARY.md diff --git a/COMPONENT_STUBBING_GUIDE.md b/COMPONENT_STUBBING_GUIDE.md new file mode 100644 index 0000000..990f10a --- /dev/null +++ b/COMPONENT_STUBBING_GUIDE.md @@ -0,0 +1,606 @@ +# Component Stubbing Guide + +**Purpose**: Guidelines for creating functional, unstyled component stubs during frontend migration + +## Principles + +1. **Functional First**: Components must work without styling +2. **Native Elements**: Use standard HTML elements (no UI libraries) +3. **Type-Safe**: All props and state properly typed +4. **Context-Aware**: Use appropriate contexts for state +5. **Server-First**: Prefer Server Components unless interactivity needed + +## Component Template + +### Client Component Template + +```tsx +'use client'; + +import { useDreams } from '@/lib/contexts'; // or other contexts + +/** + * ComponentName + * Brief description of what this component does + */ +export function ComponentName() { + // 1. Get data from context + const { dreams, addDream } = useDreams(); + + // 2. Local state (if needed) + const [isOpen, setIsOpen] = useState(false); + + // 3. Event handlers + const handleClick = () => { + // Handle user interaction + }; + + // 4. Render with native HTML + return ( +
+

Section Title

+ + + {/* Conditional rendering */} + {dreams.length === 0 ? ( +

No data yet

+ ) : ( +
    + {dreams.map(dream => ( +
  • {dream.title}
  • + ))} +
+ )} +
+ ); +} +``` + +### Server Component Template + +```tsx +import { auth } from '@/lib/auth'; +import { getDreams } from '@/services/dreams'; + +/** + * ComponentName + * Brief description of what this component does + */ +export async function ComponentName() { + // 1. Auth check (if needed) + const session = await auth(); + + // 2. Fetch data + const result = await getDreams(session.user.id); + + if (result.failed) { + return
Error: {result.errors._errors.join(', ')}
; + } + + const { dreams } = result; + + // 3. Render + return ( +
+

Dreams

+ {dreams.map(dream => ( +
+

{dream.title}

+

{dream.description}

+
+ ))} +
+ ); +} +``` + +## Component Patterns + +### 1. List Display + +```tsx +export function ItemList() { + const { items } = useItems(); + + return ( +
+

Items

+ {items.length === 0 ? ( +

No items found

+ ) : ( +
    + {items.map(item => ( +
  • + {item.name} + +
  • + ))} +
+ )} + +
+ ); +} +``` + +### 2. Form Input + +```tsx +export function ItemForm() { + const { addItem } = useItems(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const item = { + id: crypto.randomUUID(), + name: formData.get('name') as string, + description: formData.get('description') as string, + }; + + addItem(item); + e.currentTarget.reset(); + }; + + return ( +
+
+ +
+
+ +
+ + +
+ ); +} +``` + +### 3. Modal/Dialog + +```tsx +export function ItemModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

Modal Title

+ +
+
+ {/* Modal content */} +
+
+ +
+
+
+ ); +} +``` + +### 4. Tabs + +```tsx +export function TabView() { + const [activeTab, setActiveTab] = useState<'overview' | 'details'>('overview'); + + return ( +
+ + +
+ {activeTab === 'overview' && } + {activeTab === 'details' && } +
+
+ ); +} +``` + +### 5. Filters/Search + +```tsx +export function FilterableList() { + const { items } = useItems(); + const [filter, setFilter] = useState(''); + + const filteredItems = items.filter(item => + item.name.toLowerCase().includes(filter.toLowerCase()) + ); + + return ( +
+ setFilter(e.target.value)} + /> +
    + {filteredItems.map(item => ( +
  • {item.name}
  • + ))} +
+
+ ); +} +``` + +### 6. Stats/Metrics + +```tsx +export function StatsCard({ label, value }: { label: string; value: number | string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function StatsGrid() { + const { currentUser } = useUser(); + + return ( +
+ + + +
+ ); +} +``` + +### 7. Progress Indicator + +```tsx +export function ProgressBar({ value, max = 100 }: { value: number; max?: number }) { + const percentage = (value / max) * 100; + + return ( +
+ + {percentage.toFixed(0)}% +
+ ); +} +``` + +## Context Usage Patterns + +### Single Context + +```tsx +'use client'; + +import { useDreams } from '@/lib/contexts'; + +export function DreamList() { + const { dreams, deleteDream } = useDreams(); + // ... component logic +} +``` + +### Multiple Contexts + +```tsx +'use client'; + +import { useDreams, useGoals, useUser } from '@/lib/contexts'; + +export function Dashboard() { + const { currentUser } = useUser(); + const { dreams } = useDreams(); + const { weeklyGoals } = useGoals(); + + // ... component logic +} +``` + +### Optional Context (for shared components) + +```tsx +'use client'; + +import { useDreams } from '@/lib/contexts'; + +export function SharedComponent({ standalone = false }: { standalone?: boolean }) { + // Only use context if not standalone + const context = standalone ? null : useDreams(); + + // ... component logic +} +``` + +## HTML Elements Reference + +### Common Elements + +- `
` - Container +- `
` - Thematic grouping +- `
` - Self-contained content +- `
` - Introductory content +- `
` - Footer content +- `
+ +
+ + +
+ + ); +} +``` + +--- + +**Remember**: The goal is functionality, not beauty. Keep it simple, use native elements, and ensure everything works before styling. diff --git a/FRONTEND_MIGRATION_PLAN.md b/FRONTEND_MIGRATION_PLAN.md new file mode 100644 index 0000000..32d5243 --- /dev/null +++ b/FRONTEND_MIGRATION_PLAN.md @@ -0,0 +1,523 @@ +# Frontend Migration Plan: Legacy React → NextJS App Router + +**Status**: In Progress +**Date**: February 2026 +**Goal**: Migrate legacy `/src` React app to modern NextJS App Router in `apps/web` + +## Executive Summary + +This migration refactors the Dreamspace application from a client-heavy Redux-based React app to a progressive, isomorphic NextJS application using Server Components and React Contexts. The migration maintains all existing functionality while improving performance, maintainability, and deployment readiness. + +## Migration Strategy + +### 1. State Management Transformation + +**From**: Centralized Redux store with 30+ actions +**To**: Domain-specific React Contexts (6 contexts) + +| Context | Responsibility | State | +|---------|---------------|-------| +| `UserContext` | Current user profile | user data, score, stats | +| `DreamContext` | Dream book management | dreams[], yearVision | +| `GoalContext` | Weekly goal tracking | weeklyGoals[] | +| `ConnectContext` | Network connections | connects[] | +| `TeamContext` | Team collaboration | teamInfo, meetings[] | +| `ScoringContext` | Activity scoring | scoringHistory[], allTimeScore | + +**Benefits**: +- Reduced cognitive load (separate concerns) +- Better tree-shaking (only load needed contexts) +- Easier testing (isolated state) +- Clearer data flow + +### 2. Page Structure Mapping + +All 9 legacy pages mapped to NextJS App Router: + +| Legacy Route | NextJS Route | Status | +|-------------|--------------|--------| +| `/` (Dashboard) | `/dashboard/page.tsx` | ✅ Stubbed | +| `/dream-book` | `/dream-book/page.tsx` | ✅ Stubbed | +| `/dream-connect` | `/dream-connect/page.tsx` | ✅ Stubbed | +| `/scorecard` | `/scorecard/page.tsx` | ✅ Stubbed | +| `/dream-team` | `/dream-team/page.tsx` | ✅ Stubbed | +| `/people` | `/people/page.tsx` | ✅ Stubbed | +| `/build-overview` | `/build-overview/page.tsx` | ✅ Stubbed | +| `/health` | `/health/page.tsx` | ✅ Stubbed | +| `/labs/adaptive-cards` | `/labs/adaptive-cards/page.tsx` | ✅ Stubbed | + +### 3. Component Architecture + +**Legacy Pattern**: Three-layer architecture +1. Layout (orchestrator) +2. Custom Hook (business logic) +3. Presentational Components + +**New Pattern**: Server-first architecture +1. Server Component Page (data fetching) +2. Client Components (interactivity) +3. Server Actions (mutations) + +## Implementation Details + +### Phase 1: Contexts ✅ + +Created 6 domain-specific contexts in `apps/web/lib/contexts/`: + +```typescript +// Example: DreamContext +type Dream = { + id: string; + title: string; + category: string; + progress: number; + goals?: Goal[]; + // ... more fields +}; + +type DreamContextState = { + dreams: Dream[]; + yearVision: string; + addDream: (dream: Dream) => void; + updateDream: (id: string, updates: Partial) => void; + deleteDream: (id: string) => void; + // ... more actions +}; +``` + +All contexts follow the same pattern: +- Type-safe state and actions +- useState for local state +- Clear mutation methods +- Loading state management + +### Phase 2: Pages ✅ + +All pages created with: +- Server Component by default +- Auth check using `auth()` +- Functional structure (no styling) +- Native HTML elements + +Example structure: +```tsx +export default async function DashboardPage() { + const session = await auth(); + + if (!session?.user?.id) { + return
Unauthorized
; + } + + return ( +
+ + + +
+ ); +} +``` + +### Phase 3: Components - In Progress + +**Completed**: +- Dashboard components (3): Header, WeekGoalsWidget, DreamCard +- Shared components (1): Navigation + +**Remaining** (80+ components to stub): + +#### Dashboard Components ✅ +- [x] DashboardHeader +- [x] WeekGoalsWidget +- [x] DashboardDreamCard + +#### Dream Book Components +- [ ] DreamForm (create/edit) +- [ ] DreamGrid (display all) +- [ ] DreamCard (individual card) +- [ ] YearVisionCard (vision statement) +- [ ] DreamRadarChart (analytics) +- [ ] ImageUploadSection +- [ ] FirstGoalSetup + +#### Dream Tracker Components +- [ ] DreamTrackerModal (detail view) +- [ ] DreamHeader +- [ ] DreamTabNavigation +- [ ] OverviewTab +- [ ] GoalsTab +- [ ] NotesTab +- [ ] CoachNotesTab +- [ ] HistoryTab + +#### Dream Connect Components +- [ ] ConnectionFilters +- [ ] SuggestedConnections +- [ ] RecentConnects +- [ ] ConnectionCard +- [ ] ConnectDetailModal +- [ ] ConnectRequestModal + +#### Scorecard Components +- [ ] SummaryView +- [ ] HistoryView +- [ ] YearBreakdownView +- [ ] ActivityCard +- [ ] ProgressCard + +#### Dream Team Components +- [ ] TeamMembersSection +- [ ] TeamMemberCard +- [ ] TeamMemberModal +- [ ] MeetingScheduleCard +- [ ] MeetingAttendanceCard +- [ ] RecentlyCompletedDreamsCard +- [ ] MeetingHistoryModal + +#### People Dashboard Components +- [ ] PeopleHeader +- [ ] CoachesPanel +- [ ] UsersPanel +- [ ] CoachList +- [ ] CoachCard +- [ ] TeamMemberRow +- [ ] TeamMetrics +- [ ] CoachDetailModal +- [ ] ReplaceCoachModal + +#### Shared Components +- [x] Navigation +- [ ] Modal (base) +- [ ] ConfirmModal +- [ ] LoadingSpinner +- [ ] ErrorBoundary +- [ ] Toast notifications + +## Data Flow Pattern + +### Legacy (Client-Heavy) +``` +User Action → Hook → Dispatch → Reducer → Context → +Component Rerender → Service → API → Update State → +useAutosave → localStorage +``` + +### New (Progressive/Isomorphic) +``` +Server Component → Server Action (data fetch) → Render → +Client Component (interactive) → User Action → +Server Action (mutation) → revalidatePath → Rerender +``` + +## Key Differences + +| Aspect | Legacy | New | +|--------|--------|-----| +| Rendering | Client-side only | Server Components + Client Components | +| State | Redux reducer | React Contexts (client) | +| Data Fetching | useEffect + services | Server Actions + fetch | +| Forms | Controlled + React state | Uncontrolled + FormData | +| Validation | Zod schemas | Zod schemas (same) | +| Routing | React Router | NextJS App Router | +| Code Splitting | Lazy imports | Automatic (Next) | + +## Migration Phases + +### Phase 1: Foundation ✅ (Complete) +- Create context architecture +- Stub all page routes +- Basic component structure + +### Phase 2: Component Migration (Current) +- Stub all components with native HTML +- Ensure functional without styling +- Wire contexts to components + +### Phase 3: Integration +- Connect server actions to UI +- Implement data fetching +- Add form handling +- Wire up navigation + +### Phase 4: Testing & Validation +- Test all user flows +- Verify data persistence +- Check auth flows +- Performance testing + +### Phase 5: Styling (Future) +- Apply Tailwind classes +- Match legacy UI +- Responsive design +- Accessibility + +## Technical Considerations + +### Context Usage Guidelines + +**When to use Context:** +- User profile data (needed across app) +- Dreams/goals (modified from multiple places) +- UI state that needs persistence (modals, filters) + +**When NOT to use Context:** +- One-off data fetching → use Server Components +- Local component state → use useState +- Form state → use uncontrolled components + +### Server vs Client Components + +**Use Server Components for:** +- Data fetching +- Static content +- SEO-critical content +- Markdown rendering + +**Use Client Components for:** +- Interactivity (clicks, forms) +- Browser APIs (localStorage) +- React hooks (useState, useEffect) +- Context consumers + +### Server Actions Pattern + +All mutations follow this pattern: +```typescript +'use server'; + +export async function saveDream(formData: FormData) { + // 1. Auth check + const session = await auth(); + if (!session?.user?.id) { + return { failed: true, errors: { _errors: ['Unauthorized'] } }; + } + + // 2. Validation + const data = schema.parse(formData); + + // 3. Business logic + const db = getDatabaseClient(); + const dream = await db.dreams.save({ ...data, userId: session.user.id }); + + // 4. Revalidate + revalidatePath('/dream-book'); + + // 5. Return result + return { failed: false, dream }; +} +``` + +## File Structure + +``` +apps/web/ +├── app/ # NextJS App Router +│ ├── dashboard/ +│ │ └── page.tsx # Server Component +│ ├── dream-book/ +│ │ └── page.tsx +│ ├── dream-connect/ +│ │ └── page.tsx +│ ├── scorecard/ +│ │ └── page.tsx +│ ├── dream-team/ +│ │ └── page.tsx +│ ├── people/ +│ │ └── page.tsx +│ ├── build-overview/ +│ │ └── page.tsx +│ ├── health/ +│ │ └── page.tsx +│ ├── labs/ +│ │ └── adaptive-cards/ +│ │ └── page.tsx +│ └── layout.tsx # Root layout with providers +│ +├── components/ # Client Components +│ ├── dashboard/ +│ │ ├── WeekGoalsWidget.tsx +│ │ ├── DashboardDreamCard.tsx +│ │ ├── DashboardHeader.tsx +│ │ └── index.ts +│ ├── dream-book/ # TBD +│ ├── dream-connect/ # TBD +│ ├── scorecard/ # TBD +│ ├── dream-team/ # TBD +│ ├── people/ # TBD +│ └── shared/ +│ ├── Navigation.tsx +│ └── index.ts +│ +├── lib/ +│ ├── contexts/ # React Contexts +│ │ ├── DreamContext.tsx +│ │ ├── GoalContext.tsx +│ │ ├── UserContext.tsx +│ │ ├── ConnectContext.tsx +│ │ ├── TeamContext.tsx +│ │ ├── ScoringContext.tsx +│ │ ├── AppProviders.tsx +│ │ └── index.ts +│ ├── actions/ # Server Action utilities +│ └── auth.ts # Auth configuration +│ +└── services/ # Server Actions (already exist) + ├── dreams/ + ├── users/ + ├── weeks/ + ├── teams/ + ├── scoring/ + └── ... +``` + +## Dependencies + +### Keep Same +- `next-auth` - Authentication +- `zod` - Validation +- `zod-form-data` - Form parsing +- `tailwindcss` - Styling (future) + +### Remove (Legacy Only) +- `react-router-dom` - Replaced by NextJS router +- Redux (if any) - Replaced by contexts + +### Add (Already Added) +- `lucide-react` - Icons +- `canvas-confetti` - Celebrations + +## Testing Strategy + +### Unit Tests +- Context actions (add, update, delete) +- Server actions (auth, validation, errors) +- Utility functions + +### Integration Tests +- Page rendering +- Form submission +- Navigation +- Data persistence + +### E2E Tests +- Complete user flows +- Auth flows +- Error handling + +## Performance Considerations + +1. **Server Components** - Reduce client JS bundle +2. **Parallel Data Fetching** - Use Promise.all for multiple fetches +3. **Streaming** - Use Suspense for progressive loading +4. **Code Splitting** - Automatic with NextJS +5. **Image Optimization** - Use next/image component + +## Security Considerations + +1. **Auth Checks** - Every server action checks session +2. **Input Validation** - Zod schemas on all inputs +3. **CSRF Protection** - Built into Next.js +4. **SQL Injection** - Using parameterized queries +5. **XSS Protection** - React escapes by default + +## Deployment + +### Development +```bash +pnpm dev # Start dev server +pnpm type-check # TypeScript validation +pnpm lint # ESLint +``` + +### Production +```bash +pnpm build # Build for production +pnpm start # Start production server +``` + +### Environments +- **Local**: Development server +- **Staging**: Azure App Service (staging slot) +- **Production**: Azure App Service or Vercel + +## Rollout Plan + +### Option 1: Big Bang +- Complete all migration work +- Switch traffic from legacy to new +- Monitor for issues + +### Option 2: Gradual (Recommended) +1. Deploy new routes alongside legacy +2. Redirect one route at a time +3. Monitor metrics and errors +4. Roll back if needed +5. Gradually migrate all routes + +## Success Metrics + +- [ ] All 9 pages functional +- [ ] All user flows working +- [ ] Performance ≥ legacy app +- [ ] Zero critical bugs +- [ ] Type-safe (no TypeScript errors) +- [ ] Accessible (WCAG AA) +- [ ] Tests passing (>80% coverage) + +## Next Steps + +1. **Immediate**: Complete component stubbing + - Dream Book components + - Dream Connect components + - Scorecard components + - Dream Team components + - People components + +2. **Short-term**: Wire up data + - Connect contexts to server actions + - Implement form handlers + - Add loading states + - Error handling + +3. **Medium-term**: Polish + - Add Tailwind styling + - Responsive design + - Accessibility improvements + - Performance optimization + +4. **Long-term**: Enhancements + - Real-time updates (websockets) + - Offline support (service workers) + - PWA features + - Advanced analytics + +## Questions & Decisions + +### Q: Should we use React Query for server state? +**A**: No, use Server Components + Server Actions. Simpler mental model, less client JS. + +### Q: How to handle real-time updates? +**A**: Phase 1: polling with revalidatePath. Phase 2: websockets/SSE if needed. + +### Q: What about the existing Azure Functions? +**A**: They're being migrated to Server Actions (see `MIGRATION_GUIDE.md`). + +### Q: Should we keep localStorage sync? +**A**: No, Server Components fetch fresh data on each request. Use database as source of truth. + +### Q: How to handle optimistic updates? +**A**: Use `useOptimistic` hook for instant feedback, revalidate after server action completes. + +## References + +- [NextJS App Router Docs](https://nextjs.org/docs/app) +- [Server Actions Guide](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) +- [Netsurit Architecture Standards](/.claude/skills/software-architecture/references/) +- [Migration Guide](./MIGRATION_GUIDE.md) +- [Monorepo README](./README.monorepo.md) + +--- + +**Last Updated**: February 4, 2026 +**Author**: GitHub Copilot Agent +**Reviewers**: TBD diff --git a/FRONTEND_MIGRATION_SUMMARY.md b/FRONTEND_MIGRATION_SUMMARY.md new file mode 100644 index 0000000..ed14d6d --- /dev/null +++ b/FRONTEND_MIGRATION_SUMMARY.md @@ -0,0 +1,294 @@ +# Frontend Migration Analysis Summary + +## Overview + +Analysis complete for migrating the Dreamspace frontend from legacy React app (`/src`) to NextJS App Router (`apps/web`). + +## Current State + +### Legacy App (`/src`) +- **9 main pages** with React Router +- **80+ components** organized by feature +- **32 custom hooks** for business logic +- **21 services** for API calls +- **Redux-like reducer** with AppContext (30+ actions) +- Client-heavy rendering with lazy loading + +### New App (`apps/web`) +- **Server actions** already created (70+ actions across domains) +- **NextJS App Router** structure in place +- **Authentication** via NextAuth (configured) +- **Database layer** with @dreamspace/database package +- **TypeScript** throughout + +## Migration Approach + +### 1. State Management: Redux → React Contexts + +Created 6 domain-specific contexts to replace centralized Redux: + +| Context | Purpose | State Size | +|---------|---------|-----------| +| `UserContext` | Current user profile | ~10 fields | +| `DreamContext` | Dreams & year vision | dreams[] + vision | +| `GoalContext` | Weekly goals | weeklyGoals[] | +| `ConnectContext` | Networking | connects[] | +| `TeamContext` | Team collaboration | teamInfo + meetings[] | +| `ScoringContext` | Activity tracking | scoringHistory[] + scores | + +**Benefit**: Reduced coupling, better tree-shaking, clearer boundaries + +### 2. Page Structure: Client → Server Components + +All 9 pages created as NextJS routes: + +``` +✅ /dashboard/page.tsx (with 3 client components) +✅ /dream-book/page.tsx (stub) +✅ /dream-connect/page.tsx (stub) +✅ /scorecard/page.tsx (stub) +✅ /dream-team/page.tsx (stub) +✅ /people/page.tsx (stub) +✅ /build-overview/page.tsx (stub) +✅ /health/page.tsx (stub) +✅ /labs/adaptive-cards/page.tsx (stub) +``` + +**Benefit**: Server-first rendering, SEO-friendly, faster initial load + +### 3. Components: Progressive Migration + +**Completed** (4 components): +- DashboardHeader (user greeting + stats) +- WeekGoalsWidget (goal checklist) +- DashboardDreamCard (dream overview) +- Navigation (shared nav) + +**Remaining** (~80 components): +- Dream Book: DreamForm, DreamGrid, YearVisionCard, etc. +- Dream Connect: ConnectionCard, Filters, Modals +- Scorecard: SummaryView, HistoryView, YearBreakdown +- Dream Team: TeamMembers, Meetings, Stats +- People: CoachList, UserList, Metrics +- Shared: Modals, Forms, LoadingSpinner + +## Key Architectural Changes + +### Before (Legacy) +``` +Client Rendering → Redux State → useEffect → API Call → +Update State → localStorage Sync → Rerender +``` + +### After (New) +``` +Server Component → Fetch Data → Render → +Client Component (interactive) → Server Action → +Revalidate → Rerender +``` + +## Benefits of New Architecture + +1. **Performance** + - Server Components = less client JS + - Automatic code splitting + - Parallel data fetching + - Streaming with Suspense + +2. **Maintainability** + - Domain-specific contexts (vs. global Redux) + - Collocated components by feature + - Type-safe throughout + - Clear server/client boundary + +3. **Developer Experience** + - Server Actions (no API routes needed) + - Built-in form handling + - Automatic revalidation + - Better error boundaries + +4. **Progressive Enhancement** + - Works without JavaScript (mostly) + - Native form validation + - Accessible by default + +## File Structure + +``` +apps/web/ +├── app/ # Pages (Server Components) +│ ├── dashboard/ +│ ├── dream-book/ +│ ├── dream-connect/ +│ ├── scorecard/ +│ ├── dream-team/ +│ ├── people/ +│ ├── build-overview/ +│ ├── health/ +│ └── labs/ +├── components/ # UI Components (Client) +│ ├── dashboard/ ✅ Started (3 components) +│ ├── dream-book/ ⏳ TODO +│ ├── dream-connect/ ⏳ TODO +│ ├── scorecard/ ⏳ TODO +│ ├── dream-team/ ⏳ TODO +│ ├── people/ ⏳ TODO +│ └── shared/ ✅ Started (1 component) +├── lib/ +│ └── contexts/ ✅ Complete (6 contexts) +└── services/ ✅ Exists (70+ server actions) +``` + +## Migration Status + +### ✅ Phase 1: Foundation (COMPLETE) +- Context architecture +- Page structure +- Root providers +- Basic components + +### 🔄 Phase 2: Components (IN PROGRESS) +- Stub all components with native HTML +- No styling (as required) +- Functional only +- Wire to contexts + +### ⏳ Phase 3: Integration (TODO) +- Connect server actions +- Form handling +- Data fetching +- Navigation + +### ⏳ Phase 4: Testing (TODO) +- Unit tests +- Integration tests +- E2E tests + +### ⏳ Phase 5: Styling (FUTURE) +- Apply Tailwind +- Responsive design +- Accessibility + +## Context API Examples + +### Using Contexts in Client Components + +```tsx +'use client'; + +import { useDreams, useGoals } from '@/lib/contexts'; + +export function MyComponent() { + const { dreams, addDream } = useDreams(); + const { weeklyGoals, toggleWeeklyGoal } = useGoals(); + + // Component logic... +} +``` + +### Server Components Don't Use Context + +```tsx +// Server Component +import { auth } from '@/lib/auth'; +import { getUserDreams } from '@/services/dreams'; + +export default async function DreamBookPage() { + const session = await auth(); + const dreams = await getUserDreams(session.user.id); + + // Pass data to client components as props + return ; +} +``` + +## Data Flow Patterns + +### Reading Data +``` +Server Component → fetch/server action → +Client Component (via props) → +Context (if needed for mutations) +``` + +### Mutating Data +``` +Client Component → User Action → +Server Action (validate + persist) → +revalidatePath → +Server Component re-fetches → +Client Component receives new props +``` + +## Next Immediate Steps + +1. **Stub Dream Book Components** (~10 components) + - DreamForm, DreamGrid, DreamCard + - YearVisionCard, DreamRadarChart + - ImageUploadSection, FirstGoalSetup + +2. **Stub Dream Connect Components** (~6 components) + - ConnectionFilters, SuggestedConnections + - ConnectionCard, ConnectDetailModal + - ConnectRequestModal, RecentConnects + +3. **Stub Scorecard Components** (~5 components) + - SummaryView, HistoryView, YearBreakdownView + - ActivityCard, ProgressCard + +4. **Stub Dream Team Components** (~7 components) + - TeamMembersSection, TeamMemberCard + - MeetingScheduleCard, MeetingAttendanceCard + - TeamMemberModal, MeetingHistoryModal + +5. **Stub People Components** (~8 components) + - CoachesPanel, UsersPanel, CoachList + - CoachCard, TeamMemberRow, TeamMetrics + - CoachDetailModal, ReplaceCoachModal + +6. **Stub Shared Components** (~5 components) + - Modal, ConfirmModal, LoadingSpinner + - ErrorBoundary, Toast + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Breaking existing functionality | Keep legacy app running during migration | +| Performance regression | Use Server Components by default | +| Type safety gaps | Strict TypeScript, no `any` types | +| Context overuse | Only use for cross-cutting concerns | +| Server action errors | Comprehensive error handling + logging | + +## Timeline Estimate + +- **Components Stubbing**: 2-3 days (80+ components) +- **Integration**: 3-5 days (wire up data + actions) +- **Testing**: 2-3 days (ensure all flows work) +- **Styling**: 5-7 days (apply Tailwind, responsive) + +**Total**: ~15-20 days for complete migration + +## Success Criteria + +- [ ] All 9 pages render without errors +- [ ] All user flows functional (even without styling) +- [ ] No TypeScript errors in new code +- [ ] Navigation works between all pages +- [ ] Contexts integrate with server actions +- [ ] Forms submit and validate correctly +- [ ] Auth works on all protected routes +- [ ] Data persists to database +- [ ] Documentation updated + +## References + +- Full plan: `FRONTEND_MIGRATION_PLAN.md` +- Architecture guide: `.claude/skills/software-architecture/` +- Migration guide: `MIGRATION_GUIDE.md` +- Monorepo setup: `README.monorepo.md` + +--- + +**Status**: Phase 1 Complete, Phase 2 In Progress +**Last Updated**: February 4, 2026 From 557d45077b5a5006f771ea339c704ae1db386bbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:32:14 +0000 Subject: [PATCH 04/12] Add architecture comparison diagram --- ARCHITECTURE_DIAGRAM.md | 327 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 ARCHITECTURE_DIAGRAM.md diff --git a/ARCHITECTURE_DIAGRAM.md b/ARCHITECTURE_DIAGRAM.md new file mode 100644 index 0000000..5c254f0 --- /dev/null +++ b/ARCHITECTURE_DIAGRAM.md @@ -0,0 +1,327 @@ +# Dreamspace Architecture: Legacy vs New + +## Legacy Architecture (src/) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (Client Only) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ React Router │────────▶│ Page Components │ │ +│ └──────────────┘ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ AppContext │ │ +│ │ (Redux-like) │ │ +│ │ │ │ +│ │ • Dreams │ │ +│ │ • Goals │ │ +│ │ • User │ │ +│ │ • Connects │ │ +│ │ • Scoring │ │ +│ │ • Team │ │ +│ │ │ │ +│ │ 30+ Actions │ │ +│ │ 315 LOC Reducer │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Custom Hooks │ │ +│ │ (32 hooks) │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Services Layer │ │ +│ │ (21 services) │ │ +│ └──────────┬──────────┘ │ +└──────────────────────────────────────┼──────────────────────┘ + │ + │ HTTP/REST + │ + ┌──────────▼──────────┐ + │ Azure Functions │ + │ (50+ functions) │ + └──────────┬──────────┘ + │ + ┌──────────▼──────────┐ + │ Cosmos DB │ + └─────────────────────┘ +``` + +## New Architecture (apps/web/) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Server (NextJS) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ App Router │────────▶│ Server Components │ │ +│ │ │ │ (Pages - RSC) │ │ +│ └──────────────┘ └──────────┬──────────┘ │ +│ │ │ +│ │ fetch data │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Server Actions │ │ +│ │ (70+ actions) │ │ +│ │ │ │ +│ │ • users/ │ │ +│ │ • dreams/ │ │ +│ │ • weeks/ │ │ +│ │ • teams/ │ │ +│ │ • scoring/ │ │ +│ │ • connects/ │ │ +│ │ │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Database Client │ │ +│ │ (@dreamspace/db) │ │ +│ └──────────┬──────────┘ │ +└──────────────────────────────────────┼──────────────────────┘ + │ + ┌──────────▼──────────┐ + │ Cosmos DB │ + └─────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Browser (Client) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Client Components (Interactive) │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Dream │ │ Goal │ │ User │ │ │ +│ │ │ Context │ │ Context │ │ Context │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Connect │ │ Team │ │ Scoring │ │ │ +│ │ │ Context │ │ Context │ │ Context │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ (Contexts only for UI state) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key Differences + +| Aspect | Legacy | New | +|--------|--------|-----| +| **Rendering** | Client-side only | Server + Client | +| **State** | Centralized Redux | Distributed Contexts | +| **Data Flow** | Client → API → DB | Server → DB → Client | +| **Routing** | React Router (client) | NextJS (server) | +| **API Layer** | Azure Functions | Server Actions | +| **Bundle Size** | Large (all client) | Small (server-first) | +| **SEO** | Poor | Excellent | +| **Performance** | Slower initial load | Faster initial load | +| **Complexity** | 30+ actions, 1 reducer | 6 contexts, focused | + +## Data Flow Comparison + +### Legacy: Client-Heavy +``` +User Action + ↓ +React Component + ↓ +Dispatch Action → Reducer → Context Update + ↓ +useEffect triggered + ↓ +Service Layer (client) + ↓ +HTTP Request → Azure Function + ↓ +Cosmos DB + ↓ +Response → State Update + ↓ +localStorage sync + ↓ +Component Re-render +``` + +### New: Server-First +``` +Page Load + ↓ +Server Component + ↓ +Server Action (direct DB access) + ↓ +Cosmos DB + ↓ +Render HTML + ↓ +Send to Client + +User Interaction + ↓ +Client Component + ↓ +Server Action (mutation) + ↓ +Cosmos DB + ↓ +revalidatePath + ↓ +Server Component re-fetches + ↓ +Render updated HTML +``` + +## Component Architecture + +### Legacy Three-Layer Pattern +``` +┌─────────────────────────────────┐ +│ DashboardLayout.jsx │ ◀─── Orchestrator +│ - State management │ +│ - Side effects │ +│ - Event handlers │ +└──────────┬──────────────────────┘ + │ + ┌──────▼─────────┐ + │ useDashboard │ ◀─── Business Logic Hook + │ Hook │ + │ - Data fetch │ + │ - Calculations │ + └──────┬─────────┘ + │ + ┌──────▼─────────┐ + │ Presentational │ ◀─── Pure UI Components + │ Components │ + │ - No state │ + │ - Props only │ + └────────────────┘ +``` + +### New Server-First Pattern +``` +┌─────────────────────────────────┐ +│ page.tsx (Server) │ ◀─── Server Component +│ - Auth check │ +│ - Data fetching │ +│ - Render │ +└──────────┬──────────────────────┘ + │ + │ Pass data as props + │ + ┌──────▼─────────┐ + │ Client │ ◀─── Client Component + │ Components │ + │ - Interactive │ + │ - Use contexts │ + │ - Event handlers│ + └──────┬─────────┘ + │ + │ Mutations + │ + ┌──────▼─────────┐ + │ Server Actions │ ◀─── Server-side logic + │ - Validation │ + │ - DB updates │ + │ - Revalidation │ + └────────────────┘ +``` + +## File Organization + +### Legacy +``` +src/ +├── pages/ +│ ├── Dashboard.jsx (thin wrapper) +│ └── dashboard/ +│ ├── DashboardLayout.jsx (orchestrator) +│ ├── DashboardHeader.jsx +│ ├── WeekGoalsWidget.jsx +│ └── week-goals/ +│ ├── index.js +│ ├── GoalItem.jsx +│ └── AddGoalForm.jsx +├── context/ +│ ├── AppContext.jsx (global state) +│ └── AuthContext.jsx +├── state/ +│ ├── appReducer.js (315 LOC) +│ └── actionTypes.js +├── hooks/ +│ ├── useDashboard.js +│ ├── useDreamActions.js +│ └── ... (32 hooks) +└── services/ + ├── apiClient.js + ├── dreamService.js + └── ... (21 services) +``` + +### New +``` +apps/web/ +├── app/ +│ ├── dashboard/ +│ │ └── page.tsx (server component) +│ ├── dream-book/ +│ │ └── page.tsx +│ └── layout.tsx (with providers) +├── components/ +│ ├── dashboard/ +│ │ ├── DashboardHeader.tsx +│ │ ├── WeekGoalsWidget.tsx +│ │ └── DashboardDreamCard.tsx +│ └── shared/ +│ └── Navigation.tsx +├── lib/ +│ └── contexts/ (6 focused contexts) +│ ├── DreamContext.tsx +│ ├── GoalContext.tsx +│ ├── UserContext.tsx +│ ├── ConnectContext.tsx +│ ├── TeamContext.tsx +│ ├── ScoringContext.tsx +│ └── AppProviders.tsx +└── services/ (70+ server actions) + ├── dreams/ + ├── users/ + ├── weeks/ + └── ... +``` + +## Migration Benefits + +1. **Performance** + - ⚡ Faster initial page load (server rendering) + - 📦 Smaller client bundle (no Redux, less JS) + - 🎯 Automatic code splitting (NextJS) + - 🔄 Parallel data fetching + +2. **Developer Experience** + - 📝 Type-safe throughout (TypeScript) + - 🎨 Clearer separation of concerns + - 🧪 Easier to test (isolated contexts) + - 📚 Better documentation + +3. **Maintainability** + - 🔍 Easier to understand (smaller contexts) + - 🔧 Easier to modify (focused responsibilities) + - 🚀 Easier to deploy (NextJS built-in) + - 📊 Better error tracking + +4. **User Experience** + - 🌐 SEO-friendly (server rendering) + - ♿ More accessible (progressive enhancement) + - 📱 Better mobile performance + - 🔒 More secure (server-side auth) + +--- + +**Migration Status**: Phase 1 Complete ✅ +**Next Phase**: Component Stubbing (80+ components) From f4a81c400c91ba107731f7f4d5bac0cc7a450a8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:47:42 +0000 Subject: [PATCH 05/12] Refactor contexts to use Immutable.js and integrate with server actions - Add immutable.js for complex state management - Refactor all 6 contexts (Dream, Goal, User, Connect, Team, Scoring) to use Records and Lists - Implement optimistic updates with rollback on server action failures - Add loadData methods to initialize contexts from server - Integrate contexts with existing server actions (saveDreams, saveCurrentWeek, etc.) - Convert immutable data back to plain objects in hooks for easy component consumption Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- apps/web/lib/contexts/ConnectContext.tsx | 150 ++++++++++++++--- apps/web/lib/contexts/DreamContext.tsx | 206 ++++++++++++++++++++--- apps/web/lib/contexts/GoalContext.tsx | 201 ++++++++++++++++++---- apps/web/lib/contexts/ScoringContext.tsx | 137 ++++++++++++--- apps/web/lib/contexts/TeamContext.tsx | 170 +++++++++++++++---- apps/web/lib/contexts/UserContext.tsx | 109 +++++++++--- apps/web/package.json | 2 + pnpm-lock.yaml | 37 +++- 8 files changed, 851 insertions(+), 161 deletions(-) diff --git a/apps/web/lib/contexts/ConnectContext.tsx b/apps/web/lib/contexts/ConnectContext.tsx index 55ff5a1..784593d 100644 --- a/apps/web/lib/contexts/ConnectContext.tsx +++ b/apps/web/lib/contexts/ConnectContext.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { List, Record } from 'immutable'; +import { saveConnect, deleteConnect } from '@/services/connects'; /** * Connect data type @@ -10,6 +12,7 @@ export type Connect = { userId: string; dreamId?: string; withWhom: string; + withWhomId: string; when?: string; notes?: string; status?: 'pending' | 'completed'; @@ -20,47 +23,140 @@ export type Connect = { updatedAt?: string; }; +/** + * Immutable Connect record + */ +const ConnectRecord = Record({ + id: '', + userId: '', + dreamId: '', + withWhom: '', + withWhomId: '', + when: '', + notes: '', + status: 'pending', + agenda: '', + proposedWeeks: [], + schedulingMethod: '', + createdAt: '', + updatedAt: '', +}); + /** * Connect context state */ type ConnectContextState = { - connects: Connect[]; + connects: List>; isLoading: boolean; - setConnects: (connects: Connect[]) => void; - addConnect: (connect: Connect) => void; - updateConnect: (id: string, updates: Partial) => void; - deleteConnect: (id: string) => void; - setLoading: (loading: boolean) => void; + userId: string | null; + loadConnects: (userId: string, initialConnects?: Connect[]) => void; + addConnect: (connect: Connect) => Promise; + updateConnect: (id: string, updates: Partial) => Promise; + deleteConnect: (id: string) => Promise; }; const ConnectContext = createContext(undefined); /** * Connect context provider - * Manages dream connect/networking state + * Manages dream connect/networking state with immutable data structures + * Loads data on initialization and saves optimistically via services */ export function ConnectProvider({ children }: { children: ReactNode }) { - const [connects, setConnects] = useState([]); + const [connects, setConnects] = useState>>(List()); const [isLoading, setIsLoading] = useState(false); + const [userId, setUserId] = useState(null); - const addConnect = (connect: Connect) => { - setConnects((prev) => [...prev, connect]); + /** + * Load connects for a user + */ + const loadConnects = (userId: string, initialConnects?: Connect[]) => { + setUserId(userId); + if (initialConnects) { + const connectRecords = List(initialConnects.map(c => ConnectRecord(c))); + setConnects(connectRecords); + } }; - const updateConnect = (id: string, updates: Partial) => { - setConnects((prev) => - prev.map((connect) => - connect.id === id ? { ...connect, ...updates } : connect - ) - ); + /** + * Add a connect with optimistic update and server persistence + */ + const addConnect = async (connect: Connect) => { + if (!userId) return; + + // Optimistic update + const connectRecord = ConnectRecord(connect); + const previousConnects = connects; + setConnects(connects.push(connectRecord)); + + try { + const result = await saveConnect({ userId, connectData: connect }); + if (result.failed) { + // Rollback on failure + setConnects(previousConnects); + console.error('Failed to save connect:', result.errors); + } + } catch (error) { + // Rollback on error + setConnects(previousConnects); + console.error('Error saving connect:', error); + } }; - const deleteConnect = (id: string) => { - setConnects((prev) => prev.filter((connect) => connect.id !== id)); + /** + * Update a connect with optimistic update and server persistence + */ + const updateConnect = async (id: string, updates: Partial) => { + if (!userId) return; + + // Optimistic update + const previousConnects = connects; + const index = connects.findIndex(c => c.get('id') === id); + if (index === -1) return; + + const updatedConnect = connects.get(index)!.merge(updates); + setConnects(connects.set(index, updatedConnect)); + + try { + const connectData = updatedConnect.toObject(); + const result = await saveConnect({ userId, connectData }); + if (result.failed) { + // Rollback on failure + setConnects(previousConnects); + console.error('Failed to update connect:', result.errors); + } + } catch (error) { + // Rollback on error + setConnects(previousConnects); + console.error('Error updating connect:', error); + } }; - const setLoading = (loading: boolean) => { - setIsLoading(loading); + /** + * Delete a connect with optimistic update and server persistence + */ + const deleteConnectAction = async (id: string) => { + if (!userId) return; + + // Optimistic update + const previousConnects = connects; + const index = connects.findIndex(c => c.get('id') === id); + if (index === -1) return; + + setConnects(connects.delete(index)); + + try { + const result = await deleteConnect({ userId, connectId: id }); + if (result.failed) { + // Rollback on failure + setConnects(previousConnects); + console.error('Failed to delete connect:', result.errors); + } + } catch (error) { + // Rollback on error + setConnects(previousConnects); + console.error('Error deleting connect:', error); + } }; return ( @@ -68,11 +164,11 @@ export function ConnectProvider({ children }: { children: ReactNode }) { value={{ connects, isLoading, - setConnects, + userId, + loadConnects, addConnect, updateConnect, - deleteConnect, - setLoading, + deleteConnect: deleteConnectAction, }} > {children} @@ -82,11 +178,17 @@ export function ConnectProvider({ children }: { children: ReactNode }) { /** * Hook to use connect context + * Returns connects as plain JavaScript array for easier consumption */ export function useConnects() { const context = useContext(ConnectContext); if (context === undefined) { throw new Error('useConnects must be used within a ConnectProvider'); } - return context; + + // Convert immutable List to plain array for component consumption + return { + ...context, + connects: context.connects.toArray().map(c => c.toObject()), + }; } diff --git a/apps/web/lib/contexts/DreamContext.tsx b/apps/web/lib/contexts/DreamContext.tsx index 630b791..0d0909c 100644 --- a/apps/web/lib/contexts/DreamContext.tsx +++ b/apps/web/lib/contexts/DreamContext.tsx @@ -1,6 +1,8 @@ 'use client'; -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { List, Record } from 'immutable'; +import { saveDreams, saveYearVision } from '@/services/dreams'; /** * Dream data type @@ -56,53 +58,197 @@ export type HistoryEntry = { timestamp: string; }; +/** + * Immutable Dream record + */ +const DreamRecord = Record({ + id: '', + title: '', + category: '', + description: '', + progress: 0, + image: '', + goals: [], + notes: [], + coachNotes: [], + history: [], + createdAt: '', + updatedAt: '', +}); + /** * Dream context state */ type DreamContextState = { - dreams: Dream[]; + dreams: List>; yearVision: string; isLoading: boolean; - setDreams: (dreams: Dream[]) => void; - setYearVision: (vision: string) => void; - addDream: (dream: Dream) => void; - updateDream: (id: string, updates: Partial) => void; - deleteDream: (id: string) => void; - reorderDreams: (dreams: Dream[]) => void; - setLoading: (loading: boolean) => void; + userId: string | null; + loadDreams: (userId: string, initialDreams?: Dream[], initialVision?: string) => void; + setYearVision: (vision: string) => Promise; + addDream: (dream: Dream) => Promise; + updateDream: (id: string, updates: Partial) => Promise; + deleteDream: (id: string) => Promise; + reorderDreams: (dreams: Dream[]) => Promise; }; const DreamContext = createContext(undefined); /** * Dream context provider - * Manages dream book state (dreams, year vision) + * Manages dream book state with immutable data structures + * Loads data on initialization and saves optimistically via services */ export function DreamProvider({ children }: { children: ReactNode }) { - const [dreams, setDreams] = useState([]); - const [yearVision, setYearVision] = useState(''); + const [dreams, setDreams] = useState>>(List()); + const [yearVision, setYearVisionState] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [userId, setUserId] = useState(null); - const addDream = (dream: Dream) => { - setDreams((prev) => [...prev, dream]); + /** + * Load dreams data for a user + */ + const loadDreams = (userId: string, initialDreams?: Dream[], initialVision?: string) => { + setUserId(userId); + if (initialDreams) { + const dreamRecords = List(initialDreams.map(d => DreamRecord(d))); + setDreams(dreamRecords); + } + if (initialVision !== undefined) { + setYearVisionState(initialVision); + } }; - const updateDream = (id: string, updates: Partial) => { - setDreams((prev) => - prev.map((dream) => (dream.id === id ? { ...dream, ...updates } : dream)) - ); + /** + * Update year vision with optimistic update and server persistence + */ + const setYearVision = async (vision: string) => { + if (!userId) return; + + // Optimistic update + const previousVision = yearVision; + setYearVisionState(vision); + + try { + const result = await saveYearVision({ userId, yearVision: vision }); + if (result.failed) { + // Rollback on failure + setYearVisionState(previousVision); + console.error('Failed to save year vision:', result.errors); + } + } catch (error) { + // Rollback on error + setYearVisionState(previousVision); + console.error('Error saving year vision:', error); + } }; - const deleteDream = (id: string) => { - setDreams((prev) => prev.filter((dream) => dream.id !== id)); + /** + * Add a dream with optimistic update and server persistence + */ + const addDream = async (dream: Dream) => { + if (!userId) return; + + // Optimistic update + const dreamRecord = DreamRecord(dream); + const previousDreams = dreams; + setDreams(dreams.push(dreamRecord)); + + try { + const dreamsArray = dreams.push(dreamRecord).toArray().map(d => d.toObject()); + const result = await saveDreams({ userId, dreams: dreamsArray }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error('Failed to save dream:', result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error('Error saving dream:', error); + } }; - const reorderDreams = (newDreams: Dream[]) => { - setDreams(newDreams); + /** + * Update a dream with optimistic update and server persistence + */ + const updateDream = async (id: string, updates: Partial) => { + if (!userId) return; + + // Optimistic update + const previousDreams = dreams; + const index = dreams.findIndex(d => d.get('id') === id); + if (index === -1) return; + + const updatedDream = dreams.get(index)!.merge(updates); + setDreams(dreams.set(index, updatedDream)); + + try { + const dreamsArray = dreams.set(index, updatedDream).toArray().map(d => d.toObject()); + const result = await saveDreams({ userId, dreams: dreamsArray }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error('Failed to update dream:', result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error('Error updating dream:', error); + } }; - const setLoading = (loading: boolean) => { - setIsLoading(loading); + /** + * Delete a dream with optimistic update and server persistence + */ + const deleteDream = async (id: string) => { + if (!userId) return; + + // Optimistic update + const previousDreams = dreams; + const index = dreams.findIndex(d => d.get('id') === id); + if (index === -1) return; + + setDreams(dreams.delete(index)); + + try { + const dreamsArray = dreams.delete(index).toArray().map(d => d.toObject()); + const result = await saveDreams({ userId, dreams: dreamsArray }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error('Failed to delete dream:', result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error('Error deleting dream:', error); + } + }; + + /** + * Reorder dreams with optimistic update and server persistence + */ + const reorderDreams = async (newDreams: Dream[]) => { + if (!userId) return; + + // Optimistic update + const previousDreams = dreams; + const dreamRecords = List(newDreams.map(d => DreamRecord(d))); + setDreams(dreamRecords); + + try { + const result = await saveDreams({ userId, dreams: newDreams }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error('Failed to reorder dreams:', result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error('Error reordering dreams:', error); + } }; return ( @@ -111,13 +257,13 @@ export function DreamProvider({ children }: { children: ReactNode }) { dreams, yearVision, isLoading, - setDreams, + userId, + loadDreams, setYearVision, addDream, updateDream, deleteDream, reorderDreams, - setLoading, }} > {children} @@ -127,11 +273,17 @@ export function DreamProvider({ children }: { children: ReactNode }) { /** * Hook to use dream context + * Returns dreams as plain JavaScript array for easier consumption */ export function useDreams() { const context = useContext(DreamContext); if (context === undefined) { throw new Error('useDreams must be used within a DreamProvider'); } - return context; + + // Convert immutable List to plain array for component consumption + return { + ...context, + dreams: context.dreams.toArray().map(d => d.toObject()), + }; } diff --git a/apps/web/lib/contexts/GoalContext.tsx b/apps/web/lib/contexts/GoalContext.tsx index cd80323..b468e2c 100644 --- a/apps/web/lib/contexts/GoalContext.tsx +++ b/apps/web/lib/contexts/GoalContext.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { List, Record } from 'immutable'; +import { saveCurrentWeek } from '@/services/weeks'; /** * Weekly goal data type @@ -18,60 +20,184 @@ export type WeeklyGoal = { weekLog?: Record; }; +/** + * Immutable WeeklyGoal record + */ +const WeeklyGoalRecord = Record({ + id: '', + title: '', + dreamId: '', + goalId: '', + completed: false, + completedAt: '', + weekId: '', + recurrence: 'weekly', + active: true, + weekLog: {}, +}); + /** * Goal context state */ type GoalContextState = { - weeklyGoals: WeeklyGoal[]; + weeklyGoals: List>; isLoading: boolean; - setWeeklyGoals: (goals: WeeklyGoal[]) => void; - addWeeklyGoal: (goal: WeeklyGoal) => void; - updateWeeklyGoal: (id: string, updates: Partial) => void; - deleteWeeklyGoal: (id: string) => void; - toggleWeeklyGoal: (id: string) => void; - setLoading: (loading: boolean) => void; + userId: string | null; + currentWeekId: string | null; + loadWeeklyGoals: (userId: string, weekId: string, initialGoals?: WeeklyGoal[]) => void; + addWeeklyGoal: (goal: WeeklyGoal) => Promise; + updateWeeklyGoal: (id: string, updates: Partial) => Promise; + deleteWeeklyGoal: (id: string) => Promise; + toggleWeeklyGoal: (id: string) => Promise; }; const GoalContext = createContext(undefined); /** * Goal context provider - * Manages weekly goals state + * Manages weekly goals state with immutable data structures + * Loads data on initialization and saves optimistically via services */ export function GoalProvider({ children }: { children: ReactNode }) { - const [weeklyGoals, setWeeklyGoals] = useState([]); + const [weeklyGoals, setWeeklyGoals] = useState>>(List()); const [isLoading, setIsLoading] = useState(false); + const [userId, setUserId] = useState(null); + const [currentWeekId, setCurrentWeekId] = useState(null); - const addWeeklyGoal = (goal: WeeklyGoal) => { - setWeeklyGoals((prev) => [...prev, goal]); + /** + * Load weekly goals for a user and week + */ + const loadWeeklyGoals = (userId: string, weekId: string, initialGoals?: WeeklyGoal[]) => { + setUserId(userId); + setCurrentWeekId(weekId); + if (initialGoals) { + const goalRecords = List(initialGoals.map(g => WeeklyGoalRecord(g))); + setWeeklyGoals(goalRecords); + } }; - const updateWeeklyGoal = (id: string, updates: Partial) => { - setWeeklyGoals((prev) => - prev.map((goal) => (goal.id === id ? { ...goal, ...updates } : goal)) - ); + /** + * Save goals to server + */ + const saveGoals = async (goals: List>) => { + if (!userId || !currentWeekId) return; + + const goalsArray = goals.toArray().map(g => g.toObject()); + const result = await saveCurrentWeek({ userId, weekId: currentWeekId, goals: goalsArray }); + + if (result.failed) { + console.error('Failed to save weekly goals:', result.errors); + return false; + } + return true; }; - const deleteWeeklyGoal = (id: string) => { - setWeeklyGoals((prev) => prev.filter((goal) => goal.id !== id)); + /** + * Add a weekly goal with optimistic update and server persistence + */ + const addWeeklyGoal = async (goal: WeeklyGoal) => { + if (!userId || !currentWeekId) return; + + // Optimistic update + const goalRecord = WeeklyGoalRecord(goal); + const previousGoals = weeklyGoals; + setWeeklyGoals(weeklyGoals.push(goalRecord)); + + try { + const success = await saveGoals(weeklyGoals.push(goalRecord)); + if (!success) { + // Rollback on failure + setWeeklyGoals(previousGoals); + } + } catch (error) { + // Rollback on error + setWeeklyGoals(previousGoals); + console.error('Error saving weekly goal:', error); + } }; - const toggleWeeklyGoal = (id: string) => { - setWeeklyGoals((prev) => - prev.map((goal) => - goal.id === id - ? { - ...goal, - completed: !goal.completed, - completedAt: !goal.completed ? new Date().toISOString() : undefined, - } - : goal - ) - ); + /** + * Update a weekly goal with optimistic update and server persistence + */ + const updateWeeklyGoal = async (id: string, updates: Partial) => { + if (!userId || !currentWeekId) return; + + // Optimistic update + const previousGoals = weeklyGoals; + const index = weeklyGoals.findIndex(g => g.get('id') === id); + if (index === -1) return; + + const updatedGoal = weeklyGoals.get(index)!.merge(updates); + setWeeklyGoals(weeklyGoals.set(index, updatedGoal)); + + try { + const success = await saveGoals(weeklyGoals.set(index, updatedGoal)); + if (!success) { + // Rollback on failure + setWeeklyGoals(previousGoals); + } + } catch (error) { + // Rollback on error + setWeeklyGoals(previousGoals); + console.error('Error updating weekly goal:', error); + } }; - const setLoading = (loading: boolean) => { - setIsLoading(loading); + /** + * Delete a weekly goal with optimistic update and server persistence + */ + const deleteWeeklyGoal = async (id: string) => { + if (!userId || !currentWeekId) return; + + // Optimistic update + const previousGoals = weeklyGoals; + const index = weeklyGoals.findIndex(g => g.get('id') === id); + if (index === -1) return; + + setWeeklyGoals(weeklyGoals.delete(index)); + + try { + const success = await saveGoals(weeklyGoals.delete(index)); + if (!success) { + // Rollback on failure + setWeeklyGoals(previousGoals); + } + } catch (error) { + // Rollback on error + setWeeklyGoals(previousGoals); + console.error('Error deleting weekly goal:', error); + } + }; + + /** + * Toggle a weekly goal completion with optimistic update and server persistence + */ + const toggleWeeklyGoal = async (id: string) => { + if (!userId || !currentWeekId) return; + + // Optimistic update + const previousGoals = weeklyGoals; + const index = weeklyGoals.findIndex(g => g.get('id') === id); + if (index === -1) return; + + const currentGoal = weeklyGoals.get(index)!; + const updatedGoal = currentGoal.merge({ + completed: !currentGoal.get('completed'), + completedAt: !currentGoal.get('completed') ? new Date().toISOString() : undefined, + }); + setWeeklyGoals(weeklyGoals.set(index, updatedGoal)); + + try { + const success = await saveGoals(weeklyGoals.set(index, updatedGoal)); + if (!success) { + // Rollback on failure + setWeeklyGoals(previousGoals); + } + } catch (error) { + // Rollback on error + setWeeklyGoals(previousGoals); + console.error('Error toggling weekly goal:', error); + } }; return ( @@ -79,12 +205,13 @@ export function GoalProvider({ children }: { children: ReactNode }) { value={{ weeklyGoals, isLoading, - setWeeklyGoals, + userId, + currentWeekId, + loadWeeklyGoals, addWeeklyGoal, updateWeeklyGoal, deleteWeeklyGoal, toggleWeeklyGoal, - setLoading, }} > {children} @@ -94,11 +221,17 @@ export function GoalProvider({ children }: { children: ReactNode }) { /** * Hook to use goal context + * Returns goals as plain JavaScript array for easier consumption */ export function useGoals() { const context = useContext(GoalContext); if (context === undefined) { throw new Error('useGoals must be used within a GoalProvider'); } - return context; + + // Convert immutable List to plain array for component consumption + return { + ...context, + weeklyGoals: context.weeklyGoals.toArray().map(g => g.toObject()), + }; } diff --git a/apps/web/lib/contexts/ScoringContext.tsx b/apps/web/lib/contexts/ScoringContext.tsx index 117cc61..b09cbc3 100644 --- a/apps/web/lib/contexts/ScoringContext.tsx +++ b/apps/web/lib/contexts/ScoringContext.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { List, Record } from 'immutable'; +import { saveScoring } from '@/services/scoring'; /** * Scoring entry data type @@ -12,41 +14,133 @@ export type ScoringEntry = { activity: string; points: number; category?: string; + source?: string; + dreamId?: string; + weekId?: string; + connectId?: string; + createdAt?: string; }; +/** + * Immutable ScoringEntry record + */ +const ScoringEntryRecord = Record({ + id: '', + date: '', + score: 0, + activity: '', + points: 0, + category: '', + source: '', + dreamId: '', + weekId: '', + connectId: '', + createdAt: '', +}); + /** * Scoring context state */ type ScoringContextState = { - scoringHistory: ScoringEntry[]; - allYearsScoring: ScoringEntry[]; + scoringHistory: List>; + allYearsScoring: List>; allTimeScore: number; isLoading: boolean; - setScoringHistory: (history: ScoringEntry[]) => void; - setAllYearsScoring: (scoring: ScoringEntry[]) => void; - setAllTimeScore: (score: number) => void; - addScoringEntry: (entry: ScoringEntry) => void; - setLoading: (loading: boolean) => void; + userId: string | null; + loadScoringData: ( + userId: string, + initialHistory?: ScoringEntry[], + initialAllYears?: ScoringEntry[], + initialAllTimeScore?: number + ) => void; + addScoringEntry: (entry: ScoringEntry) => Promise; }; const ScoringContext = createContext(undefined); /** * Scoring context provider - * Manages scorecard and activity scoring state + * Manages scorecard and activity scoring state with immutable data structures + * Loads data on initialization and saves optimistically via services */ export function ScoringProvider({ children }: { children: ReactNode }) { - const [scoringHistory, setScoringHistory] = useState([]); - const [allYearsScoring, setAllYearsScoring] = useState([]); + const [scoringHistory, setScoringHistory] = useState>>(List()); + const [allYearsScoring, setAllYearsScoring] = useState>>(List()); const [allTimeScore, setAllTimeScore] = useState(0); const [isLoading, setIsLoading] = useState(false); + const [userId, setUserId] = useState(null); - const addScoringEntry = (entry: ScoringEntry) => { - setScoringHistory((prev) => [entry, ...prev]); + /** + * Load scoring data for a user + */ + const loadScoringData = ( + userId: string, + initialHistory?: ScoringEntry[], + initialAllYears?: ScoringEntry[], + initialAllTimeScore?: number + ) => { + setUserId(userId); + if (initialHistory) { + const historyRecords = List(initialHistory.map(e => ScoringEntryRecord(e))); + setScoringHistory(historyRecords); + } + if (initialAllYears) { + const allYearsRecords = List(initialAllYears.map(e => ScoringEntryRecord(e))); + setAllYearsScoring(allYearsRecords); + } + if (initialAllTimeScore !== undefined) { + setAllTimeScore(initialAllTimeScore); + } }; - const setLoading = (loading: boolean) => { - setIsLoading(loading); + /** + * Add a scoring entry with optimistic update and server persistence + */ + const addScoringEntry = async (entry: ScoringEntry) => { + if (!userId) return; + + // Optimistic update + const entryRecord = ScoringEntryRecord(entry); + const previousHistory = scoringHistory; + const previousAllYears = allYearsScoring; + const previousAllTimeScore = allTimeScore; + + setScoringHistory(scoringHistory.unshift(entryRecord)); + setAllYearsScoring(allYearsScoring.unshift(entryRecord)); + setAllTimeScore(allTimeScore + entry.points); + + try { + const year = new Date(entry.date).getFullYear(); + const result = await saveScoring({ + userId, + year, + entry: { + id: entry.id, + date: entry.date, + source: entry.source || 'manual', + dreamId: entry.dreamId, + weekId: entry.weekId, + connectId: entry.connectId, + points: entry.points, + activity: entry.activity, + createdAt: entry.createdAt, + }, + }); + + if (result.failed) { + // Rollback on failure + setScoringHistory(previousHistory); + setAllYearsScoring(previousAllYears); + setAllTimeScore(previousAllTimeScore); + console.error('Failed to save scoring entry:', result.errors); + } + } catch (error) { + // Rollback on error + setScoringHistory(previousHistory); + setAllYearsScoring(previousAllYears); + setAllTimeScore(previousAllTimeScore); + console.error('Error saving scoring entry:', error); + } }; return ( @@ -56,11 +150,9 @@ export function ScoringProvider({ children }: { children: ReactNode }) { allYearsScoring, allTimeScore, isLoading, - setScoringHistory, - setAllYearsScoring, - setAllTimeScore, + userId, + loadScoringData, addScoringEntry, - setLoading, }} > {children} @@ -70,11 +162,18 @@ export function ScoringProvider({ children }: { children: ReactNode }) { /** * Hook to use scoring context + * Returns data as plain JavaScript arrays for easier consumption */ export function useScoring() { const context = useContext(ScoringContext); if (context === undefined) { throw new Error('useScoring must be used within a ScoringProvider'); } - return context; + + // Convert immutable Lists to plain arrays for component consumption + return { + ...context, + scoringHistory: context.scoringHistory.toArray().map(e => e.toObject()), + allYearsScoring: context.allYearsScoring.toArray().map(e => e.toObject()), + }; } diff --git a/apps/web/lib/contexts/TeamContext.tsx b/apps/web/lib/contexts/TeamContext.tsx index 76842ba..d31e721 100644 --- a/apps/web/lib/contexts/TeamContext.tsx +++ b/apps/web/lib/contexts/TeamContext.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { List, Record } from 'immutable'; +import { updateTeamInfo, saveMeetingAttendance } from '@/services/teams'; /** * Team member data type @@ -37,52 +39,156 @@ export type TeamInfo = { members?: TeamMember[]; }; +/** + * Immutable records + */ +const TeamInfoRecord = Record({ + id: '', + name: '', + mission: '', + coachId: '', + members: [], +}); + +const MeetingRecord = Record({ + id: '', + date: '', + attendees: [], + notes: '', + teamId: '', +}); + /** * Team context state */ type TeamContextState = { - teamInfo: TeamInfo | null; - meetings: Meeting[]; + teamInfo: Record | null; + meetings: List>; isLoading: boolean; - setTeamInfo: (info: TeamInfo | null) => void; - updateTeamInfo: (updates: Partial) => void; - setMeetings: (meetings: Meeting[]) => void; - addMeeting: (meeting: Meeting) => void; - updateMeeting: (id: string, updates: Partial) => void; - setLoading: (loading: boolean) => void; + userId: string | null; + loadTeamData: (userId: string, teamInfo?: TeamInfo, meetings?: Meeting[]) => void; + updateTeamInfo: (updates: Partial) => Promise; + addMeeting: (meeting: Meeting) => Promise; + updateMeeting: (id: string, updates: Partial) => Promise; }; const TeamContext = createContext(undefined); /** * Team context provider - * Manages team collaboration state + * Manages team collaboration state with immutable data structures + * Loads data on initialization and saves optimistically via services */ export function TeamProvider({ children }: { children: ReactNode }) { - const [teamInfo, setTeamInfo] = useState(null); - const [meetings, setMeetings] = useState([]); + const [teamInfo, setTeamInfo] = useState | null>(null); + const [meetings, setMeetings] = useState>>(List()); const [isLoading, setIsLoading] = useState(false); + const [userId, setUserId] = useState(null); - const updateTeamInfo = (updates: Partial) => { - if (teamInfo) { - setTeamInfo({ ...teamInfo, ...updates }); + /** + * Load team data for a user + */ + const loadTeamData = (userId: string, initialTeamInfo?: TeamInfo, initialMeetings?: Meeting[]) => { + setUserId(userId); + if (initialTeamInfo) { + setTeamInfo(TeamInfoRecord(initialTeamInfo)); + } + if (initialMeetings) { + const meetingRecords = List(initialMeetings.map(m => MeetingRecord(m))); + setMeetings(meetingRecords); } }; - const addMeeting = (meeting: Meeting) => { - setMeetings((prev) => [...prev, meeting]); + /** + * Update team info with optimistic update and server persistence + */ + const updateTeamInfoAction = async (updates: Partial) => { + if (!teamInfo || !userId) return; + + // Optimistic update + const previousTeamInfo = teamInfo; + const updatedTeamInfo = teamInfo.merge(updates); + setTeamInfo(updatedTeamInfo); + + try { + const result = await updateTeamInfo({ + managerId: userId, + ...updates, + }); + if (result.failed) { + // Rollback on failure + setTeamInfo(previousTeamInfo); + console.error('Failed to update team info:', result.errors); + } + } catch (error) { + // Rollback on error + setTeamInfo(previousTeamInfo); + console.error('Error updating team info:', error); + } }; - const updateMeeting = (id: string, updates: Partial) => { - setMeetings((prev) => - prev.map((meeting) => - meeting.id === id ? { ...meeting, ...updates } : meeting - ) - ); + /** + * Add a meeting with optimistic update and server persistence + */ + const addMeeting = async (meeting: Meeting) => { + if (!userId) return; + + // Optimistic update + const meetingRecord = MeetingRecord(meeting); + const previousMeetings = meetings; + setMeetings(meetings.push(meetingRecord)); + + try { + const result = await saveMeetingAttendance({ + userId, + date: meeting.date, + attendees: meeting.attendees, + notes: meeting.notes, + }); + if (result.failed) { + // Rollback on failure + setMeetings(previousMeetings); + console.error('Failed to save meeting:', result.errors); + } + } catch (error) { + // Rollback on error + setMeetings(previousMeetings); + console.error('Error saving meeting:', error); + } }; - const setLoading = (loading: boolean) => { - setIsLoading(loading); + /** + * Update a meeting with optimistic update and server persistence + */ + const updateMeeting = async (id: string, updates: Partial) => { + if (!userId) return; + + // Optimistic update + const previousMeetings = meetings; + const index = meetings.findIndex(m => m.get('id') === id); + if (index === -1) return; + + const updatedMeeting = meetings.get(index)!.merge(updates); + setMeetings(meetings.set(index, updatedMeeting)); + + try { + const meetingData = updatedMeeting.toObject(); + const result = await saveMeetingAttendance({ + userId, + date: meetingData.date, + attendees: meetingData.attendees, + notes: meetingData.notes, + }); + if (result.failed) { + // Rollback on failure + setMeetings(previousMeetings); + console.error('Failed to update meeting:', result.errors); + } + } catch (error) { + // Rollback on error + setMeetings(previousMeetings); + console.error('Error updating meeting:', error); + } }; return ( @@ -91,12 +197,11 @@ export function TeamProvider({ children }: { children: ReactNode }) { teamInfo, meetings, isLoading, - setTeamInfo, - updateTeamInfo, - setMeetings, + userId, + loadTeamData, + updateTeamInfo: updateTeamInfoAction, addMeeting, updateMeeting, - setLoading, }} > {children} @@ -106,11 +211,18 @@ export function TeamProvider({ children }: { children: ReactNode }) { /** * Hook to use team context + * Returns data as plain JavaScript objects for easier consumption */ export function useTeam() { const context = useContext(TeamContext); if (context === undefined) { throw new Error('useTeam must be used within a TeamProvider'); } - return context; + + // Convert immutable data to plain objects for component consumption + return { + ...context, + teamInfo: context.teamInfo ? context.teamInfo.toObject() : null, + meetings: context.meetings.toArray().map(m => m.toObject()), + }; } diff --git a/apps/web/lib/contexts/UserContext.tsx b/apps/web/lib/contexts/UserContext.tsx index b25e504..19311f7 100644 --- a/apps/web/lib/contexts/UserContext.tsx +++ b/apps/web/lib/contexts/UserContext.tsx @@ -1,6 +1,8 @@ 'use client'; import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { Record } from 'immutable'; +import { saveUserData } from '@/services/users'; /** * User data type @@ -22,42 +24,106 @@ export type User = { dreamCategories?: string[]; }; +/** + * Immutable User record + */ +const UserRecord = Record({ + id: '', + email: '', + name: '', + displayName: '', + office: '', + avatar: '', + jobTitle: '', + department: '', + role: 'user', + isCoach: false, + score: 0, + dreamsCount: 0, + connectsCount: 0, + dreamCategories: [], +}); + /** * User context state */ type UserContextState = { - currentUser: User | null; + currentUser: Record | null; isLoading: boolean; - setCurrentUser: (user: User | null) => void; - updateUserProfile: (updates: Partial) => void; - updateScore: (score: number) => void; - setLoading: (loading: boolean) => void; + loadUser: (user: User) => void; + updateUserProfile: (updates: Partial) => Promise; + updateScore: (score: number) => Promise; }; const UserContext = createContext(undefined); /** * User context provider - * Manages current user profile state + * Manages current user profile state with immutable data structures + * Loads data on initialization and saves optimistically via services */ export function UserProvider({ children }: { children: ReactNode }) { - const [currentUser, setCurrentUser] = useState(null); + const [currentUser, setCurrentUser] = useState | null>(null); const [isLoading, setIsLoading] = useState(false); - const updateUserProfile = (updates: Partial) => { - if (currentUser) { - setCurrentUser({ ...currentUser, ...updates }); - } + /** + * Load user data + */ + const loadUser = (user: User) => { + const userRecord = UserRecord(user); + setCurrentUser(userRecord); }; - const updateScore = (score: number) => { - if (currentUser) { - setCurrentUser({ ...currentUser, score }); + /** + * Update user profile with optimistic update and server persistence + */ + const updateUserProfile = async (updates: Partial) => { + if (!currentUser) return; + + // Optimistic update + const previousUser = currentUser; + const updatedUser = currentUser.merge(updates); + setCurrentUser(updatedUser); + + try { + const userId = currentUser.get('id'); + const result = await saveUserData({ userId, ...updatedUser.toObject() }); + if (result.failed) { + // Rollback on failure + setCurrentUser(previousUser); + console.error('Failed to update user profile:', result.errors); + } + } catch (error) { + // Rollback on error + setCurrentUser(previousUser); + console.error('Error updating user profile:', error); } }; - const setLoading = (loading: boolean) => { - setIsLoading(loading); + /** + * Update score with optimistic update and server persistence + */ + const updateScore = async (score: number) => { + if (!currentUser) return; + + // Optimistic update + const previousUser = currentUser; + const updatedUser = currentUser.set('score', score); + setCurrentUser(updatedUser); + + try { + const userId = currentUser.get('id'); + const result = await saveUserData({ userId, score }); + if (result.failed) { + // Rollback on failure + setCurrentUser(previousUser); + console.error('Failed to update score:', result.errors); + } + } catch (error) { + // Rollback on error + setCurrentUser(previousUser); + console.error('Error updating score:', error); + } }; return ( @@ -65,10 +131,9 @@ export function UserProvider({ children }: { children: ReactNode }) { value={{ currentUser, isLoading, - setCurrentUser, + loadUser, updateUserProfile, updateScore, - setLoading, }} > {children} @@ -78,11 +143,17 @@ export function UserProvider({ children }: { children: ReactNode }) { /** * Hook to use user context + * Returns user as plain JavaScript object for easier consumption */ export function useUser() { const context = useContext(UserContext); if (context === undefined) { throw new Error('useUser must be used within a UserProvider'); } - return context; + + // Convert immutable Record to plain object for component consumption + return { + ...context, + currentUser: context.currentUser ? context.currentUser.toObject() : null, + }; } diff --git a/apps/web/package.json b/apps/web/package.json index 2f22411..0245c34 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@dreamspace/shared": "workspace:*", "@microsoft/applicationinsights-web": "^3.0.5", "canvas-confetti": "^1.9.4", + "immutable": "^5.1.4", "lucide-react": "^0.451.0", "next": "15.2.9", "next-auth": "^5.0.0-beta.25", @@ -31,6 +32,7 @@ "@eslint/js": "^9.39.2", "@tailwindcss/line-clamp": "^0.4.4", "@types/canvas-confetti": "^1.6.4", + "@types/immutable": "^3.8.7", "@types/node": "^20.10.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b9c5e8..a0d5bf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: canvas-confetti: specifier: ^1.9.4 version: 1.9.4 + immutable: + specifier: ^5.1.4 + version: 5.1.4 lucide-react: specifier: ^0.451.0 version: 0.451.0(react@18.3.1) @@ -81,6 +84,9 @@ importers: '@types/canvas-confetti': specifier: ^1.6.4 version: 1.9.0 + '@types/immutable': + specifier: ^3.8.7 + version: 3.8.7 '@types/node': specifier: ^20.10.0 version: 20.19.30 @@ -885,6 +891,10 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/immutable@3.8.7': + resolution: {integrity: sha512-nsHFDX48Tl3RaP4BF47HHe5njx40Pcp+0a8CIqzJata80Fp7JzkcuGB7UhZBGjH9aA1fMEahIqvPQQNmro5YLg==} + deprecated: This is a stub types definition for Facebook's Immutable (https://github.com/facebook/immutable-js). Facebook's Immutable provides its own type definitions, so you don't need @types/immutable installed! + '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -1646,7 +1656,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -1710,6 +1720,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3336,6 +3349,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/immutable@3.8.7': + dependencies: + immutable: 5.1.4 + '@types/json5@0.0.29': {} '@types/node@20.19.30': @@ -3976,8 +3993,8 @@ snapshots: '@typescript-eslint/parser': 8.54.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -3996,7 +4013,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -4007,22 +4024,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.54.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4033,7 +4050,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4361,6 +4378,8 @@ snapshots: ignore@7.0.5: {} + immutable@5.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 From 2b36bdf7b718c83fcc2af3c9ca993d8375818ad9 Mon Sep 17 00:00:00 2001 From: Matt Lynam Date: Fri, 6 Feb 2026 09:04:22 -0700 Subject: [PATCH 06/12] wip: dream context patterning --- apps/web/lib/contexts/DreamContext.tsx | 289 ------------------ apps/web/lib/contexts/dreams/DreamContext.tsx | 231 ++++++++++++++ apps/web/lib/contexts/dreams/index.ts | 3 + apps/web/lib/contexts/dreams/types.ts | 53 ++++ apps/web/lib/contexts/index.ts | 2 +- apps/web/services/admin/getCoachingAlerts.ts | 21 +- apps/web/services/prompts/getPrompts.ts | 18 +- 7 files changed, 312 insertions(+), 305 deletions(-) delete mode 100644 apps/web/lib/contexts/DreamContext.tsx create mode 100644 apps/web/lib/contexts/dreams/DreamContext.tsx create mode 100644 apps/web/lib/contexts/dreams/index.ts create mode 100644 apps/web/lib/contexts/dreams/types.ts diff --git a/apps/web/lib/contexts/DreamContext.tsx b/apps/web/lib/contexts/DreamContext.tsx deleted file mode 100644 index 0d0909c..0000000 --- a/apps/web/lib/contexts/DreamContext.tsx +++ /dev/null @@ -1,289 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { List, Record } from 'immutable'; -import { saveDreams, saveYearVision } from '@/services/dreams'; - -/** - * Dream data type - */ -export type Dream = { - id: string; - title: string; - category: string; - description?: string; - progress: number; - image?: string; - goals?: Goal[]; - notes?: Note[]; - coachNotes?: CoachNote[]; - history?: HistoryEntry[]; - createdAt?: string; - updatedAt?: string; -}; - -export type Goal = { - id: string; - dreamId: string; - title: string; - description?: string; - type: 'consistency' | 'deadline'; - recurrence?: 'weekly' | 'once'; - targetWeeks?: number; - targetDate?: string; - startDate?: string; - weekId?: string; - active: boolean; - completed: boolean; - completedAt?: string; -}; - -export type Note = { - id: string; - text: string; - createdAt: string; -}; - -export type CoachNote = { - id: string; - text: string; - sender: string; - createdAt: string; -}; - -export type HistoryEntry = { - id: string; - action: string; - details: string; - timestamp: string; -}; - -/** - * Immutable Dream record - */ -const DreamRecord = Record({ - id: '', - title: '', - category: '', - description: '', - progress: 0, - image: '', - goals: [], - notes: [], - coachNotes: [], - history: [], - createdAt: '', - updatedAt: '', -}); - -/** - * Dream context state - */ -type DreamContextState = { - dreams: List>; - yearVision: string; - isLoading: boolean; - userId: string | null; - loadDreams: (userId: string, initialDreams?: Dream[], initialVision?: string) => void; - setYearVision: (vision: string) => Promise; - addDream: (dream: Dream) => Promise; - updateDream: (id: string, updates: Partial) => Promise; - deleteDream: (id: string) => Promise; - reorderDreams: (dreams: Dream[]) => Promise; -}; - -const DreamContext = createContext(undefined); - -/** - * Dream context provider - * Manages dream book state with immutable data structures - * Loads data on initialization and saves optimistically via services - */ -export function DreamProvider({ children }: { children: ReactNode }) { - const [dreams, setDreams] = useState>>(List()); - const [yearVision, setYearVisionState] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [userId, setUserId] = useState(null); - - /** - * Load dreams data for a user - */ - const loadDreams = (userId: string, initialDreams?: Dream[], initialVision?: string) => { - setUserId(userId); - if (initialDreams) { - const dreamRecords = List(initialDreams.map(d => DreamRecord(d))); - setDreams(dreamRecords); - } - if (initialVision !== undefined) { - setYearVisionState(initialVision); - } - }; - - /** - * Update year vision with optimistic update and server persistence - */ - const setYearVision = async (vision: string) => { - if (!userId) return; - - // Optimistic update - const previousVision = yearVision; - setYearVisionState(vision); - - try { - const result = await saveYearVision({ userId, yearVision: vision }); - if (result.failed) { - // Rollback on failure - setYearVisionState(previousVision); - console.error('Failed to save year vision:', result.errors); - } - } catch (error) { - // Rollback on error - setYearVisionState(previousVision); - console.error('Error saving year vision:', error); - } - }; - - /** - * Add a dream with optimistic update and server persistence - */ - const addDream = async (dream: Dream) => { - if (!userId) return; - - // Optimistic update - const dreamRecord = DreamRecord(dream); - const previousDreams = dreams; - setDreams(dreams.push(dreamRecord)); - - try { - const dreamsArray = dreams.push(dreamRecord).toArray().map(d => d.toObject()); - const result = await saveDreams({ userId, dreams: dreamsArray }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error('Failed to save dream:', result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error('Error saving dream:', error); - } - }; - - /** - * Update a dream with optimistic update and server persistence - */ - const updateDream = async (id: string, updates: Partial) => { - if (!userId) return; - - // Optimistic update - const previousDreams = dreams; - const index = dreams.findIndex(d => d.get('id') === id); - if (index === -1) return; - - const updatedDream = dreams.get(index)!.merge(updates); - setDreams(dreams.set(index, updatedDream)); - - try { - const dreamsArray = dreams.set(index, updatedDream).toArray().map(d => d.toObject()); - const result = await saveDreams({ userId, dreams: dreamsArray }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error('Failed to update dream:', result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error('Error updating dream:', error); - } - }; - - /** - * Delete a dream with optimistic update and server persistence - */ - const deleteDream = async (id: string) => { - if (!userId) return; - - // Optimistic update - const previousDreams = dreams; - const index = dreams.findIndex(d => d.get('id') === id); - if (index === -1) return; - - setDreams(dreams.delete(index)); - - try { - const dreamsArray = dreams.delete(index).toArray().map(d => d.toObject()); - const result = await saveDreams({ userId, dreams: dreamsArray }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error('Failed to delete dream:', result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error('Error deleting dream:', error); - } - }; - - /** - * Reorder dreams with optimistic update and server persistence - */ - const reorderDreams = async (newDreams: Dream[]) => { - if (!userId) return; - - // Optimistic update - const previousDreams = dreams; - const dreamRecords = List(newDreams.map(d => DreamRecord(d))); - setDreams(dreamRecords); - - try { - const result = await saveDreams({ userId, dreams: newDreams }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error('Failed to reorder dreams:', result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error('Error reordering dreams:', error); - } - }; - - return ( - - {children} - - ); -} - -/** - * Hook to use dream context - * Returns dreams as plain JavaScript array for easier consumption - */ -export function useDreams() { - const context = useContext(DreamContext); - if (context === undefined) { - throw new Error('useDreams must be used within a DreamProvider'); - } - - // Convert immutable List to plain array for component consumption - return { - ...context, - dreams: context.dreams.toArray().map(d => d.toObject()), - }; -} diff --git a/apps/web/lib/contexts/dreams/DreamContext.tsx b/apps/web/lib/contexts/dreams/DreamContext.tsx new file mode 100644 index 0000000..c862359 --- /dev/null +++ b/apps/web/lib/contexts/dreams/DreamContext.tsx @@ -0,0 +1,231 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + ReactNode, + useOptimistic, + useCallback, + useTransition, +} from "react"; +import { List } from "immutable"; +import * as DreamsService from "@/services/dreams"; +import { Dream } from "./types"; +import { useSession } from "next-auth/react"; + +/** + * Dream context state + */ +type DreamContextState = { + dreams: List; + yearVision: string; + pending: boolean; + setYearVision: (vision: string) => Promise; + add: (dream: Dream) => Promise; + update: (id: string, updates: Partial) => Promise; + $delete: (id: string) => Promise; + reorder: (dreams: Dream[]) => Promise; +}; + +const DreamContext = createContext(undefined); + +interface DreamsProviderProps { + children: ReactNode; + data: Dream[]; +} + +/** + * Dream context provider + * Manages dream book state with immutable data structures + * Loads data on initialization and saves optimistically via services + */ +export function DreamProvider({ children, data }: DreamsProviderProps) { + const session = useSession(); + const [state, setState] = useState(List(data)); + const [dreams, setDreams] = useOptimistic(state); + const [yearVision, setYearVisionState] = useState(""); + const [pending, startTransition] = useTransition(); + + if (!session.data?.user?.id) { + return null; + } + + const userId = session.data.user.id; + + /** + * Update year vision with optimistic update and server persistence + */ + const setYearVision = async (vision: string) => { + // Optimistic update + const previousVision = yearVision; + setYearVisionState(vision); + + try { + const result = await DreamsService.saveYearVision({ + userId, + yearVision: vision, + }); + if (result.failed) { + // Rollback on failure + setYearVisionState(previousVision); + console.error("Failed to save year vision:", result.errors); + } + } catch (error) { + // Rollback on error + setYearVisionState(previousVision); + console.error("Error saving year vision:", error); + } + }; + + /** + * Add a dream with optimistic update and server persistence + */ + const add = useCallback( + async (dream: Dream) => { + // Optimistic update + const next = dreams.push(dream); + setDreams(next); + + try { + startTransition(async () => { + const result = await DreamsService.saveDreams({ + userId, + dreams: next.toArray().map((d) => d), + }); + if (result.failed) { + console.error("Failed to save dream:", result.errors); + } else { + setState(next); + } + }); + } catch (error) { + console.error("Unexpected error saving dream:", error); + } + }, + [setState, setDreams, dreams], + ); + + /** + * Update a dream with optimistic update and server persistence + */ + const updateDream = async (id: string, updates: Partial) => { + // Optimistic update + const index = dreams.findIndex((d) => d.id === id); + if (index === -1) return; + + // const updatedDream = dreams.get(index)!.merge(updates); + const next = dreams.update(index, (d) => ({ ...d!, ...updates })); + + setDreams(next); + + try { + const result = await DreamsService.saveDreams({ + userId, + dreams: next.toArray(), + }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error("Failed to update dream:", result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error("Error updating dream:", error); + } + }; + + /** + * Delete a dream with optimistic update and server persistence + */ + const deleteDream = async (id: string) => { + if (!userId) return; + + // Optimistic update + const previousDreams = dreams; + const index = dreams.findIndex((d) => d.get("id") === id); + if (index === -1) return; + + setDreams(dreams.delete(index)); + + try { + const dreamsArray = dreams + .delete(index) + .toArray() + .map((d) => d.toObject()); + const result = await DreamsService.saveDreams({ + userId, + dreams: dreamsArray, + }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error("Failed to delete dream:", result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error("Error deleting dream:", error); + } + }; + + /** + * Reorder dreams with optimistic update and server persistence + */ + const reorderDreams = async (newDreams: Dream[]) => { + if (!userId) return; + + // Optimistic update + const previousDreams = dreams; + const dreamRecords = List(newDreams.map((d) => DreamRecord(d))); + setDreams(dreamRecords); + + try { + const result = await DreamsService.saveDreams({ + userId, + dreams: newDreams, + }); + if (result.failed) { + // Rollback on failure + setDreams(previousDreams); + console.error("Failed to reorder dreams:", result.errors); + } + } catch (error) { + // Rollback on error + setDreams(previousDreams); + console.error("Error reordering dreams:", error); + } + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use dream context + */ +export function useDreams() { + const context = useContext(DreamContext); + if (context === undefined) { + throw new Error("useDreams must be used within a DreamProvider"); + } + + return context; +} diff --git a/apps/web/lib/contexts/dreams/index.ts b/apps/web/lib/contexts/dreams/index.ts new file mode 100644 index 0000000..2dd4f31 --- /dev/null +++ b/apps/web/lib/contexts/dreams/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; + +export * from "./DreamContext"; diff --git a/apps/web/lib/contexts/dreams/types.ts b/apps/web/lib/contexts/dreams/types.ts new file mode 100644 index 0000000..eae8c1e --- /dev/null +++ b/apps/web/lib/contexts/dreams/types.ts @@ -0,0 +1,53 @@ +/** + * Dream data type + */ +export type Dream = { + id: string; + title: string; + category: string; + description?: string; + progress: number; + image?: string; + goals?: Goal[]; + notes?: Note[]; + coachNotes?: CoachNote[]; + history?: HistoryEntry[]; + createdAt?: string; + updatedAt?: string; +}; + +export type Goal = { + id: string; + dreamId: string; + title: string; + description?: string; + type: "consistency" | "deadline"; + recurrence?: "weekly" | "once"; + targetWeeks?: number; + targetDate?: string; + startDate?: string; + weekId?: string; + active: boolean; + completed: boolean; + completedAt?: string; +}; + +export type Note = { + id: string; + text: string; + createdAt: string; +}; + +export type CoachNote = { + id: string; + text: string; + sender: string; + createdAt: string; +}; + +export type HistoryEntry = { + id: string; + action: string; + details: string; + timestamp: string; +}; diff --git a/apps/web/lib/contexts/index.ts b/apps/web/lib/contexts/index.ts index b829bc0..10f9293 100644 --- a/apps/web/lib/contexts/index.ts +++ b/apps/web/lib/contexts/index.ts @@ -3,7 +3,7 @@ * Barrel export for all context providers and hooks */ -export * from './DreamContext'; +export * from './dreams/DreamContext'; export * from './GoalContext'; export * from './UserContext'; export * from './ConnectContext'; diff --git a/apps/web/services/admin/getCoachingAlerts.ts b/apps/web/services/admin/getCoachingAlerts.ts index 8b4ece4..089361a 100644 --- a/apps/web/services/admin/getCoachingAlerts.ts +++ b/apps/web/services/admin/getCoachingAlerts.ts @@ -1,25 +1,30 @@ -'use server'; +"use server"; -import { withAdminAuth, createActionSuccess, handleActionError } from '@/lib/actions'; -import { getDatabaseClient } from '@dreamspace/database'; +import { + withAdminAuth, + createActionSuccess, + handleActionError, +} from "@/lib/actions"; +import { getDatabaseClient } from "@dreamspace/database"; /** * Gets coaching alerts for administrators. - * + * * @returns Coaching alerts */ export const getCoachingAlerts = withAdminAuth(async (user) => { try { const db = getDatabaseClient(); + // TODO: Implement coaching alerts query // This would analyze user/team data for alerts - + return createActionSuccess({ alerts: [], - message: 'Coaching alerts not yet implemented' + message: "Coaching alerts not yet implemented", }); } catch (error) { - console.error('Failed to get coaching alerts:', error); - return handleActionError(error, 'Failed to get coaching alerts'); + console.error("Failed to get coaching alerts:", error); + return handleActionError(error, "Failed to get coaching alerts"); } }); diff --git a/apps/web/services/prompts/getPrompts.ts b/apps/web/services/prompts/getPrompts.ts index aea74cf..284a5ea 100644 --- a/apps/web/services/prompts/getPrompts.ts +++ b/apps/web/services/prompts/getPrompts.ts @@ -1,21 +1,25 @@ -'use server'; +"use server"; -import { withAuth, createActionSuccess, handleActionError } from '@/lib/actions'; -import { getDatabaseClient } from '@dreamspace/database'; +import { + withAuth, + createActionSuccess, + handleActionError, +} from "@/lib/actions"; +import { getDatabaseClient } from "@dreamspace/database"; /** * Gets AI prompts configuration from database. - * + * * @returns Prompts configuration */ export const getPrompts = withAuth(async (user) => { try { const db = getDatabaseClient(); const prompts = await db.prompts.getPrompts(); - + return createActionSuccess({ prompts }); } catch (error) { - console.error('Failed to get prompts:', error); - return handleActionError(error, 'Failed to get prompts'); + console.error("Failed to get prompts:", error); + return handleActionError(error, "Failed to get prompts"); } }); From 0e99ea0b0eccd216b7e4fb3d09ded91ad8cc6463 Mon Sep 17 00:00:00 2001 From: Matt Lynam Date: Sat, 7 Feb 2026 01:43:05 -0700 Subject: [PATCH 07/12] feat: add context pattern --- apps/web/lib/contexts/ErrorsContext.tsx | 20 ++ apps/web/lib/contexts/dreams/DreamContext.tsx | 195 ++++++++---------- apps/web/services/dreams/saveYearVision.ts | 92 +++++---- 3 files changed, 154 insertions(+), 153 deletions(-) create mode 100644 apps/web/lib/contexts/ErrorsContext.tsx diff --git a/apps/web/lib/contexts/ErrorsContext.tsx b/apps/web/lib/contexts/ErrorsContext.tsx new file mode 100644 index 0000000..afd7eed --- /dev/null +++ b/apps/web/lib/contexts/ErrorsContext.tsx @@ -0,0 +1,20 @@ +import React, { useContext } from "react"; + +export type ErrorContextType = { + dispatch(error: string): void; + reset(): void; +}; + +const DEFAULT_ERROR_CONTEXT: ErrorContextType = { + dispatch: () => {}, + reset: () => {}, +}; + +export const ErrorContext = React.createContext( + DEFAULT_ERROR_CONTEXT, +); + +export function useErrors() { + const context = useContext(ErrorContext); + return context; +} diff --git a/apps/web/lib/contexts/dreams/DreamContext.tsx b/apps/web/lib/contexts/dreams/DreamContext.tsx index c862359..91e3c10 100644 --- a/apps/web/lib/contexts/dreams/DreamContext.tsx +++ b/apps/web/lib/contexts/dreams/DreamContext.tsx @@ -13,6 +13,7 @@ import { List } from "immutable"; import * as DreamsService from "@/services/dreams"; import { Dream } from "./types"; import { useSession } from "next-auth/react"; +import { useErrors } from "../ErrorsContext"; /** * Dream context state @@ -21,11 +22,11 @@ type DreamContextState = { dreams: List; yearVision: string; pending: boolean; - setYearVision: (vision: string) => Promise; - add: (dream: Dream) => Promise; - update: (id: string, updates: Partial) => Promise; - $delete: (id: string) => Promise; - reorder: (dreams: Dream[]) => Promise; + setYearVision: (vision: string) => void; + add: (dream: Dream) => void; + update: (id: string, updates: Partial) => void; + $delete: (id: string) => void; + reorder: (dreams: Dream[]) => void; }; const DreamContext = createContext(undefined); @@ -42,9 +43,11 @@ interface DreamsProviderProps { */ export function DreamProvider({ children, data }: DreamsProviderProps) { const session = useSession(); + const errors = useErrors(); const [state, setState] = useState(List(data)); const [dreams, setDreams] = useOptimistic(state); - const [yearVision, setYearVisionState] = useState(""); + const [visionState, setVisionState] = useState(""); + const [vision, setVision] = useOptimistic(visionState); const [pending, startTransition] = useTransition(); if (!session.data?.user?.id) { @@ -56,52 +59,43 @@ export function DreamProvider({ children, data }: DreamsProviderProps) { /** * Update year vision with optimistic update and server persistence */ - const setYearVision = async (vision: string) => { + const setYearVision = (vision: string) => { // Optimistic update - const previousVision = yearVision; - setYearVisionState(vision); + setVision(vision); - try { + startTransition(async () => { const result = await DreamsService.saveYearVision({ userId, yearVision: vision, }); if (result.failed) { - // Rollback on failure - setYearVisionState(previousVision); - console.error("Failed to save year vision:", result.errors); + errors.dispatch(result.errors._errors.join(",")); + } else { + setVisionState(vision); } - } catch (error) { - // Rollback on error - setYearVisionState(previousVision); - console.error("Error saving year vision:", error); - } + }); }; /** * Add a dream with optimistic update and server persistence */ - const add = useCallback( - async (dream: Dream) => { + const addDream = useCallback( + (dream: Dream) => { // Optimistic update const next = dreams.push(dream); setDreams(next); - try { - startTransition(async () => { - const result = await DreamsService.saveDreams({ - userId, - dreams: next.toArray().map((d) => d), - }); - if (result.failed) { - console.error("Failed to save dream:", result.errors); - } else { - setState(next); - } + startTransition(async () => { + const result = await DreamsService.saveDreams({ + userId, + dreams: next.toArray(), }); - } catch (error) { - console.error("Unexpected error saving dream:", error); - } + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); }, [setState, setDreams, dreams], ); @@ -109,103 +103,84 @@ export function DreamProvider({ children, data }: DreamsProviderProps) { /** * Update a dream with optimistic update and server persistence */ - const updateDream = async (id: string, updates: Partial) => { - // Optimistic update - const index = dreams.findIndex((d) => d.id === id); - if (index === -1) return; + const updateDream = useCallback( + (id: string, updates: Partial) => { + const index = dreams.findIndex((d) => d.id === id); + if (index === -1) return; - // const updatedDream = dreams.get(index)!.merge(updates); - const next = dreams.update(index, (d) => ({ ...d!, ...updates })); - - setDreams(next); + const next = dreams.update(index, (d) => ({ ...d!, ...updates })); + setDreams(next); - try { - const result = await DreamsService.saveDreams({ - userId, - dreams: next.toArray(), + startTransition(async () => { + const result = await DreamsService.saveDreams({ + userId, + dreams: next.toArray(), + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error("Failed to update dream:", result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error("Error updating dream:", error); - } - }; + }, + [setState, setDreams, dreams], + ); /** * Delete a dream with optimistic update and server persistence */ - const deleteDream = async (id: string) => { - if (!userId) return; + const deleteDream = useCallback( + (id: string) => { + const index = dreams.findIndex((d) => d.id === id); + if (index === -1) return; - // Optimistic update - const previousDreams = dreams; - const index = dreams.findIndex((d) => d.get("id") === id); - if (index === -1) return; - - setDreams(dreams.delete(index)); - - try { - const dreamsArray = dreams - .delete(index) - .toArray() - .map((d) => d.toObject()); - const result = await DreamsService.saveDreams({ - userId, - dreams: dreamsArray, + const next = dreams.delete(index); + setDreams(next); + + startTransition(async () => { + const result = await DreamsService.saveDreams({ + userId, + dreams: next.toArray(), + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error("Failed to delete dream:", result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error("Error deleting dream:", error); - } - }; + }, + [setState, setDreams, dreams], + ); /** * Reorder dreams with optimistic update and server persistence */ - const reorderDreams = async (newDreams: Dream[]) => { - if (!userId) return; - - // Optimistic update - const previousDreams = dreams; - const dreamRecords = List(newDreams.map((d) => DreamRecord(d))); - setDreams(dreamRecords); + const reorderDreams = useCallback( + (newDreams: Dream[]) => { + const next = List(newDreams); + setDreams(next); - try { - const result = await DreamsService.saveDreams({ - userId, - dreams: newDreams, + startTransition(async () => { + const result = await DreamsService.saveDreams({ + userId, + dreams: newDreams, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } }); - if (result.failed) { - // Rollback on failure - setDreams(previousDreams); - console.error("Failed to reorder dreams:", result.errors); - } - } catch (error) { - // Rollback on error - setDreams(previousDreams); - console.error("Error reordering dreams:", error); - } - }; + }, + [setState, setDreams], + ); return ( { - try { - const { userId, yearVision } = input; - - if (!userId) { - throw new Error('userId is required'); - } - - // Only allow users to save their own year vision - if (user.id !== userId) { - throw new Error('Forbidden'); +export const saveYearVision = withAuth( + async (user, input: SaveYearVisionInput) => { + try { + const { userId, yearVision } = input; + + if (!userId) { + throw new Error("userId is required"); + } + + // Only allow users to save their own year vision + if (user.id !== userId) { + throw new Error("Forbidden"); + } + + const db = getDatabaseClient(); + + // Get existing dreams document or create new one + const existingDoc = await db.dreams.getDreamsDocument(userId); + + const document = { + ...existingDoc, + id: userId, + userId: userId, + dreams: existingDoc?.dreams || [], + weeklyGoalTemplates: existingDoc?.weeklyGoalTemplates || [], + yearVision: yearVision, + createdAt: existingDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + + return createActionSuccess({ + id: userId, + message: "Year vision saved successfully", + }); + } catch (error) { + console.error("Failed to save year vision:", error); + return handleActionError(error, "Failed to save year vision"); } - - const db = getDatabaseClient(); - - // Get existing dreams document or create new one - const existingDoc = await db.dreams.getDreamsDocument(userId); - - const document = { - ...existingDoc, - id: userId, - userId: userId, - dreams: existingDoc?.dreams || [], - weeklyGoalTemplates: existingDoc?.weeklyGoalTemplates || [], - yearVision: yearVision, - createdAt: existingDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - - await db.dreams.upsertDreamsDocument(userId, document); - - return createActionSuccess({ - id: userId, - message: 'Year vision saved successfully' - }); - } catch (error) { - console.error('Failed to save year vision:', error); - return handleActionError(error, 'Failed to save year vision'); - } -}); + }, +); From 2017b6ba8dd974775d74568598c799d206fcbc0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 08:50:08 +0000 Subject: [PATCH 08/12] Refactor contexts to match DreamContext pattern - Restructure all contexts into subdirectories (goals/, connects/, users/, teams/, scoring/) - Use useOptimistic and useTransition for all state updates - Implement consistent optimistic update pattern across all contexts - Add useErrors context for centralized error handling - Remove old Record-based immutable wrappers in favor of List with plain objects - Simplify context API with consistent naming (add, update, $delete, toggle) - Make contexts require data prop instead of load methods - Remove conversion to plain JS in hooks - return immutable Lists directly Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- apps/web/lib/contexts/ConnectContext.tsx | 194 -------------- apps/web/lib/contexts/GoalContext.tsx | 237 ------------------ apps/web/lib/contexts/ScoringContext.tsx | 179 ------------- apps/web/lib/contexts/TeamContext.tsx | 228 ----------------- apps/web/lib/contexts/UserContext.tsx | 159 ------------ .../lib/contexts/connects/ConnectContext.tsx | 157 ++++++++++++ apps/web/lib/contexts/connects/index.ts | 3 + apps/web/lib/contexts/connects/types.ts | 18 ++ apps/web/lib/contexts/goals/GoalContext.tsx | 191 ++++++++++++++ apps/web/lib/contexts/goals/index.ts | 3 + apps/web/lib/contexts/goals/types.ts | 15 ++ apps/web/lib/contexts/index.ts | 15 +- .../lib/contexts/scoring/ScoringContext.tsx | 124 +++++++++ apps/web/lib/contexts/scoring/index.ts | 3 + apps/web/lib/contexts/scoring/types.ts | 16 ++ apps/web/lib/contexts/teams/TeamContext.tsx | 167 ++++++++++++ apps/web/lib/contexts/teams/index.ts | 3 + apps/web/lib/contexts/teams/types.ts | 34 +++ apps/web/lib/contexts/users/UserContext.tsx | 126 ++++++++++ apps/web/lib/contexts/users/index.ts | 3 + apps/web/lib/contexts/users/types.ts | 19 ++ 21 files changed, 891 insertions(+), 1003 deletions(-) delete mode 100644 apps/web/lib/contexts/ConnectContext.tsx delete mode 100644 apps/web/lib/contexts/GoalContext.tsx delete mode 100644 apps/web/lib/contexts/ScoringContext.tsx delete mode 100644 apps/web/lib/contexts/TeamContext.tsx delete mode 100644 apps/web/lib/contexts/UserContext.tsx create mode 100644 apps/web/lib/contexts/connects/ConnectContext.tsx create mode 100644 apps/web/lib/contexts/connects/index.ts create mode 100644 apps/web/lib/contexts/connects/types.ts create mode 100644 apps/web/lib/contexts/goals/GoalContext.tsx create mode 100644 apps/web/lib/contexts/goals/index.ts create mode 100644 apps/web/lib/contexts/goals/types.ts create mode 100644 apps/web/lib/contexts/scoring/ScoringContext.tsx create mode 100644 apps/web/lib/contexts/scoring/index.ts create mode 100644 apps/web/lib/contexts/scoring/types.ts create mode 100644 apps/web/lib/contexts/teams/TeamContext.tsx create mode 100644 apps/web/lib/contexts/teams/index.ts create mode 100644 apps/web/lib/contexts/teams/types.ts create mode 100644 apps/web/lib/contexts/users/UserContext.tsx create mode 100644 apps/web/lib/contexts/users/index.ts create mode 100644 apps/web/lib/contexts/users/types.ts diff --git a/apps/web/lib/contexts/ConnectContext.tsx b/apps/web/lib/contexts/ConnectContext.tsx deleted file mode 100644 index 784593d..0000000 --- a/apps/web/lib/contexts/ConnectContext.tsx +++ /dev/null @@ -1,194 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { List, Record } from 'immutable'; -import { saveConnect, deleteConnect } from '@/services/connects'; - -/** - * Connect data type - */ -export type Connect = { - id: string; - userId: string; - dreamId?: string; - withWhom: string; - withWhomId: string; - when?: string; - notes?: string; - status?: 'pending' | 'completed'; - agenda?: string; - proposedWeeks?: string[]; - schedulingMethod?: string; - createdAt?: string; - updatedAt?: string; -}; - -/** - * Immutable Connect record - */ -const ConnectRecord = Record({ - id: '', - userId: '', - dreamId: '', - withWhom: '', - withWhomId: '', - when: '', - notes: '', - status: 'pending', - agenda: '', - proposedWeeks: [], - schedulingMethod: '', - createdAt: '', - updatedAt: '', -}); - -/** - * Connect context state - */ -type ConnectContextState = { - connects: List>; - isLoading: boolean; - userId: string | null; - loadConnects: (userId: string, initialConnects?: Connect[]) => void; - addConnect: (connect: Connect) => Promise; - updateConnect: (id: string, updates: Partial) => Promise; - deleteConnect: (id: string) => Promise; -}; - -const ConnectContext = createContext(undefined); - -/** - * Connect context provider - * Manages dream connect/networking state with immutable data structures - * Loads data on initialization and saves optimistically via services - */ -export function ConnectProvider({ children }: { children: ReactNode }) { - const [connects, setConnects] = useState>>(List()); - const [isLoading, setIsLoading] = useState(false); - const [userId, setUserId] = useState(null); - - /** - * Load connects for a user - */ - const loadConnects = (userId: string, initialConnects?: Connect[]) => { - setUserId(userId); - if (initialConnects) { - const connectRecords = List(initialConnects.map(c => ConnectRecord(c))); - setConnects(connectRecords); - } - }; - - /** - * Add a connect with optimistic update and server persistence - */ - const addConnect = async (connect: Connect) => { - if (!userId) return; - - // Optimistic update - const connectRecord = ConnectRecord(connect); - const previousConnects = connects; - setConnects(connects.push(connectRecord)); - - try { - const result = await saveConnect({ userId, connectData: connect }); - if (result.failed) { - // Rollback on failure - setConnects(previousConnects); - console.error('Failed to save connect:', result.errors); - } - } catch (error) { - // Rollback on error - setConnects(previousConnects); - console.error('Error saving connect:', error); - } - }; - - /** - * Update a connect with optimistic update and server persistence - */ - const updateConnect = async (id: string, updates: Partial) => { - if (!userId) return; - - // Optimistic update - const previousConnects = connects; - const index = connects.findIndex(c => c.get('id') === id); - if (index === -1) return; - - const updatedConnect = connects.get(index)!.merge(updates); - setConnects(connects.set(index, updatedConnect)); - - try { - const connectData = updatedConnect.toObject(); - const result = await saveConnect({ userId, connectData }); - if (result.failed) { - // Rollback on failure - setConnects(previousConnects); - console.error('Failed to update connect:', result.errors); - } - } catch (error) { - // Rollback on error - setConnects(previousConnects); - console.error('Error updating connect:', error); - } - }; - - /** - * Delete a connect with optimistic update and server persistence - */ - const deleteConnectAction = async (id: string) => { - if (!userId) return; - - // Optimistic update - const previousConnects = connects; - const index = connects.findIndex(c => c.get('id') === id); - if (index === -1) return; - - setConnects(connects.delete(index)); - - try { - const result = await deleteConnect({ userId, connectId: id }); - if (result.failed) { - // Rollback on failure - setConnects(previousConnects); - console.error('Failed to delete connect:', result.errors); - } - } catch (error) { - // Rollback on error - setConnects(previousConnects); - console.error('Error deleting connect:', error); - } - }; - - return ( - - {children} - - ); -} - -/** - * Hook to use connect context - * Returns connects as plain JavaScript array for easier consumption - */ -export function useConnects() { - const context = useContext(ConnectContext); - if (context === undefined) { - throw new Error('useConnects must be used within a ConnectProvider'); - } - - // Convert immutable List to plain array for component consumption - return { - ...context, - connects: context.connects.toArray().map(c => c.toObject()), - }; -} diff --git a/apps/web/lib/contexts/GoalContext.tsx b/apps/web/lib/contexts/GoalContext.tsx deleted file mode 100644 index b468e2c..0000000 --- a/apps/web/lib/contexts/GoalContext.tsx +++ /dev/null @@ -1,237 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { List, Record } from 'immutable'; -import { saveCurrentWeek } from '@/services/weeks'; - -/** - * Weekly goal data type - */ -export type WeeklyGoal = { - id: string; - title: string; - dreamId?: string; - goalId?: string; - completed: boolean; - completedAt?: string; - weekId: string; - recurrence: 'weekly' | 'once'; - active: boolean; - weekLog?: Record; -}; - -/** - * Immutable WeeklyGoal record - */ -const WeeklyGoalRecord = Record({ - id: '', - title: '', - dreamId: '', - goalId: '', - completed: false, - completedAt: '', - weekId: '', - recurrence: 'weekly', - active: true, - weekLog: {}, -}); - -/** - * Goal context state - */ -type GoalContextState = { - weeklyGoals: List>; - isLoading: boolean; - userId: string | null; - currentWeekId: string | null; - loadWeeklyGoals: (userId: string, weekId: string, initialGoals?: WeeklyGoal[]) => void; - addWeeklyGoal: (goal: WeeklyGoal) => Promise; - updateWeeklyGoal: (id: string, updates: Partial) => Promise; - deleteWeeklyGoal: (id: string) => Promise; - toggleWeeklyGoal: (id: string) => Promise; -}; - -const GoalContext = createContext(undefined); - -/** - * Goal context provider - * Manages weekly goals state with immutable data structures - * Loads data on initialization and saves optimistically via services - */ -export function GoalProvider({ children }: { children: ReactNode }) { - const [weeklyGoals, setWeeklyGoals] = useState>>(List()); - const [isLoading, setIsLoading] = useState(false); - const [userId, setUserId] = useState(null); - const [currentWeekId, setCurrentWeekId] = useState(null); - - /** - * Load weekly goals for a user and week - */ - const loadWeeklyGoals = (userId: string, weekId: string, initialGoals?: WeeklyGoal[]) => { - setUserId(userId); - setCurrentWeekId(weekId); - if (initialGoals) { - const goalRecords = List(initialGoals.map(g => WeeklyGoalRecord(g))); - setWeeklyGoals(goalRecords); - } - }; - - /** - * Save goals to server - */ - const saveGoals = async (goals: List>) => { - if (!userId || !currentWeekId) return; - - const goalsArray = goals.toArray().map(g => g.toObject()); - const result = await saveCurrentWeek({ userId, weekId: currentWeekId, goals: goalsArray }); - - if (result.failed) { - console.error('Failed to save weekly goals:', result.errors); - return false; - } - return true; - }; - - /** - * Add a weekly goal with optimistic update and server persistence - */ - const addWeeklyGoal = async (goal: WeeklyGoal) => { - if (!userId || !currentWeekId) return; - - // Optimistic update - const goalRecord = WeeklyGoalRecord(goal); - const previousGoals = weeklyGoals; - setWeeklyGoals(weeklyGoals.push(goalRecord)); - - try { - const success = await saveGoals(weeklyGoals.push(goalRecord)); - if (!success) { - // Rollback on failure - setWeeklyGoals(previousGoals); - } - } catch (error) { - // Rollback on error - setWeeklyGoals(previousGoals); - console.error('Error saving weekly goal:', error); - } - }; - - /** - * Update a weekly goal with optimistic update and server persistence - */ - const updateWeeklyGoal = async (id: string, updates: Partial) => { - if (!userId || !currentWeekId) return; - - // Optimistic update - const previousGoals = weeklyGoals; - const index = weeklyGoals.findIndex(g => g.get('id') === id); - if (index === -1) return; - - const updatedGoal = weeklyGoals.get(index)!.merge(updates); - setWeeklyGoals(weeklyGoals.set(index, updatedGoal)); - - try { - const success = await saveGoals(weeklyGoals.set(index, updatedGoal)); - if (!success) { - // Rollback on failure - setWeeklyGoals(previousGoals); - } - } catch (error) { - // Rollback on error - setWeeklyGoals(previousGoals); - console.error('Error updating weekly goal:', error); - } - }; - - /** - * Delete a weekly goal with optimistic update and server persistence - */ - const deleteWeeklyGoal = async (id: string) => { - if (!userId || !currentWeekId) return; - - // Optimistic update - const previousGoals = weeklyGoals; - const index = weeklyGoals.findIndex(g => g.get('id') === id); - if (index === -1) return; - - setWeeklyGoals(weeklyGoals.delete(index)); - - try { - const success = await saveGoals(weeklyGoals.delete(index)); - if (!success) { - // Rollback on failure - setWeeklyGoals(previousGoals); - } - } catch (error) { - // Rollback on error - setWeeklyGoals(previousGoals); - console.error('Error deleting weekly goal:', error); - } - }; - - /** - * Toggle a weekly goal completion with optimistic update and server persistence - */ - const toggleWeeklyGoal = async (id: string) => { - if (!userId || !currentWeekId) return; - - // Optimistic update - const previousGoals = weeklyGoals; - const index = weeklyGoals.findIndex(g => g.get('id') === id); - if (index === -1) return; - - const currentGoal = weeklyGoals.get(index)!; - const updatedGoal = currentGoal.merge({ - completed: !currentGoal.get('completed'), - completedAt: !currentGoal.get('completed') ? new Date().toISOString() : undefined, - }); - setWeeklyGoals(weeklyGoals.set(index, updatedGoal)); - - try { - const success = await saveGoals(weeklyGoals.set(index, updatedGoal)); - if (!success) { - // Rollback on failure - setWeeklyGoals(previousGoals); - } - } catch (error) { - // Rollback on error - setWeeklyGoals(previousGoals); - console.error('Error toggling weekly goal:', error); - } - }; - - return ( - - {children} - - ); -} - -/** - * Hook to use goal context - * Returns goals as plain JavaScript array for easier consumption - */ -export function useGoals() { - const context = useContext(GoalContext); - if (context === undefined) { - throw new Error('useGoals must be used within a GoalProvider'); - } - - // Convert immutable List to plain array for component consumption - return { - ...context, - weeklyGoals: context.weeklyGoals.toArray().map(g => g.toObject()), - }; -} diff --git a/apps/web/lib/contexts/ScoringContext.tsx b/apps/web/lib/contexts/ScoringContext.tsx deleted file mode 100644 index b09cbc3..0000000 --- a/apps/web/lib/contexts/ScoringContext.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { List, Record } from 'immutable'; -import { saveScoring } from '@/services/scoring'; - -/** - * Scoring entry data type - */ -export type ScoringEntry = { - id: string; - date: string; - score: number; - activity: string; - points: number; - category?: string; - source?: string; - dreamId?: string; - weekId?: string; - connectId?: string; - createdAt?: string; -}; - -/** - * Immutable ScoringEntry record - */ -const ScoringEntryRecord = Record({ - id: '', - date: '', - score: 0, - activity: '', - points: 0, - category: '', - source: '', - dreamId: '', - weekId: '', - connectId: '', - createdAt: '', -}); - -/** - * Scoring context state - */ -type ScoringContextState = { - scoringHistory: List>; - allYearsScoring: List>; - allTimeScore: number; - isLoading: boolean; - userId: string | null; - loadScoringData: ( - userId: string, - initialHistory?: ScoringEntry[], - initialAllYears?: ScoringEntry[], - initialAllTimeScore?: number - ) => void; - addScoringEntry: (entry: ScoringEntry) => Promise; -}; - -const ScoringContext = createContext(undefined); - -/** - * Scoring context provider - * Manages scorecard and activity scoring state with immutable data structures - * Loads data on initialization and saves optimistically via services - */ -export function ScoringProvider({ children }: { children: ReactNode }) { - const [scoringHistory, setScoringHistory] = useState>>(List()); - const [allYearsScoring, setAllYearsScoring] = useState>>(List()); - const [allTimeScore, setAllTimeScore] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [userId, setUserId] = useState(null); - - /** - * Load scoring data for a user - */ - const loadScoringData = ( - userId: string, - initialHistory?: ScoringEntry[], - initialAllYears?: ScoringEntry[], - initialAllTimeScore?: number - ) => { - setUserId(userId); - if (initialHistory) { - const historyRecords = List(initialHistory.map(e => ScoringEntryRecord(e))); - setScoringHistory(historyRecords); - } - if (initialAllYears) { - const allYearsRecords = List(initialAllYears.map(e => ScoringEntryRecord(e))); - setAllYearsScoring(allYearsRecords); - } - if (initialAllTimeScore !== undefined) { - setAllTimeScore(initialAllTimeScore); - } - }; - - /** - * Add a scoring entry with optimistic update and server persistence - */ - const addScoringEntry = async (entry: ScoringEntry) => { - if (!userId) return; - - // Optimistic update - const entryRecord = ScoringEntryRecord(entry); - const previousHistory = scoringHistory; - const previousAllYears = allYearsScoring; - const previousAllTimeScore = allTimeScore; - - setScoringHistory(scoringHistory.unshift(entryRecord)); - setAllYearsScoring(allYearsScoring.unshift(entryRecord)); - setAllTimeScore(allTimeScore + entry.points); - - try { - const year = new Date(entry.date).getFullYear(); - const result = await saveScoring({ - userId, - year, - entry: { - id: entry.id, - date: entry.date, - source: entry.source || 'manual', - dreamId: entry.dreamId, - weekId: entry.weekId, - connectId: entry.connectId, - points: entry.points, - activity: entry.activity, - createdAt: entry.createdAt, - }, - }); - - if (result.failed) { - // Rollback on failure - setScoringHistory(previousHistory); - setAllYearsScoring(previousAllYears); - setAllTimeScore(previousAllTimeScore); - console.error('Failed to save scoring entry:', result.errors); - } - } catch (error) { - // Rollback on error - setScoringHistory(previousHistory); - setAllYearsScoring(previousAllYears); - setAllTimeScore(previousAllTimeScore); - console.error('Error saving scoring entry:', error); - } - }; - - return ( - - {children} - - ); -} - -/** - * Hook to use scoring context - * Returns data as plain JavaScript arrays for easier consumption - */ -export function useScoring() { - const context = useContext(ScoringContext); - if (context === undefined) { - throw new Error('useScoring must be used within a ScoringProvider'); - } - - // Convert immutable Lists to plain arrays for component consumption - return { - ...context, - scoringHistory: context.scoringHistory.toArray().map(e => e.toObject()), - allYearsScoring: context.allYearsScoring.toArray().map(e => e.toObject()), - }; -} diff --git a/apps/web/lib/contexts/TeamContext.tsx b/apps/web/lib/contexts/TeamContext.tsx deleted file mode 100644 index d31e721..0000000 --- a/apps/web/lib/contexts/TeamContext.tsx +++ /dev/null @@ -1,228 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { List, Record } from 'immutable'; -import { updateTeamInfo, saveMeetingAttendance } from '@/services/teams'; - -/** - * Team member data type - */ -export type TeamMember = { - id: string; - name: string; - email?: string; - avatar?: string; - role?: string; - dreamsCompleted?: number; - meetingAttendance?: number; -}; - -/** - * Meeting data type - */ -export type Meeting = { - id: string; - date: string; - attendees: string[]; - notes?: string; - teamId?: string; -}; - -/** - * Team info data type - */ -export type TeamInfo = { - id: string; - name: string; - mission?: string; - coachId?: string; - members?: TeamMember[]; -}; - -/** - * Immutable records - */ -const TeamInfoRecord = Record({ - id: '', - name: '', - mission: '', - coachId: '', - members: [], -}); - -const MeetingRecord = Record({ - id: '', - date: '', - attendees: [], - notes: '', - teamId: '', -}); - -/** - * Team context state - */ -type TeamContextState = { - teamInfo: Record | null; - meetings: List>; - isLoading: boolean; - userId: string | null; - loadTeamData: (userId: string, teamInfo?: TeamInfo, meetings?: Meeting[]) => void; - updateTeamInfo: (updates: Partial) => Promise; - addMeeting: (meeting: Meeting) => Promise; - updateMeeting: (id: string, updates: Partial) => Promise; -}; - -const TeamContext = createContext(undefined); - -/** - * Team context provider - * Manages team collaboration state with immutable data structures - * Loads data on initialization and saves optimistically via services - */ -export function TeamProvider({ children }: { children: ReactNode }) { - const [teamInfo, setTeamInfo] = useState | null>(null); - const [meetings, setMeetings] = useState>>(List()); - const [isLoading, setIsLoading] = useState(false); - const [userId, setUserId] = useState(null); - - /** - * Load team data for a user - */ - const loadTeamData = (userId: string, initialTeamInfo?: TeamInfo, initialMeetings?: Meeting[]) => { - setUserId(userId); - if (initialTeamInfo) { - setTeamInfo(TeamInfoRecord(initialTeamInfo)); - } - if (initialMeetings) { - const meetingRecords = List(initialMeetings.map(m => MeetingRecord(m))); - setMeetings(meetingRecords); - } - }; - - /** - * Update team info with optimistic update and server persistence - */ - const updateTeamInfoAction = async (updates: Partial) => { - if (!teamInfo || !userId) return; - - // Optimistic update - const previousTeamInfo = teamInfo; - const updatedTeamInfo = teamInfo.merge(updates); - setTeamInfo(updatedTeamInfo); - - try { - const result = await updateTeamInfo({ - managerId: userId, - ...updates, - }); - if (result.failed) { - // Rollback on failure - setTeamInfo(previousTeamInfo); - console.error('Failed to update team info:', result.errors); - } - } catch (error) { - // Rollback on error - setTeamInfo(previousTeamInfo); - console.error('Error updating team info:', error); - } - }; - - /** - * Add a meeting with optimistic update and server persistence - */ - const addMeeting = async (meeting: Meeting) => { - if (!userId) return; - - // Optimistic update - const meetingRecord = MeetingRecord(meeting); - const previousMeetings = meetings; - setMeetings(meetings.push(meetingRecord)); - - try { - const result = await saveMeetingAttendance({ - userId, - date: meeting.date, - attendees: meeting.attendees, - notes: meeting.notes, - }); - if (result.failed) { - // Rollback on failure - setMeetings(previousMeetings); - console.error('Failed to save meeting:', result.errors); - } - } catch (error) { - // Rollback on error - setMeetings(previousMeetings); - console.error('Error saving meeting:', error); - } - }; - - /** - * Update a meeting with optimistic update and server persistence - */ - const updateMeeting = async (id: string, updates: Partial) => { - if (!userId) return; - - // Optimistic update - const previousMeetings = meetings; - const index = meetings.findIndex(m => m.get('id') === id); - if (index === -1) return; - - const updatedMeeting = meetings.get(index)!.merge(updates); - setMeetings(meetings.set(index, updatedMeeting)); - - try { - const meetingData = updatedMeeting.toObject(); - const result = await saveMeetingAttendance({ - userId, - date: meetingData.date, - attendees: meetingData.attendees, - notes: meetingData.notes, - }); - if (result.failed) { - // Rollback on failure - setMeetings(previousMeetings); - console.error('Failed to update meeting:', result.errors); - } - } catch (error) { - // Rollback on error - setMeetings(previousMeetings); - console.error('Error updating meeting:', error); - } - }; - - return ( - - {children} - - ); -} - -/** - * Hook to use team context - * Returns data as plain JavaScript objects for easier consumption - */ -export function useTeam() { - const context = useContext(TeamContext); - if (context === undefined) { - throw new Error('useTeam must be used within a TeamProvider'); - } - - // Convert immutable data to plain objects for component consumption - return { - ...context, - teamInfo: context.teamInfo ? context.teamInfo.toObject() : null, - meetings: context.meetings.toArray().map(m => m.toObject()), - }; -} diff --git a/apps/web/lib/contexts/UserContext.tsx b/apps/web/lib/contexts/UserContext.tsx deleted file mode 100644 index 19311f7..0000000 --- a/apps/web/lib/contexts/UserContext.tsx +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, ReactNode } from 'react'; -import { Record } from 'immutable'; -import { saveUserData } from '@/services/users'; - -/** - * User data type - */ -export type User = { - id: string; - email: string; - name: string; - displayName?: string; - office?: string; - avatar?: string; - jobTitle?: string; - department?: string; - role?: 'user' | 'coach' | 'admin'; - isCoach?: boolean; - score?: number; - dreamsCount?: number; - connectsCount?: number; - dreamCategories?: string[]; -}; - -/** - * Immutable User record - */ -const UserRecord = Record({ - id: '', - email: '', - name: '', - displayName: '', - office: '', - avatar: '', - jobTitle: '', - department: '', - role: 'user', - isCoach: false, - score: 0, - dreamsCount: 0, - connectsCount: 0, - dreamCategories: [], -}); - -/** - * User context state - */ -type UserContextState = { - currentUser: Record | null; - isLoading: boolean; - loadUser: (user: User) => void; - updateUserProfile: (updates: Partial) => Promise; - updateScore: (score: number) => Promise; -}; - -const UserContext = createContext(undefined); - -/** - * User context provider - * Manages current user profile state with immutable data structures - * Loads data on initialization and saves optimistically via services - */ -export function UserProvider({ children }: { children: ReactNode }) { - const [currentUser, setCurrentUser] = useState | null>(null); - const [isLoading, setIsLoading] = useState(false); - - /** - * Load user data - */ - const loadUser = (user: User) => { - const userRecord = UserRecord(user); - setCurrentUser(userRecord); - }; - - /** - * Update user profile with optimistic update and server persistence - */ - const updateUserProfile = async (updates: Partial) => { - if (!currentUser) return; - - // Optimistic update - const previousUser = currentUser; - const updatedUser = currentUser.merge(updates); - setCurrentUser(updatedUser); - - try { - const userId = currentUser.get('id'); - const result = await saveUserData({ userId, ...updatedUser.toObject() }); - if (result.failed) { - // Rollback on failure - setCurrentUser(previousUser); - console.error('Failed to update user profile:', result.errors); - } - } catch (error) { - // Rollback on error - setCurrentUser(previousUser); - console.error('Error updating user profile:', error); - } - }; - - /** - * Update score with optimistic update and server persistence - */ - const updateScore = async (score: number) => { - if (!currentUser) return; - - // Optimistic update - const previousUser = currentUser; - const updatedUser = currentUser.set('score', score); - setCurrentUser(updatedUser); - - try { - const userId = currentUser.get('id'); - const result = await saveUserData({ userId, score }); - if (result.failed) { - // Rollback on failure - setCurrentUser(previousUser); - console.error('Failed to update score:', result.errors); - } - } catch (error) { - // Rollback on error - setCurrentUser(previousUser); - console.error('Error updating score:', error); - } - }; - - return ( - - {children} - - ); -} - -/** - * Hook to use user context - * Returns user as plain JavaScript object for easier consumption - */ -export function useUser() { - const context = useContext(UserContext); - if (context === undefined) { - throw new Error('useUser must be used within a UserProvider'); - } - - // Convert immutable Record to plain object for component consumption - return { - ...context, - currentUser: context.currentUser ? context.currentUser.toObject() : null, - }; -} diff --git a/apps/web/lib/contexts/connects/ConnectContext.tsx b/apps/web/lib/contexts/connects/ConnectContext.tsx new file mode 100644 index 0000000..ac2acd7 --- /dev/null +++ b/apps/web/lib/contexts/connects/ConnectContext.tsx @@ -0,0 +1,157 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + ReactNode, + useOptimistic, + useCallback, + useTransition, +} from "react"; +import { List } from "immutable"; +import * as ConnectsService from "@/services/connects"; +import { Connect } from "./types"; +import { useSession } from "next-auth/react"; +import { useErrors } from "../ErrorsContext"; + +/** + * Connect context state + */ +type ConnectContextState = { + connects: List; + pending: boolean; + add: (connect: Connect) => void; + update: (id: string, updates: Partial) => void; + $delete: (id: string) => void; +}; + +const ConnectContext = createContext( + undefined, +); + +interface ConnectProviderProps { + children: ReactNode; + data: Connect[]; +} + +/** + * Connect context provider + * Manages dream connect/networking state with immutable data structures + * Loads data on initialization and saves optimistically via services + */ +export function ConnectProvider({ children, data }: ConnectProviderProps) { + const session = useSession(); + const errors = useErrors(); + const [state, setState] = useState(List(data)); + const [connects, setConnects] = useOptimistic(state); + const [pending, startTransition] = useTransition(); + + if (!session.data?.user?.id) { + return null; + } + + const userId = session.data.user.id; + + /** + * Add a connect with optimistic update and server persistence + */ + const addConnect = useCallback( + (connect: Connect) => { + const next = connects.push(connect); + setConnects(next); + + startTransition(async () => { + const result = await ConnectsService.saveConnect({ + userId, + connectData: connect, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setConnects, connects, userId], + ); + + /** + * Update a connect with optimistic update and server persistence + */ + const updateConnect = useCallback( + (id: string, updates: Partial) => { + const index = connects.findIndex((c) => c.id === id); + if (index === -1) return; + + const next = connects.update(index, (c) => ({ ...c!, ...updates })); + setConnects(next); + + startTransition(async () => { + const connectData = next.get(index)!; + const result = await ConnectsService.saveConnect({ + userId, + connectData, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setConnects, connects, userId], + ); + + /** + * Delete a connect with optimistic update and server persistence + */ + const deleteConnect = useCallback( + (id: string) => { + const index = connects.findIndex((c) => c.id === id); + if (index === -1) return; + + const next = connects.delete(index); + setConnects(next); + + startTransition(async () => { + const result = await ConnectsService.deleteConnect({ + userId, + connectId: id, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setConnects, connects, userId], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to use connect context + */ +export function useConnects() { + const context = useContext(ConnectContext); + if (context === undefined) { + throw new Error("useConnects must be used within a ConnectProvider"); + } + + return context; +} diff --git a/apps/web/lib/contexts/connects/index.ts b/apps/web/lib/contexts/connects/index.ts new file mode 100644 index 0000000..8cbc108 --- /dev/null +++ b/apps/web/lib/contexts/connects/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; + +export * from "./ConnectContext"; diff --git a/apps/web/lib/contexts/connects/types.ts b/apps/web/lib/contexts/connects/types.ts new file mode 100644 index 0000000..9040df4 --- /dev/null +++ b/apps/web/lib/contexts/connects/types.ts @@ -0,0 +1,18 @@ +/** + * Connect data type + */ +export type Connect = { + id: string; + userId: string; + dreamId?: string; + withWhom: string; + withWhomId: string; + when?: string; + notes?: string; + status?: "pending" | "completed"; + agenda?: string; + proposedWeeks?: string[]; + schedulingMethod?: string; + createdAt?: string; + updatedAt?: string; +}; diff --git a/apps/web/lib/contexts/goals/GoalContext.tsx b/apps/web/lib/contexts/goals/GoalContext.tsx new file mode 100644 index 0000000..79d5212 --- /dev/null +++ b/apps/web/lib/contexts/goals/GoalContext.tsx @@ -0,0 +1,191 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + ReactNode, + useOptimistic, + useCallback, + useTransition, +} from "react"; +import { List } from "immutable"; +import * as WeeksService from "@/services/weeks"; +import { WeeklyGoal } from "./types"; +import { useSession } from "next-auth/react"; +import { useErrors } from "../ErrorsContext"; + +/** + * Goal context state + */ +type GoalContextState = { + goals: List; + pending: boolean; + add: (goal: WeeklyGoal) => void; + update: (id: string, updates: Partial) => void; + $delete: (id: string) => void; + toggle: (id: string) => void; +}; + +const GoalContext = createContext(undefined); + +interface GoalProviderProps { + children: ReactNode; + data: WeeklyGoal[]; + weekId: string; +} + +/** + * Goal context provider + * Manages weekly goals state with immutable data structures + * Loads data on initialization and saves optimistically via services + */ +export function GoalProvider({ children, data, weekId }: GoalProviderProps) { + const session = useSession(); + const errors = useErrors(); + const [state, setState] = useState(List(data)); + const [goals, setGoals] = useOptimistic(state); + const [pending, startTransition] = useTransition(); + + if (!session.data?.user?.id) { + return null; + } + + const userId = session.data.user.id; + + /** + * Add a weekly goal with optimistic update and server persistence + */ + const addGoal = useCallback( + (goal: WeeklyGoal) => { + const next = goals.push(goal); + setGoals(next); + + startTransition(async () => { + const result = await WeeksService.saveCurrentWeek({ + userId, + weekId, + goals: next.toArray(), + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setGoals, goals, weekId, userId], + ); + + /** + * Update a weekly goal with optimistic update and server persistence + */ + const updateGoal = useCallback( + (id: string, updates: Partial) => { + const index = goals.findIndex((g) => g.id === id); + if (index === -1) return; + + const next = goals.update(index, (g) => ({ ...g!, ...updates })); + setGoals(next); + + startTransition(async () => { + const result = await WeeksService.saveCurrentWeek({ + userId, + weekId, + goals: next.toArray(), + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setGoals, goals, weekId, userId], + ); + + /** + * Delete a weekly goal with optimistic update and server persistence + */ + const deleteGoal = useCallback( + (id: string) => { + const index = goals.findIndex((g) => g.id === id); + if (index === -1) return; + + const next = goals.delete(index); + setGoals(next); + + startTransition(async () => { + const result = await WeeksService.saveCurrentWeek({ + userId, + weekId, + goals: next.toArray(), + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setGoals, goals, weekId, userId], + ); + + /** + * Toggle a weekly goal completion with optimistic update and server persistence + */ + const toggleGoal = useCallback( + (id: string) => { + const index = goals.findIndex((g) => g.id === id); + if (index === -1) return; + + const next = goals.update(index, (g) => ({ + ...g!, + completed: !g!.completed, + completedAt: !g!.completed ? new Date().toISOString() : undefined, + })); + setGoals(next); + + startTransition(async () => { + const result = await WeeksService.saveCurrentWeek({ + userId, + weekId, + goals: next.toArray(), + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setGoals, goals, weekId, userId], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to use goal context + */ +export function useGoals() { + const context = useContext(GoalContext); + if (context === undefined) { + throw new Error("useGoals must be used within a GoalProvider"); + } + + return context; +} diff --git a/apps/web/lib/contexts/goals/index.ts b/apps/web/lib/contexts/goals/index.ts new file mode 100644 index 0000000..27eb054 --- /dev/null +++ b/apps/web/lib/contexts/goals/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; + +export * from "./GoalContext"; diff --git a/apps/web/lib/contexts/goals/types.ts b/apps/web/lib/contexts/goals/types.ts new file mode 100644 index 0000000..82e0498 --- /dev/null +++ b/apps/web/lib/contexts/goals/types.ts @@ -0,0 +1,15 @@ +/** + * Weekly goal data type + */ +export type WeeklyGoal = { + id: string; + title: string; + dreamId?: string; + goalId?: string; + completed: boolean; + completedAt?: string; + weekId: string; + recurrence: "weekly" | "once"; + active: boolean; + weekLog?: Record; +}; diff --git a/apps/web/lib/contexts/index.ts b/apps/web/lib/contexts/index.ts index 10f9293..1825e17 100644 --- a/apps/web/lib/contexts/index.ts +++ b/apps/web/lib/contexts/index.ts @@ -3,9 +3,12 @@ * Barrel export for all context providers and hooks */ -export * from './dreams/DreamContext'; -export * from './GoalContext'; -export * from './UserContext'; -export * from './ConnectContext'; -export * from './TeamContext'; -export * from './ScoringContext'; +export * from "./dreams"; +export * from "./goals"; +export * from "./users"; +export * from "./connects"; +export * from "./teams"; +export * from "./scoring"; +export * from "./ErrorsContext"; +export * from "./AppProviders"; + diff --git a/apps/web/lib/contexts/scoring/ScoringContext.tsx b/apps/web/lib/contexts/scoring/ScoringContext.tsx new file mode 100644 index 0000000..41d7d86 --- /dev/null +++ b/apps/web/lib/contexts/scoring/ScoringContext.tsx @@ -0,0 +1,124 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + ReactNode, + useOptimistic, + useCallback, + useTransition, +} from "react"; +import { List } from "immutable"; +import * as ScoringService from "@/services/scoring"; +import { ScoringEntry } from "./types"; +import { useSession } from "next-auth/react"; +import { useErrors } from "../ErrorsContext"; + +/** + * Scoring context state + */ +type ScoringContextState = { + entries: List; + allTimeScore: number; + pending: boolean; + add: (entry: ScoringEntry) => void; +}; + +const ScoringContext = createContext( + undefined, +); + +interface ScoringProviderProps { + children: ReactNode; + data: ScoringEntry[]; + allTimeScore: number; +} + +/** + * Scoring context provider + * Manages scorecard and activity scoring state with immutable data structures + * Loads data on initialization and saves optimistically via services + */ +export function ScoringProvider({ + children, + data, + allTimeScore: initialScore, +}: ScoringProviderProps) { + const session = useSession(); + const errors = useErrors(); + const [state, setState] = useState(List(data)); + const [entries, setEntries] = useOptimistic(state); + const [scoreState, setScoreState] = useState(initialScore); + const [allTimeScore, setAllTimeScore] = useOptimistic(scoreState); + const [pending, startTransition] = useTransition(); + + if (!session.data?.user?.id) { + return null; + } + + const userId = session.data.user.id; + + /** + * Add a scoring entry with optimistic update and server persistence + */ + const addEntry = useCallback( + (entry: ScoringEntry) => { + const next = entries.unshift(entry); + const nextScore = allTimeScore + entry.points; + setEntries(next); + setAllTimeScore(nextScore); + + startTransition(async () => { + const year = new Date(entry.date).getFullYear(); + const result = await ScoringService.saveScoring({ + userId, + year, + entry: { + id: entry.id, + date: entry.date, + source: entry.source || "manual", + dreamId: entry.dreamId, + weekId: entry.weekId, + connectId: entry.connectId, + points: entry.points, + activity: entry.activity, + createdAt: entry.createdAt, + }, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + setScoreState(nextScore); + } + }); + }, + [setState, setEntries, entries, allTimeScore, setAllTimeScore, userId], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to use scoring context + */ +export function useScoring() { + const context = useContext(ScoringContext); + if (context === undefined) { + throw new Error("useScoring must be used within a ScoringProvider"); + } + + return context; +} diff --git a/apps/web/lib/contexts/scoring/index.ts b/apps/web/lib/contexts/scoring/index.ts new file mode 100644 index 0000000..465aa8d --- /dev/null +++ b/apps/web/lib/contexts/scoring/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; + +export * from "./ScoringContext"; diff --git a/apps/web/lib/contexts/scoring/types.ts b/apps/web/lib/contexts/scoring/types.ts new file mode 100644 index 0000000..88b97af --- /dev/null +++ b/apps/web/lib/contexts/scoring/types.ts @@ -0,0 +1,16 @@ +/** + * Scoring entry data type + */ +export type ScoringEntry = { + id: string; + date: string; + score: number; + activity: string; + points: number; + category?: string; + source?: string; + dreamId?: string; + weekId?: string; + connectId?: string; + createdAt?: string; +}; diff --git a/apps/web/lib/contexts/teams/TeamContext.tsx b/apps/web/lib/contexts/teams/TeamContext.tsx new file mode 100644 index 0000000..6809b47 --- /dev/null +++ b/apps/web/lib/contexts/teams/TeamContext.tsx @@ -0,0 +1,167 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + ReactNode, + useOptimistic, + useCallback, + useTransition, +} from "react"; +import { List } from "immutable"; +import * as TeamsService from "@/services/teams"; +import { TeamInfo, Meeting } from "./types"; +import { useSession } from "next-auth/react"; +import { useErrors } from "../ErrorsContext"; + +/** + * Team context state + */ +type TeamContextState = { + teamInfo: TeamInfo | null; + meetings: List; + pending: boolean; + updateTeamInfo: (updates: Partial) => void; + addMeeting: (meeting: Meeting) => void; + updateMeeting: (id: string, updates: Partial) => void; +}; + +const TeamContext = createContext(undefined); + +interface TeamProviderProps { + children: ReactNode; + teamData: TeamInfo | null; + meetingsData: Meeting[]; +} + +/** + * Team context provider + * Manages team collaboration state with immutable data structures + * Loads data on initialization and saves optimistically via services + */ +export function TeamProvider({ + children, + teamData, + meetingsData, +}: TeamProviderProps) { + const session = useSession(); + const errors = useErrors(); + const [teamState, setTeamState] = useState(teamData); + const [teamInfo, setTeamInfo] = useOptimistic(teamState); + const [meetingsState, setMeetingsState] = useState(List(meetingsData)); + const [meetings, setMeetings] = useOptimistic(meetingsState); + const [pending, startTransition] = useTransition(); + + if (!session.data?.user?.id) { + return null; + } + + const userId = session.data.user.id; + + /** + * Update team info with optimistic update and server persistence + */ + const updateTeamInfoAction = useCallback( + (updates: Partial) => { + if (!teamInfo) return; + + const next = { ...teamInfo, ...updates }; + setTeamInfo(next); + + startTransition(async () => { + const result = await TeamsService.updateTeamInfo({ + managerId: userId, + ...updates, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setTeamState(next); + } + }); + }, + [setTeamState, setTeamInfo, teamInfo, userId], + ); + + /** + * Add a meeting with optimistic update and server persistence + */ + const addMeeting = useCallback( + (meeting: Meeting) => { + const next = meetings.push(meeting); + setMeetings(next); + + startTransition(async () => { + const result = await TeamsService.saveMeetingAttendance({ + userId, + date: meeting.date, + attendees: meeting.attendees, + notes: meeting.notes, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setMeetingsState(next); + } + }); + }, + [setMeetingsState, setMeetings, meetings, userId], + ); + + /** + * Update a meeting with optimistic update and server persistence + */ + const updateMeeting = useCallback( + (id: string, updates: Partial) => { + const index = meetings.findIndex((m) => m.id === id); + if (index === -1) return; + + const next = meetings.update(index, (m) => ({ ...m!, ...updates })); + setMeetings(next); + + startTransition(async () => { + const meetingData = next.get(index)!; + const result = await TeamsService.saveMeetingAttendance({ + userId, + date: meetingData.date, + attendees: meetingData.attendees, + notes: meetingData.notes, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setMeetingsState(next); + } + }); + }, + [setMeetingsState, setMeetings, meetings, userId], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to use team context + */ +export function useTeam() { + const context = useContext(TeamContext); + if (context === undefined) { + throw new Error("useTeam must be used within a TeamProvider"); + } + + return context; +} diff --git a/apps/web/lib/contexts/teams/index.ts b/apps/web/lib/contexts/teams/index.ts new file mode 100644 index 0000000..731502d --- /dev/null +++ b/apps/web/lib/contexts/teams/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; + +export * from "./TeamContext"; diff --git a/apps/web/lib/contexts/teams/types.ts b/apps/web/lib/contexts/teams/types.ts new file mode 100644 index 0000000..7727786 --- /dev/null +++ b/apps/web/lib/contexts/teams/types.ts @@ -0,0 +1,34 @@ +/** + * Team member data type + */ +export type TeamMember = { + id: string; + name: string; + email?: string; + avatar?: string; + role?: string; + dreamsCompleted?: number; + meetingAttendance?: number; +}; + +/** + * Meeting data type + */ +export type Meeting = { + id: string; + date: string; + attendees: string[]; + notes?: string; + teamId?: string; +}; + +/** + * Team info data type + */ +export type TeamInfo = { + id: string; + name: string; + mission?: string; + coachId?: string; + members?: TeamMember[]; +}; diff --git a/apps/web/lib/contexts/users/UserContext.tsx b/apps/web/lib/contexts/users/UserContext.tsx new file mode 100644 index 0000000..de4b01f --- /dev/null +++ b/apps/web/lib/contexts/users/UserContext.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + ReactNode, + useOptimistic, + useCallback, + useTransition, +} from "react"; +import * as UsersService from "@/services/users"; +import { User } from "./types"; +import { useSession } from "next-auth/react"; +import { useErrors } from "../ErrorsContext"; + +/** + * User context state + */ +type UserContextState = { + user: User | null; + pending: boolean; + update: (updates: Partial) => void; + updateScore: (score: number) => void; +}; + +const UserContext = createContext(undefined); + +interface UserProviderProps { + children: ReactNode; + data: User | null; +} + +/** + * User context provider + * Manages current user profile state with immutable data structures + * Loads data on initialization and saves optimistically via services + */ +export function UserProvider({ children, data }: UserProviderProps) { + const session = useSession(); + const errors = useErrors(); + const [state, setState] = useState(data); + const [user, setUser] = useOptimistic(state); + const [pending, startTransition] = useTransition(); + + if (!session.data?.user?.id) { + return null; + } + + const userId = session.data.user.id; + + /** + * Update user profile with optimistic update and server persistence + */ + const updateUser = useCallback( + (updates: Partial) => { + if (!user) return; + + const next = { ...user, ...updates }; + setUser(next); + + startTransition(async () => { + const result = await UsersService.saveUserData({ + userId, + ...next, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setUser, user, userId], + ); + + /** + * Update score with optimistic update and server persistence + */ + const updateScore = useCallback( + (score: number) => { + if (!user) return; + + const next = { ...user, score }; + setUser(next); + + startTransition(async () => { + const result = await UsersService.saveUserData({ + userId, + score, + }); + if (result.failed) { + errors.dispatch(result.errors._errors.join(",")); + } else { + setState(next); + } + }); + }, + [setState, setUser, user, userId], + ); + + return ( + + {children} + + ); +} + +/** + * Hook to use user context + */ +export function useUser() { + const context = useContext(UserContext); + if (context === undefined) { + throw new Error("useUser must be used within a UserProvider"); + } + + return context; +} diff --git a/apps/web/lib/contexts/users/index.ts b/apps/web/lib/contexts/users/index.ts new file mode 100644 index 0000000..5f951aa --- /dev/null +++ b/apps/web/lib/contexts/users/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; + +export * from "./UserContext"; diff --git a/apps/web/lib/contexts/users/types.ts b/apps/web/lib/contexts/users/types.ts new file mode 100644 index 0000000..9fa5a2e --- /dev/null +++ b/apps/web/lib/contexts/users/types.ts @@ -0,0 +1,19 @@ +/** + * User data type + */ +export type User = { + id: string; + email: string; + name: string; + displayName?: string; + office?: string; + avatar?: string; + jobTitle?: string; + department?: string; + role?: "user" | "coach" | "admin"; + isCoach?: boolean; + score?: number; + dreamsCount?: number; + connectsCount?: number; + dreamCategories?: string[]; +}; From 4f2804a9f81c9550889c432946b98bb235da31bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:18:28 +0000 Subject: [PATCH 09/12] Refactor contexts to be read-only with server action mutations - Remove all mutation methods from contexts (add, update, delete, toggle) - Contexts now only provide read-only data via immutable Lists - Create form actions for dreams (saveDreamFormAction, deleteDreamFormAction) compatible with useActionState - Create non-form mutations for reorder and simple updates (reorderDreams, updateYearVision) - All server actions use revalidatePath to trigger context data refresh - Simplify all 6 contexts to be consistent read-only providers - Remove useOptimistic, useTransition, useSession, useErrors from contexts - Contexts no longer need authentication checks - handled by server actions Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- .../lib/contexts/connects/ConnectContext.tsx | 124 +--------- apps/web/lib/contexts/dreams/DreamContext.tsx | 182 ++------------ apps/web/lib/contexts/goals/GoalContext.tsx | 161 +----------- .../lib/contexts/scoring/ScoringContext.tsx | 86 +------ apps/web/lib/contexts/teams/TeamContext.tsx | 131 +--------- apps/web/lib/contexts/users/UserContext.tsx | 96 +------- apps/web/services/dreams/formActions.ts | 231 ++++++++++++++++++ apps/web/services/dreams/index.ts | 6 + apps/web/services/dreams/mutations.ts | 100 ++++++++ 9 files changed, 408 insertions(+), 709 deletions(-) create mode 100644 apps/web/services/dreams/formActions.ts create mode 100644 apps/web/services/dreams/mutations.ts diff --git a/apps/web/lib/contexts/connects/ConnectContext.tsx b/apps/web/lib/contexts/connects/ConnectContext.tsx index ac2acd7..48899bd 100644 --- a/apps/web/lib/contexts/connects/ConnectContext.tsx +++ b/apps/web/lib/contexts/connects/ConnectContext.tsx @@ -1,29 +1,15 @@ "use client"; -import React, { - createContext, - useContext, - useState, - ReactNode, - useOptimistic, - useCallback, - useTransition, -} from "react"; +import React, { createContext, useContext, ReactNode } from "react"; import { List } from "immutable"; -import * as ConnectsService from "@/services/connects"; import { Connect } from "./types"; -import { useSession } from "next-auth/react"; -import { useErrors } from "../ErrorsContext"; /** - * Connect context state + * Connect context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation */ type ConnectContextState = { connects: List; - pending: boolean; - add: (connect: Connect) => void; - update: (id: string, updates: Partial) => void; - $delete: (id: string) => void; }; const ConnectContext = createContext( @@ -36,107 +22,16 @@ interface ConnectProviderProps { } /** - * Connect context provider - * Manages dream connect/networking state with immutable data structures - * Loads data on initialization and saves optimistically via services + * Connect context provider - READ ONLY + * Provides dream connect/networking data to components. + * Mutations are handled via server actions (not context methods). + * Server actions should use revalidatePath/revalidateTag to refresh this data. */ export function ConnectProvider({ children, data }: ConnectProviderProps) { - const session = useSession(); - const errors = useErrors(); - const [state, setState] = useState(List(data)); - const [connects, setConnects] = useOptimistic(state); - const [pending, startTransition] = useTransition(); - - if (!session.data?.user?.id) { - return null; - } - - const userId = session.data.user.id; - - /** - * Add a connect with optimistic update and server persistence - */ - const addConnect = useCallback( - (connect: Connect) => { - const next = connects.push(connect); - setConnects(next); - - startTransition(async () => { - const result = await ConnectsService.saveConnect({ - userId, - connectData: connect, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setConnects, connects, userId], - ); - - /** - * Update a connect with optimistic update and server persistence - */ - const updateConnect = useCallback( - (id: string, updates: Partial) => { - const index = connects.findIndex((c) => c.id === id); - if (index === -1) return; - - const next = connects.update(index, (c) => ({ ...c!, ...updates })); - setConnects(next); - - startTransition(async () => { - const connectData = next.get(index)!; - const result = await ConnectsService.saveConnect({ - userId, - connectData, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setConnects, connects, userId], - ); - - /** - * Delete a connect with optimistic update and server persistence - */ - const deleteConnect = useCallback( - (id: string) => { - const index = connects.findIndex((c) => c.id === id); - if (index === -1) return; - - const next = connects.delete(index); - setConnects(next); - - startTransition(async () => { - const result = await ConnectsService.deleteConnect({ - userId, - connectId: id, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setConnects, connects, userId], - ); - return ( {children} @@ -145,7 +40,8 @@ export function ConnectProvider({ children, data }: ConnectProviderProps) { } /** - * Hook to use connect context + * Hook to use connect context - READ ONLY + * For mutations, use server actions from @/services/connects */ export function useConnects() { const context = useContext(ConnectContext); diff --git a/apps/web/lib/contexts/dreams/DreamContext.tsx b/apps/web/lib/contexts/dreams/DreamContext.tsx index 91e3c10..a1dcf93 100644 --- a/apps/web/lib/contexts/dreams/DreamContext.tsx +++ b/apps/web/lib/contexts/dreams/DreamContext.tsx @@ -1,32 +1,16 @@ "use client"; -import React, { - createContext, - useContext, - useState, - ReactNode, - useOptimistic, - useCallback, - useTransition, -} from "react"; +import React, { createContext, useContext, ReactNode } from "react"; import { List } from "immutable"; -import * as DreamsService from "@/services/dreams"; import { Dream } from "./types"; -import { useSession } from "next-auth/react"; -import { useErrors } from "../ErrorsContext"; /** - * Dream context state + * Dream context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation */ type DreamContextState = { dreams: List; yearVision: string; - pending: boolean; - setYearVision: (vision: string) => void; - add: (dream: Dream) => void; - update: (id: string, updates: Partial) => void; - $delete: (id: string) => void; - reorder: (dreams: Dream[]) => void; }; const DreamContext = createContext(undefined); @@ -34,158 +18,25 @@ const DreamContext = createContext(undefined); interface DreamsProviderProps { children: ReactNode; data: Dream[]; + yearVision?: string; } /** - * Dream context provider - * Manages dream book state with immutable data structures - * Loads data on initialization and saves optimistically via services + * Dream context provider - READ ONLY + * Provides dream book data to components. + * Mutations are handled via server actions (not context methods). + * Server actions should use revalidatePath/revalidateTag to refresh this data. */ -export function DreamProvider({ children, data }: DreamsProviderProps) { - const session = useSession(); - const errors = useErrors(); - const [state, setState] = useState(List(data)); - const [dreams, setDreams] = useOptimistic(state); - const [visionState, setVisionState] = useState(""); - const [vision, setVision] = useOptimistic(visionState); - const [pending, startTransition] = useTransition(); - - if (!session.data?.user?.id) { - return null; - } - - const userId = session.data.user.id; - - /** - * Update year vision with optimistic update and server persistence - */ - const setYearVision = (vision: string) => { - // Optimistic update - setVision(vision); - - startTransition(async () => { - const result = await DreamsService.saveYearVision({ - userId, - yearVision: vision, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setVisionState(vision); - } - }); - }; - - /** - * Add a dream with optimistic update and server persistence - */ - const addDream = useCallback( - (dream: Dream) => { - // Optimistic update - const next = dreams.push(dream); - setDreams(next); - - startTransition(async () => { - const result = await DreamsService.saveDreams({ - userId, - dreams: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setDreams, dreams], - ); - - /** - * Update a dream with optimistic update and server persistence - */ - const updateDream = useCallback( - (id: string, updates: Partial) => { - const index = dreams.findIndex((d) => d.id === id); - if (index === -1) return; - - const next = dreams.update(index, (d) => ({ ...d!, ...updates })); - setDreams(next); - - startTransition(async () => { - const result = await DreamsService.saveDreams({ - userId, - dreams: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setDreams, dreams], - ); - - /** - * Delete a dream with optimistic update and server persistence - */ - const deleteDream = useCallback( - (id: string) => { - const index = dreams.findIndex((d) => d.id === id); - if (index === -1) return; - - const next = dreams.delete(index); - setDreams(next); - - startTransition(async () => { - const result = await DreamsService.saveDreams({ - userId, - dreams: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setDreams, dreams], - ); - - /** - * Reorder dreams with optimistic update and server persistence - */ - const reorderDreams = useCallback( - (newDreams: Dream[]) => { - const next = List(newDreams); - setDreams(next); - - startTransition(async () => { - const result = await DreamsService.saveDreams({ - userId, - dreams: newDreams, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setDreams], - ); - +export function DreamProvider({ + children, + data, + yearVision = "", +}: DreamsProviderProps) { return ( {children} @@ -194,7 +45,8 @@ export function DreamProvider({ children, data }: DreamsProviderProps) { } /** - * Hook to use dream context + * Hook to use dream context - READ ONLY + * For mutations, use server actions from @/services/dreams */ export function useDreams() { const context = useContext(DreamContext); diff --git a/apps/web/lib/contexts/goals/GoalContext.tsx b/apps/web/lib/contexts/goals/GoalContext.tsx index 79d5212..3b01fe0 100644 --- a/apps/web/lib/contexts/goals/GoalContext.tsx +++ b/apps/web/lib/contexts/goals/GoalContext.tsx @@ -1,30 +1,16 @@ "use client"; -import React, { - createContext, - useContext, - useState, - ReactNode, - useOptimistic, - useCallback, - useTransition, -} from "react"; +import React, { createContext, useContext, ReactNode } from "react"; import { List } from "immutable"; -import * as WeeksService from "@/services/weeks"; import { WeeklyGoal } from "./types"; -import { useSession } from "next-auth/react"; -import { useErrors } from "../ErrorsContext"; /** - * Goal context state + * Goal context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation */ type GoalContextState = { goals: List; - pending: boolean; - add: (goal: WeeklyGoal) => void; - update: (id: string, updates: Partial) => void; - $delete: (id: string) => void; - toggle: (id: string) => void; + weekId: string; }; const GoalContext = createContext(undefined); @@ -36,141 +22,17 @@ interface GoalProviderProps { } /** - * Goal context provider - * Manages weekly goals state with immutable data structures - * Loads data on initialization and saves optimistically via services + * Goal context provider - READ ONLY + * Provides weekly goals data to components. + * Mutations are handled via server actions (not context methods). + * Server actions should use revalidatePath/revalidateTag to refresh this data. */ export function GoalProvider({ children, data, weekId }: GoalProviderProps) { - const session = useSession(); - const errors = useErrors(); - const [state, setState] = useState(List(data)); - const [goals, setGoals] = useOptimistic(state); - const [pending, startTransition] = useTransition(); - - if (!session.data?.user?.id) { - return null; - } - - const userId = session.data.user.id; - - /** - * Add a weekly goal with optimistic update and server persistence - */ - const addGoal = useCallback( - (goal: WeeklyGoal) => { - const next = goals.push(goal); - setGoals(next); - - startTransition(async () => { - const result = await WeeksService.saveCurrentWeek({ - userId, - weekId, - goals: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setGoals, goals, weekId, userId], - ); - - /** - * Update a weekly goal with optimistic update and server persistence - */ - const updateGoal = useCallback( - (id: string, updates: Partial) => { - const index = goals.findIndex((g) => g.id === id); - if (index === -1) return; - - const next = goals.update(index, (g) => ({ ...g!, ...updates })); - setGoals(next); - - startTransition(async () => { - const result = await WeeksService.saveCurrentWeek({ - userId, - weekId, - goals: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setGoals, goals, weekId, userId], - ); - - /** - * Delete a weekly goal with optimistic update and server persistence - */ - const deleteGoal = useCallback( - (id: string) => { - const index = goals.findIndex((g) => g.id === id); - if (index === -1) return; - - const next = goals.delete(index); - setGoals(next); - - startTransition(async () => { - const result = await WeeksService.saveCurrentWeek({ - userId, - weekId, - goals: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setGoals, goals, weekId, userId], - ); - - /** - * Toggle a weekly goal completion with optimistic update and server persistence - */ - const toggleGoal = useCallback( - (id: string) => { - const index = goals.findIndex((g) => g.id === id); - if (index === -1) return; - - const next = goals.update(index, (g) => ({ - ...g!, - completed: !g!.completed, - completedAt: !g!.completed ? new Date().toISOString() : undefined, - })); - setGoals(next); - - startTransition(async () => { - const result = await WeeksService.saveCurrentWeek({ - userId, - weekId, - goals: next.toArray(), - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setGoals, goals, weekId, userId], - ); - return ( {children} @@ -179,7 +41,8 @@ export function GoalProvider({ children, data, weekId }: GoalProviderProps) { } /** - * Hook to use goal context + * Hook to use goal context - READ ONLY + * For mutations, use server actions from @/services/weeks */ export function useGoals() { const context = useContext(GoalContext); diff --git a/apps/web/lib/contexts/scoring/ScoringContext.tsx b/apps/web/lib/contexts/scoring/ScoringContext.tsx index 41d7d86..a33e841 100644 --- a/apps/web/lib/contexts/scoring/ScoringContext.tsx +++ b/apps/web/lib/contexts/scoring/ScoringContext.tsx @@ -1,28 +1,16 @@ "use client"; -import React, { - createContext, - useContext, - useState, - ReactNode, - useOptimistic, - useCallback, - useTransition, -} from "react"; +import React, { createContext, useContext, ReactNode } from "react"; import { List } from "immutable"; -import * as ScoringService from "@/services/scoring"; import { ScoringEntry } from "./types"; -import { useSession } from "next-auth/react"; -import { useErrors } from "../ErrorsContext"; /** - * Scoring context state + * Scoring context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation */ type ScoringContextState = { entries: List; allTimeScore: number; - pending: boolean; - add: (entry: ScoringEntry) => void; }; const ScoringContext = createContext( @@ -36,74 +24,21 @@ interface ScoringProviderProps { } /** - * Scoring context provider - * Manages scorecard and activity scoring state with immutable data structures - * Loads data on initialization and saves optimistically via services + * Scoring context provider - READ ONLY + * Provides scorecard and activity scoring data to components. + * Mutations are handled via server actions (not context methods). + * Server actions should use revalidatePath/revalidateTag to refresh this data. */ export function ScoringProvider({ children, data, - allTimeScore: initialScore, + allTimeScore, }: ScoringProviderProps) { - const session = useSession(); - const errors = useErrors(); - const [state, setState] = useState(List(data)); - const [entries, setEntries] = useOptimistic(state); - const [scoreState, setScoreState] = useState(initialScore); - const [allTimeScore, setAllTimeScore] = useOptimistic(scoreState); - const [pending, startTransition] = useTransition(); - - if (!session.data?.user?.id) { - return null; - } - - const userId = session.data.user.id; - - /** - * Add a scoring entry with optimistic update and server persistence - */ - const addEntry = useCallback( - (entry: ScoringEntry) => { - const next = entries.unshift(entry); - const nextScore = allTimeScore + entry.points; - setEntries(next); - setAllTimeScore(nextScore); - - startTransition(async () => { - const year = new Date(entry.date).getFullYear(); - const result = await ScoringService.saveScoring({ - userId, - year, - entry: { - id: entry.id, - date: entry.date, - source: entry.source || "manual", - dreamId: entry.dreamId, - weekId: entry.weekId, - connectId: entry.connectId, - points: entry.points, - activity: entry.activity, - createdAt: entry.createdAt, - }, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - setScoreState(nextScore); - } - }); - }, - [setState, setEntries, entries, allTimeScore, setAllTimeScore, userId], - ); - return ( {children} @@ -112,7 +47,8 @@ export function ScoringProvider({ } /** - * Hook to use scoring context + * Hook to use scoring context - READ ONLY + * For mutations, use server actions from @/services/scoring */ export function useScoring() { const context = useContext(ScoringContext); diff --git a/apps/web/lib/contexts/teams/TeamContext.tsx b/apps/web/lib/contexts/teams/TeamContext.tsx index 6809b47..bd61498 100644 --- a/apps/web/lib/contexts/teams/TeamContext.tsx +++ b/apps/web/lib/contexts/teams/TeamContext.tsx @@ -1,30 +1,16 @@ "use client"; -import React, { - createContext, - useContext, - useState, - ReactNode, - useOptimistic, - useCallback, - useTransition, -} from "react"; +import React, { createContext, useContext, ReactNode } from "react"; import { List } from "immutable"; -import * as TeamsService from "@/services/teams"; import { TeamInfo, Meeting } from "./types"; -import { useSession } from "next-auth/react"; -import { useErrors } from "../ErrorsContext"; /** - * Team context state + * Team context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation */ type TeamContextState = { teamInfo: TeamInfo | null; meetings: List; - pending: boolean; - updateTeamInfo: (updates: Partial) => void; - addMeeting: (meeting: Meeting) => void; - updateMeeting: (id: string, updates: Partial) => void; }; const TeamContext = createContext(undefined); @@ -36,117 +22,21 @@ interface TeamProviderProps { } /** - * Team context provider - * Manages team collaboration state with immutable data structures - * Loads data on initialization and saves optimistically via services + * Team context provider - READ ONLY + * Provides team collaboration data to components. + * Mutations are handled via server actions (not context methods). + * Server actions should use revalidatePath/revalidateTag to refresh this data. */ export function TeamProvider({ children, teamData, meetingsData, }: TeamProviderProps) { - const session = useSession(); - const errors = useErrors(); - const [teamState, setTeamState] = useState(teamData); - const [teamInfo, setTeamInfo] = useOptimistic(teamState); - const [meetingsState, setMeetingsState] = useState(List(meetingsData)); - const [meetings, setMeetings] = useOptimistic(meetingsState); - const [pending, startTransition] = useTransition(); - - if (!session.data?.user?.id) { - return null; - } - - const userId = session.data.user.id; - - /** - * Update team info with optimistic update and server persistence - */ - const updateTeamInfoAction = useCallback( - (updates: Partial) => { - if (!teamInfo) return; - - const next = { ...teamInfo, ...updates }; - setTeamInfo(next); - - startTransition(async () => { - const result = await TeamsService.updateTeamInfo({ - managerId: userId, - ...updates, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setTeamState(next); - } - }); - }, - [setTeamState, setTeamInfo, teamInfo, userId], - ); - - /** - * Add a meeting with optimistic update and server persistence - */ - const addMeeting = useCallback( - (meeting: Meeting) => { - const next = meetings.push(meeting); - setMeetings(next); - - startTransition(async () => { - const result = await TeamsService.saveMeetingAttendance({ - userId, - date: meeting.date, - attendees: meeting.attendees, - notes: meeting.notes, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setMeetingsState(next); - } - }); - }, - [setMeetingsState, setMeetings, meetings, userId], - ); - - /** - * Update a meeting with optimistic update and server persistence - */ - const updateMeeting = useCallback( - (id: string, updates: Partial) => { - const index = meetings.findIndex((m) => m.id === id); - if (index === -1) return; - - const next = meetings.update(index, (m) => ({ ...m!, ...updates })); - setMeetings(next); - - startTransition(async () => { - const meetingData = next.get(index)!; - const result = await TeamsService.saveMeetingAttendance({ - userId, - date: meetingData.date, - attendees: meetingData.attendees, - notes: meetingData.notes, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setMeetingsState(next); - } - }); - }, - [setMeetingsState, setMeetings, meetings, userId], - ); - return ( {children} @@ -155,7 +45,8 @@ export function TeamProvider({ } /** - * Hook to use team context + * Hook to use team context - READ ONLY + * For mutations, use server actions from @/services/teams */ export function useTeam() { const context = useContext(TeamContext); diff --git a/apps/web/lib/contexts/users/UserContext.tsx b/apps/web/lib/contexts/users/UserContext.tsx index de4b01f..cc9e188 100644 --- a/apps/web/lib/contexts/users/UserContext.tsx +++ b/apps/web/lib/contexts/users/UserContext.tsx @@ -1,27 +1,14 @@ "use client"; -import React, { - createContext, - useContext, - useState, - ReactNode, - useOptimistic, - useCallback, - useTransition, -} from "react"; -import * as UsersService from "@/services/users"; +import React, { createContext, useContext, ReactNode } from "react"; import { User } from "./types"; -import { useSession } from "next-auth/react"; -import { useErrors } from "../ErrorsContext"; /** - * User context state + * User context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation */ type UserContextState = { user: User | null; - pending: boolean; - update: (updates: Partial) => void; - updateScore: (score: number) => void; }; const UserContext = createContext(undefined); @@ -32,80 +19,16 @@ interface UserProviderProps { } /** - * User context provider - * Manages current user profile state with immutable data structures - * Loads data on initialization and saves optimistically via services + * User context provider - READ ONLY + * Provides current user profile data to components. + * Mutations are handled via server actions (not context methods). + * Server actions should use revalidatePath/revalidateTag to refresh this data. */ export function UserProvider({ children, data }: UserProviderProps) { - const session = useSession(); - const errors = useErrors(); - const [state, setState] = useState(data); - const [user, setUser] = useOptimistic(state); - const [pending, startTransition] = useTransition(); - - if (!session.data?.user?.id) { - return null; - } - - const userId = session.data.user.id; - - /** - * Update user profile with optimistic update and server persistence - */ - const updateUser = useCallback( - (updates: Partial) => { - if (!user) return; - - const next = { ...user, ...updates }; - setUser(next); - - startTransition(async () => { - const result = await UsersService.saveUserData({ - userId, - ...next, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setUser, user, userId], - ); - - /** - * Update score with optimistic update and server persistence - */ - const updateScore = useCallback( - (score: number) => { - if (!user) return; - - const next = { ...user, score }; - setUser(next); - - startTransition(async () => { - const result = await UsersService.saveUserData({ - userId, - score, - }); - if (result.failed) { - errors.dispatch(result.errors._errors.join(",")); - } else { - setState(next); - } - }); - }, - [setState, setUser, user, userId], - ); - return ( {children} @@ -114,7 +37,8 @@ export function UserProvider({ children, data }: UserProviderProps) { } /** - * Hook to use user context + * Hook to use user context - READ ONLY + * For mutations, use server actions from @/services/users */ export function useUser() { const context = useContext(UserContext); diff --git a/apps/web/services/dreams/formActions.ts b/apps/web/services/dreams/formActions.ts new file mode 100644 index 0000000..a4fdee5 --- /dev/null +++ b/apps/web/services/dreams/formActions.ts @@ -0,0 +1,231 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withAuth, createActionSuccess, handleActionError } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; + +/** + * Form action state for dream mutations + */ +export type DreamFormState = { + success: boolean; + errors?: { + title?: string[]; + category?: string[]; + description?: string[]; + _form?: string[]; + }; + data?: { + id: string; + }; +}; + +/** + * Schema for dream form data + */ +const dreamFormSchema = zfd.formData({ + id: zfd.text(z.string().optional()), + title: zfd.text(z.string().min(1, 'Title is required')), + category: zfd.text(z.string().min(1, 'Category is required')), + description: zfd.text(z.string().optional()), + motivation: zfd.text(z.string().optional()), + approach: zfd.text(z.string().optional()), + progress: zfd.numeric(z.number().min(0).max(100).optional()), + image: zfd.text(z.string().optional()), +}); + +/** + * Create or update a dream via form submission + * Compatible with useActionState/useFormState + * + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information + */ +export async function saveDreamFormAction( + prevState: DreamFormState | null, + formData: FormData +): Promise { + try { + // Validate form data + const validatedData = dreamFormSchema.parse(formData); + + // Get authenticated user + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing dreams + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + const existingDreams = dreamsDoc?.dreams || []; + + // Create or update dream + const dreamId = validatedData.id || `dream_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const dreamIndex = existingDreams.findIndex((d: any) => d.id === dreamId); + + const dreamData = { + id: dreamId, + title: validatedData.title, + category: validatedData.category, + description: validatedData.description || '', + motivation: validatedData.motivation || '', + approach: validatedData.approach || '', + progress: validatedData.progress || 0, + image: validatedData.image || '', + notes: dreamIndex >= 0 ? existingDreams[dreamIndex].notes : [], + coachNotes: dreamIndex >= 0 ? existingDreams[dreamIndex].coachNotes : [], + history: dreamIndex >= 0 ? existingDreams[dreamIndex].history : [], + goals: dreamIndex >= 0 ? existingDreams[dreamIndex].goals : [], + completed: dreamIndex >= 0 ? existingDreams[dreamIndex].completed : false, + isPublic: dreamIndex >= 0 ? existingDreams[dreamIndex].isPublic : false, + createdAt: dreamIndex >= 0 ? existingDreams[dreamIndex].createdAt : new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Update dreams array + let updatedDreams; + if (dreamIndex >= 0) { + updatedDreams = [...existingDreams]; + updatedDreams[dreamIndex] = dreamData; + } else { + updatedDreams = [...existingDreams, dreamData]; + } + + // Save to database + const document = { + id: userId, + userId: userId, + dreams: updatedDreams, + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: dreamsDoc?.yearVision || '', + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + + return createActionSuccess({ id: dreamId }); + })({}); + + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to save dream'], + }, + }; + } + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: result.id }, + }; + } catch (error) { + console.error('Failed to save dream:', error); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + title: error.formErrors.fieldErrors.title as string[], + category: error.formErrors.fieldErrors.category as string[], + description: error.formErrors.fieldErrors.description as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; + } +} + +/** + * Delete a dream via form submission + * Compatible with useActionState/useFormState + * + * @param prevState - Previous form state + * @param formData - Form data with dream ID + * @returns Form state with success/error information + */ +export async function deleteDreamFormAction( + prevState: DreamFormState | null, + formData: FormData +): Promise { + try { + const dreamId = formData.get('id')?.toString(); + + if (!dreamId) { + return { + success: false, + errors: { + _form: ['Dream ID is required'], + }, + }; + } + + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing dreams + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + const existingDreams = dreamsDoc?.dreams || []; + + // Filter out the dream + const updatedDreams = existingDreams.filter((d: any) => d.id !== dreamId); + + // Save to database + const document = { + id: userId, + userId: userId, + dreams: updatedDreams, + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: dreamsDoc?.yearVision || '', + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + + return createActionSuccess({ id: dreamId }); + })({}); + + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to delete dream'], + }, + }; + } + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: dreamId }, + }; + } catch (error) { + console.error('Failed to delete dream:', error); + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; + } +} diff --git a/apps/web/services/dreams/index.ts b/apps/web/services/dreams/index.ts index 758b20a..0a3bd2c 100644 --- a/apps/web/services/dreams/index.ts +++ b/apps/web/services/dreams/index.ts @@ -5,3 +5,9 @@ export * from './saveDreams'; export * from './uploadDreamPicture'; export * from './saveYearVision'; + +// Form actions (useActionState compatible) +export * from './formActions'; + +// Non-form mutations (reorder, simple updates) +export * from './mutations'; diff --git a/apps/web/services/dreams/mutations.ts b/apps/web/services/dreams/mutations.ts new file mode 100644 index 0000000..f85a785 --- /dev/null +++ b/apps/web/services/dreams/mutations.ts @@ -0,0 +1,100 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { withAuth, createActionSuccess, handleActionError } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; +import { Dream } from '@/lib/contexts/dreams/types'; + +/** + * Reorder dreams (drag and drop) + * Non-form mutation with simple signature + * + * @param dreams - Reordered array of dreams + * @returns Success response + */ +export async function reorderDreams(dreams: Dream[]) { + try { + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing document to preserve other fields + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + + // Update document with reordered dreams + const document = { + id: userId, + userId: userId, + dreams: dreams, + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: dreamsDoc?.yearVision || '', + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + + return createActionSuccess({ count: dreams.length }); + })({}); + + if (result.failed) { + throw new Error(result.errors._errors?.join(', ') || 'Failed to reorder dreams'); + } + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return result; + } catch (error) { + console.error('Failed to reorder dreams:', error); + return handleActionError(error, 'Failed to reorder dreams'); + } +} + +/** + * Update year vision (simple text field) + * Non-form mutation with simple signature + * + * @param vision - New year vision text + * @returns Success response + */ +export async function updateYearVision(vision: string) { + try { + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing document + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + + // Update document with new vision + const document = { + id: userId, + userId: userId, + dreams: dreamsDoc?.dreams || [], + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: vision, + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + + return createActionSuccess({ vision }); + })({}); + + if (result.failed) { + throw new Error(result.errors._errors?.join(', ') || 'Failed to update year vision'); + } + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return result; + } catch (error) { + console.error('Failed to update year vision:', error); + return handleActionError(error, 'Failed to update year vision'); + } +} From fbf31e1a38ed23d372c1fcc4e4120c9471e799d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:47:20 +0000 Subject: [PATCH 10/12] Refactor dream services: one export per file, FormData for saveYearVision - Split formActions.ts into saveDream.ts and deleteDream.ts (one export per file) - Remove "FormAction" suffix from function names (saveDream, not saveDreamFormAction) - Convert saveYearVision.ts to use FormData + Zod (useActionState compatible) - Move deleteDream to non-form mutation (simple signature with dreamId) - Remove updateYearVision from mutations.ts (now saveYearVision form action) - Keep reorderDreams in mutations.ts (non-FormData as specified) - Update index.ts with proper organization and comments Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- apps/web/services/dreams/deleteDream.ts | 56 +++++++ apps/web/services/dreams/index.ts | 9 +- apps/web/services/dreams/mutations.ts | 47 ------ .../dreams/{formActions.ts => saveDream.ts} | 92 +---------- apps/web/services/dreams/saveYearVision.ts | 143 ++++++++++++------ 5 files changed, 162 insertions(+), 185 deletions(-) create mode 100644 apps/web/services/dreams/deleteDream.ts rename apps/web/services/dreams/{formActions.ts => saveDream.ts} (65%) diff --git a/apps/web/services/dreams/deleteDream.ts b/apps/web/services/dreams/deleteDream.ts new file mode 100644 index 0000000..0fc4201 --- /dev/null +++ b/apps/web/services/dreams/deleteDream.ts @@ -0,0 +1,56 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { withAuth } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; + +/** + * Delete a dream + * Non-form mutation with simple signature + * + * @param dreamId - ID of the dream to delete + * @returns Success response or throws error + */ +export async function deleteDream(dreamId: string) { + try { + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing dreams + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + const existingDreams = dreamsDoc?.dreams || []; + + // Filter out the dream + const updatedDreams = existingDreams.filter((d: any) => d.id !== dreamId); + + // Save to database + const document = { + id: userId, + userId: userId, + dreams: updatedDreams, + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: dreamsDoc?.yearVision || '', + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + + return { success: true, id: dreamId }; + })({}); + + if (result.failed) { + throw new Error(result.errors._errors?.join(', ') || 'Failed to delete dream'); + } + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return result; + } catch (error) { + console.error('Failed to delete dream:', error); + throw error; + } +} diff --git a/apps/web/services/dreams/index.ts b/apps/web/services/dreams/index.ts index 0a3bd2c..2f637f4 100644 --- a/apps/web/services/dreams/index.ts +++ b/apps/web/services/dreams/index.ts @@ -2,12 +2,15 @@ * Dreams service exports * Barrel export for all dreams-related server actions */ + +// Legacy bulk operations export * from './saveDreams'; export * from './uploadDreamPicture'; -export * from './saveYearVision'; // Form actions (useActionState compatible) -export * from './formActions'; +export * from './saveDream'; +export * from './saveYearVision'; -// Non-form mutations (reorder, simple updates) +// Non-form mutations (deletes, reorders) +export * from './deleteDream'; export * from './mutations'; diff --git a/apps/web/services/dreams/mutations.ts b/apps/web/services/dreams/mutations.ts index f85a785..5db28f7 100644 --- a/apps/web/services/dreams/mutations.ts +++ b/apps/web/services/dreams/mutations.ts @@ -51,50 +51,3 @@ export async function reorderDreams(dreams: Dream[]) { return handleActionError(error, 'Failed to reorder dreams'); } } - -/** - * Update year vision (simple text field) - * Non-form mutation with simple signature - * - * @param vision - New year vision text - * @returns Success response - */ -export async function updateYearVision(vision: string) { - try { - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get existing document - const dreamsDoc = await db.dreams.getDreamsDocument(userId); - - // Update document with new vision - const document = { - id: userId, - userId: userId, - dreams: dreamsDoc?.dreams || [], - weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], - yearVision: vision, - createdAt: dreamsDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.dreams.upsertDreamsDocument(userId, document); - - return createActionSuccess({ vision }); - })({}); - - if (result.failed) { - throw new Error(result.errors._errors?.join(', ') || 'Failed to update year vision'); - } - - // Revalidate to refresh context data - revalidatePath('/dream-book'); - revalidatePath('/dashboard'); - - return result; - } catch (error) { - console.error('Failed to update year vision:', error); - return handleActionError(error, 'Failed to update year vision'); - } -} diff --git a/apps/web/services/dreams/formActions.ts b/apps/web/services/dreams/saveDream.ts similarity index 65% rename from apps/web/services/dreams/formActions.ts rename to apps/web/services/dreams/saveDream.ts index a4fdee5..d1e20c5 100644 --- a/apps/web/services/dreams/formActions.ts +++ b/apps/web/services/dreams/saveDream.ts @@ -3,13 +3,13 @@ import { revalidatePath } from 'next/cache'; import { z } from 'zod'; import { zfd } from 'zod-form-data'; -import { withAuth, createActionSuccess, handleActionError } from '@/lib/actions'; +import { withAuth, createActionSuccess } from '@/lib/actions'; import { getDatabaseClient } from '@dreamspace/database'; /** - * Form action state for dream mutations + * Form action state for dream save */ -export type DreamFormState = { +export type SaveDreamState = { success: boolean; errors?: { title?: string[]; @@ -44,10 +44,10 @@ const dreamFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function saveDreamFormAction( - prevState: DreamFormState | null, +export async function saveDream( + prevState: SaveDreamState | null, formData: FormData -): Promise { +): Promise { try { // Validate form data const validatedData = dreamFormSchema.parse(formData); @@ -149,83 +149,3 @@ export async function saveDreamFormAction( }; } } - -/** - * Delete a dream via form submission - * Compatible with useActionState/useFormState - * - * @param prevState - Previous form state - * @param formData - Form data with dream ID - * @returns Form state with success/error information - */ -export async function deleteDreamFormAction( - prevState: DreamFormState | null, - formData: FormData -): Promise { - try { - const dreamId = formData.get('id')?.toString(); - - if (!dreamId) { - return { - success: false, - errors: { - _form: ['Dream ID is required'], - }, - }; - } - - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get existing dreams - const dreamsDoc = await db.dreams.getDreamsDocument(userId); - const existingDreams = dreamsDoc?.dreams || []; - - // Filter out the dream - const updatedDreams = existingDreams.filter((d: any) => d.id !== dreamId); - - // Save to database - const document = { - id: userId, - userId: userId, - dreams: updatedDreams, - weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], - yearVision: dreamsDoc?.yearVision || '', - createdAt: dreamsDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.dreams.upsertDreamsDocument(userId, document); - - return createActionSuccess({ id: dreamId }); - })({}); - - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to delete dream'], - }, - }; - } - - // Revalidate to refresh context data - revalidatePath('/dream-book'); - revalidatePath('/dashboard'); - - return { - success: true, - data: { id: dreamId }, - }; - } catch (error) { - console.error('Failed to delete dream:', error); - - return { - success: false, - errors: { - _form: ['An unexpected error occurred'], - }, - }; - } -} diff --git a/apps/web/services/dreams/saveYearVision.ts b/apps/web/services/dreams/saveYearVision.ts index 3c6a527..8ffb819 100644 --- a/apps/web/services/dreams/saveYearVision.ts +++ b/apps/web/services/dreams/saveYearVision.ts @@ -1,63 +1,108 @@ -"use server"; +'use server'; -import { - withAuth, - createActionSuccess, - handleActionError, -} from "@/lib/actions"; -import { getDatabaseClient } from "@dreamspace/database"; - -interface SaveYearVisionInput { - userId: string; - yearVision: string; -} +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withAuth } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; /** - * Saves the year vision for a user. - * Year vision is stored in the dreams document. - * - * @param input - Contains userId and yearVision text - * @returns Success response + * Form action state for year vision save */ -export const saveYearVision = withAuth( - async (user, input: SaveYearVisionInput) => { - try { - const { userId, yearVision } = input; - - if (!userId) { - throw new Error("userId is required"); - } +export type SaveYearVisionState = { + success: boolean; + errors?: { + yearVision?: string[]; + _form?: string[]; + }; + data?: { + yearVision: string; + }; +}; - // Only allow users to save their own year vision - if (user.id !== userId) { - throw new Error("Forbidden"); - } +/** + * Schema for year vision form data + */ +const yearVisionFormSchema = zfd.formData({ + yearVision: zfd.text(z.string()), +}); +/** + * Save year vision via form submission + * Compatible with useActionState/useFormState + * + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information + */ +export async function saveYearVision( + prevState: SaveYearVisionState | null, + formData: FormData +): Promise { + try { + // Validate form data + const validatedData = yearVisionFormSchema.parse(formData); + + // Get authenticated user + const result = await withAuth(async (user) => { + const userId = user.id; const db = getDatabaseClient(); - - // Get existing dreams document or create new one - const existingDoc = await db.dreams.getDreamsDocument(userId); - + + // Get existing document + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + + // Update document with new vision const document = { - ...existingDoc, id: userId, userId: userId, - dreams: existingDoc?.dreams || [], - weeklyGoalTemplates: existingDoc?.weeklyGoalTemplates || [], - yearVision: yearVision, - createdAt: existingDoc?.createdAt || new Date().toISOString(), + dreams: dreamsDoc?.dreams || [], + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: validatedData.yearVision, + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; - + await db.dreams.upsertDreamsDocument(userId, document); - - return createActionSuccess({ - id: userId, - message: "Year vision saved successfully", - }); - } catch (error) { - console.error("Failed to save year vision:", error); - return handleActionError(error, "Failed to save year vision"); + + return { success: true, yearVision: validatedData.yearVision }; + })({}); + + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to save year vision'], + }, + }; } - }, -); + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { yearVision: result.yearVision }, + }; + } catch (error) { + console.error('Failed to save year vision:', error); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + yearVision: error.formErrors.fieldErrors.yearVision as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; + } +} + From e7488b26c5dd6a2cbf2ae6f1da3a242ff35f2e72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:29:19 +0000 Subject: [PATCH 11/12] Apply form action pattern to all services (weeks, connects, scoring, teams, users) - Fix saveDream.ts to use proper DreamsDocument structure with dreamBook field - Create saveGoal.ts form action for weeks service - Refactor saveConnect.ts to use form action pattern - Create saveScore.ts form action for scoring service - Refactor updateTeamName.ts and updateTeamMission.ts to use form actions - Create updateProfile.ts form action for users service - Update all index.ts files to organize exports (form actions vs legacy operations) - All form actions use FormData + Zod validation and are useActionState compatible - Deletes and reorders remain as simple function signatures Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- apps/web/services/connects/index.ts | 6 + apps/web/services/connects/saveConnect.ts | 171 +++++++++++-------- apps/web/services/dreams/saveDream.ts | 41 ++--- apps/web/services/scoring/index.ts | 9 +- apps/web/services/scoring/saveScore.ts | 148 ++++++++++++++++ apps/web/services/teams/index.ts | 10 +- apps/web/services/teams/updateTeamMission.ts | 134 +++++++++++---- apps/web/services/teams/updateTeamName.ts | 133 +++++++++++---- apps/web/services/users/index.ts | 9 +- apps/web/services/users/updateProfile.ts | 131 ++++++++++++++ apps/web/services/weeks/index.ts | 7 + apps/web/services/weeks/saveGoal.ts | 169 ++++++++++++++++++ 12 files changed, 797 insertions(+), 171 deletions(-) create mode 100644 apps/web/services/scoring/saveScore.ts create mode 100644 apps/web/services/users/updateProfile.ts create mode 100644 apps/web/services/weeks/saveGoal.ts diff --git a/apps/web/services/connects/index.ts b/apps/web/services/connects/index.ts index dddefea..68ee257 100644 --- a/apps/web/services/connects/index.ts +++ b/apps/web/services/connects/index.ts @@ -2,6 +2,12 @@ * Connects service exports * Barrel export for all connect-related server actions */ + +// Get operations export * from './getConnects'; + +// Form actions (useActionState compatible) export * from './saveConnect'; + +// Non-form mutations (deletes) export * from './deleteConnect'; diff --git a/apps/web/services/connects/saveConnect.ts b/apps/web/services/connects/saveConnect.ts index ee7ee72..7d08829 100644 --- a/apps/web/services/connects/saveConnect.ts +++ b/apps/web/services/connects/saveConnect.ts @@ -1,89 +1,120 @@ 'use server'; -import { withAuth, createActionSuccess, handleActionError } from '@/lib/actions'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withAuth, createActionSuccess } from '@/lib/actions'; import { getDatabaseClient } from '@dreamspace/database'; -interface SaveConnectInput { - userId: string; - connectData: { - id?: string; - userId?: string; - type?: string; - withWhom: string; - withWhomId: string; - when: string; - notes?: string; - status?: string; - agenda?: string; - proposedWeeks?: string[]; - schedulingMethod?: string; - dreamId?: string; - name?: string; - category?: string; - avatar?: string; - office?: string; - createdAt?: string; +/** + * Form action state for connect save + */ +export type SaveConnectState = { + success: boolean; + errors?: { + connectType?: string[]; + connectDate?: string[]; + recipientName?: string[]; + _form?: string[]; }; -} + data?: { + id: string; + }; +}; + +/** + * Schema for connect form data + */ +const connectFormSchema = zfd.formData({ + id: zfd.text(z.string().optional()), + connectType: zfd.text(z.string().min(1, 'Connect type is required')), + connectDate: zfd.text(z.string().min(1, 'Date is required')), + notes: zfd.text(z.string().optional()), + recipientUserId: zfd.text(z.string().optional()), + recipientName: zfd.text(z.string().optional()), + teamId: zfd.text(z.string().optional()), +}); /** - * Saves a connect (Dream Connect) for a user. - * Uses sender's userId as partition key to keep connects in correct partition. + * Create or update a connect via form submission + * Compatible with useActionState/useFormState * - * @param input - Contains userId and connectData - * @returns Saved connect document + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information */ -export const saveConnect = withAuth(async (user, input: SaveConnectInput) => { +export async function saveConnect( + prevState: SaveConnectState | null, + formData: FormData +): Promise { try { - const { userId, connectData } = input; - - if (!connectData) { - throw new Error('connectData is required'); - } + // Validate form data + const validatedData = connectFormSchema.parse(formData); - // Use the connect's userId (sender's ID) as partition key - const partitionUserId = connectData.userId || userId; + // Get authenticated user + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Create connect ID + const connectId = validatedData.id || `connect_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + // Save to database - match ConnectDocument structure + const document = { + id: connectId, + userId: userId, + connectType: validatedData.connectType, + connectDate: validatedData.connectDate, + notes: validatedData.notes, + recipientUserId: validatedData.recipientUserId, + recipientName: validatedData.recipientName, + teamId: validatedData.teamId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.connects.upsertConnect(userId, document); + + return createActionSuccess({ id: connectId }); + })({}); - if (!partitionUserId) { - throw new Error('userId is required in connectData'); + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to save connect'], + }, + }; } - const db = getDatabaseClient(); + // Revalidate to refresh context data + revalidatePath('/dream-connect'); + revalidatePath('/dashboard'); - // Create the connect document - const connectId = connectData.id - ? String(connectData.id) - : `connect_${partitionUserId}_${Date.now()}`; - - const document = { - id: connectId, - userId: partitionUserId, // Always use sender's userId as partition key - type: connectData.type || 'connect', - withWhom: connectData.withWhom, - withWhomId: connectData.withWhomId, - when: connectData.when, - notes: connectData.notes || '', - status: connectData.status || 'pending', - agenda: connectData.agenda, - proposedWeeks: connectData.proposedWeeks || [], - schedulingMethod: connectData.schedulingMethod, - dreamId: connectData.dreamId || undefined, - name: connectData.name, - category: connectData.category, - avatar: connectData.avatar, - office: connectData.office, - createdAt: connectData.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString() + return { + success: true, + data: { id: result.id }, }; - - await db.connects.upsertConnect(partitionUserId, document); - - return createActionSuccess({ - id: connectId, - connect: document - }); } catch (error) { console.error('Failed to save connect:', error); - return handleActionError(error, 'Failed to save connect'); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + connectType: error.formErrors.fieldErrors.connectType as string[], + connectDate: error.formErrors.fieldErrors.connectDate as string[], + recipientName: error.formErrors.fieldErrors.recipientName as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; } -}); +} diff --git a/apps/web/services/dreams/saveDream.ts b/apps/web/services/dreams/saveDream.ts index d1e20c5..c2c316a 100644 --- a/apps/web/services/dreams/saveDream.ts +++ b/apps/web/services/dreams/saveDream.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { zfd } from 'zod-form-data'; import { withAuth, createActionSuccess } from '@/lib/actions'; import { getDatabaseClient } from '@dreamspace/database'; +import type { DreamBookEntry } from '@dreamspace/shared'; /** * Form action state for dream save @@ -28,12 +29,11 @@ export type SaveDreamState = { const dreamFormSchema = zfd.formData({ id: zfd.text(z.string().optional()), title: zfd.text(z.string().min(1, 'Title is required')), - category: zfd.text(z.string().min(1, 'Category is required')), + category: zfd.text(z.string().optional()), description: zfd.text(z.string().optional()), - motivation: zfd.text(z.string().optional()), - approach: zfd.text(z.string().optional()), - progress: zfd.numeric(z.number().min(0).max(100).optional()), - image: zfd.text(z.string().optional()), + imageUrl: zfd.text(z.string().optional()), + imagePrompt: zfd.text(z.string().optional()), + targetDate: zfd.text(z.string().optional()), }); /** @@ -57,35 +57,29 @@ export async function saveDream( const userId = user.id; const db = getDatabaseClient(); - // Get existing dreams + // Get existing dreams document const dreamsDoc = await db.dreams.getDreamsDocument(userId); - const existingDreams = dreamsDoc?.dreams || []; + const existingDreams = dreamsDoc?.dreamBook || []; // Create or update dream const dreamId = validatedData.id || `dream_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const dreamIndex = existingDreams.findIndex((d: any) => d.id === dreamId); + const dreamIndex = existingDreams.findIndex((d: DreamBookEntry) => d.id === dreamId); - const dreamData = { + const dreamData: DreamBookEntry = { id: dreamId, title: validatedData.title, category: validatedData.category, - description: validatedData.description || '', - motivation: validatedData.motivation || '', - approach: validatedData.approach || '', - progress: validatedData.progress || 0, - image: validatedData.image || '', - notes: dreamIndex >= 0 ? existingDreams[dreamIndex].notes : [], - coachNotes: dreamIndex >= 0 ? existingDreams[dreamIndex].coachNotes : [], - history: dreamIndex >= 0 ? existingDreams[dreamIndex].history : [], - goals: dreamIndex >= 0 ? existingDreams[dreamIndex].goals : [], - completed: dreamIndex >= 0 ? existingDreams[dreamIndex].completed : false, - isPublic: dreamIndex >= 0 ? existingDreams[dreamIndex].isPublic : false, + description: validatedData.description, + imageUrl: validatedData.imageUrl, + imagePrompt: validatedData.imagePrompt, + targetDate: validatedData.targetDate, + isCompleted: dreamIndex >= 0 ? existingDreams[dreamIndex].isCompleted : false, createdAt: dreamIndex >= 0 ? existingDreams[dreamIndex].createdAt : new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Update dreams array - let updatedDreams; + let updatedDreams: DreamBookEntry[]; if (dreamIndex >= 0) { updatedDreams = [...existingDreams]; updatedDreams[dreamIndex] = dreamData; @@ -93,13 +87,12 @@ export async function saveDream( updatedDreams = [...existingDreams, dreamData]; } - // Save to database + // Save to database - match DreamsDocument structure const document = { id: userId, userId: userId, - dreams: updatedDreams, + dreamBook: updatedDreams, weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], - yearVision: dreamsDoc?.yearVision || '', createdAt: dreamsDoc?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; diff --git a/apps/web/services/scoring/index.ts b/apps/web/services/scoring/index.ts index 865cb65..eddb98a 100644 --- a/apps/web/services/scoring/index.ts +++ b/apps/web/services/scoring/index.ts @@ -2,6 +2,13 @@ * Scoring service exports * Barrel export for all scoring-related server actions */ + +// Get operations export * from './getScoring'; -export * from './saveScoring'; export * from './getAllYearsScoring'; + +// Form actions (useActionState compatible) +export * from './saveScore'; + +// Legacy operations +export * from './saveScoring'; diff --git a/apps/web/services/scoring/saveScore.ts b/apps/web/services/scoring/saveScore.ts new file mode 100644 index 0000000..2de6764 --- /dev/null +++ b/apps/web/services/scoring/saveScore.ts @@ -0,0 +1,148 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withAuth, createActionSuccess } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; +import type { QuarterScore } from '@dreamspace/shared'; + +/** + * Form action state for quarter score save + */ +export type SaveScoreState = { + success: boolean; + errors?: { + quarter?: string[]; + score?: string[]; + _form?: string[]; + }; + data?: { + id: string; + quarter: number; + }; +}; + +/** + * Schema for quarter score form data + */ +const scoreFormSchema = zfd.formData({ + year: zfd.numeric(z.number().int().min(2020)), + quarter: zfd.numeric(z.number().int().min(1).max(4)), + score: zfd.numeric(z.number().optional()), + notes: zfd.text(z.string().optional()), +}); + +/** + * Save or update a quarter score via form submission + * Compatible with useActionState/useFormState + * + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information + */ +export async function saveScore( + prevState: SaveScoreState | null, + formData: FormData +): Promise { + try { + // Validate form data + const validatedData = scoreFormSchema.parse(formData); + + // Get authenticated user + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing scoring document for the year + const documentId = `${userId}_${validatedData.year}_scoring`; + let scoringDoc; + try { + scoringDoc = await db.scoring.getScoringDocument(userId, validatedData.year); + } catch (error: any) { + if (error.code !== 404) { + throw error; + } + // Document doesn't exist yet, will create new one + } + + const existingQuarters = scoringDoc?.quarters || []; + const quarterIndex = existingQuarters.findIndex((q: QuarterScore) => q.quarter === validatedData.quarter); + + const quarterData: QuarterScore = { + quarter: validatedData.quarter, + score: validatedData.score, + notes: validatedData.notes, + scoredAt: new Date().toISOString(), + }; + + // Update quarters array + let updatedQuarters: QuarterScore[]; + if (quarterIndex >= 0) { + updatedQuarters = [...existingQuarters]; + updatedQuarters[quarterIndex] = quarterData; + } else { + updatedQuarters = [...existingQuarters, quarterData]; + } + + // Calculate annual score (average of quarters) + const scores = updatedQuarters.filter(q => q.score !== undefined).map(q => q.score!); + const annualScore = scores.length > 0 + ? scores.reduce((sum, s) => sum + s, 0) / scores.length + : undefined; + + // Save to database - match ScoringDocument structure + const document = { + id: documentId, + userId: userId, + year: validatedData.year, + quarters: updatedQuarters, + annualScore, + createdAt: scoringDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.scoring.upsertScoring(userId, validatedData.year, document); + + return createActionSuccess({ id: documentId, quarter: validatedData.quarter }); + })({}); + + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to save score'], + }, + }; + } + + // Revalidate to refresh context data + revalidatePath('/scorecard'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: result.id, quarter: result.quarter }, + }; + } catch (error) { + console.error('Failed to save score:', error); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + quarter: error.formErrors.fieldErrors.quarter as string[], + score: error.formErrors.fieldErrors.score as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; + } +} diff --git a/apps/web/services/teams/index.ts b/apps/web/services/teams/index.ts index 24df1c1..b10925d 100644 --- a/apps/web/services/teams/index.ts +++ b/apps/web/services/teams/index.ts @@ -2,12 +2,18 @@ * Teams service exports * Barrel export for all team-related server actions */ + +// Get operations export * from './getTeamMetrics'; export * from './getTeamRelationships'; -export * from './updateTeamInfo'; +export * from './getMeetingAttendance'; + +// Form actions (useActionState compatible) export * from './updateTeamName'; export * from './updateTeamMission'; + +// Legacy operations +export * from './updateTeamInfo'; export * from './updateTeamMeeting'; export * from './replaceTeamCoach'; -export * from './getMeetingAttendance'; export * from './saveMeetingAttendance'; diff --git a/apps/web/services/teams/updateTeamMission.ts b/apps/web/services/teams/updateTeamMission.ts index cf8948e..b408632 100644 --- a/apps/web/services/teams/updateTeamMission.ts +++ b/apps/web/services/teams/updateTeamMission.ts @@ -1,57 +1,117 @@ 'use server'; -import { withCoachAuth, createActionSuccess, handleActionError } from '@/lib/actions'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withCoachAuth, createActionSuccess } from '@/lib/actions'; import { getDatabaseClient } from '@dreamspace/database'; -interface UpdateTeamMissionInput { - managerId: string; - mission: string; -} +/** + * Form action state for team mission update + */ +export type UpdateTeamMissionState = { + success: boolean; + errors?: { + mission?: string[]; + _form?: string[]; + }; + data?: { + managerId: string; + mission: string; + }; +}; /** - * Updates a team's mission statement. + * Schema for team mission form data + */ +const teamMissionFormSchema = zfd.formData({ + managerId: zfd.text(z.string()), + mission: zfd.text(z.string().min(1, 'Team mission is required')), +}); + +/** + * Update a team's mission statement via form submission + * Compatible with useActionState/useFormState * Only coaches can update their own team mission. * - * @param input - Contains managerId and mission - * @returns Updated team data + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information */ -export const updateTeamMission = withCoachAuth(async (user, input: UpdateTeamMissionInput) => { +export async function updateTeamMission( + prevState: UpdateTeamMissionState | null, + formData: FormData +): Promise { try { - const { managerId, mission } = input; + // Validate form data + const validatedData = teamMissionFormSchema.parse(formData); - if (!managerId) { - throw new Error('Manager ID is required'); - } + // Get authenticated coach + const result = await withCoachAuth(async (user) => { + const { managerId, mission } = validatedData; + + // Verify the authenticated coach is modifying their own team + if (user.id !== managerId) { + throw new Error('You can only modify your own team'); + } + + const db = getDatabaseClient(); + + const team = await db.teams.getTeamByManagerId(managerId); + + if (!team) { + throw new Error(`No team found for manager: ${managerId}`); + } + + const updatedTeam = { + ...team, + teamMission: mission, + updatedAt: new Date().toISOString(), + }; + + await db.teams.updateTeam(team.id, team.managerId, updatedTeam); + + return createActionSuccess({ + managerId, + mission, + }); + })({}); - // Verify the authenticated coach is modifying their own team - if (user.id !== managerId) { - throw new Error('You can only modify your own team'); + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to update team mission'], + }, + }; } - const db = getDatabaseClient(); + // Revalidate to refresh context data + revalidatePath('/dream-team'); + revalidatePath('/dashboard'); - const team = await db.teams.getTeamByManagerId(managerId); + return { + success: true, + data: { managerId: result.managerId, mission: result.mission }, + }; + } catch (error) { + console.error('Failed to update team mission:', error); - if (!team) { - throw new Error(`No team found for manager: ${managerId}`); + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + mission: error.formErrors.fieldErrors.mission as string[], + _form: error.formErrors.formErrors, + }, + }; } - const updatedTeam = { - ...team, - mission, - lastModified: new Date().toISOString() + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, }; - - await db.teams.updateTeam(team.id, team.managerId, updatedTeam); - - return createActionSuccess({ - managerId, - mission, - teamName: team.teamName, - lastModified: updatedTeam.lastModified - }); - } catch (error) { - console.error('Failed to update team mission:', error); - return handleActionError(error, 'Failed to update team mission'); } -}); +} diff --git a/apps/web/services/teams/updateTeamName.ts b/apps/web/services/teams/updateTeamName.ts index 692c1f5..3efad1a 100644 --- a/apps/web/services/teams/updateTeamName.ts +++ b/apps/web/services/teams/updateTeamName.ts @@ -1,56 +1,117 @@ 'use server'; -import { withCoachAuth, createActionSuccess, handleActionError } from '@/lib/actions'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withCoachAuth, createActionSuccess } from '@/lib/actions'; import { getDatabaseClient } from '@dreamspace/database'; -interface UpdateTeamNameInput { - managerId: string; - teamName: string; -} +/** + * Form action state for team name update + */ +export type UpdateTeamNameState = { + success: boolean; + errors?: { + teamName?: string[]; + _form?: string[]; + }; + data?: { + managerId: string; + teamName: string; + }; +}; /** - * Updates a team's name. + * Schema for team name form data + */ +const teamNameFormSchema = zfd.formData({ + managerId: zfd.text(z.string()), + teamName: zfd.text(z.string().min(1, 'Team name is required')), +}); + +/** + * Update a team's name via form submission + * Compatible with useActionState/useFormState * Only coaches can update their own team name. * - * @param input - Contains managerId and new teamName - * @returns Updated team data + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information */ -export const updateTeamName = withCoachAuth(async (user, input: UpdateTeamNameInput) => { +export async function updateTeamName( + prevState: UpdateTeamNameState | null, + formData: FormData +): Promise { try { - const { managerId, teamName } = input; + // Validate form data + const validatedData = teamNameFormSchema.parse(formData); - if (!managerId || !teamName) { - throw new Error('Manager ID and team name are required'); - } + // Get authenticated coach + const result = await withCoachAuth(async (user) => { + const { managerId, teamName } = validatedData; + + // Verify the authenticated coach is modifying their own team + if (user.id !== managerId) { + throw new Error('You can only modify your own team'); + } + + const db = getDatabaseClient(); + + const team = await db.teams.getTeamByManagerId(managerId); + + if (!team) { + throw new Error(`No team found for manager: ${managerId}`); + } + + const updatedTeam = { + ...team, + teamName, + updatedAt: new Date().toISOString(), + }; + + await db.teams.updateTeam(team.id, team.managerId, updatedTeam); + + return createActionSuccess({ + managerId, + teamName, + }); + })({}); - // Verify the authenticated coach is modifying their own team - if (user.id !== managerId) { - throw new Error('You can only modify your own team'); + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to update team name'], + }, + }; } - const db = getDatabaseClient(); + // Revalidate to refresh context data + revalidatePath('/dream-team'); + revalidatePath('/dashboard'); - const team = await db.teams.getTeamByManagerId(managerId); + return { + success: true, + data: { managerId: result.managerId, teamName: result.teamName }, + }; + } catch (error) { + console.error('Failed to update team name:', error); - if (!team) { - throw new Error(`No team found for manager: ${managerId}`); + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + teamName: error.formErrors.fieldErrors.teamName as string[], + _form: error.formErrors.formErrors, + }, + }; } - const updatedTeam = { - ...team, - teamName, - lastModified: new Date().toISOString() + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, }; - - await db.teams.updateTeam(team.id, team.managerId, updatedTeam); - - return createActionSuccess({ - managerId, - teamName, - lastModified: updatedTeam.lastModified - }); - } catch (error) { - console.error('Failed to update team name:', error); - return handleActionError(error, 'Failed to update team name'); } -}); +} diff --git a/apps/web/services/users/index.ts b/apps/web/services/users/index.ts index bddbdf3..d549fbb 100644 --- a/apps/web/services/users/index.ts +++ b/apps/web/services/users/index.ts @@ -2,11 +2,18 @@ * User service exports * Barrel export for all user-related server actions */ + +// Get operations export * from './getUserProfile'; export * from './getUserData'; +export * from './getAllUsers'; + +// Form actions (useActionState compatible) +export * from './updateProfile'; + +// Legacy operations export * from './saveUserData'; export * from './updateUserProfile'; -export * from './getAllUsers'; export * from './assignUserToCoach'; export * from './promoteUserToCoach'; export * from './unassignUserFromTeam'; diff --git a/apps/web/services/users/updateProfile.ts b/apps/web/services/users/updateProfile.ts new file mode 100644 index 0000000..90701c5 --- /dev/null +++ b/apps/web/services/users/updateProfile.ts @@ -0,0 +1,131 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withAuth, createActionSuccess } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; + +/** + * Form action state for profile update + */ +export type UpdateProfileState = { + success: boolean; + errors?: { + name?: string[]; + email?: string[]; + _form?: string[]; + }; + data?: { + id: string; + }; +}; + +/** + * Schema for profile form data + */ +const profileFormSchema = zfd.formData({ + displayName: zfd.text(z.string().optional()), + name: zfd.text(z.string().optional()), + email: zfd.text(z.string().email().optional()), + region: zfd.text(z.string().optional()), + office: zfd.text(z.string().optional()), + title: zfd.text(z.string().optional()), + department: zfd.text(z.string().optional()), + cardBackgroundImage: zfd.text(z.string().optional()), +}); + +/** + * Update user profile via form submission + * Compatible with useActionState/useFormState + * + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information + */ +export async function updateProfile( + prevState: UpdateProfileState | null, + formData: FormData +): Promise { + try { + // Validate form data + const validatedData = profileFormSchema.parse(formData); + + // Get authenticated user + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing user document + const existingDocument = await db.users.getUserProfile(userId); + + // Create updated document with ONLY profile data (6-container architecture) + const updatedDocument = { + id: userId, + userId: userId, + // Basic profile fields + name: validatedData.displayName || validatedData.name || existingDocument?.name || 'Unknown User', + displayName: validatedData.displayName || validatedData.name || existingDocument?.displayName, + firstName: validatedData.displayName?.split(' ')[0] || existingDocument?.firstName, + lastName: validatedData.displayName?.split(' ').slice(1).join(' ') || existingDocument?.lastName, + email: validatedData.email || existingDocument?.email || '', + region: validatedData.region || existingDocument?.region, + photoUrl: existingDocument?.photoUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(validatedData.displayName || validatedData.name || 'User')}&background=6366f1&color=fff&size=100`, + // Additional profile fields + title: validatedData.title || existingDocument?.title || '', + department: validatedData.department || existingDocument?.department || '', + officeLocation: validatedData.office || existingDocument?.officeLocation, + // SECURITY: Never trust client-supplied roles + isCoach: existingDocument?.isCoach ?? false, + isActive: existingDocument?.isActive !== false, + teamId: existingDocument?.teamId, + onboardingComplete: existingDocument?.onboardingComplete ?? false, + lastLogin: existingDocument?.lastLogin, + createdAt: existingDocument?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.users.upsertUserProfile(userId, updatedDocument); + + return createActionSuccess({ id: userId }); + })({}); + + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to update profile'], + }, + }; + } + + // Revalidate to refresh context data + revalidatePath('/people'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: result.id }, + }; + } catch (error) { + console.error('Failed to update profile:', error); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + name: error.formErrors.fieldErrors.name as string[], + email: error.formErrors.fieldErrors.email as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; + } +} diff --git a/apps/web/services/weeks/index.ts b/apps/web/services/weeks/index.ts index 25cc28e..f4f1492 100644 --- a/apps/web/services/weeks/index.ts +++ b/apps/web/services/weeks/index.ts @@ -2,8 +2,15 @@ * Weeks service exports * Barrel export for all week-related server actions */ + +// Get operations export * from './getCurrentWeek'; export * from './getPastWeeks'; + +// Form actions (useActionState compatible) +export * from './saveGoal'; + +// Legacy operations export * from './saveCurrentWeek'; export * from './syncCurrentWeek'; export * from './archiveWeek'; diff --git a/apps/web/services/weeks/saveGoal.ts b/apps/web/services/weeks/saveGoal.ts new file mode 100644 index 0000000..1861a90 --- /dev/null +++ b/apps/web/services/weeks/saveGoal.ts @@ -0,0 +1,169 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; +import { withAuth, createActionSuccess } from '@/lib/actions'; +import { getDatabaseClient } from '@dreamspace/database'; +import type { WeekGoal } from '@dreamspace/shared'; + +/** + * Form action state for goal save + */ +export type SaveGoalState = { + success: boolean; + errors?: { + title?: string[]; + category?: string[]; + description?: string[]; + _form?: string[]; + }; + data?: { + id: string; + }; +}; + +/** + * Schema for goal form data + */ +const goalFormSchema = zfd.formData({ + id: zfd.text(z.string().optional()), + weekStartDate: zfd.text(z.string()), + title: zfd.text(z.string().min(1, 'Title is required')), + category: zfd.text(z.string().min(1, 'Category is required')), + description: zfd.text(z.string().optional()), + templateId: zfd.text(z.string().optional()), + goalType: zfd.text(z.string().optional()), + targetValue: zfd.numeric(z.number().optional()), + currentValue: zfd.numeric(z.number().optional()), + unit: zfd.text(z.string().optional()), +}); + +/** + * Create or update a weekly goal via form submission + * Compatible with useActionState/useFormState + * + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information + */ +export async function saveGoal( + prevState: SaveGoalState | null, + formData: FormData +): Promise { + try { + // Validate form data + const validatedData = goalFormSchema.parse(formData); + + // Get authenticated user + const result = await withAuth(async (user) => { + const userId = user.id; + const db = getDatabaseClient(); + + // Get current week document + const weekDoc = await db.weeks.getCurrentWeek(userId); + const existingGoals = weekDoc?.goals || []; + + // Create or update goal + const goalId = validatedData.id || `goal_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const goalIndex = existingGoals.findIndex((g: WeekGoal) => g.id === goalId); + + const goalData: WeekGoal = { + id: goalId, + title: validatedData.title, + category: validatedData.category, + description: validatedData.description, + templateId: validatedData.templateId, + goalType: validatedData.goalType, + targetValue: validatedData.targetValue, + currentValue: validatedData.currentValue ?? 0, + unit: validatedData.unit, + isCompleted: goalIndex >= 0 ? existingGoals[goalIndex].isCompleted : false, + completedAt: goalIndex >= 0 ? existingGoals[goalIndex].completedAt : undefined, + notes: goalIndex >= 0 ? existingGoals[goalIndex].notes : undefined, + dailyProgress: goalIndex >= 0 ? existingGoals[goalIndex].dailyProgress : [], + }; + + // Update goals array + let updatedGoals: WeekGoal[]; + if (goalIndex >= 0) { + updatedGoals = [...existingGoals]; + updatedGoals[goalIndex] = goalData; + } else { + updatedGoals = [...existingGoals, goalData]; + } + + // Calculate week number and year from weekStartDate + const weekStart = new Date(validatedData.weekStartDate); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + + // Save to database - match CurrentWeekDocument structure + const document = { + id: userId, + userId: userId, + weekStartDate: validatedData.weekStartDate, + weekEndDate: weekEnd.toISOString().split('T')[0], + goals: updatedGoals, + weekNumber: getWeekNumber(weekStart), + year: weekStart.getFullYear(), + createdAt: weekDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.weeks.upsertCurrentWeek(userId, document); + + return createActionSuccess({ id: goalId }); + })({}); + + if (result.failed) { + return { + success: false, + errors: { + _form: result.errors._errors || ['Failed to save goal'], + }, + }; + } + + // Revalidate to refresh context data + revalidatePath('/dashboard'); + revalidatePath('/scorecard'); + + return { + success: true, + data: { id: result.id }, + }; + } catch (error) { + console.error('Failed to save goal:', error); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + title: error.formErrors.fieldErrors.title as string[], + category: error.formErrors.fieldErrors.category as string[], + description: error.formErrors.fieldErrors.description as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; + } +} + +/** + * Get ISO week number + */ +function getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +} From 76ddc182123b27819b604dedc56e8e56ca5c7fde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:48:40 +0000 Subject: [PATCH 12/12] Fix withAuth usage: use as function wrapper not inside body - Change all form actions to wrap entire function with withAuth/withCoachAuth - Remove withAuth calls from inside function bodies - Pattern: export const action = withAuth(async (user, prevState, formData) => {...}) - Apply to saveDream, saveYearVision, saveGoal, saveScore, saveConnect - Apply to updateTeamName, updateTeamMission, updateProfile - User parameter now comes from wrapper, not from calling withAuth inside - Matches existing pattern from saveDreams, getCurrentWeek, getConnects, etc. Co-authored-by: mlynam <2816612+mlynam@users.noreply.github.com> --- apps/web/services/connects/saveConnect.ts | 63 ++++----- apps/web/services/dreams/saveDream.ts | 107 +++++++-------- apps/web/services/dreams/saveYearVision.ts | 57 +++----- apps/web/services/scoring/saveScore.ts | 121 ++++++++--------- apps/web/services/teams/updateTeamMission.ts | 66 ++++------ apps/web/services/teams/updateTeamName.ts | 66 ++++------ apps/web/services/users/updateProfile.ts | 87 +++++-------- apps/web/services/weeks/saveGoal.ts | 129 ++++++++----------- 8 files changed, 277 insertions(+), 419 deletions(-) diff --git a/apps/web/services/connects/saveConnect.ts b/apps/web/services/connects/saveConnect.ts index 7d08829..047d609 100644 --- a/apps/web/services/connects/saveConnect.ts +++ b/apps/web/services/connects/saveConnect.ts @@ -43,49 +43,32 @@ const connectFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function saveConnect( - prevState: SaveConnectState | null, - formData: FormData -): Promise { +export const saveConnect = withAuth(async (user, prevState: SaveConnectState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = connectFormSchema.parse(formData); - // Get authenticated user - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Create connect ID - const connectId = validatedData.id || `connect_${Date.now()}_${Math.random().toString(36).slice(2)}`; - - // Save to database - match ConnectDocument structure - const document = { - id: connectId, - userId: userId, - connectType: validatedData.connectType, - connectDate: validatedData.connectDate, - notes: validatedData.notes, - recipientUserId: validatedData.recipientUserId, - recipientName: validatedData.recipientName, - teamId: validatedData.teamId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.connects.upsertConnect(userId, document); - - return createActionSuccess({ id: connectId }); - })({}); + const userId = user.id; + const db = getDatabaseClient(); - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to save connect'], - }, - }; - } + // Create connect ID + const connectId = validatedData.id || `connect_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + // Save to database - match ConnectDocument structure + const document = { + id: connectId, + userId: userId, + connectType: validatedData.connectType, + connectDate: validatedData.connectDate, + notes: validatedData.notes, + recipientUserId: validatedData.recipientUserId, + recipientName: validatedData.recipientName, + teamId: validatedData.teamId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.connects.upsertConnect(userId, document); // Revalidate to refresh context data revalidatePath('/dream-connect'); @@ -93,7 +76,7 @@ export async function saveConnect( return { success: true, - data: { id: result.id }, + data: { id: connectId }, }; } catch (error) { console.error('Failed to save connect:', error); @@ -117,4 +100,4 @@ export async function saveConnect( }, }; } -} +}); diff --git a/apps/web/services/dreams/saveDream.ts b/apps/web/services/dreams/saveDream.ts index c2c316a..1a1fd22 100644 --- a/apps/web/services/dreams/saveDream.ts +++ b/apps/web/services/dreams/saveDream.ts @@ -44,80 +44,63 @@ const dreamFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function saveDream( - prevState: SaveDreamState | null, - formData: FormData -): Promise { +export const saveDream = withAuth(async (user, prevState: SaveDreamState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = dreamFormSchema.parse(formData); - // Get authenticated user - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get existing dreams document - const dreamsDoc = await db.dreams.getDreamsDocument(userId); - const existingDreams = dreamsDoc?.dreamBook || []; - - // Create or update dream - const dreamId = validatedData.id || `dream_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const dreamIndex = existingDreams.findIndex((d: DreamBookEntry) => d.id === dreamId); - - const dreamData: DreamBookEntry = { - id: dreamId, - title: validatedData.title, - category: validatedData.category, - description: validatedData.description, - imageUrl: validatedData.imageUrl, - imagePrompt: validatedData.imagePrompt, - targetDate: validatedData.targetDate, - isCompleted: dreamIndex >= 0 ? existingDreams[dreamIndex].isCompleted : false, - createdAt: dreamIndex >= 0 ? existingDreams[dreamIndex].createdAt : new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - // Update dreams array - let updatedDreams: DreamBookEntry[]; - if (dreamIndex >= 0) { - updatedDreams = [...existingDreams]; - updatedDreams[dreamIndex] = dreamData; - } else { - updatedDreams = [...existingDreams, dreamData]; - } - - // Save to database - match DreamsDocument structure - const document = { - id: userId, - userId: userId, - dreamBook: updatedDreams, - weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], - createdAt: dreamsDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.dreams.upsertDreamsDocument(userId, document); - - return createActionSuccess({ id: dreamId }); - })({}); + const userId = user.id; + const db = getDatabaseClient(); - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to save dream'], - }, - }; + // Get existing dreams document + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + const existingDreams = dreamsDoc?.dreamBook || []; + + // Create or update dream + const dreamId = validatedData.id || `dream_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const dreamIndex = existingDreams.findIndex((d: DreamBookEntry) => d.id === dreamId); + + const dreamData: DreamBookEntry = { + id: dreamId, + title: validatedData.title, + category: validatedData.category, + description: validatedData.description, + imageUrl: validatedData.imageUrl, + imagePrompt: validatedData.imagePrompt, + targetDate: validatedData.targetDate, + isCompleted: dreamIndex >= 0 ? existingDreams[dreamIndex].isCompleted : false, + createdAt: dreamIndex >= 0 ? existingDreams[dreamIndex].createdAt : new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Update dreams array + let updatedDreams: DreamBookEntry[]; + if (dreamIndex >= 0) { + updatedDreams = [...existingDreams]; + updatedDreams[dreamIndex] = dreamData; + } else { + updatedDreams = [...existingDreams, dreamData]; } + // Save to database - match DreamsDocument structure + const document = { + id: userId, + userId: userId, + dreamBook: updatedDreams, + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); + // Revalidate to refresh context data revalidatePath('/dream-book'); revalidatePath('/dashboard'); return { success: true, - data: { id: result.id }, + data: { id: dreamId }, }; } catch (error) { console.error('Failed to save dream:', error); @@ -141,4 +124,4 @@ export async function saveDream( }, }; } -} +}); diff --git a/apps/web/services/dreams/saveYearVision.ts b/apps/web/services/dreams/saveYearVision.ts index 8ffb819..efaa1e0 100644 --- a/apps/web/services/dreams/saveYearVision.ts +++ b/apps/web/services/dreams/saveYearVision.ts @@ -35,46 +35,29 @@ const yearVisionFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function saveYearVision( - prevState: SaveYearVisionState | null, - formData: FormData -): Promise { +export const saveYearVision = withAuth(async (user, prevState: SaveYearVisionState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = yearVisionFormSchema.parse(formData); - // Get authenticated user - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get existing document - const dreamsDoc = await db.dreams.getDreamsDocument(userId); - - // Update document with new vision - const document = { - id: userId, - userId: userId, - dreams: dreamsDoc?.dreams || [], - weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], - yearVision: validatedData.yearVision, - createdAt: dreamsDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.dreams.upsertDreamsDocument(userId, document); - - return { success: true, yearVision: validatedData.yearVision }; - })({}); + const userId = user.id; + const db = getDatabaseClient(); - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to save year vision'], - }, - }; - } + // Get existing document + const dreamsDoc = await db.dreams.getDreamsDocument(userId); + + // Update document with new vision + const document = { + id: userId, + userId: userId, + dreams: dreamsDoc?.dreams || [], + weeklyGoalTemplates: dreamsDoc?.weeklyGoalTemplates || [], + yearVision: validatedData.yearVision, + createdAt: dreamsDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.dreams.upsertDreamsDocument(userId, document); // Revalidate to refresh context data revalidatePath('/dream-book'); @@ -82,7 +65,7 @@ export async function saveYearVision( return { success: true, - data: { yearVision: result.yearVision }, + data: { yearVision: validatedData.yearVision }, }; } catch (error) { console.error('Failed to save year vision:', error); @@ -104,5 +87,5 @@ export async function saveYearVision( }, }; } -} +}); diff --git a/apps/web/services/scoring/saveScore.ts b/apps/web/services/scoring/saveScore.ts index 2de6764..6fc5074 100644 --- a/apps/web/services/scoring/saveScore.ts +++ b/apps/web/services/scoring/saveScore.ts @@ -41,88 +41,71 @@ const scoreFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function saveScore( - prevState: SaveScoreState | null, - formData: FormData -): Promise { +export const saveScore = withAuth(async (user, prevState: SaveScoreState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = scoreFormSchema.parse(formData); - // Get authenticated user - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get existing scoring document for the year - const documentId = `${userId}_${validatedData.year}_scoring`; - let scoringDoc; - try { - scoringDoc = await db.scoring.getScoringDocument(userId, validatedData.year); - } catch (error: any) { - if (error.code !== 404) { - throw error; - } - // Document doesn't exist yet, will create new one - } - - const existingQuarters = scoringDoc?.quarters || []; - const quarterIndex = existingQuarters.findIndex((q: QuarterScore) => q.quarter === validatedData.quarter); - - const quarterData: QuarterScore = { - quarter: validatedData.quarter, - score: validatedData.score, - notes: validatedData.notes, - scoredAt: new Date().toISOString(), - }; - - // Update quarters array - let updatedQuarters: QuarterScore[]; - if (quarterIndex >= 0) { - updatedQuarters = [...existingQuarters]; - updatedQuarters[quarterIndex] = quarterData; - } else { - updatedQuarters = [...existingQuarters, quarterData]; + const userId = user.id; + const db = getDatabaseClient(); + + // Get existing scoring document for the year + const documentId = `${userId}_${validatedData.year}_scoring`; + let scoringDoc; + try { + scoringDoc = await db.scoring.getScoringDocument(userId, validatedData.year); + } catch (error: any) { + if (error.code !== 404) { + throw error; } - - // Calculate annual score (average of quarters) - const scores = updatedQuarters.filter(q => q.score !== undefined).map(q => q.score!); - const annualScore = scores.length > 0 - ? scores.reduce((sum, s) => sum + s, 0) / scores.length - : undefined; - - // Save to database - match ScoringDocument structure - const document = { - id: documentId, - userId: userId, - year: validatedData.year, - quarters: updatedQuarters, - annualScore, - createdAt: scoringDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.scoring.upsertScoring(userId, validatedData.year, document); - - return createActionSuccess({ id: documentId, quarter: validatedData.quarter }); - })({}); + // Document doesn't exist yet, will create new one + } - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to save score'], - }, - }; + const existingQuarters = scoringDoc?.quarters || []; + const quarterIndex = existingQuarters.findIndex((q: QuarterScore) => q.quarter === validatedData.quarter); + + const quarterData: QuarterScore = { + quarter: validatedData.quarter, + score: validatedData.score, + notes: validatedData.notes, + scoredAt: new Date().toISOString(), + }; + + // Update quarters array + let updatedQuarters: QuarterScore[]; + if (quarterIndex >= 0) { + updatedQuarters = [...existingQuarters]; + updatedQuarters[quarterIndex] = quarterData; + } else { + updatedQuarters = [...existingQuarters, quarterData]; } + // Calculate annual score (average of quarters) + const scores = updatedQuarters.filter(q => q.score !== undefined).map(q => q.score!); + const annualScore = scores.length > 0 + ? scores.reduce((sum, s) => sum + s, 0) / scores.length + : undefined; + + // Save to database - match ScoringDocument structure + const document = { + id: documentId, + userId: userId, + year: validatedData.year, + quarters: updatedQuarters, + annualScore, + createdAt: scoringDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.scoring.upsertScoring(userId, validatedData.year, document); + // Revalidate to refresh context data revalidatePath('/scorecard'); revalidatePath('/dashboard'); return { success: true, - data: { id: result.id, quarter: result.quarter }, + data: { id: documentId, quarter: validatedData.quarter }, }; } catch (error) { console.error('Failed to save score:', error); @@ -145,4 +128,4 @@ export async function saveScore( }, }; } -} +}); diff --git a/apps/web/services/teams/updateTeamMission.ts b/apps/web/services/teams/updateTeamMission.ts index b408632..c49c6ca 100644 --- a/apps/web/services/teams/updateTeamMission.ts +++ b/apps/web/services/teams/updateTeamMission.ts @@ -38,61 +38,41 @@ const teamMissionFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function updateTeamMission( - prevState: UpdateTeamMissionState | null, - formData: FormData -): Promise { +export const updateTeamMission = withCoachAuth(async (user, prevState: UpdateTeamMissionState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = teamMissionFormSchema.parse(formData); - // Get authenticated coach - const result = await withCoachAuth(async (user) => { - const { managerId, mission } = validatedData; - - // Verify the authenticated coach is modifying their own team - if (user.id !== managerId) { - throw new Error('You can only modify your own team'); - } - - const db = getDatabaseClient(); - - const team = await db.teams.getTeamByManagerId(managerId); - - if (!team) { - throw new Error(`No team found for manager: ${managerId}`); - } - - const updatedTeam = { - ...team, - teamMission: mission, - updatedAt: new Date().toISOString(), - }; - - await db.teams.updateTeam(team.id, team.managerId, updatedTeam); - - return createActionSuccess({ - managerId, - mission, - }); - })({}); + const { managerId, mission } = validatedData; - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to update team mission'], - }, - }; + // Verify the authenticated coach is modifying their own team + if (user.id !== managerId) { + throw new Error('You can only modify your own team'); + } + + const db = getDatabaseClient(); + + const team = await db.teams.getTeamByManagerId(managerId); + + if (!team) { + throw new Error(`No team found for manager: ${managerId}`); } + const updatedTeam = { + ...team, + teamMission: mission, + updatedAt: new Date().toISOString(), + }; + + await db.teams.updateTeam(team.id, team.managerId, updatedTeam); + // Revalidate to refresh context data revalidatePath('/dream-team'); revalidatePath('/dashboard'); return { success: true, - data: { managerId: result.managerId, mission: result.mission }, + data: { managerId, mission }, }; } catch (error) { console.error('Failed to update team mission:', error); @@ -114,4 +94,4 @@ export async function updateTeamMission( }, }; } -} +}); diff --git a/apps/web/services/teams/updateTeamName.ts b/apps/web/services/teams/updateTeamName.ts index 3efad1a..d55797e 100644 --- a/apps/web/services/teams/updateTeamName.ts +++ b/apps/web/services/teams/updateTeamName.ts @@ -38,61 +38,41 @@ const teamNameFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function updateTeamName( - prevState: UpdateTeamNameState | null, - formData: FormData -): Promise { +export const updateTeamName = withCoachAuth(async (user, prevState: UpdateTeamNameState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = teamNameFormSchema.parse(formData); - // Get authenticated coach - const result = await withCoachAuth(async (user) => { - const { managerId, teamName } = validatedData; - - // Verify the authenticated coach is modifying their own team - if (user.id !== managerId) { - throw new Error('You can only modify your own team'); - } - - const db = getDatabaseClient(); - - const team = await db.teams.getTeamByManagerId(managerId); - - if (!team) { - throw new Error(`No team found for manager: ${managerId}`); - } - - const updatedTeam = { - ...team, - teamName, - updatedAt: new Date().toISOString(), - }; - - await db.teams.updateTeam(team.id, team.managerId, updatedTeam); - - return createActionSuccess({ - managerId, - teamName, - }); - })({}); + const { managerId, teamName } = validatedData; - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to update team name'], - }, - }; + // Verify the authenticated coach is modifying their own team + if (user.id !== managerId) { + throw new Error('You can only modify your own team'); + } + + const db = getDatabaseClient(); + + const team = await db.teams.getTeamByManagerId(managerId); + + if (!team) { + throw new Error(`No team found for manager: ${managerId}`); } + const updatedTeam = { + ...team, + teamName, + updatedAt: new Date().toISOString(), + }; + + await db.teams.updateTeam(team.id, team.managerId, updatedTeam); + // Revalidate to refresh context data revalidatePath('/dream-team'); revalidatePath('/dashboard'); return { success: true, - data: { managerId: result.managerId, teamName: result.teamName }, + data: { managerId, teamName }, }; } catch (error) { console.error('Failed to update team name:', error); @@ -114,4 +94,4 @@ export async function updateTeamName( }, }; } -} +}); diff --git a/apps/web/services/users/updateProfile.ts b/apps/web/services/users/updateProfile.ts index 90701c5..0ccd12f 100644 --- a/apps/web/services/users/updateProfile.ts +++ b/apps/web/services/users/updateProfile.ts @@ -43,61 +43,44 @@ const profileFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function updateProfile( - prevState: UpdateProfileState | null, - formData: FormData -): Promise { +export const updateProfile = withAuth(async (user, prevState: UpdateProfileState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = profileFormSchema.parse(formData); - // Get authenticated user - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get existing user document - const existingDocument = await db.users.getUserProfile(userId); - - // Create updated document with ONLY profile data (6-container architecture) - const updatedDocument = { - id: userId, - userId: userId, - // Basic profile fields - name: validatedData.displayName || validatedData.name || existingDocument?.name || 'Unknown User', - displayName: validatedData.displayName || validatedData.name || existingDocument?.displayName, - firstName: validatedData.displayName?.split(' ')[0] || existingDocument?.firstName, - lastName: validatedData.displayName?.split(' ').slice(1).join(' ') || existingDocument?.lastName, - email: validatedData.email || existingDocument?.email || '', - region: validatedData.region || existingDocument?.region, - photoUrl: existingDocument?.photoUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(validatedData.displayName || validatedData.name || 'User')}&background=6366f1&color=fff&size=100`, - // Additional profile fields - title: validatedData.title || existingDocument?.title || '', - department: validatedData.department || existingDocument?.department || '', - officeLocation: validatedData.office || existingDocument?.officeLocation, - // SECURITY: Never trust client-supplied roles - isCoach: existingDocument?.isCoach ?? false, - isActive: existingDocument?.isActive !== false, - teamId: existingDocument?.teamId, - onboardingComplete: existingDocument?.onboardingComplete ?? false, - lastLogin: existingDocument?.lastLogin, - createdAt: existingDocument?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.users.upsertUserProfile(userId, updatedDocument); - - return createActionSuccess({ id: userId }); - })({}); + const userId = user.id; + const db = getDatabaseClient(); - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to update profile'], - }, - }; - } + // Get existing user document + const existingDocument = await db.users.getUserProfile(userId); + + // Create updated document with ONLY profile data (6-container architecture) + const updatedDocument = { + id: userId, + userId: userId, + // Basic profile fields + name: validatedData.displayName || validatedData.name || existingDocument?.name || 'Unknown User', + displayName: validatedData.displayName || validatedData.name || existingDocument?.displayName, + firstName: validatedData.displayName?.split(' ')[0] || existingDocument?.firstName, + lastName: validatedData.displayName?.split(' ').slice(1).join(' ') || existingDocument?.lastName, + email: validatedData.email || existingDocument?.email || '', + region: validatedData.region || existingDocument?.region, + photoUrl: existingDocument?.photoUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(validatedData.displayName || validatedData.name || 'User')}&background=6366f1&color=fff&size=100`, + // Additional profile fields + title: validatedData.title || existingDocument?.title || '', + department: validatedData.department || existingDocument?.department || '', + officeLocation: validatedData.office || existingDocument?.officeLocation, + // SECURITY: Never trust client-supplied roles + isCoach: existingDocument?.isCoach ?? false, + isActive: existingDocument?.isActive !== false, + teamId: existingDocument?.teamId, + onboardingComplete: existingDocument?.onboardingComplete ?? false, + lastLogin: existingDocument?.lastLogin, + createdAt: existingDocument?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.users.upsertUserProfile(userId, updatedDocument); // Revalidate to refresh context data revalidatePath('/people'); @@ -105,7 +88,7 @@ export async function updateProfile( return { success: true, - data: { id: result.id }, + data: { id: userId }, }; } catch (error) { console.error('Failed to update profile:', error); @@ -128,4 +111,4 @@ export async function updateProfile( }, }; } -} +}); diff --git a/apps/web/services/weeks/saveGoal.ts b/apps/web/services/weeks/saveGoal.ts index 1861a90..ea08610 100644 --- a/apps/web/services/weeks/saveGoal.ts +++ b/apps/web/services/weeks/saveGoal.ts @@ -47,91 +47,74 @@ const goalFormSchema = zfd.formData({ * @param formData - Form data from submission * @returns Form state with success/error information */ -export async function saveGoal( - prevState: SaveGoalState | null, - formData: FormData -): Promise { +export const saveGoal = withAuth(async (user, prevState: SaveGoalState | null, formData: FormData): Promise => { try { // Validate form data const validatedData = goalFormSchema.parse(formData); - // Get authenticated user - const result = await withAuth(async (user) => { - const userId = user.id; - const db = getDatabaseClient(); - - // Get current week document - const weekDoc = await db.weeks.getCurrentWeek(userId); - const existingGoals = weekDoc?.goals || []; - - // Create or update goal - const goalId = validatedData.id || `goal_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const goalIndex = existingGoals.findIndex((g: WeekGoal) => g.id === goalId); - - const goalData: WeekGoal = { - id: goalId, - title: validatedData.title, - category: validatedData.category, - description: validatedData.description, - templateId: validatedData.templateId, - goalType: validatedData.goalType, - targetValue: validatedData.targetValue, - currentValue: validatedData.currentValue ?? 0, - unit: validatedData.unit, - isCompleted: goalIndex >= 0 ? existingGoals[goalIndex].isCompleted : false, - completedAt: goalIndex >= 0 ? existingGoals[goalIndex].completedAt : undefined, - notes: goalIndex >= 0 ? existingGoals[goalIndex].notes : undefined, - dailyProgress: goalIndex >= 0 ? existingGoals[goalIndex].dailyProgress : [], - }; - - // Update goals array - let updatedGoals: WeekGoal[]; - if (goalIndex >= 0) { - updatedGoals = [...existingGoals]; - updatedGoals[goalIndex] = goalData; - } else { - updatedGoals = [...existingGoals, goalData]; - } - - // Calculate week number and year from weekStartDate - const weekStart = new Date(validatedData.weekStartDate); - const weekEnd = new Date(weekStart); - weekEnd.setDate(weekEnd.getDate() + 6); - - // Save to database - match CurrentWeekDocument structure - const document = { - id: userId, - userId: userId, - weekStartDate: validatedData.weekStartDate, - weekEndDate: weekEnd.toISOString().split('T')[0], - goals: updatedGoals, - weekNumber: getWeekNumber(weekStart), - year: weekStart.getFullYear(), - createdAt: weekDoc?.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - await db.weeks.upsertCurrentWeek(userId, document); - - return createActionSuccess({ id: goalId }); - })({}); + const userId = user.id; + const db = getDatabaseClient(); - if (result.failed) { - return { - success: false, - errors: { - _form: result.errors._errors || ['Failed to save goal'], - }, - }; + // Get current week document + const weekDoc = await db.weeks.getCurrentWeek(userId); + const existingGoals = weekDoc?.goals || []; + + // Create or update goal + const goalId = validatedData.id || `goal_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const goalIndex = existingGoals.findIndex((g: WeekGoal) => g.id === goalId); + + const goalData: WeekGoal = { + id: goalId, + title: validatedData.title, + category: validatedData.category, + description: validatedData.description, + templateId: validatedData.templateId, + goalType: validatedData.goalType, + targetValue: validatedData.targetValue, + currentValue: validatedData.currentValue ?? 0, + unit: validatedData.unit, + isCompleted: goalIndex >= 0 ? existingGoals[goalIndex].isCompleted : false, + completedAt: goalIndex >= 0 ? existingGoals[goalIndex].completedAt : undefined, + notes: goalIndex >= 0 ? existingGoals[goalIndex].notes : undefined, + dailyProgress: goalIndex >= 0 ? existingGoals[goalIndex].dailyProgress : [], + }; + + // Update goals array + let updatedGoals: WeekGoal[]; + if (goalIndex >= 0) { + updatedGoals = [...existingGoals]; + updatedGoals[goalIndex] = goalData; + } else { + updatedGoals = [...existingGoals, goalData]; } + // Calculate week number and year from weekStartDate + const weekStart = new Date(validatedData.weekStartDate); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + + // Save to database - match CurrentWeekDocument structure + const document = { + id: userId, + userId: userId, + weekStartDate: validatedData.weekStartDate, + weekEndDate: weekEnd.toISOString().split('T')[0], + goals: updatedGoals, + weekNumber: getWeekNumber(weekStart), + year: weekStart.getFullYear(), + createdAt: weekDoc?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await db.weeks.upsertCurrentWeek(userId, document); + // Revalidate to refresh context data revalidatePath('/dashboard'); revalidatePath('/scorecard'); return { success: true, - data: { id: result.id }, + data: { id: goalId }, }; } catch (error) { console.error('Failed to save goal:', error); @@ -155,7 +138,7 @@ export async function saveGoal( }, }; } -} +}); /** * Get ISO week number