feat: steadier live updates and notices for public proposals

- The web pings the live connection, reconnects when it goes quiet and
  reloads what each screen shows after reconnecting.
- Dropping a card on a column it cannot go to puts it back.
- Owners and admins get a notice when a proposal arrives.
- The proposal limit lives in Redis, so it holds across instances.
- REST tells whether I voted; pasted images leave the attachment list.
- Deleting a project removes its Git integration; no lint warnings left.
- Reactions and the blocked badge are components of their own.
- Docs: what is done and what is left (chapter 10), Word and PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:57:21 +02:00
parent 74903f7842
commit c91df110f8
50 changed files with 615 additions and 321 deletions

View File

@@ -41,6 +41,7 @@ const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
/** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() {
const reconnected = new Subject<void>();
return {
projects: {
project: vi.fn(() => of(project)),
@@ -92,7 +93,9 @@ function gateways() {
releaseChanges: () => NEVER,
sprintChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER),
reconnects: () => reconnected,
},
reconnected,
};
}
@@ -161,6 +164,19 @@ describe('BoardFacade', () => {
expect(board.filter()).toEqual({ projectId: 'p2' });
});
it('reloads what it shows when the live connection comes back', () => {
const { board, fakes } = setup();
board.show({ projectId: 'p1', assigneeId: 'u2' });
fakes.issues.board.mockClear();
fakes.reconnected.next();
expect(fakes.issues.board).toHaveBeenCalledWith({
projectId: 'p1',
assigneeId: 'u2',
});
});
it('saves the current filter as a view and knows when it is showing one', () => {
const { board, fakes } = setup();
const view = {
@@ -480,7 +496,7 @@ describe('InboxFacade', () => {
{ provide: NotificationsGateway, useValue: gateway },
{
provide: TrackerRealtimeGateway,
useValue: { notifications: () => arrivals },
useValue: { notifications: () => arrivals, reconnects: () => NEVER },
},
{
provide: Notifications,

View File

@@ -79,6 +79,7 @@ export const BacklogStore = signalStore(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId).pipe(debounceTime(400)),
realtime.reconnects(),
events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone),
).pipe(
startWith(null),

View File

@@ -20,6 +20,7 @@ import {
distinctUntilChanged,
EMPTY,
exhaustMap,
filter,
map,
merge,
mergeMap,
@@ -112,6 +113,12 @@ export const BoardStore = signalStore(
load$: events
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
.pipe(switchMap(({ payload }) => load(payload))),
// Back after a cut: what moved meanwhile was not received.
resync$: realtime.reconnects().pipe(
map(() => store.filter()),
filter((current): current is BoardFilter => !!current),
switchMap((current) => load(current)),
),
live$: events.on(boardPageEvents.opened).pipe(
map(({ payload }) => payload.projectId),
distinctUntilChanged(),

View File

@@ -49,6 +49,7 @@ export const EpicsStore = signalStore(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId),
realtime.reconnects(),
// One's own vote, without waiting for the live update.
events.on(epicsApiEvents.voted),
).pipe(

View File

@@ -85,7 +85,11 @@ export const InboxStore = signalStore(
) => {
const closed$ = events.on(inboxPageEvents.closed);
return {
load$: events.on(inboxPageEvents.opened).pipe(
// Also when the live connection comes back: what arrived meanwhile was missed.
load$: merge(
events.on(inboxPageEvents.opened),
realtime.reconnects(),
).pipe(
switchMap(() =>
gateway.mine(false, LIMIT).pipe(
mapResponse({

View File

@@ -243,39 +243,41 @@ export const IssueStore = signalStore(
* API projects its read model a moment after answering, so the
* ticket, its comments and its subtasks are asked for twice.
*/
catchUp$: events
.on(
catchUp$: merge(
events.on(
issueApiEvents.commandSucceeded,
issueApiEvents.checklistSaved,
issuePageEvents.imagePasted,
)
.pipe(
map(() => store.issue()),
filter((issue): issue is Issue => !!issue),
switchMap((issue) =>
merge(
...[300, 1500].map((ms) =>
timer(ms).pipe(
switchMap(() =>
merge(
fetchIssue(issue.id),
comments
.list(issue.id)
.pipe(
map((list) => issueApiEvents.commentsLoaded(list)),
),
[
issueRealtimeEvents.childrenTouched({
issueId: issue.id,
}),
],
),
),
// Back after a cut: comments or changes may have been missed.
realtime.reconnects(),
).pipe(
map(() => store.issue()),
filter((issue): issue is Issue => !!issue),
switchMap((issue) =>
merge(
...[300, 1500].map((ms) =>
timer(ms).pipe(
switchMap(() =>
merge(
fetchIssue(issue.id),
comments
.list(issue.id)
.pipe(
map((list) => issueApiEvents.commentsLoaded(list)),
),
[
issueRealtimeEvents.childrenTouched({
issueId: issue.id,
}),
],
),
),
),
),
),
),
),
liveComments$: events.on(issuePageEvents.opened).pipe(
switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))),
filter((id, index) => index === 0 || id !== store.issue()?.id),

View File

@@ -100,6 +100,7 @@ export const ListStore = signalStore(
),
merge(
realtime.issueChanges(payload.projectId),
realtime.reconnects(),
events.on(
listApiEvents.changed,
listApiEvents.changeFailed,

View File

@@ -242,6 +242,20 @@ export const ProjectStore = signalStore(
),
),
),
// Back after a cut: refetch it, and its details follow `loaded`.
resync$: realtime.reconnects().pipe(
map(() => store.project()?.key),
filter((key): key is string => !!key),
switchMap((key) =>
projects.project(key).pipe(
mapResponse({
next: (project) => projectApiEvents.loaded(project),
error: (error) =>
projectApiEvents.loadFailed(describeError(error)),
}),
),
),
),
details$: loaded$.pipe(
switchMap(({ payload }) =>
merge(

View File

@@ -14,7 +14,7 @@ import {
ProjectsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { exhaustMap, map, switchMap, tap } from 'rxjs';
import { exhaustMap, map, merge, switchMap, tap } from 'rxjs';
import {
projectsApiEvents,
projectsPageEvents,
@@ -74,7 +74,11 @@ export const ProjectsStore = signalStore(
router = inject(Router),
auth = inject(AuthFacade),
) => ({
load$: events.on(projectsPageEvents.opened).pipe(
load$: merge(
events.on(projectsPageEvents.opened),
// Back after a cut: being added or removed meanwhile was missed.
realtime.reconnects(),
).pipe(
switchMap(() =>
projects.myProjects().pipe(
mapResponse({