From d02911714848d5bbb07475a66d793b76daf1cac7 Mon Sep 17 00:00:00 2001 From: Brock Jenkinson Date: Sun, 3 May 2026 20:55:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(v0.9):=20Phase=207=20=E2=80=94=20CategoryT?= =?UTF-8?q?ab=20sectioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat A–Z list per category with a sectioned page: Leaving this month / Available now / Out of season / Already donated. Empty groups hidden; donated items always land in Already donated. December→January wrap handled. Fossils/art (no months) collapse cleanly to Available now + Already donated. CategoryTab owns its own expandedId so only one row is open per tab and reacts to highlightId so jumps from Home shelves expand the row before ACCanvas's scroll-to fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 +++ CLAUDE.md | 1 + docs/v0.9-plan.md | 2 +- src/components/ACCanvas.tsx | 102 ++++------------ src/components/CategoryTab.tsx | 214 +++++++++++++++++++++++++++++++++ src/index.css | 72 +++++++++++ 6 files changed, 325 insertions(+), 80 deletions(-) create mode 100644 src/components/CategoryTab.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f48ded..a09b57b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. ## [Unreleased] — v0.9.0-beta (in progress) +### Added — Phase 7: CategoryTab sectioning +- **`CategoryTab` component** (`src/components/CategoryTab.tsx`) — replaces the inline category render in `ACCanvas`. Groups items into four sections (Leaving this month / Available now / Out of season / Already donated), each with an eyebrow header and item count. Empty groups are hidden. December→January wrap is handled via `next = currentMonth === 12 ? 1 : currentMonth + 1`. Donated items always land in "Already donated" regardless of season. Categories without month data (fossils, art) treat all non-donated items as "Available now". Owns its own `expandedId` state — only one row open at a time per tab — and reacts to `highlightId` by opening the matching row before ACCanvas's scroll-to fires. +- **Category page header** — Fraunces 44 `{donated} of {total} {category}` title and right-aligned meta line ("X% complete" + "Showing availability for {month}" for seasonal categories). Stats card-style header sits above the per-tab search bar. +- **Phase 7 CSS** appended to `src/index.css` (`.ac-category`, `.ac-category-head`, `.ac-category-title`, `.ac-category-meta`, `.ac-group`, `.ac-group-head`, `.ac-group-title`, `.ac-group-count`, plus `.ac-group-warn` / `.ac-group-accent` / `.ac-group-muted` / `.ac-group-done` tone modifiers). Header collapses to single column ≤700px and the category title shrinks to 32px. + +### Removed — Phase 7 +- Inline category render in `ACCanvas` (`CategoryProgress` import + flat `.ac-list` map). The progress display moves into the new category header. `CategoryProgress` itself remains in the codebase for now and is slated for removal during the Phase 9 stats rebuild per the v0.9 retirement list. + +### Decisions — Phase 7 +- **Section ordering is fixed and ungrouped categories collapse cleanly.** Fossils and art have no month data, so for those tabs only the "Available now" and "Already donated" groups can ever appear. The ordering still reads naturally; we don't special-case the layout for non-seasonal categories. +- **Per-tab search lives inside CategoryTab, not above it.** Keeping the search bar inside the new component lets the section grouping recompute on filter without re-flowing the page header. The header always shows totals for the full category, not the filtered subset, so users can see overall progress while narrowing the list. +- **Art keeps its bottom-sheet `DetailModal`.** The plan's "row click toggles inline expand" rule applies to fish/bugs/fossils; art was already on a different pattern in v0.8 and the v0.9 design preserves that. CategoryTab routes art clicks to the existing `onItemSelect` callback in `ACCanvas` and renders no inline panel for that category. +- **`ItemExpandPanel` is rendered as a sibling of `CollectibleRow` inside the same `.ac-list`.** This preserves the existing CSS that ties `.ac-row` divider + `.ac-row-pulse` keyframe to the row container without restructuring the row primitive (locked from Phase 5). + ### Added — Phase 6: HomeTab + ProgressMeter - **`ProgressMeter` component** (`src/components/ProgressMeter.tsx`) — segmented donation progress bar. 4 segments (fish/bugs/fossils/art) for ACGCN/ACWW/ACCF; 5 segments (adds sea) for ACNL/ACNH. Each segment uses its category-tinted Meadow chip token (`--chip-fish`/`--chip-bugs`/`--chip-fossils`/`--chip-art`/`--chip-sea`) and exposes a per-segment aria-label like "Fish: 12 of 40 donated". Pure helper `segmentsForGame` extracted to `src/components/progressMeterUtils.ts` and unit-tested. - **`HomeTab` rebuilt** (`src/components/HomeTab.tsx`) — new structure per v0.9 design: hero stat with italic Fraunces accent number ("X creatures still to donate this month"), warn-italic aside ("N leaving soon"), `ProgressMeter` directly underneath, 12-cell month strip with current-month highlight, "Leaving end of {month}" warn-toned shelf, "Just arrived" accent-toned shelf, and a "Latest donations" card. Sea creatures included in shelves and progress for ACNL/ACNH towns. Each shelf card / latest-donations row click fires `jumpTo(category, id)` which sets the active tab and `highlightId` so the matching row scrolls into view and pulses (Decision 10). Hero falls back to "X of Y donated" when the active game has no seasonal categories. diff --git a/CLAUDE.md b/CLAUDE.md index d0d3fc3..e029a5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ src/ ProgressMeter.tsx # v0.9 Phase 6: segmented progress bar (4 or 5 segments # gated by gameId; sea segment for ACNL/ACNH). progressMeterUtils.ts # Pure helper segmentsForGame (unit-tested). + CategoryTab.tsx # v0.9 Phase 7: sectioned category page (Leaving / Available / Out of season / Already donated) CollectibleRow.tsx # Single item row with donate toggle; shows chevron + rounded-top when expanded ItemExpandPanel.tsx # Inline accordion panel shown below CollectibleRow for fish/bugs/fossils Sidebar.tsx # v0.9 Phase 2: 280px left sidebar — brand, active town card, NavLink nav with counts, footer (replaces MuseumHeader/TabBar/TownSwitcher) diff --git a/docs/v0.9-plan.md b/docs/v0.9-plan.md index c788f90..3180701 100644 --- a/docs/v0.9-plan.md +++ b/docs/v0.9-plan.md @@ -467,7 +467,7 @@ Parallel work is allowed within a phase where components are independent. The ph --- -### Phase 7 — CategoryTab Sectioning +### Phase 7 — CategoryTab Sectioning ✅ **Branch:** `feature/v09-phase-7-sections` **Goal:** Group category items into 4 sections instead of a flat A–Z list. diff --git a/src/components/ACCanvas.tsx b/src/components/ACCanvas.tsx index f2d02fb..ff0db32 100644 --- a/src/components/ACCanvas.tsx +++ b/src/components/ACCanvas.tsx @@ -1,10 +1,9 @@ -import React from 'react'; import { useState, useMemo, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAppStore } from '../lib/store'; import { CATEGORY_META } from '../lib/categoryMeta'; import { downloadCSV } from '../lib/csvExport'; -import { filterByQuery, globalFilter, type AnyItem } from '../lib/utils'; +import { globalFilter, type AnyItem } from '../lib/utils'; import type { CategoryId } from '../lib/types'; import type { AppErrorKind } from '../lib/types'; import type { ViewId } from '../lib/viewTypes'; @@ -14,10 +13,7 @@ import ErrorBanner from './ErrorBanner'; import ErrorState from './ErrorState'; import { Sidebar } from './Sidebar'; -import { CollectibleRow } from './CollectibleRow'; -import { ItemExpandPanel } from './ItemExpandPanel'; -import { CategoryProgress } from './shared/CategoryProgress'; -import { SearchBar } from './shared/SearchBar'; +import { CategoryTab } from './CategoryTab'; import { EmptyState } from './shared/EmptyState'; import { DetailModal } from './modals/DetailModal'; @@ -96,7 +92,6 @@ export default function ACCanvas() { item: AnyItem; category: CategoryId; } | null>(null); - const [expandedId, setExpandedId] = useState(null); const [highlightId, setHighlightId] = useState(null); const { data, loading, loadError, reload } = useMuseumData( @@ -135,20 +130,18 @@ export default function ACCanvas() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.sea_creatures.length, loading, activeTab]); - // Reset per-tab search query, expanded item, and selected detail on tab changes + // Reset per-tab search query and selected detail on tab changes useEffect(() => { setQuery(''); - setExpandedId(null); setSelected(null); }, [activeTab]); // Scroll-to + highlight on jump (Decision 10). - // When highlightId changes, expand the matching row, scroll it into view, - // and let the .ac-row-pulse keyframe play. Clear after the animation duration - // so re-jumping to the same id retriggers the pulse. + // CategoryTab handles expanding the matching row when highlightId changes; + // here we scroll the row into view and clear after the animation duration so + // re-jumping to the same id retriggers the pulse. useEffect(() => { if (!highlightId) return; - setExpandedId(highlightId); const raf = requestAnimationFrame(() => { const el = document.querySelector( `[data-row-id="${CSS.escape(highlightId)}"]` @@ -170,16 +163,11 @@ export default function ACCanvas() { ? activeTab : null; - const activeItems = useMemo( + const activeItems = useMemo( () => (activeCat ? (data[activeCat] as AnyItem[]) : []), [activeCat, data] ); - const filtered = useMemo(() => { - if (!activeCat) return []; - return filterByQuery(activeItems, activeCat, query); - }, [activeItems, activeCat, query]); - const globalResults = useMemo(() => { if (!globalQuery.trim()) return null; return globalFilter(data as Record, globalQuery); @@ -300,66 +288,22 @@ export default function ACCanvas() { /> ) : ( - <> - - -
- {filtered.map(item => ( - - { - if (activeCat === 'art') { - setSelected({ item, category: activeCat! }); - } else { - setExpandedId(prev => - prev === item.id ? null : item.id - ); - } - }} - expanded={ - activeCat !== 'art' - ? expandedId === item.id - : undefined - } - highlighted={highlightId === item.id} - hemisphere={activeTown?.hemisphere ?? 'NH'} - currentMonth={new Date().getMonth() + 1} - /> - {activeCat !== 'art' && expandedId === item.id && ( - toggle(item.id)} - hemisphere={activeTown?.hemisphere ?? 'NH'} - currentMonth={new Date().getMonth() + 1} - /> - )} - - ))} - {filtered.length === 0 && ( - - )} -
- + + setSelected({ item, category: activeCat! }) + } + onToggle={id => toggle(id)} + catLabel={catLabel} + /> )} )} diff --git a/src/components/CategoryTab.tsx b/src/components/CategoryTab.tsx new file mode 100644 index 0000000..b46ff75 --- /dev/null +++ b/src/components/CategoryTab.tsx @@ -0,0 +1,214 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import type { CategoryId } from '../lib/types'; +import { + type AnyItem, + displayName, + itemMonths, + filterByQuery, +} from '../lib/utils'; +import { CollectibleRow } from './CollectibleRow'; +import { ItemExpandPanel } from './ItemExpandPanel'; +import { SearchBar } from './shared/SearchBar'; +import { EmptyState } from './shared/EmptyState'; + +interface SectionGroup { + id: 'leaving' | 'avail' | 'locked' | 'done'; + label: string; + tone: 'warn' | 'accent' | 'muted' | 'done'; + items: AnyItem[]; +} + +interface CategoryTabProps { + category: CategoryId; + items: AnyItem[]; + donated: Record; + donatedAt: Record; + hemisphere: 'NH' | 'SH'; + currentMonth: number; + query: string; + setQuery: (q: string) => void; + highlightId: string | null; + onItemSelect: (item: AnyItem) => void; + onToggle: (id: string) => void; + catLabel: string; +} + +export function CategoryTab({ + category, + items, + donated, + donatedAt, + hemisphere, + currentMonth, + query, + setQuery, + highlightId, + onItemSelect, + onToggle, + catLabel, +}: CategoryTabProps) { + const [expandedId, setExpandedId] = useState(null); + + // Reset expand when category changes + useEffect(() => { + setExpandedId(null); + }, [category]); + + // Open the expand panel when a highlight arrives (Decision 10). + useEffect(() => { + if (!highlightId) return; + if (category === 'art') return; // art uses modal, not inline + setExpandedId(highlightId); + }, [highlightId, category]); + + const filtered = useMemo( + () => filterByQuery(items, category, query), + [items, category, query] + ); + + const groups = useMemo(() => { + const leaving: AnyItem[] = []; + const avail: AnyItem[] = []; + const locked: AnyItem[] = []; + const done: AnyItem[] = []; + + const next = currentMonth === 12 ? 1 : currentMonth + 1; + + for (const it of filtered) { + if (donated[it.id]) { + done.push(it); + continue; + } + const months = itemMonths(it, category, hemisphere); + // Items without month data (fossils, art) are treated as always available. + const inSeason = + !months || months.length === 0 || months.includes(currentMonth); + const isLeaving = + !!months && + months.length > 0 && + months.includes(currentMonth) && + !months.includes(next); + + if (isLeaving) leaving.push(it); + else if (inSeason) avail.push(it); + else locked.push(it); + } + + const byName = (a: AnyItem, b: AnyItem) => + displayName(a, category).localeCompare(displayName(b, category)); + + return [ + { + id: 'leaving', + label: 'Leaving this month', + tone: 'warn', + items: leaving.sort(byName), + }, + { + id: 'avail', + label: 'Available now', + tone: 'accent', + items: avail.sort(byName), + }, + { + id: 'locked', + label: 'Out of season', + tone: 'muted', + items: locked.sort(byName), + }, + { + id: 'done', + label: 'Already donated', + tone: 'done', + items: done.sort(byName), + }, + ].filter(g => g.items.length > 0) as SectionGroup[]; + }, [filtered, donated, category, hemisphere, currentMonth]); + + const totalDonated = items.filter(i => donated[i.id]).length; + const total = items.length; + const pct = total === 0 ? 0 : Math.round((totalDonated / total) * 100); + const showMonthNote = category !== 'art' && category !== 'fossils'; + const monthName = new Date(2000, currentMonth - 1, 1).toLocaleString( + 'en-US', + { + month: 'long', + } + ); + + return ( +
+
+

+ {totalDonated} of {total} {catLabel.toLowerCase()} +

+
+ {pct}% complete + {showMonthNote && Showing availability for {monthName}} +
+
+ + + + {groups.length === 0 ? ( + + ) : ( + groups.map(g => ( +
+
+

{g.label}

+ {g.items.length} +
+
+ {g.items.map(item => ( + + { + if (category === 'art') { + onItemSelect(item); + } else { + setExpandedId(prev => + prev === item.id ? null : item.id + ); + } + }} + expanded={ + category !== 'art' ? expandedId === item.id : undefined + } + highlighted={highlightId === item.id} + hemisphere={hemisphere} + currentMonth={currentMonth} + /> + {category !== 'art' && expandedId === item.id && ( + onToggle(item.id)} + hemisphere={hemisphere} + currentMonth={currentMonth} + /> + )} + + ))} +
+
+ )) + )} +
+ ); +} diff --git a/src/index.css b/src/index.css index 9d9e95d..43586f2 100644 --- a/src/index.css +++ b/src/index.css @@ -826,6 +826,78 @@ .ac-home { display: flex; flex-direction: column; gap: 36px; } +/* ── Phase 7: Category tab sectioning ── */ +.ac-category { display: flex; flex-direction: column; gap: 18px; } +.ac-category-head { + display: flex; justify-content: space-between; align-items: flex-end; + gap: 16px; + margin-bottom: 6px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} +.ac-category-title { + font-family: var(--font-display); + font-weight: 400; + font-size: 44px; + line-height: 1; + letter-spacing: -0.02em; + color: var(--ink); + margin: 0; +} +.ac-category-title em { + font-style: italic; + color: var(--accent-ink); + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.ac-category-meta { + font-size: 13px; + color: var(--ink-soft); + text-align: right; + font-variant-numeric: tabular-nums; +} +.ac-category-meta strong { + display: block; + font-family: var(--font-display); + font-weight: 500; + color: var(--ink); + font-size: 18px; +} +.ac-group { display: flex; flex-direction: column; gap: 8px; } +.ac-group-head { + display: flex; + align-items: baseline; + gap: 12px; + padding: 0 4px; +} +.ac-group-title { + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + margin: 0; +} +.ac-group-warn .ac-group-title { color: var(--warn); } +.ac-group-accent .ac-group-title { color: var(--accent-ink); } +.ac-group-muted .ac-group-title { color: var(--ink-muted); } +.ac-group-done .ac-group-title { color: var(--ink-muted); } +.ac-group-count { + font-size: 12px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +@media (max-width: 700px) { + .ac-category-head { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .ac-category-meta { text-align: left; } + .ac-category-title { font-size: 32px; } +} + .ac-hero { display: flex; flex-direction: column; gap: 18px; }