feat: a day of the activity map opens what happened, ticket by ticket

On the project dashboard, a day with activity can be picked on the map:
a side panel lists that day's moves grouped by ticket (each key opens
it), with the time of each, and the project's own (sprints, releases,
members) apart.

The activity feed takes an `after` date (GraphQL, REST), so with
`before` it reads a window such as a day; the MCP's
get_project_activity takes a `day`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 20:42:30 +02:00
parent de9c8a7780
commit d66b1f3ece
25 changed files with 512 additions and 35 deletions

View File

@@ -10,8 +10,11 @@ import {
HostListener,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { DrawerService } from '@task-stream/web/shared/ui';
import { RouterLink } from '@angular/router';
import {
PiNoteComponent,
@@ -24,6 +27,7 @@ import {
provideInsightsFacade,
} from '@task-stream/web/tracker/application';
import {
Activity,
ProjectRole,
QA_FAILED,
StatusCategory,
@@ -72,6 +76,57 @@ const SLIDE_SECONDS = 15;
providers: [provideInsightsFacade()],
host: { '[class.is-screen]': 'screen()' },
template: `
<ng-template #dayPanel>
@if (insights.day(); as d) {
@if (!d.entries) {
<pi-skeleton shape="block" height="8rem" />
} @else if (d.error) {
<p class="ts-day__error" role="alert">{{ d.error }}</p>
} @else {
<p class="ts-muted ts-day__summary">
{{ d.entries.length }}
{{ d.entries.length === 1 ? 'movimiento' : 'movimientos' }}
@if (d.entries.length >= 200) {
(los 200 últimos)
}
· {{ dayTickets().length }}
{{ dayTickets().length === 1 ? 'ticket' : 'tickets' }}
</p>
@for (ticket of dayTickets(); track ticket.key) {
<section class="ts-day__ticket">
<a
class="ts-key"
[routerLink]="['../issues', ticket.key]"
(click)="drawer.closeAll()"
>{{ ticket.key }}</a
>
<ul>
@for (entry of ticket.entries; track entry.id) {
<li>
<time class="ts-mono">{{ time(entry.occurredAt) }}</time>
{{ entry.summary }}
</li>
}
</ul>
</section>
}
@if (dayOthers().length) {
<section class="ts-day__ticket">
<h3>Del proyecto</h3>
<ul>
@for (entry of dayOthers(); track entry.id) {
<li>
<time class="ts-mono">{{ time(entry.occurredAt) }}</time>
{{ entry.summary }}
</li>
}
</ul>
</section>
}
}
}
</ng-template>
@if (screen()) {
<header class="ts-screen-bar">
<span class="ts-screen-bar__project">
@@ -192,7 +247,11 @@ const SLIDE_SECONDS = 15;
[journey]="reading.journey"
[workflow]="facade.workflow()"
/>
<ts-activity-heatmap [days]="reading.heatmap" />
<ts-activity-heatmap
[days]="reading.heatmap"
[selectable]="true"
(daySelected)="openDay($event)"
/>
<ts-backlog-health [health]="reading.backlog" />
<ts-quality
[quality]="reading.quality"
@@ -312,6 +371,51 @@ const SLIDE_SECONDS = 15;
}
`,
styles: `
.ts-day__summary {
margin: 0 0 1rem;
font-size: 0.875rem;
}
.ts-day__error {
color: var(--ts-c-red);
}
.ts-day__ticket {
display: grid;
gap: 0.375rem;
padding: 0.75rem 0;
border-top: 1px solid var(--ts-line);
h3 {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ts-key {
justify-self: start;
font-family: var(--ts-font-mono);
font-size: 0.8125rem;
font-weight: 600;
color: var(--ts-brand);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.875rem;
}
time {
margin-right: 0.5rem;
color: var(--ts-muted);
font-size: 0.75rem;
}
}
.ts-screen-open {
display: flex;
justify-content: flex-end;
@@ -614,6 +718,48 @@ export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
protected readonly drawer = inject(DrawerService);
private readonly dayPanel =
viewChild.required<TemplateRef<unknown>>('dayPanel');
/** A day of the activity map, in a side panel: its tickets and moves. */
protected openDay(day: string): void {
const projectId = this.facade.project()?.id;
if (!projectId) return;
this.insights.openDay(projectId, day);
const label = new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'UTC',
});
this.drawer.open(`Actividad del ${label}`, this.dayPanel());
}
/** That day's moves, ticket by ticket (most active first). */
protected readonly dayTickets = computed(() => {
const groups = new Map<string, Activity[]>();
for (const entry of this.insights.day()?.entries ?? [])
if (entry.issueKey)
groups.set(entry.issueKey, [
...(groups.get(entry.issueKey) ?? []),
entry,
]);
return [...groups.entries()]
.map(([key, entries]) => ({ key, entries }))
.sort((a, b) => b.entries.length - a.entries.length);
});
/** The moves that are not about one ticket (sprints, releases, members). */
protected readonly dayOthers = computed(() =>
(this.insights.day()?.entries ?? []).filter((e) => !e.issueKey),
);
protected time(iso: string): string {
return new Date(iso).toLocaleTimeString('es', {
hour: '2-digit',
minute: '2-digit',
});
}
private readonly document = inject(DOCUMENT);
/* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */