refactor(web): page state with its page, the root keeps the shell's

Only what the header needs from the start stays at the root: the
session, my projects for the command palette, the inbox and push for
the bell, and their adapters. The home page's facades (my work, the
rest of the home, my views, the calendar), Explore's and the proposal
form's are provided by their routes, whose injectors keep them for
when you come back. The other adapters come with the tracker's routes,
now loaded lazily.

The web libs declare "sideEffects": false, so a chunk carries what it
uses and not everything an index.ts exports: main goes from 1.31 MB
to 432 kB of the same initial total, which is still held up by @pi/ui
(one module, with all of Material) and the GraphQL documents, both
needed by the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 08:34:17 +02:00
parent d0efaeba80
commit f448207e88
22 changed files with 160 additions and 47 deletions

View File

@@ -1,4 +1,4 @@
import { inject, Injectable, signal } from '@angular/core';
import { inject, Injectable, Provider, signal } from '@angular/core';
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
@@ -7,7 +7,7 @@ import { Observable } from 'rxjs';
* calendar app, made, renewed or dropped here. Per person and seldom
* touched, so it lives in this facade alone, without a store.
*/
@Injectable({ providedIn: 'root' })
@Injectable()
export class CalendarFacade {
private readonly gateway = inject(CalendarGateway);
@@ -54,3 +54,8 @@ export class CalendarFacade {
this.error.set('No se ha podido. Inténtalo de nuevo en un momento.');
}
}
/** My calendar subscription: provided by the route that shows it, not the root. */
export function provideCalendarFacade(): Provider[] {
return [CalendarFacade];
}

View File

@@ -1,10 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { explorePageEvents } from '../events/explore.events';
import { ExploreStore } from '../stores/explore.store';
/** "Explorar": public projects, for anyone (no sign-in needed). */
@Injectable({ providedIn: 'root' })
@Injectable()
export class ExploreFacade {
private readonly store = inject(ExploreStore);
private readonly dispatch = injectDispatch(explorePageEvents);
@@ -20,3 +20,8 @@ export class ExploreFacade {
this.dispatch.opened();
}
}
/** The public projects page: provided by the route that shows it, not the root. */
export function provideExploreFacade(): Provider[] {
return [ExploreStore, ExploreFacade];
}

View File

@@ -23,7 +23,7 @@ import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
import { BoardFacade, provideBoardFacade } from './board.facade';
import { HomeFacade } from './home.facade';
import { HomeFacade, provideHomeFacade } from './home.facade';
import { InboxFacade } from './inbox.facade';
import { IssueFacade, provideIssueFacade } from './issue.facade';
import { ProjectFacade, provideProjectFacade } from './project.facade';
@@ -427,6 +427,7 @@ describe('HomeFacade', () => {
);
TestBed.configureTestingModule({
providers: [
provideHomeFacade(),
{ provide: IssuesGateway, useValue: { mine } },
{
provide: ReleasesGateway,

View File

@@ -1,10 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { homePageEvents } from '../events/home.events';
import { HomeStore } from '../stores/home.store';
/** "My work" on the home page: my tickets, waiting for QA, upcoming releases. */
@Injectable({ providedIn: 'root' })
@Injectable()
export class HomeFacade {
private readonly store = inject(HomeStore);
private readonly dispatch = injectDispatch(homePageEvents);
@@ -33,3 +33,8 @@ export class HomeFacade {
this.dispatch.opened();
}
}
/** «Mi trabajo» on the home page: provided by the route that shows it, not the root. */
export function provideHomeFacade(): Provider[] {
return [HomeStore, HomeFacade];
}

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { myHomePageEvents } from '../events/my-home.events';
import { MyHomeStore } from '../stores/my-home.store';
@@ -7,7 +7,7 @@ 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' })
@Injectable()
export class MyHomeFacade {
private readonly store = inject(MyHomeStore);
private readonly dispatch = injectDispatch(myHomePageEvents);
@@ -21,3 +21,8 @@ export class MyHomeFacade {
this.dispatch.refreshed();
}
}
/** The rest of the home page: provided by the route that shows it, not the root. */
export function provideMyHomeFacade(): Provider[] {
return [MyHomeStore, MyHomeFacade];
}

View File

@@ -1,11 +1,11 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { MyViewCriteria } from '@task-stream/web/tracker/domain';
import { myViewsPageEvents } from '../events/my-views.events';
import { MyViewsStore, VIEW_LIMIT } from '../stores/my-views.store';
/** My saved views of «my work», across my projects. */
@Injectable({ providedIn: 'root' })
@Injectable()
export class MyViewsFacade {
private readonly store = inject(MyViewsStore);
private readonly dispatch = injectDispatch(myViewsPageEvents);
@@ -39,3 +39,8 @@ export class MyViewsFacade {
this.dispatch.deleteRequested(viewId);
}
}
/** My saved views, on the home page: provided by the route that shows it, not the root. */
export function provideMyViewsFacade(): Provider[] {
return [MyViewsStore, MyViewsFacade];
}

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Provider } from '@angular/core';
import {
ProposalInput,
ProposalResult,
@@ -7,7 +7,7 @@ import {
import { Observable } from 'rxjs';
/** The public form of a project: anyone reports a bug or proposes an idea. */
@Injectable({ providedIn: 'root' })
@Injectable()
export class ProposeFacade {
private readonly proposals = inject(ProposalsGateway);
@@ -18,3 +18,8 @@ export class ProposeFacade {
return this.proposals.submit(projectKey, proposal);
}
}
/** The public proposal form: provided by the route that shows it, not the root. */
export function provideProposeFacade(): Provider[] {
return [ProposeFacade];
}

View File

@@ -21,7 +21,6 @@ interface ExploreState {
/** Public projects, readable by anyone (also without signing in). */
export const ExploreStore = signalStore(
{ providedIn: 'root' },
withState<ExploreState>({
projects: [],
loaded: false,

View File

@@ -42,11 +42,11 @@ interface HomeState {
/**
* "My work" across every project I belong to: my open tickets, tickets
* waiting for QA, the open ones I follow and upcoming releases. Root-scoped, so coming back to the
* home page shows the last data at once while it refreshes.
* waiting for QA, the open ones I follow and upcoming releases. Provided by the home route,
* whose injector outlives the page, so coming back shows the last data at
* once while it refreshes.
*/
export const HomeStore = signalStore(
{ providedIn: 'root' },
withState<HomeState>({
mine: [],
mineTotal: 0,

View File

@@ -18,11 +18,11 @@ interface MyHomeState {
}
/**
* The home page beyond «my work». Root-scoped, so coming back shows the
* last reading at once while it refreshes.
* The home page beyond «my work». Provided by the home route, whose injector
* outlives the page, 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 }) => ({

View File

@@ -50,9 +50,8 @@ interface MyViewsState {
error: string | null;
}
/** My saved views of «my work» and the tickets of the one picked. Root-scoped. */
/** My saved views of «my work» and the tickets of the one picked. Provided by the home route. */
export const MyViewsStore = signalStore(
{ providedIn: 'root' },
withState<MyViewsState>({
views: [],
selectedId: null,

View File

@@ -45,22 +45,34 @@ import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
/**
* The adapters the app shell needs from the start (the command palette, the
* notifications bell and live updates): provided at the root.
*/
export function provideTrackerShellInfrastructure(): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: ProjectsGateway, useClass: GraphqlProjectsGateway },
{ provide: IssuesGateway, useClass: GraphqlIssuesGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: PushDevice, useClass: BrowserPushDevice },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
]);
}
/**
* Ports → GraphQL adapters (and REST for files) used only by the tracker's
* pages: provided by their lazy route, so they load with them.
*/
export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: CommentsGateway, useClass: GraphqlCommentsGateway },
{ provide: ReleasesGateway, useClass: GraphqlReleasesGateway },
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
{ provide: PresenceGateway, useClass: GraphqlPresenceGateway },
{ provide: PushDevice, useClass: BrowserPushDevice },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
{ provide: GitGateway, useClass: GraphqlGitGateway },

View File

@@ -1,16 +1,33 @@
import { Routes } from '@angular/router';
import { authGuard } from '@task-stream/web/identity/presenters';
import {
provideCalendarFacade,
provideExploreFacade,
provideHomeFacade,
provideMyHomeFacade,
provideMyViewsFacade,
provideProposeFacade,
} from '@task-stream/web/tracker/application';
/**
* The tracker. Home needs a session; project pages and «Explorar» don't:
* public projects can be read by anyone (the API decides what each visitor
* sees, and pages hide what they cannot change).
*
* Each page's state is provided by its route, not the root: it loads with the
* page, and the route's injector keeps it for when you come back.
*/
export const trackerRoutes: Routes = [
{
path: '',
canActivate: [authGuard],
title: 'Proyectos · TaskStream',
providers: [
provideHomeFacade(),
provideMyHomeFacade(),
provideMyViewsFacade(),
provideCalendarFacade(),
],
loadComponent: () =>
import('./pages/projects.page').then((m) => m.ProjectsPage),
},
@@ -24,6 +41,7 @@ export const trackerRoutes: Routes = [
{
path: 'explore',
title: 'Proyectos públicos · TaskStream',
providers: [provideExploreFacade()],
loadComponent: () =>
import('./pages/explore.page').then((m) => m.ExplorePage),
},
@@ -47,6 +65,7 @@ export const trackerRoutes: Routes = [
{
path: 'propose',
title: 'Proponer · TaskStream',
providers: [provideProposeFacade()],
loadComponent: () =>
import('./pages/propose.page').then((m) => m.ProposePage),
},