Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,32 @@ All notable changes to this project are documented here.
- **Reset donations is disabled** when there's no active town, instead of being hidden — keeps the Danger zone shape stable across states.
- **Settings is a top-level route** (`/settings`) rather than nested under `/town/:townId/settings`. The settings page is a property of the app, not the town — and a user mid-reset-everything wouldn't have an active town to nest under.

### Added — Phase 4: TownManager drawer
- **`TownManager` component** (`src/components/TownManager.tsx`) — right-side drawer (420px) that mounts at the layout level in `App.tsx`, so it overlays every route (home, category tabs, settings) without z-index or `overflow-hidden` issues. Below 720px it renders as a bottom sheet. Contains: the list of towns with active-town indicator, inline row edit (name + hemisphere only), a `+ New town` form (name + game + hemisphere), and per-row delete with native `confirm()` guard.
- **`useUIStore`** (`src/lib/uiStore.ts`) — small non-persisted Zustand store for transient UI state (`townManagerOpen`, `townManagerForceCreate`). Exposes `openTownManager(forceCreate?)` and `closeTownManager()`.
- **Auto-open in create mode when no towns exist** — `App.tsx` opens the TownManager forced-create when `towns.length === 0`. The drawer hides its close button, ignores Esc, and ignores scrim clicks in this state — equivalent to the previous "required" behavior on `CreateTownModal`.
- **`GAME_LIST`** export in `src/lib/types.ts` — ordered array of `Game`, used by the create-town form's game selector.
- **TownManager styles** in `src/index.css` (`.ac-tm-scrim`, `.ac-tm-drawer`, `.ac-tm-row*`, `.ac-tm-form`, `.ac-tm-seg`, `.ac-tm-cta`, `.ac-tm-newform`, `.ac-tm-empty`, `ac-fade` / `ac-slide` / `ac-slide-up` keyframes). Bottom-sheet variant at `(max-width: 720px)`.

### Changed — Phase 4
- **`Sidebar`** — the Phase 2 bridge stubs are removed: the `window.prompt` switcher, the Edit / + New buttons inside the active-town card, and the inline NH/SH segmented toggle. The single `Switch town ›` button now opens the TownManager. Hemisphere is shown as a read-only `Hem. NH` / `Hem. SH` label (editing happens in the drawer). Sidebar no longer takes `onOpenCreateTown` / `onOpenEditTown` props.
- **`useAppStore.createTown`** signature is `(name, gameId, hemisphere?)` — `playerName` removed. **`useAppStore.updateTown`** signature is `(id, patch: TownPatch)` where `TownPatch` is `{ name?, hemisphere? }`. `gameId` is intentionally not part of the patch (Decision 1 — game is immutable post-create).
- **`Town` type** — `playerName: string` field removed (Decision 5). Existing values in localStorage are silently dropped on next write; no migration step required.
- **`downloadCSV` / `buildCSV`** no longer take a `playerName` argument; the "Player" row is removed from CSV exports.

### Removed — Phase 4
- **`CreateTownModal`** (`src/components/modals/CreateTownModal.tsx`) — replaced by TownManager's New Town form.
- **`EditTownModal`** (`src/components/modals/EditTownModal.tsx`) — replaced by TownManager's inline row edit.
- **`TownNameFields`** (`src/components/shared/TownNameFields.tsx`) — no remaining consumers.
- **v0.8.1 greyed-out-buttons stopgap** — no longer needed; the TownManager drawer mounts at the layout level and works on every route, resolving the issue the stopgap worked around.

### Decisions — Phase 4
- **Decision 1 honored** — the inline edit form has no game `<select>`. The game is shown as a read-only badge with the hint "Game can't be changed after creation." `handleSave` builds a patch object that contains only `name` and `hemisphere`, never `gameId`.
- **Decision 5 honored** — `playerName` removed from `Town`, store, CSV export, and tests. No migration callback because Zustand's `persist` simply ignores fields not in the schema.
- **Hemisphere persistence** — the store keeps `hemisphere: Hemisphere` (`'NH'` | `'SH'`, default `'NH'`). The drawer passes `null` from the patch when the game isn't ACNH; `updateTown` ignores nulls so a town's stored hemisphere never gets clobbered.
- **TownManager state lives in a separate `useUIStore`** rather than inside `useAppStore`, so the drawer's open/closed state isn't persisted to localStorage across reloads.
- **Auto-open path replaces the `required` flag** — `CreateTownModal`'s `required={noTowns}` pattern is replaced by `App.tsx` opening the drawer in `forceCreate` mode whenever the store hydrates with zero towns. Same UX (modal-like, can't be dismissed) implemented as a single behavior gate inside the new component.

## [v0.8.2-alpha] — 2026-05-01

### Added
Expand Down
7 changes: 3 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ npm install # Install dependencies
**Tests:** Vitest
**Store schema:** 3-level `donated[townId][gameId][itemId]` (as of v0.7); `Town` includes `hemisphere: 'NH' | 'SH'` (as of v0.8)
**Migration:** Zustand persist v3 + `bootstrapMigration.ts` — zero data loss for existing users
**Modal pattern:** `CreateTownModal` and `EditTownModal` use always-mounted `isOpen` prop pattern; overlay is a single `flex items-center justify-center` wrapper — no `overflow-y-auto`
**Town CRUD (v0.9 Phase 4):** `TownManager` drawer mounts at the App layout level (above the router), opened via `useUIStore.openTownManager()`. Replaces `CreateTownModal`, `EditTownModal`, and the `TownSwitcher` dropdown. When `towns.length === 0`, App.tsx auto-opens it in `forceCreate` mode (no close/Esc/scrim dismissal). Per Decision 1, edit form has no game `<select>` — game is read-only post-create.
**Shell layout (v0.9 Phase 2):** `Sidebar` (280px left, sticky) + `<main className="ac-main">` in CSS grid `280px 1fr`, max-width 1440px centered. Below 980px sidebar stacks above main. `MuseumHeader`, `TabBar`, `TownSwitcher` retired — nav lives in the sidebar.

### File Structure
Expand Down Expand Up @@ -69,9 +69,8 @@ src/
MonthGrid.tsx # 12-cell month availability grid
SearchBar.tsx # Per-tab inline search input
modals/
CreateTownModal.tsx # New town form with game selector
EditTownModal.tsx # Rename town form (gameId immutable)
DetailModal.tsx # Item detail sheet
TownManager.tsx # Right-side drawer for switch/edit/create/delete towns (v0.9 Phase 4)
views/
AnalyticsView.tsx # Charts + stats tab content
ActivityFeed.tsx # Recent donations list
Expand Down Expand Up @@ -170,7 +169,7 @@ See `.claude/rules/vercel.md` for full deployment rules. Key points:
- **issue #26** — Art tab persistent label — **fixed in v0.8.2 (PR #57)**; `setSelected(null)` added to tab-change `useEffect`
- **issue #31** — Create-town edge case; low priority, open
- **Sea creatures tab** — **shipped in v0.8.2 (PR #44, Closes #56)**; Sea tab visible for ACNL and ACNH towns
- **Edit/new-town buttons greyed out on Fish, Bugs, Fossils tabs** — intentional v0.8.1 stopgap. Modals (EditTownModal, CreateTownModal) are mounted in ACCanvas, which sits below the router layout; on museum category tabs the modal renders fine but overlapping scroll context causes visual issues. Buttons show `opacity: 0.4` + tooltip directing users to Home/Search/Recent Donations instead. Proper fix (lift modals to layout level) deferred to v0.9 UI revamp.
- **Edit/new-town buttons greyed out on Fish, Bugs, Fossils tabs** — **resolved in v0.9 Phase 4**. The `TownManager` drawer mounts at the App layout level and renders correctly on every route, so the overflow/z-index issues that motivated the stopgap no longer apply.

## ACCanvas.tsx

Expand Down
14 changes: 14 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Analytics } from '@vercel/analytics/react';
import ACCanvas from './components/ACCanvas';
import SettingsRoute from './components/SettingsRoute';
import { TownManager } from './components/TownManager';
import { useHydration } from './hooks/useHydration';
import ErrorBoundary from './components/ErrorBoundary';
import { useAppStore } from './lib/store';
import { useUIStore } from './lib/uiStore';

function RootRedirect() {
const towns = useAppStore(s => s.towns);
Expand All @@ -20,6 +23,16 @@ function RootRedirect() {

function App() {
const hydrated = useHydration();
const towns = useAppStore(s => s.towns);
const openTownManager = useUIStore(s => s.openTownManager);
const townManagerOpen = useUIStore(s => s.townManagerOpen);

// Force the TownManager open in create mode whenever there are no towns.
useEffect(() => {
if (hydrated && towns.length === 0 && !townManagerOpen) {
openTownManager(true);
}
}, [hydrated, towns.length, townManagerOpen, openTownManager]);

if (!hydrated) {
return (
Expand Down Expand Up @@ -54,6 +67,7 @@ function App() {
<Route path="/town/:townId/:tab" element={<ACCanvas />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<TownManager />
<Analytics />
{typeof window !== 'undefined' && (
<div
Expand Down
26 changes: 1 addition & 25 deletions src/components/ACCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import { CategoryProgress } from './shared/CategoryProgress';
import { SearchBar } from './shared/SearchBar';
import { EmptyState } from './shared/EmptyState';

import { CreateTownModal } from './modals/CreateTownModal';
import { EditTownModal } from './modals/EditTownModal';
import { DetailModal } from './modals/DetailModal';

import { GlobalSearchBar } from './search/GlobalSearchBar';
Expand Down Expand Up @@ -98,8 +96,6 @@ export default function ACCanvas() {
item: AnyItem;
category: CategoryId;
} | null>(null);
const [showCreateTown, setShowCreateTown] = useState(false);
const [showEditTown, setShowEditTown] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);

const { data, loading, loadError, reload } = useMuseumData(
Expand Down Expand Up @@ -170,13 +166,7 @@ export default function ACCanvas() {

function handleExport() {
if (!activeTown) return;
downloadCSV(
data,
activeTownDonated,
activeTownDonatedAt,
activeTown.name,
activeTown.playerName
);
downloadCSV(data, activeTownDonated, activeTownDonatedAt, activeTown.name);
}

function handleTabChange(tab: ViewId) {
Expand Down Expand Up @@ -216,8 +206,6 @@ export default function ACCanvas() {
townId={activeTownId}
data={data}
catCounts={catCounts}
onOpenCreateTown={() => setShowCreateTown(true)}
onOpenEditTown={() => setShowEditTown(true)}
onExport={handleExport}
/>
)}
Expand Down Expand Up @@ -365,18 +353,6 @@ export default function ACCanvas() {
</div>
</main>

<CreateTownModal
isOpen={noTowns || showCreateTown}
required={noTowns}
onClose={() => setShowCreateTown(false)}
/>

<EditTownModal
isOpen={showEditTown}
town={activeTown ?? null}
onClose={() => setShowEditTown(false)}
/>

{selected && !noTowns && (
<DetailModal
item={selected.item}
Expand Down
27 changes: 1 addition & 26 deletions src/components/SettingsRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { useState } from 'react';
import { useAppStore } from '../lib/store';
import { Sidebar } from './Sidebar';
import { SettingsPage } from './SettingsPage';
import { CreateTownModal } from './modals/CreateTownModal';
import { EditTownModal } from './modals/EditTownModal';
import { useMuseumData } from '../hooks/useMuseumData';
import { useCategoryStats } from '../hooks/useCategoryStats';
import { downloadCSV } from '../lib/csvExport';
Expand All @@ -30,21 +27,12 @@ export default function SettingsRoute() {
return s.donatedAt[s.activeTownId]?.[town.gameId] ?? EMPTY_DONATED_AT;
});

const [showCreateTown, setShowCreateTown] = useState(false);
const [showEditTown, setShowEditTown] = useState(false);

const { data } = useMuseumData(activeTown?.gameId ?? 'ACGCN');
const catCounts = useCategoryStats(data, activeTownDonated);

function handleExport() {
if (!activeTown) return;
downloadCSV(
data,
activeTownDonated,
activeTownDonatedAt,
activeTown.name,
activeTown.playerName
);
downloadCSV(data, activeTownDonated, activeTownDonatedAt, activeTown.name);
}

return (
Expand All @@ -54,25 +42,12 @@ export default function SettingsRoute() {
townId={activeTownId}
data={data}
catCounts={catCounts}
onOpenCreateTown={() => setShowCreateTown(true)}
onOpenEditTown={() => setShowEditTown(true)}
onExport={handleExport}
/>
)}
<main className="ac-main">
<SettingsPage />
</main>

<CreateTownModal
isOpen={showCreateTown}
required={false}
onClose={() => setShowCreateTown(false)}
/>
<EditTownModal
isOpen={showEditTown}
town={activeTown ?? null}
onClose={() => setShowEditTown(false)}
/>
</div>
);
}
77 changes: 6 additions & 71 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { useAppStore } from '../lib/store';
import type { Hemisphere } from '../lib/store';
import { useUIStore } from '../lib/uiStore';
import { GAMES } from '../lib/types';
import type { CategoryId, GameId } from '../lib/types';
import type { ViewId, AllData } from '../lib/viewTypes';
Expand All @@ -21,21 +21,16 @@ export function Sidebar({
townId,
data,
catCounts,
onOpenCreateTown,
onOpenEditTown,
onExport,
}: {
townId: string;
data: AllData;
catCounts: Stats;
onOpenCreateTown: () => void;
onOpenEditTown: () => void;
onExport: () => void;
}) {
const navigate = useNavigate();
const towns = useAppStore(s => s.towns);
const setActiveTown = useAppStore(s => s.setActiveTown);
const setTownHemisphere = useAppStore(s => s.setTownHemisphere);
const openTownManager = useUIStore(s => s.openTownManager);
const activeTown = towns.find(t => t.id === townId);

const gameId = activeTown?.gameId ?? 'ACGCN';
Expand All @@ -46,35 +41,6 @@ export function Sidebar({
return `/town/${townId}/${view}`;
}

function handleSwitchTown() {
// Phase 2 stub: cycles to the next town if one exists, otherwise opens
// CreateTownModal. Full TownManager drawer ships in Phase 4.
const others = towns.filter(t => t.id !== townId);
if (others.length === 0) {
onOpenCreateTown();
return;
}
if (others.length === 1) {
setActiveTown(others[0].id);
navigate(`/town/${others[0].id}/home`);
return;
}
// Multiple other towns — surface a quick prompt picker (temporary).
const labels = others
.map((t, i) => `${i + 1}. ${t.name} (${GAMES[t.gameId].shortName})`)
.join('\n');
const pick = window.prompt(
`Switch to which town?\n${labels}\n\nEnter a number, or cancel.`
);
if (!pick) return;
const idx = Number.parseInt(pick, 10) - 1;
const choice = others[idx];
if (choice) {
setActiveTown(choice.id);
navigate(`/town/${choice.id}/home`);
}
}

return (
<aside className="ac-sidebar">
<div className="ac-brand">
Expand Down Expand Up @@ -113,47 +79,16 @@ export function Sidebar({
{game.hasHemispheres && (
<>
<span className="ac-dot-sep">·</span>
<span
className="ac-hem-toggle"
role="group"
aria-label="Hemisphere"
>
{(['NH', 'SH'] as const).map(h => (
<button
key={h}
type="button"
className={`ac-hem-btn ${(activeTown.hemisphere ?? 'NH') === h ? 'is-active' : ''}`}
onClick={() =>
setTownHemisphere(activeTown.id, h as Hemisphere)
}
aria-pressed={(activeTown.hemisphere ?? 'NH') === h}
>
{h}
</button>
))}
</span>
<span>Hem. {activeTown.hemisphere ?? 'NH'}</span>
</>
)}
</div>
<div className="ac-town-actions">
<button className="ac-town-switch" onClick={handleSwitchTown}>
Switch town ›
</button>
<button
className="ac-town-edit"
onClick={onOpenEditTown}
title="Edit town"
aria-label="Edit town"
className="ac-town-switch"
onClick={() => openTownManager(false)}
>
Edit
</button>
<button
className="ac-town-edit"
onClick={onOpenCreateTown}
title="New town"
aria-label="New town"
>
+ New
Switch town ›
</button>
</div>
</div>
Expand Down
Loading
Loading