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:
114
frontend/src/lib/api/read_progress.test.ts
Normal file
114
frontend/src/lib/api/read_progress.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
106
frontend/src/lib/api/read_progress.ts
Normal file
106
frontend/src/lib/api/read_progress.ts
Normal 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' }
|
||||
);
|
||||
}
|
||||
79
frontend/src/lib/api/uploads.test.ts
Normal file
79
frontend/src/lib/api/uploads.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
42
frontend/src/lib/api/uploads.ts
Normal file
42
frontend/src/lib/api/uploads.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user