/** * Snippet sort + search — pure library-ordering logic (spec §02 → Sort, Search). * * Portable core: no browser APIs, no React. The library's filter+sort pipeline * lives here so it is unit-tested in isolation and the component can derive its * view with a plain `useMemo` over primitive store values (snippets, query, * sortBy, sortOrder) — never inside a Zustand selector that builds a fresh array * (which would loop the app; see MEMORY → "Zustand stable selectors"). * * Council resolution (recorded in docs/architecture/10): * - SORT: fields Modified / Created / Name / Size; re-selecting the active field * flips direction, a different field resets to descending (spec §02). * - SEARCH: case-insensitive substring across name + comment + spec content; we * match the **draft** spec text (`draftSpec`) — the working version the user is * editing and what the spec §02 calls "the current working/draft spec text". */ import type { Snippet } from './snippet'; /** The four sortable fields (spec §02 → Sort). */ export type SortBy = 'modified' | 'created' | 'name' | 'size'; /** Sort direction. */ export type SortOrder = 'asc' | 'desc'; /** The library's default ordering: newest changes first (spec §02 → Sort). */ export const DEFAULT_SORT_BY: SortBy = 'modified'; export const DEFAULT_SORT_ORDER: SortOrder = 'desc'; export interface SortState { sortBy: SortBy; sortOrder: SortOrder; } /** * "Size" of a snippet for sorting (spec §02 → "Size sorts by stored snippet * size"). Defined as the **character length of the published spec text** — a * stable storage proxy: it's the persisted bytes-ish footprint of the record's * main payload, doesn't fluctuate with un-published in-progress typing, and needs * no `TextEncoder`. (The library *row* displays the draft's UTF-8 size for an * at-a-glance hint; sorting uses the published text so the order is stable.) */ export function snippetSortSize(snippet: Snippet): number { return snippet.spec.length; } /** * Compare two snippets by a field, ascending. Name is case-insensitive * (locale-aware); timestamps compare lexicographically (ISO-8601 sorts * chronologically as text); size compares numerically. Ties break by `id` so the * order is **stable** and deterministic across renders. */ function compareAsc(a: Snippet, b: Snippet, by: SortBy): number { let primary = 0; switch (by) { case 'name': primary = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); break; case 'created': primary = a.created.localeCompare(b.created); break; case 'modified': primary = a.modified.localeCompare(b.modified); break; case 'size': primary = snippetSortSize(a) - snippetSortSize(b); break; } // Stable tiebreak so equal-keyed rows keep a fixed, deterministic order. return primary !== 0 ? primary : a.id.localeCompare(b.id); } /** * Return a new array of `snippets` ordered by the given sort state. Pure — never * mutates the input (sorts a copy). */ export function sortSnippets( snippets: readonly Snippet[], { sortBy, sortOrder }: SortState, ): Snippet[] { const dir = sortOrder === 'asc' ? 1 : -1; return [...snippets].sort((a, b) => dir * compareAsc(a, b, sortBy)); } /** * Whether a snippet matches a search query (spec §02 → Search). Case-insensitive * substring across the snippet **name**, **comment**, and **spec content** (the * working draft text). An empty/whitespace query matches everything (search * affects visibility only — it never excludes when nothing was asked for). */ export function snippetMatchesQuery(snippet: Snippet, query: string): boolean { const q = query.trim().toLowerCase(); if (q === '') return true; return ( snippet.name.toLowerCase().includes(q) || snippet.comment.toLowerCase().includes(q) || snippet.draftSpec.toLowerCase().includes(q) ); } /** * The library view: snippets filtered by `query` then ordered by `sort`. This is * the single function the component memoizes over primitive store values. */ export function filterAndSortSnippets( snippets: readonly Snippet[], query: string, sort: SortState, ): Snippet[] { const filtered = query.trim() === '' ? [...snippets] : snippets.filter((s) => snippetMatchesQuery(s, query)); return sortSnippets(filtered, sort); }