Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c94276e
Add GetByDueDateRangeAsync to ICardRepository
Chris0Jeky Apr 9, 2026
9d125ac
Implement GetByDueDateRangeAsync in CardRepository
Chris0Jeky Apr 9, 2026
ba7c34f
Add calendar DTOs for workspace calendar endpoint
Chris0Jeky Apr 9, 2026
b20be60
Add GetCalendarAsync to workspace service
Chris0Jeky Apr 9, 2026
9685713
Add GET /api/workspace/calendar endpoint
Chris0Jeky Apr 9, 2026
53ea37c
Add CalendarCard and CalendarData types for calendar view
Chris0Jeky Apr 9, 2026
1ab0367
Add getCalendar method to workspace API client
Chris0Jeky Apr 9, 2026
5975a74
Add CalendarView with grid and timeline modes
Chris0Jeky Apr 9, 2026
2f9938c
Add /workspace/calendar route for calendar planning view
Chris0Jeky Apr 9, 2026
1681120
Add Calendar nav item to sidebar
Chris0Jeky Apr 9, 2026
7f83048
Add GetCalendarAsync unit tests for workspace service
Chris0Jeky Apr 9, 2026
7b89a03
Add CalendarView unit tests
Chris0Jeky Apr 9, 2026
46384a1
Update workspace API and route stability tests for calendar
Chris0Jeky Apr 9, 2026
5d2cbb1
Fix Board constructor calls in calendar tests
Chris0Jeky Apr 9, 2026
e6fd446
Add 'calendar' to workspace help topics
Chris0Jeky Apr 9, 2026
b916721
Fix calendar timezone, overdue consistency, and query bounds
Chris0Jeky Apr 9, 2026
7b511b5
Merge branch 'main' into feature/calendar-timeline-views
Chris0Jeky Apr 12, 2026
94613cb
Fix calendar timezone consistency and accessibility issues
Chris0Jeky Apr 12, 2026
057b7b6
Remove redundant DueDate.HasValue filter in calendar query
Chris0Jeky Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,33 @@
var result = await _workspaceService.UpdateOnboardingAsync(userId, dto, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

/// <summary>
/// Get cards with due dates within the specified date range across all accessible boards.
/// Returns calendar items suitable for calendar/timeline visualization.
/// </summary>
/// <param name="from">Start of the date range (inclusive). Defaults to start of current month.</param>
/// <param name="to">End of the date range (exclusive). Defaults to end of current month.</param>
/// <response code="200">Returns calendar cards for the date range.</response>
/// <response code="400">Invalid date range (from >= to or span > 90 days).</response>
/// <response code="401">Authentication required.</response>
[HttpGet("calendar")]
[ProducesResponseType(typeof(WorkspaceCalendarDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetCalendar(
[FromQuery] DateTimeOffset? from,
[FromQuery] DateTimeOffset? to,
CancellationToken cancellationToken = default)

Check warning on line 94 in backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs

View workflow job for this annotation

GitHub Actions / OpenAPI Guardrail / OpenAPI Guardrail

Parameter 'cancellationToken' has no matching param tag in the XML comment for 'WorkspaceController.GetCalendar(DateTimeOffset?, DateTimeOffset?, CancellationToken)' (but other parameters do)

Check warning on line 94 in backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs

View workflow job for this annotation

GitHub Actions / OpenAPI Guardrail / OpenAPI Guardrail

Parameter 'cancellationToken' has no matching param tag in the XML comment for 'WorkspaceController.GetCalendar(DateTimeOffset?, DateTimeOffset?, CancellationToken)' (but other parameters do)

Check warning on line 94 in backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs

View workflow job for this annotation

GitHub Actions / API Integration / API Integration (ubuntu-latest)

Parameter 'cancellationToken' has no matching param tag in the XML comment for 'WorkspaceController.GetCalendar(DateTimeOffset?, DateTimeOffset?, CancellationToken)' (but other parameters do)

Check warning on line 94 in backend/src/Taskdeck.Api/Controllers/WorkspaceController.cs

View workflow job for this annotation

GitHub Actions / API Integration / API Integration (windows-latest)

Parameter 'cancellationToken' has no matching param tag in the XML comment for 'WorkspaceController.GetCalendar(DateTimeOffset?, DateTimeOffset?, CancellationToken)' (but other parameters do)
{
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();
}
}
19 changes: 19 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/WorkspaceDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceCalendarCardDto> Cards);
10 changes: 10 additions & 0 deletions backend/src/Taskdeck.Application/Interfaces/ICardRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,14 @@ Task<IEnumerable<Card>> GetBlockedByBoardIdAsync(
Guid boardId,
Guid? labelId = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Get cards with due dates falling within the specified date range across multiple boards.
/// Returns cards ordered by due date ascending.
/// </summary>
Task<IEnumerable<Card>> GetByDueDateRangeAsync(
IEnumerable<Guid> boardIds,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ Task<Result<WorkspaceOnboardingDto>> UpdateOnboardingAsync(
Guid userId,
UpdateWorkspaceOnboardingDto dto,
CancellationToken cancellationToken = default);
Task<Result<WorkspaceCalendarDto>> GetCalendarAsync(
Guid userId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
}
55 changes: 55 additions & 0 deletions backend/src/Taskdeck.Application/Services/WorkspaceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,61 @@ public async Task<Result<WorkspaceOnboardingDto>> UpdateOnboardingAsync(
return Result.Success(onboarding);
}

public async Task<Result<WorkspaceCalendarDto>> GetCalendarAsync(
Guid userId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure<WorkspaceCalendarDto>(ErrorCodes.ValidationError, "User ID cannot be empty");

if (from >= to)
return Result.Failure<WorkspaceCalendarDto>(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<WorkspaceCalendarDto>(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();
Comment on lines +299 to +305
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsOverdue is computed using a full timestamp comparison (c.DueDate < referenceTime). Elsewhere in WorkspaceService overdue is date-based via ResolveDueBucket (cards due today are not overdue). This mismatch will mark “due today” items as overdue once their due time passes (often immediately at midnight) and will diverge from existing Today/saved-view semantics. Compute overdue using the same date-only logic (e.g., reuse ResolveDueBucket / compare DueDate.Value.Date vs the reference day in the due date’s offset).

Copilot uses AI. Check for mistakes.

return Result.Success(new WorkspaceCalendarDto(from, to, calendarCards.Count, calendarCards));
}

private async Task<UserPreference> EnsurePreferenceAsync(Guid userId, CancellationToken cancellationToken)
{
return await _unitOfWork.UserPreferences.GetOrCreateDefaultByUserIdAsync(userId, cancellationToken);
Expand Down
31 changes: 31 additions & 0 deletions backend/src/Taskdeck.Infrastructure/Repositories/CardRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,35 @@ public async Task<IEnumerable<Card>> GetBlockedByBoardIdAsync(

return await query.ToListAsync(cancellationToken);
}

public async Task<IEnumerable<Card>> GetByDueDateRangeAsync(
IEnumerable<Guid> 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove silent 500-card truncation from calendar query

This query hard-caps results with Take(maxResults), but the calendar endpoint returns totalCards as if the dataset is complete and exposes no pagination token/metadata. For users with more than 500 due cards in the selected range, cards are silently dropped and counts become inaccurate, so planning views miss work without warning.

Useful? React with 👍 / 👎.

.ToListAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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> { board });

_cardRepositoryMock
.Setup(r => r.GetByDueDateRangeAsync(
It.IsAny<IEnumerable<Guid>>(),
from,
to,
default))
.ReturnsAsync(new List<Card> { 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> { board });

_cardRepositoryMock
.Setup(r => r.GetByDueDateRangeAsync(
It.IsAny<IEnumerable<Guid>>(),
from,
to,
default))
.ReturnsAsync(new List<Card> { 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> { board });

_cardRepositoryMock
.Setup(r => r.GetByDueDateRangeAsync(
It.IsAny<IEnumerable<Guid>>(),
from,
to,
default))
.ReturnsAsync(new List<Card> { 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);
}
}
7 changes: 7 additions & 0 deletions frontend/taskdeck-web/src/api/workspaceApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import http from './http'
import type {
CalendarData,
HomeSummary,
TodaySummary,
UpdateWorkspaceOnboardingDto,
Expand All @@ -19,6 +20,12 @@ export const workspaceApi = {
return data
},

async getCalendar(from: string, to: string): Promise<CalendarData> {
const params = new URLSearchParams({ from, to })
const { data } = await http.get<CalendarData>(`/workspace/calendar?${params}`)
return data
},

async getPreferences(): Promise<WorkspacePreference> {
const { data } = await http.get<WorkspacePreference>('/workspace/preferences')
return data
Expand Down
10 changes: 10 additions & 0 deletions frontend/taskdeck-web/src/components/shell/ShellSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading