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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ListStore = signalStore(
|
||||
),
|
||||
merge(
|
||||
realtime.issueChanges(payload.projectId),
|
||||
realtime.reconnects(),
|
||||
events.on(
|
||||
listApiEvents.changed,
|
||||
listApiEvents.changeFailed,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user