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:
@@ -75,6 +75,7 @@ import {
|
||||
} from '../ui/markdown';
|
||||
import { RichEditorComponent } from '../ui/rich-editor';
|
||||
import { CustomFieldComponent } from '../ui/custom-field';
|
||||
import { rememberIssue } from '../ui/home/recent-issues';
|
||||
|
||||
const NONE = '';
|
||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||
@@ -1306,8 +1307,17 @@ export class IssuePage {
|
||||
constructor() {
|
||||
effect(() => this.facade.open(this.issueKey()));
|
||||
effect(() => {
|
||||
const id = this.facade.issue()?.id;
|
||||
if (id) untracked(() => this.presence.open(id));
|
||||
const issue = this.facade.issue();
|
||||
if (!issue) return;
|
||||
untracked(() => {
|
||||
this.presence.open(issue.id);
|
||||
// «Retomar» on the home page.
|
||||
rememberIssue({
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
projectKey: issue.key.slice(0, issue.key.lastIndexOf('-')),
|
||||
});
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||
|
||||
@@ -14,12 +14,14 @@ import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
HomeFacade,
|
||||
MyHomeFacade,
|
||||
ProjectsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
IssueSummary,
|
||||
Release,
|
||||
StatusCategory,
|
||||
Suggestion,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
@@ -126,10 +128,20 @@ const VISIBLE = 12;
|
||||
}
|
||||
</ul>
|
||||
} @empty {
|
||||
<p class="ts-work__empty">
|
||||
No tienes tickets abiertos asignados. Buen momento para revisar lo
|
||||
que espera QA.
|
||||
</p>
|
||||
<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">
|
||||
@@ -436,6 +448,17 @@ const VISIBLE = 12;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-work__ideas {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0 0.5rem;
|
||||
list-style: none;
|
||||
font-size: 0.875rem;
|
||||
a {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-work__more {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
@@ -444,6 +467,31 @@ const VISIBLE = 12;
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
protected readonly home = inject(HomeFacade);
|
||||
private readonly myHome = inject(MyHomeFacade);
|
||||
/** What to do when nothing is mine (from the home reading). */
|
||||
protected readonly suggestions = computed(
|
||||
() => this.myHome.home()?.suggestions ?? [],
|
||||
);
|
||||
|
||||
protected suggestionText(s: Suggestion): string {
|
||||
const n = s.count;
|
||||
switch (s.kind) {
|
||||
case 'UNASSIGNED':
|
||||
return `${n} ${n === 1 ? 'ticket sin dueño' : 'tickets sin dueño'} en ${s.projectKey}`;
|
||||
case 'QA_WAITING':
|
||||
return `${n} ${n === 1 ? 'ticket espera' : 'tickets esperan'} QA en ${s.projectKey}`;
|
||||
default:
|
||||
return `${n} ${n === 1 ? 'propuesta del público espera' : 'propuestas del público esperan'} respuesta en ${s.projectKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
protected suggestionLink(s: Suggestion): unknown[] {
|
||||
return ['/p', s.projectKey, s.kind === 'UNASSIGNED' ? 'list' : 'board'];
|
||||
}
|
||||
|
||||
protected suggestionQuery(s: Suggestion): Record<string, string> | null {
|
||||
return s.kind === 'UNASSIGNED' ? { missing: 'assignee' } : null;
|
||||
}
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly calendarPanel =
|
||||
viewChild.required<TemplateRef<unknown>>('calendarPanel');
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
DOCUMENT,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
@@ -27,12 +29,32 @@ import {
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||
import { ProjectRole } from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
HomeFacade,
|
||||
InboxFacade,
|
||||
MyHomeFacade,
|
||||
ProjectsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { GoToComponent } from '../ui/go-to';
|
||||
import { ProjectCardComponent } from '../ui/project-card';
|
||||
import { MyWorkComponent } from './my-work';
|
||||
import { ActivityFeedComponent } from '../ui/home/activity-feed';
|
||||
import { DaySummaryComponent } from '../ui/home/day-summary';
|
||||
import { FirstStepsComponent } from '../ui/home/first-steps';
|
||||
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';
|
||||
|
||||
/** How often the home page refreshes its reading while open. */
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
@Component({
|
||||
selector: 'ts-projects-page',
|
||||
@@ -43,6 +65,14 @@ import { MyWorkComponent } from './my-work';
|
||||
GoToComponent,
|
||||
ProjectCardComponent,
|
||||
MyWorkComponent,
|
||||
ActivityFeedComponent,
|
||||
DaySummaryComponent,
|
||||
FirstStepsComponent,
|
||||
HomeSprintsComponent,
|
||||
OnlineNowComponent,
|
||||
ResumeComponent,
|
||||
WeekAgendaComponent,
|
||||
WeekStatsComponent,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
@@ -53,14 +83,13 @@ import { MyWorkComponent } from './my-work';
|
||||
],
|
||||
template: `
|
||||
<header class="ts-page-header">
|
||||
<div>
|
||||
<p class="ts-eyebrow">Pireon · TaskStream</p>
|
||||
<h1>
|
||||
Hola,
|
||||
<span class="ts-gradient-text">{{ firstName() }}</span>
|
||||
</h1>
|
||||
<p>Lo tuyo en todos tus proyectos, al día en tiempo real.</p>
|
||||
</div>
|
||||
<ts-day-summary
|
||||
[firstName]="firstName()"
|
||||
[dueToday]="dueToday()"
|
||||
[overdue]="overdue()"
|
||||
[qaFailed]="home.qaFailed()"
|
||||
[mentions]="unreadMentions()"
|
||||
/>
|
||||
<div class="ts-home-actions">
|
||||
<ts-go-to />
|
||||
<pi-action-button
|
||||
@@ -71,8 +100,35 @@ import { MyWorkComponent } from './my-work';
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (myHome.home(); as h) {
|
||||
<ts-first-steps
|
||||
[steps]="h.firstSteps"
|
||||
[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>
|
||||
}
|
||||
<div class="ts-section-header">
|
||||
<h2>
|
||||
Proyectos
|
||||
@@ -104,7 +160,10 @@ import { MyWorkComponent } from './my-work';
|
||||
} @else {
|
||||
<div class="ts-projects">
|
||||
@for (project of facade.sorted(); track project.id) {
|
||||
<ts-project-card [project]="project" />
|
||||
<ts-project-card
|
||||
[project]="project"
|
||||
[stats]="statsOf().get(project.id) ?? null"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -185,6 +244,24 @@ import { MyWorkComponent } from './my-work';
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
ts-first-steps,
|
||||
ts-week-agenda {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ts-home {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
margin: 1.25rem 0 2rem;
|
||||
@media (max-width: 960px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.ts-home__side {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.ts-public-check {
|
||||
p {
|
||||
/* Under the checkbox's text, past the box. */
|
||||
@@ -248,8 +325,63 @@ export class ProjectsPage implements OnInit {
|
||||
|
||||
protected readonly pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/* The home reading: agenda, activity, the week… refreshed while open. */
|
||||
protected readonly myHome = inject(MyHomeFacade);
|
||||
protected readonly home = inject(HomeFacade);
|
||||
private readonly inbox = inject(InboxFacade);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly todayStr = new Date().toISOString().slice(0, 10);
|
||||
private readonly openMine = computed(() =>
|
||||
this.home
|
||||
.mine()
|
||||
.filter(
|
||||
(i) =>
|
||||
i.statusCategory !== StatusCategory.DONE &&
|
||||
i.statusCategory !== StatusCategory.CANCELLED,
|
||||
),
|
||||
);
|
||||
protected readonly dueToday = computed(
|
||||
() =>
|
||||
this.openMine().filter((i) => i.dueDate?.slice(0, 10) === this.todayStr)
|
||||
.length,
|
||||
);
|
||||
protected readonly overdue = computed(
|
||||
() =>
|
||||
this.openMine().filter(
|
||||
(i) => !!i.dueDate && i.dueDate.slice(0, 10) < this.todayStr,
|
||||
).length,
|
||||
);
|
||||
protected readonly unreadMentions = computed(
|
||||
() =>
|
||||
this.inbox
|
||||
.items()
|
||||
.filter((n) => n.kind === NotificationKind.MENTIONED && !n.readAt)
|
||||
.length,
|
||||
);
|
||||
protected readonly projectKeys = computed(
|
||||
() => new Map(this.facade.projects().map((p) => [p.id, p.key])),
|
||||
);
|
||||
protected readonly statsOf = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(this.myHome.home()?.projects ?? []).map((p) => [p.projectId, p]),
|
||||
),
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.facade.load();
|
||||
this.myHome.refresh();
|
||||
const timer = setInterval(() => this.myHome.refresh(), REFRESH_MS);
|
||||
const onVisible = () => {
|
||||
if (this.document.visibilityState === 'visible') this.myHome.refresh();
|
||||
};
|
||||
this.document.addEventListener('visibilitychange', onVisible);
|
||||
this.destroyRef.onDestroy(() => {
|
||||
clearInterval(timer);
|
||||
this.document.removeEventListener('visibilitychange', onVisible);
|
||||
});
|
||||
}
|
||||
|
||||
protected openCreate(): void {
|
||||
|
||||
150
libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts
Normal file
150
libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Activity } from '@task-stream/web/tracker/domain';
|
||||
import { initials, projectTone, timeAgo } from '../copy';
|
||||
|
||||
/** What moved lately in all my projects. */
|
||||
@Component({
|
||||
selector: 'ts-activity-feed',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
role: 'region',
|
||||
'aria-labelledby': 'feed-title',
|
||||
},
|
||||
styleUrl: './home-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="feed-title">Lo último en tus proyectos</h2>
|
||||
</header>
|
||||
@if (activity().length) {
|
||||
<ol class="ts-feed">
|
||||
@for (entry of shown(); track entry.id) {
|
||||
<li>
|
||||
<span class="ts-feed__who" aria-hidden="true">{{
|
||||
initialsOf(entry.actor.name)
|
||||
}}</span>
|
||||
<span class="ts-feed__what">
|
||||
@if (entry.issueKey) {
|
||||
<a
|
||||
[routerLink]="['/p', keyOf(entry), 'issues', entry.issueKey]"
|
||||
>{{ entry.summary }}</a
|
||||
>
|
||||
} @else {
|
||||
{{ entry.summary }}
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="ts-feed__project"
|
||||
[style.--tone]="toneOf(keyOf(entry))"
|
||||
>{{ keyOf(entry) }}</span
|
||||
>
|
||||
<span class="ts-feed__when">{{ ago(entry.occurredAt) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@if (activity().length > SHORT) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link ts-feed__more"
|
||||
(click)="all.set(!all())"
|
||||
>
|
||||
{{
|
||||
all()
|
||||
? 'Ver menos'
|
||||
: 'Ver más (' + (activity().length - SHORT) + ')'
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<p class="ts-empty">Aún no ha pasado nada en tus proyectos.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-feed {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 1.75rem minmax(0, 1fr) auto;
|
||||
grid-template-areas: 'who what project' 'who when when';
|
||||
column-gap: 0.625rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-feed__who {
|
||||
grid-area: who;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-feed__what {
|
||||
grid-area: what;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-feed__project {
|
||||
grid-area: project;
|
||||
color: var(--tone);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-feed__more {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ts-feed__when {
|
||||
grid-area: when;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ActivityFeedComponent {
|
||||
readonly activity = input.required<Activity[]>();
|
||||
/** Project id → key. */
|
||||
readonly projectKeys = input.required<Map<string, string>>();
|
||||
|
||||
protected readonly SHORT = 10;
|
||||
protected readonly all = signal(false);
|
||||
protected readonly shown = computed(() =>
|
||||
this.all() ? this.activity() : this.activity().slice(0, this.SHORT),
|
||||
);
|
||||
protected readonly initialsOf = initials;
|
||||
protected readonly toneOf = projectTone;
|
||||
protected readonly ago = (iso: string) => timeAgo(iso);
|
||||
|
||||
protected keyOf(entry: Activity): string {
|
||||
return (
|
||||
this.projectKeys().get(entry.projectId ?? '') ??
|
||||
entry.issueKey?.slice(0, entry.issueKey.lastIndexOf('-')) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
86
libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts
Normal file
86
libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
const plural = (n: number, one: string, many: string) =>
|
||||
`${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** «Buenos días, Ana», today's date and what today holds, in one sentence. */
|
||||
@Component({
|
||||
selector: 'ts-day-summary',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<p class="ts-eyebrow">{{ today() }}</p>
|
||||
<h1>
|
||||
{{ greeting() }},
|
||||
<span class="ts-gradient-text">{{ firstName() }}</span>
|
||||
</h1>
|
||||
<p class="ts-day">{{ summary() }}</p>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.ts-day {
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DaySummaryComponent {
|
||||
readonly firstName = input('');
|
||||
readonly dueToday = input(0);
|
||||
readonly overdue = input(0);
|
||||
readonly qaFailed = input(0);
|
||||
readonly mentions = input(0);
|
||||
/** Injected in tests; the clock otherwise. */
|
||||
readonly now = input<Date>(new Date());
|
||||
|
||||
protected readonly greeting = computed(() => {
|
||||
const hour = this.now().getHours();
|
||||
return hour < 6
|
||||
? 'Buenas noches'
|
||||
: hour < 14
|
||||
? 'Buenos días'
|
||||
: hour < 21
|
||||
? 'Buenas tardes'
|
||||
: 'Buenas noches';
|
||||
});
|
||||
|
||||
protected readonly today = computed(() =>
|
||||
this.now().toLocaleDateString('es', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}),
|
||||
);
|
||||
|
||||
protected readonly summary = computed(() => {
|
||||
const parts = [
|
||||
this.dueToday()
|
||||
? `hoy vence ${this.dueToday() === 1 ? '1 ticket tuyo' : `${this.dueToday()} tickets tuyos`}`
|
||||
: '',
|
||||
this.overdue()
|
||||
? plural(this.overdue(), 'ticket vencido', 'tickets vencidos')
|
||||
: '',
|
||||
this.qaFailed()
|
||||
? plural(this.qaFailed(), 'vuelto de QA', 'vueltos de QA')
|
||||
: '',
|
||||
this.mentions()
|
||||
? plural(this.mentions(), 'mención sin leer', 'menciones sin leer')
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
if (!parts.length) return 'Nada urgente hoy: buen momento para avanzar.';
|
||||
const text =
|
||||
parts.length > 1
|
||||
? `${parts.slice(0, -1).join(', ')} y ${parts[parts.length - 1]}`
|
||||
: parts[0];
|
||||
return text.charAt(0).toUpperCase() + text.slice(1) + '.';
|
||||
});
|
||||
}
|
||||
149
libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts
Normal file
149
libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Home } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const HIDDEN = 'task-stream.firstSteps.hidden';
|
||||
|
||||
/** The first things to do in TaskStream; gone once done (or dismissed). */
|
||||
@Component({
|
||||
selector: 'ts-first-steps',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'steps-title' },
|
||||
styleUrl: './home-panel.scss',
|
||||
template: `
|
||||
@if (shown()) {
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="steps-title">Primeros pasos</h2>
|
||||
<button type="button" class="ts-plain-link" (click)="dismiss()">
|
||||
Ocultar
|
||||
</button>
|
||||
</header>
|
||||
<p class="ts-steps__progress">
|
||||
{{ doneCount() }} de 4 hechos
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="(doneCount() / 4) * 100"></span
|
||||
></span>
|
||||
</p>
|
||||
<ol class="ts-steps">
|
||||
<li [class.is-done]="steps().project">
|
||||
@if (steps().project) {
|
||||
Crear un proyecto
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="createProject.emit()"
|
||||
>
|
||||
Crear un proyecto
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
<li [class.is-done]="steps().invited">
|
||||
@if (firstKey(); as key) {
|
||||
<a [routerLink]="['/p', key, 'members']">Invitar a alguien</a>
|
||||
} @else {
|
||||
Invitar a alguien
|
||||
}
|
||||
</li>
|
||||
<li [class.is-done]="steps().ticket">
|
||||
@if (firstKey(); as key) {
|
||||
<a [routerLink]="['/p', key, 'board']">Crear tu primer ticket</a>
|
||||
} @else {
|
||||
Crear tu primer ticket
|
||||
}
|
||||
</li>
|
||||
<li [class.is-done]="steps().calendar">
|
||||
Conectar tu calendario (botón «Calendario», en Mis tickets)
|
||||
</li>
|
||||
</ol>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host:has(.ts-steps) {
|
||||
display: block;
|
||||
}
|
||||
:host:not(:has(.ts-steps)) {
|
||||
display: none;
|
||||
}
|
||||
.ts-steps__progress {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-steps {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border: 1.5px solid var(--ts-line-strong, var(--ts-muted));
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
&.is-done {
|
||||
color: var(--ts-muted);
|
||||
text-decoration: line-through;
|
||||
&::before {
|
||||
border-color: var(--ts-c-green, #2e9e6a);
|
||||
background: var(--ts-c-green, #2e9e6a);
|
||||
}
|
||||
}
|
||||
}
|
||||
a {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class FirstStepsComponent {
|
||||
readonly steps = input.required<Home['firstSteps']>();
|
||||
/** A project to point the links at, if any. */
|
||||
readonly firstKey = input<string | null>(null);
|
||||
readonly createProject = output<void>();
|
||||
|
||||
private readonly hidden = signal(readHidden());
|
||||
protected readonly doneCount = computed(
|
||||
() => Object.values(this.steps()).filter(Boolean).length,
|
||||
);
|
||||
protected readonly shown = computed(
|
||||
() => !this.hidden() && this.doneCount() < 4,
|
||||
);
|
||||
|
||||
protected dismiss(): void {
|
||||
this.hidden.set(true);
|
||||
try {
|
||||
localStorage.setItem(HIDDEN, '1');
|
||||
} catch {
|
||||
/* a convenience */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readHidden(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(HIDDEN) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
94
libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss
Normal file
94
libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss
Normal file
@@ -0,0 +1,94 @@
|
||||
// Shared by the dashboard's insight panels (each host is a .ts-panel).
|
||||
:host {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.ts-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-issues {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ts-key {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
.ts-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-days {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-bar {
|
||||
position: relative;
|
||||
height: 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--tone, var(--ts-brand));
|
||||
}
|
||||
}
|
||||
|
||||
.ts-warn {
|
||||
color: var(--ts-c-red, #d14343);
|
||||
}
|
||||
|
||||
.ts-empty {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
71
libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts
Normal file
71
libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Home } from '@task-stream/web/tracker/domain';
|
||||
import { projectTone } from '../copy';
|
||||
|
||||
/** The sprints under way in my projects: how far, and whether they make it. */
|
||||
@Component({
|
||||
selector: 'ts-home-sprints',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
role: 'region',
|
||||
'aria-labelledby': 'sprints-title',
|
||||
},
|
||||
styleUrl: './home-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="sprints-title">Sprints en curso</h2>
|
||||
</header>
|
||||
<ul class="ts-issues ts-hsprints">
|
||||
@for (row of sprints(); track row.sprint.sprintId) {
|
||||
<li [style.--tone]="toneOf(row.projectKey)">
|
||||
<a class="ts-key" [routerLink]="['/p', row.projectKey, 'sprints']">{{
|
||||
row.projectKey
|
||||
}}</a>
|
||||
<a
|
||||
class="ts-title"
|
||||
[routerLink]="['/p', row.projectKey, 'sprints']"
|
||||
>{{ row.sprint.name }}</a
|
||||
>
|
||||
<span class="ts-days" [class.ts-warn]="row.sprint.behind">{{
|
||||
row.sprint.behind ? 'no llega' : left(row.sprint.daysLeft)
|
||||
}}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="percent(row.sprint)"></span
|
||||
></span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
`,
|
||||
styles: `
|
||||
.ts-hsprints {
|
||||
gap: 0.75rem;
|
||||
li {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
.ts-bar {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class HomeSprintsComponent {
|
||||
readonly sprints = input.required<Home['sprints']>();
|
||||
protected readonly toneOf = projectTone;
|
||||
|
||||
protected left(days: number): string {
|
||||
return days < 0
|
||||
? 'terminado'
|
||||
: days === 0
|
||||
? 'último día'
|
||||
: `quedan ${days} d`;
|
||||
}
|
||||
|
||||
protected percent(s: Home['sprints'][number]['sprint']): number {
|
||||
if (s.points) return Math.round((s.donePoints / s.points) * 100);
|
||||
return s.tickets ? Math.round((s.doneTickets / s.tickets) * 100) : 0;
|
||||
}
|
||||
}
|
||||
94
libs/web/tracker/presenters/src/lib/ui/home/online-now.ts
Normal file
94
libs/web/tracker/presenters/src/lib/ui/home/online-now.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Online } from '@task-stream/web/tracker/domain';
|
||||
import { initials } from '../copy';
|
||||
|
||||
/** Who of my projects is on a ticket right now, and which. */
|
||||
@Component({
|
||||
selector: 'ts-online-now',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
role: 'region',
|
||||
'aria-labelledby': 'online-title',
|
||||
},
|
||||
styleUrl: './home-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="online-title">Ahora mismo</h2>
|
||||
<span class="ts-muted">{{ online().length }} conectados</span>
|
||||
</header>
|
||||
@if (online().length) {
|
||||
<ul class="ts-online">
|
||||
@for (o of online(); track o.user.id) {
|
||||
<li>
|
||||
<span class="ts-online__who" aria-hidden="true"
|
||||
>{{ initialsOf(o.user.name) }}<i></i
|
||||
></span>
|
||||
<span class="ts-online__text">
|
||||
<strong>{{ o.user.name }}</strong>
|
||||
{{ o.editing ? 'edita' : 'mira' }}
|
||||
@if (o.issueKey) {
|
||||
<a [routerLink]="['/p', o.projectKey, 'issues', o.issueKey]">{{
|
||||
o.issueKey
|
||||
}}</a>
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-empty">
|
||||
Nadie más de tus proyectos está en un ticket ahora.
|
||||
</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-online {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
a {
|
||||
color: var(--ts-brand);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-online__who {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
i {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border: 2px solid var(--ts-surface);
|
||||
border-radius: 50%;
|
||||
background: var(--ts-c-green, #2e9e6a);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class OnlineNowComponent {
|
||||
readonly online = input.required<Online[]>();
|
||||
protected readonly initialsOf = initials;
|
||||
}
|
||||
37
libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts
Normal file
37
libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* The tickets I opened lately, kept in this browser only: a convenience
|
||||
* («Retomar»), so storage may fail or be empty.
|
||||
*/
|
||||
const KEY = 'task-stream.recentIssues';
|
||||
const MAX = 8;
|
||||
|
||||
export interface RecentIssue {
|
||||
key: string;
|
||||
title: string;
|
||||
projectKey: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export function recentIssues(): RecentIssue[] {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(KEY) ?? '[]');
|
||||
return Array.isArray(raw) ? (raw as RecentIssue[]).slice(0, MAX) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** This one first; the oldest drop off. */
|
||||
export function rememberIssue(issue: Omit<RecentIssue, 'at'>): void {
|
||||
try {
|
||||
const rest = recentIssues().filter((i) => i.key !== issue.key);
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify(
|
||||
[{ ...issue, at: new Date().toISOString() }, ...rest].slice(0, MAX),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* a convenience: nothing to do */
|
||||
}
|
||||
}
|
||||
49
libs/web/tracker/presenters/src/lib/ui/home/resume.ts
Normal file
49
libs/web/tracker/presenters/src/lib/ui/home/resume.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { timeAgo } from '../copy';
|
||||
import { recentIssues } from './recent-issues';
|
||||
|
||||
/** The tickets I opened lately, to pick up where I left off. */
|
||||
@Component({
|
||||
selector: 'ts-resume',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
role: 'region',
|
||||
'aria-labelledby': 'resume-title',
|
||||
},
|
||||
styleUrl: './home-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="resume-title">Retomar</h2>
|
||||
</header>
|
||||
@if (items().length) {
|
||||
<ul class="ts-issues">
|
||||
@for (item of items(); track item.key) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-key"
|
||||
[routerLink]="['/p', item.projectKey, 'issues', item.key]"
|
||||
>{{ item.key }}</a
|
||||
>
|
||||
<a
|
||||
class="ts-title"
|
||||
[routerLink]="['/p', item.projectKey, 'issues', item.key]"
|
||||
>{{ item.title }}</a
|
||||
>
|
||||
<span class="ts-days">{{ ago(item.at) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-empty">
|
||||
Los tickets que abras saldrán aquí para volver a ellos.
|
||||
</p>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ResumeComponent {
|
||||
protected readonly items = signal(recentIssues().slice(0, 5));
|
||||
protected readonly ago = (iso: string) => timeAgo(iso);
|
||||
}
|
||||
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',
|
||||
];
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Project } from '@task-stream/web/tracker/domain';
|
||||
import { HomeProject, Project } from '@task-stream/web/tracker/domain';
|
||||
import { AvatarsComponent } from './avatars';
|
||||
import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
||||
|
||||
@@ -36,6 +36,33 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
||||
@if (project().description) {
|
||||
<p class="ts-project__description">{{ project().description }}</p>
|
||||
}
|
||||
@if (stats(); as s) {
|
||||
<div class="ts-project__stats">
|
||||
<svg
|
||||
class="ts-project__spark"
|
||||
viewBox="0 0 100 24"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
[attr.aria-label]="'Actividad de 14 días: ' + s.activity.join(', ')"
|
||||
>
|
||||
<polyline [attr.points]="spark()" />
|
||||
</svg>
|
||||
<span class="ts-mono"
|
||||
>{{ s.open }} abiertos
|
||||
@if (s.overdue) {
|
||||
· <span class="ts-project__late">{{ s.overdue }} vencidos</span>
|
||||
}
|
||||
</span>
|
||||
@if (s.sprint; as sprint) {
|
||||
<span class="ts-project__sprint">
|
||||
{{ sprint.name }} · {{ sprint.percent }}%
|
||||
@if (sprint.behind) {
|
||||
<span class="ts-project__late">· no llega</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ts-project__bottom">
|
||||
<ts-avatars [names]="memberNames()" />
|
||||
<span class="ts-project__count ts-mono"
|
||||
@@ -73,6 +100,26 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--tone);
|
||||
}
|
||||
.ts-project__stats {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-project__spark {
|
||||
width: 100%;
|
||||
height: 1.75rem;
|
||||
polyline {
|
||||
fill: none;
|
||||
stroke: var(--tone);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
}
|
||||
.ts-project__late {
|
||||
color: var(--ts-c-red, #d14343);
|
||||
}
|
||||
.ts-project__updated {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
@@ -120,6 +167,19 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
||||
})
|
||||
export class ProjectCardComponent {
|
||||
readonly project = input.required<Project>();
|
||||
/** Open work, activity and sprint (projects I see whole). */
|
||||
readonly stats = input<HomeProject | null>(null);
|
||||
/** 14 days of activity as a line, scaled to its busiest day. */
|
||||
protected readonly spark = computed(() => {
|
||||
const days = this.stats()?.activity ?? [];
|
||||
const max = Math.max(1, ...days);
|
||||
return days
|
||||
.map(
|
||||
(n, i) =>
|
||||
`${(i / Math.max(1, days.length - 1)) * 100},${22 - (n / max) * 20}`,
|
||||
)
|
||||
.join(' ');
|
||||
});
|
||||
protected readonly tone = computed(() => projectTone(this.project().key));
|
||||
protected readonly memberNames = computed(() =>
|
||||
this.project().members.map((m) => m.user.name),
|
||||
|
||||
Reference in New Issue
Block a user