From 147ef01ffe4f36b1f07d046a061019abe539fb97 Mon Sep 17 00:00:00 2001 From: Yorick de Wid Date: Mon, 27 Jul 2026 10:31:48 +0000 Subject: [PATCH] feat(explorer): sort every view newest first, by document date "Alles" and "Te controleren" looked like they showed recent work. They showed whatever the API's fallback ordering produced, which is coalesce(update_date, create_date) DESC -- and the #973 backfill stamped one identical update_date onto 20,950 of 26,671 inquiries, so four rows in five sat in a single tie group in an order nobody chose. The explorer now always sorts. sort is no longer nullable: the query starts at document_date DESC, every request carries it, and the fallback is simply unreachable. Newest first on the one date the table shows, so the claim can be checked by reading down the Datum column rather than taken on trust. The Werkbank lanes get it too, where it shows more: a lane previews the first eight of the fifty it fetches, so an unsorted fetch meant eight arbitrary rows out of that same tie group. Falling out of "there is no unsorted state": the sort chip appears only once you deviate from the default (a chip on every screen is furniture, and its cross would do nothing), clicking a column twice flips direction instead of switching sorting off, and views saved before today -- which live in localStorage with sort: null -- are read back onto the default rather than resurrecting the old behaviour. Paging over this is only total once FunderMapsApi #101 lands the primary key as the last ORDER BY key. Until then document_date narrows the ties from one group of 20,950 to groups of about three, which is strictly better than what shipped before it. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/Common/FilterBuilder.vue | 44 ++++++++-------- src/services/explorer.ts | 68 ++++++++++++++++++++----- src/services/worklist.ts | 7 +++ src/views/InquiryListView.vue | 13 ++--- 4 files changed, 93 insertions(+), 39 deletions(-) diff --git a/src/components/Common/FilterBuilder.vue b/src/components/Common/FilterBuilder.vue index 15e1484..5f344ab 100644 --- a/src/components/Common/FilterBuilder.vue +++ b/src/components/Common/FilterBuilder.vue @@ -3,7 +3,13 @@ import { computed, ref } from 'vue' import { onClickOutside, onKeyStroke } from '@vueuse/core' import { INQUIRY_TYPE_LABELS, STATUS_META } from '@/services/inquiryEnums' -import { SORT_OPTIONS, sortOption, type ExplorerQuery, type SortField } from '@/services/explorer' +import { + SORT_OPTIONS, + isDefaultSort, + sortOption, + type ExplorerQuery, + type SortField, +} from '@/services/explorer' /** * The `+ Filter` popover. @@ -62,14 +68,16 @@ function setMine(value: 'reviewer' | 'creator' | null) { /** * Picking a column sorts it descending — newest, highest, most recent first is * what people mean by "sorteer op datum". Picking the column it already sorts - * on turns sorting off again, so the same button is both the on and the off. + * on flips the direction, because there is no "off" to return to: the explorer + * is always sorted by something (see `DEFAULT_SORT`), so the only question a + * second click can answer is which end you want. */ function setSort(value: SortField) { - const off = props.query.sort === value + const active = props.query.sort === value emit('update', { ...props.query, - sort: off ? null : value, - order: off ? 'desc' : props.query.order, + sort: value, + order: active ? (props.query.order === 'desc' ? 'asc' : 'desc') : 'desc', page: 1, }) } @@ -80,13 +88,11 @@ function setOrder(order: 'asc' | 'desc') { /** The active column's own words for its two directions. */ const direction = computed(() => { - const option = props.query.sort ? sortOption(props.query.sort) : null - return option - ? [ - { value: 'desc' as const, label: option.desc }, - { value: 'asc' as const, label: option.asc }, - ] - : [] + const option = sortOption(props.query.sort) + return [ + { value: 'desc' as const, label: option.desc }, + { value: 'asc' as const, label: option.asc }, + ] }) @@ -196,9 +202,9 @@ const direction = computed(() => { - -
+ +

- - + Klik dezelfde kolom nog eens om de richting om te draaien. +

Type en status volgen de volgorde van de database-enum: dat groepeert de rijen, diff --git a/src/services/explorer.ts b/src/services/explorer.ts index 4efb288..bfae5c3 100644 --- a/src/services/explorer.ts +++ b/src/services/explorer.ts @@ -28,12 +28,37 @@ export interface ExplorerQuery { type: number[] /** Restrict to rows where you are the reviewer or the creator. */ mine: 'reviewer' | 'creator' | null - sort: SortField | null + /** Always set — see `DEFAULT_SORT`. There is no "unsorted". */ + sort: SortField order: 'asc' | 'desc' /** 1-based. */ page: number } +/** + * What the explorer sorts by when nobody has said otherwise — including every + * built-in view. + * + * There used to be no answer to that: leaving `sort` off let `GET /inquiry` + * fall back to `coalesce(update_date, create_date) DESC`, which sounds like + * recency and is not. The #973 backfill stamped one identical `update_date` + * onto 20,950 of 26,671 inquiries, so four rows in five sat in a single tie + * group in an order no one chose and `LIMIT`/`OFFSET` does not promise to keep + * stable between two pages. "Alles" only looked sorted. + * + * `document_date` descending instead: newest first, on the one date the table + * actually shows, so the claim can be checked by reading down the Datum column + * rather than taken on faith. The API ends every ordering on the primary key + * (FunderMapsApi #101), which is what makes paging through it total. + */ +export const DEFAULT_SORT: SortField = 'document_date' +export const DEFAULT_ORDER: 'asc' | 'desc' = 'desc' + +/** Whether the query still carries the ordering it started with. */ +export function isDefaultSort(query: ExplorerQuery): boolean { + return query.sort === DEFAULT_SORT && query.order === DEFAULT_ORDER +} + /** * How many rows a page of the explorer holds. * @@ -47,7 +72,15 @@ export interface ExplorerQuery { export const PAGE_SIZE = 20 export function emptyQuery(): ExplorerQuery { - return { q: '', status: [], type: [], mine: null, sort: null, order: 'desc', page: 1 } + return { + q: '', + status: [], + type: [], + mine: null, + sort: DEFAULT_SORT, + order: DEFAULT_ORDER, + page: 1, + } } export interface SavedView { @@ -156,7 +189,9 @@ export function describeSort(sort: SortField, order: 'asc' | 'desc'): string { export function parseQuery(raw: Record): ExplorerQuery { const page = Number(raw.page) - const sort = SORT_FIELDS.find((field) => field === raw.sort) ?? null + // An unknown or absent `?sort=` lands on the default rather than on nothing: + // a link someone hand-edited should show a sorted list, not an arbitrary one. + const sort = SORT_FIELDS.find((field) => field === raw.sort) ?? DEFAULT_SORT return { q: typeof raw.q === 'string' ? raw.q : '', status: ints(raw.status, (n) => statusMeta(n).label !== 'Onbekend'), @@ -179,7 +214,7 @@ export function toRouteQuery(query: ExplorerQuery, viewKey: string): LocationQue if (query.status.length) out.status = query.status.join(',') if (query.type.length) out.type = query.type.join(',') if (query.mine) out.mine = query.mine - if (query.sort) { + if (!isDefaultSort(query)) { out.sort = query.sort out.order = query.order } @@ -189,7 +224,12 @@ export function toRouteQuery(query: ExplorerQuery, viewKey: string): LocationQue /** A saved view's stored shape, merged over the empty query. */ export function fromView(view: SavedView): ExplorerQuery { - return { ...emptyQuery(), ...view.query } + const merged = { ...emptyQuery(), ...view.query } + // Views saved before the explorer had a default ordering stored `sort: null`, + // and they live in someone's `localStorage` rather than in a table we can + // migrate. Anything the sort no longer recognises falls back to the default. + const sort = SORT_FIELDS.find((field) => field === merged.sort) + return { ...merged, sort: sort ?? DEFAULT_SORT, order: merged.order === 'asc' ? 'asc' : 'desc' } } /* -------------------------------------------------------------- API mapping */ @@ -211,10 +251,11 @@ export function toListOpts(query: ExplorerQuery, userId: string | null): IInquir if (query.status.length) opts.status = [...query.status] if (query.mine === 'creator' && userId) opts.creator = userId if (query.mine === 'reviewer' && userId) opts.reviewer = userId - if (query.sort) { - opts.sort = query.sort - opts.order = query.order - } + // Always sent, default included. Letting the parameter fall away would hand + // the ordering back to the endpoint's fallback, which is the one thing this + // module exists to stop happening — see `DEFAULT_SORT`. + opts.sort = query.sort + opts.order = query.order return opts } @@ -275,13 +316,16 @@ export function chipsFor(query: ExplorerQuery): Chip[] { // The sort is not a filter, but it belongs here for the same reason the // filters do: it changes which rows you see first, it survives in a shared // link, and half of what you can sort on (opsteller) has no column in the - // table to carry an arrow. Clearing it returns to the API's own ordering. - if (query.sort) { + // table to carry an arrow. + // + // Only once it differs from the default, though. A chip that is present on + // every screen is furniture, and its × would be a button that does nothing. + if (!isDefaultSort(query)) { chips.push({ id: 'sort', label: 'sortering', value: describeSort(query.sort, query.order), - clear: (q) => ({ ...q, sort: null, order: 'desc', page: 1 }), + clear: (q) => ({ ...q, sort: DEFAULT_SORT, order: DEFAULT_ORDER, page: 1 }), }) } diff --git a/src/services/worklist.ts b/src/services/worklist.ts index 0d2b696..5365dc6 100644 --- a/src/services/worklist.ts +++ b/src/services/worklist.ts @@ -15,6 +15,7 @@ */ import type { IInquiryListOpts } from '@/services/fundermaps/endpoints/inquiry' +import { DEFAULT_ORDER, DEFAULT_SORT } from '@/services/explorer' import { AUDIT_STATUS } from '@/services/inquiryEnums' /** Which side of the dossier the lane puts you on. */ @@ -74,6 +75,12 @@ export function laneQuery(lane: Lane, userId: string): IInquiryListOpts { status: lane.statuses, ...(lane.role === 'reviewer' ? { reviewer: userId } : { creator: userId }), limit: LANE_FETCH, + // Sorted for the same reason the explorer is, and it shows more here: a + // lane displays the first eight of the fifty it fetches, so without an + // ordering those eight are eight arbitrary rows out of a tie group the + // #973 backfill made 20,950 rows wide. Newest first — see `DEFAULT_SORT`. + sort: DEFAULT_SORT, + order: DEFAULT_ORDER, } } diff --git a/src/views/InquiryListView.vue b/src/views/InquiryListView.vue index a08266f..b7b1153 100644 --- a/src/views/InquiryListView.vue +++ b/src/views/InquiryListView.vue @@ -182,16 +182,17 @@ const sortField = computed(() => Object.keys(SORT_KEYS).find((field) => SORT_KEYS[field] === query.value.sort) ?? null, ) -// desc → asc → back to default recency ordering. Descending first, because -// that is what "sorteer op datum" means, and because the filter popover's sort -// section starts there too — two controls writing one piece of state should not -// disagree about which way the first click points. +// A new column sorts descending; the same column again flips the direction. +// There is no third click that switches sorting off, because the explorer is +// always sorted by something — see `DEFAULT_SORT`. Descending first, because +// that is what "sorteer op datum" means, and because the filter popover starts +// there too: two controls writing one piece of state should not disagree about +// which way the first click points. function onSort(field: string) { const key = SORT_KEYS[field] if (!key) return if (query.value.sort !== key) push({ ...query.value, sort: key, order: 'desc', page: 1 }) - else if (query.value.order === 'desc') push({ ...query.value, order: 'asc', page: 1 }) - else push({ ...query.value, sort: null, order: 'desc', page: 1 }) + else push({ ...query.value, order: query.value.order === 'desc' ? 'asc' : 'desc', page: 1 }) } const selectedIds = ref>(new Set())