Skip to content

Commit ce18308

Browse files
authored
Merge pull request #810 from Chris0Jeky/feature/calendar-timeline-views
Add calendar and timeline planning views for due-date workflows
2 parents fb2a9a8 + 057b7b6 commit ce18308

File tree

16 files changed

+1446
-0
lines changed

16 files changed

+1446
-0
lines changed

backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,33 @@ public async Task<IActionResult> UpdateOnboarding(
7474
var result = await _workspaceService.UpdateOnboardingAsync(userId, dto, cancellationToken);
7575
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
7676
}
77+
78+
/// <summary>
79+
/// Get cards with due dates within the specified date range across all accessible boards.
80+
/// Returns calendar items suitable for calendar/timeline visualization.
81+
/// </summary>
82+
/// <param name="from">Start of the date range (inclusive). Defaults to start of current month.</param>
83+
/// <param name="to">End of the date range (exclusive). Defaults to end of current month.</param>
84+
/// <response code="200">Returns calendar cards for the date range.</response>
85+
/// <response code="400">Invalid date range (from >= to or span > 90 days).</response>
86+
/// <response code="401">Authentication required.</response>
87+
[HttpGet("calendar")]
88+
[ProducesResponseType(typeof(WorkspaceCalendarDto), StatusCodes.Status200OK)]
89+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
90+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
91+
public async Task<IActionResult> GetCalendar(
92+
[FromQuery] DateTimeOffset? from,
93+
[FromQuery] DateTimeOffset? to,
94+
CancellationToken cancellationToken = default)
95+
{
96+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
97+
return errorResult!;
98+
99+
var now = DateTimeOffset.UtcNow;
100+
var effectiveFrom = from ?? new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero);
101+
var effectiveTo = to ?? effectiveFrom.AddMonths(1);
102+
103+
var result = await _workspaceService.GetCalendarAsync(userId, effectiveFrom, effectiveTo, cancellationToken);
104+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
105+
}
77106
}

backend/src/Taskdeck.Application/DTOs/WorkspaceDtos.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,22 @@ public record WorkspaceNextActionDto(
8585
string TargetSurface,
8686
Guid? BoardId = null,
8787
int? AttentionCount = null);
88+
89+
public record WorkspaceCalendarCardDto(
90+
Guid CardId,
91+
Guid BoardId,
92+
string BoardName,
93+
Guid ColumnId,
94+
string ColumnName,
95+
string Title,
96+
DateTimeOffset DueDate,
97+
bool IsBlocked,
98+
string? BlockReason,
99+
bool IsOverdue,
100+
DateTimeOffset UpdatedAt);
101+
102+
public record WorkspaceCalendarDto(
103+
DateTimeOffset From,
104+
DateTimeOffset To,
105+
int TotalCards,
106+
IReadOnlyList<WorkspaceCalendarCardDto> Cards);

backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,14 @@ Task<IEnumerable<Card>> GetBlockedByBoardIdAsync(
4141
Guid boardId,
4242
Guid? labelId = null,
4343
CancellationToken cancellationToken = default);
44+
45+
/// <summary>
46+
/// Get cards with due dates falling within the specified date range across multiple boards.
47+
/// Returns cards ordered by due date ascending.
48+
/// </summary>
49+
Task<IEnumerable<Card>> GetByDueDateRangeAsync(
50+
IEnumerable<Guid> boardIds,
51+
DateTimeOffset from,
52+
DateTimeOffset to,
53+
CancellationToken cancellationToken = default);
4454
}

backend/src/Taskdeck.Application/Services/IWorkspaceService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ Task<Result<WorkspaceOnboardingDto>> UpdateOnboardingAsync(
1616
Guid userId,
1717
UpdateWorkspaceOnboardingDto dto,
1818
CancellationToken cancellationToken = default);
19+
Task<Result<WorkspaceCalendarDto>> GetCalendarAsync(
20+
Guid userId,
21+
DateTimeOffset from,
22+
DateTimeOffset to,
23+
CancellationToken cancellationToken = default);
1924
}

backend/src/Taskdeck.Application/Services/WorkspaceService.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,61 @@ public async Task<Result<WorkspaceOnboardingDto>> UpdateOnboardingAsync(
252252
return Result.Success(onboarding);
253253
}
254254

255+
public async Task<Result<WorkspaceCalendarDto>> GetCalendarAsync(
256+
Guid userId,
257+
DateTimeOffset from,
258+
DateTimeOffset to,
259+
CancellationToken cancellationToken = default)
260+
{
261+
if (userId == Guid.Empty)
262+
return Result.Failure<WorkspaceCalendarDto>(ErrorCodes.ValidationError, "User ID cannot be empty");
263+
264+
if (from >= to)
265+
return Result.Failure<WorkspaceCalendarDto>(ErrorCodes.ValidationError, "The 'from' date must be before the 'to' date");
266+
267+
// Cap range to 90 days to prevent unbounded queries
268+
var maxRange = TimeSpan.FromDays(90);
269+
if (to - from > maxRange)
270+
return Result.Failure<WorkspaceCalendarDto>(ErrorCodes.ValidationError, "Date range cannot exceed 90 days");
271+
272+
var accessibleBoards = (await _unitOfWork.Boards.GetReadableByUserIdAsync(
273+
userId,
274+
includeArchived: false,
275+
cancellationToken))
276+
.ToList();
277+
278+
if (accessibleBoards.Count == 0)
279+
{
280+
return Result.Success(new WorkspaceCalendarDto(from, to, 0, []));
281+
}
282+
283+
var cards = (await _unitOfWork.Cards.GetByDueDateRangeAsync(
284+
accessibleBoards.Select(b => b.Id),
285+
from,
286+
to,
287+
cancellationToken))
288+
.ToList();
289+
290+
var referenceTime = DateTimeOffset.UtcNow;
291+
292+
var calendarCards = cards
293+
.Select(c => new WorkspaceCalendarCardDto(
294+
c.Id,
295+
c.BoardId,
296+
c.Board?.Name ?? "Unknown",
297+
c.ColumnId,
298+
c.Column?.Name ?? "Unknown",
299+
c.Title,
300+
c.DueDate!.Value,
301+
c.IsBlocked,
302+
c.BlockReason,
303+
ResolveDueBucket(c.DueDate, referenceTime) == TodayDueBucket.Overdue,
304+
c.UpdatedAt))
305+
.ToList();
306+
307+
return Result.Success(new WorkspaceCalendarDto(from, to, calendarCards.Count, calendarCards));
308+
}
309+
255310
private async Task<UserPreference> EnsurePreferenceAsync(Guid userId, CancellationToken cancellationToken)
256311
{
257312
return await _unitOfWork.UserPreferences.GetOrCreateDefaultByUserIdAsync(userId, cancellationToken);

backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,35 @@ public async Task<IEnumerable<Card>> GetBlockedByBoardIdAsync(
237237

238238
return await query.ToListAsync(cancellationToken);
239239
}
240+
241+
public async Task<IEnumerable<Card>> GetByDueDateRangeAsync(
242+
IEnumerable<Guid> boardIds,
243+
DateTimeOffset from,
244+
DateTimeOffset to,
245+
CancellationToken cancellationToken = default)
246+
{
247+
var materializedBoardIds = boardIds
248+
.Where(id => id != Guid.Empty)
249+
.Distinct()
250+
.ToList();
251+
252+
if (materializedBoardIds.Count == 0)
253+
return [];
254+
255+
const int maxResults = 500;
256+
257+
return await _dbSet
258+
.AsNoTracking()
259+
.Where(c =>
260+
materializedBoardIds.Contains(c.BoardId) &&
261+
c.DueDate.HasValue &&
262+
c.DueDate.Value >= from &&
263+
c.DueDate.Value < to)
264+
.Include(c => c.Board)
265+
.Include(c => c.Column)
266+
.OrderBy(c => c.DueDate)
267+
.ThenBy(c => c.BoardId)
268+
.Take(maxResults)
269+
.ToListAsync(cancellationToken);
270+
}
240271
}

backend/tests/Taskdeck.Application.Tests/Services/WorkspaceServiceTests.cs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,4 +463,175 @@ public async Task UpdateOnboardingAsync_ShouldDismissAndReplayState()
463463
replayResult.Value.Visibility.Should().Be(WorkspaceOnboardingVisibilityContract.Active);
464464
preference.OnboardingDismissedAt.Should().BeNull();
465465
}
466+
467+
// ── GetCalendarAsync ──────────────────────────────────────────────────────────
468+
469+
[Fact]
470+
public async Task GetCalendarAsync_ShouldReturnValidationError_WhenUserIdIsEmpty()
471+
{
472+
var result = await _service.GetCalendarAsync(
473+
Guid.Empty,
474+
DateTimeOffset.UtcNow,
475+
DateTimeOffset.UtcNow.AddDays(30));
476+
477+
result.IsSuccess.Should().BeFalse();
478+
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
479+
result.ErrorMessage.Should().Contain("User ID cannot be empty");
480+
}
481+
482+
[Fact]
483+
public async Task GetCalendarAsync_ShouldReturnValidationError_WhenFromIsAfterTo()
484+
{
485+
var userId = Guid.NewGuid();
486+
var now = DateTimeOffset.UtcNow;
487+
488+
var result = await _service.GetCalendarAsync(userId, now.AddDays(10), now);
489+
490+
result.IsSuccess.Should().BeFalse();
491+
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
492+
result.ErrorMessage.Should().Contain("'from' date must be before the 'to' date");
493+
}
494+
495+
[Fact]
496+
public async Task GetCalendarAsync_ShouldReturnValidationError_WhenRangeExceeds90Days()
497+
{
498+
var userId = Guid.NewGuid();
499+
var from = DateTimeOffset.UtcNow;
500+
var to = from.AddDays(91);
501+
502+
var result = await _service.GetCalendarAsync(userId, from, to);
503+
504+
result.IsSuccess.Should().BeFalse();
505+
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
506+
result.ErrorMessage.Should().Contain("90 days");
507+
}
508+
509+
[Fact]
510+
public async Task GetCalendarAsync_ShouldReturnEmptyResult_WhenNoAccessibleBoards()
511+
{
512+
var userId = Guid.NewGuid();
513+
var from = DateTimeOffset.UtcNow;
514+
var to = from.AddDays(30);
515+
516+
_boardRepositoryMock
517+
.Setup(r => r.GetReadableByUserIdAsync(userId, false, default))
518+
.ReturnsAsync([]);
519+
520+
var result = await _service.GetCalendarAsync(userId, from, to);
521+
522+
result.IsSuccess.Should().BeTrue();
523+
result.Value.TotalCards.Should().Be(0);
524+
result.Value.Cards.Should().BeEmpty();
525+
}
526+
527+
[Fact]
528+
public async Task GetCalendarAsync_ShouldReturnCardsWithDueDates()
529+
{
530+
var userId = Guid.NewGuid();
531+
var columnId = Guid.NewGuid();
532+
var from = new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero);
533+
var to = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero);
534+
535+
var board = new Board("Test Board", ownerId: userId);
536+
var card = new Card(board.Id, columnId, "Test Card", dueDate: new DateTimeOffset(2026, 4, 15, 0, 0, 0, TimeSpan.Zero));
537+
538+
_boardRepositoryMock
539+
.Setup(r => r.GetReadableByUserIdAsync(userId, false, default))
540+
.ReturnsAsync(new List<Board> { board });
541+
542+
_cardRepositoryMock
543+
.Setup(r => r.GetByDueDateRangeAsync(
544+
It.IsAny<IEnumerable<Guid>>(),
545+
from,
546+
to,
547+
default))
548+
.ReturnsAsync(new List<Card> { card });
549+
550+
var result = await _service.GetCalendarAsync(userId, from, to);
551+
552+
result.IsSuccess.Should().BeTrue();
553+
result.Value.TotalCards.Should().Be(1);
554+
result.Value.Cards.Should().HaveCount(1);
555+
result.Value.Cards[0].Title.Should().Be("Test Card");
556+
result.Value.Cards[0].BoardId.Should().Be(board.Id);
557+
}
558+
559+
[Fact]
560+
public async Task GetCalendarAsync_ShouldMarkOverdueCards()
561+
{
562+
var userId = Guid.NewGuid();
563+
var columnId = Guid.NewGuid();
564+
var from = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
565+
var to = new DateTimeOffset(2020, 2, 1, 0, 0, 0, TimeSpan.Zero);
566+
567+
var board = new Board("Test Board", ownerId: userId);
568+
var pastDueDate = new DateTimeOffset(2020, 1, 15, 0, 0, 0, TimeSpan.Zero);
569+
var card = new Card(board.Id, columnId, "Past Due Card", dueDate: pastDueDate);
570+
571+
_boardRepositoryMock
572+
.Setup(r => r.GetReadableByUserIdAsync(userId, false, default))
573+
.ReturnsAsync(new List<Board> { board });
574+
575+
_cardRepositoryMock
576+
.Setup(r => r.GetByDueDateRangeAsync(
577+
It.IsAny<IEnumerable<Guid>>(),
578+
from,
579+
to,
580+
default))
581+
.ReturnsAsync(new List<Card> { card });
582+
583+
var result = await _service.GetCalendarAsync(userId, from, to);
584+
585+
result.IsSuccess.Should().BeTrue();
586+
result.Value.Cards[0].IsOverdue.Should().BeTrue();
587+
}
588+
589+
[Fact]
590+
public async Task GetCalendarAsync_ShouldIncludeBlockedStatus()
591+
{
592+
var userId = Guid.NewGuid();
593+
var columnId = Guid.NewGuid();
594+
var from = DateTimeOffset.UtcNow;
595+
var to = from.AddDays(30);
596+
597+
var board = new Board("Test Board", ownerId: userId);
598+
var card = new Card(board.Id, columnId, "Blocked Card", dueDate: from.AddDays(5));
599+
card.Block("Waiting on dependency");
600+
601+
_boardRepositoryMock
602+
.Setup(r => r.GetReadableByUserIdAsync(userId, false, default))
603+
.ReturnsAsync(new List<Board> { board });
604+
605+
_cardRepositoryMock
606+
.Setup(r => r.GetByDueDateRangeAsync(
607+
It.IsAny<IEnumerable<Guid>>(),
608+
from,
609+
to,
610+
default))
611+
.ReturnsAsync(new List<Card> { card });
612+
613+
var result = await _service.GetCalendarAsync(userId, from, to);
614+
615+
result.IsSuccess.Should().BeTrue();
616+
result.Value.Cards[0].IsBlocked.Should().BeTrue();
617+
result.Value.Cards[0].BlockReason.Should().Be("Waiting on dependency");
618+
}
619+
620+
[Fact]
621+
public async Task GetCalendarAsync_ShouldReturnFromAndToInResponse()
622+
{
623+
var userId = Guid.NewGuid();
624+
var from = new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero);
625+
var to = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero);
626+
627+
_boardRepositoryMock
628+
.Setup(r => r.GetReadableByUserIdAsync(userId, false, default))
629+
.ReturnsAsync([]);
630+
631+
var result = await _service.GetCalendarAsync(userId, from, to);
632+
633+
result.IsSuccess.Should().BeTrue();
634+
result.Value.From.Should().Be(from);
635+
result.Value.To.Should().Be(to);
636+
}
466637
}

frontend/taskdeck-web/src/api/workspaceApi.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import http from './http'
22
import type {
3+
CalendarData,
34
HomeSummary,
45
TodaySummary,
56
UpdateWorkspaceOnboardingDto,
@@ -19,6 +20,12 @@ export const workspaceApi = {
1920
return data
2021
},
2122

23+
async getCalendar(from: string, to: string): Promise<CalendarData> {
24+
const params = new URLSearchParams({ from, to })
25+
const { data } = await http.get<CalendarData>(`/workspace/calendar?${params}`)
26+
return data
27+
},
28+
2229
async getPreferences(): Promise<WorkspacePreference> {
2330
const { data } = await http.get<WorkspacePreference>('/workspace/preferences')
2431
return data

frontend/taskdeck-web/src/components/shell/ShellSidebar.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ const navCatalog: NavItem[] = [
151151
secondaryModes: ['guided', 'agent'],
152152
keywords: 'chat automation assistant board context',
153153
},
154+
{
155+
id: 'calendar',
156+
label: 'Calendar',
157+
icon: 'D',
158+
path: '/workspace/calendar',
159+
flag: null,
160+
primaryModes: ['workbench'],
161+
secondaryModes: ['guided', 'agent'],
162+
keywords: 'calendar timeline planning due dates schedule deadlines',
163+
},
154164
{
155165
id: 'metrics',
156166
label: 'Metrics',

0 commit comments

Comments
 (0)