The Table is the visualization that lists entries (Visualizations). One component, EntriesTable (apps/renderer/src/components/table/EntriesTable.tsx), has three places: the Table page at /table, over every trackable with a filter bar; a trackable’s Table tab, scoped to it; and a tag’s Table tab, scoped to every trackable under it. A trackable’s Table is not on its Home card, which is for looking at days.

Every read is SQL

A profile holds thousands of entries, so the table never filters or sorts in JavaScript. entryRepo.query({ filter, sort, limit, offset }) in @datler/core (packages/core/src/entry-repository.ts) compiles to a parameterized WHERE, ORDER BY and LIMIT/OFFSET with a COUNT for the pager, behind the Repository Seam. The renderer reads it through useEntryQuery, keyed under the entries so any change to an entry refreshes it, and keeping the previous page on screen while the next loads, so paging does not flash. The table draws exactly the page the database returned; there is no client-side model to fall back into. Changing the filter returns to the first page, so a narrower filter never asks for a page past its end, and a delete that empties the last page moves back to the new last one: the library’s pager does not clamp, so the table does. The same query reads the month grid’s month and Day Detail’s day (Calendar Drill-down), so every filtered read of entries goes through one seam.

It is the library’s DataTable (see Jeliroco Components) in manual mode: sorting, filtering and paging are resolved in SQL, and the table is handed one page it must not re-sort or re-slice. Its rowCount is the query’s total, so the pager and aria-rowcount say “of 7576” rather than “of 25”. The library’s toolbar above it brings a column picker, a control for the whole sort, and Reset; its own row-count status and client-side filters are off, since under manual mode they would describe one page. Grouping is off for the same reason. Pages are 25 rows.

A holder is one row. The query’s nested leaves out an entry held by one the same filter keeps (HELD_SQL, the SQL of holderOf in packages/core/src/containers.ts, which a test holds to one answer with it), so a Breakfast or a Workout is a row and its parts are not paged on their own. Its Date cell carries how many it holds and opens them beneath it, indented, an open part’s own parts opening in turn. A part of another trackable in a scoped table’s property columns is named in the first column by its trackable and label; an entry standing in for its parts is named by its Template there.

Columns

columnshownsorts
Date (happened_at)alwaysyes
Trackable, its colour dot and name linking to its pageover several trackablesno: it is a join, and the SQL sort takes a column of entry
Value, through the shared entryLabel (“Egg × 2 serving”, “23:30–07:15 · 7h 45m”)unless the trackable’s entries hold more than one valueyes
one column per property, when a scoped trackable’s entries hold several (a record’s fields, an interval’s start, end and length, a reference’s item and amount)scopedwhere the property is stored, in the value’s JSON
Issues, with their fixesalwaysno
Note, the words about the entry beside its valuealwaysno
Recorded (recorded_at)scopedyes

Recorded is when the entry was logged, beside the Date it happened, which is the honest split when they differ. With no column sorted, the query orders newest first; clicking a column sorts by it, and clicking another adds it behind the first, badged 1 and 2, since the repository has always taken a list of sorts with the first as primary. Right-clicking a column’s header opens its menu, from the library’s DataTableColumnHeader: Ascending, Descending, Sort only by this, and Hide column.

Filters on /table

The Table page’s filter bar (routes/table.index.tsx) narrows the query, each filter a parameterized clause:

  • a search over text values (lower(value_text) LIKE), which does not search notes or values kept in an entry’s JSON;
  • From and To dates, whole days (fromDay, toDay): from the first day’s bare date, which sorts before every time on it, to before the day after the last, so an entry kept by its day alone and one with seconds both fall on their day;
  • a value test, >, >=, =, <= or < against a number, over value_number;
  • the trackables, as chips of every trackable you log (derived trackables and tags have no entries of their own); an explicitly empty set means none, not all;
  • one dropdown per catalog a record trackable’s reference field points to, found by walking every record’s fields, so no catalog is named in the code and a new catalog gets its filter for free (refFacetsOf). Choosing an item finds it under every key a trackable logs that catalog in, since a field added in the form has a key of its own (json_extract(value_json, '$.field') = ? for each, any matching), and shows a summary strip for each trackable over the catalog with declared summaries, folding its own filtered entries (sessions, sets, volume, top set for Strength; RecordSummary). See Exercise & Strength.

There is no filter by tag or by kind of value; either would resolve to a list of trackables in the caller, so EntryFilter stays a plain filter over the entry table.

Editing

Browsing is safe: outside Edit, a row’s click opens its entry in a dialog over the table (Inspect Without Leaving), and an entry in the Inbox opens with Move to. The Edit toggle in the toolbar turns the Date into a date-and-time input and a scalar Value into the shared ValueEditor, each committing on blur, and shows a pencil and a delete on each row; a record, a reference or an interval value is structured and is changed in the entry’s editor. The editor in a cell is given no history, because a cell commits on blur: one seeded from the last entry would write last week’s number into a cell you only tabbed through. Writes go through the shared entry hook, which converts the display unit to the base unit, so a cell that only keeps a value converts it back to display first and the round trip changes nothing.

Deleting asks first, saying it can be undone, and offers Undo afterwards, through the one hook every delete button calls (useEntryDelete); an entry that holds others, a routine’s, asks whether they go too.

Many rows at once. In Edit, each row has a tick (the library’s selectionColumn), and shift-click ticks a run. A bar above the table counts what is ticked, across pages, offers to tick every row the filters keep (“Select all N that match”), and changes them together (BulkBar, useBulkEdit):

  • Move them by minutes, hours or days, earlier or later, across trackables: each one’s moment, and an interval’s start and end with it (shiftedEntry in packages/core/src/entry-shift.ts). What a holder holds moves with it.
  • Move to another trackable, in place: each keeps its id, its time, its note and who logged it, so sync sees one row change (planMoves in apps/renderer/src/lib/move-entries.ts). Its value goes as it is where the two trackables’ values read the same (sameShape in packages/core/src/move-shape.ts: the same kind, a number in the same dimension or unit, a record’s fields by name and kind; a choice never, its codes being its own trackable’s). Into the Inbox a value becomes its words; out of it the words become the note. One inside an Event stays in it, and one inside a thing made of its own trackable’s parts leaves it (keptHolder); one that holds others is refused until they are moved. Anything else is refused and named.
  • Map and move, where the ticked entries come from one trackable and its values do not read the same in the target: a migration run on your own data (MapMove). Each of the target’s fields is set from one of theirs (starting from the field of the same name), from their note (which the field then takes), or to a fixed value (mappedValue in packages/core/src/move-map.ts, the one copy); an interval keeps the entry’s own times. A preview lists what each entry becomes, every one, scrolling where there are many, and every entry that would be refused is named beside it with its reason (refusalsOf, which checks as updateMany does and writes nothing); Move stays off until none would be. Values move as stored, in base units, so a distance mapped into a count of glasses is refused rather than converted.
  • Set a field, where every ticked entry is one trackable’s: one of its fields, or its own value, set to one value on all of them, entered with the widget its form uses (BulkSetDialog). Not a field worked out from the rest, an interval’s ends (Move does those), a hidden field, a position along an item or an item that carries an amount, which depend on the item each entry names. Everything else an entry holds is kept, an interval’s ends and an Inbox entry’s named fields among them, and the sign its old value had is dropped (withFieldSet in apps/renderer/src/lib/bulk-set.ts). A field across trackables is not offered: only their times are shared.
  • Delete them, asked once, and once more whether what they hold goes too.

Each is one batch in core, updateMany or removeMany in packages/core/src/entry-repository.ts, every row checked as a single edit is: where any would be refused, none is written. The refused get a screen of their own (RefusedPanel): each named with its reason, and three ways on: continue without them, back, or fix one in its own form, after which the rest are checked again without writing (refusalsOf). One Undo puts every row back as it was (restoreMany), parts inside their holder, and a Nothing a moved entry replaced in its span.

Derived trackables and tags

The table lists stored entries only. A derived trackable has none, so it never appears over all trackables, and its own Table tab says it is computed on read, with no stored entries, pointing to its Chart. A tag’s Table is the table over every trackable under it.

Speed

pnpm run db:bench (scripts/db/bench.ts) seeds a throwaway database and times the real entryRepo.query() (node:sqlite, the same engine the WebView uses):

rowspage, sort or filtercatalog item filter (json_extract)page at offset 10,000
62,430 (50,000 added to Chad’s year)about 9–15 ms19 ms28 ms
212,430 (200,000 added)about 30–70 ms63 ms56 ms

Measured 2026-10-14 on a Windows desktop; the record rows are Set entries, as Chad’s seed writes them.

The entry table has no secondary index, so these are full scans that grow linearly, and they stay imperceptible at the size a personal tracker reaches. Keeping every read in SQL, server-paged, is what holds them there.

Visualizations · Entry · Repository Seam · Inspect Without Leaving · Calendar Drill-down · Data Model