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:
2026-05-10 12:12:06 -04:00
parent d74f65b9d5
commit 97d246e98e
91 changed files with 124 additions and 10622 deletions

View File

@@ -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:4508: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:3412: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

View File

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

View File

@@ -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 }}

View File

@@ -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 }}

View File

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

View File

@@ -1,11 +0,0 @@
import SwiftUI
import Lendair
@main
struct LendairApp: SwiftUI.App {
var body: some Scene {
WindowGroup {
MainTabView()
}
}
}

View File

@@ -1,7 +0,0 @@
import XCTest
class LendairTests: XCTestCase {
func testPackageLoads() throws {
XCTAssertTrue(true)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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?
}

View File

@@ -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
}

View File

@@ -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"
)
]
)

View File

@@ -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.

View File

@@ -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))"
}
}
}

View File

@@ -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))"
}
}
}

View File

@@ -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))"
}
}
}

View File

@@ -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))"
}
}
}

View File

@@ -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))"
}
}
}

View File

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

View File

@@ -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))"
}
}
}

View File

@@ -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)"
}
}
}

View File

@@ -1,8 +0,0 @@
import Foundation
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case patch = "PATCH"
case delete = "DELETE"
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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
}
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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()
}

View File

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

View File

@@ -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()
}

View File

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

View File

@@ -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()
}

View File

@@ -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()
)
}

View File

@@ -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()
}

View File

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

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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
}

View File

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

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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()
)
}

View File

@@ -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()
}

View File

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

View File

@@ -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")
}
}

View File

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

View File

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

View File

@@ -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>/`

View File

@@ -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>/`

View File

@@ -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>/`

View File

@@ -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:

View File

@@ -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.

View File

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

View File

@@ -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>/`

View File

@@ -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>/`

View File

@@ -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>/`

View File

@@ -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>/`

View File

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

View File

@@ -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!

View File

@@ -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]

View File

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

View File

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

View File

@@ -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!

View File

@@ -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 125, signed up March 18 April 11. Sorted by `signup_date ASC`.
### General — 20 people (remaining)
IDs 2645, 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) |

View File

@@ -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,,
1 id,name,email,tier,signup_date,referrals,status,confirmed,upvoted,notes
2 1,,alex@writer.com,Active,2026-03-18,0,pending,,,Earliest signup
3 2,,jordan@screenplay.io,Active,2026-03-19,1,pending,,,Referred riley; potential VIP
4 3,,taylor@scripts.com,Active,2026-03-20,5,pending,,,Highest referrals; potential VIP
5 4,,morgan@films.dev,Active,2026-03-21,2,pending,,,Referred avery; potential VIP
6 5,,casey@write.net,Active,2026-03-22,5,pending,,,High referrals; potential VIP
7 6,,riley@producer.app,Active,2026-03-23,0,pending,,,Referred by jordan
8 7,,quinn@story.co,Active,2026-03-24,0,pending,,,Referred by taylor
9 8,,avery@drama.io,Active,2026-03-25,0,pending,,,Referred by morgan
10 9,,blake@scene.work,Active,2026-03-26,0,pending,,
11 10,,cameron@reel.dev,Active,2026-03-27,0,pending,,
12 11,,dakota@plot.net,Active,2026-03-28,0,pending,,
13 12,,ellis@cinema.app,Active,2026-03-29,0,pending,,
14 13,,finley@scribe.com,Active,2026-03-30,0,pending,,
15 14,,harper@draft.io,Active,2026-03-31,0,pending,,
16 15,,jay@narrative.dev,Active,2026-04-01,0,pending,,
17 16,,kendall@chapter.net,Active,2026-04-02,0,pending,,
18 17,,logan@verse.app,Active,2026-04-03,0,pending,,
19 18,,mason@script.co,Active,2026-04-04,0,pending,,
20 19,,parker@film.io,Active,2026-04-05,0,pending,,
21 20,,reece@story.dev,Active,2026-04-06,0,pending,,
22 21,,sam@writing.net,Active,2026-04-07,0,pending,,
23 22,,shawn@page.app,Active,2026-04-08,0,pending,,
24 23,,sydney@scene.dev,Active,2026-04-09,0,pending,,
25 24,,wade@plot.io,Active,2026-04-10,0,pending,,
26 25,,zane@drama.net,Active,2026-04-11,0,pending,,
27 26,,emma@screen.app,General,2026-04-12,0,pending,,
28 27,,liam@scripting.dev,General,2026-04-13,0,pending,,
29 28,,olivia@write.io,General,2026-04-14,0,pending,,
30 29,,noah@story.net,General,2026-04-15,0,pending,,
31 30,,ava@scribe.dev,General,2026-04-16,0,pending,,
32 31,,william@draft.app,General,2026-04-17,0,pending,,
33 32,,sophia@reel.net,General,2026-04-18,0,pending,,
34 33,,james@narrative.io,General,2026-04-19,0,pending,,
35 34,,mia@chapter.dev,General,2026-04-20,0,pending,,
36 35,,benjamin@verse.app,General,2026-04-21,0,pending,,
37 36,,charlotte@script.dev,General,2026-04-22,0,pending,,
38 37,,elijah@film.io,General,2026-04-23,0,pending,,
39 38,,amelia@writing.net,General,2026-04-24,0,pending,,
40 39,,lucas@page.app,General,2026-04-25,0,pending,,
41 40,,harper@scene.dev,General,2026-04-26,0,pending,,
42 41,,henry@plot.net,General,2026-04-27,0,pending,,
43 42,,evelyn@drama.io,General,2026-04-28,0,pending,,
44 43,,alexander@screen.net,General,2026-04-29,0,pending,,
45 44,,abigail@story.dev,General,2026-04-30,0,pending,,
46 45,,daniel@scribe.app,General,2026-05-01,0,pending,,

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "FrenoCorp",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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"
}
}

View File

@@ -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();
}

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -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);
});

View File

@@ -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"

View File

@@ -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 ==="

View File

@@ -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.

View File

@@ -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
}
]
}

View File

@@ -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 email signup_date waitlist_position referral_code referred_by_code referrals_count skipped_line
2 alex@writer.com 2026-03-18T16:50:12.929Z 1 7HEWI7WE 0 false
3 jordan@screenplay.io 2026-03-19T16:50:12.929Z 2 S0CLWRLR 1 true
4 taylor@scripts.com 2026-03-20T16:50:12.929Z 3 LFLE2GFG 5 false
5 morgan@films.dev 2026-03-21T16:50:12.929Z 4 F4TGMS0X 2 false
6 casey@write.net 2026-03-22T16:50:12.929Z 5 UKJTK9SL 5 true
7 riley@producer.app 2026-03-23T16:50:12.929Z 6 RB8N363R S0CLWRLR 0 false
8 quinn@story.co 2026-03-24T16:50:12.929Z 7 GPBVR7JF LFLE2GFG 0 false
9 avery@drama.io 2026-03-25T16:50:12.929Z 8 DAXXIC6R F4TGMS0X 0 false
10 blake@scene.work 2026-03-26T16:50:12.929Z 9 I86T59OU 0 false
11 cameron@reel.dev 2026-03-27T16:50:12.929Z 10 C9XGU8IN 0 false
12 dakota@plot.net 2026-03-28T16:50:12.929Z 11 701QTQSL 0 false
13 ellis@cinema.app 2026-03-29T16:50:12.929Z 12 QPXTRBC5 0 false
14 finley@scribe.com 2026-03-30T16:50:12.929Z 13 D141O1A1 0 false
15 harper@draft.io 2026-03-31T16:50:12.929Z 14 S2SQRLGV 0 false
16 jay@narrative.dev 2026-04-01T16:50:12.929Z 15 PJASWDPK 0 false
17 kendall@chapter.net 2026-04-02T16:50:12.929Z 16 2KDBNK5G 0 false
18 logan@verse.app 2026-04-03T16:50:12.929Z 17 CG70Y4CE 0 false
19 mason@script.co 2026-04-04T16:50:12.929Z 18 SGFWW3XQ 0 false
20 parker@film.io 2026-04-05T16:50:12.929Z 19 6JKZ1DH6 0 false
21 reece@story.dev 2026-04-06T16:50:12.929Z 20 EJPKTCV1 0 false
22 sam@writing.net 2026-04-07T16:50:12.929Z 21 XR8VRM7Q 0 false
23 shawn@page.app 2026-04-08T16:50:12.929Z 22 8WYWCBR5 0 false
24 sydney@scene.dev 2026-04-09T16:50:12.929Z 23 36UMEIJ8 0 false
25 wade@plot.io 2026-04-10T16:50:12.929Z 24 XD4ZJOMI 0 false
26 zane@drama.net 2026-04-11T16:50:12.929Z 25 ZLGT1U1F 0 false
27 emma@screen.app 2026-04-12T16:50:12.929Z 26 QR8H82G1 0 false
28 liam@scripting.dev 2026-04-13T16:50:12.929Z 27 0VYL89TN 0 false
29 olivia@write.io 2026-04-14T16:50:12.929Z 28 TAD3HTAG 0 false
30 noah@story.net 2026-04-15T16:50:12.929Z 29 4P789R09 0 false
31 ava@scribe.dev 2026-04-16T16:50:12.929Z 30 5O8N0G0A 0 false
32 william@draft.app 2026-04-17T16:50:12.929Z 31 GVYK4OII 0 false
33 sophia@reel.net 2026-04-18T16:50:12.929Z 32 ZCG8CZSH 0 false
34 james@narrative.io 2026-04-19T16:50:12.929Z 33 IQMG11BT 0 false
35 mia@chapter.dev 2026-04-20T16:50:12.929Z 34 OUCRPVBR 0 false
36 benjamin@verse.app 2026-04-21T16:50:12.929Z 35 CPIN2GNJ 0 false
37 charlotte@script.dev 2026-04-22T16:50:12.929Z 36 60G8M0QF 0 false
38 elijah@film.io 2026-04-23T16:50:12.929Z 37 OE4XG5DK 0 false
39 amelia@writing.net 2026-04-24T16:50:12.929Z 38 0R3D78QL 0 false
40 lucas@page.app 2026-04-25T16:50:12.929Z 39 L8ZGQF4W 0 false
41 harper@scene.dev 2026-04-26T16:50:12.929Z 40 T12HPWFM 0 false
42 henry@plot.net 2026-04-27T16:50:12.929Z 41 QMPP9SU1 0 false
43 evelyn@drama.io 2026-04-28T16:50:12.929Z 42 CVJ88I2X 0 false
44 alexander@screen.net 2026-04-29T16:50:12.929Z 43 KM62Q609 0 false
45 abigail@story.dev 2026-04-30T16:50:12.929Z 44 X4UY6LMM 0 false
46 daniel@scribe.app 2026-05-01T16:50:12.929Z 45 MMK1DXFC 0 false

View File

@@ -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
}
]

View File

@@ -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*

View File

@@ -1 +0,0 @@
export const APP_NAME = 'Lendair';

View File

@@ -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"]
}

View File

@@ -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"
}
}
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist',
},
});