feat: a home page with life
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>
This commit is contained in:
142
libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts
Normal file
142
libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user