mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|