A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refine Trakt preview and dashboard glance

Limit Trakt preview fetching to a recent history window and
remove the maxItems and sourceCount API fields.

Simplify onboarding preview messaging and import actions so
the preview consistently leads into the full import flow.

Extract a richer At a glance dashboard card with summary
stats, streaks, trend indicators, and reusable chart logic.

+369 -331
+2 -9
apps/mobile/app/onboarding.tsx
··· 428 428 setImportProgress((previous) => ({ 429 429 ...previous, 430 430 phase: "preview_ready", 431 - totalItems: fetched.importableCount, 432 - message: 433 - fetched.importableCount > 0 434 - ? `Preview ready for @${fetched.profile.username}` 435 - : `No importable items found for @${fetched.profile.username}`, 431 + message: `Preview ready for @${fetched.profile.username}`, 436 432 })); 437 - if (!fetched.importableCount) { 438 - showToast("No supported watch history items found", "info"); 439 - } 440 433 } catch (error) { 441 434 const message = 442 435 error instanceof Error ··· 452 445 }; 453 446 454 447 const handleConfirmTraktImport = async () => { 455 - if (!traktPreview || traktPreview.importableCount < 1) { 448 + if (!traktPreview) { 456 449 return; 457 450 } 458 451
+4 -31
apps/mobile/components/onboarding/OnboardingStepCards.tsx
··· 444 444 > 445 445 {importProgress.message} 446 446 </Text> 447 - {importProgress.phase === "preview_ready" && traktPreview ? ( 447 + {importProgress.phase !== "preview_ready" ? ( 448 448 <Text 449 449 style={[ 450 450 styles.importStatusMeta, 451 451 { color: colors.onSurfaceVariant }, 452 452 ]} 453 453 > 454 - {isTraktImportQueued 455 - ? `Preview loaded from ${traktQueuedImport.sourcePreviewCount} recent Trakt history rows. The full import will keep running in the background.` 456 - : `${traktPreview.importableCount} importable items found from ${traktPreview.sourceCount} Trakt history rows.`} 454 + Preparing data for import... 457 455 </Text> 458 456 ) : null} 459 457 </View> ··· 629 627 : ""} 630 628 </Text> 631 629 </View> 632 - <View style={styles.previewCountWrap}> 633 - <Text 634 - style={[ 635 - styles.previewCountLabel, 636 - { color: colors.onSurfaceVariant }, 637 - ]} 638 - > 639 - {isTraktImportQueued 640 - ? "Background import" 641 - : "Ready to import"} 642 - </Text> 643 - <Text 644 - style={[ 645 - styles.previewCountValue, 646 - { color: colors.primary }, 647 - ]} 648 - > 649 - {isTraktImportQueued 650 - ? "Queued" 651 - : traktPreview.importableCount} 652 - </Text> 653 - </View> 654 630 </View> 655 631 656 632 {isTraktImportQueued ? ( ··· 731 707 {!isTraktImportQueued ? ( 732 708 <Button 733 709 onPress={onTraktImportConfirm} 734 - disabled={ 735 - isImportBusy || traktPreview.importableCount < 1 736 - } 710 + disabled={isImportBusy} 737 711 > 738 - Import {traktPreview.importableCount} item 739 - {traktPreview.importableCount === 1 ? "" : "s"} 712 + Start import 740 713 </Button> 741 714 ) : null} 742 715 </View>
+324
apps/web/src/components/home/AtAGlanceCard.tsx
··· 1 + import type { 2 + ShelfActivityBucketDto, 3 + ShelfActivitySummaryDto, 4 + } from "@opnshelf/api"; 5 + import { Flame, TrendingUp } from "lucide-react"; 6 + import { useMemo, useState } from "react"; 7 + import { M3Button } from "@/components/ui/m3-button"; 8 + import { 9 + M3Card, 10 + M3CardContent, 11 + M3CardHeader, 12 + M3CardTitle, 13 + } from "@/components/ui/m3-card"; 14 + 15 + type DashboardRange = "week" | "month"; 16 + 17 + interface AtAGlanceCardProps { 18 + activitySummary: ShelfActivitySummaryDto | undefined; 19 + } 20 + 21 + export function AtAGlanceCard({ activitySummary }: AtAGlanceCardProps) { 22 + const [range, setRange] = useState<DashboardRange>("week"); 23 + 24 + const bars = useMemo( 25 + () => buildActivityBars(activitySummary?.dailyActivity, range), 26 + [activitySummary?.dailyActivity, range], 27 + ); 28 + 29 + const stats = useMemo( 30 + () => deriveStats(activitySummary, range), 31 + [activitySummary, range], 32 + ); 33 + 34 + const maxValue = Math.max(...bars.map((b) => b.value), 1); 35 + 36 + return ( 37 + <M3Card 38 + variant="elevated" 39 + className="h-full rounded-xl border" 40 + style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 41 + > 42 + <M3CardHeader className="flex-row items-center justify-between gap-3"> 43 + <M3CardTitle className="md-title-large">At a glance</M3CardTitle> 44 + <div 45 + className="inline-flex gap-1 rounded-full border p-0.5" 46 + style={{ 47 + backgroundColor: "var(--md-sys-color-surface-container)", 48 + borderColor: "var(--md-sys-color-outline-variant)", 49 + }} 50 + > 51 + <M3Button 52 + size="xs" 53 + variant={range === "week" ? "filled-tonal" : "text"} 54 + className="min-w-16 rounded-full" 55 + onClick={() => setRange("week")} 56 + > 57 + Week 58 + </M3Button> 59 + <M3Button 60 + size="xs" 61 + variant={range === "month" ? "filled-tonal" : "text"} 62 + className="min-w-16 rounded-full" 63 + onClick={() => setRange("month")} 64 + > 65 + Month 66 + </M3Button> 67 + </div> 68 + </M3CardHeader> 69 + 70 + <M3CardContent className="space-y-4"> 71 + {/* --- Stats strip --- */} 72 + <div className="grid grid-cols-3 gap-2"> 73 + <StatTile label="Watched" value={stats.watched} /> 74 + <StatTile label="Daily avg" value={stats.dailyAvg} /> 75 + <StatTile 76 + label="Streak" 77 + value={stats.streak} 78 + suffix="d" 79 + icon={ 80 + stats.streak > 0 ? ( 81 + <Flame className="size-3.5 text-(--md-sys-color-tertiary)" /> 82 + ) : undefined 83 + } 84 + /> 85 + </div> 86 + 87 + {/* --- Activity chart --- */} 88 + <div 89 + className="rounded-xl p-3" 90 + style={{ 91 + backgroundColor: "var(--md-sys-color-surface-container)", 92 + }} 93 + > 94 + <div className="mb-2 flex items-baseline justify-between"> 95 + <p className="text-xs font-medium text-(--md-sys-color-on-surface-variant)"> 96 + {range === "week" ? "Last 7 days" : "Past 30 days"} 97 + </p> 98 + {stats.trend !== 0 && ( 99 + <span className="flex items-center gap-1 text-xs text-(--md-sys-color-on-surface-variant)"> 100 + <TrendingUp 101 + className={`size-3 ${stats.trend < 0 ? "rotate-180" : ""}`} 102 + /> 103 + {stats.trend > 0 ? "+" : ""} 104 + {stats.trend}% 105 + </span> 106 + )} 107 + </div> 108 + 109 + {/* Chart area */} 110 + <div> 111 + {/* Bars */} 112 + <div className="relative flex gap-px" style={{ height: "6rem" }}> 113 + {/* Average line */} 114 + {stats.dailyAvgRaw > 0 && ( 115 + <div 116 + className="pointer-events-none absolute right-0 left-0 border-t border-dashed" 117 + style={{ 118 + borderColor: "var(--md-sys-color-outline)", 119 + bottom: `${(stats.dailyAvgRaw / maxValue) * 100}%`, 120 + }} 121 + /> 122 + )} 123 + {bars.map((bar) => { 124 + const pct = 125 + bar.value > 0 126 + ? Math.max((bar.value / maxValue) * 100, 14) 127 + : 4; 128 + 129 + return ( 130 + <div 131 + key={bar.key} 132 + className="group/bar relative flex flex-1 items-end justify-center" 133 + > 134 + <div 135 + className={`w-full rounded-t transition-opacity duration-150 group-hover/bar:opacity-100 ${bar.isToday ? "opacity-100" : bar.value > 0 ? "opacity-70" : "opacity-20"}`} 136 + style={{ 137 + height: `${pct}%`, 138 + backgroundColor: "var(--md-sys-color-primary)", 139 + minWidth: "2px", 140 + }} 141 + /> 142 + 143 + {bar.value > 0 && ( 144 + <div 145 + className="pointer-events-none absolute -top-7 z-10 rounded-md px-2 py-0.5 text-xs font-bold opacity-0 transition-opacity duration-100 group-hover/bar:opacity-100" 146 + style={{ 147 + backgroundColor: 148 + "var(--md-sys-color-inverse-surface)", 149 + color: "var(--md-sys-color-surface)", 150 + }} 151 + > 152 + {bar.value} items 153 + </div> 154 + )} 155 + </div> 156 + ); 157 + })} 158 + </div> 159 + 160 + {/* X-axis labels */} 161 + <div className="mt-1.5 flex"> 162 + {bars.map((bar) => ( 163 + <span 164 + key={`label-${bar.key}`} 165 + className="flex-1 text-center text-[10px] leading-tight text-(--md-sys-color-on-surface-variant)" 166 + > 167 + {bar.showLabel ? bar.label : ""} 168 + </span> 169 + ))} 170 + </div> 171 + </div> 172 + </div> 173 + </M3CardContent> 174 + </M3Card> 175 + ); 176 + } 177 + 178 + function StatTile({ 179 + label, 180 + value, 181 + suffix, 182 + icon, 183 + }: { 184 + label: string; 185 + value: number | string; 186 + suffix?: string; 187 + icon?: React.ReactNode; 188 + }) { 189 + return ( 190 + <div 191 + className="flex flex-col items-center gap-0.5 rounded-lg px-2 py-2" 192 + style={{ 193 + backgroundColor: "var(--md-sys-color-surface-container)", 194 + }} 195 + > 196 + <span className="flex items-center gap-1 text-lg font-bold tabular-nums text-(--md-sys-color-on-surface)"> 197 + {value} 198 + {suffix && ( 199 + <span className="text-xs font-medium text-(--md-sys-color-on-surface-variant)"> 200 + {suffix} 201 + </span> 202 + )} 203 + {icon} 204 + </span> 205 + <span className="text-[11px] font-medium text-(--md-sys-color-on-surface-variant)"> 206 + {label} 207 + </span> 208 + </div> 209 + ); 210 + } 211 + 212 + // --------------------------------------------------------------------------- 213 + // Helpers 214 + // --------------------------------------------------------------------------- 215 + 216 + function deriveStats( 217 + summary: ShelfActivitySummaryDto | undefined, 218 + range: DashboardRange, 219 + ) { 220 + const watched = 221 + range === "week" 222 + ? (summary?.watchedLast7Days ?? 0) 223 + : (summary?.watchedLast30Days ?? 0); 224 + 225 + const days = range === "week" ? 7 : 30; 226 + const dailyAvgRaw = days > 0 ? watched / days : 0; 227 + const dailyAvg = 228 + dailyAvgRaw % 1 === 0 ? dailyAvgRaw.toString() : dailyAvgRaw.toFixed(1); 229 + 230 + const streak = computeStreak(summary?.dailyActivity); 231 + 232 + const trend = computeTrend(summary?.dailyActivity, range); 233 + 234 + return { watched, dailyAvg, dailyAvgRaw, streak, trend }; 235 + } 236 + 237 + function computeStreak( 238 + daily: ShelfActivitySummaryDto["dailyActivity"] | undefined, 239 + ): number { 240 + if (!daily || daily.length === 0) return 0; 241 + 242 + let streak = 0; 243 + for (let i = daily.length - 1; i >= 0; i--) { 244 + if (daily[i].count > 0) { 245 + streak++; 246 + } else { 247 + break; 248 + } 249 + } 250 + return streak; 251 + } 252 + 253 + function computeTrend( 254 + daily: ShelfActivitySummaryDto["dailyActivity"] | undefined, 255 + range: DashboardRange, 256 + ): number { 257 + if (!daily || daily.length === 0) return 0; 258 + 259 + const days = range === "week" ? 7 : 30; 260 + const recent = daily.slice(-days); 261 + const prior = daily.slice(-days * 2, -days); 262 + 263 + if (prior.length === 0) return 0; 264 + 265 + const recentTotal = recent.reduce((s, b) => s + b.count, 0); 266 + const priorTotal = prior.reduce((s, b) => s + b.count, 0); 267 + 268 + if (priorTotal === 0) return recentTotal > 0 ? 100 : 0; 269 + return Math.round(((recentTotal - priorTotal) / priorTotal) * 100); 270 + } 271 + 272 + function buildActivityBars( 273 + dailyActivity: ShelfActivitySummaryDto["dailyActivity"] | undefined, 274 + range: DashboardRange, 275 + ) { 276 + const visibleBuckets = 277 + range === "week" ? (dailyActivity?.slice(-7) ?? []) : (dailyActivity ?? []); 278 + 279 + const todayStr = todayDateKey(); 280 + 281 + if (visibleBuckets.length === 0) { 282 + return Array.from({ length: range === "week" ? 7 : 30 }, (_, index) => ({ 283 + key: `placeholder-${range}-${index}`, 284 + value: 0, 285 + label: "", 286 + showLabel: false, 287 + isToday: false, 288 + })); 289 + } 290 + 291 + return visibleBuckets.map((bucket, index) => ({ 292 + key: bucket.date, 293 + value: bucket.count, 294 + isToday: bucket.date === todayStr, 295 + label: 296 + range === "week" 297 + ? formatDayKey(bucket, { weekday: "short" }).slice(0, 3) 298 + : formatDayKey(bucket, { day: "numeric" }), 299 + showLabel: 300 + range === "week" || 301 + index === 0 || 302 + index % 7 === 0 || 303 + index === visibleBuckets.length - 1, 304 + })); 305 + } 306 + 307 + function formatDayKey( 308 + bucket: ShelfActivityBucketDto, 309 + options: Intl.DateTimeFormatOptions, 310 + ) { 311 + const [year, month, day] = bucket.date.split("-").map(Number); 312 + return new Intl.DateTimeFormat(undefined, { 313 + ...options, 314 + timeZone: "UTC", 315 + }).format(new Date(Date.UTC(year, month - 1, day, 12, 0, 0, 0))); 316 + } 317 + 318 + function todayDateKey() { 319 + const d = new Date(); 320 + const yyyy = d.getFullYear(); 321 + const mm = String(d.getMonth() + 1).padStart(2, "0"); 322 + const dd = String(d.getDate()).padStart(2, "0"); 323 + return `${yyyy}-${mm}-${dd}`; 324 + }
+3 -152
apps/web/src/components/home/DashboardHomePage.tsx
··· 1 1 import { 2 2 listsControllerGetUserListsOptions, 3 3 moviesControllerUnmarkWatchedMutation, 4 - type ShelfActivityBucketDto, 5 - type ShelfActivitySummaryDto, 6 4 shelfControllerGetUserActivitySummaryOptions, 7 5 shelfControllerGetUserShelfOptions, 8 6 showsControllerDeleteEpisodeWatchHistoryEntryMutation, ··· 12 10 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 13 11 import { Link } from "@tanstack/react-router"; 14 12 import { LayoutDashboard, Search } from "lucide-react"; 15 - import { useMemo, useState } from "react"; 13 + import { useMemo } from "react"; 16 14 import { CreateListDialog } from "@/components/CreateListDialog"; 15 + import { AtAGlanceCard } from "@/components/home/AtAGlanceCard"; 17 16 import { FriendsActivitySection } from "@/components/home/FriendsActivitySection"; 18 17 import { UpNextSection } from "@/components/home/UpNextSection"; 19 18 import { ListCard } from "@/components/ListCard"; ··· 34 33 import { getProfileRoute } from "@/lib/profile-routes"; 35 34 import { createTitleSlug } from "@/lib/utils"; 36 35 37 - type DashboardRange = "week" | "month"; 38 - 39 36 export function DashboardHomePage({ user }: { user: UserDto }) { 40 - const [range, setRange] = useState<DashboardRange>("week"); 41 37 const displayName = getSocialDisplayName( 42 38 (user as unknown as { displayName?: string | null }).displayName, 43 39 user.handle, ··· 108 104 }; 109 105 }, [shelfData]); 110 106 111 - const activityBars = useMemo( 112 - () => buildActivityBars(activitySummary?.dailyActivity, range), 113 - [activitySummary?.dailyActivity, range], 114 - ); 115 - const watchedInRangeCount = 116 - range === "week" 117 - ? (activitySummary?.watchedLast7Days ?? 0) 118 - : (activitySummary?.watchedLast30Days ?? 0); 119 - 120 - const maxActivityValue = Math.max(...activityBars.map((bar) => bar.value), 1); 121 - 122 107 const { recentLists } = useMemo(() => { 123 108 const listItems = lists ?? []; 124 109 const sortedLists = [...listItems].sort((a, b) => { ··· 184 169 </div> 185 170 186 171 <div className="lg:col-span-2"> 187 - <M3Card 188 - variant="elevated" 189 - className="h-full rounded-xl border" 190 - style={{ borderColor: "var(--md-sys-color-outline-variant)" }} 191 - > 192 - <M3CardHeader> 193 - <M3CardTitle className="md-title-large">At a glance</M3CardTitle> 194 - <M3CardDescription> 195 - A lighter read on your recent momentum. 196 - </M3CardDescription> 197 - </M3CardHeader> 198 - <M3CardContent className="space-y-5"> 199 - <div 200 - className="inline-flex w-full flex-wrap gap-2 rounded-full border p-1" 201 - style={{ 202 - backgroundColor: "var(--md-sys-color-surface-container)", 203 - borderColor: "var(--md-sys-color-outline-variant)", 204 - }} 205 - > 206 - <M3Button 207 - size="sm" 208 - variant={range === "week" ? "filled-tonal" : "text"} 209 - className="min-w-24 flex-1 rounded-full" 210 - onClick={() => setRange("week")} 211 - > 212 - Week 213 - </M3Button> 214 - <M3Button 215 - size="sm" 216 - variant={range === "month" ? "filled-tonal" : "text"} 217 - className="min-w-24 flex-1 rounded-full" 218 - onClick={() => setRange("month")} 219 - > 220 - Month 221 - </M3Button> 222 - </div> 223 - <div 224 - className="rounded-xl border p-4" 225 - style={{ 226 - backgroundColor: "var(--md-sys-color-surface-container)", 227 - borderColor: "var(--md-sys-color-outline-variant)", 228 - }} 229 - > 230 - <div className="mb-4 flex items-center justify-between gap-3"> 231 - <div> 232 - <p className="text-sm font-semibold text-(--md-sys-color-on-surface)"> 233 - Viewing rhythm 234 - </p> 235 - <p className="text-xs text-(--md-sys-color-on-surface-variant)"> 236 - {range === "week" ? "Last 7 days" : "Past 30 days"} 237 - </p> 238 - </div> 239 - <p className="text-xs text-(--md-sys-color-on-surface-variant)"> 240 - {watchedInRangeCount} watched 241 - </p> 242 - </div> 243 - <div 244 - className={range === "month" ? "overflow-x-auto pb-2" : ""} 245 - > 246 - <div 247 - className={ 248 - range === "month" 249 - ? "flex min-w-[720px] gap-2" 250 - : "grid grid-cols-7 gap-2" 251 - } 252 - > 253 - {activityBars.map((bar) => ( 254 - <div 255 - key={bar.key} 256 - className={`flex min-w-0 flex-col items-center gap-2 ${ 257 - range === "month" ? "w-5 shrink-0" : "" 258 - }`} 259 - > 260 - <div className="flex h-24 w-full items-end overflow-hidden rounded-2xl bg-[rgba(127,127,127,0.14)] px-1 py-1"> 261 - <div 262 - className="w-full rounded-xl bg-(--md-sys-color-primary)" 263 - style={{ 264 - height: `${Math.max((bar.value / maxActivityValue) * 100, bar.value > 0 ? 18 : 8)}%`, 265 - }} 266 - /> 267 - </div> 268 - <span className="text-xs font-semibold text-(--md-sys-color-on-surface)"> 269 - {bar.value} 270 - </span> 271 - <span className="min-h-4 text-center text-[11px] text-(--md-sys-color-on-surface-variant)"> 272 - {bar.showLabel ? bar.label : ""} 273 - </span> 274 - </div> 275 - ))} 276 - </div> 277 - </div> 278 - </div> 279 - </M3CardContent> 280 - </M3Card> 172 + <AtAGlanceCard activitySummary={activitySummary} /> 281 173 </div> 282 174 </div> 283 175 ··· 478 370 </div> 479 371 ); 480 372 } 481 - 482 - function buildActivityBars( 483 - dailyActivity: ShelfActivitySummaryDto["dailyActivity"] | undefined, 484 - range: DashboardRange, 485 - ) { 486 - const visibleBuckets = 487 - range === "week" ? (dailyActivity?.slice(-7) ?? []) : (dailyActivity ?? []); 488 - 489 - if (visibleBuckets.length === 0) { 490 - return Array.from({ length: range === "week" ? 7 : 30 }, (_, index) => ({ 491 - key: `placeholder-${range}-${index}`, 492 - value: 0, 493 - label: "", 494 - showLabel: false, 495 - })); 496 - } 497 - 498 - return visibleBuckets.map((bucket, index) => ({ 499 - key: bucket.date, 500 - value: bucket.count, 501 - label: 502 - range === "week" 503 - ? formatDayKey(bucket, { weekday: "short" }).slice(0, 3) 504 - : formatDayKey(bucket, { month: "short", day: "numeric" }), 505 - showLabel: 506 - range === "week" || 507 - index % 5 === 0 || 508 - index === visibleBuckets.length - 1, 509 - })); 510 - } 511 - 512 - function formatDayKey( 513 - bucket: ShelfActivityBucketDto, 514 - options: Intl.DateTimeFormatOptions, 515 - ) { 516 - const [year, month, day] = bucket.date.split("-").map(Number); 517 - return new Intl.DateTimeFormat(undefined, { 518 - ...options, 519 - timeZone: "UTC", 520 - }).format(new Date(Date.UTC(year, month - 1, day, 12, 0, 0, 0))); 521 - }
+16 -39
apps/web/src/components/onboarding/onboarding-content.tsx
··· 475 475 {showImportStatusAboveInput && ( 476 476 <div className="grid gap-2 rounded-(--md-sys-shape-corner-medium) border border-(--md-sys-color-outline-variant) bg-(--md-sys-color-surface-container-high) p-3"> 477 477 <p className="md-label-large m-0">{importProgress.message}</p> 478 - {importProgress.phase === "preview_ready" && traktPreview ? ( 479 - <p className="md-body-small m-0 text-(--md-sys-color-on-surface-variant)"> 480 - {isTraktImportQueued 481 - ? `Preview loaded from ${traktQueuedImport.sourcePreviewCount} recent Trakt history rows. The full import will keep running in the background.` 482 - : `${traktPreview.importableCount} importable items found from ${traktPreview.sourceCount} Trakt history rows.`} 483 - </p> 484 - ) : ( 478 + {importProgress.phase !== "preview_ready" && ( 485 479 <p className="md-body-small m-0 text-(--md-sys-color-on-surface-variant)"> 486 480 Preparing data for import... 487 481 </p> ··· 579 573 </div> 580 574 ) : traktPreview ? ( 581 575 <div className="grid gap-4 rounded-(--md-sys-shape-corner-large) border border-(--md-sys-color-outline-variant) bg-(--md-sys-color-surface-container-high) p-4"> 582 - <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> 583 - <div className="grid gap-1"> 584 - <p className="md-label-small m-0 uppercase tracking-[0.12em] text-(--md-sys-color-primary)"> 585 - Trakt profile 586 - </p> 587 - <h3 className="md-title-large m-0"> 588 - {traktPreview.profile.name ?? 589 - `@${traktPreview.profile.username}`} 590 - </h3> 591 - <p className="md-body-medium m-0 text-(--md-sys-color-on-surface-variant)"> 592 - @{traktPreview.profile.username} 593 - {traktPreview.profile.isVip ? " • VIP" : ""} 594 - {traktPreview.profile.isPrivate ? " • Private" : ""} 595 - </p> 596 - </div> 597 - <div className="grid gap-2 text-right"> 598 - <p className="md-label-small m-0 uppercase text-(--md-sys-color-on-surface-variant)"> 599 - {isTraktImportQueued 600 - ? "Background import" 601 - : "Ready to import"} 602 - </p> 603 - <p className="md-headline-small m-0 text-(--md-sys-color-primary)"> 604 - {isTraktImportQueued 605 - ? "Queued" 606 - : traktPreview.importableCount} 607 - </p> 608 - </div> 576 + <div className="grid gap-1"> 577 + <p className="md-label-small m-0 uppercase tracking-[0.12em] text-(--md-sys-color-primary)"> 578 + Trakt profile 579 + </p> 580 + <h3 className="md-title-large m-0"> 581 + {traktPreview.profile.name ?? 582 + `@${traktPreview.profile.username}`} 583 + </h3> 584 + <p className="md-body-medium m-0 text-(--md-sys-color-on-surface-variant)"> 585 + @{traktPreview.profile.username} 586 + {traktPreview.profile.isVip ? " • VIP" : ""} 587 + {traktPreview.profile.isPrivate ? " • Private" : ""} 588 + </p> 609 589 </div> 610 590 611 591 {isTraktImportQueued ? ( ··· 657 637 <M3Button 658 638 variant="filled" 659 639 onClick={onTraktImportConfirm} 660 - disabled={ 661 - isImportBusy || traktPreview.importableCount < 1 662 - } 640 + disabled={isImportBusy} 663 641 > 664 - Import {traktPreview.importableCount} item 665 - {traktPreview.importableCount === 1 ? "" : "s"} 642 + Start import 666 643 </M3Button> 667 644 ) : null} 668 645 </div>
-1
apps/web/src/routes/-onboarding.test.tsx
··· 123 123 }, 124 124 ], 125 125 skipped: [], 126 - sourceCount: 4, 127 126 }; 128 127 129 128 const startedImport = {
+2 -9
apps/web/src/routes/onboarding.tsx
··· 390 390 setImportProgress((prev) => ({ 391 391 ...prev, 392 392 phase: "preview_ready", 393 - totalItems: fetched.importableCount, 394 - message: 395 - fetched.importableCount > 0 396 - ? `Preview ready for @${fetched.profile.username}` 397 - : `No importable items found for @${fetched.profile.username}`, 393 + message: `Preview ready for @${fetched.profile.username}`, 398 394 })); 399 - if (!fetched.importableCount) { 400 - toast.message("No supported watch history items found"); 401 - } 402 395 } catch (error) { 403 396 const message = 404 397 error instanceof Error ··· 414 407 }; 415 408 416 409 const handleConfirmTraktImport = async () => { 417 - if (!traktPreview || traktPreview.importableCount < 1) { 410 + if (!traktPreview) { 418 411 return; 419 412 } 420 413
+2 -17
backend/src/users/dto/import-history.dto.ts
··· 108 108 @ApiProperty({ description: "Trakt username or slug" }) 109 109 @IsString() 110 110 username: string; 111 - 112 - @ApiPropertyOptional({ 113 - description: 114 - "Maximum items to fetch. If omitted, fetches full available history via pagination.", 115 - minimum: 1, 116 - }) 117 - @IsOptional() 118 - @Type(() => Number) 119 - @IsInt() 120 - @Min(1) 121 - maxItems?: number; 122 111 } 123 112 124 113 export class TraktPublicProfileDto { ··· 160 149 profile: TraktPublicProfileDto; 161 150 162 151 @ApiProperty({ 163 - description: "Count of importable rows after normalization", 152 + description: 153 + "Count of importable rows after normalization (from the recent preview window)", 164 154 }) 165 155 importableCount: number; 166 156 ··· 172 162 173 163 @ApiProperty({ type: [ImportSkipDto] }) 174 164 skipped: ImportSkipDto[]; 175 - 176 - @ApiProperty({ 177 - description: "Count of rows returned by Trakt before filtering", 178 - }) 179 - sourceCount: number; 180 165 } 181 166 182 167 export class StartTraktImportDto {
+10 -13
backend/src/users/import-history.service.ts
··· 91 91 92 92 const TRAKT_HISTORY_PAGE_SIZE = 100; 93 93 const TRAKT_PREVIEW_ITEM_LIMIT = 5; 94 + const TRAKT_PREVIEW_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; 94 95 const ACTIVE_TRAKT_JOB_STATUSES: TraktJobStatus[] = [ 95 96 "queued", 96 97 "running", ··· 132 133 133 134 async fetchTraktPublicHistory( 134 135 username: string, 135 - maxItems?: number, 136 136 ): Promise<FetchTraktPublicHistoryResponseDto> { 137 137 try { 138 138 this.ensureTraktConfigured(); 139 139 140 140 const normalizedUsername = this.normalizeUsername(username); 141 - const safeMaxItems = 142 - typeof maxItems === "number" 143 - ? Math.max(Math.floor(maxItems), 1) 144 - : Number.POSITIVE_INFINITY; 145 141 const profile = await this.fetchTraktPublicProfile(normalizedUsername); 142 + const startAt = new Date(Date.now() - TRAKT_PREVIEW_WINDOW_MS); 146 143 let page = 1; 147 144 let pageCount = Number.POSITIVE_INFINITY; 148 - let sourceCount = 0; 149 145 const items: NormalizedImportItemDto[] = []; 150 146 const skipped: ImportSkipDto[] = []; 151 147 const previewItems: TraktHistoryPreviewItemDto[] = []; 152 148 153 - while (items.length < safeMaxItems && page <= pageCount) { 149 + while (page <= pageCount) { 154 150 const pageResult = await this.fetchTraktHistoryPage( 155 151 normalizedUsername, 156 152 page, 153 + { startAt }, 157 154 ); 158 155 pageCount = pageResult.pageCount ?? pageCount; 159 - sourceCount += pageResult.payload.length; 156 + const baseIndex = items.length + skipped.length + 1; 160 157 161 158 for (let i = 0; i < pageResult.payload.length; i++) { 162 - if (items.length >= safeMaxItems) { 163 - break; 164 - } 165 159 const result = this.normalizeTraktApiItem( 166 160 pageResult.payload[i], 167 - sourceCount - pageResult.payload.length + i + 1, 161 + baseIndex + i, 168 162 ); 169 163 if (result.item) { 170 164 items.push(result.item); ··· 195 189 previewItems, 196 190 items, 197 191 skipped, 198 - sourceCount, 199 192 }; 200 193 } catch (error) { 201 194 throw this.toPublicTraktException(error); ··· 751 744 private async fetchTraktHistoryPage( 752 745 username: string, 753 746 page: number, 747 + options?: { startAt?: Date }, 754 748 ): Promise<{ payload: unknown[]; pageCount?: number }> { 755 749 const url = this.createTraktUrl( 756 750 `/users/${encodeURIComponent(username)}/history`, 757 751 ); 758 752 url.searchParams.set("page", String(page)); 759 753 url.searchParams.set("limit", String(TRAKT_HISTORY_PAGE_SIZE)); 754 + if (options?.startAt) { 755 + url.searchParams.set("start_at", options.startAt.toISOString()); 756 + } 760 757 761 758 const { data, headers } = 762 759 await this.fetchTraktJsonWithHeaders<unknown>(url);
+2 -7
backend/src/users/users.controller.spec.ts
··· 83 83 previewItems: [], 84 84 items: [], 85 85 skipped: [], 86 - sourceCount: 0, 87 86 }); 88 87 89 88 await expect( 90 - controller.fetchMyTraktPublicHistory({ username: "alice", maxItems: 10 }), 89 + controller.fetchMyTraktPublicHistory({ username: "alice" }), 91 90 ).resolves.toEqual({ 92 91 profile: { 93 92 username: "alice", ··· 100 99 previewItems: [], 101 100 items: [], 102 101 skipped: [], 103 - sourceCount: 0, 104 102 }); 105 - expect(usersService.fetchTraktPublicHistory).toHaveBeenCalledWith( 106 - "alice", 107 - 10, 108 - ); 103 + expect(usersService.fetchTraktPublicHistory).toHaveBeenCalledWith("alice"); 109 104 }); 110 105 111 106 it("starts a background Trakt import", async () => {
+1 -4
backend/src/users/users.controller.ts
··· 322 322 async fetchMyTraktPublicHistory( 323 323 @Body() dto: FetchTraktPublicHistoryDto, 324 324 ): Promise<FetchTraktPublicHistoryResponseDto> { 325 - return this.usersService.fetchTraktPublicHistory( 326 - dto.username, 327 - dto.maxItems, 328 - ); 325 + return this.usersService.fetchTraktPublicHistory(dto.username); 329 326 } 330 327 331 328 @Post("me/import/trakt/public/start")
+1 -35
backend/src/users/users.service.spec.ts
··· 544 544 previewItems: [], 545 545 items: [], 546 546 skipped: [], 547 - sourceCount: 2, 548 547 }); 549 548 550 - await expect( 551 - service.fetchTraktPublicHistory("alice", 100), 552 - ).resolves.toEqual({ 549 + await expect(service.fetchTraktPublicHistory("alice")).resolves.toEqual({ 553 550 profile: { 554 551 username: "alice", 555 552 slug: "alice", ··· 562 559 previewItems: [], 563 560 items: [], 564 561 skipped: [], 565 - sourceCount: 2, 566 562 }); 567 563 expect(importHistoryService.fetchTraktPublicHistory).toHaveBeenCalledWith( 568 564 "alice", 569 - 100, 570 - ); 571 - }); 572 - 573 - it("delegates Trakt history fetching without a max item limit", async () => { 574 - importHistoryService.fetchTraktPublicHistory.mockResolvedValue({ 575 - profile: { 576 - username: "rpf_2001", 577 - slug: "rpf_2001", 578 - name: undefined, 579 - isPrivate: false, 580 - isVip: false, 581 - avatarUrl: undefined, 582 - }, 583 - importableCount: 100, 584 - previewItems: [], 585 - items: [], 586 - skipped: [], 587 - sourceCount: 100, 588 - }); 589 - 590 - await expect( 591 - service.fetchTraktPublicHistory("rpf_2001"), 592 - ).resolves.toMatchObject({ 593 - importableCount: 100, 594 - sourceCount: 100, 595 - }); 596 - expect(importHistoryService.fetchTraktPublicHistory).toHaveBeenCalledWith( 597 - "rpf_2001", 598 - undefined, 599 565 ); 600 566 }); 601 567
+1 -5
backend/src/users/users.service.ts
··· 257 257 258 258 async fetchTraktPublicHistory( 259 259 username: string, 260 - maxItems?: number, 261 260 ): Promise<FetchTraktPublicHistoryResponseDto> { 262 - return this.importHistoryService.fetchTraktPublicHistory( 263 - username, 264 - maxItems, 265 - ); 261 + return this.importHistoryService.fetchTraktPublicHistory(username); 266 262 } 267 263 268 264 async startTraktImport(
+1 -9
packages/api/src/generated/types.gen.ts
··· 567 567 * Trakt username or slug 568 568 */ 569 569 username: string; 570 - /** 571 - * Maximum items to fetch. If omitted, fetches full available history via pagination. 572 - */ 573 - maxItems?: number; 574 570 }; 575 571 576 572 export type TraktPublicProfileDto = { ··· 647 643 export type FetchTraktPublicHistoryResponseDto = { 648 644 profile: TraktPublicProfileDto; 649 645 /** 650 - * Count of importable rows after normalization 646 + * Count of importable rows after normalization (from the recent preview window) 651 647 */ 652 648 importableCount: number; 653 649 previewItems: Array<TraktHistoryPreviewItemDto>; 654 650 items: Array<NormalizedImportItemDto>; 655 651 skipped: Array<ImportSkipDto>; 656 - /** 657 - * Count of rows returned by Trakt before filtering 658 - */ 659 - sourceCount: number; 660 652 }; 661 653 662 654 export type StartTraktImportDto = {