Bento Grid Slot System¶
The tracker detail page's Data & Analytics tab uses a slot-based bento grid to render per-platform stat cards alongside the universal core stats. This document covers how the system works and how to add a new stat card.
What is the bento grid?¶
The analytics tab combines eight fixed core stats (Uploaded, Downloaded, Ratio, Buffer, Seeding, Leeching, Hit & Runs, Required Ratio) with a variable number of platform-specific slot cards. Slots are registered centrally and each slot decides at render time whether it has anything to show for the current tracker — if not, it returns null and is excluded entirely from the layout.
The grid needs to pack single-height and double-height cards into a clean rectangular layout without orphaned cells or large gaps. Rather than using CSS auto-placement (which can leave unpredictable holes), the layout algorithm runs ahead of time and produces explicit row-start, col-start, and row-span classes for every card. Each responsive breakpoint has its own algorithm and produces an independent placement.
Why explicit positioning? Tailwind's CSS grid auto-placement works fine for uniform grids, but breaks down when mixing 1-tall and 2-tall cards across multiple breakpoints. Explicit positioning gives full control over where each card lands and eliminates gaps.
Slot categories¶
Every slot belongs to one of three categories defined in src/lib/slot-types.ts:
| Category | What it renders | Where it appears |
|---|---|---|
stat-card |
A StatCard component (basic, stacked, or ring variant) |
Inside the bento grid |
badge |
A SlotBadge pill (Warned, Donor, Parked, etc.) |
Collected and displayed as a badge row above the grid |
progress |
An arbitrary component (achievement progress bars, share score, buffs) | Rendered as a flex column above the bento grid via SlotRenderer |
This document focuses on stat-card slots, as they are the most common thing to add.
Slot sizes¶
Each stat-card slot has a span field that determines how many grid rows it occupies.
span |
CardType in layout | Description |
|---|---|---|
1 (default) |
single |
Standard 1×1 card — one row tall, one column wide |
2 |
double (or triple if promoted) |
Tall card — two rows tall, one column wide. Can be promoted to triple (three rows) by the algorithm when it produces a better layout. |
The algorithm may promote a double to a triple (three rows) when doing so reduces gaps or eliminates an orphan. This is purely a layout decision — the slot itself only declares span: 1 or span: 2. The triple type exists in CardType but is never assigned directly by slot authors.
Type definitions¶
SlotContext¶
The object passed to every slot's resolve function:
// src/lib/slot-types.ts
export interface SlotContext {
tracker: TrackerSummary // DB row + computed fields
latestSnapshot: Snapshot | null
snapshots: Snapshot[]
meta: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | null
registry: TrackerRegistryEntry | undefined
accentColor: string // tracker's hex color, e.g. "#00d4ff"
}
meta is the platform-specific extra data returned by the adapter. It is null for UNIT3D trackers (they have no extra meta yet). Always guard with if (!meta) before accessing platform fields.
ResolvedSlot¶
What the registry produces after calling resolve:
// src/lib/slot-types.ts
export interface ResolvedSlot {
id: string
category: SlotCategory
props: Record<string, unknown>
priority: number
span: 1 | 2
}
SlotDefinition (internal)¶
The shape of each entry in SLOT_DEFINITIONS:
interface SlotDefinition<P = Record<string, unknown>> {
id: string
category: SlotCategory
component: ComponentType<P>
resolve: (ctx: SlotContext) => P | null // null = hide this slot
priority: number
span?: 1 | 2 // omit for 1 (default)
}
AnySlotDefinition is the erased version used in the exported array so that typed generics can coexist in a heterogeneous list without double-casts.
StatCard variants¶
All stat-card slots render via the unified StatCard component in src/components/ui/StatCard.tsx. It has three variants selected by the type prop.
basic (default)¶
Single hero value. Use for any scalar metric.
interface StatCardBasicProps {
type?: "basic" // optional — "basic" is the default
label: string // card title, displayed uppercase
value: string | number // large hero number
unit?: string // displayed small next to value, e.g. "BON", "GiB"
subtitle?: string // small text below the value
subValue?: string // secondary line, mono font, tertiary color
trend?: "up" | "down" | "flat"
tooltip?: string // adds a "?" button with a popover
icon?: ReactNode // 16×16 icon in the top-right corner
accentColor?: string // hex color for the glow effect
alert?: "warn" | "danger"
alertReason?: string // shown in a "!" tooltip when alert is set
}
stacked¶
Multiple label/value rows. Use when a concept has two or three related figures (e.g. Freeleech tokens + Merit tokens).
interface StatCardStackedProps {
type: "stacked" // required
title: string // card title
rows: Array<{
label: string
value: string | number
prefix?: string // prepended to value display
unit?: string
colorClass?: string // Tailwind class, e.g. "text-success"
}>
total?: {
label: string // displayed below a divider
value: string
unit?: string
}
sumIsHero?: boolean // promote the total to a large hero above the rows
tooltip?: string
icon?: ReactNode
accentColor?: string
alert?: "warn" | "danger"
alertReason?: string
}
A stacked card with span: 2 occupies two grid rows, giving the rows more vertical breathing room.
ring¶
Countdown progress ring. Used exclusively for the Login Deadline card. Renders an SVG ring that fills as the deadline approaches and turns amber → red as it gets close.
interface StatCardRingProps {
type: "ring" // required
title?: string // defaults to "Login Deadline"
lastAccessAt: string // ISO date string of last tracker visit
loginIntervalDays: number // from registry entry's rules.loginIntervalDays
tooltip?: string
accentColor?: string
alert?: "warn" | "danger"
alertReason?: string
}
The ring variant is driven by data from ctx.tracker.lastAccessAt and ctx.registry?.rules?.loginIntervalDays. It is unlikely you will need a second ring card.
How to add a new stat card slot¶
1. Decide what data you need¶
Look at SlotContext above. If your data is in latestSnapshot, you can read it directly. If it requires platform-specific meta, check what fields are available on the relevant *PlatformMeta type in src/lib/adapters/types.ts.
2. Write the slot definition¶
Add a new constant in src/components/tracker-detail/slot-registry.ts. Place it with the other stat-card slots:
// Example: a basic card showing invite count for a hypothetical platform
const myTrackerInvitesSlot: SlotDefinition<StatCardBasicProps> = {
id: "my-tracker-invites", // must be unique across all slots
category: "stat-card",
component: StatCard as ComponentType<StatCardBasicProps>,
priority: 50, // lower = renders earlier (leftmost/topmost)
// span: 1, // omit for default 1-tall card
resolve(ctx) {
const { meta, accentColor } = ctx
if (!meta || !("invites" in meta)) return null // guard: wrong platform
const invites = (meta as MyPlatformMeta).invites
if (typeof invites !== "number" || invites <= 0) return null // hide when empty
return {
label: "Invites",
value: invites,
accentColor,
icon: icon16(UserIcon),
}
},
}
For a stacked (double-height) card:
const myTrackerTokensSlot: SlotDefinition<StatCardStackedProps> = {
id: "my-tracker-tokens",
category: "stat-card",
component: StatCard as ComponentType<StatCardStackedProps>,
priority: 30,
span: 2, // 2-row tall card
resolve(ctx) {
const { meta, accentColor } = ctx
if (!meta || !("giftTokens" in meta)) return null
const m = meta as MyPlatformMeta
return {
type: "stacked" as const, // required for stacked
title: "Tokens",
rows: [
{ label: "Gift", value: m.giftTokens ?? 0 },
{ label: "Merit", value: m.meritTokens ?? 0 },
],
total: { label: "Total", value: String((m.giftTokens ?? 0) + (m.meritTokens ?? 0)) },
accentColor,
}
},
}
3. Register it¶
Add the slot to the SLOT_DEFINITIONS array at the bottom of slot-registry.ts. The array order does not determine layout position — priority does. Lower priority numbers appear first (leftmost in the first available row).
export const SLOT_DEFINITIONS: AnySlotDefinition[] = [
// stat-card slots
loginDeadlineSlot,
goldSlot,
// ... existing slots ...
myTrackerInvitesSlot, // add here
myTrackerTokensSlot,
// badge slots
// ...
]
That is all. The resolver, layout algorithm, and renderer pick it up automatically.
4. Verify the resolve guard¶
The resolve function is called for every tracker detail page, regardless of platform. It must return null whenever the data is not present or not applicable. Failing to guard will show broken cards on unrelated trackers.
Common guard patterns:
// Guard: only for GGn
if (!meta || !("hourlyGold" in meta)) return null
// Guard: only for Gazelle-family (has community object)
if (!meta || !("community" in meta)) return null
// Guard: hide when value is zero or missing
if (value == null || value <= 0) return null
The layout algorithm¶
File: src/lib/grid-layout.ts
Card types¶
The algorithm operates on three internal card sizes:
| Type | Row span | Assigned to |
|---|---|---|
single |
1 | Core stats + span: 1 slot cards |
double |
2 | span: 2 slot cards |
triple |
3 | A double that was promoted to fill a triple-height gap |
The first N cards in the single pool are marked fixed (N = number of columns). Fixed cards are always placed in row 1 and are never moved.
4-column breakpoint (findOptimalLayout4Col)¶
This is the primary desktop layout. It brute-forces all valid combinations of column count (3 or 4) and double-to-triple promotions, then ranks them by:
- No orphaned card in the last row (preferred)
- Fewest gap cells
- Prefer 4 columns over 3
- Fewest triples (promotions)
The winning configuration's cards each receive a { row, col, span } placement. getCardClasses turns these into static Tailwind classes (row-start-N col-start-N row-span-N). The row/col start classes are pre-enumerated as lookup tables (up to 30 rows) rather than generated dynamically, because Tailwind v4 requires static class names for its JIT scanner.
Placement order within the winner:
- Row 1: core stat singles (up to 4)
- Triple-height blocks: promoted doubles fill columns left to right; remaining columns in the same row block are filled with singles stacked 3-tall
- Double-height blocks: doubles fill columns left to right; remaining columns filled with pairs of singles
- Remaining singles: flow left to right, top to bottom in remaining rows
3-column breakpoint (findOptimalLayout3Col)¶
Fixed 3 columns. Same brute-force promotion strategy but ranks by: no-orphan → fewest gaps → fewest triples (no column-count preference since columns are fixed). Implemented and tested. Currently wired into the md breakpoint (hidden md:grid md:grid-cols-3 lg:hidden).
2-column breakpoint (findOptimalLayout2Col)¶
Fixed 2 columns. Deterministic: promotes at most one double to a triple when the total cell count is odd (to keep columns balanced). Currently wired into the mobile grid (grid grid-cols-2 md:hidden).
Breakpoint wiring (current status)¶
| Breakpoint | Tailwind class | Algorithm | Status |
|---|---|---|---|
Mobile (< md) |
grid-cols-2 |
findOptimalLayout2Col |
Wired and active |
Medium (md to lg) |
md:grid-cols-3 |
findOptimalLayout3Col |
Wired and active |
Large (>= lg) |
lg:grid-cols-3 or lg:grid-cols-4 |
findOptimalLayout4Col |
Wired and active |
The large grid uses lg:grid-cols-4 when the algorithm selects 4 columns, or lg:grid-cols-3 when it finds 3 columns produces fewer gaps.
How the renderer maps cards to content¶
In AnalyticsTab.tsx, renderLayoutCards iterates the placed cards and maps each card ID to a React element:
s1throughs{coreCount}→ core stat descriptors frombuildCoreStatDescriptorss{coreCount + 1}onward →span: 1slot cards in priority ordert1,t2, ... → the first T promoted slot doubles (triples)d1,d2, ... → the remaining slot doubles (at offset T)
The full pipeline for stat-card slots:
SlotContext built in tracker detail page
→ SLOT_DEFINITIONS[n].resolve(ctx) called for each definition
→ null returns filtered out
→ surviving slots sorted by priority
→ split into singleSlots (span=1) and doubleSlots (span=2)
→ counts passed to layout algorithms
→ layout algorithms return PlacedCard[]
→ renderLayoutCards maps card IDs to elements
→ getCardClasses(card) produces positioning classes
→ rendered as <div className={positionClasses}>{element}</div>
Adding a new badge slot¶
Badge slots follow the same SlotDefinition shape but use SlotBadge as the component:
const myBadgeSlot: SlotDefinition<SlotBadgeProps> = {
id: "my-badge",
category: "badge", // not "stat-card"
component: SlotBadge,
priority: 40,
resolve(ctx) {
if (!someCondition(ctx)) return null
return { variant: "warn", label: "My Badge" }
},
}
SlotBadgeProps variants: "default", "accent", "warn", "danger". Badges are collected separately from stat-card slots and rendered as a horizontal pill row, not inside the bento grid.
Key files reference¶
| File | Purpose |
|---|---|
src/lib/slot-types.ts |
SlotCategory, SlotContext, ResolvedSlot types |
src/lib/grid-layout.ts |
findOptimalLayout4Col, findOptimalLayout3Col, findOptimalLayout2Col, getCardClasses |
src/components/tracker-detail/slot-registry.ts |
All slot definitions + SLOT_DEFINITIONS array + renderSlotElement |
src/components/ui/StatCard.tsx |
StatCard component (basic / stacked / ring) |
src/components/tracker-detail/AnalyticsTab.tsx |
Grid renderer — calls layout algorithms, maps card IDs to elements |
src/components/tracker-detail/CoreStatCards.tsx |
buildCoreStatDescriptors — the 8 fixed core stats |
src/components/tracker-detail/SlotRenderer.tsx |
Renders progress category slots above the grid |