feat: a sprint calendar

A Calendario tab lays the project's sprints out month by month as bands,
with the releases and the tickets due on each day. Owners and admins plan
a sprint by clicking the day it starts, and the whole plan downloads as an
iCalendar file. Tickets can be searched by due-date range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:20:28 +02:00
parent feea58d0e8
commit 8c998d7423
21 changed files with 1107 additions and 1 deletions

View File

@@ -28,6 +28,9 @@ export interface IssueFilter {
/** Only the tickets hanging from this one. */
parentId?: string;
sprintId?: string;
/** Due between these days (`YYYY-MM-DD`, both included). */
dueFrom?: string;
dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** RECENT (default): last updated first. RANK: backlog order. */

View File

@@ -413,6 +413,13 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
if (filter.releaseId) query['release.id'] = filter.releaseId;
if (filter.parentId) query['parent.id'] = filter.parentId;
if (filter.dueFrom || filter.dueTo)
query['dueDate'] = {
$ne: null,
...(filter.dueFrom ? { $gte: filter.dueFrom } : {}),
// Full ISO dates on the last day still count.
...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}),
};
if (filter.noSprint) query['sprint'] = null;
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
if (filter.unassigned) query['assignee'] = null;

View File

@@ -508,6 +508,20 @@ export class IssueFilterInput {
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@Field({
nullable: true,
description: 'Due on or after this day (YYYY-MM-DD)',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueFrom?: string;
@Field({
nullable: true,
description: 'Due on or before this day (YYYY-MM-DD)',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
@IsOptional()
@IsEnum(IssueSort)

View File

@@ -509,6 +509,22 @@ export class IssueSearchQueryDto extends PageQueryDto {
@IsBoolean()
noSprint?: boolean;
@ApiPropertyOptional({
format: 'date',
description: 'Due on or after this day',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueFrom?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Due on or before this day',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
dueTo?: string;
@ApiPropertyOptional({
enum: IssueSort,
enumName: 'IssueSort',

View File

@@ -164,6 +164,8 @@ export class IssuesController {
labels: query.label,
sprintId: query.sprintId,
noSprint: query.noSprint,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
sort: query.sort,
search: query.search,
limit: query.limit,