feat: a home page with life

Greeting and summary of the day, the coming week, the latest across my
projects, who is online now, the week in numbers (mine and the team's,
with a streak), sprints under way, «Retomar», project cards with their
activity, what to pick up when nothing is mine and first steps for
newcomers. REST (GET /api/me/home), GraphQL (myHome) and MCP
(get_my_week).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:20:41 +02:00
parent affce615a7
commit 0946e9517d
57 changed files with 3728 additions and 53 deletions

View File

@@ -13,6 +13,7 @@ export * from './lib/facades/insights.facade';
export * from './lib/facades/my-views.facade';
export * from './lib/facades/presence.facade';
export * from './lib/facades/portfolio.facade';
export * from './lib/facades/my-home.facade';
export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,18 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Home } from '@task-stream/web/tracker/domain';
export const myHomePageEvents = eventGroup({
source: 'My Home Page',
events: {
refreshed: type<void>(),
},
});
export const myHomeApiEvents = eventGroup({
source: 'My Home API',
events: {
loaded: type<Home>(),
loadFailed: type<string>(),
},
});

View File

@@ -0,0 +1,23 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { myHomePageEvents } from '../events/my-home.events';
import { MyHomeStore } from '../stores/my-home.store';
/**
* The home page beyond «my work»: agenda, activity, the week, sprints,
* project cards, suggestions, first steps and who is on what now.
*/
@Injectable({ providedIn: 'root' })
export class MyHomeFacade {
private readonly store = inject(MyHomeStore);
private readonly dispatch = injectDispatch(myHomePageEvents);
/** Null until the first load. */
readonly home = this.store.home;
readonly error = this.store.error;
/** Loads it (again, to refresh). */
refresh(): void {
this.dispatch.refreshed();
}
}

View File

@@ -0,0 +1,50 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import { Home, ProjectsGateway } from '@task-stream/web/tracker/domain';
import { exhaustMap } from 'rxjs';
import { myHomeApiEvents, myHomePageEvents } from '../events/my-home.events';
import { describeError } from '../notifications';
interface MyHomeState {
home: Home | null;
error: string | null;
}
/**
* The home page beyond «my work». Root-scoped, so coming back shows the
* last reading at once while it refreshes.
*/
export const MyHomeStore = signalStore(
{ providedIn: 'root' },
withState<MyHomeState>({ home: null, error: null }),
withReducer(
on(myHomeApiEvents.loaded, ({ payload }) => ({
home: payload,
error: null,
})),
on(myHomeApiEvents.loadFailed, ({ payload }) => ({ error: payload })),
),
withEventHandlers(
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
// A refresh while one is under way waits for it instead of piling up.
load$: events.on(myHomePageEvents.refreshed).pipe(
exhaustMap(() =>
projects.myHome().pipe(
mapResponse({
next: (home) => myHomeApiEvents.loaded(home),
error: (error) =>
myHomeApiEvents.loadFailed(describeError(error)),
}),
),
),
),
}),
),
);

View File

@@ -13,3 +13,4 @@ export * from './lib/models/shortcuts';
export * from './lib/models/due';
export * from './lib/models/calendar';
export * from './lib/models/insights';
export * from './lib/models/home';

View File

@@ -0,0 +1,58 @@
import { SprintPulse } from './insights';
import { Activity, UserRef } from './models';
/** Something on a day of my coming week. */
export interface AgendaItem {
/** `YYYY-MM-DD`. */
day: string;
kind: 'ISSUE' | 'SPRINT_END' | 'RELEASE';
projectKey: string;
title: string;
issueKey: string | null;
}
/** What a project's card shows besides its name. */
export interface HomeProject {
projectId: string;
open: number;
overdue: number;
/** Activity per day, the last 14, oldest first. */
activity: number[];
sprint: { name: string; percent: number; behind: boolean } | null;
}
/** Something worth doing when nothing is mine. */
export interface Suggestion {
kind: 'UNASSIGNED' | 'PROPOSALS' | 'QA_WAITING';
projectKey: string;
count: number;
}
/** Someone of my projects on a ticket right now. */
export interface Online {
user: UserRef;
projectKey: string;
issueKey: string | null;
issueTitle: string | null;
editing: boolean;
at: string;
}
/** The home page beyond «my work». */
export interface Home {
agenda: AgendaItem[];
/** Newest first, across my projects. */
activity: Activity[];
team: { perDay: number[]; thisWeek: number; lastWeek: number };
me: { thisWeek: number; lastWeek: number; streak: number };
sprints: { projectKey: string; projectName: string; sprint: SprintPulse }[];
projects: HomeProject[];
suggestions: Suggestion[];
firstSteps: {
project: boolean;
invited: boolean;
ticket: boolean;
calendar: boolean;
};
online: Online[];
}

View File

@@ -378,6 +378,8 @@ export interface Release {
export interface Activity {
id: string;
type: string;
/** Absent in activity read before it carried the project. */
projectId?: string;
issueId: string | null;
issueKey: string | null;
actor: UserRef;

View File

@@ -1,3 +1,4 @@
import { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights';
import { Observable } from 'rxjs';
import {
@@ -69,6 +70,8 @@ export abstract class ProjectsGateway {
abstract stats(projectId: string): Observable<ProjectStats>;
/** My active projects at a glance. */
abstract portfolio(): Observable<PortfolioProject[]>;
/** The home page beyond «my work». */
abstract myHome(): Observable<Home>;
/** The dashboard; with `since`, also what changed after it. */
abstract insights(
projectId: string,

View File

@@ -7,6 +7,7 @@ import {
ProjectAnalytics,
ProjectInsights,
PortfolioProject,
Home,
ProjectSettings,
ProjectsGateway,
ProjectStats,
@@ -24,6 +25,7 @@ import {
ProjectAnalyticsDocument,
ProjectInsightsDocument,
PortfolioDocument,
MyHomeDocument,
ProjectDocument,
ProjectStatsDocument,
PublicProjectsDocument,
@@ -68,6 +70,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
myHome(): Observable<Home> {
return this.gql
.query(MyHomeDocument, {})
.pipe(map((d) => d.myHome as Home));
}
portfolio(): Observable<PortfolioProject[]> {
return this.gql.query(PortfolioDocument, {}).pipe(
map((d) =>

View File

@@ -198,6 +198,7 @@ fragment ReleaseFields on Release {
fragment ActivityFields on Activity {
id
type
projectId
issueId
issueKey
summary

View File

@@ -706,6 +706,7 @@ export type ReleaseFieldsFragment = {
export type ActivityFieldsFragment = {
id: string;
type: string;
projectId: string;
issueId: string | null;
issueKey: string | null;
summary: string;
@@ -2142,6 +2143,73 @@ export type PortfolioQuery = {
}>;
};
export type MyHomeQueryVariables = Exact<{ [key: string]: never }>;
export type MyHomeQuery = {
myHome: {
agenda: Array<{
day: string;
kind: string;
projectKey: string;
title: string;
issueKey: string | null;
}>;
activity: Array<{
id: string;
type: string;
projectId: string;
issueId: string | null;
issueKey: string | null;
summary: string;
occurredAt: string;
actor: { id: string; name: string };
}>;
team: { perDay: Array<number>; thisWeek: number; lastWeek: number };
me: { thisWeek: number; lastWeek: number; streak: number };
sprints: Array<{
projectKey: string;
projectName: string;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
};
}>;
projects: Array<{
projectId: string;
open: number;
overdue: number;
activity: Array<number>;
sprint: { name: string; percent: number; behind: boolean } | null;
}>;
suggestions: Array<{ kind: string; projectKey: string; count: number }>;
firstSteps: {
project: boolean;
invited: boolean;
ticket: boolean;
calendar: boolean;
};
online: Array<{
projectKey: string;
issueKey: string | null;
issueTitle: string | null;
editing: boolean;
at: string;
user: { id: string; name: string };
}>;
};
};
export type ReleasesQueryVariables = Exact<{
idOrKey: string;
}>;
@@ -2198,6 +2266,7 @@ export type ActivityQuery = {
activity: Array<{
id: string;
type: string;
projectId: string;
issueId: string | null;
issueKey: string | null;
summary: string;
@@ -2540,6 +2609,7 @@ export type ActivityAddedSubscription = {
activityAdded: {
id: string;
type: string;
projectId: string;
issueId: string | null;
issueKey: string | null;
summary: string;
@@ -3600,6 +3670,7 @@ export const ActivityFieldsFragmentDoc = {
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
@@ -12690,6 +12761,353 @@ export const PortfolioDocument = {
},
],
} as unknown as DocumentNode<PortfolioQuery, PortfolioQueryVariables>;
export const MyHomeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyHome' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myHome' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'agenda' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'projectKey' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'issueKey' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'activity' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'ActivityFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'team' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'perDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'thisWeek' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastWeek' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'thisWeek' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'lastWeek' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'streak' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprints' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'projectKey' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'projectName' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'projects' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'projectId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'open' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'overdue' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'activity' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'name' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'percent' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'suggestions' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'projectKey' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'firstSteps' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'project' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'invited' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'ticket' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'calendar' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'online' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'user' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'projectKey' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issueKey' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'issueTitle' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'editing' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'ActivityFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Activity' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
{ kind: 'Field', name: { kind: 'Name', value: 'occurredAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'actor' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<MyHomeQuery, MyHomeQueryVariables>;
export const ReleasesDocument = {
kind: 'Document',
definitions: [
@@ -13144,6 +13562,7 @@ export const ActivityDocument = {
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
@@ -14875,6 +15294,7 @@ export const ActivityAddedDocument = {
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },

View File

@@ -351,3 +351,79 @@ query Portfolio {
}
}
}
query MyHome {
myHome {
agenda {
day
kind
projectKey
title
issueKey
}
activity {
...ActivityFields
}
team {
perDay
thisWeek
lastWeek
}
me {
thisWeek
lastWeek
streak
}
sprints {
projectKey
projectName
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
}
projects {
projectId
open
overdue
activity
sprint {
name
percent
behind
}
}
suggestions {
kind
projectKey
count
}
firstSteps {
project
invited
ticket
calendar
}
online {
user {
...UserRefFields
}
projectKey
issueKey
issueTitle
editing
at
}
}
}

View File

@@ -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();

View File

@@ -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');

View File

@@ -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 {

View 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('-')) ??
''
);
}
}

View 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) + '.';
});
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}

View 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 */
}
}

View 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);
}

View File

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

View 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;
}
}

View File

@@ -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),