feat(web): English paths, and a portfolio that updates itself

/cartera, /explorar and /p/:key/proponer are /portfolio, /explore and
/p/:key/propose now, like every other path; the Spanish ones redirect,
so links already shared keep working. Settings sections go by
?section= with English names.

The portfolio reads itself again, quietly, when a ticket, sprint or
release of the projects shown changes, when my projects change, or when
the live connection comes back; «En directo» replaces the refresh
button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 22:15:32 +02:00
parent 9044690348
commit d0efaeba80
22 changed files with 150 additions and 66 deletions

View File

@@ -16,3 +16,11 @@ export const portfolioApiEvents = eventGroup({
loadFailed: type<string>(),
},
});
/** Something changed in one of my projects: reload, quietly. */
export const portfolioLiveEvents = eventGroup({
source: 'Portfolio Live',
events: {
changed: type<void>(),
},
});

View File

@@ -10,10 +10,18 @@ import {
import {
PortfolioProject,
ProjectsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { switchMap } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
map,
merge,
switchMap,
} from 'rxjs';
import {
portfolioApiEvents,
portfolioLiveEvents,
portfolioPageEvents,
} from '../events/portfolio.events';
import { describeError } from '../notifications';
@@ -25,7 +33,7 @@ interface PortfolioState {
error: string | null;
}
/** All my active projects at a glance. Scoped to the portfolio page. */
/** All my active projects at a glance, live. Scoped to the portfolio page. */
export const PortfolioStore = signalStore(
withState<PortfolioState>({
projects: [],
@@ -47,17 +55,46 @@ export const PortfolioStore = signalStore(
})),
),
withEventHandlers(
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
load$: events.on(portfolioPageEvents.opened).pipe(
switchMap(() =>
projects.portfolio().pipe(
mapResponse({
next: (list) => portfolioApiEvents.loaded(list),
error: (error) =>
portfolioApiEvents.loadFailed(describeError(error)),
}),
(
_store,
events = inject(Events),
projects = inject(ProjectsGateway),
realtime = inject(TrackerRealtimeGateway),
) => ({
// Opened, or something changed (then quietly: no «loading»).
load$: events
.on(portfolioPageEvents.opened, portfolioLiveEvents.changed)
.pipe(
switchMap(() =>
projects.portfolio().pipe(
mapResponse({
next: (list) => portfolioApiEvents.loaded(list),
error: (error) =>
portfolioApiEvents.loadFailed(describeError(error)),
}),
),
),
),
// Any ticket, sprint or release of the projects shown, the list of my
// projects itself, or a cut connection: read it again, calmly.
live$: events.on(portfolioApiEvents.loaded).pipe(
map(({ payload }) => payload.map((p) => p.projectId).join(',')),
distinctUntilChanged(),
switchMap((ids) =>
merge(
...ids
.split(',')
.filter(Boolean)
.flatMap((id) => [
realtime.issueChanges(id),
realtime.sprintChanges(id),
realtime.releaseChanges(id),
]),
realtime.myProjectsChanges(),
realtime.reconnects(),
).pipe(debounceTime(1500)),
),
map(() => portfolioLiveEvents.changed()),
),
}),
),