diff --git a/backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs b/backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs index 0a9302661..e3483712d 100644 --- a/backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs +++ b/backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs @@ -74,4 +74,33 @@ public async Task UpdateOnboarding( var result = await _workspaceService.UpdateOnboardingAsync(userId, dto, cancellationToken); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + + /// + /// Get cards with due dates within the specified date range across all accessible boards. + /// Returns calendar items suitable for calendar/timeline visualization. + /// + /// Start of the date range (inclusive). Defaults to start of current month. + /// End of the date range (exclusive). Defaults to end of current month. + /// Returns calendar cards for the date range. + /// Invalid date range (from >= to or span > 90 days). + /// Authentication required. + [HttpGet("calendar")] + [ProducesResponseType(typeof(WorkspaceCalendarDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetCalendar( + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var now = DateTimeOffset.UtcNow; + var effectiveFrom = from ?? new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); + var effectiveTo = to ?? effectiveFrom.AddMonths(1); + + var result = await _workspaceService.GetCalendarAsync(userId, effectiveFrom, effectiveTo, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } } diff --git a/backend/src/Taskdeck.Application/DTOs/WorkspaceDtos.cs b/backend/src/Taskdeck.Application/DTOs/WorkspaceDtos.cs index f070e8223..ccc5e7bfb 100644 --- a/backend/src/Taskdeck.Application/DTOs/WorkspaceDtos.cs +++ b/backend/src/Taskdeck.Application/DTOs/WorkspaceDtos.cs @@ -85,3 +85,22 @@ public record WorkspaceNextActionDto( string TargetSurface, Guid? BoardId = null, int? AttentionCount = null); + +public record WorkspaceCalendarCardDto( + Guid CardId, + Guid BoardId, + string BoardName, + Guid ColumnId, + string ColumnName, + string Title, + DateTimeOffset DueDate, + bool IsBlocked, + string? BlockReason, + bool IsOverdue, + DateTimeOffset UpdatedAt); + +public record WorkspaceCalendarDto( + DateTimeOffset From, + DateTimeOffset To, + int TotalCards, + IReadOnlyList Cards); diff --git a/backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs index 6522e2dbe..bae234f30 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs @@ -41,4 +41,14 @@ Task> GetBlockedByBoardIdAsync( Guid boardId, Guid? labelId = null, CancellationToken cancellationToken = default); + + /// + /// Get cards with due dates falling within the specified date range across multiple boards. + /// Returns cards ordered by due date ascending. + /// + Task> GetByDueDateRangeAsync( + IEnumerable boardIds, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/IWorkspaceService.cs b/backend/src/Taskdeck.Application/Services/IWorkspaceService.cs index ea5799f77..7af64a7d0 100644 --- a/backend/src/Taskdeck.Application/Services/IWorkspaceService.cs +++ b/backend/src/Taskdeck.Application/Services/IWorkspaceService.cs @@ -16,4 +16,9 @@ Task> UpdateOnboardingAsync( Guid userId, UpdateWorkspaceOnboardingDto dto, CancellationToken cancellationToken = default); + Task> GetCalendarAsync( + Guid userId, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/WorkspaceService.cs b/backend/src/Taskdeck.Application/Services/WorkspaceService.cs index 41259f53a..6e05503e6 100644 --- a/backend/src/Taskdeck.Application/Services/WorkspaceService.cs +++ b/backend/src/Taskdeck.Application/Services/WorkspaceService.cs @@ -252,6 +252,61 @@ public async Task> UpdateOnboardingAsync( return Result.Success(onboarding); } + public async Task> GetCalendarAsync( + Guid userId, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID cannot be empty"); + + if (from >= to) + return Result.Failure(ErrorCodes.ValidationError, "The 'from' date must be before the 'to' date"); + + // Cap range to 90 days to prevent unbounded queries + var maxRange = TimeSpan.FromDays(90); + if (to - from > maxRange) + return Result.Failure(ErrorCodes.ValidationError, "Date range cannot exceed 90 days"); + + var accessibleBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync( + userId, + includeArchived: false, + cancellationToken)) + .ToList(); + + if (accessibleBoards.Count == 0) + { + return Result.Success(new WorkspaceCalendarDto(from, to, 0, [])); + } + + var cards = (await _unitOfWork.Cards.GetByDueDateRangeAsync( + accessibleBoards.Select(b => b.Id), + from, + to, + cancellationToken)) + .ToList(); + + var referenceTime = DateTimeOffset.UtcNow; + + var calendarCards = cards + .Select(c => new WorkspaceCalendarCardDto( + c.Id, + c.BoardId, + c.Board?.Name ?? "Unknown", + c.ColumnId, + c.Column?.Name ?? "Unknown", + c.Title, + c.DueDate!.Value, + c.IsBlocked, + c.BlockReason, + ResolveDueBucket(c.DueDate, referenceTime) == TodayDueBucket.Overdue, + c.UpdatedAt)) + .ToList(); + + return Result.Success(new WorkspaceCalendarDto(from, to, calendarCards.Count, calendarCards)); + } + private async Task EnsurePreferenceAsync(Guid userId, CancellationToken cancellationToken) { return await _unitOfWork.UserPreferences.GetOrCreateDefaultByUserIdAsync(userId, cancellationToken); diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs index d15973a55..bab5f4864 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs @@ -237,4 +237,35 @@ public async Task> GetBlockedByBoardIdAsync( return await query.ToListAsync(cancellationToken); } + + public async Task> GetByDueDateRangeAsync( + IEnumerable boardIds, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken = default) + { + var materializedBoardIds = boardIds + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + if (materializedBoardIds.Count == 0) + return []; + + const int maxResults = 500; + + return await _dbSet + .AsNoTracking() + .Where(c => + materializedBoardIds.Contains(c.BoardId) && + c.DueDate.HasValue && + c.DueDate.Value >= from && + c.DueDate.Value < to) + .Include(c => c.Board) + .Include(c => c.Column) + .OrderBy(c => c.DueDate) + .ThenBy(c => c.BoardId) + .Take(maxResults) + .ToListAsync(cancellationToken); + } } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceServiceTests.cs index aef8fbaf6..bfac477e7 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceServiceTests.cs @@ -463,4 +463,175 @@ public async Task UpdateOnboardingAsync_ShouldDismissAndReplayState() replayResult.Value.Visibility.Should().Be(WorkspaceOnboardingVisibilityContract.Active); preference.OnboardingDismissedAt.Should().BeNull(); } + + // ── GetCalendarAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetCalendarAsync_ShouldReturnValidationError_WhenUserIdIsEmpty() + { + var result = await _service.GetCalendarAsync( + Guid.Empty, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddDays(30)); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("User ID cannot be empty"); + } + + [Fact] + public async Task GetCalendarAsync_ShouldReturnValidationError_WhenFromIsAfterTo() + { + var userId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + + var result = await _service.GetCalendarAsync(userId, now.AddDays(10), now); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("'from' date must be before the 'to' date"); + } + + [Fact] + public async Task GetCalendarAsync_ShouldReturnValidationError_WhenRangeExceeds90Days() + { + var userId = Guid.NewGuid(); + var from = DateTimeOffset.UtcNow; + var to = from.AddDays(91); + + var result = await _service.GetCalendarAsync(userId, from, to); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("90 days"); + } + + [Fact] + public async Task GetCalendarAsync_ShouldReturnEmptyResult_WhenNoAccessibleBoards() + { + var userId = Guid.NewGuid(); + var from = DateTimeOffset.UtcNow; + var to = from.AddDays(30); + + _boardRepositoryMock + .Setup(r => r.GetReadableByUserIdAsync(userId, false, default)) + .ReturnsAsync([]); + + var result = await _service.GetCalendarAsync(userId, from, to); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalCards.Should().Be(0); + result.Value.Cards.Should().BeEmpty(); + } + + [Fact] + public async Task GetCalendarAsync_ShouldReturnCardsWithDueDates() + { + var userId = Guid.NewGuid(); + var columnId = Guid.NewGuid(); + var from = new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero); + var to = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + + var board = new Board("Test Board", ownerId: userId); + var card = new Card(board.Id, columnId, "Test Card", dueDate: new DateTimeOffset(2026, 4, 15, 0, 0, 0, TimeSpan.Zero)); + + _boardRepositoryMock + .Setup(r => r.GetReadableByUserIdAsync(userId, false, default)) + .ReturnsAsync(new List { board }); + + _cardRepositoryMock + .Setup(r => r.GetByDueDateRangeAsync( + It.IsAny>(), + from, + to, + default)) + .ReturnsAsync(new List { card }); + + var result = await _service.GetCalendarAsync(userId, from, to); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalCards.Should().Be(1); + result.Value.Cards.Should().HaveCount(1); + result.Value.Cards[0].Title.Should().Be("Test Card"); + result.Value.Cards[0].BoardId.Should().Be(board.Id); + } + + [Fact] + public async Task GetCalendarAsync_ShouldMarkOverdueCards() + { + var userId = Guid.NewGuid(); + var columnId = Guid.NewGuid(); + var from = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + var to = new DateTimeOffset(2020, 2, 1, 0, 0, 0, TimeSpan.Zero); + + var board = new Board("Test Board", ownerId: userId); + var pastDueDate = new DateTimeOffset(2020, 1, 15, 0, 0, 0, TimeSpan.Zero); + var card = new Card(board.Id, columnId, "Past Due Card", dueDate: pastDueDate); + + _boardRepositoryMock + .Setup(r => r.GetReadableByUserIdAsync(userId, false, default)) + .ReturnsAsync(new List { board }); + + _cardRepositoryMock + .Setup(r => r.GetByDueDateRangeAsync( + It.IsAny>(), + from, + to, + default)) + .ReturnsAsync(new List { card }); + + var result = await _service.GetCalendarAsync(userId, from, to); + + result.IsSuccess.Should().BeTrue(); + result.Value.Cards[0].IsOverdue.Should().BeTrue(); + } + + [Fact] + public async Task GetCalendarAsync_ShouldIncludeBlockedStatus() + { + var userId = Guid.NewGuid(); + var columnId = Guid.NewGuid(); + var from = DateTimeOffset.UtcNow; + var to = from.AddDays(30); + + var board = new Board("Test Board", ownerId: userId); + var card = new Card(board.Id, columnId, "Blocked Card", dueDate: from.AddDays(5)); + card.Block("Waiting on dependency"); + + _boardRepositoryMock + .Setup(r => r.GetReadableByUserIdAsync(userId, false, default)) + .ReturnsAsync(new List { board }); + + _cardRepositoryMock + .Setup(r => r.GetByDueDateRangeAsync( + It.IsAny>(), + from, + to, + default)) + .ReturnsAsync(new List { card }); + + var result = await _service.GetCalendarAsync(userId, from, to); + + result.IsSuccess.Should().BeTrue(); + result.Value.Cards[0].IsBlocked.Should().BeTrue(); + result.Value.Cards[0].BlockReason.Should().Be("Waiting on dependency"); + } + + [Fact] + public async Task GetCalendarAsync_ShouldReturnFromAndToInResponse() + { + var userId = Guid.NewGuid(); + var from = new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero); + var to = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + + _boardRepositoryMock + .Setup(r => r.GetReadableByUserIdAsync(userId, false, default)) + .ReturnsAsync([]); + + var result = await _service.GetCalendarAsync(userId, from, to); + + result.IsSuccess.Should().BeTrue(); + result.Value.From.Should().Be(from); + result.Value.To.Should().Be(to); + } } diff --git a/frontend/taskdeck-web/src/api/workspaceApi.ts b/frontend/taskdeck-web/src/api/workspaceApi.ts index 502802f42..c979c7a70 100644 --- a/frontend/taskdeck-web/src/api/workspaceApi.ts +++ b/frontend/taskdeck-web/src/api/workspaceApi.ts @@ -1,5 +1,6 @@ import http from './http' import type { + CalendarData, HomeSummary, TodaySummary, UpdateWorkspaceOnboardingDto, @@ -19,6 +20,12 @@ export const workspaceApi = { return data }, + async getCalendar(from: string, to: string): Promise { + const params = new URLSearchParams({ from, to }) + const { data } = await http.get(`/workspace/calendar?${params}`) + return data + }, + async getPreferences(): Promise { const { data } = await http.get('/workspace/preferences') return data diff --git a/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue b/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue index c80983ab5..c8fcef9f4 100644 --- a/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue +++ b/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue @@ -151,6 +151,16 @@ const navCatalog: NavItem[] = [ secondaryModes: ['guided', 'agent'], keywords: 'chat automation assistant board context', }, + { + id: 'calendar', + label: 'Calendar', + icon: 'D', + path: '/workspace/calendar', + flag: null, + primaryModes: ['workbench'], + secondaryModes: ['guided', 'agent'], + keywords: 'calendar timeline planning due dates schedule deadlines', + }, { id: 'metrics', label: 'Metrics', diff --git a/frontend/taskdeck-web/src/composables/useWorkspaceHelp.ts b/frontend/taskdeck-web/src/composables/useWorkspaceHelp.ts index 898c8ed16..0f8311c32 100644 --- a/frontend/taskdeck-web/src/composables/useWorkspaceHelp.ts +++ b/frontend/taskdeck-web/src/composables/useWorkspaceHelp.ts @@ -8,6 +8,7 @@ export const workspaceHelpTopics = [ 'review', 'inbox', 'board', + 'calendar', 'activity-selectors', 'board-access-selectors', 'saved-views', diff --git a/frontend/taskdeck-web/src/router/index.ts b/frontend/taskdeck-web/src/router/index.ts index 7eb6e3566..da9028bca 100644 --- a/frontend/taskdeck-web/src/router/index.ts +++ b/frontend/taskdeck-web/src/router/index.ts @@ -39,6 +39,7 @@ const ReviewView = () => import('../views/ReviewView.vue') const DevToolsView = () => import('../views/DevToolsView.vue') const SavedViewsView = () => import('../views/SavedViewsView.vue') const MetricsView = () => import('../views/MetricsView.vue') +const CalendarView = () => import('../views/CalendarView.vue') const AgentsView = () => import('../views/AgentsView.vue') const AgentRunsView = () => import('../views/AgentRunsView.vue') const AgentRunDetailView = () => import('../views/AgentRunDetailView.vue') @@ -143,6 +144,14 @@ const router = createRouter({ meta: { requiresShell: true }, }, + // Calendar/timeline planning route + { + path: '/workspace/calendar', + name: 'workspace-calendar', + component: CalendarView, + meta: { requiresShell: true }, + }, + // Automation routes { path: '/workspace/automations', diff --git a/frontend/taskdeck-web/src/tests/api/workspaceApi.spec.ts b/frontend/taskdeck-web/src/tests/api/workspaceApi.spec.ts index 444d2d628..cca82f4b8 100644 --- a/frontend/taskdeck-web/src/tests/api/workspaceApi.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/workspaceApi.spec.ts @@ -53,4 +53,22 @@ describe('workspaceApi', () => { expect(http.put).toHaveBeenCalledWith('/workspace/onboarding', { action: 'dismiss' }) }) + + it('loads calendar data with from/to parameters', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { from: '2026-04-01', to: '2026-05-01', totalCards: 0, cards: [] } }) + + const from = '2026-04-01T00:00:00.000Z' + const to = '2026-05-01T00:00:00.000Z' + await workspaceApi.getCalendar(from, to) + + expect(http.get).toHaveBeenCalledWith( + expect.stringContaining('/workspace/calendar?'), + ) + expect(http.get).toHaveBeenCalledWith( + expect.stringContaining('from='), + ) + expect(http.get).toHaveBeenCalledWith( + expect.stringContaining('to='), + ) + }) }) diff --git a/frontend/taskdeck-web/src/tests/router/workspaceRouteStability.spec.ts b/frontend/taskdeck-web/src/tests/router/workspaceRouteStability.spec.ts index cc429167e..928c8f01e 100644 --- a/frontend/taskdeck-web/src/tests/router/workspaceRouteStability.spec.ts +++ b/frontend/taskdeck-web/src/tests/router/workspaceRouteStability.spec.ts @@ -428,6 +428,7 @@ describe('unexpected origin and path redirect protection (#687)', () => { '/workspace/boards', '/workspace/boards/board-1', '/workspace/metrics', + '/workspace/calendar', '/workspace/inbox', '/workspace/review', '/workspace/activity', diff --git a/frontend/taskdeck-web/src/tests/views/CalendarView.spec.ts b/frontend/taskdeck-web/src/tests/views/CalendarView.spec.ts new file mode 100644 index 000000000..9da4f1eed --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/CalendarView.spec.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CalendarView from '../../views/CalendarView.vue' +import type { CalendarData } from '../../types/workspace' + +const routerMocks = vi.hoisted(() => ({ + push: vi.fn(), +})) + +// Freeze time to April 2026 to match mock data +const MOCK_DATE = new Date('2026-04-05T12:00:00Z') + +const mockCalendarData: CalendarData = { + from: '2026-04-01T00:00:00Z', + to: '2026-05-01T00:00:00Z', + totalCards: 3, + cards: [ + { + cardId: 'card-1', + boardId: 'board-1', + boardName: 'Alpha Board', + columnId: 'col-1', + columnName: 'In Progress', + title: 'Ship feature X', + dueDate: '2026-04-10T00:00:00Z', + isBlocked: false, + blockReason: null, + isOverdue: false, + updatedAt: '2026-04-05T12:00:00Z', + }, + { + cardId: 'card-2', + boardId: 'board-2', + boardName: 'Beta Board', + columnId: 'col-2', + columnName: 'Todo', + title: 'Fix urgent bug', + dueDate: '2026-04-03T00:00:00Z', + isBlocked: false, + blockReason: null, + isOverdue: true, + updatedAt: '2026-04-02T12:00:00Z', + }, + { + cardId: 'card-3', + boardId: 'board-1', + boardName: 'Alpha Board', + columnId: 'col-1', + columnName: 'In Progress', + title: 'Blocked task', + dueDate: '2026-04-15T00:00:00Z', + isBlocked: true, + blockReason: 'Waiting on API key', + isOverdue: false, + updatedAt: '2026-04-05T12:00:00Z', + }, + ], +} + +const mockGetCalendar = vi.fn<(from: string, to: string) => Promise>() + +vi.mock('../../api/workspaceApi', () => ({ + workspaceApi: { + getCalendar: (...args: [string, string]) => mockGetCalendar(...args), + }, +})) + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: routerMocks.push, + }), +})) + +vi.mock('../../components/workspace/WorkspaceHelpCallout.vue', () => ({ + default: { + template: '
', + props: ['topic', 'title', 'description'], + }, +})) + +async function waitForUi() { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('CalendarView', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(MOCK_DATE) + vi.clearAllMocks() + mockGetCalendar.mockResolvedValue(mockCalendarData) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('loads calendar data on mount', async () => { + mount(CalendarView) + await waitForUi() + + expect(mockGetCalendar).toHaveBeenCalledTimes(1) + }) + + it('renders the page title and hero description', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + expect(wrapper.text()).toContain('Calendar') + expect(wrapper.text()).toContain('Planning') + expect(wrapper.text()).toContain('due-date-backed work') + }) + + it('shows loading state while fetching', async () => { + // Never resolve + mockGetCalendar.mockReturnValue(new Promise(() => {})) + const wrapper = mount(CalendarView) + await waitForUi() + + expect(wrapper.text()).toContain('Loading calendar data') + }) + + it('shows error state when fetch fails', async () => { + mockGetCalendar.mockRejectedValue(new Error('Network error')) + const wrapper = mount(CalendarView) + await waitForUi() + + expect(wrapper.text()).toContain('Network error') + }) + + it('shows error state with retry button', async () => { + mockGetCalendar.mockRejectedValue(new Error('Server down')) + const wrapper = mount(CalendarView) + await waitForUi() + + const retryBtn = wrapper.find('.td-btn--ghost.td-btn--sm') + expect(retryBtn.exists()).toBe(true) + expect(retryBtn.text()).toContain('Retry') + }) + + it('shows empty state when no cards have due dates', async () => { + mockGetCalendar.mockResolvedValue({ + from: '2026-04-01T00:00:00Z', + to: '2026-05-01T00:00:00Z', + totalCards: 0, + cards: [], + }) + const wrapper = mount(CalendarView) + await waitForUi() + + expect(wrapper.text()).toContain('No due dates this month') + }) + + it('renders card count for the month', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + expect(wrapper.text()).toContain('3 cards this month') + }) + + it('renders calendar grid with weekday headers', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const weekdays = wrapper.findAll('.td-calendar__weekday') + expect(weekdays).toHaveLength(7) + expect(weekdays[0].text()).toBe('Sun') + expect(weekdays[6].text()).toBe('Sat') + }) + + it('renders cards in the calendar grid', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const cardElements = wrapper.findAll('.td-cal-card') + expect(cardElements.length).toBeGreaterThan(0) + expect(wrapper.text()).toContain('Ship feature X') + }) + + it('applies overdue class to overdue cards', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const overdueCards = wrapper.findAll('.td-cal-card--overdue') + expect(overdueCards.length).toBeGreaterThan(0) + }) + + it('applies blocked class to blocked cards', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const blockedCards = wrapper.findAll('.td-cal-card--blocked') + expect(blockedCards.length).toBeGreaterThan(0) + }) + + it('navigates to board on card click in calendar grid', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const card = wrapper.find('.td-cal-card') + await card.trigger('click') + + expect(routerMocks.push).toHaveBeenCalled() + const pushedPath = routerMocks.push.mock.calls[0][0] as string + expect(pushedPath).toMatch(/\/workspace\/boards\/board-/) + }) + + it('switches to timeline view', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const buttons = wrapper.findAll('.td-calendar__hero-actions .td-btn') + const timelineBtn = buttons.find(b => b.text() === 'Timeline') + expect(timelineBtn).toBeDefined() + await timelineBtn!.trigger('click') + + expect(wrapper.findAll('.td-timeline-group').length).toBeGreaterThan(0) + }) + + it('renders timeline cards with status indicators', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + // Switch to timeline + const buttons = wrapper.findAll('.td-calendar__hero-actions .td-btn') + const timelineBtn = buttons.find(b => b.text() === 'Timeline') + await timelineBtn!.trigger('click') + + expect(wrapper.text()).toContain('Ship feature X') + expect(wrapper.text()).toContain('Fix urgent bug') + expect(wrapper.text()).toContain('Blocked task') + expect(wrapper.text()).toContain('Overdue') + expect(wrapper.text()).toContain('Blocked') + }) + + it('shows block reason in timeline view for blocked cards', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const buttons = wrapper.findAll('.td-calendar__hero-actions .td-btn') + const timelineBtn = buttons.find(b => b.text() === 'Timeline') + await timelineBtn!.trigger('click') + + expect(wrapper.text()).toContain('Waiting on API key') + }) + + it('renders month navigation', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const nav = wrapper.find('.td-calendar__nav') + expect(nav.exists()).toBe(true) + expect(nav.text()).toContain('Today') + }) + + it('navigates to next month on arrow click', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const initialCallCount = mockGetCalendar.mock.calls.length + + const nextBtn = wrapper.findAll('.td-calendar__nav .td-btn--ghost')[1] + await nextBtn.trigger('click') + await waitForUi() + + // Should have fetched again + expect(mockGetCalendar.mock.calls.length).toBeGreaterThan(initialCallCount) + }) + + it('navigates to previous month on arrow click', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const initialCallCount = mockGetCalendar.mock.calls.length + + const prevBtn = wrapper.findAll('.td-calendar__nav .td-btn--ghost')[0] + await prevBtn.trigger('click') + await waitForUi() + + expect(mockGetCalendar.mock.calls.length).toBeGreaterThan(initialCallCount) + }) + + it('navigates to board from timeline card click', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const buttons = wrapper.findAll('.td-calendar__hero-actions .td-btn') + const timelineBtn = buttons.find(b => b.text() === 'Timeline') + await timelineBtn!.trigger('click') + + const card = wrapper.find('.td-timeline-card') + await card.trigger('click') + + expect(routerMocks.push).toHaveBeenCalled() + }) + + it('shows board and column names in timeline meta', async () => { + const wrapper = mount(CalendarView) + await waitForUi() + + const buttons = wrapper.findAll('.td-calendar__hero-actions .td-btn') + const timelineBtn = buttons.find(b => b.text() === 'Timeline') + await timelineBtn!.trigger('click') + + expect(wrapper.text()).toContain('Alpha Board') + expect(wrapper.text()).toContain('In Progress') + }) +}) diff --git a/frontend/taskdeck-web/src/types/workspace.ts b/frontend/taskdeck-web/src/types/workspace.ts index 7efbc23bc..0674628f1 100644 --- a/frontend/taskdeck-web/src/types/workspace.ts +++ b/frontend/taskdeck-web/src/types/workspace.ts @@ -107,3 +107,24 @@ export interface TodaySummary { blockedCards: TodayAgendaCard[] recommendedActions: HomeRecommendedAction[] } + +export interface CalendarCard { + cardId: string + boardId: string + boardName: string + columnId: string + columnName: string + title: string + dueDate: string + isBlocked: boolean + blockReason: string | null + isOverdue: boolean + updatedAt: string +} + +export interface CalendarData { + from: string + to: string + totalCards: number + cards: CalendarCard[] +} diff --git a/frontend/taskdeck-web/src/views/CalendarView.vue b/frontend/taskdeck-web/src/views/CalendarView.vue new file mode 100644 index 000000000..bd4ec0575 --- /dev/null +++ b/frontend/taskdeck-web/src/views/CalendarView.vue @@ -0,0 +1,750 @@ + + + + +