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
@@ -142,9 +142,13 @@ const tagStore = useTagStore()
const api = useApi()
const router = useRouter()
// posted_* sort by earliest publish across ALL of an image's posts (original
// post date); newest/oldest sort by the primary post's date, else download date.
const SORTS = [
{ title: 'Newest first', value: 'newest' },
{ title: 'Oldest first', value: 'oldest' },
{ title: 'Newest post date', value: 'posted_new' },
{ title: 'Oldest post date', value: 'posted_old' },
{ title: 'Newest added', value: 'newest' },
{ title: 'Oldest added', value: 'oldest' },
]
const selected = ref(null)
@@ -175,7 +179,7 @@ const hasActiveFilters = computed(() =>
store.filter.tag_or.length > 0 ||
store.filter.artist_id != null ||
store.filter.media_type != null ||
store.filter.sort !== 'newest' ||
store.filter.sort !== 'posted_new' ||
store.filter.similar_to != null ||
hasRefineFilters.value
)
+4 -4
View File
@@ -23,7 +23,7 @@ export const useGalleryStore = defineStore('gallery', () => {
const error = ref(null)
const filter = ref({
tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: null,
sort: 'posted_new', post_id: null,
// #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
// "include" singletons (light editor + back-compat); tag_or is a list of
// OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
@@ -154,7 +154,7 @@ export const useGalleryStore = defineStore('gallery', () => {
if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.join(',')
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
if (filter.value.media_type) p.media = filter.value.media_type
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
if (filter.value.sort && filter.value.sort !== 'posted_new') p.sort = filter.value.sort
if (filter.value.platform) p.platform = filter.value.platform
if (filter.value.untagged) p.untagged = '1'
if (filter.value.no_artist) p.no_artist = '1'
@@ -191,7 +191,7 @@ export const useGalleryStore = defineStore('gallery', () => {
tag_exclude: _parseIds(q.tag_not),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
sort: ['newest', 'oldest', 'posted_new', 'posted_old'].includes(q.sort) ? q.sort : 'posted_new',
post_id: _toId(q.post_id),
platform: q.platform || null,
untagged: _truthy(q.untagged),
@@ -297,7 +297,7 @@ export function filterToQuery(f) {
if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
if (f.artist_id) q.artist_id = String(f.artist_id)
if (f.media_type) q.media = f.media_type
if (f.sort && f.sort !== 'newest') q.sort = f.sort
if (f.sort && f.sort !== 'posted_new') q.sort = f.sort
if (f.platform) q.platform = f.platform
if (f.untagged) q.untagged = '1'
if (f.no_artist) q.no_artist = '1'