From 97d246e98ed1c4cf905491f3d4ee02869d532611 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 10 May 2026 12:12:06 -0400 Subject: [PATCH] Clean up FrenoCorp repo: move project code to correct repositories (FRE-4529) - Removed literal $AGENT_HOME/ directory artifact - Moved Lendair iOS code to ~/code/lendair/iOS/Lendair/ - Moved marketing/ to ~/code/scripter/ - Moved ShieldAI workflow doc to ~/code/ShieldAI/ - Moved CI/CD workflows and load-test scripts to ~/code/lendair/ - Moved web configs (vercel.json, .env.example, index.html) to ~/code/lendair/web/ - Removed root-level project configs (package.json, tsconfig.json, vite.config.ts, etc.) - Removed shared/exports/ and scripts/ - Updated all 8 agent AGENTS.md files with Repository Rules section - Clarified: FrenoCorp is for agent notes/memories/plans only, not project code Co-Authored-By: Paperclip --- $AGENT_HOME/memory/2026-05-10.md | 23 - .env.example | 13 - .github/workflows/ios-ci.yml | 162 ------- .github/workflows/load-testing.yml | 81 ---- .github/workflows/web-ci.yml | 107 ----- Lendair/App/main.swift | 11 - Lendair/LendairTests/LendairTests.swift | 7 - Lendair/Models/AppSettings.swift | 16 - Lendair/Models/BeginnerMode.swift | 201 -------- Lendair/Models/Challenge.swift | 315 ------------ Lendair/Models/Club.swift | 275 ----------- Lendair/Models/CommunityEvent.swift | 243 ---------- Lendair/Models/FamilyPlan.swift | 217 --------- Lendair/Models/Notification.swift | 96 ---- Lendair/Models/Race.swift | 183 ------- Lendair/Models/TrainingPlan.swift | 312 ------------ Lendair/Package.swift | 46 -- Lendair/README.md | 264 ---------- Lendair/Services/BeginnerModeService.swift | 118 ----- Lendair/Services/ChallengeService.swift | 181 ------- Lendair/Services/ClubService.swift | 177 ------- Lendair/Services/CommunityEventService.swift | 153 ------ Lendair/Services/FamilyPlanService.swift | 125 ----- Lendair/Services/NotificationService.swift | 137 ------ Lendair/Services/RaceService.swift | 135 ------ Lendair/Services/TrainingPlanService.swift | 152 ------ Lendair/Utils/HTTPMethod.swift | 8 - .../ViewModels/BeginnerModeViewModel.swift | 94 ---- Lendair/ViewModels/ChallengeViewModel.swift | 165 ------- Lendair/ViewModels/ClubViewModel.swift | 156 ------ .../ViewModels/CommunityEventViewModel.swift | 119 ----- Lendair/ViewModels/FamilyPlanViewModel.swift | 78 --- .../ViewModels/NotificationsViewModel.swift | 80 ---- .../ViewModels/RaceDiscoveryViewModel.swift | 106 ---- .../ViewModels/TrainingPlanViewModel.swift | 108 ----- Lendair/Views/BeginnerModeView.swift | 173 ------- Lendair/Views/ChallengeDetailView.swift | 272 ----------- Lendair/Views/ChallengesView.swift | 280 ----------- Lendair/Views/ClubDetailView.swift | 276 ----------- Lendair/Views/ClubsView.swift | 250 ---------- Lendair/Views/CommunityEventDetailView.swift | 209 -------- Lendair/Views/CommunityEventsView.swift | 236 --------- Lendair/Views/FamilyMemberView.swift | 125 ----- Lendair/Views/FamilyPlanView.swift | 244 ---------- Lendair/Views/MainTabView.swift | 88 ---- Lendair/Views/NotificationRowView.swift | 93 ---- Lendair/Views/NotificationsView.swift | 105 ---- Lendair/Views/RaceDetailView.swift | 182 ------- Lendair/Views/RaceDiscoveryView.swift | 165 ------- Lendair/Views/SettingsView.swift | 111 ----- Lendair/Views/TrainingPlanDetailView.swift | 213 --------- Lendair/Views/TrainingPlanView.swift | 219 --------- Lendair/Views/WorkoutSessionView.swift | 211 -------- LendairTests/ChallengeServiceTests.swift | 447 ----------------- LendairTests/ClubServiceTests.swift | 329 ------------- LendairTests/NotificationServiceTests.swift | 359 -------------- agents/ceo/AGENTS.md | 6 + agents/cmo/AGENTS.md | 6 + agents/code-reviewer/AGENTS.md | 6 + agents/cto/AGENTS.md | 6 + agents/cto/MEMORY.md | 9 +- agents/cto/memory/2026-05-10.md | 79 ++- agents/founding-engineer/AGENTS.md | 6 + agents/junior-engineer/AGENTS.md | 6 + agents/security-reviewer/AGENTS.md | 6 + agents/senior-engineer/AGENTS.md | 6 + index.html | 13 - marketing/email-templates/01-vip-personal.md | 25 - marketing/email-templates/02-beta-tester.md | 23 - .../email-templates/03-active-waitlist.md | 25 - .../email-templates/04-general-waitlist.md | 23 - .../email-templates/05-launch-day-reminder.md | 22 - .../product-hunt-supporter-list-built.md | 186 ------- marketing/supporter-tracker.csv | 46 -- package-lock.json | 6 - package.json | 19 - scripts/load-test/compare-baseline.js | 69 --- scripts/load-test/package.json | 18 - scripts/load-test/reports/baseline.json | 6 - scripts/load-test/run-load-test.js | 68 --- scripts/setup.sh | 68 --- scripts/shift-dates-june.sh | 49 -- shared/exports/README.md | 13 - shared/exports/waitlist-export-summary.json | 45 -- shared/exports/waitlist-export.csv | 46 -- shared/exports/waitlist-export.json | 452 ------------------ shared/shieldai-workflow.md | 83 ---- src/index.ts | 1 - tsconfig.json | 18 - vercel.json | 28 -- vite.config.ts | 7 - 91 files changed, 124 insertions(+), 10622 deletions(-) delete mode 100644 $AGENT_HOME/memory/2026-05-10.md delete mode 100644 .env.example delete mode 100644 .github/workflows/ios-ci.yml delete mode 100644 .github/workflows/load-testing.yml delete mode 100644 .github/workflows/web-ci.yml delete mode 100644 Lendair/App/main.swift delete mode 100644 Lendair/LendairTests/LendairTests.swift delete mode 100644 Lendair/Models/AppSettings.swift delete mode 100644 Lendair/Models/BeginnerMode.swift delete mode 100644 Lendair/Models/Challenge.swift delete mode 100644 Lendair/Models/Club.swift delete mode 100644 Lendair/Models/CommunityEvent.swift delete mode 100644 Lendair/Models/FamilyPlan.swift delete mode 100644 Lendair/Models/Notification.swift delete mode 100644 Lendair/Models/Race.swift delete mode 100644 Lendair/Models/TrainingPlan.swift delete mode 100644 Lendair/Package.swift delete mode 100644 Lendair/README.md delete mode 100644 Lendair/Services/BeginnerModeService.swift delete mode 100644 Lendair/Services/ChallengeService.swift delete mode 100644 Lendair/Services/ClubService.swift delete mode 100644 Lendair/Services/CommunityEventService.swift delete mode 100644 Lendair/Services/FamilyPlanService.swift delete mode 100644 Lendair/Services/NotificationService.swift delete mode 100644 Lendair/Services/RaceService.swift delete mode 100644 Lendair/Services/TrainingPlanService.swift delete mode 100644 Lendair/Utils/HTTPMethod.swift delete mode 100644 Lendair/ViewModels/BeginnerModeViewModel.swift delete mode 100644 Lendair/ViewModels/ChallengeViewModel.swift delete mode 100644 Lendair/ViewModels/ClubViewModel.swift delete mode 100644 Lendair/ViewModels/CommunityEventViewModel.swift delete mode 100644 Lendair/ViewModels/FamilyPlanViewModel.swift delete mode 100644 Lendair/ViewModels/NotificationsViewModel.swift delete mode 100644 Lendair/ViewModels/RaceDiscoveryViewModel.swift delete mode 100644 Lendair/ViewModels/TrainingPlanViewModel.swift delete mode 100644 Lendair/Views/BeginnerModeView.swift delete mode 100644 Lendair/Views/ChallengeDetailView.swift delete mode 100644 Lendair/Views/ChallengesView.swift delete mode 100644 Lendair/Views/ClubDetailView.swift delete mode 100644 Lendair/Views/ClubsView.swift delete mode 100644 Lendair/Views/CommunityEventDetailView.swift delete mode 100644 Lendair/Views/CommunityEventsView.swift delete mode 100644 Lendair/Views/FamilyMemberView.swift delete mode 100644 Lendair/Views/FamilyPlanView.swift delete mode 100644 Lendair/Views/MainTabView.swift delete mode 100644 Lendair/Views/NotificationRowView.swift delete mode 100644 Lendair/Views/NotificationsView.swift delete mode 100644 Lendair/Views/RaceDetailView.swift delete mode 100644 Lendair/Views/RaceDiscoveryView.swift delete mode 100644 Lendair/Views/SettingsView.swift delete mode 100644 Lendair/Views/TrainingPlanDetailView.swift delete mode 100644 Lendair/Views/TrainingPlanView.swift delete mode 100644 Lendair/Views/WorkoutSessionView.swift delete mode 100644 LendairTests/ChallengeServiceTests.swift delete mode 100644 LendairTests/ClubServiceTests.swift delete mode 100644 LendairTests/NotificationServiceTests.swift delete mode 100644 index.html delete mode 100644 marketing/email-templates/01-vip-personal.md delete mode 100644 marketing/email-templates/02-beta-tester.md delete mode 100644 marketing/email-templates/03-active-waitlist.md delete mode 100644 marketing/email-templates/04-general-waitlist.md delete mode 100644 marketing/email-templates/05-launch-day-reminder.md delete mode 100644 marketing/product-hunt-supporter-list-built.md delete mode 100644 marketing/supporter-tracker.csv delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 scripts/load-test/compare-baseline.js delete mode 100644 scripts/load-test/package.json delete mode 100644 scripts/load-test/reports/baseline.json delete mode 100644 scripts/load-test/run-load-test.js delete mode 100755 scripts/setup.sh delete mode 100644 scripts/shift-dates-june.sh delete mode 100644 shared/exports/README.md delete mode 100644 shared/exports/waitlist-export-summary.json delete mode 100644 shared/exports/waitlist-export.csv delete mode 100644 shared/exports/waitlist-export.json delete mode 100644 shared/shieldai-workflow.md delete mode 100644 src/index.ts delete mode 100644 tsconfig.json delete mode 100644 vercel.json delete mode 100644 vite.config.ts diff --git a/$AGENT_HOME/memory/2026-05-10.md b/$AGENT_HOME/memory/2026-05-10.md deleted file mode 100644 index b094a9593..000000000 --- a/$AGENT_HOME/memory/2026-05-10.md +++ /dev/null @@ -1,23 +0,0 @@ -# 2026-05-10 - -## Today's Plan -- Recover stalled issue FRE-4990 (FRE-5102) - -## Timeline - -- **06:43 UTC** — FRE-4990 created: Fix stale-run detector ghost-run dedup on Paperclip server -- **06:45–08:28 UTC** — Ghost run 14acabf9 (Code Reviewer) generated 100+ stale-run evaluation issues; CTO closed them across ~30 heartbeats -- **07:31 UTC** — FRE-4990 reassigned from Junior Engineer → Senior Engineer, bumped to critical -- **07:46 UTC** — CEO intervened: FRE-4808 reassigned to Junior Engineer, Senior Engineer to start FRE-4990 immediately -- **11:24 UTC** — Senior Engineer identified `processPid` missing from `seedRunningRun` -- **11:28 UTC** — First fix attempt: adding `processPid` globally broke ghost-run tests -- **11:17 UTC** — Junior Engineer's execution run on FRE-4990 terminated as stale (2h silence, 0% CPU) -- **12:32 UTC** — Paperclip auto-recovery created FRE-5102, blocked FRE-4990 -- **12:34–12:36 UTC** — CTO (me) resolved FRE-5102: unblocked FRE-4990 (cleared blockedBy, set to `in_progress`), left guidance for Senior Engineer - -## Completed -- FRE-5102: Recover stalled issue FRE-4990 → done - -## Active After Recovery -- FRE-4990: `in_progress` (critical) — Senior Engineer, no blockers -- FRE-5042: `todo` (critical) — Senior Engineer, tactical fallback if dedup takes longer diff --git a/.env.example b/.env.example deleted file mode 100644 index 59a4a161f..000000000 --- a/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# Turso Database Configuration -TURSO_DATABASE_URL=libsql://-.turso.io -TURSO_AUTH_TOKEN= - -# Backup Configuration (optional) -BACKUP_INTERVAL_MS=86400000 -BACKUP_RETENTION_DAYS=30 -BACKUP_REGION=us-east - -# Clerk Authentication -VITE_CLERK_PUBLISHABLE_KEY=pk_ -VITE_CLERK_SIGN_IN_URL=/sign-in -VITE_CLERK_SIGN_UP_URL=/sign-up diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml deleted file mode 100644 index 0712bc0ae..000000000 --- a/.github/workflows/ios-ci.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: iOS CI - -on: - push: - branches: [main] - paths: - - "Lendair/**" - - ".github/workflows/ios-ci.yml" - pull_request: - branches: [main] - paths: - - "Lendair/**" - - ".github/workflows/ios-ci.yml" - -concurrency: - group: ios-ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint: - name: Swift Lint - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select Xcode - run: sudo xcode-select -s ${{ env.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - env: - XCODE_APP_PATH: ${{ vars.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - - - name: Swift Format Check - run: | - swift format lint Lendair/Models Lendair/Services Lendair/ViewModels Lendair/Views || { - echo "::warning::Swift format issues detected (non-blocking)" - } - - build: - name: Build - runs-on: macos-latest - needs: lint - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select Xcode - run: sudo xcode-select -s ${{ env.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - env: - XCODE_APP_PATH: ${{ vars.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - - - name: Swift version - run: swift --version - - - name: Resolve dependencies - run: swift package resolve - working-directory: Lendair - - - name: Build - run: swift build --target LendairApp - working-directory: Lendair - - test: - name: Test - runs-on: macos-latest - needs: build - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select Xcode - run: sudo xcode-select -s ${{ env.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - env: - XCODE_APP_PATH: ${{ vars.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - - - name: Run tests - run: swift test - working-directory: Lendair - - deploy-testflight: - name: Deploy to TestFlight - runs-on: macos-latest - needs: test - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Select Xcode - run: sudo xcode-select -s ${{ env.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - env: - XCODE_APP_PATH: ${{ vars.XCODE_APP_PATH || '/Applications/Xcode_15.4.app' }} - - - name: Generate Xcode project - run: swift package generate-xcodeproj - working-directory: Lendair - - - name: Create keychain for code signing - run: | - security create-keychain -p "" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "" build.keychain - security set-keychain-settings -t 3600 -u build.keychain - - - name: Import codesigning certificates - uses: apple-actions/import-codesign-certs@v2 - with: - p12-file-base64: ${{ secrets.APPLE_DISTRIBUTION_CERT_BASE64 }} - p12-password: ${{ secrets.APPLE_DISTRIBUTION_CERT_PASSWORD }} - keychain: build.keychain - - - name: Import provisioning profile - run: | - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - echo "${{ secrets.APPLE_PROVISIONING_PROFILE }}" > ~/Library/MobileDevice/Provisioning\ Profiles/Lendair.mobileprovision - - - name: Create Export Options Plist - run: | - cat > Lendair/ExportOptions.plist << EOF - - - - - method - app-store - uploadBitcode - - uploadSymbols - - compileBitcode - - - - EOF - - - name: Archive with xcodebuild - run: | - xcodebuild archive \ - -project Lendair/Lendair.xcodeproj \ - -scheme LendairApp \ - -configuration Release \ - -destination "generic/platform=iOS" \ - -archivePath Lendair/build/Lendair.xcarchive \ - CODE_SIGN_STYLE=Automatic \ - PROVISIONING_PROFILE_SPECIFIER=Automatic \ - DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }} - - - name: Export IPA - run: | - xcodebuild -exportArchive \ - -archivePath Lendair/build/Lendair.xcarchive \ - -exportPath Lendair/build/export \ - -exportOptionsPlist Lendair/ExportOptions.plist - - - name: Upload to TestFlight - uses: apple-actions/upload-testflight-binary@v1 - with: - app-path: Lendair/build/export/LendairApp.ipa - github-token: ${{ secrets.GITHUB_TOKEN }} - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} diff --git a/.github/workflows/load-testing.yml b/.github/workflows/load-testing.yml deleted file mode 100644 index 537c4bf98..000000000 --- a/.github/workflows/load-testing.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Load Testing - -on: - push: - branches: [main] - paths: - - "scripts/load-test/**" - - ".github/workflows/load-testing.yml" - pull_request: - branches: [main] - paths: - - "scripts/load-test/**" - - ".github/workflows/load-testing.yml" - schedule: - # Run load tests daily at 2 AM UTC - - cron: "0 2 * * *" - -concurrency: - group: load-testing-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - working-directory: scripts/load-test - -jobs: - load-test: - name: Performance Load Test - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: scripts/load-test/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Run load tests - run: npm run load-test - env: - API_BASE_URL: ${{ secrets.API_BASE_URL || 'https://api.frenocorp.com' }} - LOAD_TEST_CONCURRENCY: ${{ vars.LOAD_TEST_CONCURRENCY || 10 }} - LOAD_TEST_DURATION: ${{ vars.LOAD_TEST_DURATION || 60 }} - - - name: Upload results artifact - uses: actions/upload-artifact@v4 - with: - name: load-test-results-${{ github.run_id }} - path: scripts/load-test/reports/ - retention-days: 7 - - performance-baseline: - name: Performance Baseline Check - runs-on: ubuntu-latest - needs: load-test - if: github.event_name == 'pull_request' - steps: - - name: Download results - uses: actions/download-artifact@v4 - with: - name: load-test-results-${{ github.run_id }} - path: scripts/load-test/reports/ - - - name: Compare against baseline - run: | - if [ -f "scripts/load-test/reports/baseline.json" ]; then - echo "Comparing against baseline..." - # Add comparison logic here - npm run compare-baseline - else - echo "No baseline found, creating initial baseline" - npm run create-baseline - fi - env: - BASELINE_THRESHOLD: ${{ vars.BASELINE_THRESHOLD || 0.1 }} diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml deleted file mode 100644 index e46fac893..000000000 --- a/.github/workflows/web-ci.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Web CI - -on: - push: - branches: [main] - paths: - - "*.json" - - "*.ts" - - "*.tsx" - - "*.js" - - "*.jsx" - - "*.html" - - ".github/workflows/web-ci.yml" - pull_request: - branches: [main] - paths: - - "*.json" - - "*.ts" - - "*.tsx" - - "*.js" - - "*.jsx" - - "*.html" - - ".github/workflows/web-ci.yml" - -concurrency: - group: web-ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - name: Type Check - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npx tsc --noEmit - - test: - name: Tests - runs-on: ubuntu-latest - needs: check - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npx vitest run --reporter=verbose - - build: - name: Build - runs-on: ubuntu-latest - needs: test - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - deploy: - name: Deploy to Vercel - runs-on: ubuntu-latest - needs: build - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Deploy to Vercel - uses: amondnet/vercel-action@v30 - with: - vercel-token: ${{ secrets.VERCEL_TOKEN }} - vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} - vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} - vercel-args: --prod diff --git a/Lendair/App/main.swift b/Lendair/App/main.swift deleted file mode 100644 index 76f82d6e9..000000000 --- a/Lendair/App/main.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI -import Lendair - -@main -struct LendairApp: SwiftUI.App { - var body: some Scene { - WindowGroup { - MainTabView() - } - } -} diff --git a/Lendair/LendairTests/LendairTests.swift b/Lendair/LendairTests/LendairTests.swift deleted file mode 100644 index a3c67e7bf..000000000 --- a/Lendair/LendairTests/LendairTests.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -class LendairTests: XCTestCase { - func testPackageLoads() throws { - XCTAssertTrue(true) - } -} diff --git a/Lendair/Models/AppSettings.swift b/Lendair/Models/AppSettings.swift deleted file mode 100644 index 84935bcca..000000000 --- a/Lendair/Models/AppSettings.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -struct AppSettings { - static let appVersion = "1.0.0" - static let buildNumber = "1" - - static let termsOfServiceURL = URL(string: "https://lendair.app/terms/2026-03-22") - static let privacyPolicyURL = URL(string: "https://lendair.app/privacy/2026-03-25") -} - -enum AccountAction { - case logout - case deleteAccount - case viewTermsOfService - case viewPrivacyPolicy -} diff --git a/Lendair/Models/BeginnerMode.swift b/Lendair/Models/BeginnerMode.swift deleted file mode 100644 index ecf309bb3..000000000 --- a/Lendair/Models/BeginnerMode.swift +++ /dev/null @@ -1,201 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Beginner Configuration - -struct BeginnerConfig: Codable { - var isEnabled: Bool - var currentLevel: BeginnerLevel - var completedOnboardingSteps: [OnboardingStep] - var milestones: [Milestone] - var shownTips: Set - var preferredMetric: MetricType - - enum CodingKeys: String, CodingKey { - case isEnabled, currentLevel, completedOnboardingSteps, milestones, shownTips, preferredMetric - } -} - -// MARK: - Beginner Level - -enum BeginnerLevel: String, CaseIterable, Codable { - case justStarted - case gettingComfortable - case buildingConsistency - case progressing - - var displayName: String { - switch self { - case .justStarted: return "Just Started" - case .gettingComfortable: return "Getting Comfortable" - case .buildingConsistency: return "Building Consistency" - case .progressing: return "Progressing" - } - } - - var requiredWorkouts: Int { - switch self { - case .justStarted: return 0 - case .gettingComfortable: return 5 - case .buildingConsistency: return 15 - case .progressing: return 30 - } - } - - var icon: String { - switch self { - case .justStarted: return "sparkles" - case .gettingComfortable: return "leaf.fill" - case .buildingConsistency: return "chart.bar.fill" - case .progressing: return "bolt.fill" - } - } -} - -// MARK: - Onboarding Step - -enum OnboardingStep: String, CaseIterable, Codable { - case profileSetup - case goalSelection - case firstActivity - case inviteFriends - case enableNotifications - case choosePlan - - var displayName: String { - switch self { - case .profileSetup: return "Profile Setup" - case .goalSelection: return "Set Goals" - case .firstActivity: return "First Activity" - case .inviteFriends: return "Invite Friends" - case .enableNotifications: return "Enable Notifications" - case .choosePlan: return "Choose a Plan" - } - } - - var description: String { - switch self { - case .profileSetup: return "Complete your profile with your name and photo" - case .goalSelection: return "Tell us what you want to achieve" - case .firstActivity: return "Record your first workout" - case .inviteFriends: return "Invite friends to join Nessa" - case .enableNotifications: return "Get reminders and updates" - case .choosePlan: return "Pick a training plan to follow" - } - } -} - -// MARK: - Milestone - -struct Milestone: Identifiable, Codable { - let id: String - let title: String - let description: String - let icon: String - let requirement: MilestoneRequirement - var isCompleted: Bool - var completedAt: Date? - - enum CodingKeys: String, CodingKey { - case id, title, description, icon, requirement, isCompleted, completedAt - } - - init( - id: String, - title: String, - description: String, - icon: String, - requirement: MilestoneRequirement, - isCompleted: Bool, - completedAt: Date? - ) { - self.id = id - self.title = title - self.description = description - self.icon = icon - self.requirement = requirement - self.isCompleted = isCompleted - self.completedAt = completedAt - } -} - -// MARK: - Milestone Requirement - -struct MilestoneRequirement: Codable { - let type: RequirementType - let targetValue: Double -} - -enum RequirementType: String, CaseIterable, Codable { - case totalDistanceKm - case totalWorkouts - case consecutiveDays - case weeklyConsistency - case firstWorkout - - var displayName: String { - switch self { - case .totalDistanceKm: return "Total Distance" - case .totalWorkouts: return "Total Workouts" - case .consecutiveDays: return "Streak" - case .weeklyConsistency: return "Weekly Consistency" - case .firstWorkout: return "First Workout" - } - } -} - -// MARK: - Tip - -struct BeginnerTip: Identifiable, Codable { - let id: String - let context: TipContext - let title: String - let message: String - var isShown: Bool -} - -enum TipContext: String, CaseIterable, Codable { - case beforeWorkout - case afterWorkout - case dailyReminder - case progressUpdate - case restDay -} - -// MARK: - Metric Type - -enum MetricType: String, CaseIterable, Codable { - case distance - case duration - case pace - - var displayName: String { - switch self { - case .distance: return "Distance" - case .duration: return "Duration" - case .pace: return "Pace" - } - } -} - -// MARK: - API Response Types - -struct BeginnerConfigResponse: Decodable { - let config: BeginnerConfig -} - -struct UpdateBeginnerConfigRequest: Encodable { - var isEnabled: Bool? - var completedOnboardingSteps: [OnboardingStep]? - var preferredMetric: MetricType? -} - -struct UpdateBeginnerConfigResponse: Decodable { - let success: Bool - let config: BeginnerConfig -} - -struct MilestoneProgressResponse: Decodable { - let milestones: [Milestone] - let currentLevel: BeginnerLevel -} diff --git a/Lendair/Models/Challenge.swift b/Lendair/Models/Challenge.swift deleted file mode 100644 index 0ca64ac54..000000000 --- a/Lendair/Models/Challenge.swift +++ /dev/null @@ -1,315 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Challenge - -struct Challenge: Identifiable, Equatable, Codable { - let id: String - let title: String - let description: String - let challengeType: ChallengeType - var status: ChallengeStatus - let startDate: Date - let endDate: Date - let targetMetric: ChallengeMetric - let targetValue: Double - let targetUnit: String - var participantCount: Int - let rules: String? - let imageUrl: String? - let createdBy: String - let createdByName: String - let clubId: String? - var participationStatus: ParticipationStatus - var userProgress: Double? - let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, title, description, challengeType, status, startDate, endDate, targetMetric, targetValue, targetUnit, participantCount, rules, imageUrl, createdBy, createdByName, clubId, participationStatus, userProgress, createdAt - } - - init( - id: String, - title: String, - description: String, - challengeType: ChallengeType, - status: ChallengeStatus, - startDate: Date, - endDate: Date, - targetMetric: ChallengeMetric, - targetValue: Double, - targetUnit: String, - participantCount: Int, - rules: String?, - imageUrl: String?, - createdBy: String, - createdByName: String, - clubId: String?, - participationStatus: ParticipationStatus, - userProgress: Double?, - createdAt: Date - ) { - self.id = id - self.title = title - self.description = description - self.challengeType = challengeType - self.status = status - self.startDate = startDate - self.endDate = endDate - self.targetMetric = targetMetric - self.targetValue = targetValue - self.targetUnit = targetUnit - self.participantCount = participantCount - self.rules = rules - self.imageUrl = imageUrl - self.createdBy = createdBy - self.createdByName = createdByName - self.clubId = clubId - self.participationStatus = participationStatus - self.userProgress = userProgress - self.createdAt = createdAt - } - - static func == (lhs: Challenge, rhs: Challenge) -> Bool { - lhs.id == rhs.id && lhs.participationStatus == rhs.participationStatus - } - - var progressPercentage: Double { - guard let progress = userProgress else { return 0 } - return min((progress / targetValue) * 100, 100) - } - - var daysRemaining: Int { - let calendar = Calendar.current - let components = calendar.dateComponents([.day], from: Date(), to: endDate) - return components.day ?? 0 - } - - var isUpcoming: Bool { - startDate > Date() - } - - var isActive: Bool { - Date() >= startDate && Date() <= endDate - } - - var isCompleted: Bool { - endDate < Date() - } -} - -// MARK: - Challenge Type - -enum ChallengeType: String, CaseIterable, Codable { - case distance - case time - case frequency - case elevation - case calories - case streak - - var displayName: String { - switch self { - case .distance: return "Distance" - case .time: return "Time" - case .frequency: return "Frequency" - case .elevation: return "Elevation" - case .calories: return "Calories" - case .streak: return "Streak" - } - } - - var icon: String { - switch self { - case .distance: return "arrow.right.arrow.left" - case .time: return "stopwatch.fill" - case .frequency: return "repeat" - case .elevation: return "mountain.2.fill" - case .calories: return "flame.fill" - case .streak: return "calendar.badge.clock" - } - } - - var color: Color { - switch self { - case .distance: return .blue - case .time: return .orange - case .frequency: return .green - case .elevation: return .brown - case .calories: return .red - case .streak: return .purple - } - } -} - -// MARK: - Challenge Status - -enum ChallengeStatus: String, CaseIterable, Codable { - case upcoming - case active - case completed - case cancelled -} - -// MARK: - Challenge Metric - -enum ChallengeMetric: String, CaseIterable, Codable { - case distance - case time - case frequency - case elevation - case calories - - var unit: String { - switch self { - case .distance: return "km" - case .time: return "min" - case .frequency: return "sessions" - case .elevation: return "m" - case .calories: return "kcal" - } - } - - var displayName: String { - switch self { - case .distance: return "Distance" - case .time: return "Time" - case .frequency: return "Sessions" - case .elevation: return "Elevation" - case .calories: return "Calories" - } - } -} - -// MARK: - Participation Status - -enum ParticipationStatus: String, CaseIterable, Codable { - case participating - case notParticipating - case invited -} - -// MARK: - Challenge Participant - -struct ChallengeParticipant: Identifiable, Codable { - let id: String - let name: String - let avatarUrl: String? - let progress: Double - let rank: Int - let joinedAt: Date -} - -// MARK: - Leaderboard Entry - -struct LeaderboardEntry: Identifiable, Codable { - let id: String - let position: Int - let participantId: String - let participantName: String - let participantAvatarUrl: String? - let progress: Double - let progressPercentage: Double -} - -// MARK: - Progress Submission - -struct ProgressSubmission: Encodable { - let metric: ChallengeMetric - let value: Double - let activityDate: Date -} - -// MARK: - Create Challenge Request - -struct CreateChallengeRequest: Encodable { - let title: String - let description: String - let challengeType: ChallengeType - let startDate: Date - let endDate: Date - let targetMetric: ChallengeMetric - let targetValue: Double - let rules: String? - let clubId: String? -} - -// MARK: - Update Challenge Request - -struct UpdateChallengeRequest: Encodable { - var title: String? - var description: String? - var challengeType: ChallengeType? - var startDate: Date? - var endDate: Date? - var targetMetric: ChallengeMetric? - var targetValue: Double? - var rules: String? - var status: ChallengeStatus? -} - -// MARK: - Challenge Filter - -struct ChallengeFilter: Encodable { - var challengeType: ChallengeType? - var status: ChallengeStatus? - var participationStatus: ParticipationStatus? - var clubId: String? - var limit: Int - var offset: Int - - init( - challengeType: ChallengeType? = nil, - status: ChallengeStatus? = nil, - participationStatus: ParticipationStatus? = nil, - clubId: String? = nil, - limit: Int = 20, - offset: Int = 0 - ) { - self.challengeType = challengeType - self.status = status - self.participationStatus = participationStatus - self.clubId = clubId - self.limit = limit - self.offset = offset - } -} - -// MARK: - API Response Types - -struct ChallengeListResponse: Decodable { - let challenges: [Challenge] - let hasMore: Bool -} - -struct ChallengeDetailResponse: Decodable { - let challenge: Challenge - let participants: [ChallengeParticipant] -} - -struct CreateChallengeResponse: Decodable { - let challenge: Challenge -} - -struct UpdateChallengeResponse: Decodable { - let challenge: Challenge -} - -struct LeaderboardResponse: Decodable { - let entries: [LeaderboardEntry] - let userPosition: Int? - let totalParticipants: Int -} - -struct ParticipationResponse: Decodable { - let success: Bool - let challengeId: String - let status: ParticipationStatus -} - -struct ProgressResponse: Decodable { - let success: Bool - let challengeId: String - let newProgress: Double - let progressPercentage: Double -} diff --git a/Lendair/Models/Club.swift b/Lendair/Models/Club.swift deleted file mode 100644 index dad863bfb..000000000 --- a/Lendair/Models/Club.swift +++ /dev/null @@ -1,275 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Club - -struct Club: Identifiable, Equatable, Codable { - let id: String - let name: String - let description: String - let clubType: ClubType - let privacy: ClubPrivacy - let location: String - let latitude: Double? - let longitude: Double? - var memberCount: Int - let maxMembers: Int? - let imageUrl: String? - let rules: String? - let ownerId: String - let ownerName: String - var membershipStatus: MembershipStatus - let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, name, description, clubType, privacy, location, latitude, longitude, memberCount, maxMembers, imageUrl, rules, ownerId, ownerName, membershipStatus, createdAt - } - - init( - id: String, - name: String, - description: String, - clubType: ClubType, - privacy: ClubPrivacy, - location: String, - latitude: Double?, - longitude: Double?, - memberCount: Int, - maxMembers: Int?, - imageUrl: String?, - rules: String?, - ownerId: String, - ownerName: String, - membershipStatus: MembershipStatus, - createdAt: Date - ) { - self.id = id - self.name = name - self.description = description - self.clubType = clubType - self.privacy = privacy - self.location = location - self.latitude = latitude - self.longitude = longitude - self.memberCount = memberCount - self.maxMembers = maxMembers - self.imageUrl = imageUrl - self.rules = rules - self.ownerId = ownerId - self.ownerName = ownerName - self.membershipStatus = membershipStatus - self.createdAt = createdAt - } - - static func == (lhs: Club, rhs: Club) -> Bool { - lhs.id == rhs.id && lhs.membershipStatus == rhs.membershipStatus - } - - var availableSpots: Int? { - guard let max = maxMembers else { return nil } - return max - memberCount - } - - var isFull: Bool { - guard let max = maxMembers else { return false } - return memberCount >= max - } -} - -// MARK: - Club Type - -enum ClubType: String, CaseIterable, Codable { - case running - case walking - case cycling - case triathlon - case crossfit - case general - - var displayName: String { - switch self { - case .running: return "Running" - case .walking: return "Walking" - case .cycling: return "Cycling" - case .triathlon: return "Triathlon" - case .crossfit: return "CrossFit" - case .general: return "General Fitness" - } - } - - var icon: String { - switch self { - case .running: return "figure.run" - case .walking: return "figure.walk" - case .cycling: return "bicycle" - case .triathlon: return "triangle.fill" - case .crossfit: return "dumbbell.fill" - case .general: return "heart.fill" - } - } - - var color: Color { - switch self { - case .running: return .blue - case .walking: return .green - case .cycling: return .orange - case .triathlon: return .purple - case .crossfit: return .red - case .general: return .indigo - } - } -} - -// MARK: - Club Privacy - -enum ClubPrivacy: String, CaseIterable, Codable { - case publicPrivacy - case privateClub - case invitationOnly - - var displayName: String { - switch self { - case .publicPrivacy: return "Public" - case .privateClub: return "Private" - case .invitationOnly: return "Invitation Only" - } - } - - var icon: String { - switch self { - case .publicPrivacy: return "globe" - case .privateClub: return "lock.fill" - case .invitationOnly: return "mail.fill" - } - } -} - -// MARK: - Membership Status - -enum MembershipStatus: String, CaseIterable, Codable { - case active - case pending - case invited - case left -} - -// MARK: - Club Member - -struct ClubMember: Identifiable, Codable { - let id: String - let name: String - let avatarUrl: String? - let role: MemberRole - let joinedAt: Date - let membershipStatus: MembershipStatus -} - -enum MemberRole: String, CaseIterable, Codable { - case owner - case admin - case member - - var displayName: String { - switch self { - case .owner: return "Owner" - case .admin: return "Admin" - case .member: return "Member" - } - } -} - -// MARK: - Create Club Request - -struct CreateClubRequest: Encodable { - let name: String - let description: String - let clubType: ClubType - let privacy: ClubPrivacy - let location: String - let latitude: Double? - let longitude: Double? - let maxMembers: Int? - let rules: String? -} - -// MARK: - Update Club Request - -struct UpdateClubRequest: Encodable { - var name: String? - var description: String? - var clubType: ClubType? - var privacy: ClubPrivacy? - var location: String? - var latitude: Double? - var longitude: Double? - var maxMembers: Int? - var rules: String? -} - -// MARK: - Club Filter - -struct ClubFilter: Encodable { - var clubType: ClubType? - var privacy: ClubPrivacy? - var membershipStatus: MembershipStatus? - var location: String? - var radiusKm: Double? - var limit: Int - var offset: Int - - init( - clubType: ClubType? = nil, - privacy: ClubPrivacy? = nil, - membershipStatus: MembershipStatus? = nil, - location: String? = nil, - radiusKm: Double? = nil, - limit: Int = 20, - offset: Int = 0 - ) { - self.clubType = clubType - self.privacy = privacy - self.membershipStatus = membershipStatus - self.location = location - self.radiusKm = radiusKm - self.limit = limit - self.offset = offset - } -} - -// MARK: - API Response Types - -struct ClubListResponse: Decodable { - let clubs: [Club] - let hasMore: Bool -} - -struct ClubDetailResponse: Decodable { - let club: Club - let members: [ClubMember] -} - -struct CreateClubResponse: Decodable { - let club: Club -} - -struct UpdateClubResponse: Decodable { - let club: Club -} - -struct MembershipResponse: Decodable { - let success: Bool - let clubId: String - let status: MembershipStatus -} - -struct InviteMemberResponse: Decodable { - let success: Bool - let clubId: String - let memberId: String -} - -struct RemoveMemberResponse: Decodable { - let success: Bool - let clubId: String - let memberId: String -} diff --git a/Lendair/Models/CommunityEvent.swift b/Lendair/Models/CommunityEvent.swift deleted file mode 100644 index 4f7e6e7da..000000000 --- a/Lendair/Models/CommunityEvent.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Community Event - -struct CommunityEvent: Identifiable, Equatable, Codable { - let id: String - let title: String - let description: String - let eventType: EventType - let location: String - let latitude: Double - let longitude: Double - let startDate: Date - let endDate: Date - let distanceKm: Double? - let organizerId: String - let organizerName: String - let maxParticipants: Int? - let participantCount: Int - let imageUrl: String? - let difficulty: Difficulty? - var rsvpStatus: RSVPStatus - let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, title, description, eventType, location, latitude, longitude, startDate, endDate, distanceKm, organizerId, organizerName, maxParticipants, participantCount, imageUrl, difficulty, rsvpStatus, createdAt - } - - init( - id: String, - title: String, - description: String, - eventType: EventType, - location: String, - latitude: Double, - longitude: Double, - startDate: Date, - endDate: Date, - distanceKm: Double?, - organizerId: String, - organizerName: String, - maxParticipants: Int?, - participantCount: Int, - imageUrl: String?, - difficulty: Difficulty?, - rsvpStatus: RSVPStatus, - createdAt: Date - ) { - self.id = id - self.title = title - self.description = description - self.eventType = eventType - self.location = location - self.latitude = latitude - self.longitude = longitude - self.startDate = startDate - self.endDate = endDate - self.distanceKm = distanceKm - self.organizerId = organizerId - self.organizerName = organizerName - self.maxParticipants = maxParticipants - self.participantCount = participantCount - self.imageUrl = imageUrl - self.difficulty = difficulty - self.rsvpStatus = rsvpStatus - self.createdAt = createdAt - } - - static func == (lhs: CommunityEvent, rhs: CommunityEvent) -> Bool { - lhs.id == rhs.id && lhs.rsvpStatus == rhs.rsvpStatus - } - - var isUpcoming: Bool { - startDate > Date() - } - - var isOngoing: Bool { - Date() >= startDate && Date() <= endDate - } - - var isPast: Bool { - endDate < Date() - } - - var availableSpots: Int? { - guard let max = maxParticipants else { return nil } - return max - participantCount - } -} - -// MARK: - Event Type - -enum EventType: String, CaseIterable, Codable { - case groupRun - case race - case workshop - case socialGather - case charityEvent - case trainingCamp - - var displayName: String { - switch self { - case .groupRun: return "Group Run" - case .race: return "Race" - case .workshop: return "Workshop" - case .socialGather: return "Social" - case .charityEvent: return "Charity" - case .trainingCamp: return "Training Camp" - } - } - - var icon: String { - switch self { - case .groupRun: return "person.3.fill" - case .race: return "flag.fill" - case .workshop: return "lightbulb.fill" - case .socialGather: return "cup.and.saucer.fill" - case .charityEvent: return "heart.fill" - case .trainingCamp: return "figure.run" - } - } - - var color: Color { - switch self { - case .groupRun: return .blue - case .race: return .orange - case .workshop: return .purple - case .socialGather: return .green - case .charityEvent: return .red - case .trainingCamp: return .indigo - } - } -} - -// MARK: - RSVP Status - -enum RSVPStatus: String, CaseIterable, Codable { - case going - case maybe - case notGoing - case pending -} - -// MARK: - Event Participant - -struct EventParticipant: Identifiable, Codable { - let id: String - let name: String - let avatarUrl: String? - let rsvpStatus: RSVPStatus - let joinedAt: Date -} - -// MARK: - Create Event Request - -struct CreateEventRequest: Encodable { - let title: String - let description: String - let eventType: EventType - let location: String - let latitude: Double - let longitude: Double - let startDate: Date - let endDate: Date - let distanceKm: Double? - let maxParticipants: Int? - let difficulty: Difficulty? -} - -// MARK: - Update Event Request - -struct UpdateEventRequest: Encodable { - var title: String? - var description: String? - var eventType: EventType? - var location: String? - var latitude: Double? - var longitude: Double? - var startDate: Date? - var endDate: Date? - var distanceKm: Double? - var maxParticipants: Int? -} - -// MARK: - Event Filter - -struct EventFilter: Encodable { - var eventType: EventType? - var startDate: Date? - var endDate: Date? - var location: String? - var radiusKm: Double? - var rsvpStatus: RSVPStatus? - var limit: Int - var offset: Int - - init( - eventType: EventType? = nil, - startDate: Date? = nil, - endDate: Date? = nil, - location: String? = nil, - radiusKm: Double? = nil, - rsvpStatus: RSVPStatus? = nil, - limit: Int = 20, - offset: Int = 0 - ) { - self.eventType = eventType - self.startDate = startDate - self.endDate = endDate - self.location = location - self.radiusKm = radiusKm - self.rsvpStatus = rsvpStatus - self.limit = limit - self.offset = offset - } -} - -// MARK: - API Response Types - -struct EventListResponse: Decodable { - let events: [CommunityEvent] - let hasMore: Bool -} - -struct EventDetailResponse: Decodable { - let event: CommunityEvent - let participants: [EventParticipant] -} - -struct CreateEventResponse: Decodable { - let event: CommunityEvent -} - -struct UpdateEventResponse: Decodable { - let event: CommunityEvent -} - -struct RSVPResponse: Decodable { - let success: Bool - let eventId: String - let status: RSVPStatus -} diff --git a/Lendair/Models/FamilyPlan.swift b/Lendair/Models/FamilyPlan.swift deleted file mode 100644 index 90ee899cd..000000000 --- a/Lendair/Models/FamilyPlan.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Family Plan - -struct FamilyPlan: Identifiable, Equatable, Codable { - let id: String - let ownerId: String - let ownerName: String - let members: [FamilyMember] - let maxMembers: Int - let subscriptionStatus: SubscriptionStatus - let renewalDate: Date? - let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, ownerId, ownerName, members, maxMembers, subscriptionStatus, renewalDate, createdAt - } - - init( - id: String, - ownerId: String, - ownerName: String, - members: [FamilyMember], - maxMembers: Int, - subscriptionStatus: SubscriptionStatus, - renewalDate: Date?, - createdAt: Date - ) { - self.id = id - self.ownerId = ownerId - self.ownerName = ownerName - self.members = members - self.maxMembers = maxMembers - self.subscriptionStatus = subscriptionStatus - self.renewalDate = renewalDate - self.createdAt = createdAt - } - - static func == (lhs: FamilyPlan, rhs: FamilyPlan) -> Bool { - lhs.id == rhs.id - } - - var availableSlots: Int { - maxMembers - members.count - } - - var isActive: Bool { - subscriptionStatus == .active - } -} - -// MARK: - Family Member - -struct FamilyMember: Identifiable, Equatable, Codable { - let id: String - let name: String - let email: String - let role: MemberRole - let joinedAt: Date - let avatarUrl: String? - var isPrimary: Bool - var totalDistanceKm: Double - var totalWorkouts: Int - var weeklyDistanceKm: Double - var weeklyWorkouts: Int - - enum CodingKeys: String, CodingKey { - case id, name, email, role, joinedAt, avatarUrl, isPrimary, totalDistanceKm, totalWorkouts, weeklyDistanceKm, weeklyWorkouts - } - - init( - id: String, - name: String, - email: String, - role: MemberRole, - joinedAt: Date, - avatarUrl: String?, - isPrimary: Bool, - totalDistanceKm: Double, - totalWorkouts: Int, - weeklyDistanceKm: Double, - weeklyWorkouts: Int - ) { - self.id = id - self.name = name - self.email = email - self.role = role - self.joinedAt = joinedAt - self.avatarUrl = avatarUrl - self.isPrimary = isPrimary - self.totalDistanceKm = totalDistanceKm - self.totalWorkouts = totalWorkouts - self.weeklyDistanceKm = weeklyDistanceKm - self.weeklyWorkouts = weeklyWorkouts - } - - static func == (lhs: FamilyMember, rhs: FamilyMember) -> Bool { - lhs.id == rhs.id - } -} - -// MARK: - Member Role - -enum MemberRole: String, CaseIterable, Codable { - case owner - case member - case pending - - var displayName: String { - switch self { - case .owner: return "Owner" - case .member: return "Member" - case .pending: return "Pending" - } - } - - var icon: String { - switch self { - case .owner: return "star.fill" - case .member: return "person.fill" - case .pending: return "person.crop.circle.badge.exclamationmark" - } - } -} - -// MARK: - Subscription Status - -enum SubscriptionStatus: String, CaseIterable, Codable { - case active - case expired - case cancelled - case pending - - var displayName: String { - switch self { - case .active: return "Active" - case .expired: return "Expired" - case .cancelled: return "Cancelled" - case .pending: return "Pending" - } - } - - var color: Color { - switch self { - case .active: return .green - case .expired: return .red - case .cancelled: return .orange - case .pending: return .yellow - } - } -} - -// MARK: - Family Leaderboard Entry - -struct FamilyLeaderboardEntry: Identifiable, Codable { - let id: String - let memberId: String - let memberName: String - let avatarUrl: String? - let metric: LeaderboardMetric - let value: Double - let rank: Int -} - -// MARK: - Leaderboard Metric - -enum LeaderboardMetric: String, CaseIterable, Codable { - case distance - case workouts - case streak - - var displayName: String { - switch self { - case .distance: return "Distance" - case .workouts: return "Workouts" - case .streak: return "Streak" - } - } - - var unit: String { - switch self { - case .distance: return "km" - case .workouts: return "" - case .streak: return "days" - } - } -} - -// MARK: - Invite Member Request - -struct InviteMemberRequest: Encodable { - let email: String - let name: String -} - -// MARK: - API Response Types - -struct FamilyPlanDetailResponse: Decodable { - let plan: FamilyPlan -} - -struct InviteMemberResponse: Decodable { - let success: Bool - let invitationId: String - let memberEmail: String -} - -struct RemoveMemberResponse: Decodable { - let success: Bool - let memberId: String -} - -struct FamilyLeaderboardResponse: Decodable { - let entries: [FamilyLeaderboardEntry] - let metric: LeaderboardMetric -} diff --git a/Lendair/Models/Notification.swift b/Lendair/Models/Notification.swift deleted file mode 100644 index 49bef57e0..000000000 --- a/Lendair/Models/Notification.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Notification Item - -struct NotificationItem: Identifiable, Equatable, Codable { - let id: String - let type: NotificationType - let title: String - let message: String - let createdAt: Date - var isRead: Bool - - enum CodingKeys: String, CodingKey { - case id, type, title, message, createdAt, isRead - } - - init(id: String, type: NotificationType, title: String, message: String, createdAt: Date, isRead: Bool) { - self.id = id - self.type = type - self.title = title - self.message = message - self.createdAt = createdAt - self.isRead = isRead - } - - static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { - lhs.id == rhs.id && lhs.isRead == rhs.isRead - } -} - -// MARK: - Notification Type - -enum NotificationType: String, CaseIterable, Codable { - case loanApproved = "LOAN_APPROVED" - case loanRejected = "LOAN_REJECTED" - case paymentReceived = "PAYMENT_RECEIVED" - case paymentDue = "PAYMENT_DUE" - case newLender = "NEW_LENDER" - case systemUpdate = "SYSTEM_UPDATE" - - var icon: String { - switch self { - case .loanApproved: return "checkmark.circle.fill" - case .loanRejected: return "xmark.circle.fill" - case .paymentReceived: return "arrow.down.circle.fill" - case .paymentDue: return "exclamationmark.circle.fill" - case .newLender: return "person.circle.fill" - case .systemUpdate: return "info.circle.fill" - } - } - - var color: Color { - switch self { - case .loanApproved: return .green - case .loanRejected: return .red - case .paymentReceived: return .green - case .paymentDue: return .orange - case .newLender: return .blue - case .systemUpdate: return .gray - } - } -} - -// MARK: - List Parameters - -struct NotificationListParams: Encodable { - var limit: Int - var offset: Int - - init(limit: Int = 20, offset: Int = 0) { - self.limit = limit - self.offset = offset - } -} - -// MARK: - API Response Types - -struct NotificationListResponse: Decodable { - let notifications: [NotificationItem] - let hasMore: Bool -} - -struct NotificationMarkAsReadResponse: Decodable { - let success: Bool - let notificationId: String -} - -struct NotificationMarkAllReadResponse: Decodable { - let success: Bool - let markedCount: Int -} - -struct NotificationUnreadCountResponse: Decodable { - let count: Int -} diff --git a/Lendair/Models/Race.swift b/Lendair/Models/Race.swift deleted file mode 100644 index e32421b40..000000000 --- a/Lendair/Models/Race.swift +++ /dev/null @@ -1,183 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Race - -struct Race: Identifiable, Equatable, Codable { - let id: String - let name: String - let description: String - let location: String - let latitude: Double - let longitude: Double - let raceDate: Date - let distanceKm: Double - let raceType: RaceType - let organizerName: String - let registrationUrl: String? - let imageUrl: String? - let participantCount: Int? - let isRegistered: Bool - let isSaved: Bool - let elevationGain: Double - let terrainType: TerrainType - - enum CodingKeys: String, CodingKey { - case id, name, description, location, latitude, longitude, raceDate, distanceKm, raceType, organizerName, registrationUrl, imageUrl, participantCount, isRegistered, isSaved, elevationGain, terrainType - } - - init( - id: String, - name: String, - description: String, - location: String, - latitude: Double, - longitude: Double, - raceDate: Date, - distanceKm: Double, - raceType: RaceType, - organizerName: String, - registrationUrl: String?, - imageUrl: String?, - participantCount: Int?, - isRegistered: Bool, - isSaved: Bool, - elevationGain: Double, - terrainType: TerrainType - ) { - self.id = id - self.name = name - self.description = description - self.location = location - self.latitude = latitude - self.longitude = longitude - self.raceDate = raceDate - self.distanceKm = distanceKm - self.raceType = raceType - self.organizerName = organizerName - self.registrationUrl = registrationUrl - self.imageUrl = imageUrl - self.participantCount = participantCount - self.isRegistered = isRegistered - self.isSaved = isSaved - self.elevationGain = elevationGain - self.terrainType = terrainType - } - - static func == (lhs: Race, rhs: Race) -> Bool { - lhs.id == rhs.id && lhs.isRegistered == rhs.isRegistered && lhs.isSaved == rhs.isSaved - } - - var daysUntilRace: Int { - let calendar = Calendar.current - return calendar.dateComponents([.day], from: Date(), to: raceDate).day ?? 0 - } - - var isUpcoming: Bool { - raceDate > Date() - } -} - -// MARK: - Race Type - -enum RaceType: String, CaseIterable, Codable { - case road - case trail - case track - case virtual - - var displayName: String { - switch self { - case .road: return "Road" - case .trail: return "Trail" - case .track: return "Track" - case .virtual: return "Virtual" - } - } - - var icon: String { - switch self { - case .road: return "car.fill" - case .trail: return "mountain.2.fill" - case .track: return "circle.fill" - case .virtual: return "globe" - } - } -} - -// MARK: - Terrain Type - -enum TerrainType: String, CaseIterable, Codable { - case flat - case rolling - case hilly - case mountainous - - var displayName: String { - switch self { - case .flat: return "Flat" - case .rolling: return "Rolling" - case .hilly: return "Hilly" - case .mountainous: return "Mountainous" - } - } -} - -// MARK: - Race Filter - -struct RaceFilter: Encodable { - var distanceKm: Double? - var raceType: RaceType? - var terrainType: TerrainType? - var startDate: Date? - var endDate: Date? - var location: String? - var radiusKm: Double? - var limit: Int - var offset: Int - - init( - distanceKm: Double? = nil, - raceType: RaceType? = nil, - terrainType: TerrainType? = nil, - startDate: Date? = nil, - endDate: Date? = nil, - location: String? = nil, - radiusKm: Double? = nil, - limit: Int = 20, - offset: Int = 0 - ) { - self.distanceKm = distanceKm - self.raceType = raceType - self.terrainType = terrainType - self.startDate = startDate - self.endDate = endDate - self.location = location - self.radiusKm = radiusKm - self.limit = limit - self.offset = offset - } -} - -// MARK: - API Response Types - -struct RaceListResponse: Decodable { - let races: [Race] - let hasMore: Bool -} - -struct RaceDetailResponse: Decodable { - let race: Race -} - -struct SaveRaceResponse: Decodable { - let success: Bool - let raceId: String - let isSaved: Bool -} - -struct RegisterRaceResponse: Decodable { - let success: Bool - let raceId: String - let registrationUrl: String? -} diff --git a/Lendair/Models/TrainingPlan.swift b/Lendair/Models/TrainingPlan.swift deleted file mode 100644 index e0ff8ce6e..000000000 --- a/Lendair/Models/TrainingPlan.swift +++ /dev/null @@ -1,312 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - Training Plan - -struct TrainingPlan: Identifiable, Equatable, Codable { - let id: String - let title: String - let description: String - let planType: PlanType - let durationWeeks: Int - let difficulty: Difficulty - let startDate: Date - let endDate: Date - let weeklyWorkouts: [WeeklyWorkout] - var progress: PlanProgress - var isFollowing: Bool - let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, title, description, planType, durationWeeks, difficulty, startDate, endDate, weeklyWorkouts, progress, isFollowing, createdAt - } - - init( - id: String, - title: String, - description: String, - planType: PlanType, - durationWeeks: Int, - difficulty: Difficulty, - startDate: Date, - endDate: Date, - weeklyWorkouts: [WeeklyWorkout], - progress: PlanProgress, - isFollowing: Bool, - createdAt: Date - ) { - self.id = id - self.title = title - self.description = description - self.planType = planType - self.durationWeeks = durationWeeks - self.difficulty = difficulty - self.startDate = startDate - self.endDate = endDate - self.weeklyWorkouts = weeklyWorkouts - self.progress = progress - self.isFollowing = isFollowing - self.createdAt = createdAt - } - - static func == (lhs: TrainingPlan, rhs: TrainingPlan) -> Bool { - lhs.id == rhs.id - } -} - -// MARK: - Plan Type - -enum PlanType: String, CaseIterable, Codable { - case fiveK = "5K" - case tenK = "10K" - case halfMarathon = "HALF_MARATHON" - case fullMarathon = "FULL_MARATHON" - case custom = "CUSTOM" - - var displayName: String { - switch self { - case .fiveK: return "5K" - case .tenK: return "10K" - case .halfMarathon: return "Half Marathon" - case .fullMarathon: return "Full Marathon" - case .custom: return "Custom" - } - } - - var distanceKm: Double { - switch self { - case .fiveK: return 5.0 - case .tenK: return 10.0 - case .halfMarathon: return 21.1 - case .fullMarathon: return 42.2 - case .custom: return 0.0 - } - } - - var icon: String { - switch self { - case .fiveK: return "figure.run" - case .tenK: return "figure.run" - case .halfMarathon: return "flag.fill" - case .fullMarathon: return "flag.fill" - case .custom: return "wrench.and.screwdriver" - } - } -} - -// MARK: - Difficulty - -enum Difficulty: String, CaseIterable, Codable { - case beginner - case intermediate - case advanced - case elite - - var displayName: String { - switch self { - case .beginner: return "Beginner" - case .intermediate: return "Intermediate" - case .advanced: return "Advanced" - case .elite: return "Elite" - } - } - - var color: Color { - switch self { - case .beginner: return .green - case .intermediate: return .blue - case .advanced: return .orange - case .elite: return .red - } - } -} - -// MARK: - Weekly Workout - -struct WeeklyWorkout: Identifiable, Codable { - let id: String - let weekNumber: Int - let dailySessions: [DailySession] - - var completedSessions: Int { - dailySessions.filter { $0.status == .completed }.count - } - - var totalSessions: Int { - dailySessions.count - } - - var progressPercentage: Double { - totalSessions == 0 ? 0 : Double(completedSessions) / Double(totalSessions) * 100 - } -} - -// MARK: - Daily Session - -struct DailySession: Identifiable, Codable { - let id: String - let dayOfWeek: DayOfWeek - let workoutType: WorkoutType - let title: String - let description: String - let targetDistanceKm: Double? - let targetDurationMinutes: Int? - let targetPaceMinPerKm: Double? - let intensity: Intensity - var status: SessionStatus - var completedDistanceKm: Double? - var completedDurationMinutes: Int? - - enum CodingKeys: String, CodingKey { - case id, dayOfWeek, workoutType, title, description, targetDistanceKm, targetDurationMinutes, targetPaceMinPerKm, intensity, status, completedDistanceKm, completedDurationMinutes - } -} - -// MARK: - Day of Week - -enum DayOfWeek: String, CaseIterable, Codable { - case monday, tuesday, wednesday, thursday, friday, saturday, sunday - - var displayName: String { - switch self { - case .monday: return "Mon" - case .tuesday: return "Tue" - case .wednesday: return "Wed" - case .thursday: return "Thu" - case .friday: return "Fri" - case .saturday: return "Sat" - case .sunday: return "Sun" - } - } -} - -// MARK: - Workout Type - -enum WorkoutType: String, CaseIterable, Codable { - case easyRun - case tempoRun - case intervalTraining - case longRun - case speedWork - case recoveryRun - case crossTraining - case rest - - var displayName: String { - switch self { - case .easyRun: return "Easy Run" - case .tempoRun: return "Tempo Run" - case .intervalTraining: return "Intervals" - case .longRun: return "Long Run" - case .speedWork: return "Speed Work" - case .recoveryRun: return "Recovery Run" - case .crossTraining: return "Cross Train" - case .rest: return "Rest" - } - } - - var icon: String { - switch self { - case .easyRun: return "figure.walk" - case .tempoRun: return "figure.run" - case .intervalTraining: return "bolt.fill" - case .longRun: return "figure.run" - case .speedWork: return "speedometer" - case .recoveryRun: return "leaf.fill" - case .crossTraining: return "dumbbell.fill" - case .rest: return "moon.fill" - } - } - - var color: Color { - switch self { - case .easyRun: return .green - case .tempoRun: return .blue - case .intervalTraining: return .purple - case .longRun: return .orange - case .speedWork: return .red - case .recoveryRun: return .mint - case .crossTraining: return .gray - case .rest: return .secondary - } - } -} - -// MARK: - Intensity - -enum Intensity: String, CaseIterable, Codable { - case veryEasy - case easy - case moderate - case hard - case veryHard - - var displayName: String { - switch self { - case .veryEasy: return "Very Easy" - case .easy: return "Easy" - case .moderate: return "Moderate" - case .hard: return "Hard" - case .veryHard: return "Very Hard" - } - } -} - -// MARK: - Session Status - -enum SessionStatus: String, CaseIterable, Codable { - case pending - case inProgress - case completed - case skipped -} - -// MARK: - Plan Progress - -struct PlanProgress: Codable { - let completedWeeks: Int - let totalWeeks: Int - let completedSessions: Int - let totalSessions: Int - let currentWeekNumber: Int - - var percentage: Double { - totalWeeks == 0 ? 0 : Double(completedWeeks) / Double(totalWeeks) * 100 - } - - var sessionPercentage: Double { - totalSessions == 0 ? 0 : Double(completedSessions) / Double(totalSessions) * 100 - } -} - -// MARK: - AI Plan Generation Request - -struct GeneratePlanRequest: Encodable { - let planType: PlanType - let difficulty: Difficulty - let startDate: Date - let currentWeeklyMileageKm: Double? - let goalTimeMinutes: Int? - let availableDays: [DayOfWeek] -} - -// MARK: - API Response Types - -struct TrainingPlanListResponse: Decodable { - let plans: [TrainingPlan] - let hasMore: Bool -} - -struct TrainingPlanDetailResponse: Decodable { - let plan: TrainingPlan -} - -struct GeneratePlanResponse: Decodable { - let plan: TrainingPlan -} - -struct UpdateSessionStatusResponse: Decodable { - let success: Bool - let sessionId: String - let status: SessionStatus -} diff --git a/Lendair/Package.swift b/Lendair/Package.swift deleted file mode 100644 index 49b209054..000000000 --- a/Lendair/Package.swift +++ /dev/null @@ -1,46 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "Lendair", - platforms: [ - .iOS(.v16), - .macOS(.v13) - ], - products: [ - .library( - name: "Lendair", - targets: ["Lendair"] - ), - .executable( - name: "LendairApp", - targets: ["LendairApp"] - ) - ], - targets: [ - .target( - name: "Lendair", - path: ".", - sources: [ - "Models", - "Services", - "Utils", - "ViewModels", - "Views" - ], - linkerSettings: [ - .linkedFramework("UIKit", .when(platforms: [.iOS])) - ] - ), - .executableTarget( - name: "LendairApp", - dependencies: ["Lendair"], - path: "App" - ), - .testTarget( - name: "LendairTests", - dependencies: ["Lendair"], - path: "LendairTests" - ) - ] -) diff --git a/Lendair/README.md b/Lendair/README.md deleted file mode 100644 index a9653be55..000000000 --- a/Lendair/README.md +++ /dev/null @@ -1,264 +0,0 @@ -# Lendair iOS App - -## Overview -SwiftUI iOS app with modular feature architecture following MVVM pattern. - -## Architecture - -### MVVM Pattern -- **View**: `Views/` - SwiftUI views for all feature screens -- **ViewModel**: `ViewModels/` - State management and business logic -- **Service**: `Services/` - Data layer with API communication -- **Model**: `Models/` - Data structures and type definitions - -### File Structure -``` -Lendair/ -├── Models/ -│ ├── Notification.swift # NotificationItem, NotificationType, API response types -│ ├── TrainingPlan.swift # TrainingPlan, PlanType, WorkoutSession, PlanProgress -│ ├── Race.swift # Race, RaceType, RaceFilter, API response types -│ ├── FamilyPlan.swift # FamilyPlan, FamilyMember, LeaderboardMetric -│ ├── BeginnerMode.swift # BeginnerConfig, Milestone, OnboardingStep -│ ├── CommunityEvent.swift # CommunityEvent, EventType, RSVPStatus -│ ├── Club.swift # Club, ClubType, ClubPrivacy, MembershipStatus, ClubMember -│ └── Challenge.swift # Challenge, ChallengeType, ChallengeStatus, LeaderboardEntry -├── Services/ -│ ├── NotificationService.swift # NotificationsServiceProtocol + implementation -│ ├── TrainingPlanService.swift # TrainingPlanServiceProtocol + implementation -│ ├── RaceService.swift # RaceServiceProtocol + implementation -│ ├── FamilyPlanService.swift # FamilyPlanServiceProtocol + implementation -│ ├── BeginnerModeService.swift # BeginnerModeServiceProtocol + implementation -│ ├── CommunityEventService.swift # CommunityEventServiceProtocol + implementation -│ ├── ClubService.swift # ClubServiceProtocol + implementation -│ └── ChallengeService.swift # ChallengeServiceProtocol + implementation -├── ViewModels/ -│ ├── NotificationsViewModel.swift -│ ├── TrainingPlanViewModel.swift -│ ├── RaceDiscoveryViewModel.swift -│ ├── FamilyPlanViewModel.swift -│ ├── BeginnerModeViewModel.swift -│ ├── CommunityEventViewModel.swift -│ ├── ClubViewModel.swift -│ └── ChallengeViewModel.swift -├── Views/ -│ ├── NotificationsView.swift -│ ├── NotificationRowView.swift -│ ├── TrainingPlanView.swift -│ ├── TrainingPlanDetailView.swift -│ ├── WorkoutSessionView.swift -│ ├── RaceDiscoveryView.swift -│ ├── RaceDetailView.swift -│ ├── FamilyPlanView.swift -│ ├── FamilyMemberView.swift -│ ├── BeginnerModeView.swift -│ ├── CommunityEventsView.swift -│ ├── CommunityEventDetailView.swift -│ ├── ClubsView.swift -│ ├── ClubDetailView.swift -│ ├── ChallengesView.swift -│ ├── ChallengeDetailView.swift -│ ├── MainTabView.swift -│ └── SettingsView.swift -├── Models/ -│ ├── Notification.swift -│ ├── TrainingPlan.swift -│ ├── Race.swift -│ ├── FamilyPlan.swift -│ ├── BeginnerMode.swift -│ ├── CommunityEvent.swift -│ ├── Club.swift -│ ├── Challenge.swift -│ └── AppSettings.swift -└── README.md -``` - -## Features - -### Notifications -- Notification list with pull-to-refresh -- Mark-as-read (individual and bulk) -- Type-specific icons and color coding -- Empty state handling - -### AI Training Plans (Phase 3 - Premium) -- Personalized training plan generation (5K, 10K, Half/Full Marathon, Custom) -- Difficulty levels: Beginner, Intermediate, Advanced, Elite -- Weekly/daily workout scheduling with progressive overload -- Plan progress tracking with session completion -- Workout session execution with metrics display -- Plan following/unfollowing - -### Race Discovery (Phase 3 - Premium) -- Browse upcoming races by location, distance, type, terrain -- Race detail pages with registration links -- Save/bookmark races -- Filter by race type (Road, Trail, Track, Virtual) -- Calendar integration ready - -### Family Plans (Phase 3 - Premium) -- Multi-member household management (up to 6 members) -- Invite members via email -- Individual progress tracking per member -- Family leaderboard (distance, workouts, streak) -- Subscription status management - -### Beginner Mode (Phase 3 - Premium) -- Guided onboarding with step tracking -- Progressive levels: Just Started → Getting Comfortable → Building Consistency → Progressing -- Milestone achievements and tracking -- Contextual tips and educational content -- Simplified metric display - -### Community Events (Phase 3 - Premium) -- Event discovery and creation -- RSVP system (Going, Maybe, Not Going) -- Event types: Group Run, Race, Workshop, Social, Charity, Training Camp -- Participant tracking -- Upcoming/ongoing/past event categorization - -### Clubs (Phase 2 - Community) -- Persistent community groups for runners and fitness enthusiasts -- Club types: Running, Walking, Cycling, Triathlon, CrossFit, General Fitness -- Privacy levels: Public, Private, Invitation Only -- Member management with roles (Owner, Admin, Member) -- Invite members via email -- Club rules and capacity limits -- Discover clubs by type, location, and privacy - -### Challenges (Phase 2 - Community) -- Time-bound competitive goals with progress tracking -- Challenge types: Distance, Time, Frequency, Elevation, Calories, Streak -- Real-time leaderboard with rankings -- Progress submission and percentage tracking -- Join/leave challenges -- Create custom challenges with rules and targets -- Active/upcoming/completed challenge categorization - -### Settings/About (Phase 2 - Core) -- App version and build number display -- Links to Terms of Service and Privacy Policy documents -- User logout functionality -- Account deletion option -- Profile information display - -## Service Pattern - -All services follow the same architecture: -- **Protocol**: `Sendable` protocol for testability -- **Implementation**: Configurable `baseURL`, `URLSession`, `authToken` -- **Error Handling**: Typed error enums with `LocalizedError` conformance -- **HTTP Methods**: GET, POST, PATCH, DELETE via shared helpers - -## API Endpoints - -### Notifications -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/notifications?limit=&offset=` | List notifications | -| PATCH | `/api/notifications/:id/read` | Mark single as read | -| PATCH | `/api/notifications/read-all` | Mark all as read | - -### Training Plans -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/training-plans?type=&difficulty=` | List plans | -| GET | `/api/training-plans/:id` | Get plan detail | -| POST | `/api/training-plans/generate` | Generate AI plan | -| POST | `/api/training-plans/:id/follow` | Follow plan | -| DELETE | `/api/training-plans/:id/follow` | Unfollow plan | -| PATCH | `/api/training-plans/sessions/:id/status` | Update session status | - -### Races -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/races?type=&terrain=&...` | List races with filters | -| GET | `/api/races/:id` | Get race detail | -| POST/DELETE | `/api/races/:id/save` | Save/unsave race | -| POST | `/api/races/:id/register` | Register for race | - -### Family Plans -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/family-plan` | Get family plan | -| POST | `/api/family-plan/invite` | Invite member | -| DELETE | `/api/family-plan/members/:id` | Remove member | -| GET | `/api/family-plan/leaderboard?metric=` | Get leaderboard | - -### Beginner Mode -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/beginner-mode/config` | Get config | -| PATCH | `/api/beginner-mode/config` | Update config | -| GET | `/api/beginner-mode/milestones` | Get milestone progress | - -### Community Events -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/events?type=&rsvp=&...` | List events with filters | -| GET | `/api/events/:id` | Get event detail | -| POST | `/api/events` | Create event | -| PATCH | `/api/events/:id` | Update event | -| POST | `/api/events/:id/rsvp` | RSVP to event | - -### Clubs -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/clubs?type=&privacy=&...` | List clubs with filters | -| GET | `/api/clubs/:id` | Get club detail with members | -| POST | `/api/clubs` | Create club | -| PATCH | `/api/clubs/:id` | Update club | -| POST | `/api/clubs/:id/join` | Join club | -| POST | `/api/clubs/:id/leave` | Leave club | -| POST | `/api/clubs/:id/invite` | Invite member by email | -| DELETE | `/api/clubs/:id/members/:memberId` | Remove member | - -### Challenges -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/challenges?type=&status=&...` | List challenges with filters | -| GET | `/api/challenges/:id` | Get challenge detail with participants | -| POST | `/api/challenges` | Create challenge | -| PATCH | `/api/challenges/:id` | Update challenge | -| POST | `/api/challenges/:id/join` | Join challenge | -| POST | `/api/challenges/:id/leave` | Leave challenge | -| GET | `/api/challenges/:id/leaderboard` | Get challenge leaderboard | -| POST | `/api/challenges/:id/progress` | Submit progress | - -### Settings/About -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/settings/version` | Get app version and build number | -| GET | `/api/settings/legal/terms` | Get Terms of Service document | -| GET | `/api/settings/legal/privacy` | Get Privacy Policy document | -| POST | `/api/settings/logout` | User logout | -| DELETE | `/api/settings/account` | Delete user account | - -## Testing - -Tests are in `LendairTests/`: -- Uses mock services conforming to feature protocols -- ViewModel tests cover fetch, update, error handling, and computed properties -- Model tests cover enum cases, display values, equality, and computed properties -- Available test files: `NotificationServiceTests.swift`, `ClubServiceTests.swift`, `ChallengeServiceTests.swift` - -## Usage - -```swift -// Feature views can be integrated into your navigation stack -NavigationStack { - ClubsView() -} - -NavigationStack { - ChallengesView() -} - -NavigationStack { - CommunityEventsView() -} -``` - -## Premium Features - -All Phase 3 features (Training Plans, Race Discovery, Family Plans, Beginner Mode, Community Events) require a Pro subscription ($9.99/mo). Subscription status should be verified via the existing SubscriptionService before feature access. diff --git a/Lendair/Services/BeginnerModeService.swift b/Lendair/Services/BeginnerModeService.swift deleted file mode 100644 index 26e88e078..000000000 --- a/Lendair/Services/BeginnerModeService.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol BeginnerModeServiceProtocol: Sendable { - func getConfig() async throws -> BeginnerConfig - func updateConfig(request: UpdateBeginnerConfigRequest) async throws -> BeginnerConfig - func getMilestoneProgress() async throws -> (milestones: [Milestone], level: BeginnerLevel) -} - -// MARK: - Default Service - -class BeginnerModeService: BeginnerModeServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - init( - baseURL: URL = URL(string: "http://localhost:3000")!, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func getConfig() async throws -> BeginnerConfig { - let url = baseURL.appendingPathComponent("/api/beginner-mode/config") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(BeginnerConfigResponse.self, from: data) - return decoded.config - } - - func updateConfig(request: UpdateBeginnerConfigRequest) async throws -> BeginnerConfig { - let url = baseURL.appendingPathComponent("/api/beginner-mode/config") - var request = try buildRequest(url: url, method: .patch) - request.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(UpdateBeginnerConfigResponse.self, from: data) - return decoded.config - } - - func getMilestoneProgress() async throws -> (milestones: [Milestone], level: BeginnerLevel) { - let url = baseURL.appendingPathComponent("/api/beginner-mode/milestones") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(MilestoneProgressResponse.self, from: data) - return (decoded.milestones, decoded.currentLevel) - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body = body { - request.httpBody = body - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw BeginnerModeError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw BeginnerModeError.unauthorized - case 403: throw BeginnerModeError.forbidden - case 404: throw BeginnerModeError.notFound - case 429: throw BeginnerModeError.rateLimited - case 500...599: throw BeginnerModeError.serverError(httpResponse.statusCode) - default: throw BeginnerModeError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum BeginnerModeError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Beginner mode config not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - } - } -} diff --git a/Lendair/Services/ChallengeService.swift b/Lendair/Services/ChallengeService.swift deleted file mode 100644 index 39a6f4cd5..000000000 --- a/Lendair/Services/ChallengeService.swift +++ /dev/null @@ -1,181 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol ChallengeServiceProtocol: Sendable { - func listChallenges(filter: ChallengeFilter) async throws -> [Challenge] - func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) - func createChallenge(request: CreateChallengeRequest) async throws -> Challenge - func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge - func joinChallenge(id: String) async throws - func leaveChallenge(id: String) async throws - func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] - func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) -} - -// MARK: - Default Service - -class ChallengeService: ChallengeServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - static let defaultBaseURL: URL = URL(string: "http://localhost:3000")! - - init( - baseURL: URL = defaultBaseURL, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func listChallenges(filter: ChallengeFilter = ChallengeFilter()) async throws -> [Challenge] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/challenges"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "limit", value: String(filter.limit)), - URLQueryItem(name: "offset", value: String(filter.offset)) - ] - if let type = filter.challengeType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) } - if let status = filter.status { queryItems.append(URLQueryItem(name: "status", value: status.rawValue)) } - if let participation = filter.participationStatus { queryItems.append(URLQueryItem(name: "participation", value: participation.rawValue)) } - if let clubId = filter.clubId { queryItems.append(URLQueryItem(name: "clubId", value: clubId)) } - components.queryItems = queryItems - - let request = try buildRequest(url: components.url!) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(ChallengeListResponse.self, from: data) - return decoded.challenges - } - - func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) { - let url = baseURL.appendingPathComponent("/api/challenges/\(id)") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(ChallengeDetailResponse.self, from: data) - return (decoded.challenge, decoded.participants) - } - - func createChallenge(request: CreateChallengeRequest) async throws -> Challenge { - let url = baseURL.appendingPathComponent("/api/challenges") - var urlRequest = try buildRequest(url: url, method: .post) - urlRequest.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: urlRequest) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(CreateChallengeResponse.self, from: data) - return decoded.challenge - } - - func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge { - let url = baseURL.appendingPathComponent("/api/challenges/\(id)") - var urlRequest = try buildRequest(url: url, method: .patch) - urlRequest.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: urlRequest) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(UpdateChallengeResponse.self, from: data) - return decoded.challenge - } - - func joinChallenge(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/challenges/\(id)/join") - let request = try buildRequest(url: url, method: .post) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func leaveChallenge(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/challenges/\(id)/leave") - let request = try buildRequest(url: url, method: .post) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] { - let url = baseURL.appendingPathComponent("/api/challenges/\(challengeId)/leaderboard") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(LeaderboardResponse.self, from: data) - return decoded.entries - } - - func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) { - let url = baseURL.appendingPathComponent("/api/challenges/\(challengeId)/progress") - var request = try buildRequest(url: url, method: .post) - request.httpBody = try JSONEncoder().encode(progress) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(ProgressResponse.self, from: data) - return (decoded.newProgress, decoded.progressPercentage) - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw ChallengeError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw ChallengeError.unauthorized - case 403: throw ChallengeError.forbidden - case 404: throw ChallengeError.notFound - case 429: throw ChallengeError.rateLimited - case 500...599: throw ChallengeError.serverError(httpResponse.statusCode) - default: throw ChallengeError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum ChallengeError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Challenge not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - } - } -} diff --git a/Lendair/Services/ClubService.swift b/Lendair/Services/ClubService.swift deleted file mode 100644 index b9ab12fd3..000000000 --- a/Lendair/Services/ClubService.swift +++ /dev/null @@ -1,177 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol ClubServiceProtocol: Sendable { - func listClubs(filter: ClubFilter) async throws -> [Club] - func getClub(id: String) async throws -> (club: Club, members: [ClubMember]) - func createClub(request: CreateClubRequest) async throws -> Club - func updateClub(id: String, request: UpdateClubRequest) async throws -> Club - func joinClub(id: String) async throws - func leaveClub(id: String) async throws - func inviteMember(clubId: String, email: String) async throws - func removeMember(clubId: String, memberId: String) async throws -} - -// MARK: - Default Service - -class ClubService: ClubServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - static let defaultBaseURL: URL = URL(string: "http://localhost:3000")! - - init( - baseURL: URL = defaultBaseURL, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func listClubs(filter: ClubFilter = ClubFilter()) async throws -> [Club] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/clubs"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "limit", value: String(filter.limit)), - URLQueryItem(name: "offset", value: String(filter.offset)) - ] - if let type = filter.clubType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) } - if let privacy = filter.privacy { queryItems.append(URLQueryItem(name: "privacy", value: privacy.rawValue)) } - if let status = filter.membershipStatus { queryItems.append(URLQueryItem(name: "status", value: status.rawValue)) } - if let location = filter.location { queryItems.append(URLQueryItem(name: "location", value: location)) } - if let radius = filter.radiusKm { queryItems.append(URLQueryItem(name: "radius", value: String(radius))) } - components.queryItems = queryItems - - let request = try buildRequest(url: components.url!) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(ClubListResponse.self, from: data) - return decoded.clubs - } - - func getClub(id: String) async throws -> (club: Club, members: [ClubMember]) { - let url = baseURL.appendingPathComponent("/api/clubs/\(id)") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(ClubDetailResponse.self, from: data) - return (decoded.club, decoded.members) - } - - func createClub(request: CreateClubRequest) async throws -> Club { - let url = baseURL.appendingPathComponent("/api/clubs") - var urlRequest = try buildRequest(url: url, method: .post) - urlRequest.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: urlRequest) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(CreateClubResponse.self, from: data) - return decoded.club - } - - func updateClub(id: String, request: UpdateClubRequest) async throws -> Club { - let url = baseURL.appendingPathComponent("/api/clubs/\(id)") - var urlRequest = try buildRequest(url: url, method: .patch) - urlRequest.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: urlRequest) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(UpdateClubResponse.self, from: data) - return decoded.club - } - - func joinClub(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/clubs/\(id)/join") - let request = try buildRequest(url: url, method: .post) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func leaveClub(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/clubs/\(id)/leave") - let request = try buildRequest(url: url, method: .post) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func inviteMember(clubId: String, email: String) async throws { - let url = baseURL.appendingPathComponent("/api/clubs/\(clubId)/invite") - var request = try buildRequest(url: url, method: .post) - request.httpBody = try JSONEncoder().encode(["email": email]) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func removeMember(clubId: String, memberId: String) async throws { - let url = baseURL.appendingPathComponent("/api/clubs/\(clubId)/members/\(memberId)") - let request = try buildRequest(url: url, method: .delete) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw ClubError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw ClubError.unauthorized - case 403: throw ClubError.forbidden - case 404: throw ClubError.notFound - case 429: throw ClubError.rateLimited - case 500...599: throw ClubError.serverError(httpResponse.statusCode) - default: throw ClubError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum ClubError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Club not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - } - } -} diff --git a/Lendair/Services/CommunityEventService.swift b/Lendair/Services/CommunityEventService.swift deleted file mode 100644 index c8d49f287..000000000 --- a/Lendair/Services/CommunityEventService.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol CommunityEventServiceProtocol: Sendable { - func listEvents(filter: EventFilter) async throws -> [CommunityEvent] - func getEvent(id: String) async throws -> (event: CommunityEvent, participants: [EventParticipant]) - func createEvent(request: CreateEventRequest) async throws -> CommunityEvent - func updateEvent(id: String, request: UpdateEventRequest) async throws -> CommunityEvent - func RSVP(eventId: String, status: RSVPStatus) async throws -} - -// MARK: - Default Service - -class CommunityEventService: CommunityEventServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - init( - baseURL: URL = URL(string: "http://localhost:3000")!, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func listEvents(filter: EventFilter = EventFilter()) async throws -> [CommunityEvent] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/events"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "limit", value: String(filter.limit)), - URLQueryItem(name: "offset", value: String(filter.offset)) - ] - if let type = filter.eventType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) } - if let startDate = filter.startDate { queryItems.append(URLQueryItem(name: "startDate", value: ISO8601DateFormatter().string(from: startDate))) } - if let endDate = filter.endDate { queryItems.append(URLQueryItem(name: "endDate", value: ISO8601DateFormatter().string(from: endDate))) } - if let location = filter.location { queryItems.append(URLQueryItem(name: "location", value: location)) } - if let radius = filter.radiusKm { queryItems.append(URLQueryItem(name: "radius", value: String(radius))) } - if let rsvp = filter.rsvpStatus { queryItems.append(URLQueryItem(name: "rsvp", value: rsvp.rawValue)) } - components.queryItems = queryItems - - let request = try buildRequest(url: components.url!) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(EventListResponse.self, from: data) - return decoded.events - } - - func getEvent(id: String) async throws -> (event: CommunityEvent, participants: [EventParticipant]) { - let url = baseURL.appendingPathComponent("/api/events/\(id)") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(EventDetailResponse.self, from: data) - return (decoded.event, decoded.participants) - } - - func createEvent(request: CreateEventRequest) async throws -> CommunityEvent { - let url = baseURL.appendingPathComponent("/api/events") - var request = try buildRequest(url: url, method: .post) - request.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(CreateEventResponse.self, from: data) - return decoded.event - } - - func updateEvent(id: String, request: UpdateEventRequest) async throws -> CommunityEvent { - let url = baseURL.appendingPathComponent("/api/events/\(id)") - var request = try buildRequest(url: url, method: .patch) - request.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(UpdateEventResponse.self, from: data) - return decoded.event - } - - func RSVP(eventId: String, status: RSVPStatus) async throws { - let url = baseURL.appendingPathComponent("/api/events/\(eventId)/rsvp") - var request = try buildRequest(url: url, method: .post) - request.httpBody = try JSONEncoder().encode(["status": status.rawValue]) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body = body { - request.httpBody = body - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw CommunityEventError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw CommunityEventError.unauthorized - case 403: throw CommunityEventError.forbidden - case 404: throw CommunityEventError.notFound - case 429: throw CommunityEventError.rateLimited - case 500...599: throw CommunityEventError.serverError(httpResponse.statusCode) - default: throw CommunityEventError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum CommunityEventError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Event not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - } - } -} diff --git a/Lendair/Services/FamilyPlanService.swift b/Lendair/Services/FamilyPlanService.swift deleted file mode 100644 index e2aec2a7a..000000000 --- a/Lendair/Services/FamilyPlanService.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol FamilyPlanServiceProtocol: Sendable { - func getFamilyPlan() async throws -> FamilyPlan - func inviteMember(request: InviteMemberRequest) async throws - func removeMember(id: String) async throws - func getLeaderboard(metric: LeaderboardMetric) async throws -> [FamilyLeaderboardEntry] -} - -// MARK: - Default Service - -class FamilyPlanService: FamilyPlanServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - init( - baseURL: URL = URL(string: "http://localhost:3000")!, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func getFamilyPlan() async throws -> FamilyPlan { - let url = baseURL.appendingPathComponent("/api/family-plan") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(FamilyPlanDetailResponse.self, from: data) - return decoded.plan - } - - func inviteMember(request: InviteMemberRequest) async throws { - let url = baseURL.appendingPathComponent("/api/family-plan/invite") - var request = try buildRequest(url: url, method: .post) - request.httpBody = try JSONEncoder().encode(request) - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func removeMember(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/family-plan/members/\(id)") - let request = try buildRequest(url: url, method: .delete) - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func getLeaderboard(metric: LeaderboardMetric) async throws -> [FamilyLeaderboardEntry] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/family-plan/leaderboard"), resolvingAgainstBaseURL: true)! - components.queryItems = [URLQueryItem(name: "metric", value: metric.rawValue)] - - let request = try buildRequest(url: components.url!) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(FamilyLeaderboardResponse.self, from: data) - return decoded.entries - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body = body { - request.httpBody = body - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw FamilyPlanError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw FamilyPlanError.unauthorized - case 403: throw FamilyPlanError.forbidden - case 404: throw FamilyPlanError.notFound - case 429: throw FamilyPlanError.rateLimited - case 500...599: throw FamilyPlanError.serverError(httpResponse.statusCode) - default: throw FamilyPlanError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum FamilyPlanError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Family plan not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - } - } -} diff --git a/Lendair/Services/NotificationService.swift b/Lendair/Services/NotificationService.swift deleted file mode 100644 index dd15d96ac..000000000 --- a/Lendair/Services/NotificationService.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol NotificationsServiceProtocol: Sendable { - func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] - func markAsRead(id: String) async throws - func markAllAsRead() async throws - func getUnreadCount() async throws -> Int -} - -// MARK: - Default Service - -class NotificationsService: NotificationsServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - static let defaultBaseURL: URL = URL(string: "http://localhost:3000")! - - init( - baseURL: URL = defaultBaseURL, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/notifications"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "limit", value: String(params.limit)), - URLQueryItem(name: "offset", value: String(params.offset)) - ] - components.queryItems = queryItems - - let request = try buildRequest(url: components.url!) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(NotificationListResponse.self, from: data) - return decoded.notifications - } - - func markAsRead(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/notifications/\(id)/read") - let request = try buildRequest(url: url, method: .patch) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - _ = try JSONDecoder().decode(NotificationMarkAsReadResponse.self, from: data) - } - - func markAllAsRead() async throws { - let url = baseURL.appendingPathComponent("/api/notifications/read-all") - let request = try buildRequest(url: url, method: .patch) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - _ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data) - } - - func getUnreadCount() async throws -> Int { - let url = baseURL.appendingPathComponent("/api/notifications/unread-count") - let request = try buildRequest(url: url) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(NotificationUnreadCountResponse.self, from: data) - return decoded.count - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw NotificationError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw NotificationError.unauthorized - case 403: throw NotificationError.forbidden - case 404: throw NotificationError.notFound - case 429: throw NotificationError.rateLimited - case 500...599: throw NotificationError.serverError(httpResponse.statusCode) - default: throw NotificationError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum NotificationError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - case decodingError(Error) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Notification not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - case .decodingError(let error): return "Decoding error: \(error.localizedDescription)" - } - } -} - -// MARK: - HTTP Method (moved to Utils/HTTPMethod.swift) diff --git a/Lendair/Services/RaceService.swift b/Lendair/Services/RaceService.swift deleted file mode 100644 index f52af835a..000000000 --- a/Lendair/Services/RaceService.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol RaceServiceProtocol: Sendable { - func listRaces(filter: RaceFilter) async throws -> [Race] - func getRace(id: String) async throws -> Race - func saveRace(id: String, isSaved: Bool) async throws - func registerForRace(id: String) async throws -} - -// MARK: - Default Service - -class RaceService: RaceServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - init( - baseURL: URL = URL(string: "http://localhost:3000")!, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func listRaces(filter: RaceFilter = RaceFilter()) async throws -> [Race] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/races"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "limit", value: String(filter.limit)), - URLQueryItem(name: "offset", value: String(filter.offset)) - ] - if let distance = filter.distanceKm { queryItems.append(URLQueryItem(name: "distance", value: String(distance))) } - if let type = filter.raceType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) } - if let terrain = filter.terrainType { queryItems.append(URLQueryItem(name: "terrain", value: terrain.rawValue)) } - if let startDate = filter.startDate { queryItems.append(URLQueryItem(name: "startDate", value: ISO8601DateFormatter().string(from: startDate))) } - if let endDate = filter.endDate { queryItems.append(URLQueryItem(name: "endDate", value: ISO8601DateFormatter().string(from: endDate))) } - if let location = filter.location { queryItems.append(URLQueryItem(name: "location", value: location)) } - if let radius = filter.radiusKm { queryItems.append(URLQueryItem(name: "radius", value: String(radius))) } - components.queryItems = queryItems - - let request = try buildRequest(url: components.url!) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(RaceListResponse.self, from: data) - return decoded.races - } - - func getRace(id: String) async throws -> Race { - let url = baseURL.appendingPathComponent("/api/races/\(id)") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(RaceDetailResponse.self, from: data) - return decoded.race - } - - func saveRace(id: String, isSaved: Bool) async throws { - let method: HTTPMethod = isSaved ? .post : .delete - let url = baseURL.appendingPathComponent("/api/races/\(id)/save") - let request = try buildRequest(url: url, method: method) - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func registerForRace(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/races/\(id)/register") - let request = try buildRequest(url: url, method: .post) - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body = body { - request.httpBody = body - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw RaceError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw RaceError.unauthorized - case 403: throw RaceError.forbidden - case 404: throw RaceError.notFound - case 429: throw RaceError.rateLimited - case 500...599: throw RaceError.serverError(httpResponse.statusCode) - default: throw RaceError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum RaceError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Race not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - } - } -} diff --git a/Lendair/Services/TrainingPlanService.swift b/Lendair/Services/TrainingPlanService.swift deleted file mode 100644 index 082eaf227..000000000 --- a/Lendair/Services/TrainingPlanService.swift +++ /dev/null @@ -1,152 +0,0 @@ -import Foundation - -// MARK: - Service Protocol - -protocol TrainingPlanServiceProtocol: Sendable { - func listPlans(type: PlanType?, difficulty: Difficulty?) async throws -> [TrainingPlan] - func getPlan(id: String) async throws -> TrainingPlan - func generatePlan(request: GeneratePlanRequest) async throws -> TrainingPlan - func followPlan(id: String) async throws - func unfollowPlan(id: String) async throws - func updateSessionStatus(sessionId: String, status: SessionStatus) async throws -} - -// MARK: - Default Service - -class TrainingPlanService: TrainingPlanServiceProtocol { - private let baseURL: URL - private let session: URLSession - private let authToken: String? - - init( - baseURL: URL = URL(string: "http://localhost:3000")!, - session: URLSession = .shared, - authToken: String? = nil - ) { - self.baseURL = baseURL - self.session = session - self.authToken = authToken - } - - func listPlans(type: PlanType? = nil, difficulty: Difficulty? = nil) async throws -> [TrainingPlan] { - var components = URLComponents(url: baseURL.appendingPathComponent("/api/training-plans"), resolvingAgainstBaseURL: true)! - var queryItems: [URLQueryItem] = [] - if let type = type { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) } - if let difficulty = difficulty { queryItems.append(URLQueryItem(name: "difficulty", value: difficulty.rawValue)) } - components.queryItems = queryItems - - let request = try buildRequest(url: components.url!) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(TrainingPlanListResponse.self, from: data) - return decoded.plans - } - - func getPlan(id: String) async throws -> TrainingPlan { - let url = baseURL.appendingPathComponent("/api/training-plans/\(id)") - let request = try buildRequest(url: url) - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(TrainingPlanDetailResponse.self, from: data) - return decoded.plan - } - - func generatePlan(request: GeneratePlanRequest) async throws -> TrainingPlan { - let url = baseURL.appendingPathComponent("/api/training-plans/generate") - var request = try buildRequest(url: url, method: .post) - request.httpBody = try JSONEncoder().encode(request) - - let (data, response) = try await session.data(for: request) - try validateResponse(response) - - let decoded = try JSONDecoder().decode(GeneratePlanResponse.self, from: data) - return decoded.plan - } - - func followPlan(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/training-plans/\(id)/follow") - let request = try buildRequest(url: url, method: .post) - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func unfollowPlan(id: String) async throws { - let url = baseURL.appendingPathComponent("/api/training-plans/\(id)/follow") - let request = try buildRequest(url: url, method: .delete) - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - func updateSessionStatus(sessionId: String, status: SessionStatus) async throws { - let url = baseURL.appendingPathComponent("/api/training-plans/sessions/\(sessionId)/status") - var request = try buildRequest(url: url, method: .patch) - let body = try JSONEncoder().encode(["status": status.rawValue]) - request.httpBody = body - - let (_, response) = try await session.data(for: request) - try validateResponse(response) - } - - // MARK: - Helpers - - private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body = body { - request.httpBody = body - } - - return request - } - - private func validateResponse(_ response: URLResponse) throws { - guard let httpResponse = response as? HTTPURLResponse else { - throw TrainingPlanError.invalidResponse - } - - guard (200...299).contains(httpResponse.statusCode) else { - switch httpResponse.statusCode { - case 401: throw TrainingPlanError.unauthorized - case 403: throw TrainingPlanError.forbidden - case 404: throw TrainingPlanError.notFound - case 429: throw TrainingPlanError.rateLimited - case 500...599: throw TrainingPlanError.serverError(httpResponse.statusCode) - default: throw TrainingPlanError.httpError(httpResponse.statusCode) - } - } - } -} - -// MARK: - Error Types - -enum TrainingPlanError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case rateLimited - case serverError(Int) - case httpError(Int) - case decodingError(Error) - - var errorDescription: String { - switch self { - case .invalidResponse: return "Invalid server response" - case .unauthorized: return "Unauthorized — please log in again" - case .forbidden: return "Forbidden — check permissions" - case .notFound: return "Training plan not found" - case .rateLimited: return "Too many requests — try again shortly" - case .serverError(let code): return "Server error (\(code))" - case .httpError(let code): return "HTTP error (\(code))" - case .decodingError(let error): return "Decoding error: \(error.localizedDescription)" - } - } -} diff --git a/Lendair/Utils/HTTPMethod.swift b/Lendair/Utils/HTTPMethod.swift deleted file mode 100644 index 0b5479ce1..000000000 --- a/Lendair/Utils/HTTPMethod.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -enum HTTPMethod: String { - case get = "GET" - case post = "POST" - case patch = "PATCH" - case delete = "DELETE" -} diff --git a/Lendair/ViewModels/BeginnerModeViewModel.swift b/Lendair/ViewModels/BeginnerModeViewModel.swift deleted file mode 100644 index ba330d453..000000000 --- a/Lendair/ViewModels/BeginnerModeViewModel.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class BeginnerModeViewModel: ObservableObject { - @Published var config: BeginnerConfig? - @Published var milestones: [Milestone] = [] - @Published var currentLevel: BeginnerLevel = .justStarted - @Published var isLoading: Bool = false - @Published var error: BeginnerModeError? - - private let service: BeginnerModeServiceProtocol - - init(service: BeginnerModeServiceProtocol = BeginnerModeService()) { - self.service = service - } - - func fetchConfig() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - config = try await service.getConfig() - currentLevel = config?.currentLevel ?? .justStarted - } catch let error as BeginnerModeError { - self.error = error - } catch { - print("Failed to fetch beginner config: \(error)") - } - } - - func fetchMilestoneProgress() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let result = try await service.getMilestoneProgress() - milestones = result.milestones - currentLevel = result.level - } catch let error as BeginnerModeError { - self.error = error - } catch { - print("Failed to fetch milestone progress: \(error)") - } - } - - func toggleBeginnerMode(isEnabled: Bool) async { - do { - let request = UpdateBeginnerConfigRequest(isEnabled: isEnabled) - let updatedConfig = try await service.updateConfig(request: request) - config = updatedConfig - objectWillChange.send() - } catch { - print("Failed to toggle beginner mode: \(error)") - } - } - - func completeOnboardingStep(_ step: OnboardingStep) async { - guard var currentConfig = config else { return } - currentConfig.completedOnboardingSteps.append(step) - - do { - let request = UpdateBeginnerConfigRequest(completedOnboardingSteps: currentConfig.completedOnboardingSteps) - let updatedConfig = try await service.updateConfig(request: request) - config = updatedConfig - objectWillChange.send() - } catch { - print("Failed to complete onboarding step: \(error)") - } - } - - var onboardingSteps: [OnboardingStep] { OnboardingStep.allCases } - - var completedOnboardingCount: Int { - config?.completedOnboardingSteps.count ?? 0 - } - - var remainingOnboardingSteps: [OnboardingStep] { - let completed = config?.completedOnboardingSteps ?? [] - return onboardingSteps.filter { !completed.contains($0) } - } - - var completedMilestoneCount: Int { - milestones.filter { $0.isCompleted }.count - } - - var totalMilestoneCount: Int { - milestones.count - } - - var levels: [BeginnerLevel] { BeginnerLevel.allCases } -} diff --git a/Lendair/ViewModels/ChallengeViewModel.swift b/Lendair/ViewModels/ChallengeViewModel.swift deleted file mode 100644 index 353e51d0b..000000000 --- a/Lendair/ViewModels/ChallengeViewModel.swift +++ /dev/null @@ -1,165 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class ChallengeViewModel: ObservableObject { - @Published var challenges: [Challenge] = [] - @Published var selectedChallenge: Challenge? - @Published var leaderboard: [LeaderboardEntry] = [] - @Published var isLoading: Bool = false - @Published var error: ChallengeError? - @Published var filter: ChallengeFilter = ChallengeFilter() - - private let service: ChallengeServiceProtocol - - init(service: ChallengeServiceProtocol = ChallengeService()) { - self.service = service - } - - func fetchChallenges() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - challenges = try await service.listChallenges(filter: filter) - } catch let error as ChallengeError { - self.error = error - } catch { - print("Failed to fetch challenges: \(error)") - } - } - - func selectChallenge(id: String) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let result = try await service.getChallenge(id: id) - selectedChallenge = result.challenge - if let index = challenges.firstIndex(where: { $0.id == id }) { - challenges[index] = result.challenge - objectWillChange.send() - } - } catch let error as ChallengeError { - self.error = error - } catch { - print("Failed to get challenge: \(error)") - } - } - - func createChallenge(request: CreateChallengeRequest) async -> Challenge? { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let challenge = try await service.createChallenge(request: request) - challenges.insert(challenge, at: 0) - objectWillChange.send() - return challenge - } catch let error as ChallengeError { - self.error = error - return nil - } catch { - print("Failed to create challenge: \(error)") - return nil - } - } - - func updateChallenge(id: String, request: UpdateChallengeRequest) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let updatedChallenge = try await service.updateChallenge(id: id, request: request) - if let index = challenges.firstIndex(where: { $0.id == id }) { - challenges[index] = updatedChallenge - objectWillChange.send() - } - if selectedChallenge?.id == id { - selectedChallenge = updatedChallenge - } - } catch let error as ChallengeError { - self.error = error - } catch { - print("Failed to update challenge: \(error)") - } - } - - func joinChallenge(id: String) async { - do { - try await service.joinChallenge(id: id) - if let index = challenges.firstIndex(where: { $0.id == id }) { - challenges[index].participationStatus = .participating - challenges[index].participantCount += 1 - objectWillChange.send() - } - } catch { - print("Failed to join challenge: \(error)") - } - } - - func leaveChallenge(id: String) async { - do { - try await service.leaveChallenge(id: id) - if let index = challenges.firstIndex(where: { $0.id == id }) { - challenges[index].participationStatus = .notParticipating - challenges[index].participantCount = max(0, challenges[index].participantCount - 1) - objectWillChange.send() - } - } catch { - print("Failed to leave challenge: \(error)") - } - } - - func fetchLeaderboard(challengeId: String) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - leaderboard = try await service.getLeaderboard(challengeId: challengeId) - } catch let error as ChallengeError { - self.error = error - } catch { - print("Failed to fetch leaderboard: \(error)") - } - } - - func submitProgress(challengeId: String, progress: ProgressSubmission) async { - do { - let result = try await service.submitProgress(challengeId: challengeId, progress: progress) - if let index = challenges.firstIndex(where: { $0.id == challengeId }) { - challenges[index].userProgress = result.progress - objectWillChange.send() - } - if selectedChallenge?.id == challengeId { - selectedChallenge?.userProgress = result.progress - } - } catch { - print("Failed to submit progress: \(error)") - } - } - - var activeChallenges: [Challenge] { - challenges.filter { $0.isActive }.sorted { $0.endDate < $1.endDate } - } - - var upcomingChallenges: [Challenge] { - challenges.filter { $0.isUpcoming }.sorted { $0.startDate < $1.startDate } - } - - var completedChallenges: [Challenge] { - challenges.filter { $0.isCompleted }.sorted { $0.endDate > $1.endDate } - } - - var userChallenges: [Challenge] { - challenges.filter { $0.participationStatus == .participating } - } - - var challengeTypes: [ChallengeType] { ChallengeType.allCases } - var challengeStatuses: [ChallengeStatus] { ChallengeStatus.allCases } -} diff --git a/Lendair/ViewModels/ClubViewModel.swift b/Lendair/ViewModels/ClubViewModel.swift deleted file mode 100644 index 30da024d6..000000000 --- a/Lendair/ViewModels/ClubViewModel.swift +++ /dev/null @@ -1,156 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class ClubViewModel: ObservableObject { - @Published var clubs: [Club] = [] - @Published var selectedClub: Club? - @Published var members: [ClubMember] = [] - @Published var isLoading: Bool = false - @Published var error: ClubError? - @Published var filter: ClubFilter = ClubFilter() - - private let service: ClubServiceProtocol - - init(service: ClubServiceProtocol = ClubService()) { - self.service = service - } - - func fetchClubs() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - clubs = try await service.listClubs(filter: filter) - } catch let error as ClubError { - self.error = error - } catch { - print("Failed to fetch clubs: \(error)") - } - } - - func selectClub(id: String) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let result = try await service.getClub(id: id) - selectedClub = result.club - members = result.members - if let index = clubs.firstIndex(where: { $0.id == id }) { - clubs[index] = result.club - objectWillChange.send() - } - } catch let error as ClubError { - self.error = error - } catch { - print("Failed to get club: \(error)") - } - } - - func createClub(request: CreateClubRequest) async -> Club? { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let club = try await service.createClub(request: request) - clubs.insert(club, at: 0) - objectWillChange.send() - return club - } catch let error as ClubError { - self.error = error - return nil - } catch { - print("Failed to create club: \(error)") - return nil - } - } - - func updateClub(id: String, request: UpdateClubRequest) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let updatedClub = try await service.updateClub(id: id, request: request) - if let index = clubs.firstIndex(where: { $0.id == id }) { - clubs[index] = updatedClub - objectWillChange.send() - } - if selectedClub?.id == id { - selectedClub = updatedClub - } - } catch let error as ClubError { - self.error = error - } catch { - print("Failed to update club: \(error)") - } - } - - func joinClub(id: String) async { - do { - try await service.joinClub(id: id) - if let index = clubs.firstIndex(where: { $0.id == id }) { - clubs[index].membershipStatus = .active - clubs[index].memberCount += 1 - objectWillChange.send() - } - } catch { - print("Failed to join club: \(error)") - } - } - - func leaveClub(id: String) async { - do { - try await service.leaveClub(id: id) - if let index = clubs.firstIndex(where: { $0.id == id }) { - clubs[index].membershipStatus = .left - clubs[index].memberCount = max(0, clubs[index].memberCount - 1) - objectWillChange.send() - } - } catch { - print("Failed to leave club: \(error)") - } - } - - func inviteMember(clubId: String, email: String) async { - do { - try await service.inviteMember(clubId: clubId, email: email) - } catch { - print("Failed to invite member: \(error)") - } - } - - func removeMember(clubId: String, memberId: String) async { - do { - try await service.removeMember(clubId: clubId, memberId: memberId) - if let index = members.firstIndex(where: { $0.id == memberId }) { - members.remove(at: index) - if let clubIndex = clubs.firstIndex(where: { $0.id == clubId }) { - clubs[clubIndex].memberCount = max(0, clubs[clubIndex].memberCount - 1) - } - objectWillChange.send() - } - } catch { - print("Failed to remove member: \(error)") - } - } - - var publicClubs: [Club] { - clubs.filter { $0.privacy == .publicPrivacy } - } - - var userClubs: [Club] { - clubs.filter { $0.membershipStatus == .active } - } - - var pendingClubs: [Club] { - clubs.filter { $0.membershipStatus == .pending } - } - - var clubTypes: [ClubType] { ClubType.allCases } - var privacyOptions: [ClubPrivacy] { ClubPrivacy.allCases } -} diff --git a/Lendair/ViewModels/CommunityEventViewModel.swift b/Lendair/ViewModels/CommunityEventViewModel.swift deleted file mode 100644 index e2af2aee7..000000000 --- a/Lendair/ViewModels/CommunityEventViewModel.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class CommunityEventViewModel: ObservableObject { - @Published var events: [CommunityEvent] = [] - @Published var selectedEvent: CommunityEvent? - @Published var participants: [EventParticipant] = [] - @Published var isLoading: Bool = false - @Published var error: CommunityEventError? - @Published var filter: EventFilter = EventFilter() - - private let service: CommunityEventServiceProtocol - - init(service: CommunityEventServiceProtocol = CommunityEventService()) { - self.service = service - } - - func fetchEvents() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - events = try await service.listEvents(filter: filter) - } catch let error as CommunityEventError { - self.error = error - } catch { - print("Failed to fetch events: \(error)") - } - } - - func selectEvent(id: String) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let result = try await service.getEvent(id: id) - selectedEvent = result.event - participants = result.participants - if let index = events.firstIndex(where: { $0.id == id }) { - events[index] = result.event - objectWillChange.send() - } - } catch let error as CommunityEventError { - self.error = error - } catch { - print("Failed to get event: \(error)") - } - } - - func createEvent(request: CreateEventRequest) async -> CommunityEvent? { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let event = try await service.createEvent(request: request) - events.insert(event, at: 0) - objectWillChange.send() - return event - } catch let error as CommunityEventError { - self.error = error - return nil - } catch { - print("Failed to create event: \(error)") - return nil - } - } - - func updateEvent(id: String, request: UpdateEventRequest) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let updatedEvent = try await service.updateEvent(id: id, request: request) - if let index = events.firstIndex(where: { $0.id == id }) { - events[index] = updatedEvent - objectWillChange.send() - } - if selectedEvent?.id == id { - selectedEvent = updatedEvent - } - } catch let error as CommunityEventError { - self.error = error - } catch { - print("Failed to update event: \(error)") - } - } - - func RSVP(eventId: String, status: RSVPStatus) async { - do { - try await service.RSVP(eventId: eventId, status: status) - if let index = events.firstIndex(where: { $0.id == eventId }) { - events[index].rsvpStatus = status - objectWillChange.send() - } - } catch { - print("Failed to RSVP: \(error)") - } - } - - var upcomingEvents: [CommunityEvent] { - events.filter { $0.isUpcoming }.sorted { $0.startDate < $1.startDate } - } - - var ongoingEvents: [CommunityEvent] { - events.filter { $0.isOngoing } - } - - var pastEvents: [CommunityEvent] { - events.filter { $0.isPast }.sorted { $0.endDate > $1.endDate } - } - - var eventTypes: [EventType] { EventType.allCases } - var rsvpStatuses: [RSVPStatus] { RSVPStatus.allCases } -} diff --git a/Lendair/ViewModels/FamilyPlanViewModel.swift b/Lendair/ViewModels/FamilyPlanViewModel.swift deleted file mode 100644 index 19613eb45..000000000 --- a/Lendair/ViewModels/FamilyPlanViewModel.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class FamilyPlanViewModel: ObservableObject { - @Published var familyPlan: FamilyPlan? - @Published var leaderboard: [FamilyLeaderboardEntry] = [] - @Published var selectedMetric: LeaderboardMetric = .distance - @Published var isLoading: Bool = false - @Published var error: FamilyPlanError? - - private let service: FamilyPlanServiceProtocol - - init(service: FamilyPlanServiceProtocol = FamilyPlanService()) { - self.service = service - } - - func fetchFamilyPlan() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - familyPlan = try await service.getFamilyPlan() - } catch let error as FamilyPlanError { - self.error = error - } catch { - print("Failed to fetch family plan: \(error)") - } - } - - func inviteMember(email: String, name: String) async { - guard let plan = familyPlan, plan.availableSlots > 0 else { return } - - let request = InviteMemberRequest(email: email, name: name) - do { - try await service.inviteMember(request: request) - objectWillChange.send() - } catch { - print("Failed to invite member: \(error)") - } - } - - func removeMember(id: String) async { - guard let index = familyPlan?.members.firstIndex(where: { $0.id == id }) else { return } - do { - try await service.removeMember(id: id) - familyPlan?.members.remove(at: index) - objectWillChange.send() - } catch { - print("Failed to remove member: \(error)") - } - } - - func fetchLeaderboard() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - leaderboard = try await service.getLeaderboard(metric: selectedMetric) - } catch let error as FamilyPlanError { - self.error = error - } catch { - print("Failed to fetch leaderboard: \(error)") - } - } - - var activeMembers: [FamilyMember] { - familyPlan?.members.filter { $0.role == .member || $0.role == .owner } ?? [] - } - - var pendingInvites: [FamilyMember] { - familyPlan?.members.filter { $0.role == .pending } ?? [] - } - - var metrics: [LeaderboardMetric] { LeaderboardMetric.allCases } -} diff --git a/Lendair/ViewModels/NotificationsViewModel.swift b/Lendair/ViewModels/NotificationsViewModel.swift deleted file mode 100644 index b7e17b488..000000000 --- a/Lendair/ViewModels/NotificationsViewModel.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class NotificationsViewModel: ObservableObject { - @Published var notifications: [NotificationItem] = [] - @Published var isLoading: Bool = false - @Published var badgeCount: Int = 0 - @Published var lastRefreshDate: Date? - @Published var error: NotificationError? - - private let notificationsService: NotificationsServiceProtocol - - init(notificationsService: NotificationsServiceProtocol = NotificationsService()) { - self.notificationsService = notificationsService - } - - func fetchNotifications() async { - isLoading = true - error = nil - defer { - isLoading = false - lastRefreshDate = Date() - } - - do { - let fetchedNotifications = try await notificationsService.list() - notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt } - badgeCount = notifications.filter { !$0.isRead }.count - } catch let error as NotificationError { - self.error = error - } catch { - print("Failed to fetch notifications: \(error)") - } - } - - func refresh() async { - await fetchNotifications() - } - - func fetchUnreadCount() async { - do { - let count = try await notificationsService.getUnreadCount() - badgeCount = count - } catch { - print("Failed to fetch unread count: \(error)") - } - } - - func markAsRead(id: String) async { - guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } - - do { - try await notificationsService.markAsRead(id: id) - notifications[index].isRead = true - badgeCount = max(0, badgeCount - 1) - } catch { - print("Failed to mark notification as read: \(error)") - } - } - - func markAllAsRead() async { - let unreadIds = notifications.filter { !$0.isRead }.map { $0.id } - guard !unreadIds.isEmpty else { return } - - do { - try await notificationsService.markAllAsRead() - for index in notifications.indices { - notifications[index].isRead = true - } - badgeCount = 0 - } catch { - print("Failed to mark all as read: \(error)") - } - } - - var unreadCount: Int { - notifications.filter { !$0.isRead }.count - } -} diff --git a/Lendair/ViewModels/RaceDiscoveryViewModel.swift b/Lendair/ViewModels/RaceDiscoveryViewModel.swift deleted file mode 100644 index 13a283797..000000000 --- a/Lendair/ViewModels/RaceDiscoveryViewModel.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class RaceDiscoveryViewModel: ObservableObject { - @Published var races: [Race] = [] - @Published var savedRaces: [Race] = [] - @Published var selectedRace: Race? - @Published var isLoading: Bool = false - @Published var error: RaceError? - @Published var filter: RaceFilter = RaceFilter() - - private let service: RaceServiceProtocol - - init(service: RaceServiceProtocol = RaceService()) { - self.service = service - } - - func fetchRaces() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - races = try await service.listRaces(filter: filter) - } catch let error as RaceError { - self.error = error - } catch { - print("Failed to fetch races: \(error)") - } - } - - func selectRace(id: String) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let race = try await service.getRace(id: id) - selectedRace = race - if let index = races.firstIndex(where: { $0.id == id }) { - races[index] = race - objectWillChange.send() - } - } catch let error as RaceError { - self.error = error - } catch { - print("Failed to get race: \(error)") - } - } - - func toggleSaveRace(id: String) async { - guard let race = races.first(where: { $0.id == id }) else { return } - let newSavedState = !race.isSaved - - do { - try await service.saveRace(id: id, isSaved: newSavedState) - if let index = races.firstIndex(where: { $0.id == id }) { - races[index].isSaved = newSavedState - objectWillChange.send() - } - if newSavedState { - savedRaces.append(races.first(where: { $0.id == id }) ?? race) - } else { - savedRaces.removeAll { $0.id == id } - } - objectWillChange.send() - } catch { - print("Failed to toggle save race: \(error)") - } - } - - func registerForRace(id: String) async { - do { - try await service.registerForRace(id: id) - if let index = races.firstIndex(where: { $0.id == id }) { - races[index].isRegistered = true - objectWillChange.send() - } - } catch { - print("Failed to register for race: \(error)") - } - } - - func fetchSavedRaces() async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - savedRaces = try await service.listRaces(filter: RaceFilter(limit: 50, offset: 0)) - .filter { $0.isSaved } - } catch let error as RaceError { - self.error = error - } catch { - print("Failed to fetch saved races: \(error)") - } - } - - var upcomingRaces: [Race] { - races.filter { $0.isUpcoming }.sorted { $0.raceDate < $1.raceDate } - } - - var raceTypes: [RaceType] { RaceType.allCases } - var terrainTypes: [TerrainType] { TerrainType.allCases } -} diff --git a/Lendair/ViewModels/TrainingPlanViewModel.swift b/Lendair/ViewModels/TrainingPlanViewModel.swift deleted file mode 100644 index bdd19cea7..000000000 --- a/Lendair/ViewModels/TrainingPlanViewModel.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -class TrainingPlanViewModel: ObservableObject { - @Published var plans: [TrainingPlan] = [] - @Published var selectedPlan: TrainingPlan? - @Published var isLoading: Bool = false - @Published var error: TrainingPlanError? - - private let service: TrainingPlanServiceProtocol - - init(service: TrainingPlanServiceProtocol = TrainingPlanService()) { - self.service = service - } - - func fetchPlans(type: PlanType? = nil, difficulty: Difficulty? = nil) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - plans = try await service.listPlans(type: type, difficulty: difficulty) - } catch let error as TrainingPlanError { - self.error = error - } catch { - print("Failed to fetch training plans: \(error)") - } - } - - func selectPlan(id: String) async { - isLoading = true - error = nil - defer { isLoading = false } - - do { - selectedPlan = try await service.getPlan(id: id) - } catch let error as TrainingPlanError { - self.error = error - } catch { - print("Failed to get plan: \(error)") - } - } - - func generatePlan(request: GeneratePlanRequest) async -> TrainingPlan? { - isLoading = true - error = nil - defer { isLoading = false } - - do { - let plan = try await service.generatePlan(request: request) - plans.insert(plan, at: 0) - return plan - } catch let error as TrainingPlanError { - self.error = error - return nil - } catch { - print("Failed to generate plan: \(error)") - return nil - } - } - - func followPlan(id: String) async { - do { - try await service.followPlan(id: id) - if let index = plans.firstIndex(where: { $0.id == id }) { - plans[index].isFollowing = true - objectWillChange.send() - } - } catch { - print("Failed to follow plan: \(error)") - } - } - - func unfollowPlan(id: String) async { - do { - try await service.unfollowPlan(id: id) - if let index = plans.firstIndex(where: { $0.id == id }) { - plans[index].isFollowing = false - objectWillChange.send() - } - } catch { - print("Failed to unfollow plan: \(error)") - } - } - - func updateSessionStatus(sessionId: String, status: SessionStatus) async { - do { - try await service.updateSessionStatus(sessionId: sessionId, status: status) - if var plan = selectedPlan { - for weekIndex in plan.weeklyWorkouts.indices { - for sessionIndex in plan.weeklyWorkouts[weekIndex].dailySessions.indices { - if plan.weeklyWorkouts[weekIndex].dailySessions[sessionIndex].id == sessionId { - plan.weeklyWorkouts[weekIndex].dailySessions[sessionIndex].status = status - selectedPlan = plan - return - } - } - } - } - } catch { - print("Failed to update session status: \(error)") - } - } - - var planTypes: [PlanType] { PlanType.allCases } - var difficulties: [Difficulty] { Difficulty.allCases } -} diff --git a/Lendair/Views/BeginnerModeView.swift b/Lendair/Views/BeginnerModeView.swift deleted file mode 100644 index f74c2fdc5..000000000 --- a/Lendair/Views/BeginnerModeView.swift +++ /dev/null @@ -1,173 +0,0 @@ -import SwiftUI - -struct BeginnerModeView: View { - @StateObject private var viewModel = BeginnerModeViewModel() - @State private var showingOnboarding = false - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.config == nil { - loadingView - } else { - content - } - } - .navigationTitle("Beginner Mode") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if let config = viewModel.config { - ToolbarItem(placement: .navigationBarTrailing) { - Toggle("", isOn: Binding( - get: { config.isEnabled }, - set: { isEnabled in - Task { - await viewModel.toggleBeginnerMode(isEnabled: isEnabled) - } - } - )) - } - } - } - } - .onAppear { - Task { - await viewModel.fetchConfig() - await viewModel.fetchMilestoneProgress() - } - } - } - - @ViewBuilder - private var content: some View { - List { - Section("Current Level") { - if let config = viewModel.config { - HStack { - Image(systemName: config.currentLevel.icon) - .font(.system(size: 28)) - .foregroundColor(.blue) - VStack(alignment: .leading, spacing: 2) { - Text(config.currentLevel.displayName) - .font(.headline) - Text("Workout #\(config.currentLevel.requiredWorkouts) to advance") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - } - } - - Section("Onboarding Progress") { - Text("\(viewModel.completedOnboardingCount)/\(viewModel.onboardingSteps.count) steps completed") - .font(.subheadline) - - ForEach(viewModel.remainingOnboardingSteps, id: \.self) { step in - HStack { - Image(systemName: "circle") - .foregroundColor(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(step.displayName) - .font(.subheadline) - Text(step.description) - .font(.caption) - .foregroundColor(.secondary) - } - } - } - - if viewModel.remainingOnboardingSteps.isEmpty { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("All steps completed!") - .font(.subheadline) - .foregroundColor(.green) - } - } - } - - Section("Milestones") { - Text("\(viewModel.completedMilestoneCount)/\(viewModel.totalMilestoneCount) achieved") - .font(.subheadline) - - ForEach(viewModel.milestones) { milestone in - MilestoneRow(milestone: milestone) - } - } - - Section("Quick Tips") { - tipRow(icon: "lightbulb.fill", title: "Start Slow", message: "Begin with shorter distances and gradually increase.") - tipRow(icon: "heart.fill", title: "Stay Consistent", message: "Regular workouts yield better results than occasional long ones.") - tipRow(icon: "drop.fill", title: "Hydrate", message: "Keep water nearby during all workouts.") - tipRow(icon: "moon.fill", title: "Rest Days", message: "Recovery is when your body gets stronger.") - } - } - .listStyle(.insetGrouped) - } - - private func tipRow(icon: String, title: String, message: String) -> some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 20)) - .foregroundColor(.orange) - .frame(width: 32) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.subheadline) - .fontWeight(.medium) - Text(message) - .font(.caption) - .foregroundColor(.secondary) - } - } - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Beginner Mode...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } -} - -struct MilestoneRow: View { - let milestone: Milestone - - var body: some View { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(milestone.isCompleted ? Color.orange.opacity(0.2) : Color.secondary.opacity(0.1)) - .frame(width: 40, height: 40) - Image(systemName: milestone.isCompleted ? "\(milestone.icon).fill" : milestone.icon) - .font(.system(size: 18)) - .foregroundColor(milestone.isCompleted ? .orange : .secondary) - } - - VStack(alignment: .leading, spacing: 2) { - Text(milestone.title) - .font(.subheadline) - .fontWeight(.medium) - Text(milestone.description) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - if milestone.isCompleted { - Image(systemName: "star.fill") - .foregroundColor(.orange) - } - } - } -} - -#Preview { - BeginnerModeView() -} diff --git a/Lendair/Views/ChallengeDetailView.swift b/Lendair/Views/ChallengeDetailView.swift deleted file mode 100644 index c1d9e56cb..000000000 --- a/Lendair/Views/ChallengeDetailView.swift +++ /dev/null @@ -1,272 +0,0 @@ -import SwiftUI - -struct ChallengeDetailView: View { - let challenge: Challenge - @StateObject private var viewModel = ChallengeViewModel() - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - challengeHeader - challengeInfoSection - challengeDescription - progressSection - participationSection - leaderboardSection - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(challenge.title) - .navigationBarTitleDisplayMode(.inline) - .onAppear { - Task { - await viewModel.selectChallenge(id: challenge.id) - await viewModel.fetchLeaderboard(challengeId: challenge.id) - } - } - } - - private var challengeHeader: some View { - VStack(spacing: 12) { - HStack { - Image(systemName: challenge.challengeType.icon) - .font(.system(size: 40)) - .foregroundColor(challenge.challengeType.color) - VStack(alignment: .leading, spacing: 4) { - Text(challenge.challengeType.displayName) - .font(.title2) - .fontWeight(.bold) - Text("\(challenge.targetValue) \(challenge.targetUnit) goal") - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - } - - Divider() - - HStack(spacing: 20) { - infoItem(label: "Status", value: challenge.status.rawValue.capitalized) - infoItem(label: "Participants", value: "\(challenge.participantCount)") - infoItem(label: "Days Left", value: "\(challenge.daysRemaining)") - } - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private func infoItem(label: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundColor(.secondary) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var challengeInfoSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Challenge Details") - .font(.headline) - VStack(alignment: .leading, spacing: 6) { - detailRow(label: "Type", value: challenge.challengeType.displayName) - detailRow(label: "Metric", value: challenge.targetMetric.displayName) - detailRow(label: "Target", value: "\(challenge.targetValue) \(challenge.targetUnit)") - detailRow(label: "Start", value: formatDate(challenge.startDate)) - detailRow(label: "End", value: formatDate(challenge.endDate)) - detailRow(label: "Creator", value: challenge.createdByName) - } - } - } - - private func detailRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.subheadline) - .foregroundColor(.secondary) - .frame(width: 100, alignment: .leading) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var challengeDescription: some View { - VStack(alignment: .leading, spacing: 4) { - Text("About This Challenge") - .font(.headline) - Text(challenge.description) - .font(.subheadline) - .foregroundColor(.secondary) - if let rules = challenge.rules { - Divider() - .padding(.vertical, 4) - Text("Rules") - .font(.headline) - Text(rules) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - - private var progressSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Your Progress") - .font(.headline) - - if challenge.participationStatus == .participating { - VStack(spacing: 8) { - HStack { - Text("\(challenge.progressPercentage, specifier: "%.0f")%") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(challenge.challengeType.color) - Spacer() - Text("\(challenge.userProgress ?? 0) / \(challenge.targetValue) \(challenge.targetUnit)") - .font(.subheadline) - .foregroundColor(.secondary) - } - - ProgressView(value: challenge.progressPercentage / 100) - .tint(challenge.challengeType.color) - .frame(height: 8) - } - } else { - Text(challenge.participationStatus == .invited - ? "You've been invited — join to start tracking progress." - : "Join this challenge to track your progress.") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - - private var participationSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Participation") - .font(.headline) - - HStack(spacing: 12) { - switch challenge.participationStatus { - case .participating: - Button { - Task { - await viewModel.leaveChallenge(id: challenge.id) - } - } label: { - Label("Leave Challenge", systemImage: "flag.on.flag.fill") - .foregroundColor(.red) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.red.opacity(0.15)) - .cornerRadius(8) - - case .invited: - Button { - Task { - await viewModel.joinChallenge(id: challenge.id) - } - } label: { - Label("Accept & Join", systemImage: "checkmark.circle.fill") - .foregroundColor(.green) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.green.opacity(0.15)) - .cornerRadius(8) - - case .notParticipating: - Button { - Task { - await viewModel.joinChallenge(id: challenge.id) - } - } label: { - Label("Join Challenge", systemImage: "flag.fill") - .foregroundColor(.blue) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.blue.opacity(0.15)) - .cornerRadius(8) - } - } - } - } - } - - private var leaderboardSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Leaderboard") - .font(.headline) - - if viewModel.leaderboard.isEmpty { - Text("No participants yet.") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.vertical, 8) - } else { - ForEach(Array(viewModel.leaderboard.prefix(10), id: \.id)) { entry in - HStack(spacing: 12) { - Text("\(entry.position)") - .font(.caption) - .fontWeight(.bold) - .frame(width: 24) - Circle() - .fill(Color.secondary.opacity(0.2)) - .frame(width: 28, height: 28) - Text(entry.participantName) - .font(.subheadline) - Spacer() - Text("\(entry.progressPercentage, specifier: "%.0f")%") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(entry.position <= 3 ? .orange : .secondary) - } - } - } - } - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } -} - -#Preview { - NavigationView { - ChallengeDetailView(challenge: sampleChallenge) - } -} - -private var sampleChallenge: Challenge { - Challenge( - id: "1", - title: "Monthly 100km Challenge", - description: "Run 100km this month. Track your distance and compete with friends!", - challengeType: .distance, - status: .active, - startDate: Date().addingTimeInterval(-7 * 24 * 3600), - endDate: Date().addingTimeInterval(23 * 24 * 3600), - targetMetric: .distance, - targetValue: 100, - targetUnit: "km", - participantCount: 47, - rules: "All runs count. GPS-tracked activities only.", - imageUrl: nil, - createdBy: "user1", - createdByName: "Sarah Chen", - clubId: nil, - participationStatus: .participating, - userProgress: 42.5, - createdAt: Date().addingTimeInterval(-7 * 24 * 3600) - ) -} \ No newline at end of file diff --git a/Lendair/Views/ChallengesView.swift b/Lendair/Views/ChallengesView.swift deleted file mode 100644 index 991883747..000000000 --- a/Lendair/Views/ChallengesView.swift +++ /dev/null @@ -1,280 +0,0 @@ -import SwiftUI - -struct ChallengesView: View { - @StateObject private var viewModel = ChallengeViewModel() - @State private var showingCreateSheet = false - @State private var selectedTab: ChallengeTab = .active - @State private var alertIsPresented = false - - enum ChallengeTab: String, CaseIterable { - case active, upcoming, completed - } - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.challenges.isEmpty { - loadingView - } else if currentChallenges.isEmpty { - emptyStateView - } else { - challengeListView - } - } - .navigationTitle("Challenges") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingCreateSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showingCreateSheet) { - CreateChallengeSheet(viewModel: viewModel) - } - .alert("Error", isPresented: $alertIsPresented) { - Button("OK") { - viewModel.error = nil - alertIsPresented = false - } - } message: { - Text(viewModel.error?.errorDescription ?? "") - } - .onChange(of: viewModel.error) { _ in - if viewModel.error != nil { - alertIsPresented = true - } - } - } - .onAppear { - Task { - await viewModel.fetchChallenges() - } - } - } - - private var currentChallenges: [Challenge] { - switch selectedTab { - case .active: return viewModel.activeChallenges - case .upcoming: return viewModel.upcomingChallenges - case .completed: return viewModel.completedChallenges - } - } - - private var challengeListView: some View { - List { - Picker("Challenges", selection: $selectedTab) { - ForEach(ChallengeTab.allCases, id: \.self) { tab in - Text(tab.rawValue.capitalized).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.top, 8) - - Section(currentSectionTitle) { - ForEach(currentChallenges) { challenge in - NavigationLink(destination: ChallengeDetailView(challenge: challenge)) { - ChallengeRowView(challenge: challenge) - } - } - } - } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.fetchChallenges() - } - } - - private var currentSectionTitle: String { - switch selectedTab { - case .active: return "Active Challenges" - case .upcoming: return "Upcoming" - case .completed: return "Completed" - } - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Challenges...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "flag.fill") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("No \(selectedTab.rawValue) Challenges") - .font(.title2) - .fontWeight(.semibold) - Text(selectedTab == .active - ? "Join or create a challenge to compete with others." - : selectedTab == .upcoming - ? "New challenges will appear here." - : "Completed challenges are tracked here.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } -} - -struct CreateChallengeSheet: View { - @Environment(\.dismiss) var dismiss - let viewModel: ChallengeViewModel - @State private var title = "" - @State private var description = "" - @State private var challengeType: ChallengeType = .distance - @State private var targetMetric: ChallengeMetric = .distance - @State private var targetValue = "" - @State private var rules = "" - - var body: some View { - NavigationView { - Form { - Section("Challenge Details") { - TextField("Challenge Title", text: $title) - TextField("Description", text: $description) - } - - Section("Type") { - Picker("Challenge Type", selection: $challengeType) { - ForEach(ChallengeType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) - } - } - } - - Section("Target") { - Picker("Metric", selection: $targetMetric) { - ForEach(ChallengeMetric.allCases, id: \.self) { metric in - Text(metric.displayName).tag(metric) - } - } - HStack { - TextField("Target Value", text: $targetValue) - .keyboardType(.decimalPad) - Text(targetMetric.unit) - .foregroundColor(.secondary) - } - } - - Section("Optional") { - TextField("Rules", text: $rules) - } - } - .navigationTitle("Create Challenge") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Create") { - let endDate = Date().addingTimeInterval(30 * 24 * 3600) - let request = CreateChallengeRequest( - title: title, - description: description, - challengeType: challengeType, - startDate: Date(), - endDate: endDate, - targetMetric: targetMetric, - targetValue: Double(targetValue) ?? 0, - rules: rules.isEmpty ? nil : rules, - clubId: nil - ) - Task { - _ = await viewModel.createChallenge(request: request) - dismiss() - } - } - .disabled(title.isEmpty || targetValue.isEmpty) - } - } - } - } -} - -struct ChallengeRowView: View { - let challenge: Challenge - - var body: some View { - VStack(spacing: 8) { - HStack(spacing: 12) { - Image(systemName: challenge.challengeType.icon) - .font(.system(size: 24)) - .foregroundColor(challenge.challengeType.color) - .frame(width: 44, height: 44) - .background(challenge.challengeType.color.opacity(0.15)) - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 4) { - Text(challenge.title) - .font(.headline) - Text("\(challenge.challengeType.displayName) \u2022 \(challenge.targetValue) \(challenge.targetUnit)") - .font(.subheadline) - .foregroundColor(.secondary) - HStack(spacing: 8) { - Text("\(challenge.participantCount) participants") - .font(.caption) - .foregroundColor(.secondary) - if challenge.daysRemaining > 0 { - Text("\(challenge.daysRemaining) days left") - .font(.caption2) - .foregroundColor(.orange) - } - } - } - - Spacer() - - switch challenge.participationStatus { - case .participating: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .invited: - Image(systemName: "mail.fill") - .foregroundColor(.blue) - case .notParticipating: - Image(systemName: "circle") - .foregroundColor(.secondary) - } - } - - if challenge.participationStatus == .participating { - progressView - } - } - .padding(.vertical, 4) - } - - private var progressView: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("\(challenge.progressPercentage, specifier: "%.0f")%") - .font(.caption2) - .fontWeight(.medium) - Spacer() - Text("\(challenge.userProgress ?? 0)/\(challenge.targetValue) \(challenge.targetUnit)") - .font(.caption2) - .foregroundColor(.secondary) - } - ProgressView(value: challenge.progressPercentage / 100) - .tint(challenge.challengeType.color) - } - .padding(.horizontal, 4) - } -} - -#Preview { - ChallengesView() -} diff --git a/Lendair/Views/ClubDetailView.swift b/Lendair/Views/ClubDetailView.swift deleted file mode 100644 index 5da519ef8..000000000 --- a/Lendair/Views/ClubDetailView.swift +++ /dev/null @@ -1,276 +0,0 @@ -import SwiftUI - -struct ClubDetailView: View { - let club: Club - @StateObject private var viewModel = ClubViewModel() - @State private var inviteEmail = "" - @State private var showingInviteAlert = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - clubHeader - clubInfoSection - clubDescription - membershipSection - rulesSection - membersSection - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(club.name) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - if club.membershipStatus == .active { - Menu { - Button("Edit Club") {} - Button("Leave Club", role: .destructive) { - Task { - await viewModel.leaveClub(id: club.id) - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - } - .onAppear { - Task { - await viewModel.selectClub(id: club.id) - } - } - } - - private var clubHeader: some View { - VStack(spacing: 12) { - HStack { - Image(systemName: club.clubType.icon) - .font(.system(size: 40)) - .foregroundColor(club.clubType.color) - VStack(alignment: .leading, spacing: 4) { - Text(club.clubType.displayName) - .font(.title2) - .fontWeight(.bold) - Text(club.location) - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - } - - Divider() - - HStack(spacing: 20) { - infoItem(label: "Members", value: "\(club.memberCount)") - infoItem(label: "Privacy", value: club.privacy.displayName) - infoItem(label: "Owner", value: club.ownerName) - } - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private func infoItem(label: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundColor(.secondary) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var clubInfoSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Club Details") - .font(.headline) - VStack(alignment: .leading, spacing: 6) { - detailRow(label: "Type", value: club.clubType.displayName) - detailRow(label: "Privacy", value: club.privacy.displayName) - detailRow(label: "Location", value: club.location) - if let max = club.maxMembers { - detailRow(label: "Capacity", value: "\(club.memberCount)/\(max)") - } - detailRow(label: "Joined", value: formatDate(club.createdAt)) - } - } - } - - private func detailRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.subheadline) - .foregroundColor(.secondary) - .frame(width: 100, alignment: .leading) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var clubDescription: some View { - VStack(alignment: .leading, spacing: 4) { - Text("About This Club") - .font(.headline) - Text(club.description) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - private var membershipSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Membership") - .font(.headline) - - HStack(spacing: 12) { - switch club.membershipStatus { - case .active: - Button { - Task { - await viewModel.leaveClub(id: club.id) - } - } label: { - Label("Leave Club", systemImage: "door.left.hand.open") - .foregroundColor(.red) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.red.opacity(0.15)) - .cornerRadius(8) - - case .pending: - Label("Joining...", systemImage: "clock.fill") - .foregroundColor(.orange) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.orange.opacity(0.15)) - .cornerRadius(8) - - case .invited: - Button { - Task { - await viewModel.joinClub(id: club.id) - } - } label: { - Label("Accept Invite", systemImage: "checkmark.circle.fill") - .foregroundColor(.green) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.green.opacity(0.15)) - .cornerRadius(8) - - case .left: - Button { - Task { - await viewModel.joinClub(id: club.id) - } - } label: { - Label("Rejoin Club", systemImage: "arrow.turn.down.right") - .foregroundColor(.blue) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.blue.opacity(0.15)) - .cornerRadius(8) - } - - if club.membershipStatus == .active { - Button { - showingInviteAlert = true - } label: { - Label("Invite", systemImage: "person.crop.circle.badge.plus") - .foregroundColor(.blue) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.blue.opacity(0.15)) - .cornerRadius(8) - } - } - } - } - } - - private var rulesSection: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Club Rules") - .font(.headline) - if let rules = club.rules { - Text(rules) - .font(.subheadline) - .foregroundColor(.secondary) - } else { - Text("No rules specified.") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - - private var membersSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Members (\(club.memberCount))") - .font(.headline) - - ForEach(viewModel.members) { member in - HStack(spacing: 12) { - Circle() - .fill(Color.secondary.opacity(0.2)) - .frame(width: 32, height: 32) - VStack(alignment: .leading, spacing: 2) { - Text(member.name) - .font(.subheadline) - Text(member.role.displayName) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Text(member.membershipStatus.rawValue.capitalized) - .font(.caption) - .foregroundColor(member.membershipStatus == .active ? .green : .secondary) - } - } - } - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } -} - -#Preview { - NavigationView { - ClubDetailView(club: sampleClub) - } -} - -private var sampleClub: Club { - Club( - id: "1", - name: "Central Park Runners", - description: "A friendly running club that meets every weekend in Central Park. All levels welcome!", - clubType: .running, - privacy: .publicPrivacy, - location: "Central Park, NYC", - latitude: 40.7851, - longitude: -73.9683, - memberCount: 142, - maxMembers: 200, - imageUrl: nil, - rules: "Be respectful, stay hydrated, and have fun!", - ownerId: "user1", - ownerName: "Alex Johnson", - membershipStatus: .active, - createdAt: Date().addingTimeInterval(-30 * 24 * 3600) - ) -} diff --git a/Lendair/Views/ClubsView.swift b/Lendair/Views/ClubsView.swift deleted file mode 100644 index 3bb278ece..000000000 --- a/Lendair/Views/ClubsView.swift +++ /dev/null @@ -1,250 +0,0 @@ -import SwiftUI - -struct ClubsView: View { - @StateObject private var viewModel = ClubViewModel() - @State private var showingCreateSheet = false - @State private var selectedTab: ClubTab = .discover - @State private var alertIsPresented = false - - enum ClubTab: String, CaseIterable { - case discover, myClubs - } - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.clubs.isEmpty { - loadingView - } else if currentClubs.isEmpty { - emptyStateView - } else { - clubListView - } - } - .navigationTitle("Clubs") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingCreateSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showingCreateSheet) { - CreateClubSheet(viewModel: viewModel) - } - .alert("Error", isPresented: $alertIsPresented) { - Button("OK") { - viewModel.error = nil - alertIsPresented = false - } - } message: { - Text(viewModel.error?.errorDescription ?? "") - } - .onChange(of: viewModel.error) { _ in - if viewModel.error != nil { - alertIsPresented = true - } - } - } - .onAppear { - Task { - await viewModel.fetchClubs() - } - } - } - - private var currentClubs: [Club] { - switch selectedTab { - case .discover: return viewModel.publicClubs - case .myClubs: return viewModel.userClubs - } - } - - private var clubListView: some View { - List { - Picker("Clubs", selection: $selectedTab) { - ForEach(ClubTab.allCases, id: \.self) { tab in - Text(tab.rawValue.capitalized).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.top, 8) - - Section(currentSectionTitle) { - ForEach(currentClubs) { club in - NavigationLink(destination: ClubDetailView(club: club)) { - ClubRowView(club: club) - } - } - } - } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.fetchClubs() - } - } - - private var currentSectionTitle: String { - switch selectedTab { - case .discover: return "Discover Clubs" - case .myClubs: return "My Clubs" - } - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Clubs...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "person.3.fill") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("No \(selectedTab.rawValue) Clubs") - .font(.title2) - .fontWeight(.semibold) - Text(selectedTab == .discover - ? "Find running and fitness clubs in your area." - : "Join or create a club to get started.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } -} - -struct CreateClubSheet: View { - @Environment(\.dismiss) var dismiss - let viewModel: ClubViewModel - @State private var name = "" - @State private var description = "" - @State private var clubType: ClubType = .running - @State private var privacy: ClubPrivacy = .publicPrivacy - @State private var location = "" - @State private var maxMembers = "" - @State private var rules = "" - - var body: some View { - NavigationView { - Form { - Section("Club Details") { - TextField("Club Name", text: $name) - TextField("Description", text: $description) - TextField("Location", text: $location) - } - - Section("Type & Privacy") { - Picker("Club Type", selection: $clubType) { - ForEach(ClubType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) - } - } - Picker("Privacy", selection: $privacy) { - ForEach(ClubPrivacy.allCases, id: \.self) { priv in - Text(priv.displayName).tag(priv) - } - } - } - - Section("Optional") { - TextField("Max Members", text: $maxMembers) - .keyboardType(.numberPad) - TextField("Rules", text: $rules) - } - } - .navigationTitle("Create Club") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Create") { - let request = CreateClubRequest( - name: name, - description: description, - clubType: clubType, - privacy: privacy, - location: location, - latitude: nil, - longitude: nil, - maxMembers: Int(maxMembers), - rules: rules.isEmpty ? nil : rules - ) - Task { - _ = await viewModel.createClub(request: request) - dismiss() - } - } - .disabled(name.isEmpty || location.isEmpty) - } - } - } - } -} - -struct ClubRowView: View { - let club: Club - - var body: some View { - HStack(spacing: 12) { - Image(systemName: club.clubType.icon) - .font(.system(size: 24)) - .foregroundColor(club.clubType.color) - .frame(width: 44, height: 44) - .background(club.clubType.color.opacity(0.15)) - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 4) { - Text(club.name) - .font(.headline) - Text("\(club.location) \u2022 \(club.privacy.displayName)") - .font(.subheadline) - .foregroundColor(.secondary) - HStack(spacing: 8) { - Text("\(club.memberCount) members") - .font(.caption) - .foregroundColor(.secondary) - if let spots = club.availableSpots, spots > 0 { - Text("\(spots) spots left") - .font(.caption2) - .foregroundColor(.green) - } - } - } - - Spacer() - - switch club.membershipStatus { - case .active: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .pending: - Image(systemName: "clock.fill") - .foregroundColor(.orange) - case .invited: - Image(systemName: "mail.fill") - .foregroundColor(.blue) - case .left: - Image(systemName: "circle") - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } -} - -#Preview { - ClubsView() -} diff --git a/Lendair/Views/CommunityEventDetailView.swift b/Lendair/Views/CommunityEventDetailView.swift deleted file mode 100644 index 33a53524a..000000000 --- a/Lendair/Views/CommunityEventDetailView.swift +++ /dev/null @@ -1,209 +0,0 @@ -import SwiftUI - -struct CommunityEventDetailView: View { - let event: CommunityEvent - @StateObject private var viewModel = CommunityEventViewModel() - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - eventHeader - eventInfoSection - eventDescription - rsvpSection - participantsSection - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(event.title) - .navigationBarTitleDisplayMode(.inline) - .onAppear { - Task { - await viewModel.selectEvent(id: event.id) - } - } - } - - private var eventHeader: some View { - VStack(spacing: 12) { - HStack { - Image(systemName: event.eventType.icon) - .font(.system(size: 40)) - .foregroundColor(event.eventType.color) - VStack(alignment: .leading, spacing: 4) { - Text(event.eventType.displayName) - .font(.title2) - .fontWeight(.bold) - if let distance = event.distanceKm { - Text("\(distance) km \u2022 \(event.location)") - } else { - Text(event.location) - } - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - } - - Divider() - - HStack(spacing: 20) { - infoItem(label: "Date", value: formatDate(event.startDate)) - infoItem(label: "Participants", value: "\(event.participantCount)") - if let difficulty = event.difficulty { - infoItem(label: "Difficulty", value: difficulty.displayName) - } - } - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private func infoItem(label: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundColor(.secondary) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var eventInfoSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Event Details") - .font(.headline) - VStack(alignment: .leading, spacing: 6) { - detailRow(label: "Organizer", value: event.organizerName) - detailRow(label: "Location", value: event.location) - detailRow(label: "Start", value: formatDateTime(event.startDate)) - detailRow(label: "End", value: formatDateTime(event.endDate)) - if let max = event.maxParticipants { - detailRow(label: "Capacity", value: "\(event.participantCount)/\(max)") - } - } - } - } - - private func detailRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.subheadline) - .foregroundColor(.secondary) - .frame(width: 100, alignment: .leading) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var eventDescription: some View { - VStack(alignment: .leading, spacing: 4) { - Text("About This Event") - .font(.headline) - Text(event.description) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - private var rsvpSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Your RSVP") - .font(.headline) - - HStack(spacing: 12) { - rsvpButton(title: "Going", icon: "checkmark.circle", status: .going, color: .green) - rsvpButton(title: "Maybe", icon: "questionmark.circle", status: .maybe, color: .orange) - rsvpButton(title: "Not Going", icon: "xmark.circle", status: .notGoing, color: .red) - } - } - } - - private func rsvpButton(title: String, icon: String, status: RSVPStatus, color: Color) -> some View { - Button { - Task { - await viewModel.RSVP(eventId: event.id, status: status) - } - } label: { - VStack(spacing: 4) { - Image(systemName: event.rsvpStatus == status ? "\(icon).fill" : icon) - .foregroundColor(event.rsvpStatus == status ? color : .secondary) - Text(title) - .font(.caption) - .foregroundColor(event.rsvpStatus == status ? color : .secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(event.rsvpStatus == status ? color.opacity(0.15) : Color.secondary.opacity(0.08)) - .cornerRadius(8) - } - } - - private var participantsSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Participants (\(event.participantCount))") - .font(.headline) - - ForEach(viewModel.participants) { participant in - HStack(spacing: 12) { - Circle() - .fill(Color.secondary.opacity(0.2)) - .frame(width: 32, height: 32) - Text(participant.name) - .font(.subheadline) - Spacer() - Text(participant.rsvpStatus.rawValue.capitalized) - .font(.caption) - .foregroundColor(participant.rsvpStatus == .going ? .green : .secondary) - } - } - } - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } - - private func formatDateTime(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter.string(from: date) - } -} - -#Preview { - NavigationView { - CommunityEventDetailView(event: sampleEvent) - } -} - -private var sampleEvent: CommunityEvent { - CommunityEvent( - id: "1", - title: "Sunday Morning Group Run", - description: "Join us for a friendly morning run through the park. All paces welcome!", - eventType: .groupRun, - location: "Central Park", - latitude: 40.7851, - longitude: -73.9683, - startDate: Date().addingTimeInterval(7 * 24 * 3600), - endDate: Date().addingTimeInterval(7 * 24 * 3600 + 3600), - distanceKm: 10, - organizerId: "user1", - organizerName: "Running Club", - maxParticipants: 50, - participantCount: 23, - imageUrl: nil, - difficulty: .beginner, - rsvpStatus: .pending, - createdAt: Date() - ) -} diff --git a/Lendair/Views/CommunityEventsView.swift b/Lendair/Views/CommunityEventsView.swift deleted file mode 100644 index 843cd44a7..000000000 --- a/Lendair/Views/CommunityEventsView.swift +++ /dev/null @@ -1,236 +0,0 @@ -import SwiftUI - -struct CommunityEventsView: View { - @StateObject private var viewModel = CommunityEventViewModel() - @State private var showingCreateSheet = false - @State private var selectedTab: EventTab = .upcoming - - enum EventTab: String, CaseIterable { - case upcoming, ongoing, past - } - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.events.isEmpty { - loadingView - } else if currentEvents.isEmpty { - emptyStateView - } else { - eventListView - } - } - .navigationTitle("Community Events") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingCreateSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showingCreateSheet) { - CreateEventSheet() - } - } - .onAppear { - Task { - await viewModel.fetchEvents() - } - } - } - - private var currentEvents: [CommunityEvent] { - switch selectedTab { - case .upcoming: return viewModel.upcomingEvents - case .ongoing: return viewModel.ongoingEvents - case .past: return viewModel.pastEvents - } - } - - private var eventListView: some View { - List { - Picker("Events", selection: $selectedTab) { - ForEach(EventTab.allCases, id: \.self) { tab in - Text(tab.rawValue.capitalized).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.top, 8) - - Section(currentSectionTitle) { - ForEach(currentEvents) { event in - NavigationLink(destination: CommunityEventDetailView(event: event)) { - EventRowView(event: event) - } - } - } - } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.fetchEvents() - } - } - - private var currentSectionTitle: String { - switch selectedTab { - case .upcoming: return "Upcoming" - case .ongoing: return "Happening Now" - case .past: return "Past Events" - } - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Events...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "person.3.fill") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("No \(selectedTab.rawValue) Events") - .font(.title2) - .fontWeight(.semibold) - Text("Create or discover community running events in your area.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } -} - -struct CreateEventSheet: View { - @Environment(\.dismiss) var dismiss - @State private var title = "" - @State private var description = "" - @State private var eventType: EventType = .groupRun - @State private var location = "" - @State private var distanceKm = "" - @State private var maxParticipants = "" - - var body: some View { - NavigationView { - Form { - Section("Event Details") { - TextField("Event Title", text: $title) - TextField("Description", text: $description) - TextField("Location", text: $location) - } - - Section("Type") { - Picker("Event Type", selection: $eventType) { - ForEach(EventType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) - } - } - } - - Section("Optional") { - TextField("Distance (km)", text: $distanceKm) - .keyboardType(.decimalPad) - TextField("Max Participants", text: $maxParticipants) - .keyboardType(.numberPad) - } - } - .navigationTitle("Create Event") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Create") { - let request = CreateEventRequest( - title: title, - description: description, - eventType: eventType, - location: location, - latitude: 0, - longitude: 0, - startDate: Date(), - endDate: Date().addingTimeInterval(3600), - distanceKm: Double(distanceKm), - maxParticipants: Int(maxParticipants), - difficulty: nil - ) - dismiss() - } - .disabled(title.isEmpty || location.isEmpty) - } - } - } - } -} - -struct EventRowView: View { - let event: CommunityEvent - - var body: some View { - HStack(spacing: 12) { - Image(systemName: event.eventType.icon) - .font(.system(size: 24)) - .foregroundColor(event.eventType.color) - .frame(width: 44, height: 44) - .background(event.eventType.color.opacity(0.15)) - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 4) { - Text(event.title) - .font(.headline) - Text("\(event.location) \u2022 \(event.organizerName)") - .font(.subheadline) - .foregroundColor(.secondary) - HStack(spacing: 8) { - Text(formatDate(event.startDate)) - .font(.caption) - .foregroundColor(.secondary) - if let spots = event.availableSpots, spots > 0 { - Text("\(spots) spots left") - .font(.caption2) - .foregroundColor(.green) - } - } - } - - Spacer() - - switch event.rsvpStatus { - case .going: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .maybe: - Image(systemName: "questionmark.circle.fill") - .foregroundColor(.orange) - case .notGoing: - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - case .pending: - Image(systemName: "circle") - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter.string(from: date) - } -} - -#Preview { - CommunityEventsView() -} diff --git a/Lendair/Views/FamilyMemberView.swift b/Lendair/Views/FamilyMemberView.swift deleted file mode 100644 index c4a7ff06f..000000000 --- a/Lendair/Views/FamilyMemberView.swift +++ /dev/null @@ -1,125 +0,0 @@ -import SwiftUI - -struct FamilyMemberView: View { - let member: FamilyMember - @State private var weeklyData: [(day: String, distance: Double)] = [] - - var body: some View { - ScrollView { - VStack(spacing: 16) { - memberHeader - statsSection - weeklyActivitySection - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(member.name) - .navigationBarTitleDisplayMode(.inline) - } - - private var memberHeader: some View { - VStack(spacing: 16) { - ZStack { - Circle() - .fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.green.opacity(0.2)) - .frame(width: 80, height: 80) - Image(systemName: member.role.icon) - .font(.system(size: 36)) - .foregroundColor(member.isPrimary ? .blue : .green) - } - - VStack(spacing: 4) { - Text(member.name) - .font(.title3) - .fontWeight(.bold) - Text(member.role.displayName) - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .frame(maxWidth: .infinity) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private var statsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Statistics") - .font(.headline) - - HStack(spacing: 16) { - statCard(value: "\(Int(member.totalDistanceKm))", label: "Total km", icon: "figure.run") - statCard(value: "\(member.totalWorkouts)", label: "Workouts", icon: "checkmark.circle") - statCard(value: "\(Int(member.weeklyDistanceKm))", label: "This Week", icon: "chart.bar.fill") - } - } - } - - private func statCard(value: String, label: String, icon: String) -> some View { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 20)) - .foregroundColor(.blue) - Text(value) - .font(.title3) - .fontWeight(.bold) - Text(label) - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.secondary.opacity(0.08)) - .cornerRadius(10) - } - - private var weeklyActivitySection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Weekly Activity") - .font(.headline) - - VStack(spacing: 8) { - ForEach(DayOfWeek.allCases, id: \.self) { day in - HStack { - Text(day.displayName) - .font(.subheadline) - .frame(width: 36) - GeometryReader { geo in - RoundedRectangle(cornerRadius: 3) - .fill(Color.blue.opacity(0.6)) - .frame(width: min(geo.size.width, 200), height: 24) - } - .frame(height: 24) - Text("\(Int(member.weeklyDistanceKm / 7)) km") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - } - } -} - -#Preview { - NavigationView { - FamilyMemberView(member: sampleMember) - } -} - -private var sampleMember: FamilyMember { - FamilyMember( - id: "1", - name: "John Doe", - email: "john@example.com", - role: .owner, - joinedAt: Date().addingTimeInterval(-30 * 24 * 3600), - avatarUrl: nil, - isPrimary: true, - totalDistanceKm: 245.5, - totalWorkouts: 42, - weeklyDistanceKm: 32.0, - weeklyWorkouts: 5 - ) -} diff --git a/Lendair/Views/FamilyPlanView.swift b/Lendair/Views/FamilyPlanView.swift deleted file mode 100644 index 55a9c3be4..000000000 --- a/Lendair/Views/FamilyPlanView.swift +++ /dev/null @@ -1,244 +0,0 @@ -import SwiftUI - -struct FamilyPlanView: View { - @StateObject private var viewModel = FamilyPlanViewModel() - @State private var showingInviteSheet = false - @State private var selectedMetric: LeaderboardMetric = .distance - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.familyPlan == nil { - loadingView - } else if let plan = viewModel.familyPlan { - planContent(plan) - } else { - emptyStateView - } - } - .navigationTitle("Family Plan") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if let plan = viewModel.familyPlan, plan.isActive { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingInviteSheet = true - } label: { - Text("Invite") - } - } - } - } - .sheet(isPresented: $showingInviteSheet) { - InviteMemberSheet() - } - } - .onAppear { - Task { - await viewModel.fetchFamilyPlan() - await viewModel.fetchLeaderboard() - } - } - } - - @ViewBuilder - private func planContent(_ plan: FamilyPlan) -> some View { - List { - Section("Plan Status") { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(plan.ownerName) - .font(.headline) - Text("\(plan.members.count)/\(plan.maxMembers) members") - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - Text(plan.subscriptionStatus.displayName) - .font(.caption) - .fontWeight(.medium) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(plan.subscriptionStatus.color.opacity(0.2)) - .cornerRadius(6) - .foregroundColor(plan.subscriptionStatus.color) - } - - if let renewalDate = plan.renewalDate { - HStack { - Text("Renews") - .foregroundColor(.secondary) - Spacer() - Text(formatDate(renewalDate)) - .fontWeight(.medium) - } - .font(.subheadline) - } - } - - Section("Members") { - ForEach(plan.members) { member in - MemberRowView(member: member) - } - } - - Section("Leaderboard") { - Picker("Metric", selection: $selectedMetric) { - ForEach(viewModel.metrics, id: \.self) { metric in - Text(metric.displayName).tag(metric) - } - } - .onChange(of: selectedMetric) { newValue in - viewModel.selectedMetric = newValue - Task { await viewModel.fetchLeaderboard() } - } - - if viewModel.leaderboard.isEmpty { - Text("No data yet") - .foregroundColor(.secondary) - .font(.subheadline) - } else { - ForEach(viewModel.leaderboard) { entry in - LeaderboardRow(entry: entry, metric: selectedMetric) - } - } - } - } - .listStyle(.insetGrouped) - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Family Plan...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "person.3.fill") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("No Family Plan") - .font(.title2) - .fontWeight(.semibold) - Text("Create a family plan to share your subscription with up to 6 members.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } -} - -struct InviteMemberSheet: View { - @Environment(\.dismiss) var dismiss - @State private var email = "" - @State private var name = "" - - var body: some View { - NavigationView { - Form { - Section("New Member") { - TextField("Name", text: $name) - TextField("Email", text: $email) - .keyboardType(.emailAddress) - .autocapitalization(.none) - } - } - .navigationTitle("Invite Member") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Send Invite") { - dismiss() - } - .disabled(email.isEmpty || name.isEmpty) - } - } - } - } -} - -struct MemberRowView: View { - let member: FamilyMember - - var body: some View { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.secondary.opacity(0.15)) - .frame(width: 40, height: 40) - Image(systemName: member.role.icon) - .font(.system(size: 18)) - .foregroundColor(member.isPrimary ? .blue : .secondary) - } - - VStack(alignment: .leading, spacing: 2) { - Text(member.name) - .font(.subheadline) - .fontWeight(.medium) - Text(member.email) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("\(Int(member.weeklyDistanceKm)) km") - .font(.caption) - .fontWeight(.medium) - Text("this week") - .font(.caption2) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } -} - -struct LeaderboardRow: View { - let entry: FamilyLeaderboardEntry - let metric: LeaderboardMetric - - var body: some View { - HStack(spacing: 12) { - Text("#\(entry.rank)") - .font(.headline) - .frame(width: 30) - - Circle() - .fill(Color.secondary.opacity(0.2)) - .frame(width: 32, height: 32) - - Text(entry.memberName) - .font(.subheadline) - - Spacer() - - Text("\(Int(entry.value))\(metric.unit)") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } - } -} - -#Preview { - FamilyPlanView() -} diff --git a/Lendair/Views/MainTabView.swift b/Lendair/Views/MainTabView.swift deleted file mode 100644 index 480c2f92c..000000000 --- a/Lendair/Views/MainTabView.swift +++ /dev/null @@ -1,88 +0,0 @@ -import SwiftUI - -struct MainTabView: View { - @State private var selectedTab: AppTab = .home - @StateObject private var notificationVM = NotificationsViewModel() - - var body: some View { - TabView(selection: $selectedTab) { - Group { - TrainingPlanView() - .tag(AppTab.home) - .tabItem { - Label(AppTab.home.title, systemImage: AppTab.home.icon) - } - - ChallengesView() - .tag(AppTab.challenges) - .tabItem { - Label(AppTab.challenges.title, systemImage: AppTab.challenges.icon) - } - - ClubsView() - .tag(AppTab.clubs) - .tabItem { - Label(AppTab.clubs.title, systemImage: AppTab.clubs.icon) - } - - NotificationsView(viewModel: notificationVM) - .tag(AppTab.notifications) - .tabItem { - Label(AppTab.notifications.title, systemImage: AppTab.notifications.icon) - } - .badge(notificationVM.badgeCount) - - SettingsView() - .tag(AppTab.profile) - .tabItem { - Label(AppTab.profile.title, systemImage: AppTab.profile.icon) - } - } - } - .onAppear { - Task { - await notificationVM.fetchUnreadCount() - } - } - .onChange(of: selectedTab) { _, newTab in - if newTab == .notifications { - Task { - await notificationVM.fetchNotifications() - await notificationVM.fetchUnreadCount() - } - } - } - } -} - -enum AppTab: String, CaseIterable { - case home - case challenges - case clubs - case notifications - case profile - - var title: String { - switch self { - case .home: return "Home" - case .challenges: return "Challenges" - case .clubs: return "Clubs" - case .notifications: return "Notifications" - case .profile: return "Profile" - } - } - - var icon: String { - switch self { - case .home: return "house.fill" - case .challenges: return "flag.fill" - case .clubs: return "person.3.fill" - case .notifications: return "bell.fill" - case .profile: return "person.circle" - } - } -} - -#Preview { - MainTabView() -} diff --git a/Lendair/Views/NotificationRowView.swift b/Lendair/Views/NotificationRowView.swift deleted file mode 100644 index 7d0a590f9..000000000 --- a/Lendair/Views/NotificationRowView.swift +++ /dev/null @@ -1,93 +0,0 @@ -import SwiftUI - -struct NotificationRowView: View { - let notification: NotificationItem - - var body: some View { - HStack(spacing: 12) { - // Notification icon - Image(systemName: notification.type.icon) - .font(.system(size: 24)) - .foregroundColor(notification.type.color) - .accessibilityLabel(notification.type.rawValue) - - // Notification content - VStack(alignment: .leading, spacing: 4) { - Text(notification.title) - .font(.headline) - .foregroundColor(.primary) - - Text(notification.message) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(2) - } - - Spacer() - - // Timestamp and read indicator - VStack(alignment: .trailing, spacing: 4) { - if !notification.isRead { - Image(systemName: "circle.fill") - .font(.system(size: 8)) - .foregroundColor(.blue) - } - - Text(formatTimestamp(notification.createdAt)) - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 8) - .contentShape(Rectangle()) - } - - private func formatTimestamp(_ date: Date) -> String { - Self.formatter.localizedString(for: date, relativeTo: Date()) - } - - private static let formatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() -} - -#Preview { - List { - NotificationRowView( - notification: NotificationItem( - id: "1", - type: .loanApproved, - title: "Loan Approved", - message: "Your loan application for $500 has been approved by Sarah Johnson.", - createdAt: Date().addingTimeInterval(-3600), - isRead: false - ) - ) - - NotificationRowView( - notification: NotificationItem( - id: "2", - type: .paymentDue, - title: "Payment Due Soon", - message: "Your payment of $150 is due in 3 days.", - createdAt: Date().addingTimeInterval(-86400 * 2), - isRead: true - ) - ) - - NotificationRowView( - notification: NotificationItem( - id: "3", - type: .paymentReceived, - title: "Payment Received", - message: "You received a payment of $75 from Michael Chen.", - createdAt: Date().addingTimeInterval(-86400 * 5), - isRead: false - ) - ) - } - .listStyle(.insetGrouped) - .previewDisplayName("Notification Row Preview") -} diff --git a/Lendair/Views/NotificationsView.swift b/Lendair/Views/NotificationsView.swift deleted file mode 100644 index 5007b06fc..000000000 --- a/Lendair/Views/NotificationsView.swift +++ /dev/null @@ -1,105 +0,0 @@ -import SwiftUI - -struct NotificationsView: View { - @StateObject private var viewModel: NotificationsViewModel - @State private var showingRefreshIndicator = false - - init(viewModel: NotificationsViewModel = NotificationsViewModel()) { - self._viewModel = StateObject(wrappedValue: viewModel) - } - - var body: some View { - NavigationStack { - Group { - if viewModel.notifications.isEmpty && !viewModel.isLoading { - emptyStateView - } else { - notificationListView - } - } - .navigationTitle("Notifications") - .toolbar { - if !viewModel.notifications.isEmpty { - ToolbarItem(placement: .navigationBarTrailing) { - if viewModel.unreadCount > 0 { - Button { - Task { - await viewModel.markAllAsRead() - } - } label: { - Text("Mark All Read") - .font(.caption) - } - .foregroundColor(.blue) - } - } - } - } - } - .onAppear { - Task { - await viewModel.fetchNotifications() - } - } - } - - @ViewBuilder - private var notificationListView: some View { - List { - ForEach(viewModel.notifications) { notification in - NotificationRowView(notification: notification) - .onTapGesture { - Task { - if !notification.isRead { - await viewModel.markAsRead(id: notification.id) - } - } - } - } - .onDelete { offsets in - Task { - for index in offsets { - let notification = viewModel.notifications[index] - await viewModel.markAsRead(id: notification.id) - } - viewModel.notifications.remove(atOffsets: offsets) - } - } - } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.refresh() - } - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "bell.slash") - .font(.system(size: 64)) - .foregroundColor(.secondary) - - Text("No Notifications") - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - - Text("You're all caught up!\nWhen you have notifications, they'll appear here.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } -} - -#Preview { - NotificationsView() -} - -#Preview("With Data") { - let previewView = NotificationsView() - - // Inject mock data for preview - return previewView -} diff --git a/Lendair/Views/RaceDetailView.swift b/Lendair/Views/RaceDetailView.swift deleted file mode 100644 index 3859efd2b..000000000 --- a/Lendair/Views/RaceDetailView.swift +++ /dev/null @@ -1,182 +0,0 @@ -import SwiftUI - -struct RaceDetailView: View { - let race: Race - @StateObject private var viewModel = RaceDiscoveryViewModel() - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - raceHeader - raceInfoSection - raceDescription - actionButtons - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(race.name) - .navigationBarTitleDisplayMode(.inline) - } - - private var raceHeader: some View { - VStack(spacing: 12) { - HStack { - Image(systemName: race.raceType.icon) - .font(.system(size: 40)) - .foregroundColor(.orange) - VStack(alignment: .leading, spacing: 4) { - Text(race.raceType.displayName) - .font(.title2) - .fontWeight(.bold) - Text("\(race.distanceKm) km \u2022 \(race.terrainType.displayName)") - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - Button(action: { - Task { - await viewModel.toggleSaveRace(id: race.id) - } - }) { - Image(systemName: race.isSaved ? "bookmark.fill" : "bookmark") - .font(.title3) - .foregroundColor(race.isSaved ? .blue : .secondary) - } - } - - Divider() - - HStack(spacing: 20) { - infoItem(label: "Date", value: formatDate(race.raceDate)) - infoItem(label: "Location", value: race.location) - infoItem(label: "Days Left", value: "\(race.daysUntilRace)") - } - - if let count = race.participantCount { - HStack(spacing: 20) { - infoItem(label: "Participants", value: "\(count)") - infoItem(label: "Elevation", value: "\(Int(race.elevationGain))m") - infoItem(label: "Terrain", value: race.terrainType.displayName) - } - } - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private func infoItem(label: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption) - .foregroundColor(.secondary) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var raceInfoSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Race Details") - .font(.headline) - VStack(alignment: .leading, spacing: 6) { - detailRow(label: "Organizer", value: race.organizerName) - detailRow(label: "Distance", value: "\(race.distanceKm) km") - detailRow(label: "Elevation Gain", value: "\(Int(race.elevationGain))m") - detailRow(label: "Terrain", value: race.terrainType.displayName) - } - } - } - - private func detailRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.subheadline) - .foregroundColor(.secondary) - .frame(width: 120, alignment: .leading) - Text(value) - .font(.subheadline) - .fontWeight(.medium) - } - } - - private var raceDescription: some View { - VStack(alignment: .leading, spacing: 4) { - Text("About This Race") - .font(.headline) - Text(race.description) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - private var actionButtons: some View { - VStack(spacing: 12) { - if let url = race.registrationUrl, !race.isRegistered { - Button { - if let registrationUrl = URL(string: url) { - UIApplication.shared.open(registrationUrl) - } - } label: { - Text("Register Now") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - } - - if race.isRegistered { - HStack { - Image(systemName: "checkmark.circle.fill") - Text("Registered") - } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.green.opacity(0.15)) - .foregroundColor(.green) - .cornerRadius(10) - } - } - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter.string(from: date) - } -} - -#Preview { - NavigationView { - RaceDetailView(race: sampleRace) - } -} - -private var sampleRace: Race { - Race( - id: "1", - name: "City Marathon 2026", - description: "An annual marathon through the heart of the city. Features a flat, fast course suitable for all levels.", - location: "Downtown", - latitude: 40.7128, - longitude: -74.006, - raceDate: Date().addingTimeInterval(90 * 24 * 3600), - distanceKm: 42.2, - raceType: .road, - organizerName: "City Athletics Club", - registrationUrl: "https://example.com/register", - imageUrl: nil, - participantCount: 5000, - isRegistered: false, - isSaved: true, - elevationGain: 120, - terrainType: .flat - ) -} diff --git a/Lendair/Views/RaceDiscoveryView.swift b/Lendair/Views/RaceDiscoveryView.swift deleted file mode 100644 index 51b875afa..000000000 --- a/Lendair/Views/RaceDiscoveryView.swift +++ /dev/null @@ -1,165 +0,0 @@ -import SwiftUI - -struct RaceDiscoveryView: View { - @StateObject private var viewModel = RaceDiscoveryViewModel() - @State private var showingFilters = false - @State private var showingSavedRaces = false - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.races.isEmpty { - loadingView - } else if viewModel.races.isEmpty { - emptyStateView - } else { - raceListView - } - } - .navigationTitle("Race Discovery") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - showingSavedRaces.toggle() - } label: { - Text("Saved Races") - Image(systemName: "bookmark.fill") - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .sheet(isPresented: $showingSavedRaces) { - NavigationView { - SavedRacesSheet(viewModel: viewModel) - .navigationTitle("Saved Races") - .navigationBarTitleDisplayMode(.inline) - } - } - } - .onAppear { - Task { - await viewModel.fetchRaces() - } - } - } - - private var raceListView: some View { - List { - Section("Upcoming Races") { - ForEach(viewModel.upcomingRaces) { race in - NavigationLink(destination: RaceDetailView(race: race)) { - RaceRowView(race: race) - } - } - } - } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.fetchRaces() - } - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Races...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "flag.fill") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("No Races Found") - .font(.title2) - .fontWeight(.semibold) - Text("Discover local races and events to train for.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } -} - -struct SavedRacesSheet: View { - @ObservedObject var viewModel: RaceDiscoveryViewModel - - var body: some View { - List { - if viewModel.savedRaces.isEmpty { - Text("No saved races yet") - .foregroundColor(.secondary) - } else { - ForEach(viewModel.savedRaces) { race in - RaceRowView(race: race) - } - } - } - } -} - -struct RaceRowView: View { - let race: Race - - var body: some View { - HStack(spacing: 12) { - Image(systemName: race.raceType.icon) - .font(.system(size: 24)) - .foregroundColor(.orange) - .frame(width: 44, height: 44) - .background(Color.orange.opacity(0.15)) - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 4) { - Text(race.name) - .font(.headline) - Text("\(race.location) \u2022 \(race.distanceKm) km") - .font(.subheadline) - .foregroundColor(.secondary) - HStack(spacing: 8) { - Text(formatDate(race.raceDate)) - .font(.caption) - .foregroundColor(.secondary) - if race.isRegistered { - Text("Registered") - .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.green.opacity(0.2)) - .cornerRadius(4) - .foregroundColor(.green) - } - } - } - - Spacer() - - if race.isSaved { - Image(systemName: "bookmark.fill") - .foregroundColor(.blue) - } - } - .padding(.vertical, 4) - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return formatter.string(from: date) - } -} - -#Preview { - RaceDiscoveryView() -} diff --git a/Lendair/Views/SettingsView.swift b/Lendair/Views/SettingsView.swift deleted file mode 100644 index 811298efd..000000000 --- a/Lendair/Views/SettingsView.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI - -struct SettingsView: View { - @StateObject private var authViewModel = AuthViewModel() - - var body: some View { - List { - Section { - HStack { - Image(systemName: "person.circle") - .font(.system(size: 40)) - .foregroundColor(.primary) - - VStack(alignment: .leading) { - Text(authViewModel.userName ?? "User") - .font(.headline) - Text(authViewModel.userEmail ?? "email@example.com") - .font(.subheadline) - .foregroundColor(.secondary) - } - - Spacer() - } - } - - Section("About") { - HStack { - Text("Version") - Spacer() - Text(AppSettings.appVersion) - .foregroundColor(.secondary) - } - - HStack { - Text("Build") - Spacer() - Text(AppSettings.buildNumber) - .foregroundColor(.secondary) - } - } - - Section("Legal") { - Link(destination: AppSettings.termsOfServiceURL ?? URL(string: "about:blank")!) { - HStack { - Text("Terms of Service") - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Link(destination: AppSettings.privacyPolicyURL ?? URL(string: "about:blank")!) { - HStack { - Text("Privacy Policy") - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - - Section("Account") { - Button(action: { - authViewModel.logout() - }) { - HStack { - Image(systemName: "arrow.right.to.line") - Text("Log Out") - Spacer() - } - } - - Button(role: .destructive, action: { - authViewModel.deleteAccount() - }) { - HStack { - Image(systemName: "trash") - Text("Delete Account") - Spacer() - } - } - } - } - .navigationTitle("Settings") - } -} - -class AuthViewModel: ObservableObject { - @Published var userName: String? - @Published var userEmail: String? - - func logout() { - // Implement logout logic - userName = nil - userEmail = nil - } - - func deleteAccount() { - // Implement account deletion logic - userName = nil - userEmail = nil - } -} - -#Preview { - NavigationView { - SettingsView() - } -} diff --git a/Lendair/Views/TrainingPlanDetailView.swift b/Lendair/Views/TrainingPlanDetailView.swift deleted file mode 100644 index 574ef485c..000000000 --- a/Lendair/Views/TrainingPlanDetailView.swift +++ /dev/null @@ -1,213 +0,0 @@ -import SwiftUI - -struct TrainingPlanDetailView: View { - let plan: TrainingPlan - @State private var expandedWeek: Int? = 1 - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - planHeader - progressSection - planDescription - weeklyWorkoutsSection - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(plan.title) - .navigationBarTitleDisplayMode(.inline) - } - - private var planHeader: some View { - VStack(spacing: 8) { - HStack { - Image(systemName: plan.planType.icon) - .font(.system(size: 36)) - .foregroundColor(plan.difficulty.color) - VStack(alignment: .leading, spacing: 4) { - Text(plan.planType.displayName) - .font(.title2) - .fontWeight(.bold) - Text(plan.difficulty.displayName) - .font(.subheadline) - .foregroundColor(plan.difficulty.color) - } - Spacer() - Toggle("", isOn: Binding( - get: { plan.isFollowing }, - set: { _ in } - )) - } - - Divider() - - HStack(spacing: 24) { - statLabel(value: "\(plan.durationWeeks)", label: "Weeks") - statLabel(value: "\(plan.progress.totalSessions)", label: "Sessions") - statLabel(value: "\(Int(plan.progress.percentage))%", label: "Progress") - } - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private func statLabel(value: String, label: String) -> some View { - VStack(spacing: 4) { - Text(value) - .font(.title3) - .fontWeight(.bold) - Text(label) - .font(.caption) - .foregroundColor(.secondary) - } - } - - private var progressSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Progress") - .font(.headline) - ProgressView(value: plan.progress.percentage) - .tint(.blue) - HStack { - Text("\(plan.progress.completedWeeks)/\(plan.progress.totalWeeks) weeks") - Spacer() - Text("\(plan.progress.completedSessions)/\(plan.progress.totalSessions) sessions") - } - .font(.caption) - .foregroundColor(.secondary) - } - } - - private var planDescription: some View { - VStack(alignment: .leading, spacing: 4) { - Text("About This Plan") - .font(.headline) - Text(plan.description) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - private var weeklyWorkoutsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Weekly Schedule") - .font(.headline) - - ForEach(plan.weeklyWorkouts, id: \.id) { week in - WeekCard(week: week, isExpanded: expandedWeek == week.weekNumber) { - expandedWeek = expandedWeek == week.weekNumber ? nil : week.weekNumber - } - } - } - } -} - -struct WeekCard: View { - let week: WeeklyWorkout - let isExpanded: Bool - let toggleAction: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Week \(week.weekNumber)") - .font(.headline) - Spacer() - Text("\(week.completedSessions)/\(week.totalSessions)") - .font(.caption) - .foregroundColor(.secondary) - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - } - .padding(.vertical, 8) - .padding(.horizontal) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(8) - .onTapGesture { toggleAction() } - - if isExpanded { - ForEach(week.dailySessions) { session in - DailySessionRow(session: session) - } - } - } - } -} - -struct DailySessionRow: View { - let session: DailySession - - var body: some View { - HStack(spacing: 12) { - Image(systemName: session.workoutType.icon) - .font(.system(size: 20)) - .foregroundColor(session.workoutType.color) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text("\(session.dayOfWeek.displayName): \(session.workoutType.displayName)") - .font(.subheadline) - .fontWeight(.medium) - if session.status == .completed { - Text("Completed") - .font(.caption) - .foregroundColor(.green) - } else if let distance = session.targetDistanceKm { - Text("\(distance) km") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - switch session.status { - case .completed: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .inProgress: - Image(systemName: "pause.circle.fill") - .foregroundColor(.orange) - case .skipped: - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - case .pending: - Image(systemName: "circle") - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } -} - -#Preview { - NavigationView { - TrainingPlanDetailView(plan: samplePlan) - } -} - -private var samplePlan: TrainingPlan { - TrainingPlan( - id: "1", - title: "5K Beginner Plan", - description: "A 8-week plan designed to help beginners complete their first 5K race.", - planType: .fiveK, - durationWeeks: 8, - difficulty: .beginner, - startDate: Date(), - endDate: Date().addingTimeInterval(8 * 7 * 24 * 3600), - weeklyWorkouts: [ - WeeklyWorkout(id: "w1", weekNumber: 1, dailySessions: [ - DailySession(id: "s1", dayOfWeek: .monday, workoutType: .easyRun, title: "Easy Run", description: "Start with a comfortable pace", targetDistanceKm: 2.0, targetDurationMinutes: 20, targetPaceMinPerKm: 10, intensity: .easy, status: .completed), - DailySession(id: "s2", dayOfWeek: .wednesday, workoutType: .rest, title: "Rest Day", description: "Recovery", targetDistanceKm: nil, targetDurationMinutes: nil, targetPaceMinPerKm: nil, intensity: .veryEasy, status: .completed), - DailySession(id: "s3", dayOfWeek: .friday, workoutType: .easyRun, title: "Easy Run", description: "Build endurance", targetDistanceKm: 2.5, targetDurationMinutes: 25, targetPaceMinPerKm: 10, intensity: .easy, status: .pending), - DailySession(id: "s4", dayOfWeek: .saturday, workoutType: .longRun, title: "Long Run", description: "Gradually increase distance", targetDistanceKm: 3.0, targetDurationMinutes: 30, targetPaceMinPerKm: 10, intensity: .moderate, status: .pending) - ]) - ], - progress: PlanProgress(completedWeeks: 0, totalWeeks: 8, completedSessions: 2, totalSessions: 32, currentWeekNumber: 1), - isFollowing: true, - createdAt: Date() - ) -} diff --git a/Lendair/Views/TrainingPlanView.swift b/Lendair/Views/TrainingPlanView.swift deleted file mode 100644 index 5180d41c6..000000000 --- a/Lendair/Views/TrainingPlanView.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -struct TrainingPlanView: View { - @StateObject private var viewModel = TrainingPlanViewModel() - @State private var selectedType: PlanType? = nil - @State private var selectedDifficulty: Difficulty? = nil - @State private var showingGenerateSheet = false - - var body: some View { - NavigationView { - Group { - if viewModel.isLoading && viewModel.plans.isEmpty { - loadingView - } else if viewModel.plans.isEmpty { - emptyStateView - } else { - planListView - } - } - .navigationTitle("Training Plans") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingGenerateSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showingGenerateSheet) { - GeneratePlanSheet() - } - } - .onAppear { - Task { - await viewModel.fetchPlans() - } - } - } - - private var planListView: some View { - List { - if selectedType != nil || selectedDifficulty != nil { - Section("Filters") { - HStack { - Text("Type: \(selectedType?.displayName ?? "All")") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("Difficulty: \(selectedDifficulty?.displayName ?? "All")") - .font(.caption) - .foregroundColor(.secondary) - } - Button("Clear Filters") { - selectedType = nil - selectedDifficulty = nil - Task { await viewModel.fetchPlans() } - } - } - } - - Section("Plans") { - ForEach(viewModel.plans) { plan in - NavigationLink(destination: TrainingPlanDetailView(plan: plan)) { - PlanRowView(plan: plan) - } - } - } - - Section("Filter by Type") { - ForEach(viewModel.planTypes, id: \.self) { type in - Button(type.displayName) { - selectedType = type - Task { await viewModel.fetchPlans(type: type) } - } - .foregroundColor(type == selectedType ? .blue : .primary) - } - } - - Section("Filter by Difficulty") { - ForEach(viewModel.difficulties, id: \.self) { difficulty in - Button(difficulty.displayName) { - selectedDifficulty = difficulty - Task { await viewModel.fetchPlans(difficulty: difficulty) } - } - .foregroundColor(difficulty == selectedDifficulty ? .blue : .primary) - } - } - } - .listStyle(.insetGrouped) - .refreshable { - await viewModel.fetchPlans() - } - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - Text("Loading Plans...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 60) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "figure.run") - .font(.system(size: 64)) - .foregroundColor(.secondary) - Text("No Training Plans") - .font(.title2) - .fontWeight(.semibold) - Text("Start by generating a personalized plan or browse available plans.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - .padding(.vertical, 60) - } -} - -struct GeneratePlanSheet: View { - @Environment(\.dismiss) var dismiss - @State private var planType: PlanType = .fiveK - @State private var difficulty: Difficulty = .beginner - @State private var weeklyMileage: String = "" - @State private var goalTime: String = "" - - var body: some View { - NavigationView { - Form { - Section("Plan Type") { - Picker("Type", selection: $planType) { - ForEach(PlanType.allCases, id: \.self) { type in - Text(type.displayName).tag(type) - } - } - } - - Section("Difficulty") { - Picker("Difficulty", selection: $difficulty) { - ForEach(Difficulty.allCases, id: \.self) { diff in - Text(diff.displayName).tag(diff) - } - } - } - - Section("Optional Details") { - TextField("Current Weekly Mileage (km)", text: $weeklyMileage) - .keyboardType(.decimalPad) - TextField("Goal Time (minutes)", text: $goalTime) - .keyboardType(.numberPad) - } - } - .navigationTitle("Generate Plan") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Generate") { - let request = GeneratePlanRequest( - planType: planType, - difficulty: difficulty, - startDate: Date(), - currentWeeklyMileageKm: Double(weeklyMileage), - goalTimeMinutes: Int(goalTime), - availableDays: [.monday, .wednesday, .friday, .saturday] - ) - dismiss() - } - } - } - } - } -} - -struct PlanRowView: View { - let plan: TrainingPlan - - var body: some View { - HStack(spacing: 12) { - Image(systemName: plan.planType.icon) - .font(.system(size: 28)) - .foregroundColor(plan.difficulty.color) - .frame(width: 44, height: 44) - .background(plan.difficulty.color.opacity(0.15)) - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 4) { - Text(plan.title) - .font(.headline) - Text("\(plan.planType.displayName) \u2022 \(plan.durationWeeks) weeks \u2022 \(plan.difficulty.displayName)") - .font(.subheadline) - .foregroundColor(.secondary) - ProgressView(value: plan.progress.percentage) - .tint(.blue) - .scaleEffect(y: 0.5) - .padding(.vertical, -4) - } - - Spacer() - - if plan.isFollowing { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - } - } - .padding(.vertical, 4) - } -} - -#Preview { - TrainingPlanView() -} diff --git a/Lendair/Views/WorkoutSessionView.swift b/Lendair/Views/WorkoutSessionView.swift deleted file mode 100644 index 63f69670b..000000000 --- a/Lendair/Views/WorkoutSessionView.swift +++ /dev/null @@ -1,211 +0,0 @@ -import SwiftUI - -struct WorkoutSessionView: View { - let session: DailySession - @StateObject private var viewModel = TrainingPlanViewModel() - @State private var isRunning: Bool = false - @State private var elapsedSeconds: Int = 0 - - var body: some View { - ScrollView { - VStack(spacing: 20) { - sessionHeader - metricsSection - workoutInstructions - actionButtons - } - .padding(.horizontal) - .padding(.bottom, 32) - } - .navigationTitle(session.workoutType.displayName) - .navigationBarTitleDisplayMode(.inline) - } - - private var sessionHeader: some View { - VStack(spacing: 12) { - Image(systemName: session.workoutType.icon) - .font(.system(size: 48)) - .foregroundColor(session.workoutType.color) - - VStack(spacing: 4) { - Text(session.title) - .font(.title2) - .fontWeight(.bold) - Text(session.dayOfWeek.displayName) - .font(.subheadline) - .foregroundColor(.secondary) - } - - HStack(spacing: 24) { - if let distance = session.targetDistanceKm { - metricCard(value: "\(distance)", label: "Target km", icon: "figure.run") - } - if let duration = session.targetDurationMinutes { - metricCard(value: "\(duration)", label: "Target min", icon: "clock") - } - if let pace = session.targetPaceMinPerKm { - metricCard(value: "\(Int(pace)):00", label: "Pace /km", icon: "speedometer") - } - } - } - .padding() - .frame(maxWidth: .infinity) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(12) - } - - private func metricCard(value: String, label: String, icon: String) -> some View { - VStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 18)) - .foregroundColor(session.workoutType.color) - Text(value) - .font(.title3) - .fontWeight(.bold) - Text(label) - .font(.caption) - .foregroundColor(.secondary) - } - } - - private var metricsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Current Session") - .font(.headline) - - HStack(spacing: 16) { - currentMetric(value: "\(formatElapsed(elapsedSeconds))", label: "Elapsed", icon: "stopwatch") - currentMetric(value: "0.0", label: "Distance", icon: "route") - currentMetric(value: "--:--", label: "Pace", icon: "speedometer") - } - } - } - - private func currentMetric(value: String, label: String, icon: String) -> some View { - VStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(.blue) - Text(value) - .font(.title3) - .fontWeight(.bold) - Text(label) - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(Color.blue.opacity(0.08)) - .cornerRadius(10) - } - - private var workoutInstructions: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Instructions") - .font(.headline) - Text(session.description) - .font(.subheadline) - .foregroundColor(.secondary) - - if session.intensity != .veryEasy { - HStack { - Text("Intensity") - Spacer() - HStack(spacing: 2) { - ForEach(1...5, id: \.self) { i in - Circle() - .fill(i <= sessionIntensityLevel ? session.workoutType.color : Color.secondary.opacity(0.2)) - .frame(width: 8, height: 8) - } - } - } - .font(.caption) - } - } - } - - private var sessionIntensityLevel: Int { - switch session.intensity { - case .veryEasy: return 1 - case .easy: return 2 - case .moderate: return 3 - case .hard: return 4 - case .veryHard: return 5 - } - } - - private var actionButtons: some View { - VStack(spacing: 12) { - if isRunning { - Button { - isRunning = false - Task { - await viewModel.updateSessionStatus(sessionId: session.id, status: .completed) - } - } label: { - Text("Finish Workout") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - - Button { - isRunning = false - Task { - await viewModel.updateSessionStatus(sessionId: session.id, status: .skipped) - } - } label: { - Text("Skip Session") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.orange.opacity(0.15)) - .foregroundColor(.orange) - .cornerRadius(10) - } - } else { - Button { - isRunning = true - } label: { - Text("Start Workout") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(session.workoutType.color) - .foregroundColor(.white) - .cornerRadius(10) - } - } - } - } - - private func formatElapsed(_ seconds: Int) -> String { - let mins = seconds / 60 - let secs = seconds % 60 - return String(format: "%d:%02d", mins, secs) - } -} - -#Preview { - NavigationView { - WorkoutSessionView(session: sampleSession) - } -} - -private var sampleSession: DailySession { - DailySession( - id: "1", - dayOfWeek: .monday, - workoutType: .easyRun, - title: "Easy Recovery Run", - description: "Keep the pace comfortable. Focus on maintaining good form and breathing rhythm.", - targetDistanceKm: 5.0, - targetDurationMinutes: 30, - targetPaceMinPerKm: 6, - intensity: .easy, - status: .pending - ) -} diff --git a/LendairTests/ChallengeServiceTests.swift b/LendairTests/ChallengeServiceTests.swift deleted file mode 100644 index c26a68577..000000000 --- a/LendairTests/ChallengeServiceTests.swift +++ /dev/null @@ -1,447 +0,0 @@ -import XCTest -import SwiftUI -@testable import Lendair - -// MARK: - Mock Challenge Service - -final class MockChallengeService: ChallengeServiceProtocol { - var challenges: [Challenge] = [] - var selectedChallenge: (challenge: Challenge, participants: [ChallengeParticipant])? - var joinCalledIds: [String] = [] - var leaveCalledIds: [String] = [] - var createCalled = false - var updateCalled = false - var leaderboard: [LeaderboardEntry] = [] - var listCallCount = 0 - var listError: Error? - - func listChallenges(filter: ChallengeFilter = ChallengeFilter()) async throws -> [Challenge] { - listCallCount += 1 - if let error = listError { throw error } - return challenges - } - - func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) { - if let selected = selectedChallenge { return selected } - throw ChallengeError.notFound - } - - func createChallenge(request: CreateChallengeRequest) async throws -> Challenge { - createCalled = true - return Challenge( - id: "new-1", - title: request.title, - description: request.description, - challengeType: request.challengeType, - status: .active, - startDate: request.startDate, - endDate: request.endDate, - targetMetric: request.targetMetric, - targetValue: request.targetValue, - targetUnit: request.targetMetric.unit, - participantCount: 1, - rules: request.rules, - imageUrl: nil, - createdBy: "current-user", - createdByName: "Current User", - clubId: request.clubId, - participationStatus: .participating, - userProgress: 0, - createdAt: Date() - ) - } - - func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge { - updateCalled = true - return Challenge( - id: id, - title: request.title ?? "Updated", - description: request.description ?? "", - challengeType: request.challengeType ?? .distance, - status: .active, - startDate: Date(), - endDate: Date().addingTimeInterval(30 * 24 * 3600), - targetMetric: request.targetMetric ?? .distance, - targetValue: request.targetValue ?? 100, - targetUnit: (request.targetMetric ?? .distance).unit, - participantCount: 0, - rules: request.rules, - imageUrl: nil, - createdBy: "current-user", - createdByName: "Current User", - clubId: nil, - participationStatus: .participating, - userProgress: 0, - createdAt: Date() - ) - } - - func joinChallenge(id: String) async throws { - joinCalledIds.append(id) - } - - func leaveChallenge(id: String) async throws { - leaveCalledIds.append(id) - } - - func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] { - return leaderboard - } - - func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) { - return (progress.value, min((progress.value / 100) * 100, 100)) - } -} - -// MARK: - Helper: Sample Challenges - -extension Challenge { - static func sample( - id: String = "test-1", - title: String = "Test Challenge", - challengeType: ChallengeType = .distance, - status: ChallengeStatus = .active, - participationStatus: ParticipationStatus = .participating, - userProgress: Double = 0, - targetValue: Double = 100, - startDate: Date = Date().addingTimeInterval(-7 * 24 * 3600), - endDate: Date = Date().addingTimeInterval(23 * 24 * 3600) - ) -> Challenge { - Challenge( - id: id, - title: title, - description: "Test description", - challengeType: challengeType, - status: status, - startDate: startDate, - endDate: endDate, - targetMetric: .distance, - targetValue: targetValue, - targetUnit: "km", - participantCount: 10, - rules: nil, - imageUrl: nil, - createdBy: "user-1", - createdByName: "Test User", - clubId: nil, - participationStatus: participationStatus, - userProgress: userProgress, - createdAt: Date() - ) - } -} - -// MARK: - ChallengeServiceTests - -final class ChallengeServiceTests: XCTestCase { - // MARK: - Fetch Challenges - - @MainActor - func testFetchChallengesLoadsData() async { - let mock = MockChallengeService() - mock.challenges = [.sample(id: "1"), .sample(id: "2")] - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchChallenges() - - XCTAssertEqual(viewModel.challenges.count, 2) - XCTAssertFalse(viewModel.isLoading) - XCTAssertEqual(mock.listCallCount, 1) - } - - @MainActor - func testFetchChallengesHandlesError() async { - let mock = MockChallengeService() - mock.listError = ChallengeError.unauthorized - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchChallenges() - - XCTAssertTrue(viewModel.challenges.isEmpty) - XCTAssertFalse(viewModel.isLoading) - XCTAssertEqual(viewModel.error, .unauthorized) - } - - // MARK: - Challenge Type Display - - func testChallengeTypeDisplayNames() { - XCTAssertEqual(ChallengeType.distance.displayName, "Distance") - XCTAssertEqual(ChallengeType.time.displayName, "Time") - XCTAssertEqual(ChallengeType.frequency.displayName, "Frequency") - XCTAssertEqual(ChallengeType.elevation.displayName, "Elevation") - XCTAssertEqual(ChallengeType.calories.displayName, "Calories") - XCTAssertEqual(ChallengeType.streak.displayName, "Streak") - } - - func testChallengeTypeIcons() { - XCTAssertEqual(ChallengeType.distance.icon, "arrow.right.arrow.left") - XCTAssertEqual(ChallengeType.time.icon, "stopwatch.fill") - XCTAssertEqual(ChallengeType.frequency.icon, "repeat") - XCTAssertEqual(ChallengeType.elevation.icon, "mountain.2.fill") - XCTAssertEqual(ChallengeType.calories.icon, "flame.fill") - XCTAssertEqual(ChallengeType.streak.icon, "calendar.badge.clock") - } - - func testChallengeMetricUnits() { - XCTAssertEqual(ChallengeMetric.distance.unit, "km") - XCTAssertEqual(ChallengeMetric.time.unit, "min") - XCTAssertEqual(ChallengeMetric.frequency.unit, "sessions") - XCTAssertEqual(ChallengeMetric.elevation.unit, "m") - XCTAssertEqual(ChallengeMetric.calories.unit, "kcal") - } - - // MARK: - Challenge Time States - - func testChallengeIsUpcoming() { - let future = Challenge.sample( - id: "1", - startDate: Date().addingTimeInterval(7 * 24 * 3600), - endDate: Date().addingTimeInterval(37 * 24 * 3600) - ) - XCTAssertTrue(future.isUpcoming) - } - - func testChallengeIsActive() { - let active = Challenge.sample(id: "1") - XCTAssertTrue(active.isActive) - } - - func testChallengeIsCompleted() { - let past = Challenge.sample( - id: "1", - startDate: Date().addingTimeInterval(-30 * 24 * 3600), - endDate: Date().addingTimeInterval(-7 * 24 * 3600) - ) - XCTAssertTrue(past.isCompleted) - } - - // MARK: - Progress Percentage - - func testProgressPercentage() { - var challenge = Challenge.sample(id: "1", userProgress: 50, targetValue: 100) - XCTAssertEqual(challenge.progressPercentage, 50) - } - - func testProgressPercentageOverTarget() { - var challenge = Challenge.sample(id: "1", userProgress: 120, targetValue: 100) - XCTAssertEqual(challenge.progressPercentage, 100) - } - - func testProgressPercentageNoProgress() { - var challenge = Challenge.sample(id: "1", userProgress: 0, targetValue: 100) - XCTAssertEqual(challenge.progressPercentage, 0) - } - - func testProgressPercentageNilProgress() { - var challenge = Challenge.sample(id: "1", userProgress: nil, targetValue: 100) - XCTAssertEqual(challenge.progressPercentage, 0) - } - - // MARK: - Days Remaining - - func testDaysRemaining() { - let challenge = Challenge.sample( - id: "1", - endDate: Date().addingTimeInterval(5 * 24 * 3600) - ) - XCTAssertGreaterThan(challenge.daysRemaining, 0) - } - - // MARK: - Computed Filters - - @MainActor - func testActiveChallengesFiltersCorrectly() async { - let mock = MockChallengeService() - mock.challenges = [ - Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), - Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)), - Challenge.sample(id: "3", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)), - ] - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchChallenges() - - XCTAssertEqual(viewModel.activeChallenges.count, 1) - XCTAssertEqual(viewModel.activeChallenges.first?.id, "1") - } - - @MainActor - func testUpcomingChallengesFiltersCorrectly() async { - let mock = MockChallengeService() - mock.challenges = [ - Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), - Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)), - ] - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchChallenges() - - XCTAssertEqual(viewModel.upcomingChallenges.count, 1) - XCTAssertEqual(viewModel.upcomingChallenges.first?.id, "2") - } - - @MainActor - func testCompletedChallengesFiltersCorrectly() async { - let mock = MockChallengeService() - mock.challenges = [ - Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)), - Challenge.sample(id: "2", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), - ] - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchChallenges() - - XCTAssertEqual(viewModel.completedChallenges.count, 1) - XCTAssertEqual(viewModel.completedChallenges.first?.id, "1") - } - - @MainActor - func testUserChallengesFiltersParticipating() async { - let mock = MockChallengeService() - mock.challenges = [ - Challenge.sample(id: "1", participationStatus: .participating), - Challenge.sample(id: "2", participationStatus: .notParticipating), - Challenge.sample(id: "3", participationStatus: .participating), - ] - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchChallenges() - - XCTAssertEqual(viewModel.userChallenges.count, 2) - XCTAssertTrue(viewModel.userChallenges.allSatisfy { $0.participationStatus == .participating }) - } - - // MARK: - Join and Leave - - @MainActor - func testJoinChallengeUpdatesLocalState() async { - let mock = MockChallengeService() - let challenge = Challenge.sample(id: "1", participationStatus: .notParticipating) - mock.challenges = [challenge] - - let viewModel = ChallengeViewModel(service: mock) - viewModel.challenges = [challenge] - - await viewModel.joinChallenge(id: "1") - - XCTAssertEqual(viewModel.challenges.first?.participationStatus, .participating) - XCTAssertEqual(viewModel.challenges.first?.participantCount, 11) - XCTAssertEqual(mock.joinCalledIds, ["1"]) - } - - @MainActor - func testLeaveChallengeUpdatesLocalState() async { - let mock = MockChallengeService() - let challenge = Challenge.sample(id: "1", participationStatus: .participating) - mock.challenges = [challenge] - - let viewModel = ChallengeViewModel(service: mock) - viewModel.challenges = [challenge] - - await viewModel.leaveChallenge(id: "1") - - XCTAssertEqual(viewModel.challenges.first?.participationStatus, .notParticipating) - XCTAssertEqual(viewModel.challenges.first?.participantCount, 9) - XCTAssertEqual(mock.leaveCalledIds, ["1"]) - } - - // MARK: - Challenge Equality - - func testChallengeEquality() { - let a = Challenge.sample(id: "1", participationStatus: .participating) - let b = Challenge.sample(id: "1", participationStatus: .participating) - let c = Challenge.sample(id: "1", participationStatus: .notParticipating) - - XCTAssertEqual(a, b) - XCTAssertNotEqual(a, c) - } - - // MARK: - Create Challenge - - @MainActor - func testCreateChallengeAddsToList() async { - let mock = MockChallengeService() - let viewModel = ChallengeViewModel(service: mock) - - let request = CreateChallengeRequest( - title: "New Challenge", - description: "A new challenge", - challengeType: .distance, - startDate: Date(), - endDate: Date().addingTimeInterval(30 * 24 * 3600), - targetMetric: .distance, - targetValue: 50, - rules: nil, - clubId: nil - ) - - let result = await viewModel.createChallenge(request: request) - - XCTAssertNotNil(result) - XCTAssertEqual(viewModel.challenges.count, 1) - XCTAssertEqual(viewModel.challenges.first?.title, "New Challenge") - XCTAssertTrue(mock.createCalled) - } - - // MARK: - Leaderboard - - @MainActor - func testFetchLeaderboardLoadsData() async { - let mock = MockChallengeService() - mock.leaderboard = [ - LeaderboardEntry( - id: "1", position: 1, participantId: "user1", - participantName: "Alice", participantAvatarUrl: nil, - progress: 100, progressPercentage: 100 - ), - LeaderboardEntry( - id: "2", position: 2, participantId: "user2", - participantName: "Bob", participantAvatarUrl: nil, - progress: 75, progressPercentage: 75 - ), - ] - - let viewModel = ChallengeViewModel(service: mock) - await viewModel.fetchLeaderboard(challengeId: "1") - - XCTAssertEqual(viewModel.leaderboard.count, 2) - XCTAssertEqual(viewModel.leaderboard.first?.position, 1) - } - - // MARK: - Submit Progress - - @MainActor - func testSubmitProgressUpdatesChallenge() async { - let mock = MockChallengeService() - let challenge = Challenge.sample(id: "1", userProgress: 0) - mock.challenges = [challenge] - - let viewModel = ChallengeViewModel(service: mock) - viewModel.challenges = [challenge] - - let progress = ProgressSubmission(metric: .distance, value: 50, activityDate: Date()) - await viewModel.submitProgress(challengeId: "1", progress: progress) - - XCTAssertEqual(viewModel.challenges.first?.userProgress, 50) - } - - // MARK: - Challenge Filter Defaults - - func testChallengeFilterDefaults() { - let filter = ChallengeFilter() - XCTAssertEqual(filter.limit, 20) - XCTAssertEqual(filter.offset, 0) - XCTAssertNil(filter.challengeType) - XCTAssertNil(filter.status) - } - - // MARK: - Challenge Status Cases - - func testChallengeStatusCases() { - XCTAssertEqual(ChallengeStatus.allCases.count, 4) - XCTAssertEqual(ChallengeStatus.upcoming.rawValue, "upcoming") - XCTAssertEqual(ChallengeStatus.active.rawValue, "active") - XCTAssertEqual(ChallengeStatus.completed.rawValue, "completed") - XCTAssertEqual(ChallengeStatus.cancelled.rawValue, "cancelled") - } -} diff --git a/LendairTests/ClubServiceTests.swift b/LendairTests/ClubServiceTests.swift deleted file mode 100644 index e8fa11eaf..000000000 --- a/LendairTests/ClubServiceTests.swift +++ /dev/null @@ -1,329 +0,0 @@ -import XCTest -import SwiftUI -@testable import Lendair - -// MARK: - Mock Club Service - -final class MockClubService: ClubServiceProtocol { - var clubs: [Club] = [] - var selectedClub: (club: Club, members: [ClubMember])? - var joinCalledIds: [String] = [] - var leaveCalledIds: [String] = [] - var createCalled = false - var updateCalled = false - var listCallCount = 0 - var listError: Error? - - func listClubs(filter: ClubFilter = ClubFilter()) async throws -> [Club] { - listCallCount += 1 - if let error = listError { throw error } - return clubs - } - - func getClub(id: String) async throws -> (club: Club, members: [ClubMember]) { - if let selected = selectedClub { return selected } - throw ClubError.notFound - } - - func createClub(request: CreateClubRequest) async throws -> Club { - createCalled = true - return Club( - id: "new-1", - name: request.name, - description: request.description, - clubType: request.clubType, - privacy: request.privacy, - location: request.location, - latitude: request.latitude, - longitude: request.longitude, - memberCount: 1, - maxMembers: request.maxMembers, - imageUrl: nil, - rules: request.rules, - ownerId: "current-user", - ownerName: "Current User", - membershipStatus: .active, - createdAt: Date() - ) - } - - func updateClub(id: String, request: UpdateClubRequest) async throws -> Club { - updateCalled = true - return Club( - id: id, - name: request.name ?? "Updated", - description: request.description ?? "", - clubType: request.clubType ?? .running, - privacy: request.privacy ?? .publicPrivacy, - location: request.location ?? "", - latitude: request.latitude, - longitude: request.longitude, - memberCount: 0, - maxMembers: request.maxMembers, - imageUrl: nil, - rules: request.rules, - ownerId: "current-user", - ownerName: "Current User", - membershipStatus: .active, - createdAt: Date() - ) - } - - func joinClub(id: String) async throws { - joinCalledIds.append(id) - } - - func leaveClub(id: String) async throws { - leaveCalledIds.append(id) - } - - func inviteMember(clubId: String, email: String) async throws {} - func removeMember(clubId: String, memberId: String) async throws {} -} - -// MARK: - Helper: Sample Clubs - -extension Club { - static func sample( - id: String = "test-1", - name: String = "Test Club", - clubType: ClubType = .running, - privacy: ClubPrivacy = .publicPrivacy, - membershipStatus: MembershipStatus = .active - ) -> Club { - Club( - id: id, - name: name, - description: "Test description", - clubType: clubType, - privacy: privacy, - location: "Test Location", - latitude: nil, - longitude: nil, - memberCount: 10, - maxMembers: 50, - imageUrl: nil, - rules: nil, - ownerId: "owner-1", - ownerName: "Test Owner", - membershipStatus: membershipStatus, - createdAt: Date() - ) - } -} - -// MARK: - ClubServiceTests - -final class ClubServiceTests: XCTestCase { - // MARK: - Fetch Clubs - - @MainActor - func testFetchClubsLoadsData() async { - let mock = MockClubService() - mock.clubs = [.sample(id: "1"), .sample(id: "2")] - - let viewModel = ClubViewModel(service: mock) - await viewModel.fetchClubs() - - XCTAssertEqual(viewModel.clubs.count, 2) - XCTAssertFalse(viewModel.isLoading) - XCTAssertEqual(mock.listCallCount, 1) - } - - @MainActor - func testFetchClubsHandlesError() async { - let mock = MockClubService() - mock.listError = ClubError.unauthorized - - let viewModel = ClubViewModel(service: mock) - await viewModel.fetchClubs() - - XCTAssertTrue(viewModel.clubs.isEmpty) - XCTAssertFalse(viewModel.isLoading) - XCTAssertEqual(viewModel.error, .unauthorized) - } - - // MARK: - Club Types - - func testClubTypeDisplayNames() { - XCTAssertEqual(ClubType.running.displayName, "Running") - XCTAssertEqual(ClubType.walking.displayName, "Walking") - XCTAssertEqual(ClubType.cycling.displayName, "Cycling") - XCTAssertEqual(ClubType.triathlon.displayName, "Triathlon") - XCTAssertEqual(ClubType.crossfit.displayName, "CrossFit") - XCTAssertEqual(ClubType.general.displayName, "General Fitness") - } - - func testClubTypeIcons() { - XCTAssertEqual(ClubType.running.icon, "figure.run") - XCTAssertEqual(ClubType.walking.icon, "figure.walk") - XCTAssertEqual(ClubType.cycling.icon, "bicycle") - XCTAssertEqual(ClubType.triathlon.icon, "triangle.fill") - XCTAssertEqual(ClubType.crossfit.icon, "dumbbell.fill") - XCTAssertEqual(ClubType.general.icon, "heart.fill") - } - - func testClubPrivacyDisplayNames() { - XCTAssertEqual(ClubPrivacy.publicPrivacy.displayName, "Public") - XCTAssertEqual(ClubPrivacy.privateClub.displayName, "Private") - XCTAssertEqual(ClubPrivacy.invitationOnly.displayName, "Invitation Only") - } - - // MARK: - Club Computed Properties - - @MainActor - func testPublicClubsFiltersCorrectly() async { - let mock = MockClubService() - mock.clubs = [ - .sample(id: "1", privacy: .publicPrivacy), - .sample(id: "2", privacy: .privateClub), - .sample(id: "3", privacy: .publicPrivacy), - ] - - let viewModel = ClubViewModel(service: mock) - await viewModel.fetchClubs() - - XCTAssertEqual(viewModel.publicClubs.count, 2) - XCTAssertEqual(viewModel.publicClubs.first?.id, "1") - XCTAssertEqual(viewModel.publicClubs.last?.id, "3") - } - - @MainActor - func testUserClubsFiltersActiveMembers() async { - let mock = MockClubService() - mock.clubs = [ - .sample(id: "1", membershipStatus: .active), - .sample(id: "2", membershipStatus: .pending), - .sample(id: "3", membershipStatus: .active), - ] - - let viewModel = ClubViewModel(service: mock) - await viewModel.fetchClubs() - - XCTAssertEqual(viewModel.userClubs.count, 2) - XCTAssertTrue(viewModel.userClubs.allSatisfy { $0.membershipStatus == .active }) - } - - @MainActor - func testPendingClubsFiltersCorrectly() async { - let mock = MockClubService() - mock.clubs = [ - .sample(id: "1", membershipStatus: .active), - .sample(id: "2", membershipStatus: .pending), - .sample(id: "3", membershipStatus: .pending), - ] - - let viewModel = ClubViewModel(service: mock) - await viewModel.fetchClubs() - - XCTAssertEqual(viewModel.pendingClubs.count, 2) - } - - // MARK: - Join and Leave - - @MainActor - func testJoinClubUpdatesLocalState() async { - let mock = MockClubService() - let club = Club.sample(id: "1", membershipStatus: .left) - mock.clubs = [club] - - let viewModel = ClubViewModel(service: mock) - viewModel.clubs = [club] - - await viewModel.joinClub(id: "1") - - XCTAssertEqual(viewModel.clubs.first?.membershipStatus, .active) - XCTAssertEqual(viewModel.clubs.first?.memberCount, 11) - XCTAssertEqual(mock.joinCalledIds, ["1"]) - } - - @MainActor - func testLeaveClubUpdatesLocalState() async { - let mock = MockClubService() - let club = Club.sample(id: "1", membershipStatus: .active, memberCount: 10) - mock.clubs = [club] - - let viewModel = ClubViewModel(service: mock) - viewModel.clubs = [club] - - await viewModel.leaveClub(id: "1") - - XCTAssertEqual(viewModel.clubs.first?.membershipStatus, .left) - XCTAssertEqual(viewModel.clubs.first?.memberCount, 9) - XCTAssertEqual(mock.leaveCalledIds, ["1"]) - } - - // MARK: - Club Equality - - func testClubEquality() { - let a = Club.sample(id: "1", membershipStatus: .active) - let b = Club.sample(id: "1", membershipStatus: .active) - let c = Club.sample(id: "1", membershipStatus: .pending) - - XCTAssertEqual(a, b) - XCTAssertNotEqual(a, c) - } - - // MARK: - Club Capacity - - func testAvailableSpots() { - let club = Club.sample(id: "1", memberCount: 10, maxMembers: 50) - XCTAssertEqual(club.availableSpots, 40) - } - - func testIsFull() { - let club = Club.sample(id: "1", memberCount: 50, maxMembers: 50) - XCTAssertTrue(club.isFull) - } - - func testUnlimitedCapacity() { - let club = Club.sample(id: "1", memberCount: 100, maxMembers: nil) - XCTAssertFalse(club.isFull) - XCTAssertNil(club.availableSpots) - } - - // MARK: - Create Club - - @MainActor - func testCreateClubAddsToList() async { - let mock = MockClubService() - let viewModel = ClubViewModel(service: mock) - - let request = CreateClubRequest( - name: "New Club", - description: "A new club", - clubType: .running, - privacy: .publicPrivacy, - location: "Test Location", - latitude: nil, - longitude: nil, - maxMembers: 50, - rules: nil - ) - - let result = await viewModel.createClub(request: request) - - XCTAssertNotNil(result) - XCTAssertEqual(viewModel.clubs.count, 1) - XCTAssertEqual(viewModel.clubs.first?.name, "New Club") - XCTAssertTrue(mock.createCalled) - } - - // MARK: - Member Role - - func testMemberRoleDisplayNames() { - XCTAssertEqual(MemberRole.owner.displayName, "Owner") - XCTAssertEqual(MemberRole.admin.displayName, "Admin") - XCTAssertEqual(MemberRole.member.displayName, "Member") - } - - // MARK: - Club Filter Defaults - - func testClubFilterDefaults() { - let filter = ClubFilter() - XCTAssertEqual(filter.limit, 20) - XCTAssertEqual(filter.offset, 0) - XCTAssertNil(filter.clubType) - XCTAssertNil(filter.privacy) - } -} diff --git a/LendairTests/NotificationServiceTests.swift b/LendairTests/NotificationServiceTests.swift deleted file mode 100644 index 16d674cf0..000000000 --- a/LendairTests/NotificationServiceTests.swift +++ /dev/null @@ -1,359 +0,0 @@ -import XCTest -import SwiftUI -@testable import Lendair - -// MARK: - Mock Service - -final class MockNotificationsService: NotificationsServiceProtocol { - var notifications: [NotificationItem] = [] - var markedReadIds: [String] = [] - var markAllCalled = false - var listCallCount = 0 - var listError: Error? - var mockUnreadCount: Int = 0 - var getUnreadCountCallCount = 0 - - func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] { - listCallCount += 1 - if let error = listError { - throw error - } - return notifications - } - - func markAsRead(id: String) async throws { - markedReadIds.append(id) - } - - func markAllAsRead() async throws { - markAllCalled = true - } - - func getUnreadCount() async throws -> Int { - getUnreadCountCallCount += 1 - return mockUnreadCount - } -} - -// MARK: - Helper: Sample Notifications - -extension NotificationItem { - static func sample( - id: String = "test-1", - type: NotificationType = .loanApproved, - title: String = "Test", - message: String = "Test message", - isRead: Bool = false - ) -> NotificationItem { - NotificationItem( - id: id, - type: type, - title: title, - message: message, - createdAt: Date(), - isRead: isRead - ) - } -} - -// MARK: - NotificationServiceTests - -final class NotificationServiceTests: XCTestCase { - // MARK: - Fetch Notifications - - @MainActor - func testFetchNotificationsLoadsData() async { - let mock = MockNotificationsService() - mock.notifications = [.sample(id: "1"), .sample(id: "2")] - - let viewModel = NotificationsViewModel(notificationsService: mock) - await viewModel.fetchNotifications() - - XCTAssertEqual(viewModel.notifications.count, 2) - XCTAssertFalse(viewModel.isLoading) - XCTAssertEqual(mock.listCallCount, 1) - } - - @MainActor - func testFetchNotificationsSortsByCreatedAtDescending() async { - let mock = MockNotificationsService() - let older = NotificationItem.sample(id: "1", createdAt: Date().addingTimeInterval(-3600)) - let newer = NotificationItem.sample(id: "2", createdAt: Date()) - mock.notifications = [newer, older] - - let viewModel = NotificationsViewModel(notificationsService: mock) - await viewModel.fetchNotifications() - - XCTAssertEqual(viewModel.notifications.first?.id, "2") - XCTAssertEqual(viewModel.notifications.last?.id, "1") - } - - @MainActor - func testFetchNotificationsSetsLoadingState() async { - let mock = MockNotificationsService() - let viewModel = NotificationsViewModel(notificationsService: mock) - - await viewModel.fetchNotifications() - XCTAssertFalse(viewModel.isLoading) - XCTAssertNotNil(viewModel.lastRefreshDate) - } - - @MainActor - func testFetchNotificationsHandlesError() async { - let mock = MockNotificationsService() - mock.listError = NotificationError.unauthorized - - let viewModel = NotificationsViewModel(notificationsService: mock) - await viewModel.fetchNotifications() - - XCTAssertTrue(viewModel.notifications.isEmpty) - XCTAssertFalse(viewModel.isLoading) - XCTAssertEqual(viewModel.error, .unauthorized) - } - - // MARK: - Mark As Read - - @MainActor - func testMarkAsReadUpdatesLocalState() async { - let mock = MockNotificationsService() - let unread = NotificationItem.sample(id: "1", isRead: false) - mock.notifications = [unread] - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [unread] - - await viewModel.markAsRead(id: "1") - - XCTAssertTrue(viewModel.notifications.first?.isRead == true) - XCTAssertEqual(mock.markedReadIds, ["1"]) - } - - @MainActor - func testMarkAsReadIgnoresUnknownId() async { - let mock = MockNotificationsService() - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [.sample(id: "1")] - - await viewModel.markAsRead(id: "999") - - XCTAssertTrue(mock.markedReadIds.isEmpty) - } - - @MainActor - func testMarkAsReadReducesUnreadCount() async { - let mock = MockNotificationsService() - let read = NotificationItem.sample(id: "1", isRead: true) - let unread = NotificationItem.sample(id: "2", isRead: false) - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [read, unread] - - XCTAssertEqual(viewModel.unreadCount, 1) - - await viewModel.markAsRead(id: "2") - XCTAssertEqual(viewModel.unreadCount, 0) - } - - // MARK: - Mark All As Read - - @MainActor - func testMarkAllAsReadUpdatesAllNotifications() async { - let mock = MockNotificationsService() - let unread1 = NotificationItem.sample(id: "1", isRead: false) - let unread2 = NotificationItem.sample(id: "2", isRead: false) - let read = NotificationItem.sample(id: "3", isRead: true) - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [unread1, unread2, read] - - await viewModel.markAllAsRead() - - XCTAssertTrue(viewModel.notifications.allSatisfy { $0.isRead }) - XCTAssertTrue(mock.markAllCalled) - XCTAssertEqual(viewModel.unreadCount, 0) - } - - @MainActor - func testMarkAllAsReadNoOpWhenAllRead() async { - let mock = MockNotificationsService() - let read1 = NotificationItem.sample(id: "1", isRead: true) - let read2 = NotificationItem.sample(id: "2", isRead: true) - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [read1, read2] - - await viewModel.markAllAsRead() - - XCTAssertFalse(mock.markAllCalled) - } - - // MARK: - Unread Count - - @MainActor - func testUnreadCountCalculatesCorrectly() async { - let mock = MockNotificationsService() - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [ - NotificationItem.sample(id: "1", isRead: false), - NotificationItem.sample(id: "2", isRead: true), - NotificationItem.sample(id: "3", isRead: false), - ] - - XCTAssertEqual(viewModel.unreadCount, 2) - } - - @MainActor - func testUnreadCountIsEmptyWhenNoNotifications() async { - let mock = MockNotificationsService() - let viewModel = NotificationsViewModel(notificationsService: mock) - - XCTAssertEqual(viewModel.unreadCount, 0) - } - - // MARK: - Refresh - - @MainActor - func testRefreshReloadsData() async { - let mock = MockNotificationsService() - mock.notifications = [.sample(id: "1")] - - let viewModel = NotificationsViewModel(notificationsService: mock) - await viewModel.refresh() - - XCTAssertEqual(mock.listCallCount, 1) - XCTAssertEqual(viewModel.notifications.count, 1) - } - - // MARK: - Badge Count - - @MainActor - func testFetchUnreadCountSetsBadgeCount() async { - let mock = MockNotificationsService() - mock.mockUnreadCount = 5 - - let viewModel = NotificationsViewModel(notificationsService: mock) - await viewModel.fetchUnreadCount() - - XCTAssertEqual(viewModel.badgeCount, 5) - XCTAssertEqual(mock.getUnreadCountCallCount, 1) - } - - @MainActor - func testFetchUnreadCountZero() async { - let mock = MockNotificationsService() - mock.mockUnreadCount = 0 - - let viewModel = NotificationsViewModel(notificationsService: mock) - await viewModel.fetchUnreadCount() - - XCTAssertEqual(viewModel.badgeCount, 0) - } - - @MainActor - func testMarkAsReadDecrementsBadgeCount() async { - let mock = MockNotificationsService() - let unread = NotificationItem.sample(id: "1", isRead: false) - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [unread] - viewModel.badgeCount = 3 - - await viewModel.markAsRead(id: "1") - - XCTAssertEqual(viewModel.badgeCount, 2) - XCTAssertTrue(viewModel.notifications.first?.isRead == true) - } - - @MainActor - func testMarkAsReadBadgeCountDoesNotGoBelowZero() async { - let mock = MockNotificationsService() - let unread = NotificationItem.sample(id: "1", isRead: false) - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [unread] - viewModel.badgeCount = 0 - - await viewModel.markAsRead(id: "1") - - XCTAssertEqual(viewModel.badgeCount, 0) - } - - @MainActor - func testMarkAllAsReadResetsBadgeCount() async { - let mock = MockNotificationsService() - let unread1 = NotificationItem.sample(id: "1", isRead: false) - let unread2 = NotificationItem.sample(id: "2", isRead: false) - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.notifications = [unread1, unread2] - viewModel.badgeCount = 7 - - await viewModel.markAllAsRead() - - XCTAssertEqual(viewModel.badgeCount, 0) - } - - @MainActor - func testFetchNotificationsUpdatesBadgeCount() async { - let mock = MockNotificationsService() - mock.notifications = [ - NotificationItem.sample(id: "1", isRead: false), - NotificationItem.sample(id: "2", isRead: true), - NotificationItem.sample(id: "3", isRead: false), - ] - - let viewModel = NotificationsViewModel(notificationsService: mock) - viewModel.badgeCount = 0 - await viewModel.fetchNotifications() - - XCTAssertEqual(viewModel.badgeCount, 2) - } -} - -// MARK: - NotificationModelTests - -final class NotificationModelTests: XCTestCase { - func testNotificationTypeIcons() { - XCTAssertEqual(NotificationType.loanApproved.icon, "checkmark.circle.fill") - XCTAssertEqual(NotificationType.loanRejected.icon, "xmark.circle.fill") - XCTAssertEqual(NotificationType.paymentReceived.icon, "arrow.down.circle.fill") - XCTAssertEqual(NotificationType.paymentDue.icon, "exclamationmark.circle.fill") - XCTAssertEqual(NotificationType.newLender.icon, "person.circle.fill") - XCTAssertEqual(NotificationType.systemUpdate.icon, "info.circle.fill") - } - - func testNotificationTypeColors() { - XCTAssertEqual(NotificationType.loanApproved.color, .green) - XCTAssertEqual(NotificationType.loanRejected.color, .red) - XCTAssertEqual(NotificationType.paymentReceived.color, .green) - XCTAssertEqual(NotificationType.paymentDue.color, .orange) - XCTAssertEqual(NotificationType.newLender.color, .blue) - XCTAssertEqual(NotificationType.systemUpdate.color, .gray) - } - - func testNotificationItemEquality() { - let a = NotificationItem.sample(id: "1", isRead: false) - let b = NotificationItem.sample(id: "1", isRead: false) - let c = NotificationItem.sample(id: "1", isRead: true) - - XCTAssertEqual(a, b) - XCTAssertNotEqual(a, c) - } - - func testNotificationTypeRawValue() { - XCTAssertEqual(NotificationType.loanApproved.rawValue, "LOAN_APPROVED") - XCTAssertEqual(NotificationType.paymentDue.rawValue, "PAYMENT_DUE") - } - - func testNotificationListParamsDefaults() { - let params = NotificationListParams() - XCTAssertEqual(params.limit, 20) - XCTAssertEqual(params.offset, 0) - } - - func testNotificationListParamsCustom() { - let params = NotificationListParams(limit: 50, offset: 100) - XCTAssertEqual(params.limit, 50) - XCTAssertEqual(params.offset, 100) - } -} diff --git a/agents/ceo/AGENTS.md b/agents/ceo/AGENTS.md index f971561be..cc31569f6 100644 --- a/agents/ceo/AGENTS.md +++ b/agents/ceo/AGENTS.md @@ -22,3 +22,9 @@ These files are essential. Read them. - `$AGENT_HOME/HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat. - `$AGENT_HOME/SOUL.md` -- who you are and how you should act. - `$AGENT_HOME/TOOLS.md` -- tools you have access to + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/agents/cmo/AGENTS.md b/agents/cmo/AGENTS.md index 33445fb64..095e9f3a8 100644 --- a/agents/cmo/AGENTS.md +++ b/agents/cmo/AGENTS.md @@ -22,3 +22,9 @@ These files are essential. Read them. - `$AGENT_HOME/HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat. - `$AGENT_HOME/SOUL.md` -- who you are and how you should act. - `$AGENT_HOME/TOOLS.md` -- tools you have access to + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/agents/code-reviewer/AGENTS.md b/agents/code-reviewer/AGENTS.md index 6a18ea65f..103e83141 100644 --- a/agents/code-reviewer/AGENTS.md +++ b/agents/code-reviewer/AGENTS.md @@ -37,3 +37,9 @@ When you complete a code review: - Do NOT mark the issue as `done` - If there are no issues, assign to the Security Reviewer - If there are code issues, assign back to the original engineer with comments and set issue status back to `in_progress` + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/agents/cto/AGENTS.md b/agents/cto/AGENTS.md index 8198c4ef3..187c120f9 100644 --- a/agents/cto/AGENTS.md +++ b/agents/cto/AGENTS.md @@ -23,6 +23,12 @@ These files are essential. Read them. - `$AGENT_HOME/SOUL.md` -- who you are and how you should act. - `$AGENT_HOME/TOOLS.md` -- tools you have access to +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` + ## Oversight Responsibilities As CTO, you must: diff --git a/agents/cto/MEMORY.md b/agents/cto/MEMORY.md index 72cf1ccbc..321ffeb92 100644 --- a/agents/cto/MEMORY.md +++ b/agents/cto/MEMORY.md @@ -2,6 +2,12 @@ ## Lessons Learned +### 2026-05-10: Productivity review routing lessons +- `long_active_duration` with 0 runs/0 comments on Security Reviewer → likely a routing problem, not a productivity problem +- Security Reviewer should not be assigned code-fix work post-Code-Reviewer findings — their pipeline stage begins after Code Reviewer sign-off +- When Founding Engineer is paused mid-review-cycle, reroute fix-the-findings work to Senior Engineer (tolerates mismatched `executionAgentNameKey`) +- Always check: is the assignee the right agent type for the actual work needed, or was the issue misrouted? + ### 2026-05-10: Junior Engineer 0-run pattern - `executionAgentNameKey` is immutable on issues after creation - When reassigning issues between agents of different types, the old key remains and blocks run dispatch @@ -13,4 +19,5 @@ - Issues with `executionAgentNameKey` set to a specific engineer type cannot be directly reassigned to a different type - When an agent is paused, their queued runs stay queued until the agent is resumed or the issue is reassigned - Zero spentMonthlyCents does not mean an agent isn't running — it means the adapter isn't registering runs with the cost tracking -MD \ No newline at end of file +- Senior Engineer's streaming adapter (122B Qwen) repeatedly triggers `long_active_duration` false positives. FRE-5109 is the latest. The 6h cooldown mechanism (FRE-4785) is supposed to suppress these but the evaluations still fire. +- Recurring pattern: Senior Engineer creates code but doesn't commit it (FRE-4928 k6 files, FRE-4830). This breaks Code Reviewer's ability to verify fixes. Remediation: commit should be required at `in_progress` → `in_review` handoff. \ No newline at end of file diff --git a/agents/cto/memory/2026-05-10.md b/agents/cto/memory/2026-05-10.md index 894a8e667..37ac86cc6 100644 --- a/agents/cto/memory/2026-05-10.md +++ b/agents/cto/memory/2026-05-10.md @@ -1,14 +1,71 @@ -# 2026-05-10 +# 2026-05-10 Daily Notes -## Today's Events +## Heartbeat: FRE-5107 — Review productivity for FRE-4806 -### FRE-5105: Recover stalled issue FRE-4990 (Critical) -- Woken as CTO to recover stalled ghost-run dedup fix -- Paperclip server fix already committed at 7cf694c5 on paperclip master -- Fix includes: ghost-run exclusion check, recently-dismissed cooldown, correct cooldown timing -- All 10 heartbeat-active-run-output-watchdog tests pass -- Closed FRE-4990, FRE-5042, and FRE-5105 as done +### Context +- Woke via Paperclip wake payload for issue FRE-5107 +- Issue triggered by `long_active_duration` on Security Reviewer (6h with 0 runs, 0 comments) +- FRE-4806 was assigned to Security Reviewer but needed code-fix work -### Oversight -- Reviewed all open issues across the company -- No other issues requiring CTO intervention identified +### Investigation +- FRE-4806: Datadog APM + Sentry Error Tracking Integration +- Code Reviewer (f274248f) reviewed at 07:46:50, found 2x P1, 1x P2, 2x P3, assigned back to Founding Engineer for fixes +- Founding Engineer (d20f6f1c) is manually paused — can't work +- Issue then ended up with Security Reviewer (036d6925) who can't fix code-review findings +- Security Reviewer had 0 runs and 0 comments in 6h because they were waiting on engineering fixes + +### Actions Taken + +1. **FRE-5107** — Closed as `done` with routing decision + - Decision: Reroute — not a productivity problem + - Root cause: routing failure (Security Reviewer should never be assigned code-fix work mid-review-cycle) + +2. **FRE-4806** — Reassigned from Security Reviewer (036d6925) to Senior Engineer (c99c4ede) + - Comment documents the 5 Code Reviewer findings that need fixing + - Pipeline after fixes: Code Reviewer re-review → Security Reviewer sign-off + +### CTO Oversight Observations +- Senior Engineer now has 5 active issues (3 in_review, 1 in_progress, 1 newly assigned) +- Founding Engineer paused with 3 in_progress issues +- Many blocked Product Hunt launch items assigned to CMO +- Code review pipeline: FRE-4830, FRE-4693, FRE-4690 in_review but seem to be self-assigned (assignee=Senior Engineer, status=in_review) — may need Code Reviewer assignment + +## Heartbeat: 15:45 UTC — FRE-577 Pipeline Routing + +- Woken by issue_commented on FRE-577 +- CEO routed FRE-577 via subtask FRE-5117: Junior Engineer fixes P1 bugs → Code Reviewer re-review → CTO sign-off +- Verified FRE-5117 exists with parentId=FRE-577, assigned to Junior Engineer +- Set FRE-577 to blocked on FRE-5117 +- Released checkout +- Pipeline: Junior Engineer fixes → Code Reviewer re-review → CTO sign-off + +## Heartbeat: 16:00 UTC — FRE-4576 P1 Fixes Applied + +- Woken by issue_children_completed (FRE-5115 productivity review done) +- Found Senior Engineer overloaded (4 in_progress, 3 in_review, 2 todo) — no P1 fixes applied in 6h since review +- Applied 4 P1 + 2 P2 fixes myself per SOUL directive to stay close to code +- Build verified (vite build succeeds, all output files correct) +- Commit: 35e9f7e — reassigned to Code Reviewer (f274248f) at in_review +- FRE-4576 is in the ShieldAI repo at /home/mike/code/ShieldAI (not FrenoCorp) + +## Heartbeat: FRE-4529 — FrenoCorp Dir Cleanup + +- Woken by issue_assigned wake payload for FRE-4529 +- Removed literal `$AGENT_HOME/` directory artifact from repo root +- Moved Lendair iOS code to ~/code/lendair/iOS/Lendair/ +- Moved marketing/ to ~/code/scripter/ +- Moved shieldai-workflow.md to ~/code/ShieldAI/ +- Moved CI/CD workflows and load-test scripts to ~/code/lendair/ +- Moved vercel.json, .env.example, index.html to ~/code/lendair/web/ +- Removed root-level project configs (package.json, tsconfig.json, etc.) +- Updated all 8 agent AGENTS.md files with Repository Rules section +- Git commit created for all changes + +## Facts + +- ShieldAI extension code lives at /home/mike/code/ShieldAI/packages/extension/ +- FrenoCorp repo at /home/mike/code/FrenoCorp is for agent notes/memories only +- Lendair iOS code lives at ~/code/lendair/iOS/Lendair/ +- Lendair web code lives at ~/code/lendair/web/ +- Scripter code lives at ~/code/scripter/ +- Senior Engineer is overloaded: consider workload balancing diff --git a/agents/founding-engineer/AGENTS.md b/agents/founding-engineer/AGENTS.md index aaab303e4..be5c5df17 100644 --- a/agents/founding-engineer/AGENTS.md +++ b/agents/founding-engineer/AGENTS.md @@ -31,3 +31,9 @@ When you complete work on an issue: - Do NOT mark the issue as `done` - Instead, mark it as `in_review` and assign it to the Code Reviewer - The Code Reviewer will then assign to Security Reviewer, who will mark as `done` if no issues + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/agents/junior-engineer/AGENTS.md b/agents/junior-engineer/AGENTS.md index 4ee33fe06..8d44a9d45 100644 --- a/agents/junior-engineer/AGENTS.md +++ b/agents/junior-engineer/AGENTS.md @@ -39,3 +39,9 @@ When you complete work on an issue: - Do NOT mark the issue as `done` - Instead, mark it as `in_review` and assign it to the Code Reviewer - The Code Reviewer will then assign to Security Reviewer, who will mark as `done` if no issues + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/agents/security-reviewer/AGENTS.md b/agents/security-reviewer/AGENTS.md index 06d15c8e9..a901631e5 100644 --- a/agents/security-reviewer/AGENTS.md +++ b/agents/security-reviewer/AGENTS.md @@ -32,3 +32,9 @@ When you complete a security review: - If there are no security issues and no code quality issues, mark the issue as `done` - If there are security issues or code quality issues, assign back to the Code Reviewer or original engineer with comments, if back to engineer, set to in progress + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/agents/senior-engineer/AGENTS.md b/agents/senior-engineer/AGENTS.md index 63962176a..47151883a 100644 --- a/agents/senior-engineer/AGENTS.md +++ b/agents/senior-engineer/AGENTS.md @@ -29,3 +29,9 @@ When you complete work on an issue: - Do NOT mark the issue as `done` - Instead, mark it as `in_review` and assign it to the Code Reviewer - The Code Reviewer will then assign to Security Reviewer, who will mark as `done` if no issues + +## Repository Rules + +- `~/code/FrenoCorp` is for agent notes, memories, plans, and analysis only +- Do NOT add project code here -- product code belongs in its own repository +- Each agent's personal files live in their `$AGENT_HOME` directory under `agents//` diff --git a/index.html b/index.html deleted file mode 100644 index 919c0bde5..000000000 --- a/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Lendair - - -
- - - diff --git a/marketing/email-templates/01-vip-personal.md b/marketing/email-templates/01-vip-personal.md deleted file mode 100644 index db6c84c7f..000000000 --- a/marketing/email-templates/01-vip-personal.md +++ /dev/null @@ -1,25 +0,0 @@ -# Template: VIP Personal (T-2) - -**Send to:** 10 VIP supporters (awaiting Founder names) -**Send date:** May 5 (T-2) -**Subject:** Quick favor? Launching on Product Hunt Thursday 🚀 - ---- - -Hey [NAME], - -I'm launching Scripter on Product Hunt this Thursday and could use your support! - -It takes 10 seconds: -1. Go to [PH LINK] at 12:01 AM PT Thursday -2. Click the upvote button -3. Optionally leave a comment or share - -Product Hunt is huge for early visibility. Your upvote in the first hour especially matters. - -Can I count on you? - -Thanks! -[FOUNDER NAME] - -P.S. Happy to return the favor on your next launch! diff --git a/marketing/email-templates/02-beta-tester.md b/marketing/email-templates/02-beta-tester.md deleted file mode 100644 index a13b64e11..000000000 --- a/marketing/email-templates/02-beta-tester.md +++ /dev/null @@ -1,23 +0,0 @@ -# Template: Beta Tester (T-2) - -**Send to:** Beta testers (subset of VIP) -**Send date:** May 5 (T-2) -**Subject:** Scripter launches on Product Hunt Thursday! - ---- - -Hey [NAME], - -You were one of our amazing beta testers, so you're getting first dibs! - -Scripter officially launches on Product Hunt this Thursday. As someone who's used the product, your voice matters. - -Can you: -1. Upvote at [PH LINK] (12:01 AM PT Thursday) -2. Leave a quick comment about your experience? -3. Share with 2 screenwriter friends? - -This launch determines our visibility for months. Thank you! 🙏 - -Best, -[FOUNDER NAME] diff --git a/marketing/email-templates/03-active-waitlist.md b/marketing/email-templates/03-active-waitlist.md deleted file mode 100644 index 3d4eb80e7..000000000 --- a/marketing/email-templates/03-active-waitlist.md +++ /dev/null @@ -1,25 +0,0 @@ -# Template: Active Waitlist (T-3) - -**Send to:** 25 Active tier (earliest signups) -**Send date:** May 4 (T-3) — OVERDUE, send ASAP -**Subject:** We're launching on Product Hunt! 🎉 - ---- - -Hey [NAME], - -Big news! Scripter is launching on Product Hunt this Thursday. - -As an early waitlist subscriber, you're getting exclusive first access. - -Launch details: -- When: Thursday, 12:01 AM PT -- Where: [PH LINK] -- What: Upvote + comment = huge help! - -Early support determines our visibility for months. Can we count on you? - -Start writing free: scripter.app - -Thanks! -Team Scripter diff --git a/marketing/email-templates/04-general-waitlist.md b/marketing/email-templates/04-general-waitlist.md deleted file mode 100644 index 9411000c0..000000000 --- a/marketing/email-templates/04-general-waitlist.md +++ /dev/null @@ -1,23 +0,0 @@ -# Template: General Waitlist (T-1) - -**Send to:** 20 General tier -**Send date:** May 6 (T-1) -**Subject:** Tomorrow! Scripter on Product Hunt 🚀 - ---- - -Hey [NAME], - -Scripter launches on Product Hunt tomorrow! - -As a waitlist subscriber, you're getting first access to the new screenwriting platform. - -Launch time: Thursday, 12:01 AM PT -Link: [PH LINK] - -If you have 10 seconds to upvote, it would mean the world! - -Thanks for being part of the journey. - -Best, -Team Scripter diff --git a/marketing/email-templates/05-launch-day-reminder.md b/marketing/email-templates/05-launch-day-reminder.md deleted file mode 100644 index d8df9d385..000000000 --- a/marketing/email-templates/05-launch-day-reminder.md +++ /dev/null @@ -1,22 +0,0 @@ -# Template: Launch Day Reminder (T-0, 12:01 AM) - -**Send to:** All 45 supporters -**Send date:** May 7, 12:01 AM PT -**Subject:** 🚀 WE'RE LIVE! - ---- - -Hey [NAME], - -Scripter is LIVE on Product Hunt right now! - -Link: [PH LINK] - -If you can upvote in the next hour (12:01-1:00 AM PT), it would MASSIVELY help our ranking! - -Every upvote counts. Thank you! 🙏 - -Best, -Team Scripter - -P.S. We'll send a thank you email at the end of the day with results! diff --git a/marketing/product-hunt-supporter-list-built.md b/marketing/product-hunt-supporter-list-built.md deleted file mode 100644 index 5710d46a8..000000000 --- a/marketing/product-hunt-supporter-list-built.md +++ /dev/null @@ -1,186 +0,0 @@ -# Product Hunt Supporter List — Populated from Waitlist Export - -**Issue:** FRE-636 -**Owner:** CMO -**Last Updated:** 2026-05-04 (revised after FRE-4774 finding) -**Status:** READY — All 45 confirmed signups segmented; VIP awaits Founder names -**Launch Date:** May 7, 2026 at 12:01 AM PT - ---- - -## Executive Summary - -| Metric | Value | -|--------|-------| -| Confirmed signups | 45 (dev db — production was empty) | -| Supporter target | 45 (all hands) | -| Tier 1 VIP | 10 slots — awaiting Founder names | -| Tier 2 Active | 25 slots — populated from earliest signups | -| Tier 3 General | 20 slots — remaining signups | -| Goal | 35+ day-one upvotes | -| Launch day | May 7 | - ---- - -## Data Source — Critical Finding - -Exported by CTO (FRE-4601) from local `dev.db`. CTO later applied all migrations to production Turso (FRE-4774) and confirmed: - -> **Production DB had zero waitlist records.** All 14 tables were created by the migration but contained no data. The 8,742 figure cited in the original draft came from an external/marketing source (Typeform, Mailchimp, or estimate), not the production database. - -**Conclusion:** The 45 dev.db records are our only confirmed database-stored signups. Our launch strategy uses these 45 real supporters. - -**Investigation needed (Founder):** Where did the 8,742 number originate? - -**Columns:** email, signup_date, waitlist_position, referral_code, referred_by_code, referrals_count, skipped_line - ---- - -## Segmentation Applied - -### VIP — 10 slots (awaiting Founder input) -Proposed sources: -- **Referral champions** (4 people who referred others): taylor@scripts.com (5 refs), casey@write.net (5 refs), morgan@films.dev (2 refs), jordan@screenplay.io (1 ref) -- **Beta testers** — Founder to name (3 people) -- **Founder network** — Founder to name (3 people) - -### Active — 25 people (top 55% by signup date) -IDs 1–25, signed up March 18 – April 11. Sorted by `signup_date ASC`. - -### General — 20 people (remaining) -IDs 26–45, signed up April 12 – May 1. - ---- - -## Supporter List — Active Tier (25) - -| # | Name | Email | Signup Date | Referrals | Notes | -|---|------|-------|-------------|-----------|-------| -| 1 | — | alex@writer.com | 2026-03-18 | 0 | Earliest signup | -| 2 | — | jordan@screenplay.io | 2026-03-19 | 1 | Referred riley | -| 3 | — | taylor@scripts.com | 2026-03-20 | 5 | **Highest referrals** | -| 4 | — | morgan@films.dev | 2026-03-21 | 2 | Referred avery | -| 5 | — | casey@write.net | 2026-03-22 | 5 | High referrals | -| 6 | — | riley@producer.app | 2026-03-23 | 0 | Referred by jordan | -| 7 | — | quinn@story.co | 2026-03-24 | 0 | Referred by taylor | -| 8 | — | avery@drama.io | 2026-03-25 | 0 | Referred by morgan | -| 9 | — | blake@scene.work | 2026-03-26 | 0 | — | -| 10 | — | cameron@reel.dev | 2026-03-27 | 0 | — | -| 11 | — | dakota@plot.net | 2026-03-28 | 0 | — | -| 12 | — | ellis@cinema.app | 2026-03-29 | 0 | — | -| 13 | — | finley@scribe.com | 2026-03-30 | 0 | — | -| 14 | — | harper@draft.io | 2026-03-31 | 0 | — | -| 15 | — | jay@narrative.dev | 2026-04-01 | 0 | — | -| 16 | — | kendall@chapter.net | 2026-04-02 | 0 | — | -| 17 | — | logan@verse.app | 2026-04-03 | 0 | — | -| 18 | — | mason@script.co | 2026-04-04 | 0 | — | -| 19 | — | parker@film.io | 2026-04-05 | 0 | — | -| 20 | — | reece@story.dev | 2026-04-06 | 0 | — | -| 21 | — | sam@writing.net | 2026-04-07 | 0 | — | -| 22 | — | shawn@page.app | 2026-04-08 | 0 | — | -| 23 | — | sydney@scene.dev | 2026-04-09 | 0 | — | -| 24 | — | wade@plot.io | 2026-04-10 | 0 | — | -| 25 | — | zane@drama.net | 2026-04-11 | 0 | — | - ---- - -## Supporter List — General Tier (20) - -| # | Name | Email | Signup Date | Notes | -|---|------|-------|-------------|-------| -| 1 | — | emma@screen.app | 2026-04-12 | — | -| 2 | — | liam@scripting.dev | 2026-04-13 | — | -| 3 | — | olivia@write.io | 2026-04-14 | — | -| 4 | — | noah@story.net | 2026-04-15 | — | -| 5 | — | ava@scribe.dev | 2026-04-16 | — | -| 6 | — | william@draft.app | 2026-04-17 | — | -| 7 | — | sophia@reel.net | 2026-04-18 | — | -| 8 | — | james@narrative.io | 2026-04-19 | — | -| 9 | — | mia@chapter.dev | 2026-04-20 | — | -| 10 | — | benjamin@verse.app | 2026-04-21 | — | -| 11 | — | charlotte@script.dev | 2026-04-22 | — | -| 12 | — | elijah@film.io | 2026-04-23 | — | -| 13 | — | amelia@writing.net | 2026-04-24 | — | -| 14 | — | lucas@page.app | 2026-04-25 | — | -| 15 | — | harper@scene.dev | 2026-04-26 | — | -| 16 | — | henry@plot.net | 2026-04-27 | — | -| 17 | — | evelyn@drama.io | 2026-04-28 | — | -| 18 | — | alexander@screen.net | 2026-04-29 | — | -| 19 | — | abigail@story.dev | 2026-04-30 | — | -| 20 | — | daniel@scribe.app | 2026-05-01 | — | - ---- - -## Email Templates - -Sent as separate files: -- `/marketing/email-templates/vip-personal.md` -- `/marketing/email-templates/beta-tester.md` -- `/marketing/email-templates/active-waitlist.md` -- `/marketing/email-templates/general-waitlist.md` -- `/marketing/email-templates/launch-day.md` - ---- - -## Follow-Up Schedule (Countdown) - -| Day | Date | Action | Audience | Owner | Status | -|-----|------|--------|----------|-------|--------| -| T-3 | **May 4** | Active waitlist email | 25 Active | CMO | ⏳ Blocked (no email tool) | -| T-2 | May 5 | VIP outreach | 10 VIPs | CMO | ⏳ Blocked (no VIP names) | -| T-2 | May 5 | VIP follow-up #1 | Non-responders | CMO | ⏳ Blocked | -| T-1 | May 6 | General waitlist email | 20 General | CMO | ⏳ Blocked (no email tool) | -| T-1 | May 6 | Reminder email | All 45 | CMO | ⏳ Blocked | -| T-0 | May 7, 12:01 AM | Launch notification | All 45 | CMO | ⏳ Blocked | -| T-0 | May 7, 2:00 PM | Progress update | All 45 | CMO | ⏳ Blocked | -| T-0 | May 7, 8:00 PM | Final push | All 45 | CMO | ⏳ Blocked | -| T+1 | May 8 | Thank you email | All 45 | CMO | ⏳ Blocked | - -**Note:** All email outreach is blocked until Founder confirms email sending platform and provides VIP names. - ---- - -## Success Metrics - -| Metric | Target | Actual | Status | -|--------|--------|--------|--------| -| VIP commitments | 10/10 (100%) | — | ⏳ Pending Founder | -| Active commitments | 25/25 (100%) | — | ⏳ Pending | -| Active upvotes (day 1) | 20+ (80%) | — | ⏳ Pending | -| General upvotes (24h) | 12+ (60%) | — | ⏳ Pending | -| Total day-one upvotes | **35+** (target revised from 50+) | — | ⏳ Pending | - ---- - -## Next Actions - -### Done This Heartbeat -1. ✅ CSV tracker created at `/marketing/supporter-tracker.csv` -2. ✅ Production data investigated — 45 dev records are confirmed only source -3. ✅ FRE-4774 (CTO) resolved — production Turso migrated but empty -4. ✅ Email templates drafted at `/marketing/email-templates/` - -### Awaiting Founder (Blocking — 3 items) -1. **Provide 10 VIP names + emails** — proposed referral champions (taylor, casey, morgan, jordan) + 6 from beta/network -2. **Confirm email sending platform** — how do we send? (Mailchimp? SendGrid? Gmail manual?) -3. **Provide Product Hunt listing URL** — needed for all templates - -### Awaiting Founder (Information request) -- **Where did the 8,742 waitlist number come from?** CTO confirmed production DB was empty. Need to verify Typeform, Mailchimp, or other source. - -### What CMO Can Do Now (Not Blocked) -- ✅ Segmentation done (45 signups → 25 Active + 20 General) -- ✅ CSV tracker created -- ✅ Email templates drafted (5 variants) -- Update supporter list if new data emerges - ---- - -## Files - -| File | Purpose | -|------|---------| -| `/marketing/product-hunt-supporter-list-built.md` | This file | -| `/marketing/supporter-tracker.csv` | Machine-readable supporter list | -| `/shared/exports/waitlist-export.csv` | Raw CTO export | -| `/shared/exports/waitlist-export.json` | Raw CTO export (JSON) | diff --git a/marketing/supporter-tracker.csv b/marketing/supporter-tracker.csv deleted file mode 100644 index 76b5f2833..000000000 --- a/marketing/supporter-tracker.csv +++ /dev/null @@ -1,46 +0,0 @@ -id,name,email,tier,signup_date,referrals,status,confirmed,upvoted,notes -1,,alex@writer.com,Active,2026-03-18,0,pending,,,Earliest signup -2,,jordan@screenplay.io,Active,2026-03-19,1,pending,,,Referred riley; potential VIP -3,,taylor@scripts.com,Active,2026-03-20,5,pending,,,Highest referrals; potential VIP -4,,morgan@films.dev,Active,2026-03-21,2,pending,,,Referred avery; potential VIP -5,,casey@write.net,Active,2026-03-22,5,pending,,,High referrals; potential VIP -6,,riley@producer.app,Active,2026-03-23,0,pending,,,Referred by jordan -7,,quinn@story.co,Active,2026-03-24,0,pending,,,Referred by taylor -8,,avery@drama.io,Active,2026-03-25,0,pending,,,Referred by morgan -9,,blake@scene.work,Active,2026-03-26,0,pending,, -10,,cameron@reel.dev,Active,2026-03-27,0,pending,, -11,,dakota@plot.net,Active,2026-03-28,0,pending,, -12,,ellis@cinema.app,Active,2026-03-29,0,pending,, -13,,finley@scribe.com,Active,2026-03-30,0,pending,, -14,,harper@draft.io,Active,2026-03-31,0,pending,, -15,,jay@narrative.dev,Active,2026-04-01,0,pending,, -16,,kendall@chapter.net,Active,2026-04-02,0,pending,, -17,,logan@verse.app,Active,2026-04-03,0,pending,, -18,,mason@script.co,Active,2026-04-04,0,pending,, -19,,parker@film.io,Active,2026-04-05,0,pending,, -20,,reece@story.dev,Active,2026-04-06,0,pending,, -21,,sam@writing.net,Active,2026-04-07,0,pending,, -22,,shawn@page.app,Active,2026-04-08,0,pending,, -23,,sydney@scene.dev,Active,2026-04-09,0,pending,, -24,,wade@plot.io,Active,2026-04-10,0,pending,, -25,,zane@drama.net,Active,2026-04-11,0,pending,, -26,,emma@screen.app,General,2026-04-12,0,pending,, -27,,liam@scripting.dev,General,2026-04-13,0,pending,, -28,,olivia@write.io,General,2026-04-14,0,pending,, -29,,noah@story.net,General,2026-04-15,0,pending,, -30,,ava@scribe.dev,General,2026-04-16,0,pending,, -31,,william@draft.app,General,2026-04-17,0,pending,, -32,,sophia@reel.net,General,2026-04-18,0,pending,, -33,,james@narrative.io,General,2026-04-19,0,pending,, -34,,mia@chapter.dev,General,2026-04-20,0,pending,, -35,,benjamin@verse.app,General,2026-04-21,0,pending,, -36,,charlotte@script.dev,General,2026-04-22,0,pending,, -37,,elijah@film.io,General,2026-04-23,0,pending,, -38,,amelia@writing.net,General,2026-04-24,0,pending,, -39,,lucas@page.app,General,2026-04-25,0,pending,, -40,,harper@scene.dev,General,2026-04-26,0,pending,, -41,,henry@plot.net,General,2026-04-27,0,pending,, -42,,evelyn@drama.io,General,2026-04-28,0,pending,, -43,,alexander@screen.net,General,2026-04-29,0,pending,, -44,,abigail@story.dev,General,2026-04-30,0,pending,, -45,,daniel@scribe.app,General,2026-05-01,0,pending,, diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 9bf18fcfd..000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "FrenoCorp", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/package.json b/package.json deleted file mode 100644 index 22cd21fee..000000000 --- a/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "lendair-web", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.4.0", - "vite": "^5.2.0", - "vitest": "^1.4.0" - } -} diff --git a/scripts/load-test/compare-baseline.js b/scripts/load-test/compare-baseline.js deleted file mode 100644 index 60e12c41d..000000000 --- a/scripts/load-test/compare-baseline.js +++ /dev/null @@ -1,69 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -async function compareBaseline() { - const reportsDir = path.join(__dirname, 'reports'); - const baselinePath = path.join(reportsDir, 'baseline.json'); - const currentPath = path.join(reportsDir, 'current.json'); - - const baselineThreshold = parseFloat(process.env.BASELINE_THRESHOLD) || 0.1; - - if (!fs.existsSync(baselinePath)) { - console.log('No baseline found, creating initial baseline'); - createBaseline(); - return; - } - - const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); - const current = JSON.parse(fs.readFileSync(currentPath, 'utf8')); - - const avgTimeChange = (current.avgResponseTime - baseline.avgResponseTime) / baseline.avgResponseTime; - const successRateChange = current.successRate - baseline.successRate; - - console.log('\n=== Baseline Comparison ==='); - console.log(`Baseline Avg Response Time: ${baseline.avgResponseTime.toFixed(2)}ms`); - console.log(`Current Avg Response Time: ${current.avgResponseTime.toFixed(2)}ms`); - console.log(`Change: ${(avgTimeChange * 100).toFixed(2)}%`); - console.log(`Baseline Success Rate: ${baseline.successRate.toFixed(2)}%`); - console.log(`Current Success Rate: ${current.successRate.toFixed(2)}%`); - console.log(`Change: ${successRateChange.toFixed(2)}%`); - - const passed = Math.abs(avgTimeChange) <= baselineThreshold && successRateChange >= -1; - - if (passed) { - console.log('\n✓ Performance baseline check PASSED'); - process.exit(0); - } else { - console.log('\n✗ Performance baseline check FAILED'); - if (Math.abs(avgTimeChange) > baselineThreshold) { - console.log(` - Response time changed by ${(avgTimeChange * 100).toFixed(2)}% (threshold: ${baselineThreshold * 100}%)`); - } - if (successRateChange < -1) { - console.log(` - Success rate dropped by ${successRateChange.toFixed(2)}%`); - } - process.exit(1); - } -} - -function createBaseline() { - const reportsDir = path.join(__dirname, 'reports'); - const baseline = { - avgResponseTime: 100, - successRate: 99.0, - createdAt: new Date().toISOString() - }; - - if (!fs.existsSync(reportsDir)) { - fs.mkdirSync(reportsDir, { recursive: true }); - } - - fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2)); - console.log('Initial baseline created'); -} - -const baselinePath = path.join(__dirname, 'reports', 'baseline.json'); -if (!fs.existsSync(baselinePath)) { - createBaseline(); -} else { - compareBaseline(); -} diff --git a/scripts/load-test/package.json b/scripts/load-test/package.json deleted file mode 100644 index 9e0708382..000000000 --- a/scripts/load-test/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "frenocorp-load-tests", - "version": "1.0.0", - "description": "Load testing suite for FrenoCorp API", - "scripts": { - "load-test": "node run-load-test.js", - "baseline": "node run-baseline-test.js", - "compare-baseline": "node compare-baseline.js", - "create-baseline": "node create-baseline.js" - }, - "dependencies": { - "k6": "^0.1.0", - "axios": "^1.6.0" - }, - "devDependencies": { - "vitest": "^1.0.0" - } -} diff --git a/scripts/load-test/reports/baseline.json b/scripts/load-test/reports/baseline.json deleted file mode 100644 index f10d4f689..000000000 --- a/scripts/load-test/reports/baseline.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "avgResponseTime": 100, - "successRate": 99.0, - "createdAt": "2026-05-09T00:00:00.000Z", - "description": "Initial baseline for FRE-4931 load testing implementation" -} diff --git a/scripts/load-test/run-load-test.js b/scripts/load-test/run-load-test.js deleted file mode 100644 index ed304042e..000000000 --- a/scripts/load-test/run-load-test.js +++ /dev/null @@ -1,68 +0,0 @@ -const axios = require('axios'); - -const API_BASE_URL = process.env.API_BASE_URL || 'https://api.frenocorp.com'; -const CONCURRENCY = parseInt(process.env.LOAD_TEST_CONCURRENCY) || 10; -const DURATION = parseInt(process.env.LOAD_TEST_DURATION) || 60; - -const endpoints = [ - '/api/v1/auth/status', - '/api/v1/users/profile', - '/api/v1/activities/recent', - '/api/v1/plans/current' -]; - -async function runLoadTest() { - console.log(`Starting load test with ${CONCURRENCY} concurrent users for ${DURATION}s`); - console.log(`Target: ${API_BASE_URL}`); - - const results = { - totalRequests: 0, - successful: 0, - failed: 0, - avgResponseTime: 0, - responseTimes: [] - }; - - const startTime = Date.now(); - const endTime = startTime + (DURATION * 1000); - - while (Date.now() < endTime) { - const promises = endpoints.map(async (endpoint) => { - const requestStart = Date.now(); - try { - const response = await axios.get(`${API_BASE_URL}${endpoint}`); - results.successful++; - results.responseTimes.push(Date.now() - requestStart); - } catch (error) { - results.failed++; - console.error(`Failed request to ${endpoint}:`, error.message); - } - results.totalRequests++; - }); - - await Promise.all(promises); - } - - if (results.responseTimes.length > 0) { - results.avgResponseTime = results.responseTimes.reduce((a, b) => a + b, 0) / results.responseTimes.length; - } - - console.log('\n=== Load Test Results ==='); - console.log(`Total Requests: ${results.totalRequests}`); - console.log(`Successful: ${results.successful}`); - console.log(`Failed: ${results.failed}`); - console.log(`Success Rate: ${(results.successful / results.totalRequests * 100).toFixed(2)}%`); - console.log(`Average Response Time: ${results.avgResponseTime.toFixed(2)}ms`); - - return results; -} - -runLoadTest() - .then(() => { - console.log('\nLoad test completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('Load test failed:', error); - process.exit(1); - }); diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index 245ad72e3..000000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash - -# Vercel Project Setup Script for AudiobookPipeline -# This script creates and configures the Vercel project -# Requires: VERCEL_TOKEN environment variable or `vercel login` - -set -e - -echo "🚀 Setting up Vercel project for AudiobookPipeline..." - -# Check if Vercel CLI is installed -if ! npx vercel --version &> /dev/null; then - echo "📦 Installing Vercel CLI..." - npm install -g vercel -fi - -VERCEL_CMD="npx vercel" - -# Use VERCEL_TOKEN if available, otherwise require login -if [ -n "$VERCEL_TOKEN" ]; then - echo "🔐 Using VERCEL_TOKEN for authentication..." - export VERCEL_TOKEN -else - echo "🔐 Authenticating with Vercel..." - $VERCEL_CMD login -fi - -# Create Vercel project -echo "📁 Creating Vercel project..." -PROJECT=$($VERCEL_CMD ls | grep -i audiobookpipeline || echo "") - -if [ -z "$PROJECT" ]; then - $VERCEL_CMD init --name audiobookpipeline --framework vite - echo "✅ Project created successfully" -else - echo "ℹ️ Project already exists: $PROJECT" -fi - -# Configure environment variables -echo "⚙️ Configuring environment variables..." - -# Read from .env.local and set in Vercel -if [ -f ".env.local" ]; then - while IFS='=' read -r key value; do - # Skip comments and empty lines - [[ "$key" =~ ^#.*$ ]] && continue - [[ -z "$key" ]] && continue - - # Remove any trailing whitespace - value=$(echo "$value" | xargs) - - echo "Setting $key..." - $VERCEL_CMD env set "$key" "$value" --env production --override - $VERCEL_CMD env set "$key" "$value" --env development --override - $VERCEL_CMD env set "$key" "$value" --env preview --override - done < .env.local - - echo "✅ Environment variables configured" -else - echo "⚠️ .env.local not found. Please create it with the required variables." -fi - -# Deploy to verify configuration -echo "🚢 Testing deployment..." -$VERCEL_CMD deploy --prod --prebuilt - -echo "✨ Vercel setup complete!" -echo "📎 View your deployment at: https://audiobookpipeline.vercel.app" diff --git a/scripts/shift-dates-june.sh b/scripts/shift-dates-june.sh deleted file mode 100644 index e7ebf1511..000000000 --- a/scripts/shift-dates-june.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -# Shift Scripter launch dates forward by one month -# May N → June N, then April N → May N (order matters to avoid double-shift) -# Also renames relevant directories and files - -set -euo pipefail - -echo "=== Shifting Scripter dates forward by one month ===" - -# 1. Content replacement in files. -# Order: May→June FIRST, then April→May. -# This avoids double-shifting (Apr→May→Jun). -find . -type f \( -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) \ - -not -path "./.git/*" \ - -exec sed -i \ - -e 's/\bMay \([0-9]\+\)/June \1/g' \ - -e 's/\bApril \([0-9]\+\)/May \1/g' \ - {} + - -echo " ✅ Updated month references in .md / .yaml / .yml files" - -# 2. Rename the product-hunt-launch directory -OLD_DIR="agents/cmo/life/projects/product-hunt-launch-may-2026" -NEW_DIR="agents/cmo/life/projects/product-hunt-launch-june-2026" -if [ -d "$OLD_DIR" ]; then - find . -type f \( -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) \ - -not -path "./.git/*" \ - -exec sed -i "s|$OLD_DIR|$NEW_DIR|g" {} + - mv "$OLD_DIR" "$NEW_DIR" - echo " ✅ Renamed $OLD_DIR → $NEW_DIR" -fi - -# 3. Rename memory files dated 2026-05-* → 2026-06-* -# (and update cross-references to those filenames) -find . -type f -name "2026-05-*.md" \ - -not -path "./.git/*" \ - | while read -r f; do - newname=$(echo "$f" | sed 's|2026-05-|2026-06-|') - oldbase=$(basename "$f") - newbase=$(basename "$newname") - find . -type f \( -name "*.md" -o -name "*.yaml" -o -name "*.yml" \) \ - -not -path "./.git/*" \ - -exec sed -i "s|$oldbase|$newbase|g" {} + - mv "$f" "$newname" - echo " ✅ Renamed $f → $newname" - done - -echo "" -echo "=== Done! Dates shifted forward by one month ===" diff --git a/shared/exports/README.md b/shared/exports/README.md deleted file mode 100644 index e406e8eee..000000000 --- a/shared/exports/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Shared Exports - -Files in this directory are generated exports for cross-agent consumption. - -## Current Exports - -- `waitlist-export.csv` — 45 waitlist signups (dev.db, 2026-05-04) -- `waitlist-export.json` — Same data in JSON format -- `waitlist-export-summary.json` — Export metadata - -## Usage - -These files are readable by all agents. The CMO agent can read from this directory directly. diff --git a/shared/exports/waitlist-export-summary.json b/shared/exports/waitlist-export-summary.json deleted file mode 100644 index ab7856181..000000000 --- a/shared/exports/waitlist-export-summary.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "total_signups": 45, - "export_date": "2026-05-04T07:30:09.282Z", - "columns": [ - "email", - "signup_date", - "waitlist_position", - "referral_code", - "referred_by_code", - "referrals_count", - "skipped_line" - ], - "sample": [ - { - "id": 1, - "email": "alex@writer.com", - "signup_date": "2026-03-18T16:50:12.929Z", - "waitlist_position": 1, - "referral_code": "7HEWI7WE", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 2, - "email": "jordan@screenplay.io", - "signup_date": "2026-03-19T16:50:12.929Z", - "waitlist_position": 2, - "referral_code": "S0CLWRLR", - "referred_by_code": "", - "referrals_count": 1, - "skipped_line": true - }, - { - "id": 3, - "email": "taylor@scripts.com", - "signup_date": "2026-03-20T16:50:12.929Z", - "waitlist_position": 3, - "referral_code": "LFLE2GFG", - "referred_by_code": "", - "referrals_count": 5, - "skipped_line": false - } - ] -} \ No newline at end of file diff --git a/shared/exports/waitlist-export.csv b/shared/exports/waitlist-export.csv deleted file mode 100644 index 4bfee2389..000000000 --- a/shared/exports/waitlist-export.csv +++ /dev/null @@ -1,46 +0,0 @@ -email,signup_date,waitlist_position,referral_code,referred_by_code,referrals_count,skipped_line -"alex@writer.com","2026-03-18T16:50:12.929Z",1,"7HEWI7WE","",0,false -"jordan@screenplay.io","2026-03-19T16:50:12.929Z",2,"S0CLWRLR","",1,true -"taylor@scripts.com","2026-03-20T16:50:12.929Z",3,"LFLE2GFG","",5,false -"morgan@films.dev","2026-03-21T16:50:12.929Z",4,"F4TGMS0X","",2,false -"casey@write.net","2026-03-22T16:50:12.929Z",5,"UKJTK9SL","",5,true -"riley@producer.app","2026-03-23T16:50:12.929Z",6,"RB8N363R","S0CLWRLR",0,false -"quinn@story.co","2026-03-24T16:50:12.929Z",7,"GPBVR7JF","LFLE2GFG",0,false -"avery@drama.io","2026-03-25T16:50:12.929Z",8,"DAXXIC6R","F4TGMS0X",0,false -"blake@scene.work","2026-03-26T16:50:12.929Z",9,"I86T59OU","",0,false -"cameron@reel.dev","2026-03-27T16:50:12.929Z",10,"C9XGU8IN","",0,false -"dakota@plot.net","2026-03-28T16:50:12.929Z",11,"701QTQSL","",0,false -"ellis@cinema.app","2026-03-29T16:50:12.929Z",12,"QPXTRBC5","",0,false -"finley@scribe.com","2026-03-30T16:50:12.929Z",13,"D141O1A1","",0,false -"harper@draft.io","2026-03-31T16:50:12.929Z",14,"S2SQRLGV","",0,false -"jay@narrative.dev","2026-04-01T16:50:12.929Z",15,"PJASWDPK","",0,false -"kendall@chapter.net","2026-04-02T16:50:12.929Z",16,"2KDBNK5G","",0,false -"logan@verse.app","2026-04-03T16:50:12.929Z",17,"CG70Y4CE","",0,false -"mason@script.co","2026-04-04T16:50:12.929Z",18,"SGFWW3XQ","",0,false -"parker@film.io","2026-04-05T16:50:12.929Z",19,"6JKZ1DH6","",0,false -"reece@story.dev","2026-04-06T16:50:12.929Z",20,"EJPKTCV1","",0,false -"sam@writing.net","2026-04-07T16:50:12.929Z",21,"XR8VRM7Q","",0,false -"shawn@page.app","2026-04-08T16:50:12.929Z",22,"8WYWCBR5","",0,false -"sydney@scene.dev","2026-04-09T16:50:12.929Z",23,"36UMEIJ8","",0,false -"wade@plot.io","2026-04-10T16:50:12.929Z",24,"XD4ZJOMI","",0,false -"zane@drama.net","2026-04-11T16:50:12.929Z",25,"ZLGT1U1F","",0,false -"emma@screen.app","2026-04-12T16:50:12.929Z",26,"QR8H82G1","",0,false -"liam@scripting.dev","2026-04-13T16:50:12.929Z",27,"0VYL89TN","",0,false -"olivia@write.io","2026-04-14T16:50:12.929Z",28,"TAD3HTAG","",0,false -"noah@story.net","2026-04-15T16:50:12.929Z",29,"4P789R09","",0,false -"ava@scribe.dev","2026-04-16T16:50:12.929Z",30,"5O8N0G0A","",0,false -"william@draft.app","2026-04-17T16:50:12.929Z",31,"GVYK4OII","",0,false -"sophia@reel.net","2026-04-18T16:50:12.929Z",32,"ZCG8CZSH","",0,false -"james@narrative.io","2026-04-19T16:50:12.929Z",33,"IQMG11BT","",0,false -"mia@chapter.dev","2026-04-20T16:50:12.929Z",34,"OUCRPVBR","",0,false -"benjamin@verse.app","2026-04-21T16:50:12.929Z",35,"CPIN2GNJ","",0,false -"charlotte@script.dev","2026-04-22T16:50:12.929Z",36,"60G8M0QF","",0,false -"elijah@film.io","2026-04-23T16:50:12.929Z",37,"OE4XG5DK","",0,false -"amelia@writing.net","2026-04-24T16:50:12.929Z",38,"0R3D78QL","",0,false -"lucas@page.app","2026-04-25T16:50:12.929Z",39,"L8ZGQF4W","",0,false -"harper@scene.dev","2026-04-26T16:50:12.929Z",40,"T12HPWFM","",0,false -"henry@plot.net","2026-04-27T16:50:12.929Z",41,"QMPP9SU1","",0,false -"evelyn@drama.io","2026-04-28T16:50:12.929Z",42,"CVJ88I2X","",0,false -"alexander@screen.net","2026-04-29T16:50:12.929Z",43,"KM62Q609","",0,false -"abigail@story.dev","2026-04-30T16:50:12.929Z",44,"X4UY6LMM","",0,false -"daniel@scribe.app","2026-05-01T16:50:12.929Z",45,"MMK1DXFC","",0,false \ No newline at end of file diff --git a/shared/exports/waitlist-export.json b/shared/exports/waitlist-export.json deleted file mode 100644 index d3cc4a4db..000000000 --- a/shared/exports/waitlist-export.json +++ /dev/null @@ -1,452 +0,0 @@ -[ - { - "id": 1, - "email": "alex@writer.com", - "signup_date": "2026-03-18T16:50:12.929Z", - "waitlist_position": 1, - "referral_code": "7HEWI7WE", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 2, - "email": "jordan@screenplay.io", - "signup_date": "2026-03-19T16:50:12.929Z", - "waitlist_position": 2, - "referral_code": "S0CLWRLR", - "referred_by_code": "", - "referrals_count": 1, - "skipped_line": true - }, - { - "id": 3, - "email": "taylor@scripts.com", - "signup_date": "2026-03-20T16:50:12.929Z", - "waitlist_position": 3, - "referral_code": "LFLE2GFG", - "referred_by_code": "", - "referrals_count": 5, - "skipped_line": false - }, - { - "id": 4, - "email": "morgan@films.dev", - "signup_date": "2026-03-21T16:50:12.929Z", - "waitlist_position": 4, - "referral_code": "F4TGMS0X", - "referred_by_code": "", - "referrals_count": 2, - "skipped_line": false - }, - { - "id": 5, - "email": "casey@write.net", - "signup_date": "2026-03-22T16:50:12.929Z", - "waitlist_position": 5, - "referral_code": "UKJTK9SL", - "referred_by_code": "", - "referrals_count": 5, - "skipped_line": true - }, - { - "id": 6, - "email": "riley@producer.app", - "signup_date": "2026-03-23T16:50:12.929Z", - "waitlist_position": 6, - "referral_code": "RB8N363R", - "referred_by_code": "S0CLWRLR", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 7, - "email": "quinn@story.co", - "signup_date": "2026-03-24T16:50:12.929Z", - "waitlist_position": 7, - "referral_code": "GPBVR7JF", - "referred_by_code": "LFLE2GFG", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 8, - "email": "avery@drama.io", - "signup_date": "2026-03-25T16:50:12.929Z", - "waitlist_position": 8, - "referral_code": "DAXXIC6R", - "referred_by_code": "F4TGMS0X", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 9, - "email": "blake@scene.work", - "signup_date": "2026-03-26T16:50:12.929Z", - "waitlist_position": 9, - "referral_code": "I86T59OU", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 10, - "email": "cameron@reel.dev", - "signup_date": "2026-03-27T16:50:12.929Z", - "waitlist_position": 10, - "referral_code": "C9XGU8IN", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 11, - "email": "dakota@plot.net", - "signup_date": "2026-03-28T16:50:12.929Z", - "waitlist_position": 11, - "referral_code": "701QTQSL", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 12, - "email": "ellis@cinema.app", - "signup_date": "2026-03-29T16:50:12.929Z", - "waitlist_position": 12, - "referral_code": "QPXTRBC5", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 13, - "email": "finley@scribe.com", - "signup_date": "2026-03-30T16:50:12.929Z", - "waitlist_position": 13, - "referral_code": "D141O1A1", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 14, - "email": "harper@draft.io", - "signup_date": "2026-03-31T16:50:12.929Z", - "waitlist_position": 14, - "referral_code": "S2SQRLGV", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 15, - "email": "jay@narrative.dev", - "signup_date": "2026-04-01T16:50:12.929Z", - "waitlist_position": 15, - "referral_code": "PJASWDPK", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 16, - "email": "kendall@chapter.net", - "signup_date": "2026-04-02T16:50:12.929Z", - "waitlist_position": 16, - "referral_code": "2KDBNK5G", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 17, - "email": "logan@verse.app", - "signup_date": "2026-04-03T16:50:12.929Z", - "waitlist_position": 17, - "referral_code": "CG70Y4CE", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 18, - "email": "mason@script.co", - "signup_date": "2026-04-04T16:50:12.929Z", - "waitlist_position": 18, - "referral_code": "SGFWW3XQ", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 19, - "email": "parker@film.io", - "signup_date": "2026-04-05T16:50:12.929Z", - "waitlist_position": 19, - "referral_code": "6JKZ1DH6", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 20, - "email": "reece@story.dev", - "signup_date": "2026-04-06T16:50:12.929Z", - "waitlist_position": 20, - "referral_code": "EJPKTCV1", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 21, - "email": "sam@writing.net", - "signup_date": "2026-04-07T16:50:12.929Z", - "waitlist_position": 21, - "referral_code": "XR8VRM7Q", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 22, - "email": "shawn@page.app", - "signup_date": "2026-04-08T16:50:12.929Z", - "waitlist_position": 22, - "referral_code": "8WYWCBR5", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 23, - "email": "sydney@scene.dev", - "signup_date": "2026-04-09T16:50:12.929Z", - "waitlist_position": 23, - "referral_code": "36UMEIJ8", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 24, - "email": "wade@plot.io", - "signup_date": "2026-04-10T16:50:12.929Z", - "waitlist_position": 24, - "referral_code": "XD4ZJOMI", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 25, - "email": "zane@drama.net", - "signup_date": "2026-04-11T16:50:12.929Z", - "waitlist_position": 25, - "referral_code": "ZLGT1U1F", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 26, - "email": "emma@screen.app", - "signup_date": "2026-04-12T16:50:12.929Z", - "waitlist_position": 26, - "referral_code": "QR8H82G1", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 27, - "email": "liam@scripting.dev", - "signup_date": "2026-04-13T16:50:12.929Z", - "waitlist_position": 27, - "referral_code": "0VYL89TN", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 28, - "email": "olivia@write.io", - "signup_date": "2026-04-14T16:50:12.929Z", - "waitlist_position": 28, - "referral_code": "TAD3HTAG", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 29, - "email": "noah@story.net", - "signup_date": "2026-04-15T16:50:12.929Z", - "waitlist_position": 29, - "referral_code": "4P789R09", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 30, - "email": "ava@scribe.dev", - "signup_date": "2026-04-16T16:50:12.929Z", - "waitlist_position": 30, - "referral_code": "5O8N0G0A", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 31, - "email": "william@draft.app", - "signup_date": "2026-04-17T16:50:12.929Z", - "waitlist_position": 31, - "referral_code": "GVYK4OII", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 32, - "email": "sophia@reel.net", - "signup_date": "2026-04-18T16:50:12.929Z", - "waitlist_position": 32, - "referral_code": "ZCG8CZSH", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 33, - "email": "james@narrative.io", - "signup_date": "2026-04-19T16:50:12.929Z", - "waitlist_position": 33, - "referral_code": "IQMG11BT", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 34, - "email": "mia@chapter.dev", - "signup_date": "2026-04-20T16:50:12.929Z", - "waitlist_position": 34, - "referral_code": "OUCRPVBR", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 35, - "email": "benjamin@verse.app", - "signup_date": "2026-04-21T16:50:12.929Z", - "waitlist_position": 35, - "referral_code": "CPIN2GNJ", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 36, - "email": "charlotte@script.dev", - "signup_date": "2026-04-22T16:50:12.929Z", - "waitlist_position": 36, - "referral_code": "60G8M0QF", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 37, - "email": "elijah@film.io", - "signup_date": "2026-04-23T16:50:12.929Z", - "waitlist_position": 37, - "referral_code": "OE4XG5DK", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 38, - "email": "amelia@writing.net", - "signup_date": "2026-04-24T16:50:12.929Z", - "waitlist_position": 38, - "referral_code": "0R3D78QL", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 39, - "email": "lucas@page.app", - "signup_date": "2026-04-25T16:50:12.929Z", - "waitlist_position": 39, - "referral_code": "L8ZGQF4W", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 40, - "email": "harper@scene.dev", - "signup_date": "2026-04-26T16:50:12.929Z", - "waitlist_position": 40, - "referral_code": "T12HPWFM", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 41, - "email": "henry@plot.net", - "signup_date": "2026-04-27T16:50:12.929Z", - "waitlist_position": 41, - "referral_code": "QMPP9SU1", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 42, - "email": "evelyn@drama.io", - "signup_date": "2026-04-28T16:50:12.929Z", - "waitlist_position": 42, - "referral_code": "CVJ88I2X", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 43, - "email": "alexander@screen.net", - "signup_date": "2026-04-29T16:50:12.929Z", - "waitlist_position": 43, - "referral_code": "KM62Q609", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 44, - "email": "abigail@story.dev", - "signup_date": "2026-04-30T16:50:12.929Z", - "waitlist_position": 44, - "referral_code": "X4UY6LMM", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - }, - { - "id": 45, - "email": "daniel@scribe.app", - "signup_date": "2026-05-01T16:50:12.929Z", - "waitlist_position": 45, - "referral_code": "MMK1DXFC", - "referred_by_code": "", - "referrals_count": 0, - "skipped_line": false - } -] \ No newline at end of file diff --git a/shared/shieldai-workflow.md b/shared/shieldai-workflow.md deleted file mode 100644 index 19cd8c76c..000000000 --- a/shared/shieldai-workflow.md +++ /dev/null @@ -1,83 +0,0 @@ -# ShieldAI Code Review Workflow - -## Current State (as of May 2, 2026) - -### PR Backlog Status -- **Open PRs**: 0 (pending commits pushed to master) -- **Pending commits**: 1 commit pushed (FRE-4604) — remaining 6 were previously pushed -- **Last review cycle**: FRE-4500, FRE-4499, FRE-4612 (security findings — all done) -- **Branch protection**: Configured (see `branch-protection-rules.yaml`) -- **PR template**: Configured (`.gitea/pull_request_templates/default.md`) - -### Resolved Bottlenecks -1. ✅ PR-based workflow established with PR template -2. ✅ Branch protection rules documented and configured -3. ✅ Code review checklist integrated into PR template -4. ✅ Security review findings integrated (FRE-4499, FRE-4500, FRE-4612 all done) - -## PR Process - -1. **Feature branch creation** from `gt/master` -2. **Development commits** with conventional commit format (include issue ID: `FRE-XXXX: description`) -3. **PR creation** against `gt/master` -4. **Required reviews**: - - Code Reviewer — all PRs - - Security Reviewer — for security-sensitive changes -5. **CI checks** pass (lint, typecheck, test) -6. **Merge** via squash or rebase - -### Code Review Checklist - -- [ ] Security impact assessment -- [ ] Test coverage verification -- [ ] Type checking (TypeScript) -- [ ] Linting compliance -- [ ] Documentation updates -- [ ] Breaking changes documented -- [ ] Backward compatibility verified - -### Branch Protection Rules - -See `branch-protection-rules.yaml` for the full configuration. Summary: - -- **Protected branch**: `gt/master` -- **Required reviews**: 1 approved review before merge -- **Required status checks**: lint, typecheck, test -- **Enforce admins**: false (admins can bypass during emergencies) -- **Allow force pushes**: true (for recovery scenarios) - -## Review Assignment Policy - -| Change Type | Required Reviewers | -|-------------|-------------------| -| General code | Code Reviewer | -| Security-critical | Code Reviewer + Security Reviewer | -| API contracts | Code Reviewer + CTO | -| Database schema | Code Reviewer + Senior Engineer | - -## Review Pipeline - -``` -Engineer implements → marks in_review → Security Reviewer reviews → Code Reviewer reviews → Done -``` - -## Metrics to Track - -- PR cycle time (creation to merge) -- Review turnaround time -- PR size (lines changed) -- Review comments per PR -- Merge conflict frequency - -## Contribution Guidelines - -1. Always create a feature branch from `gt/master` -2. Use conventional commit format: `type(scope): description (FRE-XXXX)` -3. Include tests for new functionality -4. Update documentation for API changes -5. Run lint and typecheck before pushing -6. Create PR with filled template before requesting review -7. Address all review comments before merge - ---- -*Updated from FRE-4556 audit, implemented in FRE-4661* diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 0a650a50b..000000000 --- a/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const APP_NAME = 'Lendair'; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index e61ad46a7..000000000 --- a/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src/**/*.ts", "src/**/*.tsx"] -} diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 143464a4b..000000000 --- a/vercel.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "buildCommand": "npm run build", - "outputDirectory": "dist", - "devCommand": "npm run dev", - "installCommand": "npm install", - "framework": "vite", - "regions": ["iad"], - "env": { - "TURSO_DATABASE_URL": "${TURSO_DATABASE_URL}", - "TURSO_AUTH_TOKEN": "${TURSO_AUTH_TOKEN}", - "CLERK_SECRET_KEY": "${CLERK_SECRET_KEY}", - "VITE_CLERK_PUBLISHABLE_KEY": "${VITE_CLERK_PUBLISHABLE_KEY}", - "STRIPE_SECRET_KEY": "${STRIPE_SECRET_KEY}", - "VITE_STRIPE_PUBLISHABLE_KEY": "${VITE_STRIPE_PUBLISHABLE_KEY}", - "STRIPE_PRICE_ID_STANDARD": "${STRIPE_PRICE_ID_STANDARD}", - "STRIPE_PRICE_ID_UNLIMITED": "${STRIPE_PRICE_ID_UNLIMITED}", - "S3_ENDPOINT": "${S3_ENDPOINT}", - "S3_ACCESS_KEY": "${S3_ACCESS_KEY}", - "S3_SECRET_KEY": "${S3_SECRET_KEY}", - "S3_BUCKET": "${S3_BUCKET}", - "APP_URL": "${APP_URL}" - }, - "build": { - "env": { - "NODE_ENV": "production" - } - } -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 78e7e46e7..000000000 --- a/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - build: { - outDir: 'dist', - }, -});