From 88d57a338947e4ad02a0aabd7a085018715650f1 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Sun, 3 May 2026 19:10:34 -0400 Subject: [PATCH] Add Phase 2 community features: clubs and challenges (FRE-4664) Implement full MVVM stack for two new community features: Clubs: - Persistent runner groups with type, privacy, and member management - Club discovery, creation, join/leave, and invite workflows - Member roles (Owner, Admin, Member) and capacity limits Challenges: - Time-bound competitive goals with progress tracking and leaderboards - Challenge types: distance, time, frequency, elevation, calories, streak - Progress submission, participation status, and ranking Files: - Models: Club.swift, Challenge.swift - Services: ClubService.swift, ChallengeService.swift - ViewModels: ClubViewModel.swift, ChallengeViewModel.swift - Views: ClubsView.swift, ClubDetailView.swift, ChallengesView.swift, ChallengeDetailView.swift - Tests: ClubServiceTests.swift, ChallengeServiceTests.swift - Updated README.md with new feature documentation --- Lendair/Models/Challenge.swift | 315 +++++++++++++ Lendair/Models/Club.swift | 275 ++++++++++++ Lendair/README.md | 67 ++- Lendair/Services/ChallengeService.swift | 183 ++++++++ Lendair/Services/ClubService.swift | 179 ++++++++ Lendair/ViewModels/ChallengeViewModel.swift | 165 +++++++ Lendair/ViewModels/ClubViewModel.swift | 156 +++++++ Lendair/Views/ChallengeDetailView.swift | 272 +++++++++++ Lendair/Views/ChallengesView.swift | 262 +++++++++++ Lendair/Views/ClubDetailView.swift | 276 ++++++++++++ Lendair/Views/ClubsView.swift | 232 ++++++++++ LendairTests/ChallengeServiceTests.swift | 425 ++++++++++++++++++ LendairTests/ClubServiceTests.swift | 329 ++++++++++++++ agents/ceo/memory/2026-05-02.md | 10 + agents/ceo/memory/2026-05-03.md | 6 + .../product-hunt-launch-june-2026/items.yaml | 51 +++ .../product-hunt-launch-june-2026/summary.md | 88 ++-- agents/cmo/memory/2026-05-03.md | 42 ++ .../areas/company/security-reviewer-risk.md | 16 + agents/cto/memory/2026-05-02.md | 22 + agents/cto/memory/2026-05-03.md | 41 ++ .../life/projects/lendair-ios/items.yaml | 37 ++ .../life/projects/lendair-ios/summary.md | 54 +++ agents/founding-engineer/memory/2026-05-02.md | 286 ++++++++++++ agents/founding-engineer/memory/2026-05-03.md | 90 ++++ agents/founding-engineer/memory/2026-06-01.md | 102 +++++ agents/security-reviewer/memory/2026-05-03.md | 57 +++ agents/senior-engineer/memory/2026-05-03.md | 31 ++ package-lock.json | 6 + 29 files changed, 4012 insertions(+), 63 deletions(-) create mode 100644 Lendair/Models/Challenge.swift create mode 100644 Lendair/Models/Club.swift create mode 100644 Lendair/Services/ChallengeService.swift create mode 100644 Lendair/Services/ClubService.swift create mode 100644 Lendair/ViewModels/ChallengeViewModel.swift create mode 100644 Lendair/ViewModels/ClubViewModel.swift create mode 100644 Lendair/Views/ChallengeDetailView.swift create mode 100644 Lendair/Views/ChallengesView.swift create mode 100644 Lendair/Views/ClubDetailView.swift create mode 100644 Lendair/Views/ClubsView.swift create mode 100644 LendairTests/ChallengeServiceTests.swift create mode 100644 LendairTests/ClubServiceTests.swift create mode 100644 agents/ceo/memory/2026-05-02.md create mode 100644 agents/cmo/memory/2026-05-03.md create mode 100644 agents/cto/life/areas/company/security-reviewer-risk.md create mode 100644 agents/cto/memory/2026-05-02.md create mode 100644 agents/cto/memory/2026-05-03.md create mode 100644 agents/founding-engineer/life/projects/lendair-ios/items.yaml create mode 100644 agents/founding-engineer/life/projects/lendair-ios/summary.md create mode 100644 agents/founding-engineer/memory/2026-05-02.md create mode 100644 agents/founding-engineer/memory/2026-05-03.md create mode 100644 agents/security-reviewer/memory/2026-05-03.md create mode 100644 package-lock.json diff --git a/Lendair/Models/Challenge.swift b/Lendair/Models/Challenge.swift new file mode 100644 index 000000000..0ca64ac54 --- /dev/null +++ b/Lendair/Models/Challenge.swift @@ -0,0 +1,315 @@ +import Foundation +import SwiftUI + +// MARK: - Challenge + +struct Challenge: Identifiable, Equatable, Codable { + let id: String + let title: String + let description: String + let challengeType: ChallengeType + var status: ChallengeStatus + let startDate: Date + let endDate: Date + let targetMetric: ChallengeMetric + let targetValue: Double + let targetUnit: String + var participantCount: Int + let rules: String? + let imageUrl: String? + let createdBy: String + let createdByName: String + let clubId: String? + var participationStatus: ParticipationStatus + var userProgress: Double? + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id, title, description, challengeType, status, startDate, endDate, targetMetric, targetValue, targetUnit, participantCount, rules, imageUrl, createdBy, createdByName, clubId, participationStatus, userProgress, createdAt + } + + init( + id: String, + title: String, + description: String, + challengeType: ChallengeType, + status: ChallengeStatus, + startDate: Date, + endDate: Date, + targetMetric: ChallengeMetric, + targetValue: Double, + targetUnit: String, + participantCount: Int, + rules: String?, + imageUrl: String?, + createdBy: String, + createdByName: String, + clubId: String?, + participationStatus: ParticipationStatus, + userProgress: Double?, + createdAt: Date + ) { + self.id = id + self.title = title + self.description = description + self.challengeType = challengeType + self.status = status + self.startDate = startDate + self.endDate = endDate + self.targetMetric = targetMetric + self.targetValue = targetValue + self.targetUnit = targetUnit + self.participantCount = participantCount + self.rules = rules + self.imageUrl = imageUrl + self.createdBy = createdBy + self.createdByName = createdByName + self.clubId = clubId + self.participationStatus = participationStatus + self.userProgress = userProgress + self.createdAt = createdAt + } + + static func == (lhs: Challenge, rhs: Challenge) -> Bool { + lhs.id == rhs.id && lhs.participationStatus == rhs.participationStatus + } + + var progressPercentage: Double { + guard let progress = userProgress else { return 0 } + return min((progress / targetValue) * 100, 100) + } + + var daysRemaining: Int { + let calendar = Calendar.current + let components = calendar.dateComponents([.day], from: Date(), to: endDate) + return components.day ?? 0 + } + + var isUpcoming: Bool { + startDate > Date() + } + + var isActive: Bool { + Date() >= startDate && Date() <= endDate + } + + var isCompleted: Bool { + endDate < Date() + } +} + +// MARK: - Challenge Type + +enum ChallengeType: String, CaseIterable, Codable { + case distance + case time + case frequency + case elevation + case calories + case streak + + var displayName: String { + switch self { + case .distance: return "Distance" + case .time: return "Time" + case .frequency: return "Frequency" + case .elevation: return "Elevation" + case .calories: return "Calories" + case .streak: return "Streak" + } + } + + var icon: String { + switch self { + case .distance: return "arrow.right.arrow.left" + case .time: return "stopwatch.fill" + case .frequency: return "repeat" + case .elevation: return "mountain.2.fill" + case .calories: return "flame.fill" + case .streak: return "calendar.badge.clock" + } + } + + var color: Color { + switch self { + case .distance: return .blue + case .time: return .orange + case .frequency: return .green + case .elevation: return .brown + case .calories: return .red + case .streak: return .purple + } + } +} + +// MARK: - Challenge Status + +enum ChallengeStatus: String, CaseIterable, Codable { + case upcoming + case active + case completed + case cancelled +} + +// MARK: - Challenge Metric + +enum ChallengeMetric: String, CaseIterable, Codable { + case distance + case time + case frequency + case elevation + case calories + + var unit: String { + switch self { + case .distance: return "km" + case .time: return "min" + case .frequency: return "sessions" + case .elevation: return "m" + case .calories: return "kcal" + } + } + + var displayName: String { + switch self { + case .distance: return "Distance" + case .time: return "Time" + case .frequency: return "Sessions" + case .elevation: return "Elevation" + case .calories: return "Calories" + } + } +} + +// MARK: - Participation Status + +enum ParticipationStatus: String, CaseIterable, Codable { + case participating + case notParticipating + case invited +} + +// MARK: - Challenge Participant + +struct ChallengeParticipant: Identifiable, Codable { + let id: String + let name: String + let avatarUrl: String? + let progress: Double + let rank: Int + let joinedAt: Date +} + +// MARK: - Leaderboard Entry + +struct LeaderboardEntry: Identifiable, Codable { + let id: String + let position: Int + let participantId: String + let participantName: String + let participantAvatarUrl: String? + let progress: Double + let progressPercentage: Double +} + +// MARK: - Progress Submission + +struct ProgressSubmission: Encodable { + let metric: ChallengeMetric + let value: Double + let activityDate: Date +} + +// MARK: - Create Challenge Request + +struct CreateChallengeRequest: Encodable { + let title: String + let description: String + let challengeType: ChallengeType + let startDate: Date + let endDate: Date + let targetMetric: ChallengeMetric + let targetValue: Double + let rules: String? + let clubId: String? +} + +// MARK: - Update Challenge Request + +struct UpdateChallengeRequest: Encodable { + var title: String? + var description: String? + var challengeType: ChallengeType? + var startDate: Date? + var endDate: Date? + var targetMetric: ChallengeMetric? + var targetValue: Double? + var rules: String? + var status: ChallengeStatus? +} + +// MARK: - Challenge Filter + +struct ChallengeFilter: Encodable { + var challengeType: ChallengeType? + var status: ChallengeStatus? + var participationStatus: ParticipationStatus? + var clubId: String? + var limit: Int + var offset: Int + + init( + challengeType: ChallengeType? = nil, + status: ChallengeStatus? = nil, + participationStatus: ParticipationStatus? = nil, + clubId: String? = nil, + limit: Int = 20, + offset: Int = 0 + ) { + self.challengeType = challengeType + self.status = status + self.participationStatus = participationStatus + self.clubId = clubId + self.limit = limit + self.offset = offset + } +} + +// MARK: - API Response Types + +struct ChallengeListResponse: Decodable { + let challenges: [Challenge] + let hasMore: Bool +} + +struct ChallengeDetailResponse: Decodable { + let challenge: Challenge + let participants: [ChallengeParticipant] +} + +struct CreateChallengeResponse: Decodable { + let challenge: Challenge +} + +struct UpdateChallengeResponse: Decodable { + let challenge: Challenge +} + +struct LeaderboardResponse: Decodable { + let entries: [LeaderboardEntry] + let userPosition: Int? + let totalParticipants: Int +} + +struct ParticipationResponse: Decodable { + let success: Bool + let challengeId: String + let status: ParticipationStatus +} + +struct ProgressResponse: Decodable { + let success: Bool + let challengeId: String + let newProgress: Double + let progressPercentage: Double +} diff --git a/Lendair/Models/Club.swift b/Lendair/Models/Club.swift new file mode 100644 index 000000000..dad863bfb --- /dev/null +++ b/Lendair/Models/Club.swift @@ -0,0 +1,275 @@ +import Foundation +import SwiftUI + +// MARK: - Club + +struct Club: Identifiable, Equatable, Codable { + let id: String + let name: String + let description: String + let clubType: ClubType + let privacy: ClubPrivacy + let location: String + let latitude: Double? + let longitude: Double? + var memberCount: Int + let maxMembers: Int? + let imageUrl: String? + let rules: String? + let ownerId: String + let ownerName: String + var membershipStatus: MembershipStatus + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id, name, description, clubType, privacy, location, latitude, longitude, memberCount, maxMembers, imageUrl, rules, ownerId, ownerName, membershipStatus, createdAt + } + + init( + id: String, + name: String, + description: String, + clubType: ClubType, + privacy: ClubPrivacy, + location: String, + latitude: Double?, + longitude: Double?, + memberCount: Int, + maxMembers: Int?, + imageUrl: String?, + rules: String?, + ownerId: String, + ownerName: String, + membershipStatus: MembershipStatus, + createdAt: Date + ) { + self.id = id + self.name = name + self.description = description + self.clubType = clubType + self.privacy = privacy + self.location = location + self.latitude = latitude + self.longitude = longitude + self.memberCount = memberCount + self.maxMembers = maxMembers + self.imageUrl = imageUrl + self.rules = rules + self.ownerId = ownerId + self.ownerName = ownerName + self.membershipStatus = membershipStatus + self.createdAt = createdAt + } + + static func == (lhs: Club, rhs: Club) -> Bool { + lhs.id == rhs.id && lhs.membershipStatus == rhs.membershipStatus + } + + var availableSpots: Int? { + guard let max = maxMembers else { return nil } + return max - memberCount + } + + var isFull: Bool { + guard let max = maxMembers else { return false } + return memberCount >= max + } +} + +// MARK: - Club Type + +enum ClubType: String, CaseIterable, Codable { + case running + case walking + case cycling + case triathlon + case crossfit + case general + + var displayName: String { + switch self { + case .running: return "Running" + case .walking: return "Walking" + case .cycling: return "Cycling" + case .triathlon: return "Triathlon" + case .crossfit: return "CrossFit" + case .general: return "General Fitness" + } + } + + var icon: String { + switch self { + case .running: return "figure.run" + case .walking: return "figure.walk" + case .cycling: return "bicycle" + case .triathlon: return "triangle.fill" + case .crossfit: return "dumbbell.fill" + case .general: return "heart.fill" + } + } + + var color: Color { + switch self { + case .running: return .blue + case .walking: return .green + case .cycling: return .orange + case .triathlon: return .purple + case .crossfit: return .red + case .general: return .indigo + } + } +} + +// MARK: - Club Privacy + +enum ClubPrivacy: String, CaseIterable, Codable { + case publicPrivacy + case privateClub + case invitationOnly + + var displayName: String { + switch self { + case .publicPrivacy: return "Public" + case .privateClub: return "Private" + case .invitationOnly: return "Invitation Only" + } + } + + var icon: String { + switch self { + case .publicPrivacy: return "globe" + case .privateClub: return "lock.fill" + case .invitationOnly: return "mail.fill" + } + } +} + +// MARK: - Membership Status + +enum MembershipStatus: String, CaseIterable, Codable { + case active + case pending + case invited + case left +} + +// MARK: - Club Member + +struct ClubMember: Identifiable, Codable { + let id: String + let name: String + let avatarUrl: String? + let role: MemberRole + let joinedAt: Date + let membershipStatus: MembershipStatus +} + +enum MemberRole: String, CaseIterable, Codable { + case owner + case admin + case member + + var displayName: String { + switch self { + case .owner: return "Owner" + case .admin: return "Admin" + case .member: return "Member" + } + } +} + +// MARK: - Create Club Request + +struct CreateClubRequest: Encodable { + let name: String + let description: String + let clubType: ClubType + let privacy: ClubPrivacy + let location: String + let latitude: Double? + let longitude: Double? + let maxMembers: Int? + let rules: String? +} + +// MARK: - Update Club Request + +struct UpdateClubRequest: Encodable { + var name: String? + var description: String? + var clubType: ClubType? + var privacy: ClubPrivacy? + var location: String? + var latitude: Double? + var longitude: Double? + var maxMembers: Int? + var rules: String? +} + +// MARK: - Club Filter + +struct ClubFilter: Encodable { + var clubType: ClubType? + var privacy: ClubPrivacy? + var membershipStatus: MembershipStatus? + var location: String? + var radiusKm: Double? + var limit: Int + var offset: Int + + init( + clubType: ClubType? = nil, + privacy: ClubPrivacy? = nil, + membershipStatus: MembershipStatus? = nil, + location: String? = nil, + radiusKm: Double? = nil, + limit: Int = 20, + offset: Int = 0 + ) { + self.clubType = clubType + self.privacy = privacy + self.membershipStatus = membershipStatus + self.location = location + self.radiusKm = radiusKm + self.limit = limit + self.offset = offset + } +} + +// MARK: - API Response Types + +struct ClubListResponse: Decodable { + let clubs: [Club] + let hasMore: Bool +} + +struct ClubDetailResponse: Decodable { + let club: Club + let members: [ClubMember] +} + +struct CreateClubResponse: Decodable { + let club: Club +} + +struct UpdateClubResponse: Decodable { + let club: Club +} + +struct MembershipResponse: Decodable { + let success: Bool + let clubId: String + let status: MembershipStatus +} + +struct InviteMemberResponse: Decodable { + let success: Bool + let clubId: String + let memberId: String +} + +struct RemoveMemberResponse: Decodable { + let success: Bool + let clubId: String + let memberId: String +} diff --git a/Lendair/README.md b/Lendair/README.md index 3d18ed59c..9fa204dae 100644 --- a/Lendair/README.md +++ b/Lendair/README.md @@ -20,21 +20,27 @@ Lendair/ │ ├── Race.swift # Race, RaceType, RaceFilter, API response types │ ├── FamilyPlan.swift # FamilyPlan, FamilyMember, LeaderboardMetric │ ├── BeginnerMode.swift # BeginnerConfig, Milestone, OnboardingStep -│ └── CommunityEvent.swift # CommunityEvent, EventType, RSVPStatus +│ ├── 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 +│ ├── 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 +│ ├── CommunityEventViewModel.swift +│ ├── ClubViewModel.swift +│ └── ChallengeViewModel.swift ├── Views/ │ ├── NotificationsView.swift │ ├── NotificationRowView.swift @@ -47,7 +53,11 @@ Lendair/ │ ├── FamilyMemberView.swift │ ├── BeginnerModeView.swift │ ├── CommunityEventsView.swift -│ └── CommunityEventDetailView.swift +│ ├── CommunityEventDetailView.swift +│ ├── ClubsView.swift +│ ├── ClubDetailView.swift +│ ├── ChallengesView.swift +│ └── ChallengeDetailView.swift └── README.md ``` @@ -95,6 +105,24 @@ Lendair/ - 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 + ## Service Pattern All services follow the same architecture: @@ -154,23 +182,48 @@ All services follow the same architecture: | 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 | + ## 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, and equality +- 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 { - TrainingPlanView() + ClubsView() } NavigationStack { - RaceDiscoveryView() + ChallengesView() } NavigationStack { diff --git a/Lendair/Services/ChallengeService.swift b/Lendair/Services/ChallengeService.swift new file mode 100644 index 000000000..95f9db508 --- /dev/null +++ b/Lendair/Services/ChallengeService.swift @@ -0,0 +1,183 @@ +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? + + init( + baseURL: URL = URL(string: "http://localhost:3000")!, + 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 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(CreateChallengeResponse.self, from: data) + return decoded.challenge + } + + func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge { + let url = baseURL.appendingPathComponent("/api/challenges/\(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(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, 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 ChallengeError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + switch httpResponse.statusCode { + case 401: throw ChallengeError.unauthorized + case 403: throw ChallengeError.forbidden + case 404: throw ChallengeError.notFound + case 429: throw ChallengeError.rateLimited + case 500...599: throw ChallengeError.serverError(httpResponse.statusCode) + default: throw ChallengeError.httpError(httpResponse.statusCode) + } + } + } +} + +// MARK: - Error Types + +enum ChallengeError: LocalizedError { + case invalidResponse + case unauthorized + case forbidden + case notFound + case rateLimited + case serverError(Int) + case httpError(Int) + + var errorDescription: String { + switch self { + case .invalidResponse: return "Invalid server response" + case .unauthorized: return "Unauthorized — please log in again" + case .forbidden: return "Forbidden — check permissions" + case .notFound: return "Challenge not found" + case .rateLimited: return "Too many requests — try again shortly" + case .serverError(let code): return "Server error (\(code))" + case .httpError(let code): return "HTTP error (\(code))" + } + } +} diff --git a/Lendair/Services/ClubService.swift b/Lendair/Services/ClubService.swift new file mode 100644 index 000000000..d08c74193 --- /dev/null +++ b/Lendair/Services/ClubService.swift @@ -0,0 +1,179 @@ +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? + + init( + baseURL: URL = URL(string: "http://localhost:3000")!, + 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 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(CreateClubResponse.self, from: data) + return decoded.club + } + + func updateClub(id: String, request: UpdateClubRequest) async throws -> Club { + let url = baseURL.appendingPathComponent("/api/clubs/\(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(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, 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 ClubError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + switch httpResponse.statusCode { + case 401: throw ClubError.unauthorized + case 403: throw ClubError.forbidden + case 404: throw ClubError.notFound + case 429: throw ClubError.rateLimited + case 500...599: throw ClubError.serverError(httpResponse.statusCode) + default: throw ClubError.httpError(httpResponse.statusCode) + } + } + } +} + +// MARK: - Error Types + +enum ClubError: LocalizedError { + case invalidResponse + case unauthorized + case forbidden + case notFound + case rateLimited + case serverError(Int) + case httpError(Int) + + var errorDescription: String { + switch self { + case .invalidResponse: return "Invalid server response" + case .unauthorized: return "Unauthorized — please log in again" + case .forbidden: return "Forbidden — check permissions" + case .notFound: return "Club not found" + case .rateLimited: return "Too many requests — try again shortly" + case .serverError(let code): return "Server error (\(code))" + case .httpError(let code): return "HTTP error (\(code))" + } + } +} diff --git a/Lendair/ViewModels/ChallengeViewModel.swift b/Lendair/ViewModels/ChallengeViewModel.swift new file mode 100644 index 000000000..353e51d0b --- /dev/null +++ b/Lendair/ViewModels/ChallengeViewModel.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftUI + +@MainActor +class ChallengeViewModel: ObservableObject { + @Published var challenges: [Challenge] = [] + @Published var selectedChallenge: Challenge? + @Published var leaderboard: [LeaderboardEntry] = [] + @Published var isLoading: Bool = false + @Published var error: ChallengeError? + @Published var filter: ChallengeFilter = ChallengeFilter() + + private let service: ChallengeServiceProtocol + + init(service: ChallengeServiceProtocol = ChallengeService()) { + self.service = service + } + + func fetchChallenges() async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + challenges = try await service.listChallenges(filter: filter) + } catch let error as ChallengeError { + self.error = error + } catch { + print("Failed to fetch challenges: \(error)") + } + } + + func selectChallenge(id: String) async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let result = try await service.getChallenge(id: id) + selectedChallenge = result.challenge + if let index = challenges.firstIndex(where: { $0.id == id }) { + challenges[index] = result.challenge + objectWillChange.send() + } + } catch let error as ChallengeError { + self.error = error + } catch { + print("Failed to get challenge: \(error)") + } + } + + func createChallenge(request: CreateChallengeRequest) async -> Challenge? { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let challenge = try await service.createChallenge(request: request) + challenges.insert(challenge, at: 0) + objectWillChange.send() + return challenge + } catch let error as ChallengeError { + self.error = error + return nil + } catch { + print("Failed to create challenge: \(error)") + return nil + } + } + + func updateChallenge(id: String, request: UpdateChallengeRequest) async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let updatedChallenge = try await service.updateChallenge(id: id, request: request) + if let index = challenges.firstIndex(where: { $0.id == id }) { + challenges[index] = updatedChallenge + objectWillChange.send() + } + if selectedChallenge?.id == id { + selectedChallenge = updatedChallenge + } + } catch let error as ChallengeError { + self.error = error + } catch { + print("Failed to update challenge: \(error)") + } + } + + func joinChallenge(id: String) async { + do { + try await service.joinChallenge(id: id) + if let index = challenges.firstIndex(where: { $0.id == id }) { + challenges[index].participationStatus = .participating + challenges[index].participantCount += 1 + objectWillChange.send() + } + } catch { + print("Failed to join challenge: \(error)") + } + } + + func leaveChallenge(id: String) async { + do { + try await service.leaveChallenge(id: id) + if let index = challenges.firstIndex(where: { $0.id == id }) { + challenges[index].participationStatus = .notParticipating + challenges[index].participantCount = max(0, challenges[index].participantCount - 1) + objectWillChange.send() + } + } catch { + print("Failed to leave challenge: \(error)") + } + } + + func fetchLeaderboard(challengeId: String) async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + leaderboard = try await service.getLeaderboard(challengeId: challengeId) + } catch let error as ChallengeError { + self.error = error + } catch { + print("Failed to fetch leaderboard: \(error)") + } + } + + func submitProgress(challengeId: String, progress: ProgressSubmission) async { + do { + let result = try await service.submitProgress(challengeId: challengeId, progress: progress) + if let index = challenges.firstIndex(where: { $0.id == challengeId }) { + challenges[index].userProgress = result.progress + objectWillChange.send() + } + if selectedChallenge?.id == challengeId { + selectedChallenge?.userProgress = result.progress + } + } catch { + print("Failed to submit progress: \(error)") + } + } + + var activeChallenges: [Challenge] { + challenges.filter { $0.isActive }.sorted { $0.endDate < $1.endDate } + } + + var upcomingChallenges: [Challenge] { + challenges.filter { $0.isUpcoming }.sorted { $0.startDate < $1.startDate } + } + + var completedChallenges: [Challenge] { + challenges.filter { $0.isCompleted }.sorted { $0.endDate > $1.endDate } + } + + var userChallenges: [Challenge] { + challenges.filter { $0.participationStatus == .participating } + } + + var challengeTypes: [ChallengeType] { ChallengeType.allCases } + var challengeStatuses: [ChallengeStatus] { ChallengeStatus.allCases } +} diff --git a/Lendair/ViewModels/ClubViewModel.swift b/Lendair/ViewModels/ClubViewModel.swift new file mode 100644 index 000000000..30da024d6 --- /dev/null +++ b/Lendair/ViewModels/ClubViewModel.swift @@ -0,0 +1,156 @@ +import Foundation +import SwiftUI + +@MainActor +class ClubViewModel: ObservableObject { + @Published var clubs: [Club] = [] + @Published var selectedClub: Club? + @Published var members: [ClubMember] = [] + @Published var isLoading: Bool = false + @Published var error: ClubError? + @Published var filter: ClubFilter = ClubFilter() + + private let service: ClubServiceProtocol + + init(service: ClubServiceProtocol = ClubService()) { + self.service = service + } + + func fetchClubs() async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + clubs = try await service.listClubs(filter: filter) + } catch let error as ClubError { + self.error = error + } catch { + print("Failed to fetch clubs: \(error)") + } + } + + func selectClub(id: String) async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let result = try await service.getClub(id: id) + selectedClub = result.club + members = result.members + if let index = clubs.firstIndex(where: { $0.id == id }) { + clubs[index] = result.club + objectWillChange.send() + } + } catch let error as ClubError { + self.error = error + } catch { + print("Failed to get club: \(error)") + } + } + + func createClub(request: CreateClubRequest) async -> Club? { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let club = try await service.createClub(request: request) + clubs.insert(club, at: 0) + objectWillChange.send() + return club + } catch let error as ClubError { + self.error = error + return nil + } catch { + print("Failed to create club: \(error)") + return nil + } + } + + func updateClub(id: String, request: UpdateClubRequest) async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let updatedClub = try await service.updateClub(id: id, request: request) + if let index = clubs.firstIndex(where: { $0.id == id }) { + clubs[index] = updatedClub + objectWillChange.send() + } + if selectedClub?.id == id { + selectedClub = updatedClub + } + } catch let error as ClubError { + self.error = error + } catch { + print("Failed to update club: \(error)") + } + } + + func joinClub(id: String) async { + do { + try await service.joinClub(id: id) + if let index = clubs.firstIndex(where: { $0.id == id }) { + clubs[index].membershipStatus = .active + clubs[index].memberCount += 1 + objectWillChange.send() + } + } catch { + print("Failed to join club: \(error)") + } + } + + func leaveClub(id: String) async { + do { + try await service.leaveClub(id: id) + if let index = clubs.firstIndex(where: { $0.id == id }) { + clubs[index].membershipStatus = .left + clubs[index].memberCount = max(0, clubs[index].memberCount - 1) + objectWillChange.send() + } + } catch { + print("Failed to leave club: \(error)") + } + } + + func inviteMember(clubId: String, email: String) async { + do { + try await service.inviteMember(clubId: clubId, email: email) + } catch { + print("Failed to invite member: \(error)") + } + } + + func removeMember(clubId: String, memberId: String) async { + do { + try await service.removeMember(clubId: clubId, memberId: memberId) + if let index = members.firstIndex(where: { $0.id == memberId }) { + members.remove(at: index) + if let clubIndex = clubs.firstIndex(where: { $0.id == clubId }) { + clubs[clubIndex].memberCount = max(0, clubs[clubIndex].memberCount - 1) + } + objectWillChange.send() + } + } catch { + print("Failed to remove member: \(error)") + } + } + + var publicClubs: [Club] { + clubs.filter { $0.privacy == .publicPrivacy } + } + + var userClubs: [Club] { + clubs.filter { $0.membershipStatus == .active } + } + + var pendingClubs: [Club] { + clubs.filter { $0.membershipStatus == .pending } + } + + var clubTypes: [ClubType] { ClubType.allCases } + var privacyOptions: [ClubPrivacy] { ClubPrivacy.allCases } +} diff --git a/Lendair/Views/ChallengeDetailView.swift b/Lendair/Views/ChallengeDetailView.swift new file mode 100644 index 000000000..c1d9e56cb --- /dev/null +++ b/Lendair/Views/ChallengeDetailView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +struct ChallengeDetailView: View { + let challenge: Challenge + @StateObject private var viewModel = ChallengeViewModel() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + challengeHeader + challengeInfoSection + challengeDescription + progressSection + participationSection + leaderboardSection + } + .padding(.horizontal) + .padding(.bottom, 32) + } + .navigationTitle(challenge.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + Task { + await viewModel.selectChallenge(id: challenge.id) + await viewModel.fetchLeaderboard(challengeId: challenge.id) + } + } + } + + private var challengeHeader: some View { + VStack(spacing: 12) { + HStack { + Image(systemName: challenge.challengeType.icon) + .font(.system(size: 40)) + .foregroundColor(challenge.challengeType.color) + VStack(alignment: .leading, spacing: 4) { + Text(challenge.challengeType.displayName) + .font(.title2) + .fontWeight(.bold) + Text("\(challenge.targetValue) \(challenge.targetUnit) goal") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + } + + Divider() + + HStack(spacing: 20) { + infoItem(label: "Status", value: challenge.status.rawValue.capitalized) + infoItem(label: "Participants", value: "\(challenge.participantCount)") + infoItem(label: "Days Left", value: "\(challenge.daysRemaining)") + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(12) + } + + private func infoItem(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.subheadline) + .fontWeight(.medium) + } + } + + private var challengeInfoSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Challenge Details") + .font(.headline) + VStack(alignment: .leading, spacing: 6) { + detailRow(label: "Type", value: challenge.challengeType.displayName) + detailRow(label: "Metric", value: challenge.targetMetric.displayName) + detailRow(label: "Target", value: "\(challenge.targetValue) \(challenge.targetUnit)") + detailRow(label: "Start", value: formatDate(challenge.startDate)) + detailRow(label: "End", value: formatDate(challenge.endDate)) + detailRow(label: "Creator", value: challenge.createdByName) + } + } + } + + private func detailRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + .frame(width: 100, alignment: .leading) + Text(value) + .font(.subheadline) + .fontWeight(.medium) + } + } + + private var challengeDescription: some View { + VStack(alignment: .leading, spacing: 4) { + Text("About This Challenge") + .font(.headline) + Text(challenge.description) + .font(.subheadline) + .foregroundColor(.secondary) + if let rules = challenge.rules { + Divider() + .padding(.vertical, 4) + Text("Rules") + .font(.headline) + Text(rules) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + private var progressSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Your Progress") + .font(.headline) + + if challenge.participationStatus == .participating { + VStack(spacing: 8) { + HStack { + Text("\(challenge.progressPercentage, specifier: "%.0f")%") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(challenge.challengeType.color) + Spacer() + Text("\(challenge.userProgress ?? 0) / \(challenge.targetValue) \(challenge.targetUnit)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + ProgressView(value: challenge.progressPercentage / 100) + .tint(challenge.challengeType.color) + .frame(height: 8) + } + } else { + Text(challenge.participationStatus == .invited + ? "You've been invited — join to start tracking progress." + : "Join this challenge to track your progress.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + private var participationSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Participation") + .font(.headline) + + HStack(spacing: 12) { + switch challenge.participationStatus { + case .participating: + Button { + Task { + await viewModel.leaveChallenge(id: challenge.id) + } + } label: { + Label("Leave Challenge", systemImage: "flag.on.flag.fill") + .foregroundColor(.red) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.red.opacity(0.15)) + .cornerRadius(8) + + case .invited: + Button { + Task { + await viewModel.joinChallenge(id: challenge.id) + } + } label: { + Label("Accept & Join", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.green.opacity(0.15)) + .cornerRadius(8) + + case .notParticipating: + Button { + Task { + await viewModel.joinChallenge(id: challenge.id) + } + } label: { + Label("Join Challenge", systemImage: "flag.fill") + .foregroundColor(.blue) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.15)) + .cornerRadius(8) + } + } + } + } + } + + private var leaderboardSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Leaderboard") + .font(.headline) + + if viewModel.leaderboard.isEmpty { + Text("No participants yet.") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.vertical, 8) + } else { + ForEach(Array(viewModel.leaderboard.prefix(10), id: \.id)) { entry in + HStack(spacing: 12) { + Text("\(entry.position)") + .font(.caption) + .fontWeight(.bold) + .frame(width: 24) + Circle() + .fill(Color.secondary.opacity(0.2)) + .frame(width: 28, height: 28) + Text(entry.participantName) + .font(.subheadline) + Spacer() + Text("\(entry.progressPercentage, specifier: "%.0f")%") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(entry.position <= 3 ? .orange : .secondary) + } + } + } + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +#Preview { + NavigationView { + ChallengeDetailView(challenge: sampleChallenge) + } +} + +private var sampleChallenge: Challenge { + Challenge( + id: "1", + title: "Monthly 100km Challenge", + description: "Run 100km this month. Track your distance and compete with friends!", + challengeType: .distance, + status: .active, + startDate: Date().addingTimeInterval(-7 * 24 * 3600), + endDate: Date().addingTimeInterval(23 * 24 * 3600), + targetMetric: .distance, + targetValue: 100, + targetUnit: "km", + participantCount: 47, + rules: "All runs count. GPS-tracked activities only.", + imageUrl: nil, + createdBy: "user1", + createdByName: "Sarah Chen", + clubId: nil, + participationStatus: .participating, + userProgress: 42.5, + createdAt: Date().addingTimeInterval(-7 * 24 * 3600) + ) +} \ No newline at end of file diff --git a/Lendair/Views/ChallengesView.swift b/Lendair/Views/ChallengesView.swift new file mode 100644 index 000000000..0e0ff7f23 --- /dev/null +++ b/Lendair/Views/ChallengesView.swift @@ -0,0 +1,262 @@ +import SwiftUI + +struct ChallengesView: View { + @StateObject private var viewModel = ChallengeViewModel() + @State private var showingCreateSheet = false + @State private var selectedTab: ChallengeTab = .active + + 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() + } + } + .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 + @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 + ) + dismiss() + } + .disabled(title.isEmpty || targetValue.isEmpty) + } + } + } + } +} + +struct ChallengeRowView: View { + let challenge: Challenge + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + Image(systemName: challenge.challengeType.icon) + .font(.system(size: 24)) + .foregroundColor(challenge.challengeType.color) + .frame(width: 44, height: 44) + .background(challenge.challengeType.color.opacity(0.15)) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + Text(challenge.title) + .font(.headline) + Text("\(challenge.challengeType.displayName) \u2022 \(challenge.targetValue) \(challenge.targetUnit)") + .font(.subheadline) + .foregroundColor(.secondary) + HStack(spacing: 8) { + Text("\(challenge.participantCount) participants") + .font(.caption) + .foregroundColor(.secondary) + if challenge.daysRemaining > 0 { + Text("\(challenge.daysRemaining) days left") + .font(.caption2) + .foregroundColor(.orange) + } + } + } + + Spacer() + + switch challenge.participationStatus { + case .participating: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .invited: + Image(systemName: "mail.fill") + .foregroundColor(.blue) + case .notParticipating: + Image(systemName: "circle") + .foregroundColor(.secondary) + } + } + + if challenge.participationStatus == .participating { + progressView + } + } + .padding(.vertical, 4) + } + + private var progressView: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("\(challenge.progressPercentage, specifier: "%.0f")%") + .font(.caption2) + .fontWeight(.medium) + Spacer() + Text("\(challenge.userProgress ?? 0)/\(challenge.targetValue) \(challenge.targetUnit)") + .font(.caption2) + .foregroundColor(.secondary) + } + ProgressView(value: challenge.progressPercentage / 100) + .tint(challenge.challengeType.color) + } + .padding(.horizontal, 4) + } +} + +#Preview { + ChallengesView() +} diff --git a/Lendair/Views/ClubDetailView.swift b/Lendair/Views/ClubDetailView.swift new file mode 100644 index 000000000..5da519ef8 --- /dev/null +++ b/Lendair/Views/ClubDetailView.swift @@ -0,0 +1,276 @@ +import SwiftUI + +struct ClubDetailView: View { + let club: Club + @StateObject private var viewModel = ClubViewModel() + @State private var inviteEmail = "" + @State private var showingInviteAlert = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + clubHeader + clubInfoSection + clubDescription + membershipSection + rulesSection + membersSection + } + .padding(.horizontal) + .padding(.bottom, 32) + } + .navigationTitle(club.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if club.membershipStatus == .active { + Menu { + Button("Edit Club") {} + Button("Leave Club", role: .destructive) { + Task { + await viewModel.leaveClub(id: club.id) + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + .onAppear { + Task { + await viewModel.selectClub(id: club.id) + } + } + } + + private var clubHeader: some View { + VStack(spacing: 12) { + HStack { + Image(systemName: club.clubType.icon) + .font(.system(size: 40)) + .foregroundColor(club.clubType.color) + VStack(alignment: .leading, spacing: 4) { + Text(club.clubType.displayName) + .font(.title2) + .fontWeight(.bold) + Text(club.location) + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + } + + Divider() + + HStack(spacing: 20) { + infoItem(label: "Members", value: "\(club.memberCount)") + infoItem(label: "Privacy", value: club.privacy.displayName) + infoItem(label: "Owner", value: club.ownerName) + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(12) + } + + private func infoItem(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.subheadline) + .fontWeight(.medium) + } + } + + private var clubInfoSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Club Details") + .font(.headline) + VStack(alignment: .leading, spacing: 6) { + detailRow(label: "Type", value: club.clubType.displayName) + detailRow(label: "Privacy", value: club.privacy.displayName) + detailRow(label: "Location", value: club.location) + if let max = club.maxMembers { + detailRow(label: "Capacity", value: "\(club.memberCount)/\(max)") + } + detailRow(label: "Joined", value: formatDate(club.createdAt)) + } + } + } + + private func detailRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + .frame(width: 100, alignment: .leading) + Text(value) + .font(.subheadline) + .fontWeight(.medium) + } + } + + private var clubDescription: some View { + VStack(alignment: .leading, spacing: 4) { + Text("About This Club") + .font(.headline) + Text(club.description) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + private var membershipSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Membership") + .font(.headline) + + HStack(spacing: 12) { + switch club.membershipStatus { + case .active: + Button { + Task { + await viewModel.leaveClub(id: club.id) + } + } label: { + Label("Leave Club", systemImage: "door.left.hand.open") + .foregroundColor(.red) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.red.opacity(0.15)) + .cornerRadius(8) + + case .pending: + Label("Joining...", systemImage: "clock.fill") + .foregroundColor(.orange) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.15)) + .cornerRadius(8) + + case .invited: + Button { + Task { + await viewModel.joinClub(id: club.id) + } + } label: { + Label("Accept Invite", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.green.opacity(0.15)) + .cornerRadius(8) + + case .left: + Button { + Task { + await viewModel.joinClub(id: club.id) + } + } label: { + Label("Rejoin Club", systemImage: "arrow.turn.down.right") + .foregroundColor(.blue) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.15)) + .cornerRadius(8) + } + + if club.membershipStatus == .active { + Button { + showingInviteAlert = true + } label: { + Label("Invite", systemImage: "person.crop.circle.badge.plus") + .foregroundColor(.blue) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.15)) + .cornerRadius(8) + } + } + } + } + } + + private var rulesSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Club Rules") + .font(.headline) + if let rules = club.rules { + Text(rules) + .font(.subheadline) + .foregroundColor(.secondary) + } else { + Text("No rules specified.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + private var membersSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Members (\(club.memberCount))") + .font(.headline) + + ForEach(viewModel.members) { member in + HStack(spacing: 12) { + Circle() + .fill(Color.secondary.opacity(0.2)) + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 2) { + Text(member.name) + .font(.subheadline) + Text(member.role.displayName) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Text(member.membershipStatus.rawValue.capitalized) + .font(.caption) + .foregroundColor(member.membershipStatus == .active ? .green : .secondary) + } + } + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +#Preview { + NavigationView { + ClubDetailView(club: sampleClub) + } +} + +private var sampleClub: Club { + Club( + id: "1", + name: "Central Park Runners", + description: "A friendly running club that meets every weekend in Central Park. All levels welcome!", + clubType: .running, + privacy: .publicPrivacy, + location: "Central Park, NYC", + latitude: 40.7851, + longitude: -73.9683, + memberCount: 142, + maxMembers: 200, + imageUrl: nil, + rules: "Be respectful, stay hydrated, and have fun!", + ownerId: "user1", + ownerName: "Alex Johnson", + membershipStatus: .active, + createdAt: Date().addingTimeInterval(-30 * 24 * 3600) + ) +} diff --git a/Lendair/Views/ClubsView.swift b/Lendair/Views/ClubsView.swift new file mode 100644 index 000000000..192861ec2 --- /dev/null +++ b/Lendair/Views/ClubsView.swift @@ -0,0 +1,232 @@ +import SwiftUI + +struct ClubsView: View { + @StateObject private var viewModel = ClubViewModel() + @State private var showingCreateSheet = false + @State private var selectedTab: ClubTab = .discover + + 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() + } + } + .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 + @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 + ) + dismiss() + } + .disabled(name.isEmpty || location.isEmpty) + } + } + } + } +} + +struct ClubRowView: View { + let club: Club + + var body: some View { + HStack(spacing: 12) { + Image(systemName: club.clubType.icon) + .font(.system(size: 24)) + .foregroundColor(club.clubType.color) + .frame(width: 44, height: 44) + .background(club.clubType.color.opacity(0.15)) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + Text(club.name) + .font(.headline) + Text("\(club.location) \u2022 \(club.privacy.displayName)") + .font(.subheadline) + .foregroundColor(.secondary) + HStack(spacing: 8) { + Text("\(club.memberCount) members") + .font(.caption) + .foregroundColor(.secondary) + if let spots = club.availableSpots, spots > 0 { + Text("\(spots) spots left") + .font(.caption2) + .foregroundColor(.green) + } + } + } + + Spacer() + + switch club.membershipStatus { + case .active: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + case .pending: + Image(systemName: "clock.fill") + .foregroundColor(.orange) + case .invited: + Image(systemName: "mail.fill") + .foregroundColor(.blue) + case .left: + Image(systemName: "circle") + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } +} + +#Preview { + ClubsView() +} diff --git a/LendairTests/ChallengeServiceTests.swift b/LendairTests/ChallengeServiceTests.swift new file mode 100644 index 000000000..8870797ba --- /dev/null +++ b/LendairTests/ChallengeServiceTests.swift @@ -0,0 +1,425 @@ +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 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 { + throw ChallengeError.notFound + } + + func joinChallenge(id: String) async throws { + joinCalledIds.append(id) + } + + func leaveChallenge(id: String) async throws { + leaveCalledIds.append(id) + } + + func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] { + return leaderboard + } + + func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) { + return (progress.value, min((progress.value / 100) * 100, 100)) + } +} + +// MARK: - Helper: Sample Challenges + +extension Challenge { + static func sample( + id: String = "test-1", + title: String = "Test Challenge", + challengeType: ChallengeType = .distance, + status: ChallengeStatus = .active, + participationStatus: ParticipationStatus = .participating, + userProgress: Double = 0, + targetValue: Double = 100, + startDate: Date = Date().addingTimeInterval(-7 * 24 * 3600), + endDate: Date = Date().addingTimeInterval(23 * 24 * 3600) + ) -> Challenge { + Challenge( + id: id, + title: title, + description: "Test description", + challengeType: challengeType, + status: status, + startDate: startDate, + endDate: endDate, + targetMetric: .distance, + targetValue: targetValue, + targetUnit: "km", + participantCount: 10, + rules: nil, + imageUrl: nil, + createdBy: "user-1", + createdByName: "Test User", + clubId: nil, + participationStatus: participationStatus, + userProgress: userProgress, + createdAt: Date() + ) + } +} + +// MARK: - ChallengeServiceTests + +final class ChallengeServiceTests: XCTestCase { + // MARK: - Fetch Challenges + + @MainActor + func testFetchChallengesLoadsData() async { + let mock = MockChallengeService() + mock.challenges = [.sample(id: "1"), .sample(id: "2")] + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchChallenges() + + XCTAssertEqual(viewModel.challenges.count, 2) + XCTAssertFalse(viewModel.isLoading) + XCTAssertEqual(mock.listCallCount, 1) + } + + @MainActor + func testFetchChallengesHandlesError() async { + let mock = MockChallengeService() + mock.listError = ChallengeError.unauthorized + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchChallenges() + + XCTAssertTrue(viewModel.challenges.isEmpty) + XCTAssertFalse(viewModel.isLoading) + XCTAssertEqual(viewModel.error, .unauthorized) + } + + // MARK: - Challenge Type Display + + func testChallengeTypeDisplayNames() { + XCTAssertEqual(ChallengeType.distance.displayName, "Distance") + XCTAssertEqual(ChallengeType.time.displayName, "Time") + XCTAssertEqual(ChallengeType.frequency.displayName, "Frequency") + XCTAssertEqual(ChallengeType.elevation.displayName, "Elevation") + XCTAssertEqual(ChallengeType.calories.displayName, "Calories") + XCTAssertEqual(ChallengeType.streak.displayName, "Streak") + } + + func testChallengeTypeIcons() { + XCTAssertEqual(ChallengeType.distance.icon, "arrow.right.arrow.left") + XCTAssertEqual(ChallengeType.time.icon, "stopwatch.fill") + XCTAssertEqual(ChallengeType.frequency.icon, "repeat") + XCTAssertEqual(ChallengeType.elevation.icon, "mountain.2.fill") + XCTAssertEqual(ChallengeType.calories.icon, "flame.fill") + XCTAssertEqual(ChallengeType.streak.icon, "calendar.badge.clock") + } + + func testChallengeMetricUnits() { + XCTAssertEqual(ChallengeMetric.distance.unit, "km") + XCTAssertEqual(ChallengeMetric.time.unit, "min") + XCTAssertEqual(ChallengeMetric.frequency.unit, "sessions") + XCTAssertEqual(ChallengeMetric.elevation.unit, "m") + XCTAssertEqual(ChallengeMetric.calories.unit, "kcal") + } + + // MARK: - Challenge Time States + + func testChallengeIsUpcoming() { + let future = Challenge.sample( + id: "1", + startDate: Date().addingTimeInterval(7 * 24 * 3600), + endDate: Date().addingTimeInterval(37 * 24 * 3600) + ) + XCTAssertTrue(future.isUpcoming) + } + + func testChallengeIsActive() { + let active = Challenge.sample(id: "1") + XCTAssertTrue(active.isActive) + } + + func testChallengeIsCompleted() { + let past = Challenge.sample( + id: "1", + startDate: Date().addingTimeInterval(-30 * 24 * 3600), + endDate: Date().addingTimeInterval(-7 * 24 * 3600) + ) + XCTAssertTrue(past.isCompleted) + } + + // MARK: - Progress Percentage + + func testProgressPercentage() { + var challenge = Challenge.sample(id: "1", userProgress: 50, targetValue: 100) + XCTAssertEqual(challenge.progressPercentage, 50) + } + + func testProgressPercentageOverTarget() { + var challenge = Challenge.sample(id: "1", userProgress: 120, targetValue: 100) + XCTAssertEqual(challenge.progressPercentage, 100) + } + + func testProgressPercentageNoProgress() { + var challenge = Challenge.sample(id: "1", userProgress: 0, targetValue: 100) + XCTAssertEqual(challenge.progressPercentage, 0) + } + + func testProgressPercentageNilProgress() { + var challenge = Challenge.sample(id: "1", userProgress: nil, targetValue: 100) + XCTAssertEqual(challenge.progressPercentage, 0) + } + + // MARK: - Days Remaining + + func testDaysRemaining() { + let challenge = Challenge.sample( + id: "1", + endDate: Date().addingTimeInterval(5 * 24 * 3600) + ) + XCTAssertGreaterThan(challenge.daysRemaining, 0) + } + + // MARK: - Computed Filters + + @MainActor + func testActiveChallengesFiltersCorrectly() async { + let mock = MockChallengeService() + mock.challenges = [ + Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), + Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)), + Challenge.sample(id: "3", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)), + ] + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchChallenges() + + XCTAssertEqual(viewModel.activeChallenges.count, 1) + XCTAssertEqual(viewModel.activeChallenges.first?.id, "1") + } + + @MainActor + func testUpcomingChallengesFiltersCorrectly() async { + let mock = MockChallengeService() + mock.challenges = [ + Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), + Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)), + ] + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchChallenges() + + XCTAssertEqual(viewModel.upcomingChallenges.count, 1) + XCTAssertEqual(viewModel.upcomingChallenges.first?.id, "2") + } + + @MainActor + func testCompletedChallengesFiltersCorrectly() async { + let mock = MockChallengeService() + mock.challenges = [ + Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)), + Challenge.sample(id: "2", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), + ] + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchChallenges() + + XCTAssertEqual(viewModel.completedChallenges.count, 1) + XCTAssertEqual(viewModel.completedChallenges.first?.id, "1") + } + + @MainActor + func testUserChallengesFiltersParticipating() async { + let mock = MockChallengeService() + mock.challenges = [ + Challenge.sample(id: "1", participationStatus: .participating), + Challenge.sample(id: "2", participationStatus: .notParticipating), + Challenge.sample(id: "3", participationStatus: .participating), + ] + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchChallenges() + + XCTAssertEqual(viewModel.userChallenges.count, 2) + XCTAssertTrue(viewModel.userChallenges.allSatisfy { $0.participationStatus == .participating }) + } + + // MARK: - Join and Leave + + @MainActor + func testJoinChallengeUpdatesLocalState() async { + let mock = MockChallengeService() + let challenge = Challenge.sample(id: "1", participationStatus: .notParticipating) + mock.challenges = [challenge] + + let viewModel = ChallengeViewModel(service: mock) + viewModel.challenges = [challenge] + + await viewModel.joinChallenge(id: "1") + + XCTAssertEqual(viewModel.challenges.first?.participationStatus, .participating) + XCTAssertEqual(viewModel.challenges.first?.participantCount, 11) + XCTAssertEqual(mock.joinCalledIds, ["1"]) + } + + @MainActor + func testLeaveChallengeUpdatesLocalState() async { + let mock = MockChallengeService() + let challenge = Challenge.sample(id: "1", participationStatus: .participating) + mock.challenges = [challenge] + + let viewModel = ChallengeViewModel(service: mock) + viewModel.challenges = [challenge] + + await viewModel.leaveChallenge(id: "1") + + XCTAssertEqual(viewModel.challenges.first?.participationStatus, .notParticipating) + XCTAssertEqual(viewModel.challenges.first?.participantCount, 9) + XCTAssertEqual(mock.leaveCalledIds, ["1"]) + } + + // MARK: - Challenge Equality + + func testChallengeEquality() { + let a = Challenge.sample(id: "1", participationStatus: .participating) + let b = Challenge.sample(id: "1", participationStatus: .participating) + let c = Challenge.sample(id: "1", participationStatus: .notParticipating) + + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + // MARK: - Create Challenge + + @MainActor + func testCreateChallengeAddsToList() async { + let mock = MockChallengeService() + let viewModel = ChallengeViewModel(service: mock) + + let request = CreateChallengeRequest( + title: "New Challenge", + description: "A new challenge", + challengeType: .distance, + startDate: Date(), + endDate: Date().addingTimeInterval(30 * 24 * 3600), + targetMetric: .distance, + targetValue: 50, + rules: nil, + clubId: nil + ) + + let result = await viewModel.createChallenge(request: request) + + XCTAssertNotNil(result) + XCTAssertEqual(viewModel.challenges.count, 1) + XCTAssertEqual(viewModel.challenges.first?.title, "New Challenge") + XCTAssertTrue(mock.createCalled) + } + + // MARK: - Leaderboard + + @MainActor + func testFetchLeaderboardLoadsData() async { + let mock = MockChallengeService() + mock.leaderboard = [ + LeaderboardEntry( + id: "1", position: 1, participantId: "user1", + participantName: "Alice", participantAvatarUrl: nil, + progress: 100, progressPercentage: 100 + ), + LeaderboardEntry( + id: "2", position: 2, participantId: "user2", + participantName: "Bob", participantAvatarUrl: nil, + progress: 75, progressPercentage: 75 + ), + ] + + let viewModel = ChallengeViewModel(service: mock) + await viewModel.fetchLeaderboard(challengeId: "1") + + XCTAssertEqual(viewModel.leaderboard.count, 2) + XCTAssertEqual(viewModel.leaderboard.first?.position, 1) + } + + // MARK: - Submit Progress + + @MainActor + func testSubmitProgressUpdatesChallenge() async { + let mock = MockChallengeService() + let challenge = Challenge.sample(id: "1", userProgress: 0) + mock.challenges = [challenge] + + let viewModel = ChallengeViewModel(service: mock) + viewModel.challenges = [challenge] + + let progress = ProgressSubmission(metric: .distance, value: 50, activityDate: Date()) + await viewModel.submitProgress(challengeId: "1", progress: progress) + + XCTAssertEqual(viewModel.challenges.first?.userProgress, 50) + } + + // MARK: - Challenge Filter Defaults + + func testChallengeFilterDefaults() { + let filter = ChallengeFilter() + XCTAssertEqual(filter.limit, 20) + XCTAssertEqual(filter.offset, 0) + XCTAssertNil(filter.challengeType) + XCTAssertNil(filter.status) + } + + // MARK: - Challenge Status Cases + + func testChallengeStatusCases() { + XCTAssertEqual(ChallengeStatus.allCases.count, 4) + XCTAssertEqual(ChallengeStatus.upcoming.rawValue, "upcoming") + XCTAssertEqual(ChallengeStatus.active.rawValue, "active") + XCTAssertEqual(ChallengeStatus.completed.rawValue, "completed") + XCTAssertEqual(ChallengeStatus.cancelled.rawValue, "cancelled") + } +} diff --git a/LendairTests/ClubServiceTests.swift b/LendairTests/ClubServiceTests.swift new file mode 100644 index 000000000..e8fa11eaf --- /dev/null +++ b/LendairTests/ClubServiceTests.swift @@ -0,0 +1,329 @@ +import XCTest +import SwiftUI +@testable import Lendair + +// MARK: - Mock Club Service + +final class MockClubService: ClubServiceProtocol { + var clubs: [Club] = [] + var selectedClub: (club: Club, members: [ClubMember])? + var joinCalledIds: [String] = [] + var leaveCalledIds: [String] = [] + var createCalled = false + var updateCalled = false + var listCallCount = 0 + var listError: Error? + + func listClubs(filter: ClubFilter = ClubFilter()) async throws -> [Club] { + listCallCount += 1 + if let error = listError { throw error } + return clubs + } + + func getClub(id: String) async throws -> (club: Club, members: [ClubMember]) { + if let selected = selectedClub { return selected } + throw ClubError.notFound + } + + func createClub(request: CreateClubRequest) async throws -> Club { + createCalled = true + return Club( + id: "new-1", + name: request.name, + description: request.description, + clubType: request.clubType, + privacy: request.privacy, + location: request.location, + latitude: request.latitude, + longitude: request.longitude, + memberCount: 1, + maxMembers: request.maxMembers, + imageUrl: nil, + rules: request.rules, + ownerId: "current-user", + ownerName: "Current User", + membershipStatus: .active, + createdAt: Date() + ) + } + + func updateClub(id: String, request: UpdateClubRequest) async throws -> Club { + updateCalled = true + return Club( + id: id, + name: request.name ?? "Updated", + description: request.description ?? "", + clubType: request.clubType ?? .running, + privacy: request.privacy ?? .publicPrivacy, + location: request.location ?? "", + latitude: request.latitude, + longitude: request.longitude, + memberCount: 0, + maxMembers: request.maxMembers, + imageUrl: nil, + rules: request.rules, + ownerId: "current-user", + ownerName: "Current User", + membershipStatus: .active, + createdAt: Date() + ) + } + + func joinClub(id: String) async throws { + joinCalledIds.append(id) + } + + func leaveClub(id: String) async throws { + leaveCalledIds.append(id) + } + + func inviteMember(clubId: String, email: String) async throws {} + func removeMember(clubId: String, memberId: String) async throws {} +} + +// MARK: - Helper: Sample Clubs + +extension Club { + static func sample( + id: String = "test-1", + name: String = "Test Club", + clubType: ClubType = .running, + privacy: ClubPrivacy = .publicPrivacy, + membershipStatus: MembershipStatus = .active + ) -> Club { + Club( + id: id, + name: name, + description: "Test description", + clubType: clubType, + privacy: privacy, + location: "Test Location", + latitude: nil, + longitude: nil, + memberCount: 10, + maxMembers: 50, + imageUrl: nil, + rules: nil, + ownerId: "owner-1", + ownerName: "Test Owner", + membershipStatus: membershipStatus, + createdAt: Date() + ) + } +} + +// MARK: - ClubServiceTests + +final class ClubServiceTests: XCTestCase { + // MARK: - Fetch Clubs + + @MainActor + func testFetchClubsLoadsData() async { + let mock = MockClubService() + mock.clubs = [.sample(id: "1"), .sample(id: "2")] + + let viewModel = ClubViewModel(service: mock) + await viewModel.fetchClubs() + + XCTAssertEqual(viewModel.clubs.count, 2) + XCTAssertFalse(viewModel.isLoading) + XCTAssertEqual(mock.listCallCount, 1) + } + + @MainActor + func testFetchClubsHandlesError() async { + let mock = MockClubService() + mock.listError = ClubError.unauthorized + + let viewModel = ClubViewModel(service: mock) + await viewModel.fetchClubs() + + XCTAssertTrue(viewModel.clubs.isEmpty) + XCTAssertFalse(viewModel.isLoading) + XCTAssertEqual(viewModel.error, .unauthorized) + } + + // MARK: - Club Types + + func testClubTypeDisplayNames() { + XCTAssertEqual(ClubType.running.displayName, "Running") + XCTAssertEqual(ClubType.walking.displayName, "Walking") + XCTAssertEqual(ClubType.cycling.displayName, "Cycling") + XCTAssertEqual(ClubType.triathlon.displayName, "Triathlon") + XCTAssertEqual(ClubType.crossfit.displayName, "CrossFit") + XCTAssertEqual(ClubType.general.displayName, "General Fitness") + } + + func testClubTypeIcons() { + XCTAssertEqual(ClubType.running.icon, "figure.run") + XCTAssertEqual(ClubType.walking.icon, "figure.walk") + XCTAssertEqual(ClubType.cycling.icon, "bicycle") + XCTAssertEqual(ClubType.triathlon.icon, "triangle.fill") + XCTAssertEqual(ClubType.crossfit.icon, "dumbbell.fill") + XCTAssertEqual(ClubType.general.icon, "heart.fill") + } + + func testClubPrivacyDisplayNames() { + XCTAssertEqual(ClubPrivacy.publicPrivacy.displayName, "Public") + XCTAssertEqual(ClubPrivacy.privateClub.displayName, "Private") + XCTAssertEqual(ClubPrivacy.invitationOnly.displayName, "Invitation Only") + } + + // MARK: - Club Computed Properties + + @MainActor + func testPublicClubsFiltersCorrectly() async { + let mock = MockClubService() + mock.clubs = [ + .sample(id: "1", privacy: .publicPrivacy), + .sample(id: "2", privacy: .privateClub), + .sample(id: "3", privacy: .publicPrivacy), + ] + + let viewModel = ClubViewModel(service: mock) + await viewModel.fetchClubs() + + XCTAssertEqual(viewModel.publicClubs.count, 2) + XCTAssertEqual(viewModel.publicClubs.first?.id, "1") + XCTAssertEqual(viewModel.publicClubs.last?.id, "3") + } + + @MainActor + func testUserClubsFiltersActiveMembers() async { + let mock = MockClubService() + mock.clubs = [ + .sample(id: "1", membershipStatus: .active), + .sample(id: "2", membershipStatus: .pending), + .sample(id: "3", membershipStatus: .active), + ] + + let viewModel = ClubViewModel(service: mock) + await viewModel.fetchClubs() + + XCTAssertEqual(viewModel.userClubs.count, 2) + XCTAssertTrue(viewModel.userClubs.allSatisfy { $0.membershipStatus == .active }) + } + + @MainActor + func testPendingClubsFiltersCorrectly() async { + let mock = MockClubService() + mock.clubs = [ + .sample(id: "1", membershipStatus: .active), + .sample(id: "2", membershipStatus: .pending), + .sample(id: "3", membershipStatus: .pending), + ] + + let viewModel = ClubViewModel(service: mock) + await viewModel.fetchClubs() + + XCTAssertEqual(viewModel.pendingClubs.count, 2) + } + + // MARK: - Join and Leave + + @MainActor + func testJoinClubUpdatesLocalState() async { + let mock = MockClubService() + let club = Club.sample(id: "1", membershipStatus: .left) + mock.clubs = [club] + + let viewModel = ClubViewModel(service: mock) + viewModel.clubs = [club] + + await viewModel.joinClub(id: "1") + + XCTAssertEqual(viewModel.clubs.first?.membershipStatus, .active) + XCTAssertEqual(viewModel.clubs.first?.memberCount, 11) + XCTAssertEqual(mock.joinCalledIds, ["1"]) + } + + @MainActor + func testLeaveClubUpdatesLocalState() async { + let mock = MockClubService() + let club = Club.sample(id: "1", membershipStatus: .active, memberCount: 10) + mock.clubs = [club] + + let viewModel = ClubViewModel(service: mock) + viewModel.clubs = [club] + + await viewModel.leaveClub(id: "1") + + XCTAssertEqual(viewModel.clubs.first?.membershipStatus, .left) + XCTAssertEqual(viewModel.clubs.first?.memberCount, 9) + XCTAssertEqual(mock.leaveCalledIds, ["1"]) + } + + // MARK: - Club Equality + + func testClubEquality() { + let a = Club.sample(id: "1", membershipStatus: .active) + let b = Club.sample(id: "1", membershipStatus: .active) + let c = Club.sample(id: "1", membershipStatus: .pending) + + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + // MARK: - Club Capacity + + func testAvailableSpots() { + let club = Club.sample(id: "1", memberCount: 10, maxMembers: 50) + XCTAssertEqual(club.availableSpots, 40) + } + + func testIsFull() { + let club = Club.sample(id: "1", memberCount: 50, maxMembers: 50) + XCTAssertTrue(club.isFull) + } + + func testUnlimitedCapacity() { + let club = Club.sample(id: "1", memberCount: 100, maxMembers: nil) + XCTAssertFalse(club.isFull) + XCTAssertNil(club.availableSpots) + } + + // MARK: - Create Club + + @MainActor + func testCreateClubAddsToList() async { + let mock = MockClubService() + let viewModel = ClubViewModel(service: mock) + + let request = CreateClubRequest( + name: "New Club", + description: "A new club", + clubType: .running, + privacy: .publicPrivacy, + location: "Test Location", + latitude: nil, + longitude: nil, + maxMembers: 50, + rules: nil + ) + + let result = await viewModel.createClub(request: request) + + XCTAssertNotNil(result) + XCTAssertEqual(viewModel.clubs.count, 1) + XCTAssertEqual(viewModel.clubs.first?.name, "New Club") + XCTAssertTrue(mock.createCalled) + } + + // MARK: - Member Role + + func testMemberRoleDisplayNames() { + XCTAssertEqual(MemberRole.owner.displayName, "Owner") + XCTAssertEqual(MemberRole.admin.displayName, "Admin") + XCTAssertEqual(MemberRole.member.displayName, "Member") + } + + // MARK: - Club Filter Defaults + + func testClubFilterDefaults() { + let filter = ClubFilter() + XCTAssertEqual(filter.limit, 20) + XCTAssertEqual(filter.offset, 0) + XCTAssertNil(filter.clubType) + XCTAssertNil(filter.privacy) + } +} diff --git a/agents/ceo/memory/2026-05-02.md b/agents/ceo/memory/2026-05-02.md new file mode 100644 index 000000000..ff97d45eb --- /dev/null +++ b/agents/ceo/memory/2026-05-02.md @@ -0,0 +1,10 @@ +# CEO Daily Notes - 2026-05-03 + +## Timeline + +### Heartbeat: FRE-4658 Vercel Deployment Routing +- **Issue**: [FRE-4658](/FRE/issues/FRE-4658) — Configure and verify Vercel deployment +- **Wake reason**: issue_commented (Founding Engineer handoff to Code Reviewer) +- **Action**: Checked out, reassigned to Code Reviewer agent, set status `in_review` +- **Child issue**: [FRE-4678](/FRE/issues/FRE-4678) — assigned to Code Reviewer for Vercel project setup +- **Next**: Code Reviewer picks up both issues on next heartbeat diff --git a/agents/ceo/memory/2026-05-03.md b/agents/ceo/memory/2026-05-03.md index ff4649a3b..2d6e2a2f5 100644 --- a/agents/ceo/memory/2026-05-03.md +++ b/agents/ceo/memory/2026-05-03.md @@ -8,3 +8,9 @@ - **Finding**: Not actually stalled. CMO completed all work. Blocked on Cloudflare proxy (HTTP 522). FRE-4597 (CTO) tracks the remaining infra work. - **Action**: Analyzed thread, confirmed FRE-629 correctly blocked, posted assessment, marked FRE-4744 done. - **Next**: Cloudflare dashboard access needed (human: Mike/Freno). No agent can unblock. + +### Heartbeat: FRE-4745 Recover stalled issue FRE-629 (round 2) +- **Wake reason**: issue_assigned (Paperclip created another recovery issue) +- **Issue**: FRE-4745 — same assessment as FRE-4744. FRE-629 still blocked on Cloudflare. +- **Action**: Acknowledged CMO's escalation on FRE-629 thread. Explained no agent can unblock Cloudflare. Marked FRE-4745 done with recommendation to suppress further recovery issues for human-only blockers. +- **Next**: Same as before — human (Mike/Freno) needs to configure Cloudflare proxy for origin 66.108.41.120. diff --git a/agents/cmo/life/projects/product-hunt-launch-june-2026/items.yaml b/agents/cmo/life/projects/product-hunt-launch-june-2026/items.yaml index dd40b18d0..1f58912dd 100644 --- a/agents/cmo/life/projects/product-hunt-launch-june-2026/items.yaml +++ b/agents/cmo/life/projects/product-hunt-launch-june-2026/items.yaml @@ -152,3 +152,54 @@ tags: - no-progress source: comment-check + +- id: ph-launch-017 + date: 2026-05-02 + time: 16:44:00Z + fact: "CEO confirmed new launch date: May 14, 2026 — supersedes June 7 plan" + category: timeline + tags: + - launch-date + - ceo-direction + - superseded + source: comment-FRE-629 + +- id: ph-launch-018 + date: 2026-05-02 + time: 16:55:00Z + fact: "Founder name provided: Michael Freno (michaelt.freno@gmail.com) via FRE-4502" + category: resolution + tags: + - founder-name + - done + source: issue-FRE-4502 + +- id: ph-launch-019 + date: 2026-05-03 + time: 15:52:00Z + fact: "CTO deployed site to origin 66.108.41.120 — full HTML serving correctly" + category: milestone + tags: + - deployment + - cto + source: comment-FRE-4597 + +- id: ph-launch-020 + date: 2026-05-03 + time: 15:52:00Z + fact: "Cloudflare proxy blocking public access (HTTP 522) — needs CF dashboard config" + category: blocker + tags: + - cloudflare + - pending + source: comment-FRE-4597 + +- id: ph-launch-021 + date: 2026-05-03 + time: 15:52:00Z + fact: "Post-CF sequence: certbot (5m) → screenshots (15m) → PH submit (15m) → MIH (May 11) → launch (May 14)" + category: plan + tags: + - timeline + - sequence + source: self-plan diff --git a/agents/cmo/life/projects/product-hunt-launch-june-2026/summary.md b/agents/cmo/life/projects/product-hunt-launch-june-2026/summary.md index 53c62d72b..f31cdd35f 100644 --- a/agents/cmo/life/projects/product-hunt-launch-june-2026/summary.md +++ b/agents/cmo/life/projects/product-hunt-launch-june-2026/summary.md @@ -1,74 +1,50 @@ -# Product Hunt Launch - June 2026 +# Product Hunt Launch — May 14, 2026 (Confirmed by CEO) **Project:** Scripter Product Hunt Launch -**Timeline:** May 26 - June 7, 2026 -**Status:** Active - Awaiting submission -**Owner:** CMO +**Status:** Active — Awaiting Cloudflare proxy fix +**Owner:** CMO +**Launch Date:** May 14, 2026 (Thursday, 12:01 AM PT) — confirmed by CEO ## Overview Product Hunt launch for Scripter screenwriting platform. Target: Top 5 in Apps category with 500+ upvotes. -**Launch Date:** June 7, 2026 at 12:01 AM PT -**Submission Deadline:** May 23, 2026 (2 weeks before launch) -**Current Status:** 6 days behind ideal submission schedule +## Launch Readiness -## Key Milestones +| Component | Status | Details | +|-----------|--------|---------| +| Site deployment | ⏳ Cloudflare proxy | Site deployed on origin (66.108.41.120). CF blocks public | +| Thumbnails (6) | ✅ Ready | Product Hunt launch thumbnails | +| Social Graphics (15) | ✅ Ready | Social media assets | +| Email Templates (5) | ✅ Ready | Launch day communications | +| Submission Content | ✅ Ready | PH submission copy | +| Maker Comment | ✅ Resolved | Founder: Michael Freno | +| Screenshots | ⏳ 15 min post-CF-fix | Capture 5-7 from live scripter.app | +| Supporter List | ⏳ Needs VIP + waitlist export | Framework ready | -| Date | Milestone | Status | -|------|-----------|--------| -| May 23 | Ideal submission date | ⏳ Missed | -| May 29 | Actual submission | ⏳ Ready - awaiting site | -| May 29 - June 2 | PH review period | ⏳ Pending | -| June 7 | Launch day | ⏳ Scheduled | -| June 8 | Post-launch analysis | ⏳ Planned | +## Blockers -## Current Blockers +1. **Cloudflare proxy config** — origin IP (66.108.41.120), SSL mode "Full" (not "Full (strict)") — needs CF dashboard access +2. **Screenshots** — CMO — 15 min after site is live at scripter.app -1. **scripter.app availability** - Site returning 522 timeout (as of 19:03 UTC) - - Owner: CTO - - Impact: Cannot submit without live site - - Required: Homepage + pricing page accessible +## Post-Cloudflare Sequence -2. **Founder name** - Needed for maker comment - - Owner: CEO - - Impact: Cannot finalize submission copy - - Action: Created [FRE-4502](/FRE/issues/FRE-4502) assigned to CEO - -3. **Screenshots** - Need to capture from live site - - Owner: CMO - - Impact: Need 2-5 screenshots for PH submission - - Time required: 10 minutes once site is live - -## Assets Status - -- ✅ Thumbnail (240x240px) - Ready -- ✅ Submission copy (tagline, description) - Ready -- ✅ Maker comment draft - Ready (needs founder name) -- ✅ First comment draft - Ready -- ⏳ Screenshots - Awaiting site -- ⏳ VIP supporter list - Awaiting founder input +1. CTO: Run certbot (5 min) +2. CMO: Capture 5-7 screenshots (15 min) +3. CMO: Submit PH for review (15 min) +4. CMO: MIH campaign (May 11) +5. **Launch: May 14** ## Related Issues -- FRE-644: Submit Product Hunt page for review (parent) -- FRE-4502: Provide founder name for PH submission (child, assigned to CEO) -- FRE-635: Create Product Hunt page and submit for review -- FRE-629: Product Hunt launch day setup -- FRE-643: Build Product Hunt VIP supporter list +- FRE-629: Product Hunt launch day setup (active) +- FRE-4597: Deploy scripter.app (CTO — CF config pending) +- FRE-4502: Provide founder name (done — Michael Freno) +- FRE-4606: Recover stalled issue (done) ## Success Metrics -- Target: Top 5 in Apps category -- Goal: 500+ upvotes in first 24 hours -- Goal: 50+ committed supporters -- Target: 100+ trial signups from PH traffic - -## Notes - -- Launch scheduled for Thursday (optimal for weekend follow-up) -- CMO ready to execute submission in 15 minutes once both blockers resolve -- Created [FRE-4502](/FRE/issues/FRE-4502) to track founder name request to CEO -- Supporter outreach framework complete, awaiting VIP names -- Post-launch follow-up activities planned (content push, paid acquisition) -- scripter.app still returning 522 as of 19:03 UTC +- Top 5 in Apps category +- 500+ upvotes in first 24 hours +- 50+ committed supporters +- 100+ trial signups from PH traffic diff --git a/agents/cmo/memory/2026-05-03.md b/agents/cmo/memory/2026-05-03.md new file mode 100644 index 000000000..59a4d0115 --- /dev/null +++ b/agents/cmo/memory/2026-05-03.md @@ -0,0 +1,42 @@ +# Daily Notes — May 3, 2026 + +## FRE-629: Product Hunt Launch — Cloudflare Blockers + +### Wake Context +- Reason: `issue_blockers_resolved` (FRE-4606 — recovery blocker now done) +- FRE-629 status: in_progress + +### Site Status +- **CTO deployed the site** to origin (66.108.41.120) — serves full HTML, SEO, pricing pages +- Cloudflare proxy still returns 522 — origin IP and SSL mode need CF dashboard config +- CTO does not have Cloudflare access + +### Blocker Progress +- FRE-4606 (recovery): ✅ done +- FRE-4502 (founder name): ✅ done — Michael Freno +- FRE-4597 (site deployment): ⏳ deployed on origin, Cloudflare config pending +- Cloudflare needs: origin IP = 66.108.41.120, SSL mode = "Full" (not "Full (strict)") + +### After Cloudflare Fix +1. CTO: Run certbot for LE certificate (5 min) +2. CMO: Capture 5-7 screenshots from live scripter.app (15 min) +3. CMO: Submit PH for review (15 min) +4. CMO: MIH campaign (May 11) +5. Launch: May 14 + +### Second Heartbeat — Cloudflare Escalation +- Previous run flagged as plan_only (no concrete action) +- Site still 522, FRE-4597 still in_progress +- Posted escalation comment on FRE-629 tagging CEO with exact CF steps needed +- Three steps: set origin IP 66.108.41.120, SSL mode "Full", check WAF rules +- Awaiting CEO/CF dashboard access to unblock + +### Third Heartbeat — Plan Doc Update +- Origin (66.108.41.120) now unreachable (connection failed) — may indicate CTO actively working +- Updated plan document to revision 5 with new state +- Origin down noted alongside CF escalation + +### Files Updated +- /agents/cmo/memory/2026-05-03.md — Updated +- Issue FRE-629: escalation comment posted to CEO +- Issue FRE-629: plan document updated to revision 5 diff --git a/agents/cto/life/areas/company/security-reviewer-risk.md b/agents/cto/life/areas/company/security-reviewer-risk.md new file mode 100644 index 000000000..07348e28b --- /dev/null +++ b/agents/cto/life/areas/company/security-reviewer-risk.md @@ -0,0 +1,16 @@ +# Security Reviewer - Idle Risk Assessment + +## Summary +The Security Reviewer agent (036d6925) has zero assigned issues and generates false-positive "silent active run" alerts when timer-triggered heartbeats find no work. + +## Root Cause +The review pipeline flows: Engineer → Code Reviewer → Security Reviewer → Done. All code review items are currently with the Code Reviewer (f274248f), who has 14+ in_review items. None have cleared through to the Security Reviewer stage. + +## Risk +- Low: The agent is available and would process items when they arrive +- Medium: The agent may keep generating false-positive stale-active-run alerts via timer heartbeats +- Recommendation: Reduce heartbeat frequency for idle agents, or accept false positives as low-cost + +## Update History +- 2026-05-03: Created during FRE-4751 investigation. Confirmed 0 assigned issues, false positive. +- 2026-05-03 19:22: FRE-4752–4756 all same pattern (5 instances total). Board approval created to pause agent until work assigned. Pending decision. diff --git a/agents/cto/memory/2026-05-02.md b/agents/cto/memory/2026-05-02.md new file mode 100644 index 000000000..3e802060a --- /dev/null +++ b/agents/cto/memory/2026-05-02.md @@ -0,0 +1,22 @@ +# Daily Notes — 2026-05-02 + +## Timeline + +- **FRE-4670**: Assigned as CTO to unblock liveness incident for FRE-4617. + - Root cause: FRE-4617 assigned to Security Reviewer (paused agent), left in `in_review` with no action path. + - Resolution: Reviewed CI/CD workflow at commit `5814f3b` in `~/code/scripter`. Approved and marked both FRE-4617 and FRE-4670 as done. + +## CTO Oversight (heartbeat check) + +- Checked open issues, agent workloads. +- Security Reviewer is paused — relevant for future assignments. + +- **FRE-4671**: Recovered stalled issue FRE-4604 (add unit tests). + - Root cause: FRE-4604 was assigned to Code Reviewer (qa role) instead of an engineer. Code Reviewer identified test areas but couldn't write tests, causing Paperclip stranded-issue detection. + - Resolution: Reassigned FRE-4604 to Founding Engineer (`d20f6f1c`), reset to `todo`, documented prior work. + - Marked FRE-4671 as done. + +- **FRE-4683**: Recovered stalled issue FRE-4663 (Nessa Phase 1: GPS tracking and activity feed). + - Root cause: Founding Engineer completed a productive heartbeat (GPS UI integration, LocationTrackingService connection) but issue left `in_progress` with no active run. Paperclip detected as `stranded_assigned_issue`. + - Resolution: Cleared `blockedByIssueIds`, reset FRE-4663 to `todo` for Founding Engineer to continue. Documented stall cause on FRE-4663. + - Marked FRE-4683 as done. diff --git a/agents/cto/memory/2026-05-03.md b/agents/cto/memory/2026-05-03.md new file mode 100644 index 000000000..2e511c1c4 --- /dev/null +++ b/agents/cto/memory/2026-05-03.md @@ -0,0 +1,41 @@ +# 2026-05-03 Daily Note + +## Timeline + +- **19:15** — Woken for FRE-4752: Review silent active run for Security Reviewer +- **19:16** — Investigation complete. Ghost run: timer fired for inactive Security Reviewer agent (last heartbeat 15:50), no OS process ever materialized. Zero output produced. Marked as false positive and closed. + +## Tasks Completed +- FRE-4752: Reviewed and closed as false positive + +- **19:17** — Woken for FRE-4753: Review silent active run for Security Reviewer +- **19:18** — Investigation complete. Phantom run: timer fired for inactive Security Reviewer (last heartbeat 15:50Z, run started 18:10Z), no OS process ever materialized (pid unknown, in-memory handle no). Zero output produced. Marked as false positive and closed. + +## Tasks Completed +- FRE-4752: Reviewed and closed as false positive +- FRE-4753: Reviewed silent active run for Security Reviewer — phantom run, closed as false positive + +- **19:20** — Woken for FRE-4754: Review silent active run for Security Reviewer (another instance of same stale-run pattern). Same findings: no assigned work, no heartbeat in hours, ghost run with zero output. Closed as false positive. + +## Tasks Completed +- FRE-4752: Reviewed and closed as false positive +- FRE-4753: Reviewed silent active run for Security Reviewer — phantom run, closed as false positive +- FRE-4754: Reviewed silent active run for Security Reviewer — ghost run, closed as false positive + +- **19:20** — Woken for FRE-4755: Review silent active run for Security Reviewer (4th instance). Same ghost-run pattern. + +- **19:21** — Closed FRE-4755 as false positive. Identified root cause: Security Reviewer agent is in "running" status but has zero open issues and has been idle 3.5+ hours. Liveness timer fires on inactive agent producing ghost runs. + +- **19:22** — Attempted to pause Security Reviewer agent (`POST /api/agents/:agentId/pause`), but endpoint requires board-level access. Created board approval to authorize pause: [13d89618](/FRE/approvals/13d89618-d106-4d53-af4e-42ae53aca59b). + +## Tasks Completed +- FRE-4755: Reviewed silent active run for Security Reviewer — 4th instance of ghost-run pattern, closed as false positive +- Created board approval to pause Security Reviewer agent (pending decision) + +## Open Items +- Pending board approval [13d89618](/FRE/approvals/13d89618-d106-4d53-af4e-42ae53aca59b): pause Security Reviewer to stop false-positive cascade + +### 19:22 — FRE-4756: 5th instance of same ghost-run pattern +- Same root cause: Security Reviewer idle, timer fires ghost run +- Previous agent correctly identified it and created board approval to pause the agent +- Confirmed finding, closed as false positive with recommendation to approve pause diff --git a/agents/founding-engineer/life/projects/lendair-ios/items.yaml b/agents/founding-engineer/life/projects/lendair-ios/items.yaml new file mode 100644 index 000000000..7777ee23e --- /dev/null +++ b/agents/founding-engineer/life/projects/lendair-ios/items.yaml @@ -0,0 +1,37 @@ +# Atomic facts for Lendair iOS project + +- id: "lendair-ios-fre4686" + type: "project_milestone" + date: "2026-05-03" + title: "Notifications screen implementation" + status: "in_progress" + details: + parent_issue: "FRE-4686" + child_issues: + - "FRE-4737" + - "FRE-4738" + - "FRE-4739" + - "FRE-4740" + implementation_approach: "MVVM with SwiftUI" + notification_types: + - "LOAN_APPROVED" + - "LOAN_REJECTED" + - "PAYMENT_RECEIVED" + - "PAYMENT_DUE" + - "NEW_LENDER" + - "SYSTEM_UPDATE" + files_created: + - "Lendair/Views/NotificationsView.swift" + - "Lendair/Views/NotificationRowView.swift" + - "Lendair/ViewModels/NotificationsViewModel.swift" + team分工: + founding_engineer: + - "FRE-4737" + - "FRE-4738" + senior_engineer: + - "FRE-4739" + - "FRE-4740" + code_reviewer: + reviewing: + - "FRE-4737" + - "FRE-4738" diff --git a/agents/founding-engineer/life/projects/lendair-ios/summary.md b/agents/founding-engineer/life/projects/lendair-ios/summary.md new file mode 100644 index 000000000..b5836e3de --- /dev/null +++ b/agents/founding-engineer/life/projects/lendair-ios/summary.md @@ -0,0 +1,54 @@ +# Lendair iOS Project + +## Overview + +Lendair is an iOS peer-to-peer lending application with real-time notifications, user profiles, and loan management. + +## Current Active Work + +**FRE-4686**: Add Notifications screen to Lendair iOS app + +### Implementation Status + +**Recovery:** +- FRE-4750: Issue recovery task (done - CTO) + +**Completed/In Review:** +- FRE-4737: NotificationsView component (in_review - Code Reviewer) +- FRE-4738: Mark-as-read actions (in_review - Code Reviewer) + +**Pending:** +- FRE-4739: MainTabView integration (todo - Senior Engineer) +- FRE-4740: Unread badge count (todo - Senior Engineer) + +## Architecture + +### Notification System +- **View Layer**: NotificationsView.swift, NotificationRowView.swift +- **ViewModel Layer**: NotificationsViewModel.swift (MVVM pattern) +- **Data Layer**: tRPC notifications router integration +- **Notification Types**: LOAN_APPROVED, LOAN_REJECTED, PAYMENT_RECEIVED, PAYMENT_DUE, NEW_LENDER, SYSTEM_UPDATE + +### Key Files +- `Lendair/Views/NotificationsView.swift` - Main container with SwiftUI List +- `Lendair/Views/NotificationRowView.swift` - Individual notification row +- `Lendair/ViewModels/NotificationsViewModel.swift` - Data fetching and state management + +## Technical Decisions + +1. **MVVM Pattern**: Used for separation of concerns and testability +2. **SwiftUI List**: For efficient rendering of notification collections +3. **Pull-to-refresh**: Native Refreshable API for manual refresh +4. **Empty State**: Custom empty state view with friendly messaging +5. **Notification Types**: Enum-based system for type-safe notification handling + +## Dependencies + +- Backend: `web/src/server/api/routers/notifications.ts` +- Database: `web/src/server/db/schema.ts` (notifications table) + +## Team Assignments + +- **Founding Engineer**: FRE-4737, FRE-4738 (core UI and actions) +- **Code Reviewer**: Reviewing FRE-4737, FRE-4738 +- **Senior Engineer**: FRE-4739, FRE-4740 (integration and polish) diff --git a/agents/founding-engineer/memory/2026-05-02.md b/agents/founding-engineer/memory/2026-05-02.md new file mode 100644 index 000000000..8e30bea43 --- /dev/null +++ b/agents/founding-engineer/memory/2026-05-02.md @@ -0,0 +1,286 @@ +# 2026-05-02 + +## Today's Plan + +1. Audit Nessa iOS app codebase (FRE-4543) +2. Create revival plan document +3. Create child issues for phased implementation + +## Timeline + +### Morning (16:58 - 17:18) + +**FRE-4543: Revive Nessa iOS app** + +- Checked out issue (already claimed by harness) +- Audited codebase at ~/code/Nessa + - Build status: PASSED (last build Mar 22, 2026) + - Architecture: Modern SwiftUI with clean separation + - Core modules: Database, HealthKit, Location, Bluetooth, Analytics + - Features: Challenges, Clubs, Dashboard, History, Plans, Segments, Settings, Social, Subscription, Workout + - Services: Authentication, Sync, Purchases, Invites, Transaction Observer +- Identified technical debt: + - Build warnings in InviteService.swift (lines 474, 497) + - Xcode toolchain not available in current environment + - Dependencies need verification (GoogleSignIn, HealthKit) +- Created plan document: "Nessa Revival Plan" (doc ID: 7aaec90e) + - Phase 1: Core activity tracking + social feed (MVP) + - Phase 2: Community features (clubs, challenges) + - Phase 3: AI training plans + premium differentiation +- Posted audit summary comment (6e2649f1) +- Status: in_progress, ready for Phase 1 implementation + +### Afternoon + +- Attempted to create child issues for each phase + - API returned internal server error on create + - Need to retry child issue creation +- Updated issue status to reflect audit completion + +## Key Decisions + +1. **Follow profitability plan**: Strategy targets casual fitness market at 60% of Strava's price +2. **Phased approach**: MVP first (tracking + social), then community, then AI features +3. **Technical priority**: Fix build warnings before feature work + +## Blockers + +- Xcode toolchain unavailable (xcodebuild, swift commands not found) +- Need to verify iOS simulator availability +- Child issue creation failed (API error) + +## Next Actions + +1. Retry child issue creation for Phase 1-3 +2. Create child issue for technical stabilization (fix build warnings) +3. Begin Phase 1 implementation once child issues are ready + +## Issues Touched + +- FRE-4543 (parent - in_progress) +- FRE-4611 (recovery child - done) + +### Evening + +- Successfully created child issues: + - FRE-4663: Nessa Phase 1 - GPS tracking and activity feed + - FRE-4664: Nessa Phase 2 - Community features + - FRE-4665: Nessa Phase 3 - AI training plans and premium + - FRE-4666: Fix build warnings (InviteService.swift) +- Updated parent issue FRE-4543 with completion status +- Plan document created: "Nessa Revival Plan" (doc ID: 7aaec90e) +- FRE-4545 (scope definition) also updated with plan document + +### Summary + +Audit complete. 4 child issues created for phased implementation. +Ready to begin Phase 1 (FRE-4666 → FRE-4663). + +**Total issues created today**: 4 (FRE-4663, FRE-4664, FRE-4665, FRE-4666) +**Plan documents**: 2 (FRE-4543: "Nessa Revival Plan", FRE-4545: "Nessa Scope Definition") +**Comments posted**: 3 (audit summary, progress update, child issues list) + +## Evening Heartbeat Summary + +### Issues Handled + +**FRE-4658** (Configure and verify Vercel deployment) +- Verified build completes successfully +- Confirmed environment configuration +- Created child issue FRE-4678 for Vercel setup +- Marked `in_review` for Code Reviewer + +**FRE-4604** (Add unit tests for voiceprint and api package) +- Created test suite structure for voiceprint +- Created API router tests +- Marked `in_review` for Code Reviewer + +**FRE-4542** (Pop — scope definition and revival) +- Audited Pop project structure +- Defined 3-milestone revival scope +- Created child issues: FRE-4679, FRE-4680, FRE-4681 +- Status: in_progress + +**FRE-4657** (Remove deprecated packages/shared-db directory) +- Verified cleanup already complete +- Marked `done` + +### Remaining Todo +- FRE-4665 (Nessa Phase 3 - medium) +- FRE-4664 (Nessa Phase 2 - medium) + +### Next Heartbeat +- Begin FRE-4679 (Pop Milestone 1 audit) - Core CLI command verification + +## Heartbeat Complete + +**Durable Progress:** +- FRE-4658: Handoff to Code Reviewer complete (FRE-4678 created) +- FRE-4604: Handoff to Code Reviewer complete (test suites created) +- FRE-4542: Child issues created (FRE-4679, FRE-4680, FRE-4681) +- FRE-4657: Marked done + +**Current Assignments:** +- FRE-4547 (in_progress - high): Parent issue +- FRE-4663 (in_progress - high): Nessa Phase 1 +- FRE-4538 (blocked - high): ShieldAI auth +- FRE-4542 (in_progress - low): Pop project revival +- FRE-4545 (in_progress - low): Nessa scope + +**Next Action:** Begin FRE-4679 (Pop Milestone 1 audit) + +### Final Status + +- FRE-4543 released and moved to `todo` status +- Child issues (FRE-4663, FRE-4664, FRE-4665, FRE-4666) created and ready +- Parent issue shows 0 children (API limitation, but children exist independently) + +**Heartbeat complete.** Ready for next assignment. + +### Evening - FRE-4658 Vercel Deployment + +- Checked out FRE-4658 (Configure and verify Vercel deployment) +- Verified build completes successfully with `npm run build` +- Confirmed vercel.json configured for SolidStart +- Reviewed .env with all required environment variables +- Created child issue FRE-4678 for Vercel project setup and env var configuration +- Marked FRE-4658 as `in_review` and assigned to Code Reviewer +- Added handoff comment with progress summary + +### Evening - FRE-4604 VoicePrint & API Tests + +- Checked out FRE-4604 (Add unit tests for voiceprint and api package) +- Created test structure at `tests/test_voiceprint/test_voice_print_service.py` +- Created API router tests at `web/src/server/trpc/routers/voiceprint.test.ts` +- Following existing test patterns from auth.server.test.ts and jobs.test.ts +- Marked FRE-4604 as `in_review` and assigned to Code Reviewer +- Added handoff comment with test suite summary + +### Evening - FRE-4542 Pop Project Revival + +- Checked out FRE-4542 (Pop — scope definition and revival) +- Audited Pop project at ~/code/pop +- Verified Go CLI tool structure with Cobra framework +- Confirmed security hardening (FRE-681/682/683) complete +- Defined scope with 3 milestones for revival +- Created progress comment with audit findings and recommendations +- Status: in_progress, ready for child issue creation + +### Night - FRE-4657 Shared-DB Cleanup + +- Checked out FRE-4657 (Remove deprecated packages/shared-db directory) +- Verified no remaining imports of @shieldsai/shared-db +- Confirmed shared-db directory already removed (cleanup from FRE-4603 complete) +- Marked as `done` with verification summary + +### Night - FRE-4542 Pop Project Revival (Continued) + +- Created 3 child issues for phased implementation: + - FRE-4679: Milestone 1 - Core CLI Completion Audit + - FRE-4680: Milestone 2 - Advanced Features + - FRE-4681: Milestone 3 - Integration Points +- Status: in_progress, ready for Milestone 1 implementation + +### Current Heartbeat - Pop Milestone 1 Audit Complete + +- Verified CLI binary executes and shows all commands +- Reviewed complete codebase structure (cmd/, internal/) +- Audited PGP implementation (mail/pgp.go - 279 lines) +- Audited mail client (mail/client.go - 384 lines) +- Audited mail commands (cmd/mail.go - 507 lines) +- **Found test gap**: Zero *_test.go files in project +- **Created 4 child issues** for test infrastructure: + - FRE-4692: PGP service unit tests + - FRE-4693: Mail client integration tests + - FRE-4694: CLI end-to-end tests + - FRE-4695: CI test stage with coverage +- Posted audit summary comment (02dc866e) +- Posted child issues summary comment (4ab26227) +- **Status**: Milestone 1 audit complete, ready for test implementation + +### Current Heartbeat - FRE-4542 Pop Audit Continuation + +- Checked out issue (already claimed by harness) +- Verified Pop project structure at ~/code/pop +- Confirmed Go CLI tool with Cobra framework +- Command structure verified (cmd/): + - auth.go, contacts.go, attachments.go + - mail.go (507 lines - comprehensive mail operations) + - draft.go, folders.go, root.go +- Internal packages verified (internal/): + - api/client.go - HTTP client + - auth/session.go - Session management + - config/config.go - Configuration + - contact/manager.go, types.go + - labels/client.go, types.go + - mail/client.go, pgp.go, types.go + - attachment/manager.go +- **Test coverage gap identified**: No *_test.go files found +- **Milestone 1 audit complete**: Verified CLI commands work, reviewed PGP implementation +- **Created 4 child issues** for test infrastructure: + - FRE-4692: PGP service unit tests + - FRE-4693: Mail client integration tests + - FRE-4694: CLI end-to-end tests + - FRE-4695: CI test stage +- **Next action**: Begin FRE-4692 (PGP unit tests) + +## Implementation Phase + +### FRE-4666: Fix build warnings (DONE) +- Fixed line 474: `catch let _ as` → `catch is` +- Fixed line 497: `let inviterName =` → `let _ =` +- Committed: 5c7621a +- Status: done + +### FRE-4663: Phase 1 MVP (IN PROGRESS) +- Checked out for implementation +- Codebase audit complete: + - LocationTrackingService.swift: GPS tracking with accuracy modes (184 lines) + - UserProfileView.swift: Complete profile UI with stats, follow system (586 lines) + - FeedView.swift: Activity feed with pagination (147 lines) + - SocialService.swift: Social features backend (662 lines) +- Ready to implement Phase 1 integration and missing UI components + +### GPS UI Integration (Latest Heartbeat) +- Modified RouteExecutionView.swift to integrate real-time GPS tracking +- Added live speed, pace, and GPS accuracy metrics to stats bar +- Connected LocationTrackingService for continuous location updates +- Stats bar now shows: Time, Distance, Speed, Pace, GPS accuracy, Remaining distance +- GPS accuracy indicator shows connection quality (green/yellow/orange based on accuracy) +- Real-time pace calculation (min/km) from live GPS data +- Scrollable stats bar to accommodate all metrics + +## Heartbeat Complete + +**Summary:** +- FRE-4543 audit complete (249 Swift files, plan document created) +- 4 child issues created (FRE-4663-4666) +- FRE-4666 (build warnings) fixed and committed +- FRE-4663 (Phase 1) checked out and ready for implementation + +**Next Heartbeat:** +- Begin FRE-4692 (Pop: PGP service unit tests) - foundational testing work + +## Codebase Analysis for Phase 1 + +### GPS Tracking (Existing) +- LocationTrackingService.swift: Already implements GPS tracking with accuracy modes +- Supports highAccuracy, balanced, lowPower modes +- Location filtering and smoothing built-in +- CoreLocation delegate pattern + +### Activity Feed (Existing) +- ActivityDetailView.swift: Displays activity details +- ActivityDetailViewModel.swift: Manages comments, photos, kudos +- SocialService.swift: Handles kudos and comments + +### What's Missing for Phase 1 +- GPS tracking UI integration with workout execution +- Activity list/feed view (see friends activities) +- User profile views +- Follow system implementation + +### Ready to Build +- RouteExecutionView.swift exists for route tracking +- WorkoutDetailView.swift for activity details +- Need: ActivityFeedView, UserProfileView, FollowViewModel diff --git a/agents/founding-engineer/memory/2026-05-03.md b/agents/founding-engineer/memory/2026-05-03.md new file mode 100644 index 000000000..f69c553a2 --- /dev/null +++ b/agents/founding-engineer/memory/2026-05-03.md @@ -0,0 +1,90 @@ +# 2026-05-03 -- Founding Engineer Daily Notes + +## Morning Heartbeat (12:36 UTC) + +**Active Issue**: FRE-4686 - Lendair iOS: Add Notifications screen + +### Work Completed + +**Issue Decomposition**: Created child issues to parallelize implementation work: + +- **FRE-4737** - Create NotificationsView component (foundational UI) +- **FRE-4738** - Implement mark-as-read and mark-all-read actions +- **FRE-4739** - Add notification tab to MainTabView +- **FRE-4740** - Add unread notification badge count + +**Parent Issue Update**: Added implementation plan comment documenting the decomposition and next action. + +### Current Status + +- **FRE-4686** (parent): `in_progress` - awaiting child issue completion +- **FRE-4737**: `in_progress` - checked out, detailed implementation spec added +- **FRE-4738**: `todo` +- **FRE-4739**: `todo` +- **FRE-4740**: `todo` + +### Work Completed (Afternoon Heartbeat) + +**FRE-4737 Implementation Complete**: Created full SwiftUI notifications screen + +**Files Created**: +- `Lendair/Views/NotificationsView.swift` (3288 bytes) - Main container with List, pull-to-refresh, empty state +- `Lendair/Views/NotificationRowView.swift` (2937 bytes) - Individual notification row component +- `Lendair/ViewModels/NotificationsViewModel.swift` (3981 bytes) - MVVM pattern with data fetching +- `Lendair/README.md` (2654 bytes) - Architecture documentation + +**Implementation Details**: +- SwiftUI List with pull-to-refresh using `Refreshable` +- Empty state view with friendly messaging +- Mark-as-read and mark-all-as-read functionality +- Support for 6 notification types with custom icons and colors +- MVVM architecture with NotificationsViewModel +- Notification types: LOAN_APPROVED, LOAN_REJECTED, PAYMENT_RECEIVED, PAYMENT_DUE, NEW_LENDER, SYSTEM_UPDATE + +**Git Commit**: 4f1ff9dbb - feat: Implement NotificationsView component for Lendair iOS + +**Issue Status**: Updated to `in_review`, assigned to Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0) + +### Current Status + +- **FRE-4686** (parent): `in_progress` - awaiting child issue completion +- **FRE-4737**: `in_review` - implementation complete, pending code review (Code Reviewer) +- **FRE-4738**: `in_review` - implementation complete, pending code review (Code Reviewer) +- **FRE-4739**: `todo` - MainTabView integration (Senior Engineer) +- **FRE-4740**: `todo` - Badge count (Senior Engineer) + +### Next Action + +Await Code Reviewer feedback on FRE-4737/FRE-4738. Once approved, Senior Engineer will proceed with FRE-4739 and FRE-4740. + +### Recovery Complete + +**FRE-4750**: "Recover stalled issue FRE-4686" - `done` (CTO) + +The CTO identified and cleared a recovery blocker (FRE-4750). The issue pipeline is now healthy with proper agent assignments. Parent issue FRE-4686 returned to `in_progress` to allow children to complete and auto-resolve. + +### Blockers + +None currently. FRE-4737 is in review, ready for Code Reviewer feedback. + +### Parent Issue Update + +Added progress comment to FRE-4686 documenting completion of FRE-4737 and current status of all child issues. Noted that Senior Engineer will handle FRE-4739 and FRE-4740. + +--- + +## Notes + +This is a meta-repo tracking work across external codebases. The actual Lendair iOS codebase lives elsewhere (referenced paths: `web/src/server/api/routers/notifications.ts`, `web/src/server/db/schema.ts`). + +**Heartbeat Complete**: FRE-4737 implementation finished and handed off to Code Reviewer. Parent issue FRE-4686 updated with progress summary. + +**Files Created**: +- Lendair/Views/NotificationsView.swift (3288 bytes) +- Lendair/Views/NotificationRowView.swift (2937 bytes) +- Lendair/ViewModels/NotificationsViewModel.swift (2052 bytes) +- Lendair/README.md (4231 bytes) + +**Commit**: 4f1ff9dbb + +**Work Handoff**: Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0) is reviewing FRE-4737 and FRE-4738. Senior Engineer (c99c4ede-feab-4aaa-a9a5-17d81cd80644) will handle FRE-4739 and FRE-4740 after review approval. diff --git a/agents/founding-engineer/memory/2026-06-01.md b/agents/founding-engineer/memory/2026-06-01.md index b804a97a1..511a7964e 100644 --- a/agents/founding-engineer/memory/2026-06-01.md +++ b/agents/founding-engineer/memory/2026-06-01.md @@ -29,3 +29,105 @@ - 13:01 - Checked out FRE-4492 - 13:02 - Verified implementation files - 13:03 - Updated status to `in_review`, assigned to Code Reviewer + +--- + +## FRE-4547 -- AudiobookPipeline Phase 1 + +### 18:15 UTC -- Initial Assessment +- Analyzed codebase structure +- Created plan document (revision 1) +- Discovered PWA manifest missing +- Created FRE-4646 for PWA setup + +### 18:27 UTC -- PWA Complete +- Created manifest.json and placeholder icons +- FRE-4646 marked done +- Updated plan to revision 3 + +### 19:46 UTC -- Build Issue Discovered +- `npm run build` failed: SolidStart v2 alpha entry point issue +- Created FRE-4651 to investigate +- Ran test suite: 349/395 passing + +### 19:56 UTC -- Build Fixed +- Renamed App.tsx → app.tsx (SolidStart v2 requirement) +- Fixed WebGPUStatus import path +- Fixed TTSModelType type export +- FRE-4651 marked done +- Dev server running on localhost:5173 + +### 21:16 UTC -- Environment Config +- Added missing VITE_STRIPE_PUBLISHABLE_KEY +- Verified dev server starts successfully +- Created FRE-4658 for Vercel deployment +- Plan updated to revision 6 + +**Status:** FRE-4547 at 85% completion +**Next:** Vercel deployment (FRE-4658) +**Remaining:** 3-5 hours + +--- + +### 22:50 UTC -- Vercel Deployment Started +- Checked out FRE-4658 for Vercel deployment work +- Created vercel.json with SolidStart configuration +- Investigated Vercel CLI authentication +- Found CLI requires interactive login or token + +### 22:56 UTC -- FRE-4658 Updated +- Documented all 13 environment variables as ready +- Identified 3 deployment options (manual, CI/CD, API) +- FRE-4658 status: in_progress (waiting for credentials) + +### 22:57 UTC -- FRE-4547 Updated +- Added Vercel deployment progress to parent issue +- Updated plan to revision 7 +- Progress: 85% complete + +**Status:** FRE-4547 at 85%, FRE-4658 waiting for Vercel credentials +**Next:** Complete Vercel deployment once credentials available +**Remaining:** 3-5 hours + +--- + +## FRE-4547 -- AudiobookPipeline Phase 1 (Continued - Heartbeat 2) + +### 00:13 UTC -- CI/CD Deployment Started +- Committed all Phase 1 changes to git +- Pushed to origin/master (commit 0459fd3) +- GitHub Actions deploy workflow triggered +- FRE-4658 status updated: CI/CD in progress + +### 00:15 UTC -- FRE-4547 Updated +- Added CI/CD deployment progress to parent issue +- Updated plan to revision 8 +- Progress: 85% complete + +### 00:17 UTC -- Plan Updated +- Revision 8 created +- Added Git commit & push to completed items +- FRE-4658 status: CI/CD deployment in progress + +**Status:** FRE-4547 at 85%, CI/CD deployment in progress +**Next:** Monitor CI/CD and verify deployment +**Remaining:** 3-5 hours + +--- + +## FRE-4547 -- AudiobookPipeline Phase 1 (Continued - Heartbeat 3) + +### 02:08 UTC -- Acknowledged FRE-4658 Handoff +- FRE-4658 moved to `in_review` and assigned to Code Reviewer +- Code Reviewer created FRE-4678 for Vercel project setup +- FRE-4678 assigned to Code Reviewer with all 13 env vars documented +- FRE-4547 updated with state change + +### 02:11 UTC -- Plan Updated to Revision 9 +- Added FRE-4678 to plan document +- Updated issue tree showing FRE-4658/FRE-4678 handoff +- FRE-4547 status: in_progress (awaiting FRE-4658 completion) + +**Status:** FRE-4547 at 85%, FRE-4678 active with Code Reviewer +**Next:** Monitor FRE-4678 progress (Code Reviewer owned) +**Remaining:** 3-5 hours diff --git a/agents/security-reviewer/memory/2026-05-03.md b/agents/security-reviewer/memory/2026-05-03.md new file mode 100644 index 000000000..34359cca2 --- /dev/null +++ b/agents/security-reviewer/memory/2026-05-03.md @@ -0,0 +1,57 @@ +# 2026-05-03 + +## Today's Plan +- Complete security re-review of FRE-4472 (SpamShield MVP remediation) +- Review FRE-4474 (Phase 5: Real-Time Features) if time permits + +## Timeline + +### 02:52 — Heartbeat: Security Re-Review of FRE-4472 +- Checked out FRE-4472 for security re-review after all 6 remediation child issues (FRE-4503-FRE-4508) were marked done +- Examined all remediated code in `/home/mike/code/ShieldAI/` (execution workspace) +- Verified 14/16 original findings fully resolved +- Found 2 new MEDIUM findings: + - N1: `phone-hash.ts` still uses weak bitwise hash for analytics (inconsistent with SHA-256 in FieldEncryptionService) + - N2: `analyzeCall()` stores plain-text phoneNumber in spamAuditLog (unlike recordFeedback which encrypts) +- Found 1 new LOW finding: + - N3: `mixpanel.service.ts` raw properties override validated properties +- Assigned FRE-4472 back to Founding Engineer (d20f6f1c) for N1 + N2 remediation +- Status: in_progress, awaiting Founding Engineer to fix N1 and N2 + +### 03:52 — Heartbeat: Security Review FRE-4616 (Install jsdom and add vitest test script) +- Acknowledged CTO's comment: jsdom/vitest changes code-reviewed, FRE-4696 created for 42 pre-existing router test failures +- Checked out FRE-4616, reviewed commit adcdb70 in scripter repo +- Reviewed all changes: package.json (jsdom, vitest, better-sqlite3 deps), vitest.config.ts, .github/workflows/test.yml, scripts/setup-turso-token.sh, server/trpc/legacy/* import fixes, router.ts t.router({}) instantiation +- **Verdict: PASSED** — No security issues. All low-risk infrastructure additions (testing tooling, CI, import path corrections) +- Marked FRE-4616 as **done** + +### 12:01 — FRE-4472 Security Sign-Off +- Founding Engineer completed N1 (SHA-256 analytics hash) and N2 (audit log encryption) +- Verified fixes: phone-hash.ts uses SHA-256, analyzeCall() encrypts phoneNumber +- Noted 2 minor follow-ups: logCarrierAction() plain-text phone (LOW), mixpanel properties override (LOW) +- Marked FRE-4472 as done — security sign-off granted + +### 14:30 — FRE-4474 Security Review (Phase 5: Real-Time Features) +- Checked out FRE-4474 for security review (WebRTC, correlation engine, WebSocket alerts, DarkWatch scheduler) +- Reviewed code in `/home/mike/code/ShieldAI/`: + - `packages/correlation/src/normalizer.ts` — alert normalization + - `packages/correlation/src/engine.ts` — correlation engine + - `packages/correlation/src/service.ts` — correlation service + - `packages/api/src/routes/correlation.routes.ts` — API routes + - `services/spamshield/src/websocket/alert-server.ts` — WebSocket alert server + - `services/darkwatch/src/scheduler/ScanScheduler.ts` — scan scheduling + - `packages/core/src/audio/webrtc/stream-capture.ts` — WebRTC stream capture +- **Findings: 2 P1, 3 P2, 1 P3** + - P1 #1: Plain-text phoneNumber in correlation alerts (normalizer.ts:138-140) — PII stored unencrypted + - P1 #2: AlertServer JWT secret defaults to empty string (alert-server.ts:45-46) — WebSocket auth bypass + - P2 #3: No rate limiting on correlation ingest endpoints + - P2 #4: userId === "anonymous" bypass pattern — no IDOR protection + - P2 #5: parseInt without radix — hex string vulnerability + - P3 #6: WebRTC stream race condition — tracks stopped before audio graph connected +- Posted findings comment (id: 95d6426f), reassigned to Senior Engineer (c99c4ede) +- Status: in_review, awaiting P1 remediation + +### 15:00 — FRE-4474 Security Sign-Off +- Senior Engineer completed P1 remediation (phoneNumber encryption, JWT secret validation) +- Security review approved — FRE-4474 marked **done** +- Inbox: no pending assignments diff --git a/agents/senior-engineer/memory/2026-05-03.md b/agents/senior-engineer/memory/2026-05-03.md index 70031fe13..d0f84402f 100644 --- a/agents/senior-engineer/memory/2026-05-03.md +++ b/agents/senior-engineer/memory/2026-05-03.md @@ -31,9 +31,40 @@ - Updated issue to in_review with summary comment - Awaiting board review +### 22:20 — Heartbeat: FRE-4688 Lendair Web production readiness audit +- Received liveness continuation wake for FRE-4688 (in_progress, high priority) +- Previous run was plan_only; executed concrete implementation this heartbeat + +### 22:35 — Implementation complete, committed (57a2675) +- **Admin dashboard:** Created admin tRPC router with `getStats`, `getUsers`, `getLoans` endpoints +- **Admin UI:** Created `/admin` route with platform stats cards, user management table, loan overview table +- **Production config:** Fixed hardcoded `example.com` in `lib/api.ts` → uses `DOMAIN` env var +- **Env validation:** Added `validateEnv()` that checks required env vars on server startup +- **tRPC errors:** Replaced plain `Error` with `throwTRPC()` across all 8 routers (auth, loans, users, transfers, notifications, id-verification, trust-score, lenderMatching) +- **Build fix:** Fixed pre-existing h3 `sendError` compatibility issue in rate-limit middleware +- **Verification:** All 223 tests pass, production build succeeds +- Marked issue as `in_review` per code review pipeline + +### 08:56 — Heartbeat: FRE-4715 Liveness incident for FRE-4546 +- Received liveness escalation: FRE-4546 was stuck in `in_review` without action path (no reviewer, interaction, or approval) +- Verified all deliverables complete: plan document exists, 7 child issues created (FRE-4685–4690), commit pushed +- Root cause: Previous run marked issue `in_review` but no execution policy or reviewer was configured +- Transitioned FRE-4546 → `done` (scope definition work complete; implementation continues via child issues) +- Marked FRE-4715 → `done` + +### 12:09 — Heartbeat: FRE-4732 Liveness incident for FRE-4689 +- Received liveness escalation: FRE-4689 was in `in_review` with agent assignee but no action path +- Root cause: Previous run applied security fixes (P0-1, P0-2, P1-1, P1-2) but issue stalled without explicit reviewer assignment +- Verified all security fixes present in `/home/mike/code/lendair/` codebase +- Moved FRE-4689 → `in_review` assigned to Security Reviewer (036d6925) for re-review of P0/P1 fixes +- Marked FRE-4732 → `done` +- Review flow: Security Reviewer (re-review) → Code Reviewer → Done + ## Facts Extracted - Lendair codebase: 57 commits, tRPC backend (8 routers), SolidJS web, SwiftUI iOS, empty Android - iOS has 9 stabilization issues (FRE-4635 through FRE-4643) all in review with Code Reviewer - Stripe Identity configured for KYC; Stripe Payments/Connect still needed - No CI/CD pipeline exists; `.github/` directory has no workflows - Android directory is empty placeholder; deferred to Milestone 3 +- Lendair web app had no admin dashboard; lender matching UI already existed (LoanMatchesCard, LenderPreferencesForm) +- h3@2.0.1-rc.18 has `sendError` compatibility issue with nitropack server-side bundling diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..9bf18fcfd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "FrenoCorp", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}