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:
@@ -432,6 +432,7 @@ export class IssuesService {
|
||||
issueId?: string;
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
},
|
||||
): Promise<ActivityReadModel[]> {
|
||||
return this.queryBus.execute(
|
||||
@@ -441,6 +442,7 @@ export class IssuesService {
|
||||
params.issueId,
|
||||
params.limit,
|
||||
params.before,
|
||||
params.after,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export abstract class FindActivityRepository {
|
||||
limit: number;
|
||||
/** ISO date cursor: only activity strictly older than this. */
|
||||
before?: string;
|
||||
/** ISO date: only activity at or after this. */
|
||||
after?: string;
|
||||
}): Promise<ActivityReadModel[]>;
|
||||
/** The latest of several projects, newest first. */
|
||||
abstract findAcross(
|
||||
|
||||
@@ -35,6 +35,7 @@ export class GetActivityQueryHandler
|
||||
issueId: query.issueId,
|
||||
limit: Math.min(query.limit, 200),
|
||||
before: query.before,
|
||||
after: query.after,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export class GetActivityQuery extends Query<ActivityReadModel[]> {
|
||||
public readonly limit = 50,
|
||||
/** ISO date cursor: only activity strictly older than this. */
|
||||
public readonly before?: string,
|
||||
/** ISO date: only activity at or after this (with `before`, a window). */
|
||||
public readonly after?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -603,10 +603,15 @@ export class MongoFindActivityRepository extends FindActivityRepository {
|
||||
issueId?: string;
|
||||
limit: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
}): Promise<ActivityReadModel[]> {
|
||||
const query: FilterQuery<ActivityDoc> = { projectId: params.projectId };
|
||||
if (params.issueId) query.issueId = params.issueId;
|
||||
if (params.before) query.occurredAt = { $lt: params.before };
|
||||
if (params.before || params.after)
|
||||
query.occurredAt = {
|
||||
...(params.before && { $lt: params.before }),
|
||||
...(params.after && { $gte: params.after }),
|
||||
};
|
||||
const docs = await this.activity
|
||||
.find(query)
|
||||
.sort({ occurredAt: -1 })
|
||||
|
||||
@@ -193,12 +193,19 @@ export class IssueResolver {
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50,
|
||||
@Args('before', { nullable: true, description: 'ISO date cursor' })
|
||||
before?: string,
|
||||
@Args('after', {
|
||||
nullable: true,
|
||||
description:
|
||||
'ISO date: only activity at or after it (with before, a window such as a day)',
|
||||
})
|
||||
after?: string,
|
||||
) {
|
||||
return this.issuesService.activity(viewerOf(me), {
|
||||
projectId,
|
||||
issueId,
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -473,6 +473,15 @@ export class ActivityQueryDto {
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date-time',
|
||||
description:
|
||||
'Entries at or after this date (with before, a window such as a day)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export class PageQueryDto {
|
||||
|
||||
@@ -316,6 +316,7 @@ export class ProjectsController {
|
||||
issueId: query.issueId,
|
||||
limit: query.limit,
|
||||
before: query.before,
|
||||
after: query.after,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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