docs(18): create phase plan for global items and public profiles

This commit is contained in:
2026-04-05 12:52:55 +02:00
parent c9117cd51a
commit 37d5711475
6 changed files with 1032 additions and 2 deletions

View File

@@ -177,7 +177,13 @@ Plans:
3. A global item page shows basic info and how many users own it
4. User can edit their profile (display name, avatar, bio) and view their own public profile page
5. User can toggle a setup between public and private; public setups are viewable by anyone without logging in and appear on the owner's public profile
**Plans**: TBD
**Plans:** 5 plans
Plans:
- [ ] 18-01-PLAN.md — Schema foundation: globalItems, itemGlobalLinks, user profile columns, setup isPublic, Zod schemas, types, seed data
- [ ] 18-02-PLAN.md — Global item backend: service (search, owner count, link/unlink), routes, seed script, tests
- [ ] 18-03-PLAN.md — Profile and sharing backend: profile service, public profile/setup routes, auth middleware updates, tests
- [ ] 18-04-PLAN.md — Global item client: catalog browse/search page, detail page, link-to-global-item UI
- [ ] 18-05-PLAN.md — Profile and sharing client: profile edit in settings, public profile page, setup visibility toggle
**UI hint**: yes
## Progress
@@ -201,4 +207,4 @@ Plans:
| 15. External Authentication | v2.0 | 0/? | Not started | - |
| 16. Multi-User Data Model | v2.0 | 2/4 | Complete | 2026-04-05 |
| 17. Object Storage | v2.0 | 3/3 | Complete | 2026-04-05 |
| 18. Global Items & Public Profiles | v2.0 | 0/? | Not started | - |
| 18. Global Items & Public Profiles | v2.0 | 0/5 | Not started | - |

View File

@@ -0,0 +1,190 @@
---
phase: 18-global-items-public-profiles
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
- src/shared/schemas.ts
- src/shared/types.ts
- src/db/global-items-seed.json
autonomous: true
requirements: [GLOB-01, GLOB-02, PROF-01, PROF-03]
must_haves:
truths:
- "globalItems table exists with brand, model, category, weightGrams, priceCents, imageUrl, description, createdAt columns"
- "itemGlobalLinks junction table exists linking items to globalItems"
- "users table has displayName, avatarUrl, bio nullable columns"
- "setups table has isPublic boolean column defaulting to false"
- "Zod schemas exist for global item search, item linking, profile update, and setup visibility"
- "Types are inferred from Zod schemas and Drizzle tables, not manually duplicated"
artifacts:
- path: "src/db/schema.ts"
provides: "globalItems, itemGlobalLinks tables + users profile cols + setups isPublic"
contains: "globalItems"
- path: "src/shared/schemas.ts"
provides: "searchGlobalItemsSchema, linkItemSchema, updateProfileSchema"
contains: "searchGlobalItemsSchema"
- path: "src/shared/types.ts"
provides: "GlobalItem, ItemGlobalLink, UpdateProfile, LinkItem types"
contains: "GlobalItem"
- path: "src/db/global-items-seed.json"
provides: "Initial bikepacking gear catalog seed data"
min_lines: 20
key_links:
- from: "src/shared/types.ts"
to: "src/db/schema.ts"
via: "Drizzle $inferSelect"
pattern: "globalItems\\.\\$inferSelect"
- from: "src/shared/types.ts"
to: "src/shared/schemas.ts"
via: "Zod z.infer"
pattern: "z\\.infer.*updateProfileSchema"
---
<objective>
Define all schema foundations for Phase 18: new database tables (globalItems, itemGlobalLinks), column additions to users (profile fields) and setups (isPublic), Zod validation schemas, TypeScript types, and seed data file.
Purpose: Every subsequent plan depends on these schema definitions. Defining contracts first prevents the scavenger hunt anti-pattern.
Output: Updated schema.ts, schemas.ts, types.ts, and global-items-seed.json
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@src/db/schema.ts
@src/shared/schemas.ts
@src/shared/types.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Schema tables and column additions</name>
<files>src/db/schema.ts</files>
<read_first>src/db/schema.ts</read_first>
<action>
Add `boolean` to the drizzle-orm/pg-core imports (per D-01, D-12).
Add the `globalItems` table per D-01:
- `id: serial("id").primaryKey()`
- `brand: text("brand").notNull()`
- `model: text("model").notNull()`
- `category: text("category")`
- `weightGrams: doublePrecision("weight_grams")`
- `priceCents: integer("price_cents")`
- `imageUrl: text("image_url")`
- `description: text("description")`
- `createdAt: timestamp("created_at").defaultNow().notNull()`
Add the `itemGlobalLinks` junction table per D-02:
- `id: serial("id").primaryKey()`
- `itemId: integer("item_id").notNull().references(() => items.id, { onDelete: "cascade" }).unique()` — each user item links to at most one global item
- `globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" })`
Extend the `users` table per D-08 — add three nullable text columns:
- `displayName: text("display_name")`
- `avatarUrl: text("avatar_url")`
- `bio: text("bio")`
Extend the `setups` table per D-12 — add:
- `isPublic: boolean("is_public").notNull().default(false)`
Place new tables after setupItems section. Export all new tables.
After schema changes, run `bun run db:generate` to create the migration, then `bun run db:push` to verify it applies cleanly.
</action>
<verify>
<automated>bun run db:generate 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "globalItems" src/db/schema.ts
- grep -q "itemGlobalLinks" src/db/schema.ts
- grep -q "displayName" src/db/schema.ts
- grep -q "isPublic" src/db/schema.ts
- grep -q "boolean" src/db/schema.ts
</acceptance_criteria>
<done>All four schema additions (globalItems table, itemGlobalLinks table, users profile columns, setups isPublic) are defined and exported. Migration generated successfully.</done>
</task>
<task type="auto">
<name>Task 2: Zod schemas, types, and seed data</name>
<files>src/shared/schemas.ts, src/shared/types.ts, src/db/global-items-seed.json</files>
<read_first>src/shared/schemas.ts, src/shared/types.ts</read_first>
<action>
**schemas.ts** — Add the following Zod schemas at the end of the file:
1. `searchGlobalItemsSchema` per D-04 and D-16:
```
z.object({ q: z.string().optional() })
```
2. `linkItemSchema` per D-18:
```
z.object({ globalItemId: z.number().int().positive() })
```
3. `updateProfileSchema` per D-08, D-21:
```
z.object({
displayName: z.string().max(100).optional(),
avatarUrl: z.string().optional(),
bio: z.string().max(500).optional(),
})
```
4. Update the existing `updateSetupSchema` to include `isPublic` per D-12, D-14:
Add `isPublic: z.boolean().optional()` to the existing schema object.
5. Update the existing `createSetupSchema` if it exists — add `isPublic: z.boolean().optional().default(false)`.
**types.ts** — Add type exports:
- `export type GlobalItem = typeof globalItems.$inferSelect;` (import globalItems, itemGlobalLinks from schema)
- `export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect;`
- `export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>;`
- `export type LinkItem = z.infer<typeof linkItemSchema>;`
- `export type UpdateProfile = z.infer<typeof updateProfileSchema>;`
**global-items-seed.json** — Create per D-06, D-07. Array of 15-20 bikepacking gear items covering categories like bags (frame bags, handlebar bags, saddle bags), shelters (tents, bivvies, tarps), sleep systems (sleeping bags, pads), cooking, hydration, and lighting. Each object has: `brand`, `model`, `category`, `weightGrams`, `priceCents`, `description`. Use real product names and approximate specs (e.g., Revelate Designs Terrapin, Apidura Expedition Handlebar Pack, Sea to Summit Spark SP1, MSR PocketRocket 2, Nemo Tensor Ultralight). Do NOT include `id` or `createdAt`.
</action>
<verify>
<automated>bun run lint 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "searchGlobalItemsSchema" src/shared/schemas.ts
- grep -q "linkItemSchema" src/shared/schemas.ts
- grep -q "updateProfileSchema" src/shared/schemas.ts
- grep -q "GlobalItem" src/shared/types.ts
- grep -q "UpdateProfile" src/shared/types.ts
- test -f src/db/global-items-seed.json
</acceptance_criteria>
<done>Zod schemas cover global item search, item linking, profile update, and setup visibility. Types inferred from schemas and Drizzle tables. Seed file has 15-20 bikepacking items with real product names.</done>
</task>
</tasks>
<verification>
- `bun run lint` passes with no errors
- `grep -c "export const" src/db/schema.ts` shows new table exports
- `bun run db:generate` creates a clean migration
- Seed JSON is valid: `node -e "JSON.parse(require('fs').readFileSync('src/db/global-items-seed.json','utf8'))"`
</verification>
<success_criteria>
Schema.ts has globalItems, itemGlobalLinks, user profile columns, and setup isPublic. Schemas.ts has all new Zod validators. Types.ts exports all new types. Seed JSON file exists with 15-20 items. Lint passes.
</success_criteria>
<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,221 @@
---
phase: 18-global-items-public-profiles
plan: 02
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- src/server/services/global-item.service.ts
- src/server/routes/global-items.ts
- src/db/seed-global-items.ts
- src/db/seed.ts
- src/server/index.ts
- tests/services/global-item.service.test.ts
- tests/routes/global-items.test.ts
autonomous: true
requirements: [GLOB-01, GLOB-02, GLOB-03, GLOB-04, GLOB-05]
must_haves:
truths:
- "GET /api/global-items returns the full global catalog without authentication"
- "GET /api/global-items?q=revelate returns only items matching brand or model (case-insensitive)"
- "GET /api/global-items/:id returns item details with an ownerCount field"
- "POST /api/items/:id/link links a user item to a global item (requires auth)"
- "DELETE /api/items/:id/link removes the link (requires auth)"
- "Seed data imports on first run and is idempotent on subsequent runs"
artifacts:
- path: "src/server/services/global-item.service.ts"
provides: "searchGlobalItems, getGlobalItemWithOwnerCount, linkItemToGlobal, unlinkItemFromGlobal"
exports: ["searchGlobalItems", "getGlobalItemWithOwnerCount", "linkItemToGlobal", "unlinkItemFromGlobal"]
- path: "src/server/routes/global-items.ts"
provides: "GET /api/global-items, GET /api/global-items/:id"
min_lines: 30
- path: "src/db/seed-global-items.ts"
provides: "seedGlobalItems function"
exports: ["seedGlobalItems"]
- path: "tests/services/global-item.service.test.ts"
provides: "Service tests for GLOB-01 through GLOB-05"
min_lines: 50
- path: "tests/routes/global-items.test.ts"
provides: "Route tests for global item endpoints"
min_lines: 40
key_links:
- from: "src/server/routes/global-items.ts"
to: "src/server/services/global-item.service.ts"
via: "import and call service functions"
pattern: "searchGlobalItems|getGlobalItemWithOwnerCount"
- from: "src/server/index.ts"
to: "src/server/routes/global-items.ts"
via: "app.route registration"
pattern: "app\\.route.*global-items"
- from: "src/db/seed.ts"
to: "src/db/seed-global-items.ts"
via: "seedGlobalItems call in seedDefaults"
pattern: "seedGlobalItems"
---
<objective>
Build the complete global item catalog backend: service layer with ILIKE search and owner count, route handlers for public GET + authenticated link/unlink, seed script integration, and auth middleware updates for public access.
Purpose: Delivers GLOB-01 through GLOB-05 server-side. Users can search gear, view details with owner counts, and link personal items to global entries.
Output: global-item.service.ts, global-items.ts routes, seed-global-items.ts, updated index.ts + seed.ts, service + route tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md
@src/db/schema.ts
@src/server/services/item.service.ts
@src/server/routes/items.ts
@src/server/index.ts
@src/server/middleware/auth.ts
@src/db/seed.ts
@tests/helpers/db.ts
<interfaces>
<!-- From Plan 01 outputs (schema.ts additions): -->
export const globalItems = pgTable("global_items", {
id: serial("id").primaryKey(),
brand: text("brand").notNull(),
model: text("model").notNull(),
category: text("category"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
imageUrl: text("image_url"),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const itemGlobalLinks = pgTable("item_global_links", {
id: serial("id").primaryKey(),
itemId: integer("item_id").notNull().references(() => items.id, { onDelete: "cascade" }).unique(),
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
});
<!-- From schemas.ts: -->
export const searchGlobalItemsSchema = z.object({ q: z.string().optional() });
export const linkItemSchema = z.object({ globalItemId: z.number().int().positive() });
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Global item service + seed script + tests</name>
<files>src/server/services/global-item.service.ts, src/db/seed-global-items.ts, src/db/seed.ts, tests/services/global-item.service.test.ts</files>
<read_first>src/server/services/item.service.ts, src/db/seed.ts, tests/helpers/db.ts, src/db/schema.ts, src/shared/schemas.ts</read_first>
<behavior>
- searchGlobalItems(db) returns all global items when no query provided
- searchGlobalItems(db, "revelate") returns only items with "revelate" in brand or model (case-insensitive)
- searchGlobalItems(db, "100%") does not match everything (wildcard chars escaped)
- getGlobalItemWithOwnerCount(db, id) returns item with ownerCount: 0 when no links exist
- getGlobalItemWithOwnerCount(db, id) returns ownerCount: 2 when 2 user items are linked
- getGlobalItemWithOwnerCount(db, nonExistentId) returns null
- linkItemToGlobal(db, itemId, globalItemId) creates link, returns link row
- linkItemToGlobal(db, itemId, globalItemId) when already linked throws/returns error
- unlinkItemFromGlobal(db, itemId) removes the link
- seedGlobalItems(db) inserts seed data on first call, skips on second call (idempotent)
</behavior>
<action>
**global-item.service.ts**: Create at `src/server/services/global-item.service.ts`. Follow the existing service pattern (import db type from `../../db/index.ts`, use `type Db = typeof prodDb`).
Functions:
1. `searchGlobalItems(db: Db, query?: string)` — No userId param (per D-03, public data). Uses `ilike` from drizzle-orm on brand and model columns. Escape `%` and `_` in query before wrapping in `%..%` pattern. Return `db.select().from(globalItems)` with optional where clause using `or(ilike(brand, pattern), ilike(model, pattern))`.
2. `getGlobalItemWithOwnerCount(db: Db, id: number)` — Select from globalItems where id matches. Then count from itemGlobalLinks where globalItemId matches. Return `{ ...item, ownerCount }` or null.
3. `linkItemToGlobal(db: Db, itemId: number, globalItemId: number)` — Insert into itemGlobalLinks. Let unique constraint on itemId handle duplicates (catch and return 409-style error).
4. `unlinkItemFromGlobal(db: Db, itemId: number)` — Delete from itemGlobalLinks where itemId matches. Return deleted count.
**seed-global-items.ts**: Create at `src/db/seed-global-items.ts`.
- `export async function seedGlobalItems(db: Db)` — Check if any rows exist in globalItems table. If yes, return early. If no, import from `./global-items-seed.json` and insert all rows.
**seed.ts**: Add `seedGlobalItems(prodDb)` call to the existing `seedDefaults()` function (after existing seeds).
**Tests**: Write tests FIRST (TDD). Use `createTestDb()` from test helper. Insert test global items directly in test setup. For owner count tests, create test user items and link them.
</action>
<verify>
<automated>bun test tests/services/global-item.service.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "searchGlobalItems" src/server/services/global-item.service.ts
- grep -q "getGlobalItemWithOwnerCount" src/server/services/global-item.service.ts
- grep -q "linkItemToGlobal" src/server/services/global-item.service.ts
- grep -q "unlinkItemFromGlobal" src/server/services/global-item.service.ts
- grep -q "seedGlobalItems" src/db/seed-global-items.ts
- grep -q "seedGlobalItems" src/db/seed.ts
- grep -q "ilike" src/server/services/global-item.service.ts
- test -f tests/services/global-item.service.test.ts
</acceptance_criteria>
<done>All 4 service functions pass tests. Seed script is idempotent. ILIKE search works case-insensitively with wildcard escaping.</done>
</task>
<task type="auto">
<name>Task 2: Global item routes + auth middleware update + route tests</name>
<files>src/server/routes/global-items.ts, src/server/routes/items.ts, src/server/index.ts, tests/routes/global-items.test.ts</files>
<read_first>src/server/routes/items.ts, src/server/index.ts, src/server/middleware/auth.ts, tests/routes/setups.test.ts</read_first>
<action>
**global-items.ts route**: Create at `src/server/routes/global-items.ts`. Follow existing route pattern (Hono app with Env type).
1. `GET /` (maps to `/api/global-items`) — per D-16. Read `q` from query string. Call `searchGlobalItems(db, q)`. Return JSON array. No auth needed.
2. `GET /:id` (maps to `/api/global-items/:id`) — per D-17. Parse id with `parseId`. Call `getGlobalItemWithOwnerCount(db, id)`. Return 404 if null, otherwise JSON with ownerCount.
**items.ts route updates** — per D-18, D-19. Add two new endpoints to existing item routes:
3. `POST /:id/link` — Validate body with `linkItemSchema` via zValidator. Get userId from context. Verify the item belongs to the user (call getItemById first). Call `linkItemToGlobal(db, itemId, globalItemId)`. Return 201 on success, 409 if already linked, 404 if item not found.
4. `DELETE /:id/link` — Get userId. Verify item ownership. Call `unlinkItemFromGlobal(db, itemId)`. Return 200.
**index.ts updates**:
1. Import `globalItemRoutes` from routes/global-items.ts
2. Register: `app.route("/api/global-items", globalItemRoutes)` — place after existing route registrations.
3. Update auth middleware skip: Add `if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next();` before the `requireAuth` call, per Research Pattern 3 recommendation.
**Route tests**: Follow existing route test pattern (from tests/routes/setups.test.ts). Create test Hono app with db middleware + auth middleware. Test:
- GET /api/global-items returns 200 without auth
- GET /api/global-items?q=tent filters results
- GET /api/global-items/:id returns item with ownerCount
- GET /api/global-items/999 returns 404
- POST /api/items/:id/link returns 201
- POST /api/items/:id/link duplicate returns 409
- DELETE /api/items/:id/link returns 200
</action>
<verify>
<automated>bun test tests/routes/global-items.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "global-items" src/server/index.ts
- grep -q "globalItemRoutes\|globalItems" src/server/routes/global-items.ts
- grep -q "link" src/server/routes/items.ts
- grep -q "api/global-items" src/server/index.ts
- test -f tests/routes/global-items.test.ts
</acceptance_criteria>
<done>Global item endpoints work: search returns filtered results, detail includes ownerCount, link/unlink modify junction table. Auth middleware allows unauthenticated GET access to /api/global-items. All route tests pass.</done>
</task>
</tasks>
<verification>
- `bun test tests/services/global-item.service.test.ts` — all service tests pass
- `bun test tests/routes/global-items.test.ts` — all route tests pass
- `bun test` — full suite passes (no regressions)
</verification>
<success_criteria>
Global item catalog is fully functional server-side. Search, detail with owner count, link/unlink all work. Seed data imports idempotently. Public GET endpoints work without auth. All tests pass.
</success_criteria>
<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,210 @@
---
phase: 18-global-items-public-profiles
plan: 03
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- src/server/services/profile.service.ts
- src/server/routes/profiles.ts
- src/server/routes/auth.ts
- src/server/services/setup.service.ts
- src/server/routes/setups.ts
- src/server/index.ts
- tests/services/profile.service.test.ts
- tests/routes/profiles.test.ts
autonomous: true
requirements: [PROF-01, PROF-02, PROF-03, PROF-04, PROF-05]
must_haves:
truths:
- "PUT /api/auth/profile updates display name, avatar URL, and bio for the authenticated user"
- "GET /api/users/:id/profile returns public profile data (name, avatar, bio, public setups) without auth"
- "PATCH or PUT to setup with isPublic=true makes the setup public"
- "GET /api/setups/:id/public returns setup details without auth (only if isPublic is true)"
- "GET /api/setups/:id/public returns 404 for private setups"
- "Public profile lists only public setups, not private ones"
artifacts:
- path: "src/server/services/profile.service.ts"
provides: "updateProfile, getPublicProfile"
exports: ["updateProfile", "getPublicProfile"]
- path: "src/server/routes/profiles.ts"
provides: "GET /api/users/:id/profile route"
min_lines: 20
- path: "tests/services/profile.service.test.ts"
provides: "Profile service tests"
min_lines: 40
- path: "tests/routes/profiles.test.ts"
provides: "Profile and public setup route tests"
min_lines: 50
key_links:
- from: "src/server/routes/profiles.ts"
to: "src/server/services/profile.service.ts"
via: "import and call"
pattern: "getPublicProfile"
- from: "src/server/routes/auth.ts"
to: "src/server/services/profile.service.ts"
via: "import updateProfile"
pattern: "updateProfile"
- from: "src/server/index.ts"
to: "src/server/routes/profiles.ts"
via: "app.route registration"
pattern: "app\\.route.*profiles"
---
<objective>
Build the user profiles and public sharing backend: profile service for CRUD and public profile data, profile update endpoint on auth routes, public profile route, setup isPublic toggle, and public setup view endpoint.
Purpose: Delivers PROF-01 through PROF-05 server-side. Users can edit their profile, toggle setup visibility, and anyone can view public profiles and setups without auth.
Output: profile.service.ts, profiles.ts routes, updated auth.ts + setup service/routes + index.ts, service + route tests
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md
@src/server/services/setup.service.ts
@src/server/routes/setups.ts
@src/server/routes/auth.ts
@src/server/index.ts
@src/server/middleware/auth.ts
@tests/helpers/db.ts
<interfaces>
<!-- From Plan 01 (users table additions): -->
users table now has:
displayName: text("display_name"), // nullable
avatarUrl: text("avatar_url"), // nullable
bio: text("bio"), // nullable
<!-- From Plan 01 (setups table addition): -->
setups table now has:
isPublic: boolean("is_public").notNull().default(false),
<!-- From schemas.ts: -->
export const updateProfileSchema = z.object({
displayName: z.string().max(100).optional(),
avatarUrl: z.string().optional(),
bio: z.string().max(500).optional(),
});
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Profile service + setup visibility + tests</name>
<files>src/server/services/profile.service.ts, src/server/services/setup.service.ts, tests/services/profile.service.test.ts</files>
<read_first>src/server/services/setup.service.ts, src/db/schema.ts, tests/helpers/db.ts, src/shared/schemas.ts</read_first>
<behavior>
- updateProfile(db, userId, { displayName: "Alice" }) updates user and returns updated row
- updateProfile(db, userId, { bio: "Bikepacker" }) updates bio only, leaves other fields untouched
- updateProfile(db, userId, {}) does nothing harmful, returns user
- getPublicProfile(db, userId) returns { id, displayName, avatarUrl, bio, setups: [] } when user has no public setups
- getPublicProfile(db, userId) returns only public setups in the setups array (not private ones)
- getPublicProfile(db, nonExistentId) returns null
- getPublicSetupWithItems(db, setupId) returns setup with items when isPublic is true
- getPublicSetupWithItems(db, setupId) returns null when isPublic is false
- Updated setup service: createSetup and updateSetup handle isPublic field
</behavior>
<action>
**profile.service.ts**: Create at `src/server/services/profile.service.ts`. Follow service pattern.
1. `updateProfile(db: Db, userId: number, data: UpdateProfile)` — Use `db.update(users).set(data).where(eq(users.id, userId)).returning()`. Return updated user or null if not found. Only set fields that are present in data (Drizzle handles undefined correctly).
2. `getPublicProfile(db: Db, userId: number)` — Select id, displayName, avatarUrl, bio from users. Then select id, name, createdAt from setups where userId matches AND isPublic is true. Return `{ ...user, setups: publicSetups }` or null.
3. `getPublicSetupWithItems(db: Db, setupId: number)` — Similar to existing `getSetupWithItems` but: no userId param, adds `eq(setups.isPublic, true)` to where clause. Returns null if setup doesn't exist or is private. Include items via setupItems join (same pattern as existing function). Include weight/cost aggregates.
**setup.service.ts updates**:
- Update `createSetup` to accept and persist `isPublic` from data (default false if not provided).
- Update `updateSetup` to accept and persist `isPublic` if provided.
- Update `getAllSetups` return fields to include `isPublic`.
- Update `getSetupWithItems` return to include `isPublic`.
**Tests**: Write tests FIRST. Use `createTestDb()`. Create user profile data via direct db.update. Create setups with isPublic true/false to test filtering.
</action>
<verify>
<automated>bun test tests/services/profile.service.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "updateProfile" src/server/services/profile.service.ts
- grep -q "getPublicProfile" src/server/services/profile.service.ts
- grep -q "getPublicSetupWithItems" src/server/services/profile.service.ts
- grep -q "isPublic" src/server/services/setup.service.ts
- test -f tests/services/profile.service.test.ts
</acceptance_criteria>
<done>Profile service and public setup service functions pass all tests. Setup service handles isPublic in create/update/list/detail.</done>
</task>
<task type="auto">
<name>Task 2: Profile routes + public setup route + auth middleware + route tests</name>
<files>src/server/routes/profiles.ts, src/server/routes/auth.ts, src/server/routes/setups.ts, src/server/index.ts, tests/routes/profiles.test.ts</files>
<read_first>src/server/routes/auth.ts, src/server/routes/setups.ts, src/server/index.ts, tests/routes/setups.test.ts</read_first>
<action>
**profiles.ts route**: Create at `src/server/routes/profiles.ts`.
1. `GET /:id/profile` (maps to `/api/users/:id/profile`) — per D-20. Parse id with parseId. Call `getPublicProfile(db, id)`. Return 404 if null, otherwise JSON. No auth needed.
**auth.ts updates** — per D-21:
2. `PUT /profile` (maps to `/api/auth/profile`) — Validate body with `updateProfileSchema` via zValidator. Get userId from context. Call `updateProfile(db, userId, body)`. Return updated profile JSON.
**setups.ts updates** — per D-22:
3. Add `GET /:id/public` endpoint — Parse id with parseId. Call `getPublicSetupWithItems(db, id)`. Return 404 if null (setup not found or is private). Return JSON with setup details and items. This route exists within the existing setup routes file, but the auth middleware skip handles making it public.
4. Ensure existing PUT /:id passes isPublic from body through to updateSetup service function.
**index.ts updates**:
1. Import `profileRoutes` from routes/profiles.ts
2. Register: `app.route("/api/users", profileRoutes)`
3. Update auth middleware skip: Add conditions for:
- `c.req.path.match(/^\/api\/users\/\d+\/profile$/) && c.req.method === "GET"` — skip auth
- `c.req.path.match(/^\/api\/setups\/\d+\/public$/) && c.req.method === "GET"` — skip auth
**Route tests**: Test:
- GET /api/users/:id/profile returns 200 without auth, includes public setups only
- GET /api/users/999/profile returns 404
- PUT /api/auth/profile returns 200 with updated fields (requires auth)
- PUT /api/auth/profile without auth returns 401
- GET /api/setups/:id/public returns 200 for public setup without auth
- GET /api/setups/:id/public returns 404 for private setup
</action>
<verify>
<automated>bun test tests/routes/profiles.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "profileRoutes\|profile" src/server/routes/profiles.ts
- grep -q "profile" src/server/routes/auth.ts
- grep -q "public" src/server/routes/setups.ts
- grep -q "api/users" src/server/index.ts
- grep -q "api/setups.*public\|api/users.*profile" src/server/index.ts
- test -f tests/routes/profiles.test.ts
</acceptance_criteria>
<done>Public profile endpoint returns user info + public setups. Profile update requires auth. Public setup view works without auth and returns 404 for private setups. Auth middleware correctly skips public routes. All route tests pass.</done>
</task>
</tasks>
<verification>
- `bun test tests/services/profile.service.test.ts` — all service tests pass
- `bun test tests/routes/profiles.test.ts` — all route tests pass
- `bun test` — full suite passes (no regressions from setup service changes)
</verification>
<success_criteria>
Profile CRUD works server-side. Public profile shows user info and public setups only. Setup visibility toggle persists. Public setup endpoint serves setup details without auth. Auth middleware correctly routes public/private access. All tests pass.
</success_criteria>
<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,198 @@
---
phase: 18-global-items-public-profiles
plan: 04
type: execute
wave: 3
depends_on: ["18-02"]
files_modified:
- src/client/hooks/useGlobalItems.ts
- src/client/routes/global-items/index.tsx
- src/client/routes/global-items/$globalItemId.tsx
- src/client/components/GlobalItemCard.tsx
- src/client/components/LinkToGlobalItem.tsx
- src/client/lib/api.ts
autonomous: false
requirements: [GLOB-03, GLOB-04, GLOB-05]
must_haves:
truths:
- "User can browse a global catalog page listing all global items with brand, model, category"
- "User can search the global catalog by typing a query and results filter in real-time"
- "User can click a global item to see its detail page with specs, image, description, and owner count"
- "User can link a personal collection item to a global item via a UI control"
artifacts:
- path: "src/client/hooks/useGlobalItems.ts"
provides: "useGlobalItems, useGlobalItem, useLinkItem, useUnlinkItem hooks"
exports: ["useGlobalItems", "useGlobalItem"]
- path: "src/client/routes/global-items/index.tsx"
provides: "Global catalog browse/search page"
min_lines: 40
- path: "src/client/routes/global-items/$globalItemId.tsx"
provides: "Global item detail page with owner count"
min_lines: 30
- path: "src/client/components/GlobalItemCard.tsx"
provides: "Card component for global item in list"
min_lines: 20
key_links:
- from: "src/client/routes/global-items/index.tsx"
to: "src/client/hooks/useGlobalItems.ts"
via: "useGlobalItems hook"
pattern: "useGlobalItems"
- from: "src/client/hooks/useGlobalItems.ts"
to: "/api/global-items"
via: "apiGet fetch"
pattern: "apiGet.*global-items"
---
<objective>
Build the global item catalog client: search/browse page, detail page with owner count, item cards, and link-to-global-item UI. Users can discover gear and connect their personal items to the shared catalog.
Purpose: Delivers the client-side experience for GLOB-03 (search), GLOB-04 (linking), and GLOB-05 (detail with owner count).
Output: useGlobalItems hook, catalog browse page, detail page, GlobalItemCard, LinkToGlobalItem component
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md
@src/client/hooks/useItems.ts
@src/client/lib/api.ts
@src/client/routes/collection/index.tsx
<interfaces>
<!-- API endpoints from Plan 02: -->
GET /api/global-items?q=string -> GlobalItem[]
GET /api/global-items/:id -> { ...GlobalItem, ownerCount: number }
POST /api/items/:id/link { globalItemId: number } -> ItemGlobalLink (201)
DELETE /api/items/:id/link -> 200
<!-- Types from Plan 01: -->
type GlobalItem = { id, brand, model, category, weightGrams, priceCents, imageUrl, description, createdAt }
type GlobalItemWithOwnerCount = GlobalItem & { ownerCount: number }
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Global item hooks and catalog pages</name>
<files>src/client/hooks/useGlobalItems.ts, src/client/routes/global-items/index.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/components/GlobalItemCard.tsx</files>
<read_first>src/client/hooks/useItems.ts, src/client/lib/api.ts, src/client/routes/collection/index.tsx</read_first>
<action>
**useGlobalItems.ts** hook: Create at `src/client/hooks/useGlobalItems.ts`. Follow existing hook pattern from useItems.ts.
1. `useGlobalItems(query?: string)``useQuery` with key `["global-items", query]`, fetches `apiGet<GlobalItem[]>("/api/global-items" + (query ? "?q=" + encodeURIComponent(query) : ""))`. Use 300ms debounced query value for search (or accept debounce at the component level).
2. `useGlobalItem(id: number | null)``useQuery` with key `["global-items", id]`, fetches `apiGet<GlobalItemWithOwnerCount>("/api/global-items/${id}")`, `enabled: id != null`.
3. `useLinkItem()``useMutation` calling `apiPost("/api/items/${itemId}/link", { globalItemId })`. On success, invalidate `["items"]` and `["global-items"]` query keys.
4. `useUnlinkItem()``useMutation` calling `apiDelete("/api/items/${itemId}/link")`. On success, invalidate same keys.
**GlobalItemCard.tsx**: Create at `src/client/components/GlobalItemCard.tsx`. Card displaying brand, model, category badge, weight (formatted as g/kg), price (formatted from cents). Links to `/global-items/${item.id}` detail page. Show image thumbnail if imageUrl exists. Light/airy Tailwind styling matching existing collection cards.
**global-items/index.tsx**: Catalog browse/search page.
- Search input at top with placeholder "Search gear by brand or model..."
- Debounce input by 300ms before passing to `useGlobalItems(debouncedQuery)`
- Grid of GlobalItemCard components (responsive: 1 col mobile, 2 cols md, 3 cols lg)
- Loading skeleton while fetching
- Empty state: "No items found" or "Search the global gear catalog"
- TanStack Router: `createFileRoute("/global-items/")` with component export
**global-items/$globalItemId.tsx**: Detail page.
- TanStack Router: `createFileRoute("/global-items/$globalItemId")` with params loader
- Fetch single item with `useGlobalItem(Number(globalItemId))`
- Display: brand, model, category, weight, price, description, image (full size)
- Show owner count badge: "{N} users own this" or "Be the first to add this"
- Back link to catalog
</action>
<verify>
<automated>bun run lint 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "useGlobalItems" src/client/hooks/useGlobalItems.ts
- grep -q "useGlobalItem" src/client/hooks/useGlobalItems.ts
- grep -q "useLinkItem" src/client/hooks/useGlobalItems.ts
- test -f src/client/routes/global-items/index.tsx
- test -f "src/client/routes/global-items/\$globalItemId.tsx"
- test -f src/client/components/GlobalItemCard.tsx
</acceptance_criteria>
<done>Global catalog page shows searchable grid of items. Detail page shows specs, image, and owner count. Hooks handle all data fetching and mutations. Lint passes.</done>
</task>
<task type="auto">
<name>Task 2: Link-to-global-item UI in collection</name>
<files>src/client/components/LinkToGlobalItem.tsx</files>
<read_first>src/client/routes/collection/index.tsx, src/client/hooks/useGlobalItems.ts</read_first>
<action>
**LinkToGlobalItem.tsx**: Create at `src/client/components/LinkToGlobalItem.tsx`. Per D-04 and D-18.
A component that allows linking a user's personal item to a global catalog entry. Design as a small modal/popover or inline search:
1. Trigger: A "Link to catalog" button shown on item detail or edit view. If already linked, show "Linked to {brand} {model}" with an unlink option.
2. When triggered, show a search input that calls `useGlobalItems(query)` with debounce.
3. Display matching global items as clickable options.
4. On select, call `useLinkItem()` mutation with itemId and globalItemId.
5. Show success state: linked item name with a link to the global item detail page.
6. Unlink: If already linked, show the linked global item with an "Unlink" button that calls `useUnlinkItem()`.
Keep it simple — a dropdown/combobox pattern works well. Use Tailwind for styling. Match the light/airy aesthetic of existing components.
Wire this component into the item edit form or item detail view at the appropriate place (after the existing form fields, or as a separate section below item details).
</action>
<verify>
<automated>bun run lint 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "LinkToGlobalItem" src/client/components/LinkToGlobalItem.tsx
- grep -q "useLinkItem\|linkItem" src/client/components/LinkToGlobalItem.tsx
- grep -q "useUnlinkItem\|unlinkItem" src/client/components/LinkToGlobalItem.tsx
</acceptance_criteria>
<done>Users can search the global catalog from within their item view, link/unlink their item, and see the current link status. Lint passes.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify global catalog UI</name>
<files>none</files>
<action>
Human verification of the global item catalog UI. Review what was built: browse page with search, detail page with owner count, and link/unlink from collection items.
Steps to verify:
1. Start dev server: `bun run dev`
2. Navigate to `/global-items` — should see catalog with seed items in a grid
3. Type "revelate" in search — should filter to matching items
4. Click a global item — detail page shows brand, model, specs, "0 users own this"
5. Go to your collection, open an item, find "Link to catalog" control
6. Search for a global item and link it — should show linked status
7. Return to global item detail — owner count should now show "1 user owns this"
8. Unlink the item — owner count returns to 0
</action>
<verify>
<automated>bun run build 2>&1 | tail -3</automated>
</verify>
<done>User approves global catalog UI: search works, detail page shows owner count, link/unlink flow is functional.</done>
</task>
</tasks>
<verification>
- `bun run lint` passes
- `bun run build` succeeds (client builds with new routes)
- Visual verification of catalog page, search, detail page, and link/unlink flow
</verification>
<success_criteria>
Global catalog is browsable and searchable. Item detail shows owner count. Users can link/unlink personal items to global entries. All pages render correctly with Tailwind styling.
</success_criteria>
<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,205 @@
---
phase: 18-global-items-public-profiles
plan: 05
type: execute
wave: 3
depends_on: ["18-03"]
files_modified:
- src/client/hooks/useProfile.ts
- src/client/routes/users/$userId.tsx
- src/client/routes/settings.tsx
- src/client/routes/setups/index.tsx
- src/client/components/ProfileSection.tsx
- src/client/components/PublicSetupCard.tsx
autonomous: false
requirements: [PROF-01, PROF-02, PROF-03, PROF-04, PROF-05]
must_haves:
truths:
- "User can edit display name, avatar, and bio in settings page"
- "Public profile page at /users/:id shows display name, avatar, bio, and public setups"
- "Public profile page works without login (no auth required)"
- "User can toggle a setup between public and private in the setup detail/edit view"
- "Public setups appear on the owner's profile page; private ones do not"
artifacts:
- path: "src/client/hooks/useProfile.ts"
provides: "usePublicProfile, useUpdateProfile hooks"
exports: ["usePublicProfile", "useUpdateProfile"]
- path: "src/client/routes/users/$userId.tsx"
provides: "Public profile page"
min_lines: 40
- path: "src/client/components/ProfileSection.tsx"
provides: "Profile edit form within settings"
min_lines: 30
- path: "src/client/components/PublicSetupCard.tsx"
provides: "Card for setup shown on public profile"
min_lines: 15
key_links:
- from: "src/client/routes/users/$userId.tsx"
to: "src/client/hooks/useProfile.ts"
via: "usePublicProfile hook"
pattern: "usePublicProfile"
- from: "src/client/hooks/useProfile.ts"
to: "/api/users/:id/profile"
via: "apiGet fetch"
pattern: "apiGet.*users.*profile"
- from: "src/client/routes/settings.tsx"
to: "src/client/components/ProfileSection.tsx"
via: "component import"
pattern: "ProfileSection"
---
<objective>
Build the user profile and public sharing client: profile edit section in settings, public profile page, setup visibility toggle, and public setup cards.
Purpose: Delivers the client-side experience for PROF-01 (profile edit), PROF-02 (public profile), PROF-03 (setup toggle), PROF-04 (public setup view), PROF-05 (profile lists public setups).
Output: useProfile hook, public profile page, ProfileSection component, PublicSetupCard, updated settings and setup views
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md
@src/client/routes/settings.tsx
@src/client/routes/setups/index.tsx
@src/client/hooks/useItems.ts
@src/client/lib/api.ts
<interfaces>
<!-- API endpoints from Plan 03: -->
PUT /api/auth/profile { displayName?, avatarUrl?, bio? } -> updated user (auth required)
GET /api/users/:id/profile -> { id, displayName, avatarUrl, bio, setups: [{ id, name, createdAt }] } (no auth)
GET /api/setups/:id/public -> { id, name, isPublic, items: [...], totalWeight, totalCost } (no auth, 404 if private)
<!-- Setup now includes isPublic in responses from Plan 03 -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Profile hooks and profile edit UI</name>
<files>src/client/hooks/useProfile.ts, src/client/components/ProfileSection.tsx, src/client/routes/settings.tsx</files>
<read_first>src/client/routes/settings.tsx, src/client/hooks/useItems.ts, src/client/lib/api.ts</read_first>
<action>
**useProfile.ts** hook: Create at `src/client/hooks/useProfile.ts`.
1. `usePublicProfile(userId: number | null)``useQuery` with key `["profiles", userId]`, fetches `apiGet("/api/users/${userId}/profile")`, `enabled: userId != null`.
2. `useUpdateProfile()``useMutation` calling `apiPut("/api/auth/profile", data)`. On success, invalidate `["profiles"]` query key. Return mutation.
**ProfileSection.tsx**: Create at `src/client/components/ProfileSection.tsx`. Per D-09.
A form section that contains:
- Display name text input (max 100 chars) with label
- Bio textarea (max 500 chars) with character counter
- Avatar: Show current avatar if set, with a "Change avatar" button that opens the existing ImageUpload component (per D-11, reuse existing image upload + MinIO storage). After upload, set avatarUrl to the returned filename (the route will handle presigned URL generation).
- Save button calling `useUpdateProfile()` mutation
- Success/error toast feedback (use existing toast pattern if available, otherwise simple inline message)
Pre-populate form with current profile data. On mount, fetch current user profile via an appropriate mechanism (could be from auth context or a dedicated endpoint).
**settings.tsx**: Read the existing settings page. Add a "Profile" section at the top (before API Keys and other settings). Import and render `<ProfileSection />`. The section should have a heading "Profile" with a brief description "Your public profile information."
</action>
<verify>
<automated>bun run lint 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- grep -q "usePublicProfile" src/client/hooks/useProfile.ts
- grep -q "useUpdateProfile" src/client/hooks/useProfile.ts
- grep -q "ProfileSection" src/client/components/ProfileSection.tsx
- grep -q "ProfileSection" src/client/routes/settings.tsx
</acceptance_criteria>
<done>Profile edit section in settings page with display name, bio, and avatar upload. Hooks handle fetch and mutation. Form saves correctly.</done>
</task>
<task type="auto">
<name>Task 2: Public profile page and setup visibility toggle</name>
<files>src/client/routes/users/$userId.tsx, src/client/components/PublicSetupCard.tsx, src/client/routes/setups/index.tsx</files>
<read_first>src/client/routes/setups/index.tsx, src/client/hooks/useProfile.ts</read_first>
<action>
**users/$userId.tsx**: Create at `src/client/routes/users/$userId.tsx`. Per D-10.
Public profile page (no auth required to view):
- TanStack Router: `createFileRoute("/users/$userId")` with params
- Fetch profile with `usePublicProfile(Number(userId))`
- Layout: Avatar (or placeholder icon), display name (or "User #{id}" fallback), bio text
- Below profile: "Public Setups" heading with grid of PublicSetupCard components
- Empty state if no public setups: "No public setups yet"
- Loading skeleton while fetching
- 404 handling if user not found
**PublicSetupCard.tsx**: Create at `src/client/components/PublicSetupCard.tsx`.
A card for setups shown on the public profile:
- Setup name as heading
- Created date formatted
- Links to `/setups/${id}/public` for the public view (or you can create an inline expandable view)
- Light card styling with subtle border/shadow, matching existing setup cards
**setups/index.tsx or setup detail**: Update the setup list or detail view to include the isPublic toggle per D-14.
- In the setup detail/edit view, add a toggle switch or checkbox labeled "Public" next to the setup name
- When toggled, call the existing setup update mutation with `isPublic: true/false`
- Show a small icon or badge on the setup list indicating public status (e.g., a globe icon or "Public" chip)
- Default all existing setups to show as private (per D-12)
</action>
<verify>
<automated>bun run lint 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- test -f "src/client/routes/users/\$userId.tsx"
- grep -q "usePublicProfile" "src/client/routes/users/\$userId.tsx"
- test -f src/client/components/PublicSetupCard.tsx
- grep -q "isPublic\|public" src/client/routes/setups/index.tsx
</acceptance_criteria>
<done>Public profile page shows user info and public setups. Setup detail has visibility toggle. Public setups appear on profile. Private setups are hidden from profile.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verify profiles and public sharing UI</name>
<files>none</files>
<action>
Human verification of user profiles and public sharing. Review what was built: profile edit in settings, public profile page, setup visibility toggle.
Steps to verify:
1. Start dev server: `bun run dev`
2. Go to Settings — should see a new "Profile" section at top
3. Enter a display name and bio, save — should show success
4. Upload an avatar image — should display
5. Go to Setups, open a setup detail, find the "Public" toggle
6. Toggle a setup to public
7. Navigate to `/users/{your-user-id}` — should see profile with the public setup listed
8. Open an incognito/private window (no auth)
9. Visit the same `/users/{id}` URL — should show profile and public setup without login
10. Toggle the setup back to private — it should disappear from the profile page
</action>
<verify>
<automated>bun run build 2>&1 | tail -3</automated>
</verify>
<done>User approves profiles and sharing UI: profile edit works, public profile shows correct data, setup toggle works, unauthenticated access functions correctly.</done>
</task>
</tasks>
<verification>
- `bun run lint` passes
- `bun run build` succeeds
- Visual verification: profile edit, public profile page, setup toggle, and public access
</verification>
<success_criteria>
Profile can be edited in settings. Public profile page works without auth. Setup visibility toggle works. Public setups appear on profile, private ones don't. Avatar upload uses existing image infrastructure.
</success_criteria>
<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-05-SUMMARY.md`
</output>