feat(collections): page-wide sort control + priority-weight helper (TASK-1670, closes IDEA-1648) (#673)

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.
This commit is contained in:
xarmian
2026-05-30 21:30:12 -04:00
committed by GitHub
parent 9dd4fa653e
commit 520380946e
4 changed files with 199 additions and 8 deletions
+75
View File
@@ -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;
}
}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Item, Collection } from '$lib/types';
import { parseSchema, parseFields } from '$lib/types';
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import type { DndEvent } from 'svelte-dnd-action';
import ItemCard from './ItemCard.svelte';
@@ -41,9 +42,16 @@
* two matches share a column.
*/
preserveOrder?: boolean;
/**
* Page-wide sort applied within each lane (TASK-1670). 'manual'
* (default) keeps the stored sort_order — the drag order. Any
* other mode also disables item drag, since reordering a sorted
* lane would be meaningless (the comparator would re-sort it).
*/
sortMode?: SortMode;
}
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false }: Props = $props();
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false, sortMode = 'manual' }: Props = $props();
let confirmArchiveColumn = $state<string | null>(null);
// Which lane's ⋯ menu is open (null = none). The menu is the new home
@@ -163,10 +171,13 @@
}
}
// `preserveOrder` opts out of the in-column sort so search rank
// from the parent isn't overridden — TASK-1367.
// from the parent isn't overridden — TASK-1367. Otherwise sort
// each lane by the page-wide sort mode (TASK-1670); 'manual'
// resolves to the stored sort_order, preserving prior behavior.
if (!preserveOrder) {
const cmp = itemComparator(sortMode, collection);
for (const col of columns) {
result[col].sort((a, b) => a.sort_order - b.sort_order);
result[col].sort(cmp);
}
}
return result;
@@ -362,8 +373,11 @@
// requested rank-preserving order (search
// active) — otherwise a drag would persist the
// relevance-ranked subset order as the stored
// `sort_order`. TASK-1367 / Codex R5.
dragDisabled: isMobile || !canEdit || preserveOrder
// `sort_order`. TASK-1367 / Codex R5. Also disable
// under any non-manual page sort (TASK-1670): the
// lane is comparator-ordered, so a drag couldn't
// stick anyway.
dragDisabled: isMobile || !canEdit || preserveOrder || sortMode !== 'manual'
}}
onconsider={(e) => handleConsider(colValue, e)}
onfinalize={(e) => handleFinalize(colValue, e)}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Item, Collection } from '$lib/types';
import { parseSchema, parseFields } from '$lib/types';
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
import { SvelteSet } from 'svelte/reactivity';
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import type { DndEvent } from 'svelte-dnd-action';
@@ -46,6 +47,13 @@
* status column.
*/
preserveOrder?: boolean;
/**
* Page-wide sort applied within each group (TASK-1670). 'manual'
* (default) keeps the stored sort_order — the drag order. Any
* other mode also disables item drag, since reordering a sorted
* group would be meaningless.
*/
sortMode?: SortMode;
}
let {
@@ -63,7 +71,8 @@
itemProgress,
progressLabel = 'tasks',
canEdit = true,
preserveOrder = false
preserveOrder = false,
sortMode = 'manual'
}: Props = $props();
let confirmArchiveGroup = $state<string | null>(null);
@@ -151,9 +160,12 @@
// `preserveOrder` opts out of the in-group sort so a parent that
// already sorted by relevance (search active) doesn't get its
// ranking clobbered when two matches share a column. TASK-1367.
// Otherwise sort each group by the page-wide sort mode
// (TASK-1670); 'manual' resolves to the stored sort_order.
if (!preserveOrder) {
const cmp = itemComparator(sortMode, collection);
for (const key of Object.keys(result)) {
result[key].sort((a, b) => a.sort_order - b.sort_order);
result[key].sort(cmp);
}
}
return result;
@@ -304,7 +316,9 @@
// active) — otherwise a drag would persist
// the relevance-ranked subset order as the
// stored `sort_order`. TASK-1367 / Codex R5.
dragDisabled: !canEdit || preserveOrder
// Also disable under any non-manual page sort
// (TASK-1670) — the group is comparator-ordered.
dragDisabled: !canEdit || preserveOrder || sortMode !== 'manual'
}}
onconsider={(e) => handleConsider(groupName, e)}
onfinalize={(e) => handleFinalize(groupName, e)}
@@ -25,6 +25,7 @@
import { localSearch, parseSearchQuery } from '$lib/stores/localSearch.svelte';
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
import { confirmOpenChildrenOrThrow, isOpenChildrenError } from '$lib/items/openChildrenError';
import { SORT_OPTIONS, priorityField, type SortMode } from '$lib/collections/itemSort';
type ViewMode = 'list' | 'board' | 'table';
@@ -38,6 +39,9 @@
let metaLoading = $state(true);
let collection = $state<Collection | null>(null);
let viewMode = $state<ViewMode>('list');
// Page-wide within-group sort (TASK-1670 / IDEA-1648). 'manual' is the
// stored sort_order (drag order). Persisted per collection.
let sortMode = $state<SortMode>('manual');
let activeFilters = $state<Record<string, string>>({});
// Multi-select tag filter (OR semantics). Tags live on the top-level
// `tags` column, not in `fields` JSON, so they're tracked separately
@@ -197,6 +201,39 @@
return defaultMode;
}
// Persist the page-wide sort per collection (mirrors saveViewMode).
function saveSortMode(mode: SortMode) {
sortMode = mode;
if (collSlug) {
try { localStorage.setItem(`pad-sort-${collSlug}`, mode); } catch {}
}
}
function loadSavedSortMode(coll: string): SortMode {
try {
const saved = localStorage.getItem(`pad-sort-${coll}`);
if (SORT_OPTIONS.some((o) => o.value === saved)) return saved as SortMode;
} catch {}
return 'manual';
}
// Sort options available for this collection: hide "Priority" when the
// collection has no `priority` select field (it would be a no-op).
let sortOptions = $derived(
collection && priorityField(collection)
? SORT_OPTIONS
: SORT_OPTIONS.filter((o) => o.value !== 'priority')
);
// Fall back to 'manual' if the active sort isn't valid for this
// collection (e.g. a 'priority' choice persisted for a collection
// that has no priority field after switching collections).
$effect(() => {
if (!sortOptions.some((o) => o.value === sortMode)) {
sortMode = 'manual';
}
});
// Sync filters to URL query params (shareable)
function updateUrlFilters() {
if (!collSlug || !wsSlug) return;
@@ -501,6 +538,7 @@
const defaultMode = (['board', 'list', 'table'].includes(settings.default_view))
? settings.default_view as ViewMode : 'list';
viewMode = loadSavedViewMode(coll, defaultMode);
sortMode = loadSavedSortMode(coll);
// Override with URL params if present
loadUrlFilters();
@@ -1542,6 +1580,22 @@
</div>
{/if}
{#if viewMode !== 'table'}
<label class="sort-control" title="Sort items">
<span class="sort-label">Sort</span>
<select
class="sort-select"
value={sortMode}
onchange={(e) => saveSortMode(e.currentTarget.value as SortMode)}
aria-label="Sort items"
>
{#each sortOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</label>
{/if}
<button
class="filter-toggle-btn"
class:has-filters={hasActiveFilters}
@@ -1791,6 +1845,7 @@
{progressLabel}
canEdit={canEditThisCollection}
preserveOrder={searchQuery.trim() !== ''}
{sortMode}
/>
{:else if viewMode === 'table'}
<TableView
@@ -1819,6 +1874,7 @@
{progressLabel}
canEdit={canEditThisCollection}
preserveOrder={searchQuery.trim() !== ''}
{sortMode}
/>
{/if}
{/if}
@@ -2115,6 +2171,38 @@
padding: var(--space-3) 0;
}
/* Page-wide sort control (TASK-1670) — sits next to the view toggle. */
.sort-control {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: 0.82em;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.sort-label {
display: none;
}
@media (min-width: 900px) {
.sort-label {
display: inline;
}
}
.sort-select {
appearance: auto;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 6px;
font-size: inherit;
cursor: pointer;
}
.archive-toggle {
display: flex;
align-items: center;