fix(content): show selected tier on cards while downloads are in flight
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) <noreply@anthropic.com>
This commit is contained in:
parent
6e5284e563
commit
059cf2afbe
|
|
@ -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<Set<string>> {
|
||||
const ids = new Set<string>()
|
||||
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<string, InstalledResource>,
|
||||
inFlightIds: Set<string>,
|
||||
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<CollectionWithStatus[]> {
|
||||
|
|
|
|||
|
|
@ -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<CategoryCardProps> = ({ 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 (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col bg-desert-green rounded-lg p-6 text-white border shadow-sm hover:shadow-lg transition-shadow cursor-pointer h-80',
|
||||
selectedTier ? 'border-lime-400 border-2' : 'border-desert-green'
|
||||
badgeTier ? 'border-lime-400 border-2' : 'border-desert-green'
|
||||
)}
|
||||
onClick={() => onClick?.(category)}
|
||||
>
|
||||
|
|
@ -46,10 +66,17 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
|
|||
<DynamicIcon icon={category.icon as DynamicIconName} className="w-6 h-6 mr-2" />
|
||||
<h3 className="text-lg font-semibold">{category.name}</h3>
|
||||
</div>
|
||||
{selectedTier ? (
|
||||
{badgeTier ? (
|
||||
<div className="flex items-center">
|
||||
<IconCircleCheck className="w-5 h-5 text-lime-400" />
|
||||
<span className="text-lime-400 text-sm ml-1">{selectedTier.name}</span>
|
||||
{badgeStatus === 'downloading' ? (
|
||||
<IconLoader2 className="w-5 h-5 text-lime-400 animate-spin" />
|
||||
) : (
|
||||
<IconCircleCheck className="w-5 h-5 text-lime-400" />
|
||||
)}
|
||||
<span className="text-lime-400 text-sm ml-1">
|
||||
{badgeTier.name}
|
||||
{badgeStatus === 'downloading' && ' (downloading)'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<IconChevronRight className="w-5 h-5 text-white opacity-70" />
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
Loading…
Reference in New Issue