feat: read & upload history (0.19.0)

Per-user reading progress and uploader attribution.

Schema (migration 0011): `read_progress` table (one row per (user,
manga); chapter_id nullable on chapter delete) and nullable
`uploaded_by` columns on mangas + chapters with partial indexes
scoped to non-null rows.

Endpoints (all `/me/*`, auth-scoped):
- PUT `/v1/me/read-progress` upserts. FK violations + cross-manga
  chapter ids both surface as 4xx (404 / 422) so the API can't be
  used to write logically invalid rows.
- GET `/v1/me/read-progress` paged newest-first list.
- GET `/v1/me/read-progress/:manga_id` enriched with chapter_number
  for the manga page's Continue CTA.
- DELETE `/v1/me/read-progress/:manga_id` idempotent.
- GET `/v1/me/uploads` interleaved manga + chapter uploads as a
  tagged union; limit-only pagination.

Existing manga + chapter upload handlers stamp `uploaded_by`.

Frontend:
- Reader emits progress on mount + page change (debounce) and via
  IntersectionObserver in continuous mode. High-water mark is seeded
  from the persisted server value so re-opening a chapter doesn't
  regress to page 1. Tab close survives via `sendBeacon` (fallback
  `keepalive` fetch); SPA navigation flushes via regular fetch.
- Manga detail page shows "Continue reading Chapter N — page M"
  above the chapters list, working even for mangas with >50
  chapters.
- New `/profile/history` tab with reading history (clear-per-row,
  inline error on failure) and uploads (mangas + chapters mixed
  chronologically with type-aware rendering).

171 backend tests (incl. 16 history tests covering ownership, FK
race, cross-link guard, chapter SET NULL behaviour) and 97 frontend
tests + svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 18:19:52 +02:00
parent 7560d59616
commit 19c1276490
31 changed files with 1927 additions and 17 deletions

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import {
updateReadProgress,
listMyReadProgress,
listMyReadProgressOrEmpty,
getMyReadProgressForManga,
clearReadProgress
} from './read_progress';
function ok(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
describe('read_progress api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('updateReadProgress PUTs to /v1/me/read-progress', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
user_id: 'u1',
manga_id: 'm1',
chapter_id: 'c1',
page: 5,
updated_at: '2026-05-17T12:00:00Z'
})
);
const r = await updateReadProgress({ manga_id: 'm1', chapter_id: 'c1', page: 5 });
expect(r.page).toBe(5);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body as string)).toEqual({
manga_id: 'm1',
chapter_id: 'c1',
page: 5
});
});
it('listMyReadProgress returns the paged envelope', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
);
const r = await listMyReadProgress();
expect(r.items).toEqual([]);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/me\/read-progress$/);
});
it('listMyReadProgressOrEmpty returns empty page on 401', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
const r = await listMyReadProgressOrEmpty();
expect(r.items).toEqual([]);
});
it('getMyReadProgressForManga returns null on 404 (not yet read)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'no progress'));
const r = await getMyReadProgressForManga('m1');
expect(r).toBeNull();
});
it('getMyReadProgressForManga returns null on 401 (guest)', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login'));
const r = await getMyReadProgressForManga('m1');
expect(r).toBeNull();
});
it('getMyReadProgressForManga returns the row with chapter_number when present', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
manga_id: 'm1',
chapter_id: 'c1',
chapter_number: 7,
page: 3,
updated_at: '2026-05-17T12:00:00Z'
})
);
const r = await getMyReadProgressForManga('m1');
expect(r?.chapter_id).toBe('c1');
expect(r?.chapter_number).toBe(7);
expect(r?.page).toBe(3);
});
it('clearReadProgress DELETEs the resource', async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await clearReadProgress('m1');
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('DELETE');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/me\/read-progress\/m1$/);
});
});

View File

@@ -0,0 +1,106 @@
import { ApiError, request, type Page } from './client';
export type ReadProgress = {
user_id: string;
manga_id: string;
chapter_id: string | null;
page: number;
updated_at: string;
};
export type ReadProgressSummary = {
manga_id: string;
manga_title: string;
manga_cover_image_path: string | null;
chapter_id: string | null;
/** `null` if the chapter was deleted after the progress was written. */
chapter_number: number | null;
page: number;
updated_at: string;
};
export type ReadProgressPage = {
items: ReadProgressSummary[];
page: Page;
};
export type UpsertReadProgress = {
manga_id: string;
chapter_id?: string | null;
page?: number | null;
};
export async function updateReadProgress(
input: UpsertReadProgress
): Promise<ReadProgress> {
return request<ReadProgress>('/v1/me/read-progress', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input)
});
}
export async function listMyReadProgress(
opts: { limit?: number; offset?: number } = {}
): Promise<ReadProgressPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<ReadProgressPage>(
`/v1/me/read-progress${qs ? `?${qs}` : ''}`
);
}
export async function listMyReadProgressOrEmpty(): Promise<ReadProgressPage> {
try {
return await listMyReadProgress();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { items: [], page: { limit: 50, offset: 0, total: null } };
}
throw e;
}
}
/**
* Single-manga response shape returned by GET /me/read-progress/:id.
* Includes `chapter_number` so the "Continue reading" CTA can render
* without resolving the chapter id against a paged chapters list.
*/
export type ReadProgressForManga = {
manga_id: string;
chapter_id: string | null;
/** `null` if the chapter was deleted after the progress was written. */
chapter_number: number | null;
page: number;
updated_at: string;
};
/**
* Returns the user's progress for a specific manga, or `null` when
* they've never opened it (or aren't signed in). Used by the manga
* detail page's "Continue from Ch. N" CTA and by the reader to seed
* its session-local high-water mark from the persisted value.
*/
export async function getMyReadProgressForManga(
mangaId: string
): Promise<ReadProgressForManga | null> {
try {
return await request<ReadProgressForManga>(
`/v1/me/read-progress/${encodeURIComponent(mangaId)}`
);
} catch (e) {
if (e instanceof ApiError && (e.status === 404 || e.status === 401)) {
return null;
}
throw e;
}
}
export async function clearReadProgress(mangaId: string): Promise<void> {
await request<void>(
`/v1/me/read-progress/${encodeURIComponent(mangaId)}`,
{ method: 'DELETE' }
);
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { listMyUploads, listMyUploadsOrEmpty } from './uploads';
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
function envelope(status: number, code: string, message: string): Response {
return new Response(JSON.stringify({ error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' }
});
}
describe('uploads api client', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('listMyUploads returns the discriminated union of entries', async () => {
fetchSpy.mockResolvedValueOnce(
ok({
items: [
{
kind: 'manga',
manga: {
id: 'm1',
title: 'A',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-05-17T12:00:00Z',
updated_at: '2026-05-17T12:00:00Z'
},
created_at: '2026-05-17T12:00:00Z'
},
{
kind: 'chapter',
manga_id: 'm1',
manga_title: 'A',
manga_cover_image_path: null,
chapter: {
id: 'c1',
manga_id: 'm1',
number: 1,
title: null,
page_count: 3,
created_at: '2026-05-17T13:00:00Z'
},
created_at: '2026-05-17T13:00:00Z'
}
],
page: { limit: 50, offset: 0, total: 2 }
})
);
const r = await listMyUploads();
expect(r.items[0].kind).toBe('manga');
expect(r.items[1].kind).toBe('chapter');
// Discriminant pattern-match (compile-time check via the union).
if (r.items[1].kind === 'chapter') {
expect(r.items[1].chapter.number).toBe(1);
}
});
it('listMyUploadsOrEmpty returns empty page on 401', async () => {
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
const r = await listMyUploadsOrEmpty();
expect(r.items).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
import { ApiError, request, type Manga, type Page } from './client';
import type { Chapter } from './chapters';
/**
* Tagged union returned by `GET /v1/me/uploads`. The discriminant lives
* on the `kind` field; pattern-match on it before accessing the rest.
*/
export type UploadEntry =
| { kind: 'manga'; manga: Manga; created_at: string }
| {
kind: 'chapter';
manga_id: string;
manga_title: string;
manga_cover_image_path: string | null;
chapter: Chapter;
created_at: string;
};
export type UploadsPage = {
items: UploadEntry[];
page: Page;
};
export async function listMyUploads(
opts: { limit?: number } = {}
): Promise<UploadsPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
const qs = params.toString();
return request<UploadsPage>(`/v1/me/uploads${qs ? `?${qs}` : ''}`);
}
export async function listMyUploadsOrEmpty(): Promise<UploadsPage> {
try {
return await listMyUploads();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
return { items: [], page: { limit: 50, offset: 0, total: null } };
}
throw e;
}
}