# Records model

> Fields describe a list once, and a view saves its search, filters, sort, and layout as JSON that means the same in the browser, on a server, in Meilisearch or Elasticsearch, and in a URL.

Status: alpha. Added in Components 2.16.0. Updated in Components 2.17.0, 24 Oct 2026.

Organisers work through lists: events, sessions, orders, attendees, and
customers. `@oztix/roadie-core/records` describes those lists once, as plain
TypeScript with no components, so a search typed on a list screen means the
same thing in the browser, on your server, in Meilisearch or Elasticsearch,
and in a shared link. [RecordTable](https://ticketsolutionsptyltd.github.io/roadie/components/record-table.md) and
[RecordGrid](https://ticketsolutionsptyltd.github.io/roadie/components/record-grid.md) build on it.

## Fields

A field describes one fact about a record: its key, its label, its type, and
how it may be filtered, sorted, and searched. Keep one field list per entity
and share it between screens. Every per-row fact is a key, never a function,
so the same list works in a browser, on a server, and in an adapter. Field
lists are code, and views are the JSON.

```tsx
import type { RecordField } from '@oztix/roadie-core/records'

export const eventFields: RecordField[] = [
  { key: 'name', label: 'Name', type: 'text' },
  { key: 'orderNumber', label: 'Order number', type: 'text', match: '^OZ-\\d{5}$' },
  {
    key: 'venue',
    label: 'Venue',
    type: 'option',
    options: [
      { value: 'quilted-walrus-room', label: 'The Quilted Walrus Room' },
      { value: 'antler-kettle-hall', label: 'Antler Kettle Hall' }
    ]
  },
  {
    key: 'location',
    label: 'Location',
    type: 'location',
    options: [
      { value: 'au', label: 'Australia', kind: 'country' },
      { value: 'au-vic', label: 'Victoria', kind: 'state', parent: 'au' },
      { value: 'au-vic-melbourne', label: 'Melbourne', kind: 'city', parent: 'au-vic' },
      { value: 'au-vic-fitzroy', label: 'Fitzroy', kind: 'suburb', parent: 'au-vic-melbourne' },
      { value: 'au-nsw', label: 'New South Wales', kind: 'state', parent: 'au' },
      { value: 'au-nsw-sydney', label: 'Sydney', kind: 'city', parent: 'au-nsw' }
    ]
  },
  {
    key: 'offer',
    label: 'Availability',
    type: 'option',
    // The dashboard status map: Badge, sort order, and labels from one place.
    status: {
      available: { intent: 'success', order: 1 },
      selling_fast: { intent: 'warning', order: 2 },
      sold_out: { intent: 'danger', order: 3 }
    }
  },
  { key: 'gross', label: 'Gross', type: 'money', currencyKey: 'currency' },
  {
    key: 'starts',
    label: 'Start date',
    type: 'date',
    moment: 'event',
    end: 'ends',
    timeZoneKey: 'venueZone',
    localDateKey: 'startsLocal',
    endLocalDateKey: 'endsLocal'
  },
  { key: 'created', label: 'Created', type: 'date', moment: 'timestamp' }
]
```

| Property                          | Meaning                                                                                                                                                       |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`                            | text, option, location, number, money, date, or boolean. It decides which operators fit.                                                                      |
| `filterable`                      | Defaults to true. Set false to keep a field out of filters and suggestions.                                                                                   |
| `sortable`                        | Defaults to true.                                                                                                                                             |
| `searchable`                      | Free-text search reads it. Defaults to true for text and false otherwise.                                                                                     |
| `multiple`                        | The row holds a list. Is means has any of, and is not means has none of. An option field also gets has all. A list of dates can't be filtered yet.            |
| `end`                             | For a date range, the key holding the end. Date filters then test overlap.                                                                                    |
| `moment`                          | How a date compares: event, access, timestamp (the default), or date. See [Dates](#dates).                                                                    |
| `timeZoneKey`                     | For event and access moments, the row key holding the venue's IANA zone.                                                                                      |
| `localDateKey`, `endLocalDateKey` | The stored venue-local date, for adapters that can't work it out per row.                                                                                     |
| `match`                           | A pattern for identifiers, such as order numbers, as a string anchored with `^` and `$`. Typed text that matches, in any case, is offered as an exact filter. |
| `format`                          | How a number or money field's values read. Defaults to number, or currency for money.                                                                         |
| `currency`, `currencyKey`         | A fixed ISO code, or the row key holding it.                                                                                                                  |
| `status`                          | The dashboard status map. An option field with one gets Badge rendering, sorting by order, and labels from one definition.                                    |
| `options`                         | The values to pick from, each with an optional parent for hierarchies and, on a location, a kind.                                                             |

### Places

A location field holds a place, such as a suburb, or a list of places. Its
options are places with stable ids, never free text, and each names the place
it's in as its `parent` and what it is as its `kind`: `venue`, `suburb`,
`city`, `region`, `state`, `country`, or any other. So is and is not read is in and is
not in. A record is in its own place and every place that contains it, so with a
record in Fitzroy, "Location is in Victoria" matches and "Location is not in
Melbourne" doesn't. Suggestions say where each place is, such as "Suburb in
Melbourne" or "Country".

- A parent the options don't hold ends the line, so that place is top level.
- A value the options don't hold is in only itself.
- A location field without options picks from the values its records hold,
  each in only itself.
- Is empty and is not empty work as they do on any field.

Distance filters come later as a `near` operator, never `within`, which dates
use. Its value will be `{ lat, lng, meters, label }`, and a field option will
name the key holding the record's coordinates.

Derived states, such as Selling fast or Sold out, are worked out once on the
server and arrive as option values. Roadie never bands a percentage into a
state.

In React, `useRecords` takes `RecordsField` from
`@oztix/roadie-components/records`: a field with an optional `icon`, a bold
Phosphor icon. `Records.Search` shows it on the field's suggestions and their
filters, never on values or chips. A field without one gets its type's icon: a
tag for an option, a half circle for an option with `status`, a map pin for a
location, Aa for text, a hash for a number, a dollar sign for money, a calendar
for a date, and a toggle for a boolean. Give a field that names a kind of
thing its own, such as a building for a venue.

## Views

A view is a search, filters, sort, and layout, saved under a name. Each filter
is a chip. Chips are ANDed, and the values inside one chip are ORed. Query and
layout are independent, so the same search reads as a table or a grid of
cards, and Configure switches between them. Page, scroll row, and selection
are session state, never part of a view.

```json
{
  "version": 1,
  "id": "melbourne-weekend",
  "name": "Melbourne this weekend",
  "entity": "events",
  "query": {
    "search": "lampshade",
    "filters": [
      {
        "field": "location",
        "operator": "is",
        "values": [
          "au-vic-melbourne"
        ]
      },
      {
        "field": "starts",
        "operator": "within",
        "value": "this-weekend"
      },
      {
        "field": "offer",
        "operator": "is-not",
        "values": [
          "sold_out"
        ]
      }
    ],
    "sort": [
      {
        "field": "starts",
        "direction": "ascending"
      }
    ]
  },
  "layout": {
    "type": "table",
    "columns": {
      "order": [
        "name",
        "gross",
        "starts"
      ],
      "hidden": [
        "created"
      ]
    }
  }
}
```

| Field type    | Operators                             |
| ------------- | ------------------------------------- |
| Text          | contains, not-contains, is, is-not    |
| Option        | is, is-not, and has-all when multiple |
| Number, money | eq, neq, lt, gt, between              |
| Date          | on, before, after, between, within    |
| Boolean       | is-true, is-false                     |
| Any           | is-set, is-not-set                    |

Empty is a real state: null, an empty string, and an empty list are all not
set. Negative operators (is-not, not-contains, and neq) keep records where the
field is empty, the way Meilisearch does. Check a view from storage or an API
with `validateRecordView(view, fields)`. It rejects unknown fields and
operators that don't fit, and says what would. A view carries its format as
`version`, `RECORD_VIEW_VERSION`. A view saved without one reads as version 1,
and a version this release doesn't know is rejected.

## Scope

Scope is the part of a query the page sets, such as "products offered on
Lampshade Disco, any of its days, or its collection". It's a filter list your
app resolves, because it can span levels a flat record can't express. Scope is
never saved in a view, and Clear never removes it. Pass it to `RecordTable`,
`RecordGrid`, or `useRecords` as `scope`. In server mode, fetch the request
`onRequestChange` hands you, or `records.request` from `useRecords`. It puts
the scope before the view's own filters.

## Dates

Date filters follow the same moment kinds as
[date and time formatting](https://ticketsolutionsptyltd.github.io/roadie/foundations/date-and-time.md). Calendar words resolve
in the viewer's zone, so "today" is today's date at every venue. An event at
11:30pm in Perth is still a Saturday gig when it's already Sunday in Sydney.

| moment    | Example                   | Filters compare                            |
| --------- | ------------------------- | ------------------------------------------ |
| event     | Session start, doors      | Venue-local calendar date, never converted |
| access    | On sale, presale          | Venue-local date                           |
| timestamp | Order placed              | The viewer's day, as instants              |
| date      | Birthday, all-day on-sale | A plain date with no zone                  |

Relative ranges have one fixed meaning: a week is Monday to Sunday, a weekend
is Saturday and Sunday, rolling windows count today, and the financial year
starts in July. The core functions take `weekStart` and `fiscalYearStart`, but
the Records components always use these defaults, so keep them wherever a view
also runs in the browser.

Each range has one form: the last, this, or next day, week, month, quarter, or
year is a word, such as `this-week` or `last-quarter`, and any other period is
an object, such as `{ period: 'month', offset: -2 }`. Two views that mean the
same then compare equal.

Hour windows and the open ranges upcoming, past, and ongoing compare the
instant, except on a plain date, where upcoming starts today and past ends
yesterday. A range field matches when any part of it overlaps, so a five-night
festival is on this weekend if one night is.

A time written without an offset names one moment, in the record's or the
viewer's zone. When daylight saving repeats an hour it's the first pass, and
when it skips one it's the moment the clocks jump. Records and filters read
times the same way, so a single moment is always before, on, or after a date
or time, never two of these.

## Browser and server

`resolveRecordQuery(query, fields, { now, timeZone })` fixes relative dates at
a moment, and every place a query runs reads the result the same way. A
server reads the [request](#server-contract) the browser sends, with its
dates already fixed, never the view, so it needs no clock. Pick where it runs
by where the records live: in the browser, a Meilisearch index, an
Elasticsearch index, or your own API.

| Runs in       | Call                                        | Use it for                                                        |
| ------------- | ------------------------------------------- | ----------------------------------------------------------------- |
| Browser       | `matchesRecordQuery(row, resolved, fields)` | Every row is already loaded, up to a few thousand.                |
| Meilisearch   | `toMeilisearch(request, fields)`            | A Meilisearch index, with typo-tolerant search.                   |
| Elasticsearch | `toElasticsearch(request, fields)`          | An Elasticsearch or OpenSearch index that already serves a list.  |
| Your API      | `POST /api/…/search`                        | Any other store. The server reads the request and maps it itself. |

Each index has a translator on its own subpath, so the model stays
backend-agnostic. On the docs site, the request for the view from
[Views](#views), at 11:30pm on a Friday in Melbourne, runs through each one,
and the result shows as comments under the call.

### Meilisearch

`toMeilisearch(request, fields)` from `@oztix/roadie-core/records/meilisearch`
returns Meilisearch's `q`, `filter`, and `sort`, and the page.

```ts
const request = toRecordsRequest(view.query, eventFields, {
  now: new Date('2026-10-02T13:30:00Z'), // 11:30pm Friday in Melbourne
  timeZone: 'Australia/Melbourne'
}, { page: 0, pageSize: 50 })
toMeilisearch(request, eventFields)
// {
//   "q": "lampshade",
//   "filter": [
//     "location = \"au-vic-melbourne\"",
//     "(startsLocal <= \"2026-10-04\" AND endsLocal >= \"2026-10-03\")",
//     "offer != \"sold_out\""
//   ],
//   "sort": [
//     "starts:asc"
//   ],
//   "page": 1,
//   "hitsPerPage": 50
// }
```

| Index setting          | What it needs                                               |
| ---------------------- | ----------------------------------------------------------- |
| Version                | Meilisearch 1.15 or later, which compares date strings      |
| Instants               | Epoch seconds, or `epoch: 'milliseconds'`                   |
| Event and access dates | The venue-local date under `localDateKey`                   |
| Ranges                 | The end on every record                                     |
| Search                 | `searchableAttributes` lists the fields marked `searchable` |
| Text `contains`        | The `containsFilter` experimental feature                   |

#### Pages and counts

A request's `page` and `pageSize` become `page` (one-based) and
`hitsPerPage`, so the response's `totalHits` counts the matches, up to the
index's `pagination.maxTotalHits`. A [RecordTable](https://ticketsolutionsptyltd.github.io/roadie/components/record-table.md)
takes that count as `rowCount` and the page as its data. A range, `start` to
`end`, becomes a page the same way when it is a whole page, as the ranges a
list asks for are, and `offset` and `limit` otherwise, where Meilisearch only
estimates the count.

#### Permissions

Scope facet value search to the person's permissions on the server, as well
as the search itself.

### Elasticsearch

`toElasticsearch(request, fields)` from
`@oztix/roadie-core/records/elasticsearch` returns an Elasticsearch search
body: `query`, `sort`, `from`, and `size`. Each chip is one clause in
`bool.filter`, and search is a `simple_query_string` that needs every word.

```ts
const request = toRecordsRequest(view.query, eventFields, {
  now: new Date('2026-10-02T13:30:00Z'), // 11:30pm Friday in Melbourne
  timeZone: 'Australia/Melbourne'
}, { page: 0, pageSize: 50 })
toElasticsearch(request, eventFields)
// {
//   "query": {
//     "bool": {
//       "must": [
//         {
//           "simple_query_string": {
//             "query": "lampshade",
//             "fields": [
//               "name",
//               "orderNumber"
//             ],
//             "default_operator": "and"
//           }
//         }
//       ],
//       "filter": [
//         {
//           "term": {
//             "location": {
//               "value": "au-vic-melbourne",
//               "case_insensitive": true
//             }
//           }
//         },
//         {
//           "bool": {
//             "filter": [
//               {
//                 "range": {
//                   "startsLocal": {
//                     "lte": "2026-10-04||/d",
//                     "format": "yyyy-MM-dd"
//                   }
//                 }
//               },
//               {
//                 "range": {
//                   "endsLocal": {
//                     "gte": "2026-10-03",
//                     "format": "yyyy-MM-dd"
//                   }
//                 }
//               }
//             ]
//           }
//         },
//         {
//           "bool": {
//             "must_not": [
//               {
//                 "term": {
//                   "offer": {
//                     "value": "sold_out",
//                     "case_insensitive": true
//                   }
//                 }
//               }
//             ]
//           }
//         }
//       ]
//     }
//   },
//   "sort": [
//     {
//       "starts": {
//         "order": "asc",
//         "missing": "_last"
//       }
//     }
//   ],
//   "from": 0,
//   "size": 50
// }
```

| Index setting          | What it needs                                                                                          |
| ---------------------- | ------------------------------------------------------------------------------------------------------ |
| Version                | Elasticsearch 7.10 or later, or OpenSearch, for case-insensitive terms                                 |
| Filters and sort       | `keyword` fields, or a `keyword` sub-field named in `paths`, such as `{ venue: 'venue.raw' }`          |
| Search                 | `text` fields: those marked `searchable`, or `searchFields` with boosts, such as `['name^5', 'venue']` |
| Instants               | `date`                                                                                                 |
| Event and access dates | The venue-local date or date-time under `localDateKey`, as a `date` with no zone                       |
| Ranges                 | The end on every record                                                                                |

#### Pages and counts

Post the body to your search endpoint, which adds the person's permissions as
a filter of its own, and add `highlight` and `track_total_hits` as the page
needs. The response's `hits.total.value` is `rowCount`. It's exact, for
`rowCountExact`, when `hits.total.relation` is `eq`, and Elasticsearch counts
to 10,000 unless `track_total_hits` says otherwise. With no paging, the body
has no `size`, so set one for an export.

#### Counts beside each filter value

A checkbox list with a count beside each value counts that field against
every other filter, so ticking one venue doesn't hide the rest. Pass the field
as `except`, which leaves out its own chips, and add the aggregation:

```ts
const { query } = toElasticsearch(request, eventFields, { except: 'venue' })
const body = {
  query,
  size: 0,
  aggs: { venue: { terms: { field: 'venue', size: 50 } } }
}
// Each bucket's doc_count is that venue's count beside its checkbox.
```

#### Dates and search

A venue-local date-time under `localDateKey`, such as `2026-10-03T19:30:00`,
counts as its whole day, so an index that already stores one needs no new
field. Search matches analysed words, so a searchable field mapped only as
`keyword` matches only its whole value: map it as `text`, with its `keyword`
sub-field in `paths`.

### Places on any backend

A filter on a place stays a plain term, `location:is:au-vic`, in the URL and
in every backend. Store each record's places once, when you index it:
`recordPlaceReader(field)` returns a reader that lists a record's place and
every place that contains it. An indexer calls it per record, so make one per field,
and it reads the options once. `recordPlaces(field, value)` does the same for
one value. In the browser, `matchesRecordQuery` reads them from the options
itself.

```ts
import { recordPlaceReader } from '@oztix/roadie-core/records'

const placesOf = recordPlaceReader(locationField)
const documents = venues.map((venue) => ({
  ...venue,
  location: placesOf(venue.location)
}))
// placesOf('au-vic-fitzroy'): ['au-vic-fitzroy', 'au-vic-melbourne', 'au-vic', 'au']
```

An index can store that list already, as a record's value, and the field
takes it too. A filter then reads the same everywhere:

| Backend       | Location is in Victoria                             |
| ------------- | --------------------------------------------------- |
| Meilisearch   | `location = "au-vic"`, from `toMeilisearch`         |
| Algolia       | `facetFilters: [["location:au-vic"]]`               |
| Elasticsearch | `{ term: { location: … } }`, from `toElasticsearch` |
| An API        | `?location=au-vic`, matched against the list        |

A filter holding several places ORs them: Algolia's inner array, or
Elasticsearch's `should`. Is not in negates the same term: Meilisearch's
`location != "au-vic"`, Algolia's `"location:-au-vic"`, or the term in a
`must_not`. Each keeps records with no place, as Roadie does. Algolia needs
`location` in `attributesForFaceting`, and Elasticsearch needs it mapped as a
`keyword`. Listing every place inside the
one picked would be thousands of suburbs for a state, too many for a query,
so the places travel with the record instead.

Distance filters, when they come, read the record's coordinates with
Meilisearch's `_geoRadius(lat, lng, meters)` and sort by `_geoPoint`,
Algolia's `aroundLatLng` and `aroundRadius`, or Elasticsearch's
`geo_distance` on a `geo_point`.

## Server contract

A server never reads a view. It reads a `RecordsRequest`: the view's query
resolved at a moment, with the page's scope first, under a version. Relative
dates arrive fixed, so the server needs no calendar rules, and "this weekend"
means the same there as in the browser. `onRequestChange` hands it over,
`useRecords` holds it as `records.request`, every action gets it as `request`,
and `toRecordsRequest(query, fields, { now, timeZone, scope }, paging)` builds
one anywhere else. POST it as JSON. The view from [Views](#views), on its first
page at 11:30pm on a Friday in Melbourne, is this request:

```json
{
  "version": 1,
  "query": {
    "search": "lampshade",
    "filters": [
      {
        "field": "location",
        "operator": "is",
        "values": [
          "au-vic-melbourne"
        ]
      },
      {
        "field": "starts",
        "operator": "overlaps",
        "range": {
          "kind": "dates",
          "start": "2026-10-03",
          "end": "2026-10-04"
        }
      },
      {
        "field": "offer",
        "operator": "is-not",
        "values": [
          "sold_out"
        ]
      }
    ],
    "sort": [
      {
        "field": "starts",
        "direction": "ascending"
      }
    ],
    "timeZone": "Australia/Melbourne"
  },
  "page": 0,
  "pageSize": 50
}
```

| Key                | Holds                                                                                                                                                                                                                                                                              |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`          | 1, `RECORDS_REQUEST_VERSION`. Reject a version the server doesn't know. It changes only when an existing key's meaning does.                                                                                                                                                       |
| `query.search`     | The search text, for the fields marked `searchable`.                                                                                                                                                                                                                               |
| `query.filters`    | The chips in order, ANDed. A date filter becomes `overlaps` with a `range`: `dates` compares calendar dates, the venue's for event and access fields, and `instants` compares epoch milliseconds. Ends are inclusive and null is open. Every other filter is as the view holds it. |
| `query.sort`       | The sort, in order.                                                                                                                                                                                                                                                                |
| `query.timeZone`   | The viewer's zone, for rows that name no venue zone.                                                                                                                                                                                                                               |
| `page`, `pageSize` | One page, counted from 0, in a paged list.                                                                                                                                                                                                                                         |
| `start`, `end`     | Rows from `start` up to, not including, `end`, in a list loaded by range.                                                                                                                                                                                                          |

A request with no paging means every record that matches, as for an export
or an action on all of them. Match as the browser does: text compares ignoring
case, for `is`, `is-not`, `has-all`, `contains`, and `not-contains`, and
negative operators keep records where the field is empty. Field keys are
logical names, never index paths: the server's adapter maps each one, such as
`location` to `venue.city`, so an index can change without changing a saved
view, a link, or a request.

Answer with the page's rows and the count, `rowCount`. An index that stops
counting, such as one that counts to 10,000, also answers whether the count
is exact, for `rowCountExact`. Counts then read "10,000+", and paging stops at
the last page the index can return.

`@oztix/roadie-core/records/records-request.schema.json` and
`@oztix/roadie-core/records/record-view.schema.json` are JSON Schemas, draft
07, for a request and a stored view. They're generated from the schemas
`validateRecordView` uses, so they always agree with it. Generate a server's
models from them, or check what arrives against them.
`@oztix/roadie-core/records/json-schema` returns the same schemas in
JavaScript, from `recordsRequestJsonSchema()` and `recordViewJsonSchema()`.

Ignore keys you don't know. A later release can add one at the top of a
request or in its `query` and still send version 1, and the request schema
accepts them there. A filter or a sort with a key the schema doesn't name is
rejected, since it would change what matches.

So is a filter operator the schema doesn't name, and a version 1 server
rejects it. A new operator, such as `near` for distance, comes with request
version 2, or only on fields the server opts in to, never quietly in
version 1.

On a .NET server, generate the models from the schema files with a tool such
as NJsonSchema, or write them as records. ASP.NET Core reads the camel case
keys into them. Apply the person's permissions on the server as a filter of
your own, ANDed with the request's, so the browser can never widen what it
sees.

```csharp
public sealed record RecordsRequest(
    int Version,
    RecordsQuery Query,
    int? Page = null,
    int? PageSize = null,
    int? Start = null,
    int? End = null);

public sealed record RecordsQuery(
    string Search,
    IReadOnlyList<RecordsFilter> Filters,
    IReadOnlyList<RecordsSort> Sort,
    string TimeZone);

// Value holds a string, a number, or two numbers, by operator.
public sealed record RecordsFilter(
    string Field,
    string Operator,
    IReadOnlyList<string>? Values = null,
    JsonElement? Value = null,
    RecordsFilterRange? Range = null);

// Kind "dates" holds 'YYYY-MM-DD' strings; "instants", epoch milliseconds.
public sealed record RecordsFilterRange(string Kind, JsonElement? Start, JsonElement? End);

public sealed record RecordsSort(string Field, string Direction);

app.MapPost("/api/events/search", async (
    RecordsRequest request, ClaimsPrincipal user, EventSearch search) =>
{
    if (request.Version != 1) return Results.BadRequest("Unsupported request version");
    var permitted = search.PermissionFilter(user);
    var page = await search.RunAsync(request, permitted);
    return Results.Ok(new { rows = page.Rows, rowCount = page.Total, rowCountExact = page.Exact });
});
```

`EventSearch` stands for your adapter: it maps each field key to the index,
turns each filter into the index's query, and adds the permission filter to
every search, count, export, and bulk action.

## Typed text

`parseRecordQuery(text, { fields, now, timeZone })` reads typed text into ranked
suggestions, shaped for `QueryField`. It knows nothing about any one entity.
To search events and orders together, call it once per entity with its
`entity` and merge the results by `score`. It reads identifiers,
`field:value`, field names, option and status values, booleans by name, and
date phrases. Each part of the text gets its best reading first, with the rest
kept as `remainder`. A phrase always becomes a visible, editable chip, never
hidden logic. On the docs site, the suggestions for "melb this weekend" show
as comments under the call, with each one's kind, label, and remainder.

```ts
parseRecordQuery('melb this weekend', { fields: eventFields, now, timeZone })
// [
//   {
//     "kind": "filter",
//     "label": "Start date is this weekend",
//     "remainder": "melb"
//   },
//   {
//     "kind": "filter",
//     "label": "Location is in Melbourne",
//     "remainder": "this weekend"
//   },
//   {
//     "kind": "filter",
//     "label": "Created is this weekend",
//     "remainder": "melb"
//   }
// ]
```

Values read from the start of a word, so `melb` suggests "Location is in
Melbourne" and `elb` suggests nothing. A value named only in part is left out
when a longer reading takes its words: in `melb this weekend`, "this" belongs
to "Start date is this weekend", so it never suggests a venue such as Nimbus
Thistle Hall. Text a field contains takes no words this way, so
`name disco melb` still suggests "Location is in Melbourne".

Negation reads as the opposite filter. "not melb" suggests "Location is not
in Melbourne", and never "Location is in Melbourne". The negating words are
part of the filter, so none of them stay in `remainder`.

| Form                                                                                      | Typed                        | Suggests                             |
| ----------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------ |
| `not`, `isn't`, `excludes`, `excluding`, `except`, `without`, `!=`, or `≠` before a value | `excluding walrus`           | Venue is not The Quilted Walrus Room |
| `-` before a value                                                                        | `-sold out`                  | Status is not Sold out               |
| `!`, `!=`, or `-` after `field:`                                                          | `venue:!walrus`              | Venue is not The Quilted Walrus Room |
| `<field> is not`, or `<field>` then any word in the first row                             | `location not syd`           | Location is not in Sydney            |
| `<field> doesn't contain` or `<field> does not contain`                                   | `name doesn't contain disco` | Name doesn't contain "disco"         |

The apostrophe is optional, so `location isnt syd` suggests "Location is not in
Sydney".

`venue is walrus`, `venue = walrus`, and `name contains disco` read the same
way, as the filter without the negation. Once a value is negated, its positive
is never suggested, so "status is not sold out" never offers "Status is Sold
out".

- A `-` before a number is a minus sign, so `capacity:-5` reads as "Capacity
  is -5".
- A date or a comparison, such as more than, has no opposite. A negated one
  reads nothing, and neither do its words, so `not this weekend` never
  suggests a venue such as Nimbus Thistle Hall.

A number or money field's name, then a comparison, reads as that filter.
`field:value` takes the same words, so `capacity:over 100` and
`capacity:20 and 50` work too.

| Form                                       | Typed                        | Suggests                  |
| ------------------------------------------ | ---------------------------- | ------------------------- |
| `>`, `more than`, or `over`                | `capacity > 100`             | Capacity is more than 100 |
| `<`, `less than`, or `under`               | `capacity under 50`          | Capacity is less than 50  |
| `=` or `is`                                | `capacity = 100`             | Capacity is 100           |
| `!=` or `≠`                                | `capacity != 100`            | Capacity is not 100       |
| `between` and `and`, or a range with a `-` | `capacity between 20 and 50` | Capacity is 20 to 50      |

A range reads in either order, so `capacity 50-20` suggests "Capacity is 20 to
50".

- There's no at least or at most, so `>=`, `<=`, `≥`, `≤`, `at least`,
  `at most`, and `100+` read nothing and `capacity >= 100` stays search text.
  The field's name still reads as the field, so with a Sold field,
  `sold >= 100` never suggests "Status is Sold out".
- Numbers a space separates stay apart, so `capacity > 5 6` suggests "Capacity
  is more than 5" and keeps 6 as search text.

Any field's name, then a value, reads as `field:value` does, with or without
`is`. Text a field contains stops before words other filters read to the end, so
`name disco melb` suggests "Name contains “disco”" and "Location is in
Melbourne" first, then "Name contains “disco melb”".

| Form                            | Typed                   | Suggests                   |
| ------------------------------- | ----------------------- | -------------------------- |
| `<field> <value>`, with no verb | `location melb`         | Location is in Melbourne   |
| `<field> <value>`, with no verb | `capacity 100`          | Capacity is 100            |
| `<field> <value>`, with no verb | `starts this weekend`   | Start date is this weekend |
| `<field> is empty`              | `starts is empty`       | Start date is empty        |
| `<field> is not empty`          | `location is not empty` | Location is not empty      |

A date keeps the word that compares it. `starts after today` suggests Start
date after today's date, never "Start date is today", and `after`, `before`,
`since`, `from`, `until`, `till`, `on or after`, and `on or before` read as the
[date phrases](https://ticketsolutionsptyltd.github.io/roadie/foundations/date-and-time.md#typed-phrases) do. One that can't be
a filter, such as `after 7pm`, suggests no date. The words of a longer date
read nothing on their own, so `last monday` never also suggests the coming
Monday, and `20 to 23 oct` never suggests the 23rd alone. A range of two days
reads whole or not at all, so `tomorrow to yesterday` suggests no date. Each
of its days can still be a value, so with a Day field, `saturday and sunday`
suggests "Day is Saturday" after the dates. Words that lead into a date, such
as `on`, `the`, and `in the`, are part of it, so `on the weekend` never
suggests "Status is On sale", and `the 20th of oct` never suggests a venue
whose name starts with The.

A field name a statement reads is never also read as a value. With a Sold
field, `sold > 100` suggests "Sold is more than 100" and never "Status is Sold
out". A statement comes before any other reading of its value that leaves
the field's name out, so `day saturday and sunday` suggests "Day is Saturday"
before the dates.

`describeRecordFilter(filter, fields, { now, timeZone })` writes any filter as
its chip reads, such as "Gross is more than $100", with the dates a relative
range stands for in `detail`. `parseRecordQuery` labels its filters the same way and
takes each `description` from that `detail`, so a suggestion and the chip it
makes read alike, dates included. Next and last weekend, whose chips have no
`detail`, are described as "Next weekend" and "Last weekend". `Records.Search` uses both, and
shows a page's `scope` as locked chips that are never part of the view. Text
that names a filter already applied, in the view or the scope, still suggests
it, marked Already applied. Taking it clears those words and adds nothing, so
with "Location is in Melbourne" applied, `melb syd` can take both words.

## URL format

`recordViewToSearchParams(view, position)` writes a view as search params, so a list
screen opens from a link with the view applied. The format is versioned, as
`RECORD_URL_VERSION`, and a public contract: the same view always writes the
same URL.
`recordViewFromSearchParams(params, fields, { fallback })` reads it back, ignores keys
it doesn't know, and returns your fallback view with its problems when the URL
holds something invalid.

| Key                                     | Holds                                                                                                                                                |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `v`                                     | The URL format, `RECORD_URL_VERSION`, 1                                                                                                              |
| `view`, `entity`                        | The saved view it started from, and the entity. A URL view has no name                                                                               |
| `q`                                     | The search text                                                                                                                                      |
| `f`                                     | One per chip, in order, as `field:operator:value`. Lists are comma separated                                                                         |
| `sort`                                  | Fields in order, descending with a leading dash, such as `-starts,name`                                                                              |
| `layout`, `columns`, `hidden`, `fields` | The layout and its settings                                                                                                                          |
| `page`, `size`, `row`                   | Where the reader is: the page, or in a list loaded by range, the first row on screen. The URL counts pages from 1, and `position.page` counts from 0 |

On the docs site, the view from [Views](#views) on its first page shows as the
params `recordViewToSearchParams` writes, one a line.

```text
v=1
view=melbourne-weekend
entity=events
q=lampshade
f=location:is:au-vic-melbourne
f=starts:within:this-weekend
f=offer:is-not:sold_out
sort=starts
layout=table
columns=name,gross,starts
hidden=created
page=1
```

## Modified views

`equalRecordViews(view, baseline)` tells whether a view still matches the one it was
opened from, for the modified mark beside a view name. It ignores id, name,
spacing in the search, the order of chips and their values, and how a
relative range is written. Sort and column order still count.

Pass that saved view to `useRecords` as `baseline` and it gives `modified` and
`resetView`. `Records.ViewActions` shows the name and the mark, with a menu to
save, save as, rename, reset, or delete the view. Your app keeps the presets
and the views people save, and lists them, as
[RecordTable](https://ticketsolutionsptyltd.github.io/roadie/components/record-table.md#saved-views) shows.

## Guidelines

### Describe per-row facts as keys

A function cannot travel to a server, a URL, or a search index.

**Do**

```tsx
{ key: 'starts', type: 'date', moment: 'event', timeZoneKey: 'venueZone' }
```

Name the key that holds the venue's zone.

**Don’t**

```tsx
{ key: 'starts', type: 'date', zone: (row) => row.venue.zone }
```

Work it out in a callback only the browser can run.

### Keep session state out of views

A saved view should open the same for everyone, at the top.

**Do**

```tsx
recordViewToSearchParams(view, { page: 2, row: 40 })
```

Pass the page and row as position, next to the view.

**Don’t**

```tsx
saveView({ ...view, page: 2, selected: ['ord_123'] })
```

Save where one person had scrolled to as part of the view.

## API reference

### Records

```tsx
import { Records } from '@oztix/roadie-components/records'
```

- `caption`: `string`. Accessible name for the records, such as the table's.
- `records`: `RecordsInstance<Row>`. Required.
- `layouts`: `readonly AnyRecordLayout[]`. Required. The ways to show the records. Content shows the one the view names, or the first.

### Records.Actions

Actions on every matching record, with nothing selected, such as an export.

- `actions`: `readonly RecordsAction<Row>[]`. Required. Act on everything the search and filters match. The first is a button and the rest a More actions menu; in a toolbar under 40rem, all are in the menu.
- `className`: `string`.

### Records.BulkActions

- `actions`: `readonly RecordsBulkAction[]`. Required.
- `recordName`: `RecordName`. Overrides the records' `recordName`.
- `className`: `string`.

### Records.Content

Shows the records in the layout the view names, or the first one given.

- `className`: `string`.
- `maxHeight`: `string`. Scrolls the records in their own box this tall, any CSS length. The toolbar stays outside it.
- `fill`: `boolean`. Fills its parent's height and scrolls inside, like `maxHeight`. The parent needs a definite height, such as `Pane.Body`.

### Records.Header

The records' controls, sticking above the content as it scrolls.

- `children`: `ReactNode`. Replaces the standard controls: the search, the view actions, the options and the actions.
- `searchPlaceholder`: `string`. Defaults to `'Search and filter'`. The standard search's placeholder.
- `searchLabel`: `string`. Names the standard search when its placeholder doesn't.
- `searchFocusShortcut`: `string | false`. Defaults to `'/'`. The key that focuses the standard search, a `KeyboardEvent.key` value such as `/`, or `false` for none.
- `actions`: `readonly RecordsAction<Row>[]`. The standard controls' actions on every matching record, at the end as `Records.Actions`.
- `viewActions`: `RecordsViewActionsProps`. The standard controls' view actions, after the search as `Records.ViewActions`.
- `className`: `string`.

### Records.Options

A button that opens the view's options: the layout, the sort, and the shown layout's own settings, such as the table's columns. A popover, or a bottom drawer on a phone. Renders nothing when there is nothing to set.

- `label`: `string`. Defaults to `"Configure" and the layout, such as "Configure table"`. Names the button, its tooltip and the panel.
- `className`: `string`.

### Records.Pagination

- `pageSizes`: `number[]`. Defaults to `[25, 50, 100]`.
- `className`: `string`.

### Records.Provider

Shares records with parts spread across a layout, such as a `Pane`'s header, body and footer.

- `records`: `RecordsInstance<Row>`. Required.
- `layouts`: `readonly AnyRecordLayout[]`. Required. The ways to show the records. Content shows the one the view names, or the first.
- `caption`: `string`. Accessible name for the records, such as the table's.

### Records.Search

Search and filter in one field. Typing searches the records' searchable fields and suggests filters, such as a city or "this weekend", that become chips. Selecting a chip edits its values in the list, and a date picked under is, is before, is after, or is between comes from a calendar there. The page's `scope` shows first, as locked chips.

- `placeholder`: `string`. Defaults to `Search and filter`.
- `aria-label`: `string`. Names the field. Defaults to the placeholder.
- `focusShortcut`: `string | false`. Defaults to `/`. A key that focuses the search from anywhere on the page, as `QueryField`'s `focusShortcut` takes it, or `false` for none.
- `className`: `string`.

### Records.Select

Select and Done, with Select all, for a layout that selects through Select mode. Renders nothing while the shown layout gives each record a checkbox.

- `className`: `string`.

### Records.Status

Announces loading, errors, the result count and the selection to screen readers.

No props beyond the standard HTML attributes.

### Records.ViewActions

The open view's name, marked when it has changed, with a menu to save, save as, rename, reset or delete it. The app keeps the views: each action calls its handler, and Reset goes back to the records' `baseline`. Renders nothing with no baseline and no `onSaveAs`.

- `onSave`: `RecordsViewHandler`. Saves the changes over the `baseline`: gets the view with the baseline's id and name. Leave it out for a view people can't change, such as a preset.
- `onSaveAs`: `RecordsViewHandler`. Saves the view as a new one: gets it with the name given and no id, for the app to assign.
- `onRename`: `RecordsViewHandler`. Gets the `baseline` with its new name, leaving any changes unsaved.
- `onDelete`: `RecordsViewHandler`. Gets the `baseline` once people confirm. Open another view once it resolves.
- `className`: `string`.

Docs updated 21 Oct 2026.
