feat(gallery): sort by earliest post date across all posts (new default)
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s

The gallery's newest/oldest sort keys off image_record.effective_date =
COALESCE(primary post's post_date, created_at). The primary post is often the
repost/download the file came from, so the grid led with download dates rather
than when content was first posted (operator-flagged).

Add a second materialized sort key, earliest_post_date = MIN(post_date) across
ALL of an image's provenance posts (every post it appears in), else created_at —
the original publish date. Mirrors the effective_date pattern so the sort stays a
forward index scan.

- alembic 0071: add earliest_post_date + index (DESC, id DESC); backfill
  created_at baseline then MIN over image_provenance ⋈ post.
- importer: recompute earliest_post_date whenever a dated post is linked (MIN over
  the image's provenance, which now includes the just-added row).
- gallery_service: new sorts posted_new / posted_old key off earliest_post_date;
  cursor + year/month grouping follow the active column transparently.
- api: accept posted_new|posted_old; DEFAULT is now posted_new so the grid leads
  with original publish date. newest/oldest (effective_date) still available.
- frontend: sort dropdown gains "Newest/Oldest post date" (default Newest post
  date); existing effective-date sorts relabelled "Newest/Oldest added".
- tests: service test asserts posted_new/posted_old key off earliest_post_date;
  frontend default-sort omission test updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-07-01 10:46:09 -04:00
parent ccbb5cbc9e
commit c22f37d64d
9 changed files with 176 additions and 15 deletions
+7 -2
View File
@@ -44,7 +44,8 @@ def _parse_filters():
the image must match at least one tag from EACH group (groups ANDed).
- `tag_not` is a comma-separated exclude list (image must carry none).
`media` is image|video; `sort` is newest|oldest; `platform` selects one
`media` is image|video; `sort` is newest|oldest|posted_new|posted_old
(default posted_new); `platform` selects one
platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
(date_to is widened by a day so the whole day is covered by the service's
@@ -67,8 +68,12 @@ def _parse_filters():
artist_id = int(artist_id_raw) if artist_id_raw else None
media = request.args.get("media")
media_type = media if media in ("image", "video") else None
# newest/oldest key off effective_date (primary post / download); posted_new/
# posted_old off earliest_post_date (original publish across all posts). The
# default is posted_new so the grid leads with original publish date, not the
# download/repost the primary post points at (operator-flagged 2026-07-01).
sort = request.args.get("sort")
sort = sort if sort in ("newest", "oldest") else "newest"
sort = sort if sort in ("newest", "oldest", "posted_new", "posted_old") else "posted_new"
platform = request.args.get("platform") or None
untagged = request.args.get("untagged") in ("1", "true", "yes")
no_artist = request.args.get("no_artist") in ("1", "true", "yes")
+10
View File
@@ -97,6 +97,16 @@ class ImageRecord(Base):
effective_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Denormalized ORIGINAL-publish sort key (alembic 0071) = MIN(post_date)
# across ALL of the image's provenance posts, else created_at. effective_date
# above keys off the PRIMARY post (often the repost/download the file came
# from); this keys off the earliest publish across EVERY post the image
# appears in, so the gallery can sort by when content was first posted rather
# than when it was downloaded (operator-flagged 2026-07-01). Maintained by
# services/importer.py, recomputed whenever a dated post is linked.
earliest_post_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
+24 -2
View File
@@ -55,6 +55,25 @@ def _effective_date_col():
return ImageRecord.effective_date
# Sort key -> the materialized column the gallery orders + cursors on. Both are
# indexed (DESC, id DESC), so every sort is a forward index range scan.
# newest/oldest → effective_date (primary post's date, else download)
# posted_new/_old → earliest_post_date (earliest publish across ALL posts)
_SORT_COLUMNS = {
"newest": ImageRecord.effective_date,
"oldest": ImageRecord.effective_date,
"posted_new": ImageRecord.earliest_post_date,
"posted_old": ImageRecord.earliest_post_date,
}
_ASCENDING_SORTS = {"oldest", "posted_old"}
def _sort_column(sort: str):
"""The materialized date column a gallery sort orders/cursors on (falls back
to effective_date for any unknown sort)."""
return _SORT_COLUMNS.get(sort, ImageRecord.effective_date)
def _outer_join_primary_post(stmt: Select) -> Select:
"""LEFT JOIN Post on ImageRecord.primary_post_id so the COALESCE
above sees Post.post_date when available. Images without a post
@@ -406,7 +425,10 @@ class GalleryService:
tag_ids, post_id, artist_id, tag_or_groups, tag_exclude,
)
eff = _effective_date_col()
# eff is the ACTIVE sort column (effective_date or earliest_post_date);
# the cursor, ordering and year/month grouping all key off it, so the
# 'post date' sort paginates + buckets by original publish transparently.
eff = _sort_column(sort)
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt)
stmt = _apply_scope(
@@ -417,7 +439,7 @@ class GalleryService:
date_from=date_from, date_to=date_to,
)
descending = sort != "oldest"
descending = sort not in _ASCENDING_SORTS
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
# The cursor is just (last eff, last id); the request's sort
+19 -1
View File
@@ -17,7 +17,7 @@ from enum import StrEnum
from pathlib import Path
from PIL import Image
from sqlalchemy import select, update
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -1411,6 +1411,24 @@ class Importer:
# default (matches the old COALESCE(post_date, created_at) fallback).
if record.primary_post_id == post.id and post.post_date is not None:
record.effective_date = post.post_date
# earliest_post_date (alembic 0071) = MIN(post_date) across ALL of this
# image's provenance posts, not just the primary — so the gallery can
# sort by original publish rather than the download/repost the primary
# points at. Recompute from provenance whenever a dated post is linked;
# the provenance row for THIS post was committed above, so the MIN
# includes it. Leaves the created_at default when no linked post is dated.
if post.post_date is not None:
earliest = self.session.execute(
select(func.min(Post.post_date))
.select_from(ImageProvenance)
.join(Post, Post.id == ImageProvenance.post_id)
.where(
ImageProvenance.image_record_id == record.id,
Post.post_date.is_not(None),
)
).scalar_one_or_none()
if earliest is not None:
record.earliest_post_date = earliest
self.session.flush()
def _copy_to_library(