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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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
|
||||
13
.env.example
13
.env.example
@@ -1,13 +0,0 @@
|
||||
# Turso Database Configuration
|
||||
TURSO_DATABASE_URL=libsql://<region>-<project>.turso.io
|
||||
TURSO_AUTH_TOKEN=<auth-token>
|
||||
|
||||
# Backup Configuration (optional)
|
||||
BACKUP_INTERVAL_MS=86400000
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
BACKUP_REGION=us-east
|
||||
|
||||
# Clerk Authentication
|
||||
VITE_CLERK_PUBLISHABLE_KEY=pk_<your-publishable-key>
|
||||
VITE_CLERK_SIGN_IN_URL=/sign-in
|
||||
VITE_CLERK_SIGN_UP_URL=/sign-up
|
||||
162
.github/workflows/ios-ci.yml
vendored
162
.github/workflows/ios-ci.yml
vendored
@@ -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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>compileBitcode</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
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 }}
|
||||
81
.github/workflows/load-testing.yml
vendored
81
.github/workflows/load-testing.yml
vendored
@@ -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 }}
|
||||
107
.github/workflows/web-ci.yml
vendored
107
.github/workflows/web-ci.yml
vendored
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
import Lendair
|
||||
|
||||
@main
|
||||
struct LendairApp: SwiftUI.App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainTabView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
class LendairTests: XCTestCase {
|
||||
func testPackageLoads() throws {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String>
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case patch = "PATCH"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/<role>/`
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
## Oversight Responsibilities
|
||||
|
||||
As CTO, you must:
|
||||
|
||||
@@ -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
|
||||
- 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
@@ -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/<role>/`
|
||||
|
||||
13
index.html
13
index.html
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lendair</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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!
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!
|
||||
@@ -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) |
|
||||
@@ -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,,
|
||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "FrenoCorp",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
19
package.json
19
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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 ==="
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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*
|
||||
@@ -1 +0,0 @@
|
||||
export const APP_NAME = 'Lendair';
|
||||
@@ -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"]
|
||||
}
|
||||
28
vercel.json
28
vercel.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user