From f83c55194bcae5e2bfce8d23978da4f0e465d518 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Tue, 19 May 2026 11:44:17 -0700 Subject: [PATCH] fix(content): show selected tier on cards while downloads are in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since PR #36b6d8e moved tier-installation tracking from a client-side persistence model to a server-side derive-from-disk model, the card display only ever updates once every file in a tier is fully on disk. A user who picks Standard sees a blank card for the duration of the download (often hours for large tiers like Wikibooks). Worse, if some files finish before others, the card briefly shows a lower tier (e.g. Essential) before promoting to the selected tier on completion, which reads as "the system didn't accept my pick." Backend: compute a sibling `downloadingTierSlug` by unioning installed resource IDs with the IDs from active RunDownloadJob queue entries (waiting + active + delayed, failed deliberately excluded), then resolving the highest tier whose every resource is in that union. Set only when it differs from `installedTierSlug` — no point reporting "downloading Standard" when Standard is already fully installed. Frontend: unify the prominent corner badge logic in CategoryCard to a single `badgeTier` derived from selectedTier > downloadingTier > installedTier. Spinner + "(downloading)" suffix when in flight, checkmark for installed/selected. The pill row and lime border follow the same source. Verified on NOMAD3: backend correctly resolves the downloading tier from in-flight BullMQ jobs; CategoryCard shows the spinner badge immediately on Submit and switches to the checkmark variant when downloads complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/collection_manifest_service.ts | 74 ++++++++++++++++++- admin/inertia/components/CategoryCard.tsx | 41 ++++++++-- admin/types/collections.ts | 5 ++ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/admin/app/services/collection_manifest_service.ts b/admin/app/services/collection_manifest_service.ts index bc69368..2479f82 100644 --- a/admin/app/services/collection_manifest_service.ts +++ b/admin/app/services/collection_manifest_service.ts @@ -5,6 +5,8 @@ import { DateTime } from 'luxon' import { join } from 'path' import CollectionManifest from '#models/collection_manifest' import InstalledResource from '#models/installed_resource' +import { QueueService } from './queue_service.js' +import { RunDownloadJob } from '#jobs/run_download_job' import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections' import { ensureDirectoryExists, @@ -98,10 +100,74 @@ export class CollectionManifestService { const installedResources = await InstalledResource.query().where('resource_type', 'zim') const installedMap = new Map(installedResources.map((r) => [r.resource_id, r])) - return spec.categories.map((category) => ({ - ...category, - installedTierSlug: this.getInstalledTierForCategory(category.tiers, installedMap), - })) + // In-flight ZIM download resource IDs from the BullMQ queue. Used to + // surface the user's tier intent immediately on submit, before any single + // file has finished downloading. Failed jobs are excluded so a stuck + // queue entry doesn't keep claiming the user's pick forever. + const inFlightIds = await this.getInFlightZimResourceIds() + + return spec.categories.map((category) => { + const installedTierSlug = this.getInstalledTierForCategory(category.tiers, installedMap) + const downloadingTierSlug = this.getDownloadingTierForCategory( + category.tiers, + installedMap, + inFlightIds, + installedTierSlug + ) + return { ...category, installedTierSlug, downloadingTierSlug } + }) + } + + private async getInFlightZimResourceIds(): Promise> { + const ids = new Set() + try { + const queue = QueueService.getInstance().getQueue(RunDownloadJob.queue) + const jobs = await queue.getJobs(['waiting', 'active', 'delayed']) + for (const job of jobs) { + if (job.data?.filetype !== 'zim') continue + const resourceId = job.data?.resourceMetadata?.resource_id + if (typeof resourceId === 'string') ids.add(resourceId) + } + } catch (error) { + // Don't fail the whole categories endpoint if the queue is briefly + // unreachable — just report no in-flight downloads. + logger.warn('[CollectionManifestService] Could not read download queue:', error?.message || error) + } + return ids + } + + /** + * Highest tier whose every resource is installed OR has an in-flight + * download. Returns undefined when there are no in-flight downloads for this + * category, or when the result would just duplicate installedTierSlug (i.e. + * everything that's downloading is already installed — nothing new to show). + */ + getDownloadingTierForCategory( + tiers: SpecTier[], + installedMap: Map, + inFlightIds: Set, + installedTierSlug: string | undefined + ): string | undefined { + if (inFlightIds.size === 0) return undefined + + // Cheap pre-check: any of this category's resources actually in flight? + const anyInFlight = tiers.some((tier) => + CollectionManifestService.resolveTierResources(tier, tiers).some((r) => inFlightIds.has(r.id)) + ) + if (!anyInFlight) return undefined + + const reversedTiers = [...tiers].reverse() + for (const tier of reversedTiers) { + const resolved = CollectionManifestService.resolveTierResources(tier, tiers) + if (resolved.length === 0) continue + const allAccountedFor = resolved.every( + (r) => installedMap.has(r.id) || inFlightIds.has(r.id) + ) + if (allAccountedFor) { + return tier.slug === installedTierSlug ? undefined : tier.slug + } + } + return undefined } async getMapCollectionsWithStatus(): Promise { diff --git a/admin/inertia/components/CategoryCard.tsx b/admin/inertia/components/CategoryCard.tsx index ae60b4c..6b63b4d 100644 --- a/admin/inertia/components/CategoryCard.tsx +++ b/admin/inertia/components/CategoryCard.tsx @@ -2,7 +2,7 @@ import { formatBytes } from '~/lib/util' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import type { CategoryWithStatus, SpecTier } from '../../types/collections' import classNames from 'classnames' -import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react' +import { IconChevronRight, IconCircleCheck, IconLoader2 } from '@tabler/icons-react' export interface CategoryCardProps { category: CategoryWithStatus @@ -29,14 +29,34 @@ const CategoryCard: React.FC = ({ category, selectedTier, onC const minSize = getTierTotalSize(category.tiers[0], category.tiers) const maxSize = getTierTotalSize(category.tiers[category.tiers.length - 1], category.tiers) - // Determine which tier to highlight: selectedTier (wizard) > installedTierSlug (persisted) - const highlightedTierSlug = selectedTier?.slug || category.installedTierSlug + // Priority order for the prominent corner badge + lime border: + // 1. selectedTier — in-session wizard pick (highest priority, reflects + // what the user is editing right now) + // 2. downloadingTierSlug — backend-derived from in-flight downloads, so + // the card shows the user's intent immediately after Submit, before + // any single file has finished downloading + // 3. installedTierSlug — fully on disk + const downloadingTier = !selectedTier && category.downloadingTierSlug + ? category.tiers.find((t) => t.slug === category.downloadingTierSlug) + : null + const installedTier = !selectedTier && !downloadingTier && category.installedTierSlug + ? category.tiers.find((t) => t.slug === category.installedTierSlug) + : null + const badgeTier = selectedTier || downloadingTier || installedTier + const badgeStatus: 'selected' | 'downloading' | 'installed' | null = selectedTier + ? 'selected' + : downloadingTier + ? 'downloading' + : installedTier + ? 'installed' + : null + const highlightedTierSlug = badgeTier?.slug return (
onClick?.(category)} > @@ -46,10 +66,17 @@ const CategoryCard: React.FC = ({ category, selectedTier, onC

{category.name}

- {selectedTier ? ( + {badgeTier ? (
- - {selectedTier.name} + {badgeStatus === 'downloading' ? ( + + ) : ( + + )} + + {badgeTier.name} + {badgeStatus === 'downloading' && ' (downloading)'} +
) : ( diff --git a/admin/types/collections.ts b/admin/types/collections.ts index abd47fc..b8fbdb4 100644 --- a/admin/types/collections.ts +++ b/admin/types/collections.ts @@ -64,6 +64,11 @@ export type ResourceStatus = 'installed' | 'not_installed' | 'update_available' export type CategoryWithStatus = SpecCategory & { installedTierSlug?: string + // Highest tier whose every resource is either installed OR has an in-flight + // download. Set only when it differs from installedTierSlug — i.e. the user + // picked something larger and downloads are still running. Lets the UI show + // the user's actual intent during the (often long) download window. + downloadingTierSlug?: string } export type CollectionWithStatus = SpecCollection & {