Added a "Hide all visible" button

It hides the opportunities the filters show that aren't hidden already,
after asking for confirmation with their count. Those the filters leave
out keep their marks, so clearing the filters shows them as before.

The backend has no bulk route, so each opportunity is hidden with its
own request. The rows go at once; every mark change is now saved at
most six at a time (lib/concurrency.ts), so hundreds of requests aren't
sent together. Any that fail are shown again, with one toast for them
all.
This commit is contained in:
bwilliams committed 2026-10-19 09:47:49 +01:00
1 parent b1cc9745ba
commit b8244da2e5
7 files changed
+205 -10

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from 'vitest';
import { limitConcurrency } from './concurrency';
/** A task that runs until it's finished by hand, recording when it starts. */
function manualTask(started: string[], name: string) {
let finish!: (value: string) => void;
let fail!: (err: Error) => void;
const task = () => {
started.push(name);
return new Promise<string>((resolve, reject) => {
finish = resolve;
fail = reject;
});
};
return { task, finish: () => finish(name), fail: () => fail(new Error(name)) };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('runs at most max tasks at once, in order', async () => {
const limited = limitConcurrency(2);
const started: string[] = [];
const tasks = ['a', 'b', 'c', 'd'].map((name) => manualTask(started, name));
const results = tasks.map((t) => limited(t.task));
await settle();
expect(started).toEqual(['a', 'b']);
tasks[1]?.finish();
await settle();
expect(started).toEqual(['a', 'b', 'c']);
tasks[0]?.finish();
tasks[2]?.finish();
await settle();
tasks[3]?.finish();
await expect(Promise.all(results)).resolves.toEqual(['a', 'b', 'c', 'd']);
});
test('a failed task makes room for the next', async () => {
const limited = limitConcurrency(1);
const started: string[] = [];
const first = manualTask(started, 'a');
const second = manualTask(started, 'b');
const firstResult = limited(first.task);
const secondResult = limited(second.task);
first.fail();
await expect(firstResult).rejects.toThrow('a');
await settle();
expect(started).toEqual(['a', 'b']);
second.finish();
await expect(secondResult).resolves.toBe('b');
});
test('a task given a finished task’s place still counts towards max', async () => {
const limited = limitConcurrency(1);
const started: string[] = [];
const first = manualTask(started, 'a');
const second = manualTask(started, 'b');
const third = manualTask(started, 'c');
void limited(first.task);
void limited(second.task);
first.finish();
await settle();
void limited(third.task);
await settle();
expect(started).toEqual(['a', 'b']);
second.finish();
await settle();
expect(started).toEqual(['a', 'b', 'c']);
third.finish();
});
+29
View File
@@ -0,0 +1,29 @@
/** Runs a task when there's room for it, and returns its result. */
export type Limited = <T>(task: () => Promise<T>) => Promise<T>;
/**
* Runs tasks at most max at a time, in the order they're given; the rest
* wait until one finishes, whether it succeeds or fails.
*/
export function limitConcurrency(max: number): Limited {
let running = 0;
const waiting: (() => void)[] = [];
// A finished task hands its place to the first waiting one, if there is
// one, so running only falls when nothing is waiting.
const next = (): void => {
const resume = waiting.shift();
if (resume) resume();
else running -= 1;
};
return async <T>(task: () => Promise<T>): Promise<T> => {
if (running >= max) await new Promise<void>((resolve) => waiting.push(resolve));
else running += 1;
try {
return await task();
} finally {
next();
}
};
}
+6 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import { formatDate, formatMoney, parseDate } from './format';
import { formatCount, formatDate, formatMoney, parseDate } from './format';
test.each([
{ name: 'null', value: null },
@@ -26,3 +26,8 @@ test('money is shown in whole pounds, and nothing as a dash', () => {
expect(formatMoney(0)).toBe('—');
expect(formatMoney(-5)).toBe('—');
});
test('formatCount groups thousands', () => {
expect(formatCount(4854)).toBe('4,854');
expect(formatCount(7)).toBe('7');
});
+7
View File
@@ -14,6 +14,8 @@ const moneyFormat = new Intl.NumberFormat('en-GB', {
maximumFractionDigits: 0,
});
const countFormat = new Intl.NumberFormat('en-GB');
/** Treats null and Go's zero time.Time as "no date". */
export function parseDate(value: DateTimeString | null | undefined): Date | null {
if (!value || value.startsWith(ZERO_TIME_PREFIX)) return null;
@@ -31,3 +33,8 @@ export function formatDate(value: DateTimeString | null | undefined): string {
export function formatMoney(value: number): string {
return value > 0 ? moneyFormat.format(value) : '—';
}
/** A count for display, e.g. "4,854". */
export function formatCount(value: number): string {
return countFormat.format(value);
}