aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-03-03 22:54:56 -0800
committerFuwn <[email protected]>2026-03-03 22:54:56 -0800
commit07a00c3ea35b4df7eb23275704dd26f842db76be (patch)
tree097ee52433779830aa4ddd6666e85ed42878cfc9 /src/lib
parentrefactor(effect): migrate svelte json hotspots to typed decoders (diff)
downloaddue.moe-07a00c3ea35b4df7eb23275704dd26f842db76be.tar.xz
due.moe-07a00c3ea35b4df7eb23275704dd26f842db76be.zip
fix(anime): align due cover rendering with due classificationHEADmain
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/List/Anime/rendering.test.ts96
-rw-r--r--src/lib/List/Anime/rendering.ts19
-rw-r--r--src/lib/List/CleanGrid.svelte5
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts13
-rw-r--r--src/lib/Media/Anime/Airing/classify.test.ts94
5 files changed, 225 insertions, 2 deletions
diff --git a/src/lib/List/Anime/rendering.test.ts b/src/lib/List/Anime/rendering.test.ts
new file mode 100644
index 00000000..038e3729
--- /dev/null
+++ b/src/lib/List/Anime/rendering.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import type { Media } from "$lib/Data/AniList/media";
+import { shouldRenderAnimeCover } from "$lib/List/Anime/rendering";
+
+const baseMedia = (id: number): Media =>
+ ({
+ id,
+ idMal: id,
+ status: "RELEASING",
+ type: "ANIME",
+ episodes: 12,
+ chapters: 0,
+ volumes: 0,
+ duration: 24,
+ format: "TV",
+ title: {
+ romaji: `Fixture Show ${id}`,
+ english: `Fixture Show ${id}`,
+ native: `Fixture Show ${id}`,
+ },
+ nextAiringEpisode: {
+ episode: 9,
+ nativeEpisode: 10,
+ airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
+ nativeAiringAt: Math.floor(Date.now() / 1000) + 36 * 60 * 60,
+ },
+ synonyms: [],
+ mediaListEntry: {
+ progress: 8,
+ progressVolumes: 0,
+ status: "CURRENT",
+ score: 0,
+ repeat: 0,
+ startedAt: {
+ year: 2026,
+ month: 1,
+ day: 1,
+ },
+ completedAt: {
+ year: 0,
+ month: 0,
+ day: 0,
+ },
+ createdAt: 0,
+ updatedAt: 0,
+ customLists: {},
+ },
+ startDate: {
+ year: 2026,
+ month: 1,
+ },
+ endDate: {
+ year: 2026,
+ month: 12,
+ },
+ coverImage: {
+ extraLarge: "https://example.com/cover-xl.jpg",
+ medium: "https://example.com/cover-md.jpg",
+ },
+ tags: [],
+ genres: [],
+ season: "WINTER",
+ isAdult: false,
+ relations: {
+ edges: [],
+ },
+ }) as Media;
+
+describe("anime cover rendering", () => {
+ it("renders due media when nativeEpisode indicates user is behind", () => {
+ const media = baseMedia(194028);
+
+ expect(
+ shouldRenderAnimeCover(media, {
+ upcoming: false,
+ notYetReleased: false,
+ }),
+ ).toBe(true);
+ });
+
+ it("hides caught-up releasing media outside upcoming lists", () => {
+ const media = baseMedia(194028);
+
+ media.mediaListEntry = {
+ ...(media.mediaListEntry as NonNullable<typeof media.mediaListEntry>),
+ progress: 9,
+ };
+
+ expect(
+ shouldRenderAnimeCover(media, {
+ upcoming: false,
+ notYetReleased: false,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/src/lib/List/Anime/rendering.ts b/src/lib/List/Anime/rendering.ts
new file mode 100644
index 00000000..8a79d74c
--- /dev/null
+++ b/src/lib/List/Anime/rendering.ts
@@ -0,0 +1,19 @@
+import type { Media } from "$lib/Data/AniList/media";
+import { hasDueEpisodes } from "$lib/Media/Anime/Airing/classify";
+
+interface AnimeCoverRenderOptions {
+ upcoming: boolean;
+ notYetReleased: boolean;
+}
+
+export const shouldRenderAnimeCover = (
+ media: Media,
+ options: AnimeCoverRenderOptions,
+): boolean => {
+ const progress = media.mediaListEntry?.progress || 0;
+
+ if (options.upcoming || options.notYetReleased) return true;
+ if (media.status === "FINISHED") return progress !== media.episodes;
+
+ return hasDueEpisodes(media);
+};
diff --git a/src/lib/List/CleanGrid.svelte b/src/lib/List/CleanGrid.svelte
index 006a5e4e..384d4940 100644
--- a/src/lib/List/CleanGrid.svelte
+++ b/src/lib/List/CleanGrid.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Media } from "$lib/Data/AniList/media";
import ParallaxImage from "$lib/Image/ParallaxImage.svelte";
+import { shouldRenderAnimeCover } from "$lib/List/Anime/rendering";
import { outboundLink } from "$lib/Media/links";
import LinkedTooltip from "$lib/Tooltip/LinkedTooltip.svelte";
import settings from "$stores/settings";
@@ -30,7 +31,9 @@ $: processedMedia =
{@const progress = (title.mediaListEntry || { progress: 0 }).progress}
{@const isAboveFold = index < 6}
- {#if type === 'anime' ? upcoming || notYetReleased || progress !== (title.nextAiringEpisode?.episode || 9999) - 1 : progress != title.episodes}
+ {#if type === 'anime'
+ ? shouldRenderAnimeCover(title, { upcoming, notYetReleased })
+ : progress != title.episodes}
<div class="cover-card" id={`${type}-${title.id}-${uniqueID}`}>
<LinkedTooltip
pin={`${type}-${title.id}-${uniqueID}`}
diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts
index 0c91f3ac..40dda4b8 100644
--- a/src/lib/Media/Anime/Airing/Subtitled/match.ts
+++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts
@@ -530,6 +530,9 @@ const buildInjectAiringTimeCacheKey = (
[
anime.id,
anime.status,
+ anime.mediaListEntry?.status || "",
+ anime.mediaListEntry?.progress || 0,
+ anime.mediaListEntry?.updatedAt || 0,
anime.nextAiringEpisode?.episode || 0,
anime.nextAiringEpisode?.airingAt || 0,
displayNativeCountdown ? 1 : 0,
@@ -540,6 +543,14 @@ const buildInjectAiringTimeCacheKey = (
const cloneInjectedMedia = (media: Media): Media =>
({
...media,
+ mediaListEntry: media.mediaListEntry
+ ? {
+ ...media.mediaListEntry,
+ startedAt: { ...media.mediaListEntry.startedAt },
+ completedAt: { ...media.mediaListEntry.completedAt },
+ customLists: { ...media.mediaListEntry.customLists },
+ }
+ : undefined,
nextAiringEpisode: media.nextAiringEpisode
? { ...media.nextAiringEpisode }
: undefined,
@@ -634,3 +645,5 @@ export const injectAiringTime = (
return cloneInjectedMedia(cachedValue);
};
+
+export const clearInjectAiringTimeCache = () => injectAiringTimeCache.clear();
diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts
index fb7f2c8f..b05be83b 100644
--- a/src/lib/Media/Anime/Airing/classify.test.ts
+++ b/src/lib/Media/Anime/Airing/classify.test.ts
@@ -6,7 +6,10 @@ import {
hasDueEpisodes,
getAnimeEpisodeState,
} from "$lib/Media/Anime/Airing/classify";
-import { injectAiringTime } from "$lib/Media/Anime/Airing/Subtitled/match";
+import {
+ clearInjectAiringTimeCache,
+ injectAiringTime,
+} from "$lib/Media/Anime/Airing/Subtitled/match";
import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease";
const toScheduleTime = (epochSeconds: number) => {
@@ -196,4 +199,93 @@ describe("injectAiringTime cache safety", () => {
expect(typeof second.nextAiringEpisode?.airingAt).toBe("number");
expect(typeof second.nextAiringEpisode?.nativeAiringAt).toBe("number");
});
+
+ it("does not reuse stale progress across cache hits for media 194028", () => {
+ clearInjectAiringTimeCache();
+
+ const media = baseMedia(194028);
+ const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
+ const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
+ const nativeAiringDate = new Date(nativeAiringAt * 1000);
+ const airingDay = nativeAiringDate.toLocaleString("en-US", {
+ weekday: "long",
+ });
+ const subsPlease = {
+ tz: "America/Los_Angeles",
+ schedule: {
+ [airingDay]: [
+ {
+ title: media.title.romaji,
+ page: "",
+ image_url: "",
+ time: toScheduleTime(subtitledAiringAt),
+ },
+ ],
+ },
+ } as unknown as SubsPlease;
+
+ media.nextAiringEpisode = {
+ episode: 10,
+ airingAt: nativeAiringAt,
+ };
+
+ settings.setKey("displayNativeCountdown", false);
+
+ const caughtUp = {
+ ...media,
+ mediaListEntry: {
+ ...(media.mediaListEntry || {}),
+ progress: 9,
+ },
+ } as Media;
+ const behind = {
+ ...media,
+ mediaListEntry: {
+ ...(media.mediaListEntry || {}),
+ progress: 8,
+ },
+ } as Media;
+ const cachedCaughtUp = injectAiringTime(caughtUp, subsPlease);
+ const updatedBehind = injectAiringTime(behind, subsPlease);
+
+ expect(hasDueEpisodes(cachedCaughtUp)).toBe(false);
+ expect(hasDueEpisodes(updatedBehind)).toBe(true);
+ });
+
+ it("does not let caller mutate cached mediaListEntry progress", () => {
+ clearInjectAiringTimeCache();
+
+ const media = baseMedia(194028);
+ const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
+ const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
+ const nativeAiringDate = new Date(nativeAiringAt * 1000);
+ const airingDay = nativeAiringDate.toLocaleString("en-US", {
+ weekday: "long",
+ });
+ const subsPlease = {
+ tz: "America/Los_Angeles",
+ schedule: {
+ [airingDay]: [
+ {
+ title: media.title.romaji,
+ page: "",
+ image_url: "",
+ time: toScheduleTime(subtitledAiringAt),
+ },
+ ],
+ },
+ } as unknown as SubsPlease;
+
+ settings.setKey("displayNativeCountdown", false);
+
+ const originalProgress = media.mediaListEntry?.progress || 0;
+ const first = injectAiringTime(media, subsPlease);
+
+ if (first.mediaListEntry) first.mediaListEntry.progress = 999;
+
+ const second = injectAiringTime(media, subsPlease);
+
+ expect(media.mediaListEntry?.progress).toBe(originalProgress);
+ expect(second.mediaListEntry?.progress).toBe(originalProgress);
+ });
});