feat(web): a home page in two columns, with four figures on top

The home page was two grids of panels side by side, each row as tall as
its tallest panel, which left gaps. Now: four figures of the same size
(my open tickets, waiting for QA, finished by me and by the team this
week), the week, my views, and two columns that stack on their own: my
tickets and the latest on the left; sprints, QA, following, releases,
who is online and «Retomar» on the right. Project cards are as tall as
their row, with the team at the bottom, and a phone only lists the
days of the week that have something.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 21:37:45 +02:00
parent 0564810d36
commit 9044690348
9 changed files with 425 additions and 383 deletions

View File

@@ -5,6 +5,7 @@ import {
DestroyRef,
DOCUMENT,
inject,
input,
OnInit,
TemplateRef,
viewChild,
@@ -34,7 +35,6 @@ import {
typeLook,
} from '../ui/copy';
import { CalendarFeedComponent } from '../ui/calendar-feed';
import { MyViewsComponent } from '../ui/my-views';
const VISIBLE = 12;
@@ -52,213 +52,199 @@ const VISIBLE = 12;
PiIconComponent,
PiSkeletonComponent,
CalendarFeedComponent,
MyViewsComponent,
],
template: `
@if (!home.loaded()) {
<div class="ts-work">
@if (part() === 'main') {
<pi-skeleton shape="block" height="18rem" />
<div class="ts-work__side">
<pi-skeleton shape="block" height="8rem" />
<pi-skeleton shape="block" height="8rem" />
</div>
</div>
} @else {
<ts-my-views />
<div class="ts-work">
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
<header class="ts-work__head">
<h2 id="mine-title">Mis tickets</h2>
<span class="ts-muted ts-mono"
>{{ home.mineTotal() }} abiertos</span
} @else {
<pi-skeleton shape="block" height="8rem" />
<pi-skeleton shape="block" height="8rem" />
}
} @else if (part() === 'main') {
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
<header class="ts-work__head">
<h2 id="mine-title">Mis tickets</h2>
<span class="ts-muted ts-mono">{{ home.mineTotal() }} abiertos</span>
<button
type="button"
class="ts-plain-link ts-work__calendar"
(click)="openCalendar()"
>
<pi-icon icon="calendar" size="x-small" aria-hidden="true" />
Calendario
</button>
</header>
@if (home.qaFailed()) {
<p class="ts-work__alert" role="status">
<pi-icon icon="exclamation" size="small" aria-hidden="true" />
{{ home.qaFailed() }}
{{
home.qaFailed() === 1
? 'ticket tuyo ha fallado en QA'
: 'tickets tuyos han fallado en QA'
}}
</p>
}
@for (group of visibleGroups(); track group.category) {
<h3 class="ts-work__group">
<span
class="ts-pill"
[style.--tone]="categoryTone(group.category)"
>{{ categoryLabel(group.category) }}</span
>
<button
type="button"
class="ts-plain-link ts-work__calendar"
(click)="openCalendar()"
>
<pi-icon icon="calendar" size="x-small" aria-hidden="true" />
Calendario
</button>
</header>
@if (home.qaFailed()) {
<p class="ts-work__alert" role="status">
<pi-icon icon="exclamation" size="small" aria-hidden="true" />
{{ home.qaFailed() }}
{{
home.qaFailed() === 1
? 'ticket tuyo ha fallado en QA'
: 'tickets tuyos han fallado en QA'
}}
</p>
}
@for (group of visibleGroups(); track group.category) {
<h3 class="ts-work__group">
<span
class="ts-pill"
[style.--tone]="categoryTone(group.category)"
>{{ categoryLabel(group.category) }}</span
>
<span class="ts-mono">{{ group.issues.length }}</span>
</h3>
<ul class="ts-work__list">
@for (issue of group.issues; track issue.id) {
<li>
<a
class="ts-work__row"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="typeTone(issue)"
<span class="ts-mono">{{ group.issues.length }}</span>
</h3>
<ul class="ts-work__list">
@for (issue of group.issues; track issue.id) {
<li>
<a
class="ts-work__row"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="typeTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title"
><span
class="ts-work__status"
[style.--tone]="statusTone(issue)"
>{{ statusLabel(issue) }}</span
>
{{ issue.title }}</span
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title"
><span
class="ts-work__status"
[style.--tone]="statusTone(issue)"
>{{ statusLabel(issue) }}</span
>
{{ issue.title }}</span
>
<span
class="ts-work__project"
[style.--tone]="projectToneOf(issue.key)"
>{{ keyOf(issue.key) }}</span
>
</a>
</li>
}
</ul>
} @empty {
<p class="ts-work__empty">No tienes tickets abiertos asignados.</p>
@if (suggestions().length) {
<ul class="ts-work__ideas">
@for (s of suggestions(); track s.kind + s.projectKey) {
<li>
<a
[routerLink]="suggestionLink(s)"
[queryParams]="suggestionQuery(s)"
>{{ suggestionText(s) }}</a
>
</li>
}
</ul>
<span
class="ts-work__project"
[style.--tone]="projectToneOf(issue.key)"
>{{ keyOf(issue.key) }}</span
>
</a>
</li>
}
</ul>
} @empty {
<p class="ts-work__empty">No tienes tickets abiertos asignados.</p>
@if (suggestions().length) {
<ul class="ts-work__ideas">
@for (s of suggestions(); track s.kind + s.projectKey) {
<li>
<a
[routerLink]="suggestionLink(s)"
[queryParams]="suggestionQuery(s)"
>{{ suggestionText(s) }}</a
>
</li>
}
</ul>
}
@if (home.mineTotal() > shown()) {
<p class="ts-muted ts-work__more">
y {{ home.mineTotal() - shown() }} más, en los tableros de cada
proyecto
</p>
}
@if (home.mineTotal() > shown()) {
<p class="ts-muted ts-work__more">
y {{ home.mineTotal() - shown() }} más, en los tableros de cada
proyecto
</p>
}
</section>
} @else {
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
<header class="ts-work__head">
<h2 id="qa-title">Esperando QA</h2>
<span class="ts-muted ts-mono">{{ home.awaitingQaTotal() }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.awaitingQa().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
@if (issue.assignee; as assignee) {
<span class="ts-work__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
}
</a>
</li>
} @empty {
<li class="ts-work__empty">Nada esperando QA.</li>
}
</section>
</ul>
</section>
<div class="ts-work__side">
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
<header class="ts-work__head">
<h2 id="qa-title">Esperando QA</h2>
<span class="ts-muted ts-mono">{{ home.awaitingQaTotal() }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.awaitingQa().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
@if (issue.assignee; as assignee) {
<span class="ts-work__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
}
</a>
</li>
} @empty {
<li class="ts-work__empty">Nada esperando QA.</li>
}
</ul>
</section>
<section
class="ts-panel ts-work__panel"
aria-labelledby="following-title"
>
<header class="ts-work__head">
<h2 id="following-title">Que sigo</h2>
<span class="ts-muted ts-mono">{{ home.followingTotal() }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.following().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
@if (issue.assignee; as assignee) {
<span class="ts-work__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
}
</a>
</li>
} @empty {
<li class="ts-work__empty">
No sigues ningún ticket abierto. En la ficha de uno, «Seguir».
</li>
}
</ul>
</section>
<section
class="ts-panel ts-work__panel"
aria-labelledby="following-title"
>
<header class="ts-work__head">
<h2 id="following-title">Que sigo</h2>
<span class="ts-muted ts-mono">{{ home.followingTotal() }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.following().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
@if (issue.assignee; as assignee) {
<span class="ts-work__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
}
</a>
</li>
} @empty {
<li class="ts-work__empty">
No sigues ningún ticket abierto. En la ficha de uno, «Seguir».
</li>
}
</ul>
</section>
<section
class="ts-panel ts-work__panel"
aria-labelledby="releases-title"
>
<header class="ts-work__head">
<h2 id="releases-title">Próximas releases</h2>
</header>
<ul class="ts-work__list">
@for (release of home.releases(); track release.id) {
<li>
<a
class="ts-work__release"
[routerLink]="['/p', projectOf(release), 'releases']"
>
<span class="ts-work__version">{{ release.name }}</span>
<span
class="ts-work__project"
[style.--tone]="tone(projectOf(release))"
>{{ projectOf(release) }}</span
>
<span class="ts-work__when ts-mono">{{
when(release)
}}</span>
<span
class="ts-work__meter"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="release.progress.percent"
[attr.aria-label]="'Progreso de ' + release.name"
><span [style.width.%]="release.progress.percent"></span
></span>
<span class="ts-work__percent ts-mono"
>{{ release.progress.finished }}/{{
release.progress.total
}}</span
>
</a>
</li>
} @empty {
<li class="ts-work__empty">Ninguna release planificada.</li>
}
</ul>
</section>
</div>
</div>
<section class="ts-panel ts-work__panel" aria-labelledby="releases-title">
<header class="ts-work__head">
<h2 id="releases-title">Próximas releases</h2>
</header>
<ul class="ts-work__list">
@for (release of home.releases(); track release.id) {
<li>
<a
class="ts-work__release"
[routerLink]="['/p', projectOf(release), 'releases']"
>
<span class="ts-work__version">{{ release.name }}</span>
<span
class="ts-work__project"
[style.--tone]="tone(projectOf(release))"
>{{ projectOf(release) }}</span
>
<span class="ts-work__when ts-mono">{{ when(release) }}</span>
<span
class="ts-work__meter"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="release.progress.percent"
[attr.aria-label]="'Progreso de ' + release.name"
><span [style.width.%]="release.progress.percent"></span
></span>
<span class="ts-work__percent ts-mono"
>{{ release.progress.finished }}/{{
release.progress.total
}}</span
>
</a>
</li>
} @empty {
<li class="ts-work__empty">Ninguna release planificada.</li>
}
</ul>
</section>
}
<ng-template #calendarPanel>
<ts-calendar-feed />
@@ -266,21 +252,9 @@ const VISIBLE = 12;
`,
styles: `
:host {
display: block;
margin-bottom: 2.5rem;
}
.ts-work {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
@media (max-width: 960px) {
grid-template-columns: minmax(0, 1fr);
}
}
.ts-work__side {
display: grid;
gap: 1.25rem;
align-content: start;
}
.ts-work__panel {
padding: 1.375rem 1.5rem 1.25rem;
@@ -466,6 +440,8 @@ const VISIBLE = 12;
`,
})
export class MyWorkComponent implements OnInit {
/** «main»: my tickets (and my views); «side»: QA, following, releases. */
readonly part = input<'main' | 'side'>('main');
protected readonly home = inject(HomeFacade);
private readonly myHome = inject(MyHomeFacade);
/** What to do when nothing is mine (from the home reading). */
@@ -545,6 +521,8 @@ export class MyWorkComponent implements OnInit {
projectTone(projectKeyOf(issueKey));
ngOnInit(): void {
// Both parts read the same store: the main one keeps it fresh.
if (this.part() !== 'main') return;
this.home.load();
// Back to the tab after a while: refresh my work.
const onVisible = () => {

View File

@@ -51,7 +51,8 @@ import { HomeSprintsComponent } from '../ui/home/home-sprints';
import { OnlineNowComponent } from '../ui/home/online-now';
import { ResumeComponent } from '../ui/home/resume';
import { WeekAgendaComponent } from '../ui/home/week-agenda';
import { WeekStatsComponent } from '../ui/home/week-stats';
import { HomeKpisComponent } from '../ui/home/kpis';
import { MyViewsComponent } from '../ui/my-views';
/** How often the home page refreshes its reading while open. */
const REFRESH_MS = 60_000;
@@ -72,7 +73,8 @@ const REFRESH_MS = 60_000;
OnlineNowComponent,
ResumeComponent,
WeekAgendaComponent,
WeekStatsComponent,
HomeKpisComponent,
MyViewsComponent,
PiActionButtonComponent,
PiEmptyStateComponent,
PiSkeletonComponent,
@@ -106,29 +108,43 @@ const REFRESH_MS = 60_000;
[firstKey]="facade.sorted()[0]?.key ?? null"
(createProject)="openCreate()"
/>
@if (facade.projects().length) {
<ts-week-agenda [items]="h.agenda" />
}
}
@if (facade.projects().length) {
<ts-my-work />
@if (myHome.home(); as h) {
<div class="ts-home">
<ts-activity-feed
[activity]="h.activity"
[projectKeys]="projectKeys()"
/>
<div class="ts-home__side">
<ts-week-stats [me]="h.me" [team]="h.team" />
@if (h.sprints.length) {
<ts-home-sprints [sprints]="h.sprints" />
}
<ts-online-now [online]="h.online" />
<ts-resume />
</div>
</div>
<ts-home-kpis
[mine]="home.mineTotal()"
[dueToday]="dueToday()"
[overdue]="overdue()"
[awaitingQa]="home.awaitingQaTotal()"
[me]="h.me"
[team]="h.team"
/>
<ts-week-agenda [items]="h.agenda" />
}
<ts-my-views />
<!-- Two columns that stack on their own: no gaps between rows. -->
<div class="ts-home">
<div class="ts-home__column">
<ts-my-work />
@if (myHome.home(); as h) {
<ts-activity-feed
[activity]="h.activity"
[projectKeys]="projectKeys()"
/>
}
</div>
<div class="ts-home__column">
@if (myHome.home()?.sprints?.length) {
<ts-home-sprints [sprints]="myHome.home()!.sprints" />
}
<ts-my-work part="side" />
@if (myHome.home(); as h) {
<ts-online-now [online]="h.online" />
}
<ts-resume />
</div>
</div>
<div class="ts-section-header">
<h2>
Proyectos
@@ -245,22 +261,25 @@ const REFRESH_MS = 60_000;
font-size: 0.8125rem;
}
ts-first-steps,
ts-week-agenda {
ts-home-kpis,
ts-week-agenda,
ts-my-views {
margin-bottom: 1.25rem;
}
.ts-home {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
margin: 1.25rem 0 2rem;
margin-bottom: 2.5rem;
@media (max-width: 960px) {
grid-template-columns: minmax(0, 1fr);
}
}
.ts-home__side {
.ts-home__column {
display: grid;
gap: 1.25rem;
min-width: 0;
}
.ts-public-check {
p {

View File

@@ -0,0 +1,180 @@
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)} %`;
}
/** The home page's figures, four tiles of the same size. */
@Component({
selector: 'ts-home-kpis',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { role: 'region', 'aria-label': 'Cifras' },
template: `
<div class="ts-panel ts-kpi">
<p class="ts-kpi__label">Abiertos tuyos</p>
<p class="ts-kpi__value">{{ mine() }}</p>
<p class="ts-kpi__sub" [class.is-late]="overdue() > 0">
@if (overdue()) {
{{ overdue() }} {{ overdue() === 1 ? 'vencido' : 'vencidos' }}
@if (dueToday()) {
· {{ dueToday() }} hoy
}
} @else if (dueToday()) {
{{ dueToday() }} {{ dueToday() === 1 ? 'vence' : 'vencen' }} hoy
} @else {
Nada vence hoy
}
</p>
</div>
<div class="ts-panel ts-kpi">
<p class="ts-kpi__label">Esperando QA</p>
<p class="ts-kpi__value">{{ awaitingQa() }}</p>
<p class="ts-kpi__sub">En tus proyectos</p>
</div>
<div class="ts-panel ts-kpi">
<p class="ts-kpi__label">Terminados por ti</p>
<p class="ts-kpi__value">
{{ me().thisWeek }}
@if (myChange(); as c) {
<span class="ts-kpi__change" [class.is-down]="c.startsWith('−')">{{
c
}}</span>
}
</p>
<p class="ts-kpi__sub">
@if (me().streak > 1) {
{{ me().streak }} días seguidos
} @else if (me().streak === 1) {
Esta semana · racha de 1 día
} @else {
Esta semana · sin racha
}
</p>
</div>
<div class="ts-panel ts-kpi">
<p class="ts-kpi__label">Terminados por el equipo</p>
<p class="ts-kpi__value">
{{ team().thisWeek }}
@if (teamChange(); as c) {
<span class="ts-kpi__change" [class.is-down]="c.startsWith('−')">{{
c
}}</span>
}
</p>
<div
class="ts-kpi__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>
`,
styles: `
:host {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1.25rem;
@media (max-width: 900px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.ts-kpi {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
padding: 1.125rem 1.25rem;
}
.ts-kpi__label {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ts-kpi__value {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0.25rem 0 0;
font-size: 2rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
}
.ts-kpi__change {
color: var(--ts-c-green);
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0;
&.is-down {
color: var(--ts-muted);
}
}
.ts-kpi__sub {
margin: auto 0 0;
padding-top: 0.375rem;
color: var(--ts-ink-soft);
font-size: 0.8125rem;
&.is-late {
color: var(--ts-c-red);
font-weight: 600;
}
}
.ts-kpi__bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 1.5rem;
margin-top: auto;
padding-top: 0.375rem;
span {
flex: 1;
min-height: 2px;
border-radius: 2px 2px 0 0;
background: color-mix(in srgb, var(--ts-brand) 30%, transparent);
&.is-this-week {
background: var(--ts-brand);
}
}
}
`,
})
export class HomeKpisComponent {
readonly mine = input.required<number>();
readonly dueToday = input.required<number>();
readonly overdue = input.required<number>();
readonly awaitingQa = input.required<number>();
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;
}
}

View File

@@ -32,7 +32,7 @@ const KIND: Record<AgendaItem['kind'], string> = {
</header>
<ol class="ts-agenda">
@for (day of days(); track day.date) {
<li [class.is-today]="day.today">
<li [class.is-today]="day.today" [class.is-empty]="!day.items.length">
<p class="ts-agenda__day">
<span>{{ day.weekday }}</span> <strong>{{ day.number }}</strong>
</p>
@@ -128,6 +128,10 @@ const KIND: Record<AgendaItem['kind'], string> = {
li {
min-height: 0;
}
/* On a phone, only the days with something (and today). */
li.is-empty:not(.is-today) {
display: none;
}
}
}
`,

View File

@@ -1,142 +0,0 @@
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;
}
}

View File

@@ -60,6 +60,8 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
<span class="ts-project__late">· no llega</span>
}
</span>
} @else {
<span class="ts-project__sprint ts-muted">Sin sprint en curso</span>
}
</div>
}
@@ -74,12 +76,13 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
</a>
`,
styles: `
/* As tall as the tallest card of its row, the team always at the bottom. */
:host {
display: block;
display: grid;
}
.ts-project {
display: grid;
grid-template-rows: auto auto 1fr auto;
display: flex;
flex-direction: column;
gap: 0.375rem;
min-height: 12rem;
padding: 1.5rem 1.5rem 1.25rem;
@@ -103,7 +106,7 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
.ts-project__stats {
display: grid;
gap: 0.25rem;
margin-top: 0.25rem;
margin: 0.25rem 0 0.75rem;
color: var(--ts-ink-soft);
font-size: 0.75rem;
}
@@ -145,7 +148,7 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
display: flex;
align-items: center;
gap: 0.625rem;
margin-top: 0.75rem;
margin-top: auto;
padding-top: 0.875rem;
border-top: 1px dashed var(--ts-line);
}