Skip to content

Tables tab: a real Modified date, default sort and range filter - #2585

Merged
jh-RLI merged 2 commits into
developfrom
feature-2557-modified-date
Oct 2, 2026
Merged

jh-RLI merged 2 commits into
developfrom
feature-2557-modified-date

Conversation

@jh-RLI

@jh-RLI jh-RLI commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Closes #2557 (slice 5 of #2551).

What it does

Two stamps per Table. data_modified and metadata_modified are nullable, never auto_now, and are set explicitly by the write that changes them:

write half where
Apply of the Edit Journal (only when something was applied) data api.actions.apply_changes
Bulk Upload success data bulk upload view, success branch
column add / alter (alter only when a statement ran) data column_add, column_alter
queued column / constraint change data table_change_column, table_change_constraint
every metadata write, incl. table creation, tag edits, a finished review metadata set_table_metadata, in the same save as publishable

The data half goes through one helper, Table.stamp_data_modified(): one UPDATE of one field, with no save(). Publish, unpublish, embargo and role changes stamp neither half, and each case is tested. Both halves use the application's clock (timezone.now()), not the database's now(). Postgres's now() is the start of the surrounding transaction, and the two halves are compared by GREATEST.

The column. It shows the later of the two halves, with the exact timestamp on hover. A muted "data" beneath the date marks a value known only from the data half ("Last data change. Metadata edits are recorded since v1.11.0."). "–" means nothing was recorded. Both notes are also visually hidden text for screen readers. The column is placed in the stacked row too.

Default sort -modified, title, pk, with unknown dates last in both directions (Sort(..., nulls_last=True)). The bare URL now means this sort, so the title sort is written into the URL as ?sort=table. Tests that assumed the interim title default were updated. test_default_is_displayed_title_case_insensitive_then_pk would now pass only by accident (every row ties on an unknown Modified), so it now asks for sort=table explicitly.

Range filter, generic. login/listing.py gains RangeFilter: two URL parameters (<name>_from, <name>_to; ISO dates; either end optional; both inclusive), one control with two date inputs, and one chip that removes both ends. It counts once in "More filters (n)" and "Filters (n)". To make this possible, each filter now names its own URL params. That keeps Listing.params, chip removal and Reset generic. dates_within(expression, alias) filters on the local day the list shows, so 00:30 on 3 Oct in Berlin counts as 3 Oct. An unknown date matches no range, even an open one. #2559's Created filter can reuse RangeFilter with its own apply for the "before Nov 2025" rule. A date that cannot be parsed is ignored and dropped from the URL, the same as an unparseable page number. The date inputs apply on change delay:600ms, so typing a year digit by digit sends one request.

Migration dataedit.0056_table_modified: two ADD COLUMN ... timestamp with time zone NULL, no DEFAULT, no UPDATE (checked with sqlmigrate). The deploy step is in the deploy checklist.

Measured

  • Query budget is unchanged: 7 per request at 4, 130 and 300 Tables, including under the Modified sort, and 12 with every filter including the range (QueryCountTests, FilterQueryCountTests).

  • Column thresholds were re-measured with benchmarks/tables_tab/widths.mjs on the real row. seed.py now seeds a Modified mix: 15 % unknown, 45 % data only, 40 % both. Modified is 111 px wide; its header ("Modified ↓") is wider than any date.

    DejaVu Segoe UI threshold (was)
    every column 1,133 1,033 1,160 (1,050)
    without Topics 1,024 933 1,045 (930)
    also without Review, Datasets 812 747 830 (720)
    filter bar on one line 958 902 960 (960)

    At no screen width does the table scroll sideways. The 90–126 px page overflow at 1024 px is the known navbar user menu, the same as in Tables tab: columns collapse by the list's own width, stacked rows on small screens #2555.

Browser check (headless Chrome 152, seeded p90/max accounts)

  • The bare URL lists rows newest first with unknowns last. The header toggles to ?sort=modified (oldest first, unknowns still last), and Back restores the previous state.
  • "Sort by" offers "Modified: oldest first / newest first", with the latter selected by default.
  • Setting the from and to dates: four quick changes send one request, the URL gains both ends in order, the count goes 130 → 67 → 12, and "More filters (1)" and "Filters (1)" both show.
  • One chip reads "Modified: 1 Jan 2026 – 30 Jun 2026". Removing it empties both inputs and keeps the search, and focus moves to the list heading.
  • No new console errors. The known reverseApiBaseUrl redeclaration on history restore predates this PR.
  • Headless-shell does not take keystrokes into a date input, so the inputs were set the way a date picker sets them (value plus change).

For you to decide

  1. At 1440 px, Review and Datasets now drop. With Modified the row needs 1,024 px without Topics, and the list is 976 px there. Keeping them needs about 48 px trimmed from the row; the Table column's 13rem minimum is the largest candidate. This is the design call Tables tab: columns collapse by the list's own width, stacked rows on small screens #2555 predicted. I have not made it.
  2. MODIFIED_RECORDED_SINCE = "v1.11.0" in login/tables_tab.py is a guess at the release this ships in (v1.10.1 is already tagged). The deploy checklist says to check it before tagging.
  3. "One helper": the data half has one (stamp_data_modified). The metadata half is set directly in set_table_metadata's existing save, as the session brief asked: no second save. Each half is written in exactly one place.
  4. The queued column/constraint path is unreachable over HTTP. apply_queued_column raises before it reaches the change, because get_column_changes reads api_columns.exception, which does not exist (The table change queue is non-functional end to end, and applying a queued change has no permission check #2490). Its two writers are stamped and tested by calling them directly. Found on the way, not fixed: table_change_column's alter branch also crashes for every existing column, because it does "NO" in is_nullable and describe_columns returns a bool. Only its ADD branch works.

Tests

  • api/tests/test_modification_stamps.py (18): each choke point stamps its half; a refused metadata write, a failed bulk upload, a failed queued change and an alter naming nothing stamp nothing; publish, publish with embargo, unpublish and a role grant stamp nothing. It uses its own table name, so it cannot collide with a parallel session's test_table.
  • login/tests/test_tables_modified.py (32): column notes, default sort and tiebreaks, both directions, every range case including the local-day boundary, chips, URL, counts, controls, and row publish/unpublish stamping nothing.
  • Vitest: 3 new cases (date inputs follow the region, chip removal empties both ends, one end keeps the other).
  • Full Django suite: 1,250 run, green apart from LightImportTest, which fails only under the isolated-settings trick and passes alone. Vitest: 161 green.

🤖 Generated with Claude Code

jh-RLI and others added 2 commits October 2, 2026 21:37
Each Table records when its content last changed, in two nullable
halves stamped explicitly (never auto_now): data_modified by
Table.stamp_data_modified() at Apply of the Edit Journal, Bulk Upload
success and column/constraint DDL; metadata_modified by
api.actions.set_table_metadata in the same save that stores the Publish
gate verdict (table creation and a finished review included). Publish,
unpublish, embargo and role changes stamp neither. Both halves use the
application's clock, so GREATEST compares like with like. Migration
dataedit.0056: two ADD COLUMN ... NULL, no default, no update
(checked with sqlmigrate).

The tables tab shows the later half in a Modified column, "data" beneath
it when only the data half is known, "–" when neither is, each with its
note on hover and as text for screen readers. It becomes the default
sort (-modified, title, pk), unknowns last both ways, so the bare URL
now means that and the title sort is written as ?sort=table.

login/listing.py gains a generic RangeFilter: two URL parameters
(<name>_from, <name>_to, ISO dates, either end optional, inclusive), one
control, one chip that removes both ends, one in "Filters (n)". Each
filter now names its own URL params, so Listing.params, the chips and
Reset stay generic. dates_within() filters on the local day the list
shows; an unknown date matches no range. The Modified range sits behind
"More filters". Query budget unchanged: 7 per request at every account
size, 12 with every filter.

Column thresholds re-measured with the Modified column (111 px):
1,160 / 1,045 / 830 (were 1,050 / 930 / 720). At a 1440 px screen
Review and Datasets now drop too; recorded beside the container queries.

Closes #2557

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jh-RLI jh-RLI self-assigned this Oct 2, 2026
@jh-RLI
jh-RLI merged commit 32f3c44 into develop Oct 2, 2026
4 of 5 checks passed
@jh-RLI
jh-RLI deleted the feature-2557-modified-date branch October 2, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tables tab: a real Modified date (data and metadata), default sort and range filter

1 participant