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 <noreply@paperclip.ing>
This commit is contained in:
@@ -90,3 +90,7 @@ struct NotificationMarkAllReadResponse: Decodable {
|
|||||||
let success: Bool
|
let success: Bool
|
||||||
let markedCount: Int
|
let markedCount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NotificationUnreadCountResponse: Decodable {
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ protocol NotificationsServiceProtocol: Sendable {
|
|||||||
func list(params: NotificationListParams) async throws -> [NotificationItem]
|
func list(params: NotificationListParams) async throws -> [NotificationItem]
|
||||||
func markAsRead(id: String) async throws
|
func markAsRead(id: String) async throws
|
||||||
func markAllAsRead() async throws
|
func markAllAsRead() async throws
|
||||||
|
func getUnreadCount() async throws -> Int
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Default Service
|
// MARK: - Default Service
|
||||||
@@ -62,6 +63,17 @@ class NotificationsService: NotificationsServiceProtocol {
|
|||||||
_ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data)
|
_ = 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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest {
|
private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
class NotificationsViewModel: ObservableObject {
|
class NotificationsViewModel: ObservableObject {
|
||||||
@Published var notifications: [NotificationItem] = []
|
@Published var notifications: [NotificationItem] = []
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var badgeCount: Int = 0
|
||||||
@Published var lastRefreshDate: Date?
|
@Published var lastRefreshDate: Date?
|
||||||
@Published var error: NotificationError?
|
@Published var error: NotificationError?
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let fetchedNotifications = try await notificationsService.list()
|
let fetchedNotifications = try await notificationsService.list()
|
||||||
notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt }
|
notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
badgeCount = notifications.filter { !$0.isRead }.count
|
||||||
} catch let error as NotificationError {
|
} catch let error as NotificationError {
|
||||||
self.error = error
|
self.error = error
|
||||||
} catch {
|
} catch {
|
||||||
@@ -36,12 +38,22 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
await fetchNotifications()
|
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 {
|
func markAsRead(id: String) async {
|
||||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await notificationsService.markAsRead(id: id)
|
try await notificationsService.markAsRead(id: id)
|
||||||
notifications[index].isRead = true
|
notifications[index].isRead = true
|
||||||
|
badgeCount = max(0, badgeCount - 1)
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to mark notification as read: \(error)")
|
print("Failed to mark notification as read: \(error)")
|
||||||
@@ -57,6 +69,7 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
for index in notifications.indices {
|
for index in notifications.indices {
|
||||||
notifications[index].isRead = true
|
notifications[index].isRead = true
|
||||||
}
|
}
|
||||||
|
badgeCount = 0
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to mark all as read: \(error)")
|
print("Failed to mark all as read: \(error)")
|
||||||
|
|||||||
79
Lendair/Views/MainTabView.swift
Normal file
79
Lendair/Views/MainTabView.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NotificationsView: View {
|
struct NotificationsView: View {
|
||||||
@StateObject private var viewModel = NotificationsViewModel()
|
@StateObject private var viewModel: NotificationsViewModel
|
||||||
@State private var showingRefreshIndicator = false
|
@State private var showingRefreshIndicator = false
|
||||||
|
|
||||||
|
init(viewModel: NotificationsViewModel = NotificationsViewModel()) {
|
||||||
|
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Group {
|
Group {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ final class MockNotificationsService: NotificationsServiceProtocol {
|
|||||||
var markAllCalled = false
|
var markAllCalled = false
|
||||||
var listCallCount = 0
|
var listCallCount = 0
|
||||||
var listError: Error?
|
var listError: Error?
|
||||||
|
var mockUnreadCount: Int = 0
|
||||||
|
var getUnreadCountCallCount = 0
|
||||||
|
|
||||||
func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] {
|
func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] {
|
||||||
listCallCount += 1
|
listCallCount += 1
|
||||||
@@ -26,6 +28,11 @@ final class MockNotificationsService: NotificationsServiceProtocol {
|
|||||||
func markAllAsRead() async throws {
|
func markAllAsRead() async throws {
|
||||||
markAllCalled = true
|
markAllCalled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUnreadCount() async throws -> Int {
|
||||||
|
getUnreadCountCallCount += 1
|
||||||
|
return mockUnreadCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper: Sample Notifications
|
// MARK: - Helper: Sample Notifications
|
||||||
@@ -216,6 +223,91 @@ final class NotificationServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(mock.listCallCount, 1)
|
XCTAssertEqual(mock.listCallCount, 1)
|
||||||
XCTAssertEqual(viewModel.notifications.count, 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
|
// MARK: - NotificationModelTests
|
||||||
|
|||||||
15
agents/cto/life/areas/people/senior-engineer/items.yaml
Normal file
15
agents/cto/life/areas/people/senior-engineer/items.yaml
Normal file
@@ -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]
|
||||||
5
agents/cto/life/areas/people/senior-engineer/summary.md
Normal file
5
agents/cto/life/areas/people/senior-engineer/summary.md
Normal file
@@ -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.
|
||||||
@@ -39,3 +39,57 @@
|
|||||||
- Same root cause: Security Reviewer idle, timer fires ghost run
|
- Same root cause: Security Reviewer idle, timer fires ghost run
|
||||||
- Previous agent correctly identified it and created board approval to pause the agent
|
- Previous agent correctly identified it and created board approval to pause the agent
|
||||||
- Confirmed finding, closed as false positive with recommendation to approve pause
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user