From 520380946ea5ab3e958dd110e7c83f5410da078b Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 30 May 2026 21:30:12 -0400 Subject: [PATCH] feat(collections): page-wide sort control + priority-weight helper (TASK-1670, closes IDEA-1648) (#673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Sort dropdown to the collection toolbar (Manual, Priority, Recently updated, Created, A→Z) applied within each lane/group in both BoardView and ListView. A shared helper (lib/collections/itemSort.ts) builds the comparator; the priority weight reads the `priority` select field's own options order, so high/medium/low and must/should/nice both rank naturally (top option = highest) with no hardcoded map. - 'manual' (default) resolves to the stored sort_order — prior behavior. - Non-manual sorts disable item drag (like the preserveOrder seam): a comparator-ordered lane can't accept a drag, so DnD is suppressed. - 'Priority' is hidden when the collection has no priority field; a stale persisted 'priority' falls back to 'manual'. - Sort persists per collection in localStorage (mirrors view mode). - Table view keeps its own column sorting; the control is board/list only. --- web/src/lib/collections/itemSort.ts | 75 ++++++++++++++++ .../components/collections/BoardView.svelte | 24 +++-- .../components/collections/ListView.svelte | 20 ++++- .../[workspace]/[collection]/+page.svelte | 88 +++++++++++++++++++ 4 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/collections/itemSort.ts diff --git a/web/src/lib/collections/itemSort.ts b/web/src/lib/collections/itemSort.ts new file mode 100644 index 00000000..eec06b7f --- /dev/null +++ b/web/src/lib/collections/itemSort.ts @@ -0,0 +1,75 @@ +// Page-wide item sorting (TASK-1670 / IDEA-1648). +// +// A single comparator factory shared by BoardView (within-lane) and +// ListView (within-group) so a sort chosen on the collection toolbar +// applies consistently in both. `manual` preserves the stored +// `sort_order` (the drag-to-reorder order) and is the default. +import type { Item, Collection } from '$lib/types'; +import { parseFields, parseSchema } from '$lib/types'; + +export type SortMode = 'manual' | 'priority' | 'updated' | 'created' | 'title'; + +export const SORT_OPTIONS: { value: SortMode; label: string }[] = [ + { value: 'manual', label: 'Manual' }, + { value: 'priority', label: 'Priority' }, + { value: 'updated', label: 'Recently updated' }, + { value: 'created', label: 'Created' }, + { value: 'title', label: 'A → Z' } +]; + +// The select field a "Priority" sort ranks by. Convention is the field +// keyed `priority` (e.g. tasks high/medium/low, conventions +// must/should/nice-to-have). Returns undefined when the collection has +// no such field, so the toolbar can hide the Priority option. +export function priorityField(collection: Collection) { + const schema = parseSchema(collection); + return schema.fields.find((f) => f.key === 'priority' && f.type === 'select'); +} + +// Priority weight = the value's index in the field's `options` array. +// Options are authored top-to-bottom (high…low, must…nice), so a lower +// index is a higher priority. Items missing the field, or carrying a +// value not in the schema, sort last. Reading the field's own option +// order means different priority vocabularies rank naturally without a +// hardcoded weight map. +function priorityWeight(item: Item, options: string[]): number { + const val = parseFields(item).priority; + const idx = typeof val === 'string' ? options.indexOf(val) : -1; + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx; +} + +function timeValue(s: string | undefined): number { + if (!s) return 0; + const t = Date.parse(s); + return Number.isNaN(t) ? 0 : t; +} + +// Build the within-group comparator for `mode`. `priority` falls back to +// `sort_order` as a stable tie-break; the date modes sort newest-first; +// `title` is case-insensitive A→Z. `manual` (default) is the stored +// `sort_order`, preserving drag ordering. +export function itemComparator( + mode: SortMode, + collection: Collection +): (a: Item, b: Item) => number { + switch (mode) { + case 'priority': { + const options = priorityField(collection)?.options ?? []; + return (a, b) => + priorityWeight(a, options) - priorityWeight(b, options) || + a.sort_order - b.sort_order; + } + case 'updated': + return (a, b) => timeValue(b.updated_at) - timeValue(a.updated_at); + case 'created': + return (a, b) => timeValue(b.created_at) - timeValue(a.created_at); + case 'title': + return (a, b) => + (a.title || '').localeCompare(b.title || '', undefined, { + sensitivity: 'base' + }); + case 'manual': + default: + return (a, b) => a.sort_order - b.sort_order; + } +} diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 81aab13a..d0042594 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -1,6 +1,7 @@