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:
@@ -286,7 +286,7 @@ visualización de la plataforma:
|
|||||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, la semana (`ts-week-agenda`), mis vistas guardadas, mi trabajo (`ts-my-work`), lo último en mis proyectos, la semana en cifras, sprints en curso, quién está ahora, «Retomar» y mis proyectos con su actividad |
|
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar». Debajo, mis proyectos con su actividad |
|
||||||
| `/cartera` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas |
|
| `/cartera` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas |
|
||||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ import {
|
|||||||
DestroyRef,
|
DestroyRef,
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
OnInit,
|
OnInit,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
viewChild,
|
viewChild,
|
||||||
@@ -34,7 +35,6 @@ import {
|
|||||||
typeLook,
|
typeLook,
|
||||||
} from '../ui/copy';
|
} from '../ui/copy';
|
||||||
import { CalendarFeedComponent } from '../ui/calendar-feed';
|
import { CalendarFeedComponent } from '../ui/calendar-feed';
|
||||||
import { MyViewsComponent } from '../ui/my-views';
|
|
||||||
|
|
||||||
const VISIBLE = 12;
|
const VISIBLE = 12;
|
||||||
|
|
||||||
@@ -52,26 +52,20 @@ const VISIBLE = 12;
|
|||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
CalendarFeedComponent,
|
CalendarFeedComponent,
|
||||||
MyViewsComponent,
|
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@if (!home.loaded()) {
|
@if (!home.loaded()) {
|
||||||
<div class="ts-work">
|
@if (part() === 'main') {
|
||||||
<pi-skeleton shape="block" height="18rem" />
|
<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 {
|
} @else {
|
||||||
<ts-my-views />
|
<pi-skeleton shape="block" height="8rem" />
|
||||||
<div class="ts-work">
|
<pi-skeleton shape="block" height="8rem" />
|
||||||
|
}
|
||||||
|
} @else if (part() === 'main') {
|
||||||
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
|
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
|
||||||
<header class="ts-work__head">
|
<header class="ts-work__head">
|
||||||
<h2 id="mine-title">Mis tickets</h2>
|
<h2 id="mine-title">Mis tickets</h2>
|
||||||
<span class="ts-muted ts-mono"
|
<span class="ts-muted ts-mono">{{ home.mineTotal() }} abiertos</span>
|
||||||
>{{ home.mineTotal() }} abiertos</span
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ts-plain-link ts-work__calendar"
|
class="ts-plain-link ts-work__calendar"
|
||||||
@@ -150,8 +144,7 @@ const VISIBLE = 12;
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
} @else {
|
||||||
<div class="ts-work__side">
|
|
||||||
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
|
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
|
||||||
<header class="ts-work__head">
|
<header class="ts-work__head">
|
||||||
<h2 id="qa-title">Esperando QA</h2>
|
<h2 id="qa-title">Esperando QA</h2>
|
||||||
@@ -213,10 +206,7 @@ const VISIBLE = 12;
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section class="ts-panel ts-work__panel" aria-labelledby="releases-title">
|
||||||
class="ts-panel ts-work__panel"
|
|
||||||
aria-labelledby="releases-title"
|
|
||||||
>
|
|
||||||
<header class="ts-work__head">
|
<header class="ts-work__head">
|
||||||
<h2 id="releases-title">Próximas releases</h2>
|
<h2 id="releases-title">Próximas releases</h2>
|
||||||
</header>
|
</header>
|
||||||
@@ -233,9 +223,7 @@ const VISIBLE = 12;
|
|||||||
[style.--tone]="tone(projectOf(release))"
|
[style.--tone]="tone(projectOf(release))"
|
||||||
>{{ projectOf(release) }}</span
|
>{{ projectOf(release) }}</span
|
||||||
>
|
>
|
||||||
<span class="ts-work__when ts-mono">{{
|
<span class="ts-work__when ts-mono">{{ when(release) }}</span>
|
||||||
when(release)
|
|
||||||
}}</span>
|
|
||||||
<span
|
<span
|
||||||
class="ts-work__meter"
|
class="ts-work__meter"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
@@ -257,8 +245,6 @@ const VISIBLE = 12;
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<ng-template #calendarPanel>
|
<ng-template #calendarPanel>
|
||||||
<ts-calendar-feed />
|
<ts-calendar-feed />
|
||||||
@@ -266,21 +252,9 @@ const VISIBLE = 12;
|
|||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
:host {
|
: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;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.ts-work__panel {
|
.ts-work__panel {
|
||||||
padding: 1.375rem 1.5rem 1.25rem;
|
padding: 1.375rem 1.5rem 1.25rem;
|
||||||
@@ -466,6 +440,8 @@ const VISIBLE = 12;
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class MyWorkComponent implements OnInit {
|
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);
|
protected readonly home = inject(HomeFacade);
|
||||||
private readonly myHome = inject(MyHomeFacade);
|
private readonly myHome = inject(MyHomeFacade);
|
||||||
/** What to do when nothing is mine (from the home reading). */
|
/** What to do when nothing is mine (from the home reading). */
|
||||||
@@ -545,6 +521,8 @@ export class MyWorkComponent implements OnInit {
|
|||||||
projectTone(projectKeyOf(issueKey));
|
projectTone(projectKeyOf(issueKey));
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Both parts read the same store: the main one keeps it fresh.
|
||||||
|
if (this.part() !== 'main') return;
|
||||||
this.home.load();
|
this.home.load();
|
||||||
// Back to the tab after a while: refresh my work.
|
// Back to the tab after a while: refresh my work.
|
||||||
const onVisible = () => {
|
const onVisible = () => {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ import { HomeSprintsComponent } from '../ui/home/home-sprints';
|
|||||||
import { OnlineNowComponent } from '../ui/home/online-now';
|
import { OnlineNowComponent } from '../ui/home/online-now';
|
||||||
import { ResumeComponent } from '../ui/home/resume';
|
import { ResumeComponent } from '../ui/home/resume';
|
||||||
import { WeekAgendaComponent } from '../ui/home/week-agenda';
|
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. */
|
/** How often the home page refreshes its reading while open. */
|
||||||
const REFRESH_MS = 60_000;
|
const REFRESH_MS = 60_000;
|
||||||
@@ -72,7 +73,8 @@ const REFRESH_MS = 60_000;
|
|||||||
OnlineNowComponent,
|
OnlineNowComponent,
|
||||||
ResumeComponent,
|
ResumeComponent,
|
||||||
WeekAgendaComponent,
|
WeekAgendaComponent,
|
||||||
WeekStatsComponent,
|
HomeKpisComponent,
|
||||||
|
MyViewsComponent,
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
@@ -106,29 +108,43 @@ const REFRESH_MS = 60_000;
|
|||||||
[firstKey]="facade.sorted()[0]?.key ?? null"
|
[firstKey]="facade.sorted()[0]?.key ?? null"
|
||||||
(createProject)="openCreate()"
|
(createProject)="openCreate()"
|
||||||
/>
|
/>
|
||||||
@if (facade.projects().length) {
|
|
||||||
<ts-week-agenda [items]="h.agenda" />
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (facade.projects().length) {
|
@if (facade.projects().length) {
|
||||||
|
@if (myHome.home(); as h) {
|
||||||
|
<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 />
|
<ts-my-work />
|
||||||
@if (myHome.home(); as h) {
|
@if (myHome.home(); as h) {
|
||||||
<div class="ts-home">
|
|
||||||
<ts-activity-feed
|
<ts-activity-feed
|
||||||
[activity]="h.activity"
|
[activity]="h.activity"
|
||||||
[projectKeys]="projectKeys()"
|
[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" />
|
|
||||||
}
|
}
|
||||||
|
</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-online-now [online]="h.online" />
|
||||||
|
}
|
||||||
<ts-resume />
|
<ts-resume />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<div class="ts-section-header">
|
<div class="ts-section-header">
|
||||||
<h2>
|
<h2>
|
||||||
Proyectos
|
Proyectos
|
||||||
@@ -245,22 +261,25 @@ const REFRESH_MS = 60_000;
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
ts-first-steps,
|
ts-first-steps,
|
||||||
ts-week-agenda {
|
ts-home-kpis,
|
||||||
|
ts-week-agenda,
|
||||||
|
ts-my-views {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
.ts-home {
|
.ts-home {
|
||||||
display: grid;
|
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;
|
gap: 1.25rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
margin: 1.25rem 0 2rem;
|
margin-bottom: 2.5rem;
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ts-home__side {
|
.ts-home__column {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.ts-public-check {
|
.ts-public-check {
|
||||||
p {
|
p {
|
||||||
|
|||||||
180
libs/web/tracker/presenters/src/lib/ui/home/kpis.ts
Normal file
180
libs/web/tracker/presenters/src/lib/ui/home/kpis.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ const KIND: Record<AgendaItem['kind'], string> = {
|
|||||||
</header>
|
</header>
|
||||||
<ol class="ts-agenda">
|
<ol class="ts-agenda">
|
||||||
@for (day of days(); track day.date) {
|
@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">
|
<p class="ts-agenda__day">
|
||||||
<span>{{ day.weekday }}</span> <strong>{{ day.number }}</strong>
|
<span>{{ day.weekday }}</span> <strong>{{ day.number }}</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -128,6 +128,10 @@ const KIND: Record<AgendaItem['kind'], string> = {
|
|||||||
li {
|
li {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
/* On a phone, only the days with something (and today). */
|
||||||
|
li.is-empty:not(.is-today) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,8 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
<span class="ts-project__late">· no llega</span>
|
<span class="ts-project__late">· no llega</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="ts-project__sprint ts-muted">Sin sprint en curso</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -74,12 +76,13 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
</a>
|
</a>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
/* As tall as the tallest card of its row, the team always at the bottom. */
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: grid;
|
||||||
}
|
}
|
||||||
.ts-project {
|
.ts-project {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto auto 1fr auto;
|
flex-direction: column;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
padding: 1.5rem 1.5rem 1.25rem;
|
padding: 1.5rem 1.5rem 1.25rem;
|
||||||
@@ -103,7 +106,7 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
.ts-project__stats {
|
.ts-project__stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
margin-top: 0.25rem;
|
margin: 0.25rem 0 0.75rem;
|
||||||
color: var(--ts-ink-soft);
|
color: var(--ts-ink-soft);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -145,7 +148,7 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: auto;
|
||||||
padding-top: 0.875rem;
|
padding-top: 0.875rem;
|
||||||
border-top: 1px dashed var(--ts-line);
|
border-top: 1px dashed var(--ts-line);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user