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:
2026-10-15 19:20:41 +02:00
parent affce615a7
commit 0946e9517d
57 changed files with 3728 additions and 53 deletions

View File

@@ -0,0 +1,172 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { AgendaItem } from '@task-stream/web/tracker/domain';
const DAY = 86_400_000;
const KIND: Record<AgendaItem['kind'], string> = {
ISSUE: 'vence',
SPRINT_END: 'fin de sprint',
RELEASE: 'release',
};
/** My coming week: what is due, which sprints end, which releases land. */
@Component({
selector: 'ts-week-agenda',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
role: 'region',
'aria-labelledby': 'agenda-title',
},
styleUrl: './home-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="agenda-title">Tu semana</h2>
<span class="ts-muted">{{ count() }} cosas en los próximos 7 días</span>
</header>
<ol class="ts-agenda">
@for (day of days(); track day.date) {
<li [class.is-today]="day.today">
<p class="ts-agenda__day">
<span>{{ day.weekday }}</span> <strong>{{ day.number }}</strong>
</p>
@for (item of day.items; track $index) {
<a
class="ts-agenda__item"
[attr.data-kind]="item.kind"
[routerLink]="link(item)"
[attr.title]="item.projectKey + ' · ' + item.title"
>
<span class="ts-agenda__kind">{{ kindOf(item) }}</span>
{{ item.issueKey ?? item.projectKey }}
<span class="ts-agenda__title">{{ item.title }}</span>
</a>
} @empty {
<span class="ts-agenda__none">—</span>
}
</li>
}
</ol>
`,
styles: `
.ts-agenda {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
align-content: start;
gap: 0.25rem;
min-height: 6rem;
padding: 0.5rem;
border-radius: 0.625rem;
background: var(--ts-sunken);
}
li.is-today {
outline: 2px solid var(--ts-brand);
outline-offset: -2px;
}
}
.ts-agenda__day {
margin: 0 0 0.25rem;
color: var(--ts-muted);
font-size: 0.75rem;
text-transform: capitalize;
strong {
color: var(--ts-ink);
font-size: 1rem;
}
}
.ts-agenda__item {
display: grid;
gap: 0.0625rem;
padding: 0.25rem 0.375rem;
border-left: 3px solid var(--ts-brand);
border-radius: 0.375rem;
background: var(--ts-surface);
color: inherit;
font-family: var(--ts-font-mono);
font-size: 0.6875rem;
text-decoration: none;
&[data-kind='SPRINT_END'] {
border-left-color: var(--ts-c-amber, #c98a12);
}
&[data-kind='RELEASE'] {
border-left-color: var(--ts-c-green, #2e9e6a);
}
&:hover {
background: var(--pi-color-primary-25);
}
}
.ts-agenda__kind {
color: var(--ts-muted);
font-family: inherit;
}
.ts-agenda__title {
overflow: hidden;
font-family: var(--ts-font-body, inherit);
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-agenda__none {
color: var(--ts-muted);
font-size: 0.75rem;
}
@media (max-width: 720px) {
.ts-agenda {
grid-template-columns: minmax(0, 1fr);
li {
min-height: 0;
}
}
}
`,
})
export class WeekAgendaComponent {
readonly items = input.required<AgendaItem[]>();
readonly now = input<Date>(new Date());
protected readonly count = computed(() => this.items().length);
protected readonly days = computed(() => {
const now = this.now();
const start = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
return Array.from({ length: 7 }, (_, n) => {
const time = start + n * DAY;
const date = new Date(time).toISOString().slice(0, 10);
return {
date,
today: n === 0,
weekday: new Date(time).toLocaleDateString('es', {
weekday: 'short',
timeZone: 'UTC',
}),
number: new Date(time).getUTCDate(),
items: this.items().filter((i) => i.day === date),
};
});
});
protected kindOf(item: AgendaItem): string {
return KIND[item.kind];
}
protected link(item: AgendaItem): unknown[] {
return item.issueKey
? ['/p', item.projectKey, 'issues', item.issueKey]
: [
'/p',
item.projectKey,
item.kind === 'RELEASE' ? 'releases' : 'sprints',
];
}
}