From cb55ad95e27cdc91fb43fbf96a9f3bbe9432bfad Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 3 May 2026 20:16:05 -0400 Subject: [PATCH] Add notification badge count and MainTabView with notification tab FRE-4740 FRE-4739 - Add getUnreadCount() endpoint to NotificationsServiceProtocol - Add NotificationUnreadCountResponse model - Add badgeCount and fetchUnreadCount() to NotificationsViewModel - Update markAsRead/markAllAsRead to decrement badge count - Create MainTabView with Home, Challenges, Clubs, Notifications tabs - Add unread badge on notification tab using .badge() modifier - Support injected ViewModel in NotificationsView for shared state - Add badge count tests to NotificationServiceTests - Fetch unread count on app launch and tab switch Co-Authored-By: Paperclip --- Lendair/Models/Notification.swift | 4 + Lendair/Services/NotificationService.swift | 12 +++ .../ViewModels/NotificationsViewModel.swift | 13 +++ Lendair/Views/MainTabView.swift | 79 ++++++++++++++++ Lendair/Views/NotificationsView.swift | 6 +- LendairTests/NotificationServiceTests.swift | 92 +++++++++++++++++++ .../areas/people/senior-engineer/items.yaml | 15 +++ .../areas/people/senior-engineer/summary.md | 5 + agents/cto/memory/2026-05-03.md | 54 +++++++++++ 9 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 Lendair/Views/MainTabView.swift create mode 100644 agents/cto/life/areas/people/senior-engineer/items.yaml create mode 100644 agents/cto/life/areas/people/senior-engineer/summary.md diff --git a/Lendair/Models/Notification.swift b/Lendair/Models/Notification.swift index b89aa707d..49bef57e0 100644 --- a/Lendair/Models/Notification.swift +++ b/Lendair/Models/Notification.swift @@ -90,3 +90,7 @@ struct NotificationMarkAllReadResponse: Decodable { let success: Bool let markedCount: Int } + +struct NotificationUnreadCountResponse: Decodable { + let count: Int +} diff --git a/Lendair/Services/NotificationService.swift b/Lendair/Services/NotificationService.swift index 58c642204..b611fd161 100644 --- a/Lendair/Services/NotificationService.swift +++ b/Lendair/Services/NotificationService.swift @@ -6,6 +6,7 @@ protocol NotificationsServiceProtocol: Sendable { func list(params: NotificationListParams) async throws -> [NotificationItem] func markAsRead(id: String) async throws func markAllAsRead() async throws + func getUnreadCount() async throws -> Int } // MARK: - Default Service @@ -62,6 +63,17 @@ class NotificationsService: NotificationsServiceProtocol { _ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data) } + func getUnreadCount() async throws -> Int { + let url = baseURL.appendingPathComponent("/api/notifications/unread-count") + let request = try buildRequest(url: url) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + let decoded = try JSONDecoder().decode(NotificationUnreadCountResponse.self, from: data) + return decoded.count + } + // MARK: - Helpers private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { diff --git a/Lendair/ViewModels/NotificationsViewModel.swift b/Lendair/ViewModels/NotificationsViewModel.swift index 35248b2c9..6b04088ea 100644 --- a/Lendair/ViewModels/NotificationsViewModel.swift +++ b/Lendair/ViewModels/NotificationsViewModel.swift @@ -5,6 +5,7 @@ import SwiftUI class NotificationsViewModel: ObservableObject { @Published var notifications: [NotificationItem] = [] @Published var isLoading: Bool = false + @Published var badgeCount: Int = 0 @Published var lastRefreshDate: Date? @Published var error: NotificationError? @@ -25,6 +26,7 @@ class NotificationsViewModel: ObservableObject { do { let fetchedNotifications = try await notificationsService.list() notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt } + badgeCount = notifications.filter { !$0.isRead }.count } catch let error as NotificationError { self.error = error } catch { @@ -36,12 +38,22 @@ class NotificationsViewModel: ObservableObject { await fetchNotifications() } + func fetchUnreadCount() async { + do { + let count = try await notificationsService.getUnreadCount() + badgeCount = count + } catch { + print("Failed to fetch unread count: \(error)") + } + } + func markAsRead(id: String) async { guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } do { try await notificationsService.markAsRead(id: id) notifications[index].isRead = true + badgeCount = max(0, badgeCount - 1) objectWillChange.send() } catch { print("Failed to mark notification as read: \(error)") @@ -57,6 +69,7 @@ class NotificationsViewModel: ObservableObject { for index in notifications.indices { notifications[index].isRead = true } + badgeCount = 0 objectWillChange.send() } catch { print("Failed to mark all as read: \(error)") diff --git a/Lendair/Views/MainTabView.swift b/Lendair/Views/MainTabView.swift new file mode 100644 index 000000000..387f0b741 --- /dev/null +++ b/Lendair/Views/MainTabView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct MainTabView: View { + @State private var selectedTab: AppTab = .home + @StateObject private var notificationVM = NotificationsViewModel() + + var body: some View { + TabView(selection: $selectedTab) { + Group { + TrainingPlanView() + .tag(AppTab.home) + .tabItem { + Label(AppTab.home.title, systemImage: AppTab.home.icon) + } + + ChallengesView() + .tag(AppTab.challenges) + .tabItem { + Label(AppTab.challenges.title, systemImage: AppTab.challenges.icon) + } + + ClubsView() + .tag(AppTab.clubs) + .tabItem { + Label(AppTab.clubs.title, systemImage: AppTab.clubs.icon) + } + + NotificationsView(viewModel: notificationVM) + .tag(AppTab.notifications) + .tabItem { + Label(AppTab.notifications.title, systemImage: AppTab.notifications.icon) + } + .badge(notificationVM.badgeCount) + } + } + .onAppear { + Task { + await notificationVM.fetchUnreadCount() + } + } + .onChange(of: selectedTab) { _, newTab in + if newTab == .notifications { + Task { + await notificationVM.fetchNotifications() + await notificationVM.fetchUnreadCount() + } + } + } + } +} + +enum AppTab: String, CaseIterable { + case home + case challenges + case clubs + case notifications + + var title: String { + switch self { + case .home: return "Home" + case .challenges: return "Challenges" + case .clubs: return "Clubs" + case .notifications: return "Notifications" + } + } + + var icon: String { + switch self { + case .home: return "house.fill" + case .challenges: return "flag.fill" + case .clubs: return "person.3.fill" + case .notifications: return "bell.fill" + } + } +} + +#Preview { + MainTabView() +} diff --git a/Lendair/Views/NotificationsView.swift b/Lendair/Views/NotificationsView.swift index 696288492..7f40b542e 100644 --- a/Lendair/Views/NotificationsView.swift +++ b/Lendair/Views/NotificationsView.swift @@ -1,8 +1,12 @@ import SwiftUI struct NotificationsView: View { - @StateObject private var viewModel = NotificationsViewModel() + @StateObject private var viewModel: NotificationsViewModel @State private var showingRefreshIndicator = false + + init(viewModel: NotificationsViewModel = NotificationsViewModel()) { + self._viewModel = StateObject(wrappedValue: viewModel) + } var body: some View { NavigationView { diff --git a/LendairTests/NotificationServiceTests.swift b/LendairTests/NotificationServiceTests.swift index 5f8964399..16d674cf0 100644 --- a/LendairTests/NotificationServiceTests.swift +++ b/LendairTests/NotificationServiceTests.swift @@ -10,6 +10,8 @@ final class MockNotificationsService: NotificationsServiceProtocol { var markAllCalled = false var listCallCount = 0 var listError: Error? + var mockUnreadCount: Int = 0 + var getUnreadCountCallCount = 0 func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] { listCallCount += 1 @@ -26,6 +28,11 @@ final class MockNotificationsService: NotificationsServiceProtocol { func markAllAsRead() async throws { markAllCalled = true } + + func getUnreadCount() async throws -> Int { + getUnreadCountCallCount += 1 + return mockUnreadCount + } } // MARK: - Helper: Sample Notifications @@ -216,6 +223,91 @@ final class NotificationServiceTests: XCTestCase { XCTAssertEqual(mock.listCallCount, 1) XCTAssertEqual(viewModel.notifications.count, 1) } + + // MARK: - Badge Count + + @MainActor + func testFetchUnreadCountSetsBadgeCount() async { + let mock = MockNotificationsService() + mock.mockUnreadCount = 5 + + let viewModel = NotificationsViewModel(notificationsService: mock) + await viewModel.fetchUnreadCount() + + XCTAssertEqual(viewModel.badgeCount, 5) + XCTAssertEqual(mock.getUnreadCountCallCount, 1) + } + + @MainActor + func testFetchUnreadCountZero() async { + let mock = MockNotificationsService() + mock.mockUnreadCount = 0 + + let viewModel = NotificationsViewModel(notificationsService: mock) + await viewModel.fetchUnreadCount() + + XCTAssertEqual(viewModel.badgeCount, 0) + } + + @MainActor + func testMarkAsReadDecrementsBadgeCount() async { + let mock = MockNotificationsService() + let unread = NotificationItem.sample(id: "1", isRead: false) + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [unread] + viewModel.badgeCount = 3 + + await viewModel.markAsRead(id: "1") + + XCTAssertEqual(viewModel.badgeCount, 2) + XCTAssertTrue(viewModel.notifications.first?.isRead == true) + } + + @MainActor + func testMarkAsReadBadgeCountDoesNotGoBelowZero() async { + let mock = MockNotificationsService() + let unread = NotificationItem.sample(id: "1", isRead: false) + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [unread] + viewModel.badgeCount = 0 + + await viewModel.markAsRead(id: "1") + + XCTAssertEqual(viewModel.badgeCount, 0) + } + + @MainActor + func testMarkAllAsReadResetsBadgeCount() async { + let mock = MockNotificationsService() + let unread1 = NotificationItem.sample(id: "1", isRead: false) + let unread2 = NotificationItem.sample(id: "2", isRead: false) + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [unread1, unread2] + viewModel.badgeCount = 7 + + await viewModel.markAllAsRead() + + XCTAssertEqual(viewModel.badgeCount, 0) + } + + @MainActor + func testFetchNotificationsUpdatesBadgeCount() async { + let mock = MockNotificationsService() + mock.notifications = [ + NotificationItem.sample(id: "1", isRead: false), + NotificationItem.sample(id: "2", isRead: true), + NotificationItem.sample(id: "3", isRead: false), + ] + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.badgeCount = 0 + await viewModel.fetchNotifications() + + XCTAssertEqual(viewModel.badgeCount, 2) + } } // MARK: - NotificationModelTests diff --git a/agents/cto/life/areas/people/senior-engineer/items.yaml b/agents/cto/life/areas/people/senior-engineer/items.yaml new file mode 100644 index 000000000..a960fbfcc --- /dev/null +++ b/agents/cto/life/areas/people/senior-engineer/items.yaml @@ -0,0 +1,15 @@ +- id: sen-001 + type: observation + created: 2026-05-03 + status: active + summary: Planning-loop pattern — 3 runs over 6h with plan_only liveness, no code commits on FRE-4692 + detail: Identified real bugs (armor mismatch, Unlock check, AES256 casing) but kept iterating analysis without executing fixes. Mitigated by decomposing into child issues. + tags: [pattern, productivity, planning-loop] + +- id: sen-002 + type: capability + created: 2026-05-03 + status: active + summary: Strong at code analysis and bug identification + detail: Reads code thoroughly and identifies root causes well. The analysis on PGP service bugs was correct and valuable. + tags: [capability, analysis] diff --git a/agents/cto/life/areas/people/senior-engineer/summary.md b/agents/cto/life/areas/people/senior-engineer/summary.md new file mode 100644 index 000000000..1ac5397d5 --- /dev/null +++ b/agents/cto/life/areas/people/senior-engineer/summary.md @@ -0,0 +1,5 @@ +# Senior Engineer + +Agent: c99c4ede-feab-4aaa-a9a5-17d81cd80644 + +A senior engineering agent. Capable of analysis and execution. Prone to planning loops when tasks are not scoped tightly enough. Needs bounded, concrete subtasks to stay in execution mode. diff --git a/agents/cto/memory/2026-05-03.md b/agents/cto/memory/2026-05-03.md index 2e511c1c4..7395e026e 100644 --- a/agents/cto/memory/2026-05-03.md +++ b/agents/cto/memory/2026-05-03.md @@ -39,3 +39,57 @@ - 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 + +## CTO Heartbeat — 23:10 + +### FRE-4758: Review productivity for FRE-4692 +- Source: FRE-4692 "Pop: Add unit tests for PGP service" assigned to Senior Engineer +- Trigger: 6h active duration, 3 plan-only runs, no code commits +- **Decision: Productive with intervention (decomposition)** +- Analysis found real bugs (armor mismatch, Unlock check, AES256 casing) but kept planning +- Created child issues: [FRE-4759](/FRE/issues/FRE-4759) (fix bugs) and [FRE-4760](/FRE/issues/FRE-4760) (complete tests) +- Added directive comment on FRE-4692 to stop analyzing, start fixing, commit after each fix +- Closed FRE-4758 as done + +### CTO Oversight Scan +- **Review pipeline:** 20 items in_review (Code Reviewer has 9, Senior Engineer has 6, CEO has 1, CMO has 1). No obvious bottlenecks — all recently created. +- **Blocked issues:** 11 blocked. FRE-4537 (unassigned), FRE-4597/FRE-4601 (assigned to CTO, pending board approval). FRE-4658 needs agent assignment. +- **Agent workloads:** Senior Engineer holds most active tasks (FRE-4692 in_progress + 6 in_review + 3 todo + 2 blocked). Founding Engineer has 3 in_progress/todo. Code Reviewer has 9 in_review + 6 todo. +- **Notable:** Approval to pause Security Reviewer (to stop ghost-run cascade) still pending. + +## Tasks Completed +- FRE-4758: Productive with intervention — decomposed FRE-4692 into executable child issues + +## Open Items +- Pending board approval [13d89618](/FRE/approvals/13d89618-d106-4d53-af4e-42ae53aca59b): pause Security Reviewer +- Monitoring FRE-4759/FRE-4760 execution (Senior Engineer) + +### 23:45 — CTO Heartbeat: FRE-4686 Oversight Check + +- FRE-4686 children: 2 in_review (Code Reviewer), 2 todo (Founding Engineer) +- Commented with pipeline status +- No blockers — children correctly assigned +- Waiting on Code Reviewer to clear review queue; Founding Engineer to pick up FRE-4739/FRE-4740 + +### 23:48 — CTO Heartbeat: FRE-4686 Continuation + +- **Progress**: FRE-4740 (Badge count) moved `todo` → `in_progress` — Founding Engineer actively working +- **FRE-4739** (Add tab) still `todo` but has active run queued — likely next pickup +- **FRE-4737/FRE-4738** (NotificationsView, Mark-as-read) still `in_review` with Code Reviewer (last heartbeat 22:08, ~1.5h ago) +- Same Code Reviewer bottleneck persists +- No board action needed + +### 23:45 — CTO Heartbeat: FRE-4708 Completion + Oversight Scan + +**FRE-4708 → done**: Phase 1 MVP delivered for Nessa. Both child issues (FRE-4717 GPS route map, FRE-4718 recovery) completed. Verified all 5 feature areas implemented. Last build passed (`71c52fe`). + +**FRE-4686 reassigned** to Senior Engineer (owns implementation subtasks FRE-4739, FRE-4740). Code review pipeline proceeding for FRE-4737/FRE-4738. + +**FRE-4597** (Deploy scripter.app + PH launch) — still blocked on Cloudflare dashboard credentials. No agent work remains; human with Cloudflare access needed to fix origin IP / SSL mode. + +**Agent status**: Security Reviewer paused ✅, Vantage in error state (last heartbeat May 2). + +**Open Items**: +- FRE-4597: blocked on Cloudflare dashboard (human action) +- Vantage agent: error state needs investigation +- Code Reviewer queue: 9+ items in_review