diff --git a/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs b/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs index 9bfe517ca..e714075e2 100644 --- a/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs @@ -49,6 +49,18 @@ public async Task MarkAsRead(Guid id, CancellationToken cancellat return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + [HttpPost("mark-all-read")] + public async Task MarkAllAsRead( + [FromQuery] Guid? boardId = null, + CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _notificationService.MarkAllAsReadAsync(userId, boardId, cancellationToken); + return result.IsSuccess ? Ok(new { markedCount = result.Value }) : result.ToErrorActionResult(); + } + [HttpGet("preferences")] public async Task GetPreferences(CancellationToken cancellationToken = default) { diff --git a/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs b/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs index c81d619fb..d42094bd2 100644 --- a/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs @@ -16,4 +16,9 @@ Task> GetByUserIdAsync( Guid userId, string deduplicationKey, CancellationToken cancellationToken = default); + + Task> GetUnreadByUserIdAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/INotificationService.cs b/backend/src/Taskdeck.Application/Services/INotificationService.cs index 4900ecb59..55984cb8d 100644 --- a/backend/src/Taskdeck.Application/Services/INotificationService.cs +++ b/backend/src/Taskdeck.Application/Services/INotificationService.cs @@ -15,6 +15,11 @@ Task> MarkAsReadAsync( Guid notificationId, CancellationToken cancellationToken = default); + Task> MarkAllAsReadAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default); + Task> GetPreferencesAsync( Guid userId, CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs b/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs index d519a76ba..e08f130b8 100644 --- a/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs +++ b/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs @@ -28,6 +28,14 @@ public Task> MarkAsReadAsync( return Task.FromResult(Result.Failure(ErrorCodes.NotFound, "Notification not found")); } + public Task> MarkAllAsReadAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Result.Success(0)); + } + public Task> GetPreferencesAsync( Guid userId, CancellationToken cancellationToken = default) diff --git a/backend/src/Taskdeck.Application/Services/NotificationService.cs b/backend/src/Taskdeck.Application/Services/NotificationService.cs index 4e121d21f..fab34cb67 100644 --- a/backend/src/Taskdeck.Application/Services/NotificationService.cs +++ b/backend/src/Taskdeck.Application/Services/NotificationService.cs @@ -84,6 +84,43 @@ public async Task> MarkAsReadAsync( return Result.Success(MapToDto(notification)); } + public async Task> MarkAllAsReadAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID cannot be empty"); + + if (boardId.HasValue && _authorizationService is not null) + { + var boardPermission = await _authorizationService.CanReadBoardAsync(userId, boardId.Value); + if (!boardPermission.IsSuccess || !boardPermission.Value) + { + return Result.Failure( + ErrorCodes.Forbidden, + "You do not have access to notifications for this board"); + } + } + + var unreadNotifications = await _unitOfWork.Notifications.GetUnreadByUserIdAsync( + userId, boardId, cancellationToken); + + var count = 0; + foreach (var notification in unreadNotifications) + { + notification.MarkAsRead(); + count++; + } + + if (count > 0) + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + return Result.Success(count); + } + public async Task> GetPreferencesAsync( Guid userId, CancellationToken cancellationToken = default) @@ -218,6 +255,8 @@ private static bool TryResolveCadence( NotificationType.Mention => preference.MentionImmediateEnabled, NotificationType.Assignment => preference.AssignmentImmediateEnabled, NotificationType.ProposalOutcome => preference.ProposalOutcomeImmediateEnabled, + NotificationType.BoardChange => true, + NotificationType.System => true, _ => false }; @@ -226,6 +265,8 @@ private static bool TryResolveCadence( NotificationType.Mention => preference.MentionDigestEnabled, NotificationType.Assignment => preference.AssignmentDigestEnabled, NotificationType.ProposalOutcome => preference.ProposalOutcomeDigestEnabled, + NotificationType.BoardChange => false, + NotificationType.System => false, _ => false }; diff --git a/backend/src/Taskdeck.Domain/Entities/Notification.cs b/backend/src/Taskdeck.Domain/Entities/Notification.cs index ed2d9f4d1..6e1284e2f 100644 --- a/backend/src/Taskdeck.Domain/Entities/Notification.cs +++ b/backend/src/Taskdeck.Domain/Entities/Notification.cs @@ -94,7 +94,9 @@ public enum NotificationType { Mention, Assignment, - ProposalOutcome + ProposalOutcome, + BoardChange, + System } public enum NotificationCadence diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs index 2df7a8920..2ba0d9e29 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs @@ -49,4 +49,20 @@ public async Task> GetByUserIdAsync( n => n.UserId == userId && n.DeduplicationKey == deduplicationKey, cancellationToken); } + + public async Task> GetUnreadByUserIdAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default) + { + var query = _dbSet + .Where(n => n.UserId == userId && !n.IsRead); + + if (boardId.HasValue) + { + query = query.Where(n => n.BoardId == boardId.Value); + } + + return await query.ToListAsync(cancellationToken); + } } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/NotificationServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/NotificationServiceTests.cs index 23fb0f1e8..11c3715f7 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/NotificationServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/NotificationServiceTests.cs @@ -164,6 +164,66 @@ public async Task GetNotificationsAsync_ShouldReturnForbidden_WhenBoardLookupFai result.ErrorMessage.Should().Be("You do not have access to notifications for this board"); } + [Fact] + public async Task MarkAllAsReadAsync_ShouldMarkAllUnread_WhenNotificationsExist() + { + var userId = Guid.NewGuid(); + var n1 = new Notification(userId, NotificationType.Mention, NotificationCadence.Immediate, "N1", "Message 1"); + var n2 = new Notification(userId, NotificationType.Assignment, NotificationCadence.Immediate, "N2", "Message 2"); + + _notificationRepositoryMock + .Setup(r => r.GetUnreadByUserIdAsync(userId, null, default)) + .ReturnsAsync(new[] { n1, n2 }); + + var result = await _service.MarkAllAsReadAsync(userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(2); + n1.IsRead.Should().BeTrue(); + n2.IsRead.Should().BeTrue(); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task MarkAllAsReadAsync_ShouldReturnZero_WhenNoUnreadNotifications() + { + var userId = Guid.NewGuid(); + _notificationRepositoryMock + .Setup(r => r.GetUnreadByUserIdAsync(userId, null, default)) + .ReturnsAsync(Array.Empty()); + + var result = await _service.MarkAllAsReadAsync(userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(0); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); + } + + [Fact] + public async Task MarkAllAsReadAsync_ShouldReturnForbidden_WhenBoardAccessDenied() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + + _authorizationServiceMock + .Setup(s => s.CanReadBoardAsync(userId, boardId)) + .ReturnsAsync(Result.Success(false)); + + var result = await _service.MarkAllAsReadAsync(userId, boardId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task MarkAllAsReadAsync_ShouldReturnValidationError_WhenUserIdEmpty() + { + var result = await _service.MarkAllAsReadAsync(Guid.Empty); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + [Fact] public async Task PublishAsync_ShouldAvoidDuplicatesWithinSameUnitOfWork_WhenPreferenceIsNotPersistedYet() { diff --git a/frontend/taskdeck-web/src/api/notificationsApi.ts b/frontend/taskdeck-web/src/api/notificationsApi.ts index 9b770b876..d3f9c01ad 100644 --- a/frontend/taskdeck-web/src/api/notificationsApi.ts +++ b/frontend/taskdeck-web/src/api/notificationsApi.ts @@ -1,6 +1,7 @@ import http from './http' import { buildQueryString } from '../utils/queryBuilder' import type { + MarkAllReadResponse, NotificationItem, NotificationPreference, NotificationQuery, @@ -18,6 +19,12 @@ export const notificationsApi = { return data }, + async markAllRead(boardId?: string): Promise { + const qs = boardId ? `?boardId=${encodeURIComponent(boardId)}` : '' + const { data } = await http.post(`/notifications/mark-all-read${qs}`) + return data + }, + async getPreferences(): Promise { const { data } = await http.get('/notifications/preferences') return data diff --git a/frontend/taskdeck-web/src/composables/useNotificationGrouping.ts b/frontend/taskdeck-web/src/composables/useNotificationGrouping.ts new file mode 100644 index 000000000..990679d8b --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useNotificationGrouping.ts @@ -0,0 +1,162 @@ +import type { NotificationItem, NotificationTypeName } from '../types/notifications' + +/** + * Normalize a notification type (number or string) to a canonical string name. + */ +export function normalizeType(value: number | string): NotificationTypeName { + const s = String(value) + if (s === '0' || s === 'Mention') return 'Mention' + if (s === '1' || s === 'Assignment') return 'Assignment' + if (s === '2' || s === 'ProposalOutcome') return 'ProposalOutcome' + if (s === '3' || s === 'BoardChange') return 'BoardChange' + if (s === '4' || s === 'System') return 'System' + return 'System' +} + +/** + * Human-readable label for a notification type. + */ +export function typeLabel(value: number | string): string { + const t = normalizeType(value) + switch (t) { + case 'Mention': return 'Mention' + case 'Assignment': return 'Assignment' + case 'ProposalOutcome': return 'Proposal' + case 'BoardChange': return 'Board Change' + case 'System': return 'System' + } +} + +/** + * Tailwind border-left color class for a notification type. + * Returns a left-border class. Also includes an aria-compatible + * label via the type badge so color is not the sole differentiator. + */ +export function typeBorderClass(value: number | string): string { + const t = normalizeType(value) + switch (t) { + case 'ProposalOutcome': return 'border-l-4 border-l-amber-500' + case 'Mention': return 'border-l-4 border-l-blue-500' + case 'BoardChange': return 'border-l-4 border-l-green-500' + case 'Assignment': return 'border-l-4 border-l-purple-500' + case 'System': return 'border-l-4 border-l-gray-400' + } +} + +/** + * Tailwind badge classes for a notification type. + */ +export function typeBadgeClass(value: number | string): string { + const t = normalizeType(value) + switch (t) { + case 'ProposalOutcome': return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' + case 'Mention': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' + case 'BoardChange': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' + case 'Assignment': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' + case 'System': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' + } +} + +// ---------- Time-based grouping ---------- + +export type TimeGroup = 'Today' | 'Yesterday' | 'This week' | 'Older' + +/** + * Assign a notification to a time-based group relative to the given "now" date. + */ +export function timeGroup(createdAt: string, now: Date = new Date()): TimeGroup { + const created = new Date(createdAt) + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterdayStart = new Date(todayStart) + yesterdayStart.setDate(yesterdayStart.getDate() - 1) + const weekStart = new Date(todayStart) + weekStart.setDate(weekStart.getDate() - 6) + + if (created >= todayStart) return 'Today' + if (created >= yesterdayStart) return 'Yesterday' + if (created >= weekStart) return 'This week' + return 'Older' +} + +// ---------- Smart grouping ---------- + +export interface NotificationGroup { + /** Unique key for v-for */ + key: string + /** The time-based header this group belongs to */ + timeHeader: TimeGroup + /** If true, this group is a collapsed summary of multiple same-type notifications */ + isCollapsed: boolean + /** Summary label when collapsed, e.g. "3 automation proposals updated" */ + summaryLabel: string | null + /** The individual notifications in this group */ + items: NotificationItem[] +} + +/** + * Group notifications by time header and collapse consecutive same-type + * notifications into summary groups. + * + * Notifications are expected to be sorted by createdAt descending (newest first). + */ +export function groupNotifications( + notifications: NotificationItem[], + now: Date = new Date(), +): NotificationGroup[] { + if (notifications.length === 0) return [] + + const groups: NotificationGroup[] = [] + let currentTimeHeader: TimeGroup | null = null + let pendingItems: NotificationItem[] = [] + let pendingType: NotificationTypeName | null = null + + function flushPending() { + if (pendingItems.length === 0 || currentTimeHeader === null) return + + if (pendingItems.length >= 2) { + const label = `${pendingItems.length} ${typeLabel(pendingItems[0].type).toLowerCase()} notifications` + groups.push({ + key: `group-${pendingItems[0].id}`, + timeHeader: currentTimeHeader, + isCollapsed: true, + summaryLabel: label, + items: [...pendingItems], + }) + } else { + groups.push({ + key: `single-${pendingItems[0].id}`, + timeHeader: currentTimeHeader, + isCollapsed: false, + summaryLabel: null, + items: [...pendingItems], + }) + } + pendingItems = [] + pendingType = null + } + + for (const notification of notifications) { + const header = timeGroup(notification.createdAt, now) + const nType = normalizeType(notification.type) + + // Time header changed — flush and start new section + if (header !== currentTimeHeader) { + flushPending() + currentTimeHeader = header + } + + // Same type as pending — accumulate + if (nType === pendingType) { + pendingItems.push(notification) + continue + } + + // Different type — flush previous and start new pending + flushPending() + pendingType = nType + pendingItems = [notification] + } + + flushPending() + return groups +} diff --git a/frontend/taskdeck-web/src/store/notificationStore.ts b/frontend/taskdeck-web/src/store/notificationStore.ts index 3a3bf34d6..dbf2e0b73 100644 --- a/frontend/taskdeck-web/src/store/notificationStore.ts +++ b/frontend/taskdeck-web/src/store/notificationStore.ts @@ -64,6 +64,27 @@ export const useNotificationStore = defineStore('notifications', () => { } } + async function markAllRead(boardId?: string) { + guardDemoMutation() + try { + const result = await notificationsApi.markAllRead(boardId) + notifications.value = notifications.value.map((item) => { + if (boardId && item.boardId !== boardId) return item + return { + ...item, + isRead: true, + readAt: item.readAt ?? new Date().toISOString(), + } + }) + return result + } catch (e: unknown) { + const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message + error.value = msg + toast.error(msg) + throw e + } + } + async function fetchPreferences() { if (isDemoMode) { loading.value = true @@ -112,6 +133,7 @@ export const useNotificationStore = defineStore('notifications', () => { error, fetchNotifications, markAsRead, + markAllRead, fetchPreferences, updatePreferences, } diff --git a/frontend/taskdeck-web/src/tests/api/notificationsApi.spec.ts b/frontend/taskdeck-web/src/tests/api/notificationsApi.spec.ts index ce466f31b..ed69ef523 100644 --- a/frontend/taskdeck-web/src/tests/api/notificationsApi.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/notificationsApi.spec.ts @@ -39,6 +39,24 @@ describe('notificationsApi', () => { expect(http.post).toHaveBeenCalledWith('/notifications/n1/read') }) + it('marks all notifications as read without board scope', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { markedCount: 5 } }) + + const result = await notificationsApi.markAllRead() + + expect(http.post).toHaveBeenCalledWith('/notifications/mark-all-read') + expect(result.markedCount).toBe(5) + }) + + it('marks all notifications as read with board scope', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { markedCount: 3 } }) + + const result = await notificationsApi.markAllRead('board-42') + + expect(http.post).toHaveBeenCalledWith('/notifications/mark-all-read?boardId=board-42') + expect(result.markedCount).toBe(3) + }) + it('updates preferences', async () => { vi.mocked(http.put).mockResolvedValue({ data: { userId: 'u1' } }) diff --git a/frontend/taskdeck-web/src/tests/composables/useNotificationGrouping.spec.ts b/frontend/taskdeck-web/src/tests/composables/useNotificationGrouping.spec.ts new file mode 100644 index 000000000..2566015b2 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/useNotificationGrouping.spec.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeType, + typeLabel, + typeBorderClass, + typeBadgeClass, + timeGroup, + groupNotifications, +} from '../../composables/useNotificationGrouping' +import type { NotificationItem } from '../../types/notifications' + +function makeItem(overrides: Partial = {}): NotificationItem { + return { + id: 'n1', + userId: 'u1', + boardId: null, + type: 'Mention', + cadence: 'Immediate', + title: 'Test', + message: 'Test message', + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } +} + +describe('normalizeType', () => { + it('maps numeric values to type names', () => { + expect(normalizeType(0)).toBe('Mention') + expect(normalizeType(1)).toBe('Assignment') + expect(normalizeType(2)).toBe('ProposalOutcome') + expect(normalizeType(3)).toBe('BoardChange') + expect(normalizeType(4)).toBe('System') + }) + + it('maps string values to type names', () => { + expect(normalizeType('Mention')).toBe('Mention') + expect(normalizeType('Assignment')).toBe('Assignment') + expect(normalizeType('ProposalOutcome')).toBe('ProposalOutcome') + expect(normalizeType('BoardChange')).toBe('BoardChange') + expect(normalizeType('System')).toBe('System') + }) + + it('defaults unknown values to System', () => { + expect(normalizeType('Unknown')).toBe('System') + expect(normalizeType(99)).toBe('System') + }) +}) + +describe('typeLabel', () => { + it('returns human-readable labels', () => { + expect(typeLabel('Mention')).toBe('Mention') + expect(typeLabel('ProposalOutcome')).toBe('Proposal') + expect(typeLabel('BoardChange')).toBe('Board Change') + expect(typeLabel('System')).toBe('System') + expect(typeLabel('Assignment')).toBe('Assignment') + }) +}) + +describe('typeBorderClass', () => { + it('returns amber border for proposals', () => { + expect(typeBorderClass('ProposalOutcome')).toContain('border-l-amber-500') + }) + + it('returns blue border for mentions', () => { + expect(typeBorderClass('Mention')).toContain('border-l-blue-500') + }) + + it('returns green border for board changes', () => { + expect(typeBorderClass('BoardChange')).toContain('border-l-green-500') + }) + + it('returns purple border for assignments', () => { + expect(typeBorderClass('Assignment')).toContain('border-l-purple-500') + }) + + it('returns gray border for system', () => { + expect(typeBorderClass('System')).toContain('border-l-gray-400') + }) + + it('all classes include border-l-4', () => { + expect(typeBorderClass('Mention')).toContain('border-l-4') + expect(typeBorderClass('System')).toContain('border-l-4') + }) +}) + +describe('typeBadgeClass', () => { + it('returns amber badge for proposals', () => { + expect(typeBadgeClass('ProposalOutcome')).toContain('bg-amber-100') + }) + + it('returns blue badge for mentions', () => { + expect(typeBadgeClass('Mention')).toContain('bg-blue-100') + }) + + it('includes dark mode variant', () => { + expect(typeBadgeClass('Mention')).toContain('dark:bg-blue-900') + }) +}) + +describe('timeGroup', () => { + const now = new Date('2026-03-31T14:00:00Z') + + it('classifies today', () => { + expect(timeGroup('2026-03-31T10:00:00Z', now)).toBe('Today') + }) + + it('classifies yesterday', () => { + expect(timeGroup('2026-03-30T20:00:00Z', now)).toBe('Yesterday') + }) + + it('classifies this week', () => { + expect(timeGroup('2026-03-27T12:00:00Z', now)).toBe('This week') + }) + + it('classifies older', () => { + expect(timeGroup('2026-03-20T12:00:00Z', now)).toBe('Older') + }) +}) + +describe('groupNotifications', () => { + const now = new Date('2026-03-31T14:00:00Z') + + it('returns empty for empty input', () => { + expect(groupNotifications([], now)).toEqual([]) + }) + + it('single notification is not collapsed', () => { + const items = [makeItem({ id: 'n1', type: 'Mention', createdAt: '2026-03-31T10:00:00Z' })] + const groups = groupNotifications(items, now) + expect(groups).toHaveLength(1) + expect(groups[0].isCollapsed).toBe(false) + expect(groups[0].summaryLabel).toBeNull() + expect(groups[0].timeHeader).toBe('Today') + expect(groups[0].items).toHaveLength(1) + }) + + it('collapses 2+ consecutive same-type notifications', () => { + const items = [ + makeItem({ id: 'n1', type: 'Mention', createdAt: '2026-03-31T13:00:00Z' }), + makeItem({ id: 'n2', type: 'Mention', createdAt: '2026-03-31T12:00:00Z' }), + makeItem({ id: 'n3', type: 'Mention', createdAt: '2026-03-31T11:00:00Z' }), + ] + const groups = groupNotifications(items, now) + expect(groups).toHaveLength(1) + expect(groups[0].isCollapsed).toBe(true) + expect(groups[0].summaryLabel).toBe('3 mention notifications') + expect(groups[0].items).toHaveLength(3) + }) + + it('does not collapse different types', () => { + const items = [ + makeItem({ id: 'n1', type: 'Mention', createdAt: '2026-03-31T13:00:00Z' }), + makeItem({ id: 'n2', type: 'ProposalOutcome', createdAt: '2026-03-31T12:00:00Z' }), + ] + const groups = groupNotifications(items, now) + expect(groups).toHaveLength(2) + expect(groups[0].isCollapsed).toBe(false) + expect(groups[1].isCollapsed).toBe(false) + }) + + it('splits groups across time boundaries', () => { + const items = [ + makeItem({ id: 'n1', type: 'Mention', createdAt: '2026-03-31T10:00:00Z' }), + makeItem({ id: 'n2', type: 'Mention', createdAt: '2026-03-30T20:00:00Z' }), + ] + const groups = groupNotifications(items, now) + expect(groups).toHaveLength(2) + expect(groups[0].timeHeader).toBe('Today') + expect(groups[1].timeHeader).toBe('Yesterday') + }) + + it('mixes collapsed and single groups', () => { + const items = [ + makeItem({ id: 'n1', type: 'Mention', createdAt: '2026-03-31T13:00:00Z' }), + makeItem({ id: 'n2', type: 'Mention', createdAt: '2026-03-31T12:00:00Z' }), + makeItem({ id: 'n3', type: 'ProposalOutcome', createdAt: '2026-03-31T11:00:00Z' }), + ] + const groups = groupNotifications(items, now) + expect(groups).toHaveLength(2) + expect(groups[0].isCollapsed).toBe(true) + expect(groups[0].summaryLabel).toBe('2 mention notifications') + expect(groups[1].isCollapsed).toBe(false) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/notificationStore.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStore.spec.ts index 301cd7cf4..395f3dd01 100644 --- a/frontend/taskdeck-web/src/tests/store/notificationStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/notificationStore.spec.ts @@ -12,6 +12,7 @@ vi.mock('../../api/notificationsApi', () => ({ notificationsApi: { getNotifications: vi.fn(), markAsRead: vi.fn(), + markAllRead: vi.fn(), getPreferences: vi.fn(), updatePreferences: vi.fn(), }, @@ -154,6 +155,104 @@ describe('notificationStore', () => { expect(toastMocks.success).toHaveBeenCalled() }) + it('marks all notifications as read in local state', async () => { + const store = useNotificationStore() + store.notifications = [ + { + id: 'n1', + userId: 'u1', + boardId: null, + type: 'Mention', + cadence: 'Immediate', + title: 'First', + message: 'Message 1', + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 'n2', + userId: 'u1', + boardId: null, + type: 'Mention', + cadence: 'Immediate', + title: 'Second', + message: 'Message 2', + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + vi.mocked(notificationsApi.markAllRead).mockResolvedValue({ markedCount: 2 }) + + await store.markAllRead() + + expect(store.notifications[0].isRead).toBe(true) + expect(store.notifications[1].isRead).toBe(true) + expect(notificationsApi.markAllRead).toHaveBeenCalledWith(undefined) + }) + + it('only marks board-scoped notifications when boardId provided', async () => { + const store = useNotificationStore() + store.notifications = [ + { + id: 'n1', + userId: 'u1', + boardId: 'board-A', + type: 'ProposalOutcome', + cadence: 'Immediate', + title: 'First', + message: 'Message 1', + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 'n2', + userId: 'u1', + boardId: 'board-B', + type: 'Mention', + cadence: 'Immediate', + title: 'Second', + message: 'Message 2', + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + vi.mocked(notificationsApi.markAllRead).mockResolvedValue({ markedCount: 1 }) + + await store.markAllRead('board-A') + + expect(store.notifications[0].isRead).toBe(true) + expect(store.notifications[1].isRead).toBe(false) + expect(notificationsApi.markAllRead).toHaveBeenCalledWith('board-A') + }) + + it('sets error when markAllRead fails', async () => { + const store = useNotificationStore() + vi.mocked(notificationsApi.markAllRead).mockRejectedValue(new Error('batch failed')) + + await expect(store.markAllRead()).rejects.toBeInstanceOf(Error) + + expect(store.error).toBe('Failed to mark all notifications as read') + expect(toastMocks.error).toHaveBeenCalledWith('Failed to mark all notifications as read') + }) + it('sets error and toasts when fetching notifications fails', async () => { const store = useNotificationStore() vi.mocked(notificationsApi.getNotifications).mockRejectedValue(new Error('network')) diff --git a/frontend/taskdeck-web/src/tests/views/NotificationInboxView.spec.ts b/frontend/taskdeck-web/src/tests/views/NotificationInboxView.spec.ts index c52869c43..4098821b7 100644 --- a/frontend/taskdeck-web/src/tests/views/NotificationInboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/NotificationInboxView.spec.ts @@ -30,6 +30,7 @@ const mockNotificationStore = reactive({ error: null as string | null, fetchNotifications: vi.fn<(query?: { unreadOnly?: boolean; boardId?: string; limit?: number }) => Promise>(), markAsRead: vi.fn<(notificationId: string) => Promise>(), + markAllRead: vi.fn<(boardId?: string) => Promise<{ markedCount: number }>>(), }) vi.mock('../../store/notificationStore', () => ({ @@ -60,6 +61,7 @@ describe('NotificationInboxView', () => { mockNotificationStore.error = null mockNotificationStore.fetchNotifications.mockResolvedValue(undefined) mockNotificationStore.markAsRead.mockResolvedValue(undefined) + mockNotificationStore.markAllRead.mockResolvedValue({ markedCount: 0 }) routerMocks.push.mockReset() routeMock.query = {} }) @@ -92,7 +94,6 @@ describe('NotificationInboxView', () => { expect(wrapper.text()).toContain('Mark read') expect(wrapper.text()).toContain('Open Proposal') expect(wrapper.text()).toContain('Board-linked') - expect(wrapper.text()).not.toContain('board board-1') }) it('marks notification as read when action is clicked', async () => { @@ -166,4 +167,190 @@ describe('NotificationInboxView', () => { hash: '#proposal-proposal-42', }) }) + + it('renders type badge with accessible label', async () => { + mockNotificationStore.notifications = [ + { + id: 'n1', + title: 'Test', + message: 'Test message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: true, + readAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + const wrapper = mount(NotificationInboxView) + await waitForUi() + + expect(wrapper.text()).toContain('Mention') + }) + + it('shows Mark all read button when there are unread notifications', async () => { + mockNotificationStore.notifications = [ + { + id: 'n1', + title: 'Unread', + message: 'Message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + const wrapper = mount(NotificationInboxView) + await waitForUi() + + const markAllBtn = wrapper.findAll('button').find((b) => b.text() === 'Mark all read') + expect(markAllBtn).toBeDefined() + }) + + it('hides Mark all read button when all notifications are read', async () => { + mockNotificationStore.notifications = [ + { + id: 'n1', + title: 'Read', + message: 'Message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: true, + readAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + const wrapper = mount(NotificationInboxView) + await waitForUi() + + const markAllBtn = wrapper.findAll('button').find((b) => b.text() === 'Mark all read') + expect(markAllBtn).toBeUndefined() + }) + + it('calls markAllRead on the store when Mark all read is clicked', async () => { + mockNotificationStore.notifications = [ + { + id: 'n1', + title: 'Unread', + message: 'Message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: false, + readAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + + const wrapper = mount(NotificationInboxView) + await waitForUi() + + const markAllBtn = wrapper.findAll('button').find((b) => b.text() === 'Mark all read') + await markAllBtn?.trigger('click') + + expect(mockNotificationStore.markAllRead).toHaveBeenCalledWith(undefined) + }) + + it('renders time header sections', async () => { + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + mockNotificationStore.notifications = [ + { + id: 'n1', + title: 'Today item', + message: 'Message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: true, + readAt: new Date().toISOString(), + createdAt: today.toISOString(), + updatedAt: today.toISOString(), + }, + { + id: 'n2', + title: 'Yesterday item', + message: 'Message', + boardId: null, + type: 'Assignment', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: true, + readAt: new Date().toISOString(), + createdAt: yesterday.toISOString(), + updatedAt: yesterday.toISOString(), + }, + ] + + const wrapper = mount(NotificationInboxView) + await waitForUi() + + expect(wrapper.text()).toContain('Today') + expect(wrapper.text()).toContain('Yesterday') + }) + + it('shows collapsed summary for consecutive same-type notifications', async () => { + const now = new Date() + const t1 = new Date(now.getTime() - 1000) + const t2 = new Date(now.getTime() - 2000) + + mockNotificationStore.notifications = [ + { + id: 'n1', + title: 'Mention 1', + message: 'Message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: true, + readAt: null, + createdAt: t1.toISOString(), + updatedAt: t1.toISOString(), + }, + { + id: 'n2', + title: 'Mention 2', + message: 'Message', + boardId: null, + type: 'Mention', + cadence: 0, + sourceEntityType: null, + sourceEntityId: null, + isRead: true, + readAt: null, + createdAt: t2.toISOString(), + updatedAt: t2.toISOString(), + }, + ] + + const wrapper = mount(NotificationInboxView) + await waitForUi() + + expect(wrapper.text()).toContain('2 mention notifications') + }) }) diff --git a/frontend/taskdeck-web/src/types/notifications.ts b/frontend/taskdeck-web/src/types/notifications.ts index 75a5d4d1a..0e687ac0a 100644 --- a/frontend/taskdeck-web/src/types/notifications.ts +++ b/frontend/taskdeck-web/src/types/notifications.ts @@ -1,3 +1,10 @@ +export type NotificationTypeName = + | 'Mention' + | 'Assignment' + | 'ProposalOutcome' + | 'BoardChange' + | 'System' + export interface NotificationItem { id: string userId: string @@ -14,6 +21,10 @@ export interface NotificationItem { updatedAt: string } +export interface MarkAllReadResponse { + markedCount: number +} + export interface NotificationQuery { unreadOnly?: boolean boardId?: string diff --git a/frontend/taskdeck-web/src/views/NotificationInboxView.vue b/frontend/taskdeck-web/src/views/NotificationInboxView.vue index c5ca45598..e6a155df7 100644 --- a/frontend/taskdeck-web/src/views/NotificationInboxView.vue +++ b/frontend/taskdeck-web/src/views/NotificationInboxView.vue @@ -3,6 +3,14 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useNotificationStore } from '../store/notificationStore' import { getErrorDisplay } from '../composables/useErrorMapper' +import { + groupNotifications, + typeBorderClass, + typeBadgeClass, + typeLabel, + type NotificationGroup, + type TimeGroup, +} from '../composables/useNotificationGrouping' import type { NotificationItem } from '../types/notifications' import { normalizeBoardIdQueryParam } from '../utils/navigation' @@ -11,17 +19,29 @@ const route = useRoute() const router = useRouter() const unreadOnly = ref(false) const inlineError = ref(null) +const expandedGroups = ref>(new Set()) const items = computed(() => notifications.notifications) const unreadCount = computed(() => items.value.filter((item) => !item.isRead).length) const activeBoardId = computed(() => normalizeBoardIdQueryParam(route.query.boardId)) -function formatType(value: number | string): string { - const normalized = String(value) - if (normalized === '0' || normalized === 'Mention') return 'Mention' - if (normalized === '1' || normalized === 'Assignment') return 'Assignment' - if (normalized === '2' || normalized === 'ProposalOutcome') return 'Proposal Outcome' - return normalized +const grouped = computed(() => groupNotifications(items.value)) + +/** Distinct time headers in display order */ +const timeHeaders = computed(() => { + const seen = new Set() + const result: TimeGroup[] = [] + for (const g of grouped.value) { + if (!seen.has(g.timeHeader)) { + seen.add(g.timeHeader) + result.push(g.timeHeader) + } + } + return result +}) + +function groupsForHeader(header: TimeGroup): NotificationGroup[] { + return grouped.value.filter((g) => g.timeHeader === header) } function formatCadence(value: number | string): string { @@ -35,6 +55,14 @@ function normalizeSourceEntityType(value: string | null): string { return value?.trim().toLowerCase() ?? '' } +function toggleGroupExpand(key: string) { + if (expandedGroups.value.has(key)) { + expandedGroups.value.delete(key) + } else { + expandedGroups.value.add(key) + } +} + async function loadNotifications() { inlineError.value = null try { @@ -57,6 +85,15 @@ async function markAsRead(notificationId: string) { } } +async function markAllRead() { + inlineError.value = null + try { + await notifications.markAllRead(activeBoardId.value || undefined) + } catch (e: unknown) { + inlineError.value = getErrorDisplay(e, notifications.error || 'Failed to mark all as read').message + } +} + function destinationLabel(item: NotificationItem): string | null { if (normalizeSourceEntityType(item.sourceEntityType) === 'proposal' && item.sourceEntityId) { return 'Open Proposal' @@ -97,24 +134,33 @@ watch([unreadOnly, activeBoardId], () => {