Greeting and summary of the day, the coming week, the latest across my projects, who is online now, the week in numbers (mine and the team's, with a streak), sprints under way, «Retomar», project cards with their activity, what to pick up when nothing is mine and first steps for newcomers. REST (GET /api/me/home), GraphQL (myHome) and MCP (get_my_week). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
3.8 KiB
TypeScript
143 lines
3.8 KiB
TypeScript
import {
|
||
ChangeDetectionStrategy,
|
||
Component,
|
||
computed,
|
||
input,
|
||
} from '@angular/core';
|
||
import { Home } from '@task-stream/web/tracker/domain';
|
||
|
||
/** «+30 %» or «−10 %» against the week before (nothing when there was none). */
|
||
function change(now: number, before: number): string {
|
||
if (!before) return now ? 'nuevo' : '';
|
||
const pct = Math.round(((now - before) / before) * 100);
|
||
return `${pct >= 0 ? '+' : '−'}${Math.abs(pct)} %`;
|
||
}
|
||
|
||
/** How the week goes: mine (and my streak) and my projects' teams. */
|
||
@Component({
|
||
selector: 'ts-week-stats',
|
||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
host: {
|
||
class: 'ts-panel',
|
||
role: 'region',
|
||
'aria-labelledby': 'stats-title',
|
||
},
|
||
styleUrl: './home-panel.scss',
|
||
template: `
|
||
<header class="ts-panel__head">
|
||
<h2 id="stats-title">La semana</h2>
|
||
<span class="ts-muted">Tickets terminados, últimos 7 días</span>
|
||
</header>
|
||
<div class="ts-week">
|
||
<div>
|
||
<p class="ts-week__label">Tú</p>
|
||
<p class="ts-week__value">
|
||
{{ me().thisWeek }}
|
||
@if (myChange(); as c) {
|
||
<span class="ts-week__change" [class.is-down]="c.startsWith('−')">{{
|
||
c
|
||
}}</span>
|
||
}
|
||
</p>
|
||
<p class="ts-week__sub">
|
||
@if (me().streak > 1) {
|
||
{{ me().streak }} días seguidos terminando algo
|
||
} @else if (me().streak === 1) {
|
||
Terminaste algo hoy o ayer
|
||
} @else {
|
||
Sin racha: hoy puede empezar una
|
||
}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p class="ts-week__label">Tus equipos</p>
|
||
<p class="ts-week__value">
|
||
{{ team().thisWeek }}
|
||
@if (teamChange(); as c) {
|
||
<span class="ts-week__change" [class.is-down]="c.startsWith('−')">{{
|
||
c
|
||
}}</span>
|
||
}
|
||
</p>
|
||
<div
|
||
class="ts-week__bars"
|
||
role="img"
|
||
[attr.aria-label]="'Terminados por día: ' + team().perDay.join(', ')"
|
||
>
|
||
@for (n of team().perDay; track $index) {
|
||
<span
|
||
[style.height.%]="height(n)"
|
||
[class.is-this-week]="$index >= 7"
|
||
[attr.title]="n + ' terminados'"
|
||
></span>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
styles: `
|
||
.ts-week {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 1rem;
|
||
}
|
||
.ts-week__label {
|
||
margin: 0;
|
||
color: var(--ts-muted);
|
||
font-size: 0.75rem;
|
||
}
|
||
.ts-week__value {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.5rem;
|
||
margin: 0.125rem 0 0.25rem;
|
||
font-size: 1.75rem;
|
||
font-weight: 700;
|
||
}
|
||
.ts-week__change {
|
||
color: var(--ts-c-green, #2e9e6a);
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
&.is-down {
|
||
color: var(--ts-muted);
|
||
}
|
||
}
|
||
.ts-week__sub {
|
||
margin: 0;
|
||
color: var(--ts-ink-soft);
|
||
font-size: 0.8125rem;
|
||
}
|
||
.ts-week__bars {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
height: 2.5rem;
|
||
span {
|
||
flex: 1;
|
||
min-height: 2px;
|
||
border-radius: 2px 2px 0 0;
|
||
background: color-mix(in srgb, var(--ts-brand) 35%, transparent);
|
||
&.is-this-week {
|
||
background: var(--ts-brand);
|
||
}
|
||
}
|
||
}
|
||
`,
|
||
})
|
||
export class WeekStatsComponent {
|
||
readonly me = input.required<Home['me']>();
|
||
readonly team = input.required<Home['team']>();
|
||
|
||
protected readonly myChange = computed(() =>
|
||
change(this.me().thisWeek, this.me().lastWeek),
|
||
);
|
||
protected readonly teamChange = computed(() =>
|
||
change(this.team().thisWeek, this.team().lastWeek),
|
||
);
|
||
private readonly max = computed(() => Math.max(1, ...this.team().perDay));
|
||
|
||
protected height(n: number): number {
|
||
return (n / this.max()) * 100;
|
||
}
|
||
}
|