diff --git a/api/_utils/db.ts b/api/_utils/db.ts index 3739a80..a94ed46 100644 --- a/api/_utils/db.ts +++ b/api/_utils/db.ts @@ -83,6 +83,9 @@ const mediaSchema = new mongoose.Schema( // --- Indexes --- mediaSchema.index({ user_id: 1, last_updated: -1 }); mediaSchema.index({ user_id: 1, media_type: 1 }); +mediaSchema.index({ user_id: 1, status: 1, last_updated: -1 }); +mediaSchema.index({ user_id: 1, media_type: 1, status: 1, last_updated: -1 }); +mediaSchema.index({ user_id: 1, title: 1 }); // Auto-update last_updated field mediaSchema.pre("findOneAndUpdate", function (next) { diff --git a/api/media.ts b/api/media.ts index de74f91..244700c 100644 --- a/api/media.ts +++ b/api/media.ts @@ -227,6 +227,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { if (id && !isValidObjectId(id)) { return jsonError(res, "INVALID_ID", "Invalid ID format", 400); } + const userObjectId = new mongoose.Types.ObjectId(userId); switch (req.method) { case "GET": { @@ -244,7 +245,6 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { ); const skip = (page - 1) * limit; - const userObjectId = new mongoose.Types.ObjectId(userId); const match: Record = { user_id: userObjectId }; if (search) match.title = { $regex: escapeRegex(search), $options: "i" }; if (mediaType && isAllowedMediaType(mediaType)) match.media_type = mediaType; @@ -340,7 +340,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const result = await MediaItem.deleteMany({ _id: { $in: objectIds }, - user_id: userId, + user_id: userObjectId, }); return jsonOk(res, { deleted: Number(result.deletedCount || 0), @@ -394,7 +394,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } docs.push({ ...validated.normalized, - user_id: userId, + user_id: userObjectId, last_updated: new Date(), }); } @@ -426,7 +426,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { .replace(/\s+/g, " "); const normalizedType = String(validated.normalized.media_type || ""); const duplicate = await MediaItem.findOne({ - user_id: userId, + user_id: userObjectId, media_type: normalizedType, title: { $regex: `^${escapeRegex(normalizedTitle)}$`, @@ -437,7 +437,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { if (duplicate && duplicateMode !== "keep_both") { if (duplicateMode === "merge") { const merged = await MediaItem.findOneAndUpdate( - { _id: duplicate._id, user_id: userId }, + { _id: duplicate._id, user_id: userObjectId }, { ...validated.normalized, last_updated: new Date() }, { new: true }, ); @@ -456,7 +456,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const newItem = await MediaItem.create({ ...validated.normalized, - user_id: userId, + user_id: userObjectId, last_updated: new Date(), }); return jsonOk(res, newItem, 201); @@ -488,7 +488,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } const updated = await MediaItem.findOneAndUpdate( - { _id: id, user_id: userId }, + { _id: id, user_id: userObjectId }, validated.normalized, { new: true }, ); @@ -514,7 +514,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } const deleted = await MediaItem.findOneAndDelete({ _id: id, - user_id: userId, + user_id: userObjectId, }); if (!deleted) { return jsonError(res, "NOT_FOUND", "Not found", 404); diff --git a/docs/PERFORMANCE_REVIEW.md b/docs/PERFORMANCE_REVIEW.md new file mode 100644 index 0000000..d7cb751 --- /dev/null +++ b/docs/PERFORMANCE_REVIEW.md @@ -0,0 +1,96 @@ +# Performance Review + +Chronicle is a Vite/TypeScript single-page media tracker backed by Vercel API routes and MongoDB/Mongoose. The current architecture is straightforward and deployable, but several slow-production-system patterns showed up during review: UI actions sometimes update before the database confirms, repeated global refreshes hide latency instead of managing it, search has avoidable debounce delay plus an unindexed contains-regex path, and some actions provide weak progress feedback. + +## Issues + +- Problem: Card delete and +1 progress actions update the local UI before the API write completes. +- Cause: `src/features/media/cards.ts` performs optimistic remove/increment and then reverts on failure. +- Impact: If a tab closes, network drops, or the function times out, users can briefly see uncommitted data as real state. This violates the production requirement to avoid fake backend success. +- Fix: Keep the existing card visible, mark the specific action pending, wait for the API response, then apply the returned/confirmed state locally. + +- Problem: Analytics/global stats can stay stale after add/edit/delete/status changes. +- Cause: stats refresh is TTL-gated in `src/services/media.ts`, and mutation paths often update local media without forcing stats refresh. +- Impact: Users can complete/delete/add entries and see dashboard totals lag for up to 30 seconds. +- Fix: Add a mutation helper that refreshes stats after confirmed writes and call it from create/update/delete/bulk paths. + +- Problem: Search feels slower than necessary. +- Cause: the controls layer debounces input, then `setSearchTerm` adds a second debounce before fetching. +- Impact: Search waits roughly 550ms before the API request even starts, making normal typing feel sluggish. +- Fix: keep one debounce at the input boundary and make state-to-fetch immediate. + +- Problem: MongoDB list queries are under-indexed for common filters/sorts. +- Cause: the schema only indexes `{ user_id, last_updated }` and `{ user_id, media_type }`. +- Impact: filtered status/type lists and title sorts can scan/sort more documents as libraries grow. +- Fix: add compound indexes for `{ user_id, status, last_updated }`, `{ user_id, media_type, status, last_updated }`, and `{ user_id, title }`. + +- Problem: API list/export pages request `limit=500`, but the backend caps at 100. +- Cause: `fetchAllExportItems` asks for 500 while `/api/media` clamps to 100. +- Impact: The code is misleading and progress feedback is absent during large exports. +- Fix: request the real max page size and surface export progress/disabled button states. + +- Problem: Settings modal flickers with stale/empty values. +- Cause: the modal opens immediately, then fields update after `/api/user/settings` returns. +- Impact: Users can see stale settings and may try to interact before data is loaded. +- Fix: open in an explicit loading state, disable controls, then enable once current settings are loaded. + +- Problem: Loading skeletons are generic blocks. +- Cause: `renderMediaCards` renders plain rectangles instead of card-shaped placeholders. +- Impact: Perceived loading is worse and layout confidence is lower. +- Fix: render skeletons that mirror card poster/body/action structure. + +- Problem: Media covers load slowly across a full page of cards. +- Cause: the cover queue processes one item at a time with a long per-item delay. +- Impact: visible covers can trickle in slowly for normal 24-item pages. +- Fix: process covers in a small concurrency-limited batch while preserving cache and external API restraint. + +- Problem: Buttons can lose meaningful text while loading. +- Cause: several handlers replace button HTML with only a spinner. +- Impact: Users lose context and screen-reader feedback is weaker. +- Fix: keep action text such as `Saving...`, `Loading...`, `Exporting...`, `Updating...`, and `Deleting...` alongside the spinner. + +## Key Areas + +- Data fetching + - Keep one debounce for search. + - Avoid full refetches when a confirmed API response already returns the updated item. + - Force stats refresh only after confirmed mutations that affect dashboard counts or ratings. + +- API performance (Vercel functions) + - Mongo connection reuse is already in place. + - List endpoints should keep payloads paginated and indexed. + - Export should page predictably with the backend max limit. + +- Database queries (MongoDB) + - Add compound indexes for list/filter/sort access patterns. + - Avoid unbounded query params and keep pagination capped. + - Consider a future normalized title/search field or Atlas Search for large libraries. + +- UI/UX responsiveness + - Use per-action loading states so only the affected card or bulk action is disabled. + - Replace fake optimistic writes with confirmed-write updates. + - Improve skeletons and long-running export/import feedback. + +- State management + - Track pending card actions in the store. + - Keep local media updates, but only after API success. + - Refresh derived stats deterministically after writes. + +## Priority Fix Plan + +- High + - Remove optimistic card delete/increment behavior. + - Add per-action pending state and non-blank loading labels. + - Force stats refresh after confirmed create/edit/delete/status/rating mutations. + - Add MongoDB compound indexes for common filters and sorts. + +- Medium + - Remove double debounce in search. + - Improve settings loading state. + - Improve export button/progress feedback. + - Improve card skeletons. + +- Low + - Investigate bundle splitting for ExcelJS/export code. + - Add normalized search fields or Atlas Search if libraries become large. + - Add endpoint-level timing logs around list/export/stats routes. diff --git a/src/features/dashboard.ts b/src/features/dashboard.ts index 2722e84..36132a5 100644 --- a/src/features/dashboard.ts +++ b/src/features/dashboard.ts @@ -1,12 +1,8 @@ import { store } from "../state/store.js"; -import { fetchMedia } from "../services/media.js"; +import { fetchMedia, refreshStatsAfterMutation } from "../services/media.js"; import { apiFetch } from "../api/client.js"; import { showToast } from "../ui/toast.js"; -// ── Search debounce ────────────────────────────────────────────── -let searchTimer: ReturnType | null = null; -const SEARCH_DEBOUNCE_MS = 300; - export function toggleBulkMode(): void { store.set((prev) => ({ ...prev, @@ -29,28 +25,34 @@ export async function submitBulkStatus(status: string): Promise { const ids = Array.from(store.get().selectedIds); if (ids.length === 0) return showToast("No entries selected.", "error"); - const updates = await Promise.allSettled( - ids.map((id) => - apiFetch(`/media?id=${id}`, { - method: "PUT", - body: JSON.stringify({ status }), - }), - ), - ); - - const ok = updates.filter((r) => r.status === "fulfilled").length; - const fail = updates.length - ok; - showToast( - `Updated ${ok} entries${fail ? `, ${fail} failed` : ""}`, - ok > 0 ? "success" : "error", - ); - - store.set((prev) => ({ - ...prev, - selectedIds: new Set(), - bulkMode: false, - })); - await fetchMedia(true, true); + store.set((prev) => ({ ...prev, bulkAction: "status" })); + try { + const updates = await Promise.allSettled( + ids.map((id) => + apiFetch(`/media?id=${id}`, { + method: "PUT", + body: JSON.stringify({ status }), + }), + ), + ); + + const ok = updates.filter((r) => r.status === "fulfilled").length; + const fail = updates.length - ok; + showToast( + `Updated ${ok} entries${fail ? `, ${fail} failed` : ""}`, + ok > 0 ? "success" : "error", + ); + + store.set((prev) => ({ + ...prev, + selectedIds: new Set(), + bulkMode: false, + })); + await fetchMedia(true, true); + await refreshStatsAfterMutation(); + } finally { + store.set((prev) => ({ ...prev, bulkAction: "" })); + } } export async function submitBulkIncrement(): Promise { @@ -58,32 +60,37 @@ export async function submitBulkIncrement(): Promise { const ids = Array.from(state.selectedIds); if (ids.length === 0) return showToast("No entries selected.", "error"); - const updates = await Promise.allSettled( - ids.map((id) => { - const item = state.media.find((m) => m._id === id); - if (!item) return Promise.resolve(null); - return apiFetch(`/media?id=${id}`, { - method: "PUT", - body: JSON.stringify({ - progress_current: item.progress_current + 1, - }), - }); - }), - ); - - const ok = updates.filter((r) => r.status === "fulfilled").length; - const fail = updates.length - ok; - showToast( - `Incremented ${ok} entries${fail ? `, ${fail} failed` : ""}`, - ok > 0 ? "success" : "error", - ); - - store.set((prev) => ({ - ...prev, - selectedIds: new Set(), - bulkMode: false, - })); - await fetchMedia(true, true); + store.set((prev) => ({ ...prev, bulkAction: "increment" })); + try { + const updates = await Promise.allSettled( + ids.map((id) => { + const item = state.media.find((m) => m._id === id); + if (!item) return Promise.resolve(null); + return apiFetch(`/media?id=${id}`, { + method: "PUT", + body: JSON.stringify({ + progress_current: item.progress_current + 1, + }), + }); + }), + ); + + const ok = updates.filter((r) => r.status === "fulfilled").length; + const fail = updates.length - ok; + showToast( + `Incremented ${ok} entries${fail ? `, ${fail} failed` : ""}`, + ok > 0 ? "success" : "error", + ); + + store.set((prev) => ({ + ...prev, + selectedIds: new Set(), + bulkMode: false, + })); + await fetchMedia(true, true); + } finally { + store.set((prev) => ({ ...prev, bulkAction: "" })); + } } export async function submitBulkDelete(): Promise { @@ -93,46 +100,49 @@ export async function submitBulkDelete(): Promise { return false; } - let ok = 0; - let fail = 0; - const CHUNK = 500; - for (let i = 0; i < ids.length; i += CHUNK) { - const chunk = ids.slice(i, i + CHUNK); - try { - const res = (await apiFetch("/media?bulk_delete=1", { - method: "POST", - body: JSON.stringify({ ids: chunk }), - })) as { deleted?: number; requested?: number }; - ok += Number(res?.deleted || 0); - fail += Math.max( - 0, - Number(res?.requested || chunk.length) - Number(res?.deleted || 0), - ); - } catch { - fail += chunk.length; + store.set((prev) => ({ ...prev, bulkAction: "delete" })); + try { + let ok = 0; + let fail = 0; + const CHUNK = 500; + for (let i = 0; i < ids.length; i += CHUNK) { + const chunk = ids.slice(i, i + CHUNK); + try { + const res = (await apiFetch("/media?bulk_delete=1", { + method: "POST", + body: JSON.stringify({ ids: chunk }), + })) as { deleted?: number; requested?: number }; + ok += Number(res?.deleted || 0); + fail += Math.max( + 0, + Number(res?.requested || chunk.length) - Number(res?.deleted || 0), + ); + } catch { + fail += chunk.length; + } } - } - showToast( - `Deleted ${ok} entries${fail ? `, ${fail} failed` : ""}`, - ok > 0 ? "success" : "error", - ); - - store.set((prev) => ({ - ...prev, - selectedIds: new Set(), - bulkMode: false, - })); - await fetchMedia(true, true); - return true; + showToast( + `Deleted ${ok} entries${fail ? `, ${fail} failed` : ""}`, + ok > 0 ? "success" : "error", + ); + + store.set((prev) => ({ + ...prev, + selectedIds: new Set(), + bulkMode: false, + })); + await fetchMedia(true, true); + await refreshStatsAfterMutation(); + return true; + } finally { + store.set((prev) => ({ ...prev, bulkAction: "" })); + } } export function setSearchTerm(term: string): void { store.set((prev) => ({ ...prev, search: term })); - if (searchTimer) clearTimeout(searchTimer); - searchTimer = setTimeout(() => { - void fetchMedia(true); - }, SEARCH_DEBOUNCE_MS); + void fetchMedia(true); } export function setFilterSort( diff --git a/src/features/import-export/index.ts b/src/features/import-export/index.ts index 3a94c14..0fc1742 100644 --- a/src/features/import-export/index.ts +++ b/src/features/import-export/index.ts @@ -8,23 +8,34 @@ import { showToast } from "../../ui/toast.js"; import { apiFetch } from "../../api/client.js"; import { fetchMedia } from "../../services/media.js"; +type ExportProgress = { + fetched: number; + total?: number; +}; + // Export (fast + selectors ready) -async function fetchAllExportItems(mediaType?: string): Promise { +async function fetchAllExportItems( + mediaType?: string, + onProgress?: (progress: ExportProgress) => void, +): Promise { const items: MediaItem[] = []; let page = 1; let hasMore = true; while (hasMore) { const query = new URLSearchParams({ page: String(page), - limit: "500", + limit: "100", sort_by: "title", }); if (mediaType) query.set("media_type", mediaType); const payload = (await apiFetch(`/media?${query.toString()}`)) as - | { items?: MediaItem[]; has_more?: boolean } + | { items?: MediaItem[]; total?: number; has_more?: boolean } | MediaItem[]; const batch = Array.isArray(payload) ? payload : payload.items || []; items.push(...batch); + if (!Array.isArray(payload)) { + onProgress?.({ fetched: items.length, total: payload.total }); + } hasMore = Array.isArray(payload) ? false : Boolean(payload.has_more); page += 1; } @@ -55,8 +66,10 @@ function buildJsonPayload(items: MediaItem[]) { ); } -export async function exportJSON(): Promise { - const items = await fetchAllExportItems(); +export async function exportJSON( + onProgress?: (progress: ExportProgress) => void, +): Promise { + const items = await fetchAllExportItems(undefined, onProgress); const payload = buildJsonPayload(items); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json", @@ -99,8 +112,10 @@ export function exportCSV(items: MediaItem[], filename?: string): void { showToast(`Exported ${items.length} entries as CSV`, "success"); } -export async function exportAllCSV(): Promise { - const items = await fetchAllExportItems(); +export async function exportAllCSV( + onProgress?: (progress: ExportProgress) => void, +): Promise { + const items = await fetchAllExportItems(undefined, onProgress); exportCSV(items); } @@ -160,19 +175,34 @@ export function openExportTypeDialog(): void { newCancel.addEventListener("click", () => dialog.close()); newConfirm.addEventListener("click", async () => { + const originalText = newConfirm.textContent || "Export"; const mediaType = typeSelect.value; const format = formatSelect.value; - const scoped = await fetchAllExportItems(mediaType); - if (scoped.length === 0) { - showToast(`No ${mediaType} entries to export.`, "error"); - return; + newConfirm.setAttribute("disabled", "true"); + newCancel.setAttribute("disabled", "true"); + newConfirm.innerHTML = ` Exporting...`; + try { + const scoped = await fetchAllExportItems(mediaType, ({ fetched, total }) => { + newConfirm.innerHTML = ` Exporting ${fetched}${total ? `/${total}` : ""}`; + }); + if (scoped.length === 0) { + showToast(`No ${mediaType} entries to export.`, "error"); + return; + } + const stamp = dateStamp(); + const typeSlug = slugType(mediaType); + if (format === "csv") + exportCSV(scoped, `chronicle-${typeSlug}-${stamp}.csv`); + else await exportXLSX(scoped, `chronicle-${typeSlug}-${stamp}.xlsx`); + dialog.close(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to export."; + showToast(message, "error"); + } finally { + newConfirm.removeAttribute("disabled"); + newCancel.removeAttribute("disabled"); + newConfirm.textContent = originalText; } - const stamp = dateStamp(); - const typeSlug = slugType(mediaType); - if (format === "csv") - exportCSV(scoped, `chronicle-${typeSlug}-${stamp}.csv`); - else await exportXLSX(scoped, `chronicle-${typeSlug}-${stamp}.xlsx`); - dialog.close(); }); dialog.showModal(); } @@ -259,6 +289,7 @@ export function setupImportHandler(): void { showToast("No valid rows found in file.", "error"); return; } + showToast(`Importing ${entries.length} entries...`, "success"); let imported = 0; let skipped = 0; const CHUNK_SIZE = 100; diff --git a/src/features/media/cards.ts b/src/features/media/cards.ts index f64709d..1bfd178 100644 --- a/src/features/media/cards.ts +++ b/src/features/media/cards.ts @@ -4,7 +4,8 @@ import { updateMedia, deleteMedia, removeLocalMediaItem, - restoreLocalMediaItem, + upsertLocalMediaItem, + refreshStatsAfterMutation, } from "../../services/media.js"; import { showToast } from "../../ui/toast.js"; import { showConfirm } from "../../ui/modals.js"; @@ -42,60 +43,45 @@ async function handleDelete(id: string): Promise { "Delete entry?", `"${title}" will be permanently removed.`, async () => { - const removed = removeLocalMediaItem(id); + const pendingKey = `${id}:delete`; + if (store.get().pendingActionIds.has(pendingKey)) return; + store.updatePendingActionIds((set) => set.add(pendingKey)); try { await deleteMedia(id, false); + removeLocalMediaItem(id); + await refreshStatsAfterMutation(); showToast("Entry deleted", "success"); - } catch { - if (removed) restoreLocalMediaItem(removed); - showToast("Failed to delete. Please try again.", "error"); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to delete. Please try again."; + showToast(message, "error"); + } finally { + store.updatePendingActionIds((set) => set.delete(pendingKey)); } }, ); } async function handleIncrement(id: string): Promise { - const itemIndex = store.get().media.findIndex((m) => m._id === id); - if (itemIndex === -1) return; - - const currentItem = store.get().media[itemIndex]; - const oldProgress = currentItem.progress_current; - const oldUpdatedAt = currentItem.last_updated; - const nextUpdatedAt = new Date().toISOString(); - - // Optimistic update - store.set((prev) => ({ - ...prev, - mediaRev: prev.mediaRev + 1, - media: prev.media.map((m, i) => - i === itemIndex - ? { - ...m, - progress_current: m.progress_current + 1, - last_updated: nextUpdatedAt, - } - : m, - ), - })); + const currentItem = store.get().media.find((m) => m._id === id); + if (!currentItem) return; + const pendingKey = `${id}:increment`; + if (store.get().pendingActionIds.has(pendingKey)) return; + store.updatePendingActionIds((set) => set.add(pendingKey)); try { - await updateMedia(id, { progress_current: oldProgress + 1 }, false); - } catch { - // Revert - store.set((prev) => ({ - ...prev, - mediaRev: prev.mediaRev + 1, - media: prev.media.map((m, i) => - i === itemIndex - ? { - ...m, - progress_current: oldProgress, - last_updated: oldUpdatedAt, - } - : m, - ), - })); - showToast("Failed to update progress.", "error"); + const updated = await updateMedia( + id, + { progress_current: currentItem.progress_current + 1 }, + false, + ); + upsertLocalMediaItem(updated); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to update progress."; + showToast(message, "error"); + } finally { + store.updatePendingActionIds((set) => set.delete(pendingKey)); } } @@ -123,15 +109,18 @@ export function setupCardEventDelegation(): void { const incrementButton = target.closest(".btn-increment") as HTMLElement | null; if (editButton) { + if ((editButton as HTMLButtonElement).disabled) return; const id = editButton.getAttribute("data-id"); if (!id) return; const item = store.get().media.find((m) => m._id === id); if (item) openModal(item); } else if (deleteButton) { + if ((deleteButton as HTMLButtonElement).disabled) return; const id = deleteButton.getAttribute("data-id"); if (!id) return; await handleDelete(id); } else if (incrementButton) { + if ((incrementButton as HTMLButtonElement).disabled) return; const id = incrementButton.getAttribute("data-id"); if (!id) return; await handleIncrement(id); diff --git a/src/features/media/modal.ts b/src/features/media/modal.ts index 8b24920..cbf476a 100644 --- a/src/features/media/modal.ts +++ b/src/features/media/modal.ts @@ -7,6 +7,7 @@ import { createMedia, updateMedia, upsertLocalMediaItem, + refreshStatsAfterMutation, } from "../../services/media.js"; type ApiLikeError = { code?: string }; @@ -113,7 +114,7 @@ export function openModal(item?: MediaItem): void { } const originalText = newLookupBtn.textContent || "Lookup"; newLookupBtn.disabled = true; - newLookupBtn.innerHTML = ``; + newLookupBtn.innerHTML = ` Looking up...`; const result = await lookupMediaMeta(title, typeInput.value); if (!result) { @@ -206,7 +207,7 @@ export function setupMediaFormHandler(): void { const originalText = saveBtn.textContent; saveBtn.disabled = true; - saveBtn.innerHTML = ``; + saveBtn.innerHTML = ` Saving...`; const createEntry = async (mode?: "merge" | "keep_both") => createMedia(data, mode); @@ -215,10 +216,12 @@ export function setupMediaFormHandler(): void { if (id) { const updated = await updateMedia(id, data, false); upsertLocalMediaItem(updated); + await refreshStatsAfterMutation(); } else { try { const created = await createEntry(); upsertLocalMediaItem(created); + await refreshStatsAfterMutation(); } catch (err) { if (isApiLikeError(err) && err.code === "DUPLICATE_TITLE") { saveBtn.disabled = false; @@ -230,6 +233,7 @@ export function setupMediaFormHandler(): void { try { const merged = await createEntry("merge"); upsertLocalMediaItem(merged); + await refreshStatsAfterMutation(); ( document.getElementById("media-modal") as HTMLDialogElement ).close(); @@ -245,6 +249,7 @@ export function setupMediaFormHandler(): void { try { const created = await createEntry("keep_both"); upsertLocalMediaItem(created); + await refreshStatsAfterMutation(); ( document.getElementById("media-modal") as HTMLDialogElement ).close(); diff --git a/src/features/settings.ts b/src/features/settings.ts index c570cae..e2f6d2b 100644 --- a/src/features/settings.ts +++ b/src/features/settings.ts @@ -13,6 +13,30 @@ function syncNotificationState( } } +function setSettingsFormLoading(loading: boolean): void { + const inputId = document.getElementById( + "settings-telegram-id", + ) as HTMLInputElement | null; + const inputNotifications = document.getElementById( + "settings-notifications", + ) as HTMLInputElement | null; + const btnSave = document.getElementById( + "settings-save", + ) as HTMLButtonElement | null; + + if (inputId) { + inputId.disabled = loading; + inputId.placeholder = loading ? "Loading settings..." : "Enter your numeric chat ID"; + } + if (inputNotifications) inputNotifications.disabled = loading; + if (btnSave) { + btnSave.disabled = loading; + btnSave.innerHTML = loading + ? ` Loading...` + : "Save changes"; + } +} + export function setupSettingsGlobalHandlers() { const modal = document.getElementById("settings-modal") as HTMLDialogElement; const form = document.getElementById("settings-form") as HTMLFormElement; @@ -39,7 +63,7 @@ export function setupSettingsGlobalHandlers() { if (btnSave.disabled) return; btnSave.disabled = true; - btnSave.textContent = "Saving..."; + btnSave.innerHTML = ` Saving...`; try { const payload = { @@ -79,12 +103,11 @@ export function attachSettingsButtonListener() { const notificationState = document.getElementById( "settings-notifications-state", ) as HTMLSpanElement | null; - const btnSave = document.getElementById( - "settings-save", - ) as HTMLButtonElement; - - modal.showModal(); // Show immediately! - btnSave.disabled = true; + inputId.value = ""; + inputNotifications.checked = false; + syncNotificationState(inputNotifications, notificationState); + setSettingsFormLoading(true); + modal.showModal(); try { const data = (await apiFetch("/user/settings")) as { @@ -98,7 +121,7 @@ export function attachSettingsButtonListener() { const message = e instanceof Error ? e.message : "Failed to load settings"; showToast(message, "error"); } finally { - btnSave.disabled = false; + setSettingsFormLoading(false); } }); } diff --git a/src/services/media.ts b/src/services/media.ts index 4c573a8..c236139 100644 --- a/src/services/media.ts +++ b/src/services/media.ts @@ -46,7 +46,10 @@ export function restoreLocalMediaItem(item: MediaItem): void { async function refreshStatsIfStale(force = false): Promise { const now = Date.now(); if (!force && now - lastStatsFetchAt < STATS_REFRESH_TTL_MS) return; - if (statsInFlight) return statsInFlight; + if (statsInFlight) { + await statsInFlight; + if (!force) return; + } statsInFlight = (async () => { try { @@ -65,6 +68,10 @@ async function refreshStatsIfStale(force = false): Promise { return statsInFlight; } +export async function refreshStatsAfterMutation(): Promise { + await refreshStatsIfStale(true); +} + export async function fetchMedia( reset = true, background = false, diff --git a/src/state/core.ts b/src/state/core.ts index 1ab188b..2333d53 100644 --- a/src/state/core.ts +++ b/src/state/core.ts @@ -28,7 +28,9 @@ export interface AppState { hasMore: boolean; total: number; bulkMode: boolean; + bulkAction: "" | "status" | "increment" | "delete"; selectedIds: Set; + pendingActionIds: Set; globalStats: GlobalStats | null; } @@ -50,7 +52,9 @@ export function createInitialState(overrides?: Partial): AppState { hasMore: false, total: 0, bulkMode: false, + bulkAction: "", selectedIds: new Set(), + pendingActionIds: new Set(), globalStats: null, ...overrides, }; @@ -210,4 +214,13 @@ export class Store { return { ...prev, selectedIds: newSet }; }); } + + /** Helper for per-item pending actions. */ + updatePendingActionIds(updater: (set: Set) => void): void { + this.set((prev) => { + const newSet = new Set(prev.pendingActionIds); + updater(newSet); + return { ...prev, pendingActionIds: newSet }; + }); + } } diff --git a/src/state/store.ts b/src/state/store.ts index 609030f..3a3caf5 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -15,6 +15,8 @@ const COVER_CACHE_KEY = "chronicle:cover-cache:v3"; const COVER_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7; const COVER_CACHE_NULL_TTL_MS = 1000 * 60 * 30; const COVER_CACHE_MAX = 600; +const COVER_FETCH_CONCURRENCY = 2; +const COVER_FETCH_BATCH_DELAY_MS = 450; export const coverCache = new Map(); export let coverQueue: { title: string; id: string; mangadexId?: string }[] = @@ -132,21 +134,27 @@ export async function processCoverQueue(): Promise { if (coverProcessing || coverQueue.length === 0) return; coverProcessing = true; while (coverQueue.length > 0) { - const { title, id, mangadexId } = coverQueue.shift()!; - const cacheKey = mangadexId ? `md-${mangadexId}` : title; - if (getCachedCover(cacheKey) !== undefined) continue; - try { - const imageUrl = mangadexId - ? await fetchMangadexCover(mangadexId) - : await fetchAnimeCover(title); - setCachedCover(cacheKey, imageUrl); - if (imageUrl) { - applyCoverToThumb(id, imageUrl); - } - } catch { - setCachedCover(cacheKey, null); + const batch = coverQueue.splice(0, COVER_FETCH_CONCURRENCY); + await Promise.all( + batch.map(async ({ title, id, mangadexId }) => { + const cacheKey = mangadexId ? `md-${mangadexId}` : title; + if (getCachedCover(cacheKey) !== undefined) return; + try { + const imageUrl = mangadexId + ? await fetchMangadexCover(mangadexId) + : await fetchAnimeCover(title); + setCachedCover(cacheKey, imageUrl); + if (imageUrl) { + applyCoverToThumb(id, imageUrl); + } + } catch { + setCachedCover(cacheKey, null); + } + }), + ); + if (coverQueue.length > 0) { + await new Promise((r) => setTimeout(r, COVER_FETCH_BATCH_DELAY_MS)); } - await new Promise((r) => setTimeout(r, mangadexId ? 250 : 1100)); } coverProcessing = false; flushCoverCache(); diff --git a/src/style.css b/src/style.css index b02725a..4d877dd 100644 --- a/src/style.css +++ b/src/style.css @@ -585,6 +585,28 @@ button:disabled { box-shadow: none; } +.spinner { + width: 1em; + height: 1em; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 999px; + display: inline-block; + flex-shrink: 0; + vertical-align: -0.15em; + animation: spin 0.75s linear infinite; +} + +button .spinner { + margin-right: 6px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + .btn-primary { background: var(--accent); color: var(--accent-text); @@ -2048,6 +2070,44 @@ dialog::backdrop { .skeleton > * { opacity: 0; } +.skeleton-card { + pointer-events: none; +} +.skeleton-card .card-poster, +.skeleton-card .card-body, +.skeleton-card .card-actions { + opacity: 1; +} +.skeleton-card .skeleton { + border-color: transparent; +} +.skeleton-line { + height: 12px; + border-radius: var(--radius-pill); +} +.skeleton-line-xs { + width: 92px; +} +.skeleton-line-sm { + width: 132px; +} +.skeleton-line-md { + width: 168px; +} +.skeleton-line-lg { + width: min(260px, 100%); + height: 20px; +} +.skeleton-progress { + height: 10px; + border-radius: var(--radius-pill); + margin-top: 14px; +} +.skeleton-button { + flex: 1; + min-height: 38px; + border-radius: var(--radius-sm); +} @keyframes skeleton-loading { 0% { background-position: 200% 0; diff --git a/src/ui/components/bulkBar.ts b/src/ui/components/bulkBar.ts index e5585a3..5491006 100644 --- a/src/ui/components/bulkBar.ts +++ b/src/ui/components/bulkBar.ts @@ -17,22 +17,29 @@ export function renderBulkBar(): void { container.innerHTML = ""; return; } + const isBusy = Boolean(state.bulkAction); + const statusLabel = + state.bulkAction === "status" ? ` Applying...` : "Apply Status"; + const incrementLabel = + state.bulkAction === "increment" ? ` Updating...` : "+1 Progress"; + const deleteLabel = + state.bulkAction === "delete" ? ` Deleting...` : "Delete Selected"; container.innerHTML = `
${state.selectedIds.size} selected - - - - - - + + +
`; diff --git a/src/ui/components/mediaCards.ts b/src/ui/components/mediaCards.ts index aadae3b..04c7654 100644 --- a/src/ui/components/mediaCards.ts +++ b/src/ui/components/mediaCards.ts @@ -37,6 +37,7 @@ function renderCard( m: MediaItem, selectedSet: ReadonlySet, bulkMode: boolean, + pendingActionIds: ReadonlySet, ): string { const pct = m.progress_total ? Math.min(100, Math.round((m.progress_current / m.progress_total) * 100)) @@ -47,6 +48,9 @@ function renderCard( m.status === "Watching/Reading" && daysSince(m.last_updated) >= 14; const staleClass = isStale ? " card-stale" : ""; const isSelected = selectedSet.has(m._id); + const isIncrementing = pendingActionIds.has(`${m._id}:increment`); + const isDeleting = pendingActionIds.has(`${m._id}:delete`); + const hasPendingAction = isIncrementing || isDeleting; const coverUrl = getCoverUrl(m); const thumbStyle = coverUrl ? `background-image:url('${coverUrl}')` : ""; const thumbClass = coverUrl ? "card-thumb thumb-loaded" : "card-thumb"; @@ -117,26 +121,34 @@ function renderCard(
- - -
@@ -158,7 +170,25 @@ export function renderMediaCards(): void { .fill(0) .map( (_, i) => - `
`, + `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`, ) .join(""); container.innerHTML = skels; @@ -168,6 +198,7 @@ export function renderMediaCards(): void { } const selectedSet = state.selectedIds; + const pendingActionIds = state.pendingActionIds; if (filteredMedia.length === 0) { const hasFilters = state.search || state.filterType || state.filterStatus; @@ -199,7 +230,7 @@ export function renderMediaCards(): void { const nextIds = new Set(); for (const m of filteredMedia) { - const cardMarkup = renderCard(m, selectedSet, state.bulkMode); + const cardMarkup = renderCard(m, selectedSet, state.bulkMode, pendingActionIds); const nextHash = [ m.title, m.media_type, @@ -212,6 +243,8 @@ export function renderMediaCards(): void { m.last_updated, state.bulkMode ? "1" : "0", selectedSet.has(m._id) ? "1" : "0", + pendingActionIds.has(`${m._id}:increment`) ? "inc" : "", + pendingActionIds.has(`${m._id}:delete`) ? "del" : "", ].join("|"); const prevHash = cardRenderHashById.get(m._id); @@ -239,7 +272,7 @@ export function renderMediaCards(): void { loadMoreBtn.style.display = state.hasMore ? "inline-flex" : "none"; loadMoreBtn.disabled = state.loadingMore; loadMoreBtn.innerHTML = state.loadingMore - ? `` + ? ` Loading more...` : "Load more"; loadMoreBtn.onclick = async () => { if (state.loadingMore || !state.hasMore) return; diff --git a/src/ui/renderApp.ts b/src/ui/renderApp.ts index b8c9490..960c6ea 100644 --- a/src/ui/renderApp.ts +++ b/src/ui/renderApp.ts @@ -22,6 +22,22 @@ function setDisplay(id: string, value: string): void { if (el) el.style.display = value; } +async function runButtonTask( + button: HTMLButtonElement, + loadingLabel: string, + task: () => Promise, +): Promise { + const original = button.innerHTML; + button.disabled = true; + button.innerHTML = ` ${loadingLabel}`; + try { + await task(); + } finally { + button.disabled = false; + button.innerHTML = original; + } +} + export function renderApp(): void { const app = document.getElementById("app")!; const state = store.get(); @@ -91,7 +107,9 @@ function renderAuthScreen(app: HTMLElement): void { buttons.forEach((b) => { b.disabled = true; if (b.getAttribute("data-action") === action) - b.innerHTML = ``; + b.innerHTML = ` ${ + action === "register" ? "Registering..." : "Logging in..." + }`; }); errorEl.textContent = ""; @@ -314,9 +332,15 @@ function renderDashboard(app: HTMLElement): void { .getElementById("btn-export-json") ?.addEventListener("click", async () => { document.getElementById("export-menu")?.classList.remove("open"); - showToast("Exporting JSON... Please wait.", "success"); + const button = document.getElementById( + "btn-export-json", + ) as HTMLButtonElement; try { - await exportJSON(); + await runButtonTask(button, "Exporting...", async () => { + await exportJSON(({ fetched, total }) => { + button.innerHTML = ` Exporting ${fetched}${total ? `/${total}` : ""}`; + }); + }); } catch { showToast("Failed to export JSON.", "error"); } @@ -325,9 +349,15 @@ function renderDashboard(app: HTMLElement): void { .getElementById("btn-export-csv") ?.addEventListener("click", async () => { document.getElementById("export-menu")?.classList.remove("open"); - showToast("Exporting CSV... Please wait.", "success"); + const button = document.getElementById( + "btn-export-csv", + ) as HTMLButtonElement; try { - await exportAllCSV(); + await runButtonTask(button, "Exporting...", async () => { + await exportAllCSV(({ fetched, total }) => { + button.innerHTML = ` Exporting ${fetched}${total ? `/${total}` : ""}`; + }); + }); } catch { showToast("Failed to export CSV.", "error"); } diff --git a/src/ui/root.ts b/src/ui/root.ts index a6887ca..cb37327 100644 --- a/src/ui/root.ts +++ b/src/ui/root.ts @@ -23,6 +23,7 @@ export function mountApp(): void { store.subscribe( (state) => ({ bulkMode: state.bulkMode, + bulkAction: state.bulkAction, selectedIds: Array.from(state.selectedIds), }), () => renderBulkBar(), @@ -50,6 +51,7 @@ export function mountApp(): void { loading: state.loading, loadingMore: state.loadingMore, hasMore: state.hasMore, + pendingActionIds: Array.from(state.pendingActionIds), }), () => renderMediaCards(), );