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:
@@ -1,5 +1,7 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { inject, Injectable, Provider, signal } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { Activity, ActivityGateway } from '@task-stream/web/tracker/domain';
|
||||
import { describeError } from '../notifications';
|
||||
import { insightsPageEvents } from '../events/insights.events';
|
||||
import { InsightsStore } from '../stores/insights.store';
|
||||
|
||||
@@ -8,6 +10,7 @@ import { InsightsStore } from '../stores/insights.store';
|
||||
export class InsightsFacade {
|
||||
private readonly store = inject(InsightsStore);
|
||||
private readonly dispatch = injectDispatch(insightsPageEvents);
|
||||
private readonly activity = inject(ActivityGateway);
|
||||
|
||||
readonly insights = this.store.insights;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
@@ -21,6 +24,28 @@ export class InsightsFacade {
|
||||
open(projectId: string, since: string | null = null): void {
|
||||
this.dispatch.opened({ projectId, since });
|
||||
}
|
||||
|
||||
/** The day picked on the activity map: what happened then. */
|
||||
readonly day = signal<{
|
||||
day: string;
|
||||
entries: Activity[] | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
/** Reads what happened that day (`YYYY-MM-DD`) into `day`. */
|
||||
openDay(projectId: string, day: string): void {
|
||||
this.day.set({ day, entries: null, error: null });
|
||||
this.activity.onDay(projectId, day).subscribe({
|
||||
next: (entries) => {
|
||||
if (this.day()?.day === day)
|
||||
this.day.set({ day, entries, error: null });
|
||||
},
|
||||
error: (error) => {
|
||||
if (this.day()?.day === day)
|
||||
this.day.set({ day, entries: [], error: describeError(error) });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of the dashboard, for its route component. */
|
||||
|
||||
@@ -358,6 +358,8 @@ export abstract class SprintsGateway {
|
||||
|
||||
export abstract class ActivityGateway {
|
||||
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
||||
/** What happened that day (`YYYY-MM-DD`, UTC), newest first (200 at most). */
|
||||
abstract onDay(projectId: string, day: string): Observable<Activity[]>;
|
||||
}
|
||||
|
||||
export abstract class UsersGateway {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
ActivityDocument,
|
||||
ActivityOfDayDocument,
|
||||
AddCommentDocument,
|
||||
ArchiveReleaseDocument,
|
||||
CreateReleaseDocument,
|
||||
@@ -144,6 +145,14 @@ export class GraphqlActivityGateway extends ActivityGateway {
|
||||
.query(ActivityDocument, { projectId, issueId: issueId ?? null })
|
||||
.pipe(map((d) => d.activity.map(toActivity)));
|
||||
}
|
||||
|
||||
onDay(projectId: string, day: string): Observable<Activity[]> {
|
||||
const after = `${day}T00:00:00.000Z`;
|
||||
const before = new Date(Date.parse(after) + 86_400_000).toISOString();
|
||||
return this.gql
|
||||
.query(ActivityOfDayDocument, { projectId, after, before })
|
||||
.pipe(map((d) => d.activity.map(toActivity)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -2358,6 +2358,25 @@ export type WriteReleaseNotesMutation = {
|
||||
writeReleaseNotes: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type ActivityOfDayQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
after: string;
|
||||
before: string;
|
||||
}>;
|
||||
|
||||
export type ActivityOfDayQuery = {
|
||||
activity: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
projectId: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
actor: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SavedFilterFieldsFragment = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -14144,6 +14163,154 @@ export const WriteReleaseNotesDocument = {
|
||||
WriteReleaseNotesMutation,
|
||||
WriteReleaseNotesMutationVariables
|
||||
>;
|
||||
export const ActivityOfDayDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'ActivityOfDay' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'after' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'before' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'activity' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'after' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'after' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'before' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'before' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
value: { kind: 'IntValue', value: '200' },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'ActivityFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'ActivityFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Activity' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'occurredAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'actor' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<ActivityOfDayQuery, ActivityOfDayQueryVariables>;
|
||||
export const SavedFiltersDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -72,3 +72,9 @@ mutation WriteReleaseNotes($releaseId: ID!, $notes: String) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query ActivityOfDay($projectId: ID!, $after: String!, $before: String!) {
|
||||
activity(projectId: $projectId, after: $after, before: $before, limit: 200) {
|
||||
...ActivityFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 --- */
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
|
||||
const WEEKDAYS = [
|
||||
@@ -55,12 +56,23 @@ const MONTHS = [
|
||||
}}</span>
|
||||
@for (week of weeks(); track week.start) {
|
||||
@if (week.days[row]; as day) {
|
||||
<span
|
||||
class="ts-heat__day"
|
||||
[attr.data-level]="day.level"
|
||||
[class.is-future]="day.future"
|
||||
[attr.title]="day.title"
|
||||
></span>
|
||||
@if (selectable() && day.count && !day.future) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-heat__day is-selectable"
|
||||
[attr.data-level]="day.level"
|
||||
[attr.title]="day.title + ' · ver qué pasó'"
|
||||
[attr.aria-label]="day.title + '. Ver qué pasó ese día'"
|
||||
(click)="daySelected.emit(day.date)"
|
||||
></button>
|
||||
} @else {
|
||||
<span
|
||||
class="ts-heat__day"
|
||||
[attr.data-level]="day.level"
|
||||
[class.is-future]="day.future"
|
||||
[attr.title]="day.title"
|
||||
></span>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +128,21 @@ const MONTHS = [
|
||||
outline: 1px dashed var(--ts-line);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* A plain square, past @pi/ui's global button look. */
|
||||
&.is-selectable {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-ink);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-heat__note {
|
||||
margin-top: 0.875rem;
|
||||
@@ -127,6 +154,10 @@ export class ActivityHeatmapComponent {
|
||||
readonly days = input.required<{ day: string; count: number }[]>();
|
||||
/** Today (`YYYY-MM-DD`), to grey out the rest of this week. */
|
||||
readonly today = input<string>(new Date().toISOString().slice(0, 10));
|
||||
/** Days with activity can be picked (not on the page for clients). */
|
||||
readonly selectable = input(false);
|
||||
/** A day picked, as `YYYY-MM-DD`. */
|
||||
readonly daySelected = output<string>();
|
||||
|
||||
protected readonly total = computed(() =>
|
||||
this.days().reduce((sum, d) => sum + d.count, 0),
|
||||
@@ -139,7 +170,13 @@ export class ActivityHeatmapComponent {
|
||||
const weeks: {
|
||||
start: string;
|
||||
month: string;
|
||||
days: { level: number; future: boolean; title: string }[];
|
||||
days: {
|
||||
date: string;
|
||||
count: number;
|
||||
level: number;
|
||||
future: boolean;
|
||||
title: string;
|
||||
}[];
|
||||
}[] = [];
|
||||
for (let w = 0; w * 7 < days.length; w++) {
|
||||
const week = days.slice(w * 7, w * 7 + 7);
|
||||
@@ -162,6 +199,8 @@ export class ActivityHeatmapComponent {
|
||||
days: week.map((d) => {
|
||||
const future = d.day > this.today();
|
||||
return {
|
||||
date: d.day,
|
||||
count: d.count,
|
||||
future,
|
||||
level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0,
|
||||
title: future
|
||||
|
||||
Reference in New Issue
Block a user