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:
@@ -11,3 +11,4 @@ export * from './lib/models/swimlanes';
|
||||
export * from './lib/models/roadmap';
|
||||
export * from './lib/models/shortcuts';
|
||||
export * from './lib/models/due';
|
||||
export * from './lib/models/calendar';
|
||||
|
||||
56
libs/web/tracker/domain/src/lib/models/calendar.spec.ts
Normal file
56
libs/web/tracker/domain/src/lib/models/calendar.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { addDays, monthGrid, shiftMonth, toIcs, weekBands } from './calendar';
|
||||
|
||||
describe('sprint calendar', () => {
|
||||
it('lays a month out in weeks from Monday', () => {
|
||||
const weeks = monthGrid('2026-10');
|
||||
expect(weeks[0][0]).toBe('2026-09-28');
|
||||
expect(weeks.at(-1)?.[6]).toBe('2026-11-01');
|
||||
expect(weeks.every((w) => w.length === 7)).toBe(true);
|
||||
expect(shiftMonth('2026-12', 1)).toBe('2027-01');
|
||||
expect(shiftMonth('2026-01', -1)).toBe('2025-12');
|
||||
expect(addDays('2026-02-28', 1)).toBe('2026-03-01');
|
||||
});
|
||||
|
||||
it('places sprints as bands across a week, in rows that do not overlap', () => {
|
||||
const week = monthGrid('2026-10')[2]; // 12 to 18 October
|
||||
const bands = weekBands(
|
||||
[
|
||||
{ id: 'a', startDate: '2026-10-05', endDate: '2026-10-14' },
|
||||
{ id: 'b', startDate: '2026-10-15', endDate: '2026-10-28' },
|
||||
{ id: 'c', startDate: '2026-10-13', endDate: '2026-10-13' },
|
||||
{ id: 'x', startDate: '2026-11-01', endDate: '2026-11-10' },
|
||||
],
|
||||
week,
|
||||
);
|
||||
expect(bands.map((b) => [b.item.id, b.column, b.span, b.row])).toEqual([
|
||||
['a', 1, 3, 0],
|
||||
['c', 2, 1, 1],
|
||||
['b', 4, 4, 0],
|
||||
]);
|
||||
expect(bands[0]).toMatchObject({
|
||||
continuesBefore: true,
|
||||
continuesAfter: false,
|
||||
});
|
||||
expect(bands[2]).toMatchObject({ continuesAfter: true });
|
||||
});
|
||||
|
||||
it('writes an iCalendar file with all-day events', () => {
|
||||
const ics = toIcs(
|
||||
'WEB, sprints',
|
||||
[
|
||||
{
|
||||
uid: 's1',
|
||||
title: 'Sprint 1; pagos',
|
||||
start: '2026-10-12',
|
||||
end: '2026-10-23',
|
||||
},
|
||||
],
|
||||
new Date('2026-10-08T10:00:00Z'),
|
||||
);
|
||||
expect(ics).toContain('X-WR-CALNAME:WEB\\, sprints');
|
||||
expect(ics).toContain('DTSTART;VALUE=DATE:20261012');
|
||||
expect(ics).toContain('DTEND;VALUE=DATE:20261024');
|
||||
expect(ics).toContain('SUMMARY:Sprint 1\\; pagos');
|
||||
expect(ics.split('\r\n')[0]).toBe('BEGIN:VCALENDAR');
|
||||
});
|
||||
});
|
||||
146
libs/web/tracker/domain/src/lib/models/calendar.ts
Normal file
146
libs/web/tracker/domain/src/lib/models/calendar.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* The sprint calendar: a month as weeks of days (Monday first), the sprints
|
||||
* as bands across them, and the same plan as an iCalendar file. Days are
|
||||
* `YYYY-MM-DD` strings throughout: no time zones to trip over.
|
||||
*/
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const toDay = (time: number) => new Date(time).toISOString().slice(0, 10);
|
||||
const timeOf = (day: string) => Date.parse(`${day}T00:00:00Z`);
|
||||
|
||||
export function addDays(day: string, days: number): string {
|
||||
return toDay(timeOf(day) + days * DAY);
|
||||
}
|
||||
|
||||
/** `YYYY-MM` of a day, and the month after or before it. */
|
||||
export function monthOf(day: string): string {
|
||||
return day.slice(0, 7);
|
||||
}
|
||||
|
||||
export function shiftMonth(month: string, by: number): string {
|
||||
const [year, m] = month.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, m - 1 + by, 1));
|
||||
return date.toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
/** The weeks (Monday to Sunday) that cover a month, with spill-over days. */
|
||||
export function monthGrid(month: string): string[][] {
|
||||
const first = `${month}-01`;
|
||||
const weekday = (new Date(timeOf(first)).getUTCDay() + 6) % 7;
|
||||
let day = addDays(first, -weekday);
|
||||
const next = `${shiftMonth(month, 1)}-01`;
|
||||
const weeks: string[][] = [];
|
||||
while (day < next) {
|
||||
const week: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
week.push(day);
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export interface CalendarSpan {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
/** Where a span sits in a week: columns 1-7, and a row so none overlap. */
|
||||
export interface WeekBand<T extends CalendarSpan> {
|
||||
item: T;
|
||||
/** First column (1 = Monday). */
|
||||
column: number;
|
||||
span: number;
|
||||
row: number;
|
||||
/** It goes on before this week / after it. */
|
||||
continuesBefore: boolean;
|
||||
continuesAfter: boolean;
|
||||
}
|
||||
|
||||
/** The spans that touch a week, laid out in rows (first come, first row). */
|
||||
export function weekBands<T extends CalendarSpan>(
|
||||
items: T[],
|
||||
week: string[],
|
||||
): WeekBand<T>[] {
|
||||
const [first, last] = [week[0], week[6]];
|
||||
const rows: string[][] = [];
|
||||
return items
|
||||
.filter((item) => item.startDate <= last && item.endDate >= first)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||
.map((item) => {
|
||||
const start = item.startDate < first ? first : item.startDate;
|
||||
const end = item.endDate > last ? last : item.endDate;
|
||||
const column = week.indexOf(start) + 1;
|
||||
const span = week.indexOf(end) - week.indexOf(start) + 1;
|
||||
// The first row whose last band ended before this one starts.
|
||||
let row = rows.findIndex((ends) => ends[ends.length - 1] < start);
|
||||
if (row === -1) {
|
||||
rows.push([]);
|
||||
row = rows.length - 1;
|
||||
}
|
||||
rows[row].push(end);
|
||||
return {
|
||||
item,
|
||||
column,
|
||||
span,
|
||||
row,
|
||||
continuesBefore: item.startDate < first,
|
||||
continuesAfter: item.endDate > last,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
uid: string;
|
||||
title: string;
|
||||
/** First day, and the last one included (same for one-day events). */
|
||||
start: string;
|
||||
end: string;
|
||||
description?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const icsText = (text: string) =>
|
||||
text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/[,;]/g, (c) => `\\${c}`);
|
||||
const icsDay = (day: string) => day.replace(/-/g, '');
|
||||
|
||||
/** The plan as an iCalendar file, all-day events, for any calendar app. */
|
||||
export function toIcs(
|
||||
name: string,
|
||||
events: CalendarEvent[],
|
||||
now = new Date(),
|
||||
): string {
|
||||
const stamp = now
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}/, '');
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//TaskStream//Calendario de sprints//ES',
|
||||
'CALSCALE:GREGORIAN',
|
||||
`X-WR-CALNAME:${icsText(name)}`,
|
||||
...events.flatMap((event) => [
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${event.uid}@taskstream`,
|
||||
`DTSTAMP:${stamp}`,
|
||||
`DTSTART;VALUE=DATE:${icsDay(event.start)}`,
|
||||
// DTEND is exclusive: the day after the last one.
|
||||
`DTEND;VALUE=DATE:${icsDay(addDays(event.end, 1))}`,
|
||||
`SUMMARY:${icsText(event.title)}`,
|
||||
...(event.description
|
||||
? [`DESCRIPTION:${icsText(event.description)}`]
|
||||
: []),
|
||||
...(event.url ? [`URL:${event.url}`] : []),
|
||||
'END:VEVENT',
|
||||
]),
|
||||
'END:VCALENDAR',
|
||||
];
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
@@ -115,6 +115,12 @@ export abstract class IssuesGateway {
|
||||
parentId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract epics(projectId: string): Observable<Epic[]>;
|
||||
/** Tickets due between two days (`YYYY-MM-DD`, both included). */
|
||||
abstract due(
|
||||
projectId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Observable<IssueSummary[]>;
|
||||
/** To the trash (whoever opened it, or a manager). */
|
||||
abstract trash(issueId: string): Observable<CommandResult>;
|
||||
abstract restore(issueId: string): Observable<CommandResult>;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
BoardDocument,
|
||||
ChangeIssueStatusDocument,
|
||||
CreateIssueDocument,
|
||||
DueIssuesDocument,
|
||||
EpicsDocument,
|
||||
IssueChildrenDocument,
|
||||
IssueDocument,
|
||||
@@ -183,6 +184,14 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
);
|
||||
}
|
||||
|
||||
due(projectId: string, from: string, to: string): Observable<IssueSummary[]> {
|
||||
return this.gql
|
||||
.query(DueIssuesDocument, {
|
||||
filter: { projectId, dueFrom: from, dueTo: to },
|
||||
})
|
||||
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
trash(issueId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(TrashIssueDocument, { issueId })
|
||||
|
||||
@@ -149,6 +149,10 @@ export type IssueFilterInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
categories?: Array<StatusCategory> | null | undefined;
|
||||
/** Due on or after this day (YYYY-MM-DD) */
|
||||
dueFrom?: string | null | undefined;
|
||||
/** Due on or before this day (YYYY-MM-DD) */
|
||||
dueTo?: string | null | undefined;
|
||||
labels?: Array<string> | null | undefined;
|
||||
/** Only tickets in no sprint */
|
||||
noSprint?: boolean | null | undefined;
|
||||
@@ -1124,6 +1128,39 @@ export type TrashQuery = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DueIssuesQueryVariables = Exact<{
|
||||
filter: IssueFilterInput;
|
||||
}>;
|
||||
|
||||
export type DueIssuesQuery = {
|
||||
issues: {
|
||||
items: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type NotificationFieldsFragment = {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
@@ -6993,6 +7030,167 @@ export const TrashDocument = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<TrashQuery, TrashQueryVariables>;
|
||||
export const DueIssuesDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'DueIssues' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'filter' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'IssueFilterInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issues' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'filter' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'filter' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
value: { kind: 'IntValue', value: '200' },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'items' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Issue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'release' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprint' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<DueIssuesQuery, DueIssuesQueryVariables>;
|
||||
export const MyNotificationsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -239,3 +239,11 @@ query Trash($projectId: ID!) {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
|
||||
query DueIssues($filter: IssueFilterInput!) {
|
||||
issues(filter: $filter, limit: 200) {
|
||||
items {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
601
libs/web/tracker/presenters/src/lib/pages/calendar.page.ts
Normal file
601
libs/web/tracker/presenters/src/lib/pages/calendar.page.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
addDays,
|
||||
CalendarEvent,
|
||||
dueState,
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
localDay,
|
||||
monthGrid,
|
||||
monthOf,
|
||||
ReleaseStatus,
|
||||
shiftMonth,
|
||||
Sprint,
|
||||
SprintStatus,
|
||||
toIcs,
|
||||
weekBands,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
const WEEKDAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
/** Tickets listed in a day before "+N más". */
|
||||
const PER_DAY = 3;
|
||||
|
||||
/**
|
||||
* /p/:key/calendar — the sprints of the project month by month, with the
|
||||
* releases and the tickets due each day. Managers plan a sprint by clicking
|
||||
* the day it starts; the whole plan downloads as an .ics file.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-calendar-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiActionButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-cal__toolbar">
|
||||
<div class="ts-cal__nav" role="group" aria-label="Mes">
|
||||
<button
|
||||
type="button"
|
||||
class="ts-cal__arrow"
|
||||
aria-label="Mes anterior"
|
||||
(click)="move(-1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<h2 class="ts-cal__month" aria-live="polite">{{ monthLabel() }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-cal__arrow"
|
||||
aria-label="Mes siguiente"
|
||||
(click)="move(1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button type="button" class="ts-plain-link" (click)="today()">
|
||||
Hoy
|
||||
</button>
|
||||
</div>
|
||||
<span class="ts-spacer"></span>
|
||||
<ul class="ts-cal__legend" aria-label="Leyenda">
|
||||
<li><span class="ts-cal__key is-active"></span>Sprint en curso</li>
|
||||
<li><span class="ts-cal__key"></span>Sprint planificado</li>
|
||||
<li><span class="ts-cal__key is-closed"></span>Cerrado</li>
|
||||
<li><span aria-hidden="true">◆</span> Release</li>
|
||||
</ul>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Descargar .ics"
|
||||
(execute)="downloadIcs()"
|
||||
/>
|
||||
</div>
|
||||
@if (project.canManage()) {
|
||||
<p class="ts-muted ts-cal__hint">
|
||||
Pulsa el día en que empieza un sprint para planificarlo.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div
|
||||
class="ts-cal"
|
||||
role="grid"
|
||||
[attr.aria-label]="'Calendario de ' + monthLabel()"
|
||||
>
|
||||
<div class="ts-cal__weekdays" role="row">
|
||||
@for (day of weekdays; track day) {
|
||||
<span role="columnheader">{{ day }}</span>
|
||||
}
|
||||
</div>
|
||||
@for (week of weeks(); track week[0]) {
|
||||
<div class="ts-cal__week" role="row" [style.--rows]="rowsOf(week)">
|
||||
<div class="ts-cal__bands">
|
||||
@for (band of bandsOf(week); track band.item.id) {
|
||||
<a
|
||||
class="ts-cal__band"
|
||||
routerLink="../sprints"
|
||||
[attr.data-status]="band.item.status"
|
||||
[class.continues-before]="band.continuesBefore"
|
||||
[class.continues-after]="band.continuesAfter"
|
||||
[style.grid-column]="band.column + ' / span ' + band.span"
|
||||
[style.grid-row]="band.row + 1"
|
||||
[title]="sprintTitle(band.item)"
|
||||
>
|
||||
{{ band.item.name }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@for (day of week; track day) {
|
||||
<div
|
||||
class="ts-cal__day"
|
||||
role="gridcell"
|
||||
[class.is-other]="monthOf(day) !== month()"
|
||||
[class.is-today]="day === todayDay"
|
||||
[attr.aria-label]="dayLabel(day)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-cal__number"
|
||||
[disabled]="!project.canManage()"
|
||||
[attr.aria-label]="
|
||||
'Planificar un sprint desde el ' + dayLabel(day)
|
||||
"
|
||||
(click)="planFrom(day)"
|
||||
>
|
||||
{{ +day.slice(8) }}
|
||||
</button>
|
||||
<span class="ts-cal__spacer" aria-hidden="true"></span>
|
||||
@for (release of releasesOn(day); track release.id) {
|
||||
<a
|
||||
class="ts-cal__release"
|
||||
routerLink="../releases"
|
||||
[class.is-shipped]="release.status === 'RELEASED'"
|
||||
[title]="'Release ' + release.name"
|
||||
>◆ {{ release.name }}</a
|
||||
>
|
||||
}
|
||||
@for (issue of dueOn(day).slice(0, perDay); track issue.id) {
|
||||
<a
|
||||
class="ts-cal__issue"
|
||||
[attr.data-due]="due(issue)"
|
||||
[routerLink]="['..', 'issues', issue.key]"
|
||||
[title]="issue.key + ' · ' + issue.title"
|
||||
>{{ issue.key }} {{ issue.title }}</a
|
||||
>
|
||||
}
|
||||
@if (dueOn(day).length > perDay) {
|
||||
<span class="ts-cal__more"
|
||||
>+{{ dueOn(day).length - perDay }} más</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-template #planForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); plan()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field [(value)]="name">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
<div class="ts-form__row">
|
||||
<pi-text-field placeholder="AAAA-MM-DD" [(value)]="start">
|
||||
<pi-label [isRequired]="true">Empieza</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-text-field placeholder="AAAA-MM-DD" [(value)]="end">
|
||||
<pi-label [isRequired]="true">Acaba</pi-label>
|
||||
</pi-text-field>
|
||||
</div>
|
||||
<pi-textarea
|
||||
placeholder="Qué tiene que quedar hecho al final"
|
||||
[(value)]="goal"
|
||||
>
|
||||
<pi-label>Objetivo</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Planificar"
|
||||
[disabled]="!canPlan()"
|
||||
(execute)="plan()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.ts-cal__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ts-cal__nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-cal__month {
|
||||
min-width: 10rem;
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ts-cal__arrow {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ts-surface);
|
||||
color: inherit;
|
||||
font-size: 1.125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ts-cal__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
li {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.ts-cal__key {
|
||||
width: 0.875rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-brand) 35%, transparent);
|
||||
&.is-active {
|
||||
background: var(--ts-brand);
|
||||
}
|
||||
&.is-closed {
|
||||
background: var(--ts-line-strong);
|
||||
}
|
||||
}
|
||||
.ts-cal__hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-cal {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--ts-surface);
|
||||
}
|
||||
.ts-cal__weekdays,
|
||||
.ts-cal__week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(7.5rem, 1fr));
|
||||
min-width: 52.5rem;
|
||||
}
|
||||
.ts-cal__weekdays span {
|
||||
padding: 0.375rem 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ts-cal__week {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
}
|
||||
/* Sprint bands sit under the day numbers, across the week. */
|
||||
.ts-cal__bands {
|
||||
position: absolute;
|
||||
inset: 2rem 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-auto-rows: 1.25rem;
|
||||
gap: 0.125rem 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ts-cal__band {
|
||||
overflow: hidden;
|
||||
margin: 0 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-brand) 30%, transparent);
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25rem;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
&[data-status='ACTIVE'] {
|
||||
background: var(--ts-brand);
|
||||
color: var(--ts-on-brand, #fff);
|
||||
}
|
||||
&[data-status='CLOSED'] {
|
||||
background: var(--ts-line-strong);
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
&.continues-before {
|
||||
margin-left: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
&.continues-after {
|
||||
margin-right: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
.ts-cal__day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-height: 7.5rem;
|
||||
padding: 0.25rem 0.375rem 0.375rem;
|
||||
border-left: 1px solid var(--ts-line);
|
||||
&:first-of-type {
|
||||
border-left: 0;
|
||||
}
|
||||
&.is-other {
|
||||
background: color-mix(in srgb, var(--ts-line) 35%, transparent);
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
}
|
||||
.ts-cal__spacer {
|
||||
flex: none;
|
||||
height: calc(var(--rows, 0) * 1.375rem);
|
||||
}
|
||||
.ts-cal__number {
|
||||
flex: none;
|
||||
width: 1.75rem;
|
||||
height: 1.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
&:not(:disabled):hover {
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
}
|
||||
}
|
||||
.ts-cal__day.is-today .ts-cal__number {
|
||||
background: var(--ts-brand);
|
||||
color: var(--ts-on-brand, #fff);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-cal__release,
|
||||
.ts-cal__issue,
|
||||
.ts-cal__more {
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-cal__release {
|
||||
color: var(--ts-c-violet, var(--ts-brand));
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
&.is-shipped {
|
||||
color: var(--ts-c-green, var(--ts-muted));
|
||||
}
|
||||
}
|
||||
.ts-cal__issue {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
&[data-due='overdue'] {
|
||||
color: var(--ts-c-red, inherit);
|
||||
}
|
||||
}
|
||||
.ts-cal__more {
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CalendarPage {
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly issues = inject(IssuesGateway);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly planForm =
|
||||
viewChild.required<TemplateRef<unknown>>('planForm');
|
||||
|
||||
protected readonly weekdays = WEEKDAYS;
|
||||
protected readonly perDay = PER_DAY;
|
||||
protected readonly todayDay = localDay(new Date());
|
||||
protected readonly monthOf = monthOf;
|
||||
protected readonly month = signal(monthOf(this.todayDay));
|
||||
protected readonly weeks = computed(() => monthGrid(this.month()));
|
||||
private readonly dueIssues = signal<IssueSummary[]>([]);
|
||||
|
||||
protected readonly monthLabel = computed(() =>
|
||||
new Date(`${this.month()}-01T00:00:00Z`).toLocaleDateString('es', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}),
|
||||
);
|
||||
|
||||
/* Plan a sprint */
|
||||
protected readonly name = signal('');
|
||||
protected readonly start = signal('');
|
||||
protected readonly end = signal('');
|
||||
protected readonly goal = signal('');
|
||||
protected readonly canPlan = computed(
|
||||
() =>
|
||||
!!this.name().trim() &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(this.start()) &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(this.end()) &&
|
||||
this.start() <= this.end(),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Tickets due in the weeks shown (spill-over days included).
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
const weeks = this.weeks();
|
||||
if (!projectId) return;
|
||||
const from = weeks[0][0];
|
||||
const to = weeks[weeks.length - 1][6];
|
||||
untracked(() =>
|
||||
this.issues
|
||||
.due(projectId, from, to)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (issues) => this.dueIssues.set(issues),
|
||||
error: () => this.dueIssues.set([]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected bandsOf(week: string[]) {
|
||||
return weekBands(this.project.sprints(), week);
|
||||
}
|
||||
|
||||
protected rowsOf(week: string[]): number {
|
||||
return this.bandsOf(week).reduce(
|
||||
(rows, band) => Math.max(rows, band.row + 1),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
protected releasesOn(day: string) {
|
||||
return this.project
|
||||
.releases()
|
||||
.filter(
|
||||
(r) =>
|
||||
r.status !== ReleaseStatus.ARCHIVED &&
|
||||
(r.releasedAt ?? r.targetDate)?.slice(0, 10) === day,
|
||||
);
|
||||
}
|
||||
|
||||
protected dueOn(day: string): IssueSummary[] {
|
||||
return this.dueIssues().filter((i) => i.dueDate?.slice(0, 10) === day);
|
||||
}
|
||||
|
||||
protected due(issue: IssueSummary) {
|
||||
return dueState(issue.dueDate, issue.statusCategory, this.todayDay);
|
||||
}
|
||||
|
||||
protected dayLabel(day: string): string {
|
||||
return new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
protected sprintTitle(sprint: Sprint): string {
|
||||
const status =
|
||||
sprint.status === SprintStatus.ACTIVE
|
||||
? 'en curso'
|
||||
: sprint.status === SprintStatus.CLOSED
|
||||
? 'cerrado'
|
||||
: 'planificado';
|
||||
return `${sprint.name} (${status}) · ${sprint.startDate} – ${sprint.endDate} · ${sprint.progress.finished}/${sprint.progress.total} tickets`;
|
||||
}
|
||||
|
||||
protected move(by: number): void {
|
||||
this.month.update((month) => shiftMonth(month, by));
|
||||
}
|
||||
|
||||
protected today(): void {
|
||||
this.month.set(monthOf(this.todayDay));
|
||||
}
|
||||
|
||||
/** Two weeks from the day clicked, named after the next number. */
|
||||
protected planFrom(day: string): void {
|
||||
if (!this.project.canManage()) return;
|
||||
this.name.set(`Sprint ${this.project.sprints().length + 1}`);
|
||||
this.start.set(day);
|
||||
this.end.set(addDays(day, 13));
|
||||
this.goal.set('');
|
||||
this.drawer.open('Planificar sprint', this.planForm());
|
||||
}
|
||||
|
||||
protected plan(): void {
|
||||
if (!this.canPlan()) return;
|
||||
this.project.createSprint({
|
||||
name: this.name().trim(),
|
||||
goal: this.goal().trim() || null,
|
||||
startDate: this.start(),
|
||||
endDate: this.end(),
|
||||
});
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
/** Sprints, releases and due tickets of the project, for any calendar app. */
|
||||
protected downloadIcs(): void {
|
||||
const project = this.project.project();
|
||||
if (!project) return;
|
||||
const base = `${location.origin}/p/${project.key}`;
|
||||
const events: CalendarEvent[] = [
|
||||
...this.project.sprints().map((s) => ({
|
||||
uid: `sprint-${s.id}`,
|
||||
title: `${project.key} · ${s.name}`,
|
||||
start: s.startDate,
|
||||
end: s.endDate,
|
||||
description: s.goal,
|
||||
url: `${base}/sprints`,
|
||||
})),
|
||||
...this.project
|
||||
.releases()
|
||||
.filter((r) => r.targetDate || r.releasedAt)
|
||||
.map((r) => {
|
||||
const day = (r.releasedAt ?? r.targetDate ?? '').slice(0, 10);
|
||||
return {
|
||||
uid: `release-${r.id}`,
|
||||
title: `${project.key} · Release ${r.name}`,
|
||||
start: day,
|
||||
end: day,
|
||||
url: `${base}/releases`,
|
||||
};
|
||||
}),
|
||||
...this.dueIssues().map((i) => {
|
||||
const day = (i.dueDate ?? '').slice(0, 10);
|
||||
return {
|
||||
uid: `due-${i.id}`,
|
||||
title: `${i.key} vence: ${i.title}`,
|
||||
start: day,
|
||||
end: day,
|
||||
url: `${base}/issues/${i.key}`,
|
||||
};
|
||||
}),
|
||||
];
|
||||
const blob = new Blob([toIcs(`${project.key} · ${project.name}`, events)], {
|
||||
type: 'text/calendar;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${project.key}-sprints.ics`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.toast.success('Calendario descargado');
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
|
||||
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
|
||||
<a routerLink="calendar" routerLinkActive="is-active">Calendario</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
|
||||
@@ -50,6 +50,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/sprints.page').then((m) => m.SprintsPage),
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
title: 'Calendario · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/calendar.page').then((m) => m.CalendarPage),
|
||||
},
|
||||
{
|
||||
path: 'roadmap',
|
||||
title: 'Hoja de ruta · TaskStream',
|
||||
|
||||
Reference in New Issue
Block a user