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) 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

+ ) : ( + + )} +
+ ); +} +``` + +### 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

+ ) : ( + + )} + +
+ ); +} +``` + +### 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)} + /> + +
+ ); +} +``` + +### 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 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/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/connects/ConnectContext.tsx b/apps/web/lib/contexts/connects/ConnectContext.tsx new file mode 100644 index 0000000..48899bd --- /dev/null +++ b/apps/web/lib/contexts/connects/ConnectContext.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React, { createContext, useContext, ReactNode } from "react"; +import { List } from "immutable"; +import { Connect } from "./types"; + +/** + * Connect context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation + */ +type ConnectContextState = { + connects: List; +}; + +const ConnectContext = createContext( + undefined, +); + +interface ConnectProviderProps { + children: ReactNode; + data: Connect[]; +} + +/** + * 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) { + return ( + + {children} + + ); +} + +/** + * Hook to use connect context - READ ONLY + * For mutations, use server actions from @/services/connects + */ +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/dreams/DreamContext.tsx b/apps/web/lib/contexts/dreams/DreamContext.tsx new file mode 100644 index 0000000..a1dcf93 --- /dev/null +++ b/apps/web/lib/contexts/dreams/DreamContext.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, { createContext, useContext, ReactNode } from "react"; +import { List } from "immutable"; +import { Dream } from "./types"; + +/** + * Dream context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation + */ +type DreamContextState = { + dreams: List; + yearVision: string; +}; + +const DreamContext = createContext(undefined); + +interface DreamsProviderProps { + children: ReactNode; + data: Dream[]; + yearVision?: string; +} + +/** + * 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, + yearVision = "", +}: DreamsProviderProps) { + return ( + + {children} + + ); +} + +/** + * Hook to use dream context - READ ONLY + * For mutations, use server actions from @/services/dreams + */ +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/goals/GoalContext.tsx b/apps/web/lib/contexts/goals/GoalContext.tsx new file mode 100644 index 0000000..3b01fe0 --- /dev/null +++ b/apps/web/lib/contexts/goals/GoalContext.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React, { createContext, useContext, ReactNode } from "react"; +import { List } from "immutable"; +import { WeeklyGoal } from "./types"; + +/** + * Goal context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation + */ +type GoalContextState = { + goals: List; + weekId: string; +}; + +const GoalContext = createContext(undefined); + +interface GoalProviderProps { + children: ReactNode; + data: WeeklyGoal[]; + weekId: string; +} + +/** + * 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) { + return ( + + {children} + + ); +} + +/** + * Hook to use goal context - READ ONLY + * For mutations, use server actions from @/services/weeks + */ +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 new file mode 100644 index 0000000..1825e17 --- /dev/null +++ b/apps/web/lib/contexts/index.ts @@ -0,0 +1,14 @@ +/** + * Context exports + * Barrel export for all context providers and hooks + */ + +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..a33e841 --- /dev/null +++ b/apps/web/lib/contexts/scoring/ScoringContext.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React, { createContext, useContext, ReactNode } from "react"; +import { List } from "immutable"; +import { ScoringEntry } from "./types"; + +/** + * Scoring context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation + */ +type ScoringContextState = { + entries: List; + allTimeScore: number; +}; + +const ScoringContext = createContext( + undefined, +); + +interface ScoringProviderProps { + children: ReactNode; + data: ScoringEntry[]; + allTimeScore: number; +} + +/** + * 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, +}: ScoringProviderProps) { + return ( + + {children} + + ); +} + +/** + * Hook to use scoring context - READ ONLY + * For mutations, use server actions from @/services/scoring + */ +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..bd61498 --- /dev/null +++ b/apps/web/lib/contexts/teams/TeamContext.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, { createContext, useContext, ReactNode } from "react"; +import { List } from "immutable"; +import { TeamInfo, Meeting } from "./types"; + +/** + * Team context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation + */ +type TeamContextState = { + teamInfo: TeamInfo | null; + meetings: List; +}; + +const TeamContext = createContext(undefined); + +interface TeamProviderProps { + children: ReactNode; + teamData: TeamInfo | null; + meetingsData: Meeting[]; +} + +/** + * 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) { + return ( + + {children} + + ); +} + +/** + * Hook to use team context - READ ONLY + * For mutations, use server actions from @/services/teams + */ +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..cc9e188 --- /dev/null +++ b/apps/web/lib/contexts/users/UserContext.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React, { createContext, useContext, ReactNode } from "react"; +import { User } from "./types"; + +/** + * User context state - READ ONLY + * Mutations should be handled by server actions that trigger revalidation + */ +type UserContextState = { + user: User | null; +}; + +const UserContext = createContext(undefined); + +interface UserProviderProps { + children: ReactNode; + data: User | null; +} + +/** + * 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) { + return ( + + {children} + + ); +} + +/** + * Hook to use user context - READ ONLY + * For mutations, use server actions from @/services/users + */ +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[]; +}; 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/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/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..047d609 100644 --- a/apps/web/services/connects/saveConnect.ts +++ b/apps/web/services/connects/saveConnect.ts @@ -1,89 +1,103 @@ '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 const saveConnect = withAuth(async (user, prevState: SaveConnectState | null, formData: FormData): Promise => { try { - const { userId, connectData } = input; - - if (!connectData) { - throw new Error('connectData is required'); - } - - // Use the connect's userId (sender's ID) as partition key - const partitionUserId = connectData.userId || userId; - - if (!partitionUserId) { - throw new Error('userId is required in connectData'); - } + // Validate form data + const validatedData = connectFormSchema.parse(formData); + const userId = user.id; const db = getDatabaseClient(); - // Create the connect document - const connectId = connectData.id - ? String(connectData.id) - : `connect_${partitionUserId}_${Date.now()}`; + // 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: 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() + 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(partitionUserId, document); + await db.connects.upsertConnect(userId, document); - return createActionSuccess({ - id: connectId, - connect: document - }); + // Revalidate to refresh context data + revalidatePath('/dream-connect'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: connectId }, + }; } 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/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 758b20a..2f637f4 100644 --- a/apps/web/services/dreams/index.ts +++ b/apps/web/services/dreams/index.ts @@ -2,6 +2,15 @@ * Dreams service exports * Barrel export for all dreams-related server actions */ + +// Legacy bulk operations export * from './saveDreams'; export * from './uploadDreamPicture'; + +// Form actions (useActionState compatible) +export * from './saveDream'; export * from './saveYearVision'; + +// 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 new file mode 100644 index 0000000..5db28f7 --- /dev/null +++ b/apps/web/services/dreams/mutations.ts @@ -0,0 +1,53 @@ +'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'); + } +} diff --git a/apps/web/services/dreams/saveDream.ts b/apps/web/services/dreams/saveDream.ts new file mode 100644 index 0000000..1a1fd22 --- /dev/null +++ b/apps/web/services/dreams/saveDream.ts @@ -0,0 +1,127 @@ +'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 { DreamBookEntry } from '@dreamspace/shared'; + +/** + * Form action state for dream save + */ +export type SaveDreamState = { + 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().optional()), + description: zfd.text(z.string().optional()), + imageUrl: zfd.text(z.string().optional()), + imagePrompt: zfd.text(z.string().optional()), + targetDate: 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 const saveDream = withAuth(async (user, prevState: SaveDreamState | null, formData: FormData): Promise => { + try { + // Validate form data + const validatedData = dreamFormSchema.parse(formData); + + 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); + + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: dreamId }, + }; + } 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'], + }, + }; + } +}); diff --git a/apps/web/services/dreams/saveYearVision.ts b/apps/web/services/dreams/saveYearVision.ts index 45d74be..efaa1e0 100644 --- a/apps/web/services/dreams/saveYearVision.ts +++ b/apps/web/services/dreams/saveYearVision.ts @@ -1,57 +1,91 @@ '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 } from '@/lib/actions'; import { getDatabaseClient } from '@dreamspace/database'; -interface SaveYearVisionInput { - userId: string; - yearVision: string; -} +/** + * Form action state for year vision save + */ +export type SaveYearVisionState = { + success: boolean; + errors?: { + yearVision?: string[]; + _form?: string[]; + }; + data?: { + yearVision: string; + }; +}; /** - * Saves the year vision for a user. - * Year vision is stored in the dreams document. + * 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 input - Contains userId and yearVision text - * @returns Success response + * @param prevState - Previous form state + * @param formData - Form data from submission + * @returns Form state with success/error information */ -export const saveYearVision = withAuth(async (user, input: SaveYearVisionInput) => { +export const saveYearVision = withAuth(async (user, prevState: SaveYearVisionState | null, formData: FormData): Promise => { 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'); - } + // Validate form data + const validatedData = yearVisionFormSchema.parse(formData); + 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(), - updatedAt: 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' - }); + // Revalidate to refresh context data + revalidatePath('/dream-book'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { yearVision: validatedData.yearVision }, + }; } catch (error) { console.error('Failed to save year vision:', error); - return handleActionError(error, 'Failed to save year vision'); + + 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'], + }, + }; } }); + 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"); } }); 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..6fc5074 --- /dev/null +++ b/apps/web/services/scoring/saveScore.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'; +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 const saveScore = withAuth(async (user, prevState: SaveScoreState | null, formData: FormData): Promise => { + try { + // Validate form data + const validatedData = scoreFormSchema.parse(formData); + + 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); + + // Revalidate to refresh context data + revalidatePath('/scorecard'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: documentId, quarter: validatedData.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..c49c6ca 100644 --- a/apps/web/services/teams/updateTeamMission.ts +++ b/apps/web/services/teams/updateTeamMission.ts @@ -1,27 +1,49 @@ '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 const updateTeamMission = withCoachAuth(async (user, 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'); - } + const { managerId, mission } = validatedData; // Verify the authenticated coach is modifying their own team if (user.id !== managerId) { @@ -38,20 +60,38 @@ export const updateTeamMission = withCoachAuth(async (user, input: UpdateTeamMis const updatedTeam = { ...team, - mission, - lastModified: new Date().toISOString() + teamMission: mission, + updatedAt: new Date().toISOString(), }; await db.teams.updateTeam(team.id, team.managerId, updatedTeam); - return createActionSuccess({ - managerId, - mission, - teamName: team.teamName, - lastModified: updatedTeam.lastModified - }); + // Revalidate to refresh context data + revalidatePath('/dream-team'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { managerId, mission }, + }; } catch (error) { console.error('Failed to update team mission:', error); - return handleActionError(error, 'Failed to update team mission'); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + mission: error.formErrors.fieldErrors.mission as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; } }); diff --git a/apps/web/services/teams/updateTeamName.ts b/apps/web/services/teams/updateTeamName.ts index 692c1f5..d55797e 100644 --- a/apps/web/services/teams/updateTeamName.ts +++ b/apps/web/services/teams/updateTeamName.ts @@ -1,27 +1,49 @@ '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 const updateTeamName = withCoachAuth(async (user, 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'); - } + const { managerId, teamName } = validatedData; // Verify the authenticated coach is modifying their own team if (user.id !== managerId) { @@ -39,18 +61,37 @@ export const updateTeamName = withCoachAuth(async (user, input: UpdateTeamNameIn const updatedTeam = { ...team, teamName, - lastModified: new Date().toISOString() + updatedAt: new Date().toISOString(), }; await db.teams.updateTeam(team.id, team.managerId, updatedTeam); - return createActionSuccess({ - managerId, - teamName, - lastModified: updatedTeam.lastModified - }); + // Revalidate to refresh context data + revalidatePath('/dream-team'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { managerId, teamName }, + }; } catch (error) { console.error('Failed to update team name:', error); - return handleActionError(error, 'Failed to update team name'); + + if (error instanceof z.ZodError) { + return { + success: false, + errors: { + teamName: error.formErrors.fieldErrors.teamName as string[], + _form: error.formErrors.formErrors, + }, + }; + } + + return { + success: false, + errors: { + _form: ['An unexpected error occurred'], + }, + }; } }); 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..0ccd12f --- /dev/null +++ b/apps/web/services/users/updateProfile.ts @@ -0,0 +1,114 @@ +'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 const updateProfile = withAuth(async (user, prevState: UpdateProfileState | null, formData: FormData): Promise => { + try { + // Validate form data + const validatedData = profileFormSchema.parse(formData); + + 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); + + // Revalidate to refresh context data + revalidatePath('/people'); + revalidatePath('/dashboard'); + + return { + success: true, + data: { id: userId }, + }; + } 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..ea08610 --- /dev/null +++ b/apps/web/services/weeks/saveGoal.ts @@ -0,0 +1,152 @@ +'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 const saveGoal = withAuth(async (user, prevState: SaveGoalState | null, formData: FormData): Promise => { + try { + // Validate form data + const validatedData = goalFormSchema.parse(formData); + + 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); + + // Revalidate to refresh context data + revalidatePath('/dashboard'); + revalidatePath('/scorecard'); + + return { + success: true, + data: { id: goalId }, + }; + } 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); +} 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