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:
172
libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts
Normal file
172
libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user