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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user