{"version":3,"file":"filter-apply.cjs","names":[],"sources":["../../../src/components/FilterBar/filter-apply.ts"],"sourcesContent":["// The other half of the filter model: evaluating a `Filter[]` instead of only\n// describing it. `FilterBar` produces filters, `applyFilters` runs them over an\n// in-memory list, and `filtersToQueryParams` (filter-query.ts) hands the same set\n// to a paginated backend. Without these two the model stops one step short of\n// useful and every app writes the eleven operator branches again.\n\nimport { formatDateForInput } from \"@/utils/format\";\nimport type { Filter, FilterOperator } from \"./filter-model\";\nimport { isComplete } from \"./filter-model\";\n\n/** Matches a plain `yyyy-mm-dd`, the shape `<input type=\"date\">` produces. */\nconst DATE_ONLY = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/**\n * One collator for every text comparison in this module.\n *\n * `localeCompare(value, undefined, { numeric: true })` builds a collator on each\n * call — passing an options bag opts out of the engine's cached-default fast\n * path — and this comparison runs once per row per filter. Measured at 200k\n * comparisons: 453 ms through `localeCompare` against 25 ms through a hoisted\n * collator, i.e. ~23 ms of main thread per keystroke on a 10k-row list.\n */\nconst TEXT_COLLATOR = new Intl.Collator(undefined, { numeric: true });\n\n/**\n * Read a property off a row without asserting the row's shape.\n *\n * `Filter.field` is a string chosen by the app's field list, so it cannot be a\n * `keyof T`; the lookup goes through an index signature and a missing property\n * yields `undefined`, which the operators treat as an empty value.\n *\n * @param item - The row being tested.\n * @param field - Property name from the filter.\n * @returns The property value, or `undefined` when the row has no such key.\n */\nfunction readField(item: unknown, field: string): unknown {\n    if (item == null || typeof item !== \"object\") return undefined;\n    return (item as Record<string, unknown>)[field];\n}\n\n/**\n * Local `yyyy-mm-dd` for a value that represents a date, or `null`.\n *\n * Delegates the formatting to {@link formatDateForInput}, which owns the rule\n * that the key is built from the local calendar parts rather than `toISOString`.\n * This adds only the narrowing from `unknown` and maps \"not a date\" to `null`\n * instead of to the empty string a date input wants.\n *\n * @param value - A `Date`, a date-ish string, or anything else.\n * @returns The day key, or `null` when the value does not denote a date.\n */\nfunction dayKey(value: unknown): string | null {\n    if (value instanceof Date) return formatDateForInput(value) || null;\n    if (typeof value !== \"string\" || value.trim() === \"\") return null;\n    return formatDateForInput(value) || null;\n}\n\n/**\n * Compare a row value against a filter value, in the row value's own type.\n *\n * The filter always carries strings — it comes from form inputs and from the\n * URL — while the row carries whatever the API returned. Comparing them as text\n * gets the two common cases wrong: `10` would sort before `9`, and a date would\n * be compared by however it happens to be formatted. So the row value picks the\n * comparison, and the filter value is coerced into it.\n *\n * Dates compare by **day**, not by instant: a row stamped `2026-03-05T13:00:00Z`\n * is `>= 2026-03-05`, which is what the person picking a date in the UI means.\n *\n * @param left - The row value.\n * @param right - The raw filter value.\n * @returns Negative, zero or positive, or `null` when the two cannot be compared.\n */\nfunction compare(left: unknown, right: string): number | null {\n    if (left == null) return null;\n\n    if (typeof left === \"number\") {\n        const parsed = Number(right);\n        return Number.isNaN(parsed) ? null : left - parsed;\n    }\n\n    if (typeof left === \"boolean\") {\n        const parsed = right === \"true\" ? true : right === \"false\" ? false : null;\n        return parsed === null ? null : Number(left) - Number(parsed);\n    }\n\n    if (DATE_ONLY.test(right)) {\n        const key = dayKey(left);\n        return key === null ? null : key.localeCompare(right);\n    }\n\n    return TEXT_COLLATOR.compare(String(left), right);\n}\n\n/**\n * Equality in the row value's type, falling back to exact text.\n *\n * Case-sensitive on purpose: `eq` maps to a plain `WHERE column = value` on the\n * server side, so a case-insensitive client would quietly disagree with the\n * backend for the same filter. `contains` is the case-insensitive operator, and\n * it is also the default one for text fields, so the friendly behaviour is what\n * people get without asking.\n *\n * Identical strings take a fast path out, since the row value is text in the\n * common case and the collator is the expensive part.\n *\n * @param left - The row value.\n * @param right - The raw filter value.\n * @returns Whether the two are equal.\n */\nfunction equals(left: unknown, right: string): boolean {\n    if (left === right) return true;\n    const result = compare(left, right);\n    if (result !== null) return result === 0;\n    return String(left ?? \"\") === right;\n}\n\n/**\n * Is this row value empty?\n *\n * Empty means absent, blank text or an empty list — never falsy. `0` and `false`\n * are values somebody chose, and a filter that hid them would be reporting the\n * wrong count on every dashboard that tracks zeroes.\n *\n * @param value - The row value.\n * @returns Whether the value counts as empty.\n */\nfunction isEmptyValue(value: unknown): boolean {\n    if (value == null) return true;\n    if (typeof value === \"string\") return value.trim() === \"\";\n    if (Array.isArray(value)) return value.length === 0;\n    return false;\n}\n\n/**\n * The filter's value as a list, whatever arity the operator uses.\n *\n * @param filter - The filter to read.\n * @returns Its values, trimmed of nothing — a leading space may be meaningful text.\n */\nfunction valuesOf(filter: Filter): string[] {\n    if (filter.value === undefined) return [];\n    return Array.isArray(filter.value) ? [...filter.value] : [String(filter.value)];\n}\n\n/**\n * Evaluate one operator against one row value.\n *\n * @param operator - The comparison to make.\n * @param left - The row value.\n * @param values - The filter values (one, two for `between`, many for `in`).\n * @returns Whether the row value satisfies the operator.\n */\nfunction matchesOperator(operator: FilterOperator, left: unknown, values: string[]): boolean {\n    switch (operator) {\n        case \"empty\":\n            return isEmptyValue(left);\n        case \"notEmpty\":\n            return !isEmptyValue(left);\n        case \"eq\":\n            return equals(left, values[0] ?? \"\");\n        case \"ne\":\n            return !equals(left, values[0] ?? \"\");\n        case \"contains\":\n            return String(left ?? \"\")\n                .toLowerCase()\n                .includes((values[0] ?? \"\").toLowerCase());\n        case \"in\":\n            return values.some((value) => equals(left, value));\n        case \"between\": {\n            const [first = \"\", second = \"\"] = values;\n            const low = compare(left, first);\n            const high = compare(left, second);\n            if (low === null || high === null) return false;\n            return low >= 0 && high <= 0;\n        }\n        default: {\n            const result = compare(left, values[0] ?? \"\");\n            if (result === null) return false;\n            if (operator === \"gt\") return result > 0;\n            if (operator === \"gte\") return result >= 0;\n            if (operator === \"lt\") return result < 0;\n            return result <= 0;\n        }\n    }\n}\n\n/**\n * Order the two values of a `between` so the range is never empty by accident.\n *\n * A person who picks the later date first means the range between the two dates,\n * not \"no rows\". The server-side `BETWEEN` needs `(lo, hi)` in order too, which\n * is why the normalisation lives here and is shared by `filtersToQueryParams`.\n *\n * @param values - The raw pair.\n * @returns The pair, ascending.\n */\nexport function orderRange(values: readonly string[]): string[] {\n    const [first = \"\", second = \"\"] = values;\n    if (DATE_ONLY.test(first) && DATE_ONLY.test(second)) {\n        return first <= second ? [first, second] : [second, first];\n    }\n    const low = Number(first);\n    const high = Number(second);\n    if (!Number.isNaN(low) && !Number.isNaN(high)) {\n        return low <= high ? [first, second] : [second, first];\n    }\n    return TEXT_COLLATOR.compare(first, second) <= 0 ? [first, second] : [second, first];\n}\n\n/**\n * Run a filter set over an in-memory list.\n *\n * Closes the loop `FilterBar` opens: the bar produces `Filter[]`, this applies\n * them. Filters combine with `AND`, matching the flat model the bar builds, and\n * an incomplete filter is skipped rather than treated as a match of nothing — a\n * half-filled form should not empty the table underneath it.\n *\n * Comparison follows the row's type, not the filter's: numbers compare\n * numerically, dates compare by day, and everything else compares as text with\n * `numeric: true` so `\"item 2\"` lands before `\"item 10\"`.\n *\n * A few behaviours differ from the SQL the server-side twin produces, and the\n * difference is deliberate rather than accidental:\n *\n * - `ne` matches rows whose value is absent. In SQL, `column <> 'x'` is `NULL`\n *   for a `NULL` column and the row drops out. Here \"is not paid\" shows the rows\n *   with no status at all, which is what the chip claims.\n * - `empty` matches `NULL`, blank text and empty lists; `__isnull` on the server\n *   only matches `NULL`. A column that stores `\"\"` instead of `NULL` is where\n *   the two disagree.\n *\n * @example\n * const visible = applyFilters(orders, filters);\n *\n * Each filter's values are normalised once, before the scan, rather than inside\n * the per-row predicate: the arity, the `between` ordering and the value array\n * depend only on the filter, so deriving them per row multiplied that work by\n * the row count. `empty`/`notEmpty` ignore their values entirely, which is why\n * the prepared shape does not need to distinguish them.\n *\n * @param items - The full list.\n * @param filters - Applied filters; incomplete ones are ignored.\n * @returns A new array with the rows that satisfy every complete filter.\n */\nexport function applyFilters<T>(items: readonly T[], filters: readonly Filter[]): T[] {\n    const active = filters.filter(isComplete).map((filter) => ({\n        field: filter.field,\n        operator: filter.operator,\n        values: filter.operator === \"between\" ? orderRange(valuesOf(filter)) : valuesOf(filter),\n    }));\n    if (active.length === 0) return [...items];\n\n    return items.filter((item) =>\n        active.every(({ field, operator, values }) =>\n            matchesOperator(operator, readField(item, field), values),\n        ),\n    );\n}\n"],"mappings":"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"}