docs(33): create phase plans for currency system
6 plans across 3 waves covering market-aware pricing, exchange rates, community price data, and currency-normalized display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -183,12 +183,26 @@ Plans:
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 33: Currency System
|
||||
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly
|
||||
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly — full market-aware pricing system with community price data
|
||||
**Depends on**: Phase 32
|
||||
**Requirements**: TBD (discuss phase)
|
||||
**Requirements**: D-01 through D-21 (from discuss phase)
|
||||
**Success Criteria** (what must be TRUE):
|
||||
TBD (discuss phase)
|
||||
**Plans**: TBD
|
||||
1. User can select a market/currency in settings and all prices display in that currency
|
||||
2. Catalog items show market-specific MSRP with community price aggregation per market
|
||||
3. Converted prices are clearly labeled as approximate with ~ prefix and dual display format
|
||||
4. Users can submit community prices for items they own (ownership validated)
|
||||
5. Comparison table normalizes candidate prices to user's currency for apples-to-apples comparison
|
||||
6. Exchange rates fetched daily from ECB via frankfurter.app with 24h cache
|
||||
**Plans**: 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 33-01-PLAN.md — Schema (market_prices, community_prices tables) + currency conversion service
|
||||
- [ ] 33-02-PLAN.md — [BLOCKING] Database migration generation and push
|
||||
- [ ] 33-03-PLAN.md — Market prices API, exchange rates endpoint, item/candidate currency context
|
||||
- [ ] 33-04-PLAN.md — Community price service (ownership validation, median aggregation) + setup totals
|
||||
- [ ] 33-05-PLAN.md — Formatter evolution, market/currency selector, auto-suggestion, conversion toggle
|
||||
- [ ] 33-06-PLAN.md — Catalog detail market prices, comparison table normalization, MCP tool updates
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 34: i18n Foundation
|
||||
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
|
||||
@@ -234,7 +248,7 @@ Plans:
|
||||
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
|
||||
| 32. Setup Sharing System | v2.3 | 0/4 | Planned | — |
|
||||
| 33. Currency System | v2.3 | TBD | Pending | — |
|
||||
| 33. Currency System | v2.3 | 0/6 | Planned | — |
|
||||
| 34. i18n Foundation | v2.3 | TBD | Pending | — |
|
||||
|
||||
## Backlog
|
||||
|
||||
270
.planning/phases/33-currency-system/33-01-PLAN.md
Normal file
270
.planning/phases/33-currency-system/33-01-PLAN.md
Normal file
@@ -0,0 +1,270 @@
|
||||
---
|
||||
phase: 33-currency-system
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/db/schema.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/shared/types.ts
|
||||
- src/server/services/currency.service.ts
|
||||
- tests/services/currency.service.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03, D-06, D-07, D-08, D-09]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "market_prices table exists with global_item_id, market, currency, price_cents columns"
|
||||
- "community_prices table exists with global_item_id, user_id, market, currency, price_cents, price_date, source_type columns"
|
||||
- "items table has price_currency column"
|
||||
- "thread_candidates table has found_price_cents, found_price_currency, found_price_date columns"
|
||||
- "currency service fetches exchange rates from frankfurter.app"
|
||||
- "currency service caches rates in memory with 24h TTL"
|
||||
- "currency service converts prices between currencies accurately"
|
||||
artifacts:
|
||||
- path: "src/db/schema.ts"
|
||||
provides: "market_prices and community_prices table definitions, new columns on items and threadCandidates"
|
||||
contains: "marketPrices"
|
||||
- path: "src/server/services/currency.service.ts"
|
||||
provides: "Exchange rate fetching, caching, and conversion"
|
||||
exports: ["getExchangeRates", "convertPrice", "CURRENCY_MARKET_MAP"]
|
||||
- path: "tests/services/currency.service.test.ts"
|
||||
provides: "Unit tests for currency service"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: "src/server/services/currency.service.ts"
|
||||
to: "https://api.frankfurter.app"
|
||||
via: "fetch in getExchangeRates"
|
||||
pattern: "frankfurter"
|
||||
- from: "src/db/schema.ts"
|
||||
to: "src/shared/types.ts"
|
||||
via: "Drizzle inferred types"
|
||||
pattern: "marketPrices|communityPrices"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the database schema for market-aware pricing and build the currency conversion service.
|
||||
|
||||
Purpose: Foundation layer — all other plans depend on these tables and the conversion service.
|
||||
Output: New DB tables (market_prices, community_prices), new columns on items/candidates, currency service with rate fetching/caching/conversion.
|
||||
</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/33-currency-system/33-CONTEXT.md
|
||||
@.planning/phases/33-currency-system/33-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From src/db/schema.ts — existing table patterns -->
|
||||
From src/db/schema.ts:
|
||||
```typescript
|
||||
// Pattern: pgTable with serial id, references, timestamps
|
||||
export const globalItems = pgTable("global_items", {
|
||||
id: serial("id").primaryKey(),
|
||||
brand: text("brand").notNull(),
|
||||
model: text("model").notNull(),
|
||||
priceCents: integer("price_cents"),
|
||||
// ...
|
||||
}, (table) => [unique().on(table.brand, table.model)]);
|
||||
|
||||
export const items = pgTable("items", {
|
||||
id: serial("id").primaryKey(),
|
||||
priceCents: integer("price_cents"),
|
||||
purchasePriceCents: integer("purchase_price_cents"),
|
||||
globalItemId: integer("global_item_id").references(() => globalItems.id),
|
||||
// ...
|
||||
});
|
||||
|
||||
export const threadCandidates = pgTable("thread_candidates", {
|
||||
id: serial("id").primaryKey(),
|
||||
priceCents: integer("price_cents"),
|
||||
globalItemId: integer("global_item_id").references(() => globalItems.id),
|
||||
// ...
|
||||
});
|
||||
|
||||
export const settings = pgTable("settings", {
|
||||
userId: integer("user_id").notNull().references(() => users.id),
|
||||
key: text("key").notNull(),
|
||||
value: text("value").notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.userId, table.key] })]);
|
||||
```
|
||||
|
||||
From src/shared/schemas.ts:
|
||||
```typescript
|
||||
export const createItemSchema = z.object({
|
||||
priceCents: z.number().int().nonnegative().optional(),
|
||||
purchasePriceCents: z.number().int().nonnegative().optional(),
|
||||
// ...
|
||||
});
|
||||
|
||||
export const createCandidateSchema = z.object({
|
||||
priceCents: z.number().int().nonnegative().optional(),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add market_prices and community_prices tables + new columns to schema</name>
|
||||
<files>src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts</files>
|
||||
<read_first>src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts</read_first>
|
||||
<behavior>
|
||||
- marketPrices table has columns: id, globalItemId (FK to globalItems), market (text), currency (text), priceCents (integer), source (text nullable), createdAt (timestamp)
|
||||
- marketPrices has unique constraint on (globalItemId, market, currency)
|
||||
- communityPrices table has columns: id, globalItemId (FK to globalItems), userId (FK to users), market (text), currency (text), priceCents (integer), priceDate (timestamp nullable), sourceType (text, 'purchased' | 'researched'), createdAt (timestamp)
|
||||
- communityPrices has unique constraint on (globalItemId, userId, sourceType)
|
||||
- items table gets new nullable column: priceCurrency (text, default 'EUR')
|
||||
- threadCandidates table gets new nullable columns: foundPriceCents (integer), foundPriceCurrency (text), foundPriceDate (timestamp)
|
||||
- Zod schemas updated: createItemSchema gains optional priceCurrency field, createCandidateSchema gains optional foundPriceCents/foundPriceCurrency/foundPriceDate fields
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-01, D-02: Add `marketPrices` pgTable to schema.ts with columns: `id` (serial PK), `globalItemId` (integer FK to globalItems ON DELETE CASCADE), `market` (text NOT NULL — 'EU', 'US', 'UK', etc.), `currency` (text NOT NULL — 'EUR', 'USD', 'GBP'), `priceCents` (integer NOT NULL), `source` (text nullable — 'manufacturer', 'retailer', 'community'), `createdAt` (timestamp defaultNow). Add unique constraint on (globalItemId, market, currency).
|
||||
|
||||
Per D-04, D-05: Add `communityPrices` pgTable with: `id` (serial PK), `globalItemId` (integer FK to globalItems ON DELETE CASCADE), `userId` (integer FK to users), `market` (text NOT NULL), `currency` (text NOT NULL), `priceCents` (integer NOT NULL), `priceDate` (timestamp nullable), `sourceType` (text NOT NULL — 'purchased' or 'researched'), `createdAt` (timestamp defaultNow). Unique constraint on (globalItemId, userId, sourceType).
|
||||
|
||||
Per D-03: Add `priceCurrency` column to `items` table: `priceCurrency: text("price_currency").default("EUR")`.
|
||||
|
||||
Per D-06, D-07: Add to `threadCandidates` table: `foundPriceCents: integer("found_price_cents")`, `foundPriceCurrency: text("found_price_currency")`, `foundPriceDate: timestamp("found_price_date")`.
|
||||
|
||||
Update `src/shared/schemas.ts`:
|
||||
- `createItemSchema`: add `priceCurrency: z.string().max(3).optional()`
|
||||
- `updateItemSchema`: inherits via `.partial()`
|
||||
- `createCandidateSchema`: add `foundPriceCents: z.number().int().nonnegative().optional()`, `foundPriceCurrency: z.string().max(3).optional()`, `foundPriceDate: z.string().datetime().optional()`
|
||||
- `updateCandidateSchema`: inherits via `.partial()`
|
||||
|
||||
Update `src/shared/types.ts` if it has manual type definitions — if types are inferred from Drizzle/Zod, no changes needed.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/db/schema.ts contains `export const marketPrices = pgTable("market_prices"`
|
||||
- src/db/schema.ts contains `export const communityPrices = pgTable("community_prices"`
|
||||
- src/db/schema.ts items table contains `priceCurrency: text("price_currency")`
|
||||
- src/db/schema.ts threadCandidates table contains `foundPriceCents: integer("found_price_cents")`
|
||||
- src/db/schema.ts threadCandidates table contains `foundPriceCurrency: text("found_price_currency")`
|
||||
- src/db/schema.ts threadCandidates table contains `foundPriceDate: timestamp("found_price_date")`
|
||||
- src/shared/schemas.ts createItemSchema contains `priceCurrency`
|
||||
- src/shared/schemas.ts createCandidateSchema contains `foundPriceCents`
|
||||
- marketPrices has unique constraint on globalItemId + market + currency
|
||||
- communityPrices has unique constraint on globalItemId + userId + sourceType
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "marketPrices\|communityPrices\|priceCurrency\|foundPriceCents" src/db/schema.ts</automated>
|
||||
</verify>
|
||||
<done>Both new tables defined in schema with all columns and constraints, existing tables have new columns, Zod schemas updated</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Create currency conversion service with exchange rate fetching and caching</name>
|
||||
<files>src/server/services/currency.service.ts, tests/services/currency.service.test.ts</files>
|
||||
<read_first>src/server/services/currency.service.ts (will be new), src/server/services/setup.service.ts (for service pattern)</read_first>
|
||||
<behavior>
|
||||
- getExchangeRates() fetches from https://api.frankfurter.app/latest?from=EUR
|
||||
- getExchangeRates() caches result in memory for 24 hours
|
||||
- getExchangeRates() returns cached rates when cache is valid
|
||||
- getExchangeRates() returns stale cache on fetch failure
|
||||
- convertPrice(1000, 'EUR', 'USD', rates) returns correct USD cents using rates.USD
|
||||
- convertPrice(1000, 'USD', 'EUR', rates) returns correct EUR cents using 1/rates.USD
|
||||
- convertPrice(1000, 'EUR', 'EUR', rates) returns 1000 (same currency = no conversion)
|
||||
- CURRENCY_MARKET_MAP maps EUR→EU, USD→US, GBP→UK, JPY→JP, CAD→CA, AUD→AU
|
||||
- getMarketForCurrency('EUR') returns 'EU'
|
||||
</behavior>
|
||||
<action>
|
||||
Per D-08: Create `src/server/services/currency.service.ts` with:
|
||||
|
||||
```typescript
|
||||
export interface ExchangeRates {
|
||||
base: string;
|
||||
date: string;
|
||||
rates: Record<string, number>;
|
||||
}
|
||||
|
||||
export const CURRENCY_MARKET_MAP: Record<string, string> = {
|
||||
EUR: "EU", USD: "US", GBP: "UK", JPY: "JP", CAD: "CA", AUD: "AU",
|
||||
};
|
||||
|
||||
export function getMarketForCurrency(currency: string): string {
|
||||
return CURRENCY_MARKET_MAP[currency] ?? currency;
|
||||
}
|
||||
```
|
||||
|
||||
Per D-08, D-09: Implement `getExchangeRates()`:
|
||||
- Fetch from `https://api.frankfurter.app/latest?from=EUR`
|
||||
- Parse response: `{ base: "EUR", date: "2026-04-13", rates: { USD: 1.08, GBP: 0.86, ... } }`
|
||||
- Cache in module-level variables: `let cachedRates: ExchangeRates | null = null; let cacheExpiry = 0;`
|
||||
- Cache TTL: 24 hours (86400000ms)
|
||||
- On fetch failure: return cached rates if available, throw if no cache
|
||||
- Always include base currency in rates: `rates.EUR = 1` (self-reference for conversion math)
|
||||
|
||||
Implement `convertPrice(cents: number, from: string, to: string, rates: ExchangeRates): number`:
|
||||
- If `from === to`, return cents unchanged
|
||||
- Convert `from` to EUR base: `centsInEur = cents / rates[from]`
|
||||
- Convert EUR to `to`: `result = centsInEur * rates[to]`
|
||||
- Return `Math.round(result)` (integer cents)
|
||||
|
||||
Export a `resetCache()` function for testing.
|
||||
|
||||
Create `tests/services/currency.service.test.ts`:
|
||||
- Test convertPrice with known rates: EUR→USD, USD→EUR, same currency
|
||||
- Test getExchangeRates caching (mock fetch)
|
||||
- Test CURRENCY_MARKET_MAP entries
|
||||
- Test getMarketForCurrency
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/services/currency.service.ts exports getExchangeRates, convertPrice, CURRENCY_MARKET_MAP, getMarketForCurrency
|
||||
- convertPrice(1000, "EUR", "EUR", rates) returns 1000
|
||||
- convertPrice(1000, "EUR", "USD", {base:"EUR",date:"",rates:{EUR:1,USD:1.08}}) returns 1080
|
||||
- tests/services/currency.service.test.ts exists with at least 4 test cases
|
||||
- `bun test tests/services/currency.service.test.ts` passes
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/currency.service.test.ts</automated>
|
||||
</verify>
|
||||
<done>Currency service with rate fetching, 24h caching, conversion math, and market mapping — all tested</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| server→frankfurter.app | External API for exchange rates — untrusted data |
|
||||
| client→server | Price currency values from user input |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-33-01 | Tampering | currency.service.ts | mitigate | Validate exchange rate response shape before caching — reject if rates are missing or negative |
|
||||
| T-33-02 | Spoofing | schema.ts priceCurrency | mitigate | Zod validation on priceCurrency field limits to max 3 chars; server validates against known currency list |
|
||||
| T-33-03 | Denial of Service | currency.service.ts | mitigate | Cache rates for 24h; stale-serve on fetch failure; no user-triggered fetches |
|
||||
| T-33-04 | Information Disclosure | community_prices | accept | Community prices are intentionally public aggregate data — no PII beyond userId which is already public in profiles |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun test tests/services/currency.service.test.ts` passes
|
||||
- `bun run db:generate` produces a migration for the new tables/columns
|
||||
- schema.ts grep shows marketPrices, communityPrices, priceCurrency, foundPriceCents
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- New tables (market_prices, community_prices) defined in schema
|
||||
- Existing tables extended with currency/date columns
|
||||
- Currency service fetches, caches, and converts prices
|
||||
- All tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/33-currency-system/33-01-SUMMARY.md`
|
||||
</output>
|
||||
111
.planning/phases/33-currency-system/33-02-PLAN.md
Normal file
111
.planning/phases/33-currency-system/33-02-PLAN.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
phase: 33-currency-system
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: [01]
|
||||
files_modified:
|
||||
- drizzle-pg/meta/_journal.json
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03, D-06, D-07]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Database schema matches Drizzle schema definitions"
|
||||
- "market_prices table exists in the database"
|
||||
- "community_prices table exists in the database"
|
||||
- "items table has price_currency column"
|
||||
- "thread_candidates table has found_price_cents, found_price_currency, found_price_date columns"
|
||||
artifacts:
|
||||
- path: "drizzle-pg/"
|
||||
provides: "Migration SQL file for new tables and columns"
|
||||
key_links:
|
||||
- from: "src/db/schema.ts"
|
||||
to: "drizzle-pg/"
|
||||
via: "bun run db:generate"
|
||||
pattern: "market_prices|community_prices"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Generate and apply database migration for the new market pricing tables and columns.
|
||||
|
||||
Purpose: [BLOCKING] Schema push — database must match code before any API work can proceed. Without this, TypeScript types pass (from config) but runtime queries fail.
|
||||
Output: Migration SQL applied to database.
|
||||
</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/STATE.md
|
||||
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: [BLOCKING] Generate and push database migration</name>
|
||||
<files>drizzle-pg/</files>
|
||||
<read_first>src/db/schema.ts, drizzle.config.ts</read_first>
|
||||
<action>
|
||||
Run Drizzle migration generation and push:
|
||||
|
||||
1. Generate migration: `bun run db:generate`
|
||||
- This reads src/db/schema.ts and produces a SQL migration file in drizzle-pg/
|
||||
- Expected: creates new migration for market_prices table, community_prices table, and new columns on items/thread_candidates
|
||||
|
||||
2. Apply migration: `bun run db:push`
|
||||
- Applies the generated migration to the PostgreSQL database
|
||||
- Verify by checking that the migration was applied without errors
|
||||
|
||||
3. Verify tables exist by running a quick query or checking the migration output
|
||||
|
||||
Note: Drizzle ORM detected, push command is `bun run db:push` (per project CLAUDE.md). Non-TTY compatible — no interactive prompts expected.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- A new migration SQL file exists in drizzle-pg/ containing CREATE TABLE market_prices
|
||||
- A new migration SQL file exists in drizzle-pg/ containing CREATE TABLE community_prices
|
||||
- Migration SQL contains ALTER TABLE items ADD COLUMN price_currency
|
||||
- Migration SQL contains ALTER TABLE thread_candidates ADD COLUMN found_price_cents
|
||||
- `bun run db:push` exits with code 0
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && ls drizzle-pg/*.sql | tail -1 | xargs grep -c "market_prices\|community_prices"</automated>
|
||||
</verify>
|
||||
<done>Database schema matches Drizzle definitions — all new tables and columns exist in the live database</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| None | Schema migration is an internal operation with no external trust boundaries |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-33-05 | Tampering | migration SQL | accept | Migrations are generated from code and applied by the developer — no external input |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Migration file exists in drizzle-pg/ with correct DDL
|
||||
- `bun run db:push` completes successfully
|
||||
- No runtime errors when querying new tables
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Database has market_prices and community_prices tables
|
||||
- items table has price_currency column
|
||||
- thread_candidates table has found_price_cents, found_price_currency, found_price_date columns
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/33-currency-system/33-02-SUMMARY.md`
|
||||
</output>
|
||||
226
.planning/phases/33-currency-system/33-03-PLAN.md
Normal file
226
.planning/phases/33-currency-system/33-03-PLAN.md
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
phase: 33-currency-system
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [01, 02]
|
||||
files_modified:
|
||||
- src/server/services/market-price.service.ts
|
||||
- src/server/routes/market-prices.ts
|
||||
- src/server/routes/exchange-rates.ts
|
||||
- src/server/index.ts
|
||||
- src/server/services/item.service.ts
|
||||
- src/server/services/thread.service.ts
|
||||
- tests/services/market-price.service.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-06, D-09, D-10]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /api/exchange-rates returns current exchange rates"
|
||||
- "GET /api/global-items/:id/prices returns market prices for a catalog item"
|
||||
- "POST /api/global-items/:id/prices creates/updates a market price (authenticated)"
|
||||
- "Item and candidate API responses include price currency context"
|
||||
- "Candidate update accepts foundPriceCents, foundPriceCurrency, foundPriceDate fields"
|
||||
artifacts:
|
||||
- path: "src/server/services/market-price.service.ts"
|
||||
provides: "CRUD operations for market prices"
|
||||
exports: ["getMarketPrices", "upsertMarketPrice"]
|
||||
- path: "src/server/routes/market-prices.ts"
|
||||
provides: "Market price API endpoints"
|
||||
- path: "src/server/routes/exchange-rates.ts"
|
||||
provides: "Exchange rate API endpoint"
|
||||
- path: "tests/services/market-price.service.test.ts"
|
||||
provides: "Market price service tests"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: "src/server/routes/market-prices.ts"
|
||||
to: "src/server/services/market-price.service.ts"
|
||||
via: "route handler calls service"
|
||||
pattern: "getMarketPrices|upsertMarketPrice"
|
||||
- from: "src/server/routes/exchange-rates.ts"
|
||||
to: "src/server/services/currency.service.ts"
|
||||
via: "route handler calls getExchangeRates"
|
||||
pattern: "getExchangeRates"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create market prices API, exchange rates endpoint, and update existing item/candidate endpoints with currency context.
|
||||
|
||||
Purpose: Server-side price infrastructure — enables clients and MCP consumers to access market prices and perform currency conversion.
|
||||
Output: New API endpoints for market prices and exchange rates, updated item/candidate responses with currency fields.
|
||||
</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/33-currency-system/33-CONTEXT.md
|
||||
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From src/server/services/currency.service.ts (created in Plan 01):
|
||||
```typescript
|
||||
export interface ExchangeRates {
|
||||
base: string;
|
||||
date: string;
|
||||
rates: Record<string, number>;
|
||||
}
|
||||
export function getExchangeRates(): Promise<ExchangeRates>;
|
||||
export function convertPrice(cents: number, from: string, to: string, rates: ExchangeRates): number;
|
||||
export const CURRENCY_MARKET_MAP: Record<string, string>;
|
||||
export function getMarketForCurrency(currency: string): string;
|
||||
```
|
||||
|
||||
From src/db/schema.ts (updated in Plan 01):
|
||||
```typescript
|
||||
export const marketPrices = pgTable("market_prices", {
|
||||
id: serial("id").primaryKey(),
|
||||
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
|
||||
market: text("market").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
source: text("source"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => [unique().on(table.globalItemId, table.market, table.currency)]);
|
||||
```
|
||||
|
||||
From src/server/routes/items.ts (existing pattern):
|
||||
```typescript
|
||||
// Route pattern: Hono routes with zod-validator
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
```
|
||||
|
||||
From src/server/index.ts (existing route registration pattern):
|
||||
```typescript
|
||||
app.route("/api/items", itemRoutes);
|
||||
app.route("/api/threads", threadRoutes);
|
||||
// etc.
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create market price service and API endpoints</name>
|
||||
<files>src/server/services/market-price.service.ts, src/server/routes/market-prices.ts, src/server/routes/exchange-rates.ts, src/server/index.ts, tests/services/market-price.service.test.ts</files>
|
||||
<read_first>src/server/services/global-item.service.ts, src/server/routes/global-items.ts, src/server/index.ts</read_first>
|
||||
<behavior>
|
||||
- getMarketPrices(db, globalItemId) returns all market prices for a global item
|
||||
- getMarketPricesForMarket(db, globalItemId, market) returns market-specific prices
|
||||
- upsertMarketPrice(db, data) creates or updates a market price (ON CONFLICT update)
|
||||
- GET /api/exchange-rates returns ExchangeRates JSON (public, no auth)
|
||||
- GET /api/global-items/:id/prices returns { marketPrices: [...], communityStats: [...] }
|
||||
- POST /api/global-items/:id/prices requires auth, validates with Zod, calls upsertMarketPrice
|
||||
</behavior>
|
||||
<action>
|
||||
Create `src/server/services/market-price.service.ts`:
|
||||
- `getMarketPrices(db, globalItemId)`: SELECT * FROM market_prices WHERE global_item_id = $1 ORDER BY market
|
||||
- `getMarketPricesForMarket(db, globalItemId, market)`: Same + AND market = $2
|
||||
- `upsertMarketPrice(db, { globalItemId, market, currency, priceCents, source })`: INSERT INTO market_prices ... ON CONFLICT (global_item_id, market, currency) DO UPDATE SET price_cents = EXCLUDED.price_cents, source = EXCLUDED.source
|
||||
- Type `Db` follows existing pattern: `type Db = typeof prodDb`
|
||||
|
||||
Create `src/server/routes/exchange-rates.ts`:
|
||||
- `GET /` (mounted at /api/exchange-rates): Call `getExchangeRates()` from currency.service, return JSON response
|
||||
- Public endpoint (no auth required) — follows existing pattern where GET endpoints are public
|
||||
|
||||
Create `src/server/routes/market-prices.ts`:
|
||||
- `GET /global-items/:id/prices`: Call getMarketPrices(db, id), return { marketPrices }
|
||||
- `POST /global-items/:id/prices`: Require auth (per existing auth middleware pattern), validate body with Zod schema `{ market: z.string(), currency: z.string().max(3), priceCents: z.number().int().nonnegative(), source: z.string().optional() }`, call upsertMarketPrice
|
||||
|
||||
Register routes in `src/server/index.ts`:
|
||||
- `app.route("/api/exchange-rates", exchangeRateRoutes)`
|
||||
- `app.route("/api/market-prices", marketPriceRoutes)`
|
||||
|
||||
Create `tests/services/market-price.service.test.ts`:
|
||||
- Test getMarketPrices returns empty array for unknown item
|
||||
- Test upsertMarketPrice creates a new market price
|
||||
- Test upsertMarketPrice updates existing price on conflict
|
||||
- Test getMarketPricesForMarket filters by market
|
||||
- Use createTestDb() helper (from tests/helpers/db.ts)
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/services/market-price.service.ts exports getMarketPrices, getMarketPricesForMarket, upsertMarketPrice
|
||||
- src/server/routes/exchange-rates.ts exports a Hono app
|
||||
- src/server/routes/market-prices.ts exports a Hono app with GET and POST handlers
|
||||
- src/server/index.ts contains `app.route("/api/exchange-rates"`
|
||||
- src/server/index.ts contains `app.route("/api/market-prices"`
|
||||
- `bun test tests/services/market-price.service.test.ts` passes
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/market-price.service.test.ts</automated>
|
||||
</verify>
|
||||
<done>Market prices API and exchange rates endpoint working with tests</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update item and candidate endpoints with currency context</name>
|
||||
<files>src/server/services/item.service.ts, src/server/services/thread.service.ts</files>
|
||||
<read_first>src/server/services/item.service.ts, src/server/services/thread.service.ts, src/server/routes/items.ts, src/server/routes/threads.ts</read_first>
|
||||
<action>
|
||||
Update `src/server/services/item.service.ts`:
|
||||
- In create/update functions: accept and persist `priceCurrency` field from request body
|
||||
- In getAll/getById responses: include `priceCurrency` in the SELECT column list
|
||||
- The existing `priceCents` fields remain unchanged — `priceCurrency` is additive
|
||||
|
||||
Update `src/server/services/thread.service.ts`:
|
||||
- In candidate create/update functions: accept and persist `foundPriceCents`, `foundPriceCurrency`, `foundPriceDate` fields (per D-06, D-07)
|
||||
- In getThreadWithCandidates response: include `foundPriceCents`, `foundPriceCurrency`, `foundPriceDate` in the candidate SELECT
|
||||
- The existing candidate `priceCents` field remains unchanged
|
||||
|
||||
Per D-09, D-10: Do NOT add conversion logic to these endpoints yet — that will be handled by the client formatter evolution in Plan 05. The server returns raw prices with currency metadata; the client handles display formatting.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/services/item.service.ts create function handles priceCurrency
|
||||
- src/server/services/item.service.ts getAll includes priceCurrency in select
|
||||
- src/server/services/thread.service.ts candidate create handles foundPriceCents, foundPriceCurrency, foundPriceDate
|
||||
- src/server/services/thread.service.ts getThreadWithCandidates includes foundPriceCents, foundPriceCurrency, foundPriceDate
|
||||
- `bun test` passes (existing tests still work)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test</automated>
|
||||
</verify>
|
||||
<done>Item and candidate services return currency context in all responses, accept new currency fields on create/update</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client→server | Market price submissions (POST) — user input for price, currency, market |
|
||||
| server→database | SQL queries with user-provided market/currency strings |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-33-06 | Tampering | POST /api/market-prices | mitigate | Zod validation on all fields — priceCents must be non-negative integer, currency max 3 chars, market non-empty string |
|
||||
| T-33-07 | Elevation of Privilege | POST /api/market-prices | mitigate | Auth middleware required on POST — only authenticated users can submit prices |
|
||||
| T-33-08 | Injection | market-price.service.ts | mitigate | Use Drizzle ORM parameterized queries — no raw SQL string concatenation |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun test` passes (all existing + new tests)
|
||||
- Exchange rates endpoint returns valid JSON
|
||||
- Market prices endpoint returns array for known global item
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Exchange rates and market prices APIs available
|
||||
- Item/candidate responses include currency context
|
||||
- All tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/33-currency-system/33-03-SUMMARY.md`
|
||||
</output>
|
||||
223
.planning/phases/33-currency-system/33-04-PLAN.md
Normal file
223
.planning/phases/33-currency-system/33-04-PLAN.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
phase: 33-currency-system
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [01, 02]
|
||||
files_modified:
|
||||
- src/server/services/community-price.service.ts
|
||||
- src/server/routes/community-prices.ts
|
||||
- src/server/services/setup.service.ts
|
||||
- src/server/services/totals.service.ts
|
||||
- src/server/index.ts
|
||||
- tests/services/community-price.service.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-03, D-04, D-05, D-07, D-21]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Users can submit community prices for items they own"
|
||||
- "Community price submissions are tied to collection ownership"
|
||||
- "Community price aggregation returns per-market median and report count"
|
||||
- "Setup totals handle items with the same currency correctly"
|
||||
artifacts:
|
||||
- path: "src/server/services/community-price.service.ts"
|
||||
provides: "Community price submission, ownership validation, aggregation"
|
||||
exports: ["submitCommunityPrice", "getCommunityPriceStats", "validateOwnership"]
|
||||
- path: "src/server/routes/community-prices.ts"
|
||||
provides: "Community price API endpoints"
|
||||
- path: "tests/services/community-price.service.test.ts"
|
||||
provides: "Community price service tests"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: "src/server/services/community-price.service.ts"
|
||||
to: "src/db/schema.ts"
|
||||
via: "Drizzle queries on communityPrices + items tables"
|
||||
pattern: "communityPrices"
|
||||
- from: "src/server/routes/community-prices.ts"
|
||||
to: "src/server/services/community-price.service.ts"
|
||||
via: "route handler calls service"
|
||||
pattern: "submitCommunityPrice|getCommunityPriceStats"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create community price submission system with ownership validation and per-market aggregation, plus update setup/totals services for currency awareness.
|
||||
|
||||
Purpose: Enable community price data (D-04, D-05, D-21) and ensure setup totals work correctly with currency metadata.
|
||||
Output: Community price API, aggregation queries, updated setup/totals services.
|
||||
</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/33-currency-system/33-CONTEXT.md
|
||||
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From src/db/schema.ts (Plan 01):
|
||||
```typescript
|
||||
export const communityPrices = pgTable("community_prices", {
|
||||
id: serial("id").primaryKey(),
|
||||
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").notNull().references(() => users.id),
|
||||
market: text("market").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
priceDate: timestamp("price_date"),
|
||||
sourceType: text("source_type").notNull(), // 'purchased' | 'researched'
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
}, (table) => [unique().on(table.globalItemId, table.userId, table.sourceType)]);
|
||||
```
|
||||
|
||||
From src/server/services/setup.service.ts (existing):
|
||||
```typescript
|
||||
export async function getAllSetups(db: Db, userId: number) { ... }
|
||||
// Uses SQL: SUM(COALESCE(global_items.price_cents, items.price_cents) * items.quantity)
|
||||
export async function getSetupWithItems(db: Db, userId: number, setupId: number) { ... }
|
||||
```
|
||||
|
||||
From src/server/services/totals.service.ts (existing):
|
||||
```typescript
|
||||
export async function getCategoryTotals(db: Db, userId: number) { ... }
|
||||
export async function getGlobalTotals(db: Db, userId: number) { ... }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create community price service with ownership validation and aggregation</name>
|
||||
<files>src/server/services/community-price.service.ts, src/server/routes/community-prices.ts, src/server/index.ts, tests/services/community-price.service.test.ts</files>
|
||||
<read_first>src/server/services/item.service.ts, src/server/routes/items.ts, src/server/index.ts, src/db/schema.ts</read_first>
|
||||
<behavior>
|
||||
- validateOwnership(db, userId, globalItemId) returns true if user has an item with that globalItemId
|
||||
- validateOwnership(db, userId, globalItemId) returns false if user does not own the item
|
||||
- submitCommunityPrice(db, data) creates/updates a community price (ON CONFLICT upsert)
|
||||
- submitCommunityPrice returns null if ownership validation fails
|
||||
- getCommunityPriceStats(db, globalItemId, market?) returns { market, currency, medianPrice, reportCount }[]
|
||||
- getCommunityPriceStats filters by market when market param provided
|
||||
- Stats only returned when reportCount >= 3 (minimum threshold per D-21)
|
||||
- POST /api/community-prices requires auth
|
||||
- GET /api/community-prices/:globalItemId returns aggregated stats (public)
|
||||
</behavior>
|
||||
<action>
|
||||
Create `src/server/services/community-price.service.ts`:
|
||||
|
||||
Per D-05: `validateOwnership(db, userId, globalItemId)`:
|
||||
- SELECT COUNT(*) FROM items WHERE user_id = $1 AND global_item_id = $2
|
||||
- Return count > 0
|
||||
|
||||
Per D-04, D-05: `submitCommunityPrice(db, { globalItemId, userId, market, currency, priceCents, priceDate, sourceType })`:
|
||||
- First call validateOwnership — if false, return null (user doesn't own this item)
|
||||
- INSERT INTO community_prices ... ON CONFLICT (global_item_id, user_id, source_type) DO UPDATE SET price_cents = EXCLUDED.price_cents, price_date = EXCLUDED.price_date, market = EXCLUDED.market, currency = EXCLUDED.currency
|
||||
- Return the upserted row
|
||||
|
||||
Per D-21: `getCommunityPriceStats(db, globalItemId, market?)`:
|
||||
- Use PostgreSQL PERCENTILE_CONT(0.5) for median calculation
|
||||
- Query: SELECT market, currency, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_cents) as median_price, COUNT(*) as report_count FROM community_prices WHERE global_item_id = $1 [AND market = $2] GROUP BY market, currency HAVING COUNT(*) >= 3
|
||||
- Return array of { market, currency, medianPrice (integer cents), reportCount }
|
||||
|
||||
Create `src/server/routes/community-prices.ts`:
|
||||
- `GET /:globalItemId`: Call getCommunityPriceStats(db, id), return JSON
|
||||
- `POST /`: Require auth. Validate body: `{ globalItemId: z.number().int(), market: z.string(), currency: z.string().max(3), priceCents: z.number().int().nonnegative(), priceDate: z.string().datetime().optional(), sourceType: z.enum(["purchased", "researched"]) }`. Call submitCommunityPrice. Return 403 if ownership validation fails, 200 with data otherwise.
|
||||
|
||||
Register in `src/server/index.ts`: `app.route("/api/community-prices", communityPriceRoutes)`
|
||||
|
||||
Create `tests/services/community-price.service.test.ts`:
|
||||
- Test validateOwnership returns false for non-owner
|
||||
- Test validateOwnership returns true when user owns item with that globalItemId
|
||||
- Test submitCommunityPrice creates a price submission
|
||||
- Test submitCommunityPrice returns null when user doesn't own item
|
||||
- Test getCommunityPriceStats returns empty when < 3 reports
|
||||
- Use createTestDb() helper
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/services/community-price.service.ts exports validateOwnership, submitCommunityPrice, getCommunityPriceStats
|
||||
- src/server/routes/community-prices.ts has GET and POST handlers
|
||||
- src/server/index.ts contains `app.route("/api/community-prices"`
|
||||
- getCommunityPriceStats uses PERCENTILE_CONT for median
|
||||
- getCommunityPriceStats HAVING COUNT(*) >= 3
|
||||
- submitCommunityPrice checks ownership before insert
|
||||
- `bun test tests/services/community-price.service.test.ts` passes
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/community-price.service.test.ts</automated>
|
||||
</verify>
|
||||
<done>Community price system working with ownership validation, median aggregation with 3-report minimum</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update setup and totals services for currency awareness</name>
|
||||
<files>src/server/services/setup.service.ts, src/server/services/totals.service.ts</files>
|
||||
<read_first>src/server/services/setup.service.ts, src/server/services/totals.service.ts</read_first>
|
||||
<action>
|
||||
Note: The current SQL aggregates (SUM of price_cents) assume all prices are in the same currency. For now, this assumption holds because:
|
||||
1. All existing data uses the same implicit currency
|
||||
2. The user's personal items have `priceCurrency` defaulting to 'EUR'
|
||||
3. Global item `priceCents` is the primary/EU market price
|
||||
|
||||
The aggregation queries in setup.service.ts and totals.service.ts should include the `priceCurrency` field in their response so the client can display the correct currency symbol, but the actual SUM logic does not need conversion yet (that would require the server to know the user's preferred currency during aggregation, which is a Plan 05/06 concern).
|
||||
|
||||
Update `src/server/services/setup.service.ts`:
|
||||
- In `getSetupWithItems`: Add `priceCurrency: items.priceCurrency` to the itemList SELECT columns
|
||||
- The `totalCost` aggregate stays as-is (all in primary currency for now)
|
||||
|
||||
Update `src/server/services/totals.service.ts`:
|
||||
- No changes needed — totals are global aggregates returned to the authenticated user
|
||||
- The client formatter (Plan 05) will handle displaying in the user's preferred currency
|
||||
|
||||
This is intentionally minimal — the server returns raw data with currency metadata, and the client handles conversion display. Server-side conversion for aggregates would require passing the user's currency preference through every query, which adds complexity without benefit when the primary market is EUR.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/server/services/setup.service.ts getSetupWithItems includes priceCurrency in itemList select
|
||||
- Existing `bun test` passes — no regressions in setup or totals tests
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test</automated>
|
||||
</verify>
|
||||
<done>Setup and totals services return currency metadata alongside prices</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client→server | Community price submissions — untrusted user price data |
|
||||
| server→database | Ownership validation query |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-33-09 | Spoofing | POST /api/community-prices | mitigate | Auth middleware + ownership validation ensures only item owners can submit prices |
|
||||
| T-33-10 | Tampering | community-price.service.ts | mitigate | Zod validation on all input fields, priceCents must be non-negative integer, currency max 3 chars |
|
||||
| T-33-11 | Repudiation | community_prices | accept | Price submissions tracked with userId and createdAt — sufficient audit trail for a single-user app |
|
||||
| T-33-12 | Information Disclosure | GET /api/community-prices | accept | Community price stats are intentionally public (anonymous aggregates, no individual prices exposed) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun test` passes (all tests including new community price tests)
|
||||
- Community price stats respect 3-report minimum
|
||||
- Ownership validation prevents unauthorized submissions
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Community price CRUD with ownership gate
|
||||
- Aggregation with median and minimum report threshold
|
||||
- Setup items include currency metadata
|
||||
- All tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/33-currency-system/33-04-SUMMARY.md`
|
||||
</output>
|
||||
308
.planning/phases/33-currency-system/33-05-PLAN.md
Normal file
308
.planning/phases/33-currency-system/33-05-PLAN.md
Normal file
@@ -0,0 +1,308 @@
|
||||
---
|
||||
phase: 33-currency-system
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [01, 03]
|
||||
files_modified:
|
||||
- src/client/lib/formatters.ts
|
||||
- src/client/hooks/useCurrency.ts
|
||||
- src/client/hooks/useFormatters.ts
|
||||
- src/client/hooks/useExchangeRates.ts
|
||||
- src/client/routes/settings.tsx
|
||||
autonomous: true
|
||||
requirements: [D-10, D-11, D-12, D-13, D-14, D-15, D-16]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Currency picker in settings now implies market selection"
|
||||
- "Settings page has a 'Show Converted Prices' toggle"
|
||||
- "formatPrice supports dual display format: source price + converted in parentheses"
|
||||
- "Converted prices always show ~ prefix to indicate approximation"
|
||||
- "useCurrency returns currency, market, and showConversions flag"
|
||||
- "Auto-suggestion appears on first visit based on browser locale"
|
||||
artifacts:
|
||||
- path: "src/client/lib/formatters.ts"
|
||||
provides: "Extended formatPrice with dual display and conversion options"
|
||||
exports: ["formatPrice", "formatDualPrice"]
|
||||
- path: "src/client/hooks/useCurrency.ts"
|
||||
provides: "Market-aware currency hook"
|
||||
exports: ["useCurrency"]
|
||||
- path: "src/client/hooks/useExchangeRates.ts"
|
||||
provides: "React Query hook for exchange rates"
|
||||
exports: ["useExchangeRates"]
|
||||
- path: "src/client/routes/settings.tsx"
|
||||
provides: "Updated settings page with market/currency selector and conversion toggle"
|
||||
key_links:
|
||||
- from: "src/client/hooks/useFormatters.ts"
|
||||
to: "src/client/lib/formatters.ts"
|
||||
via: "formatPrice import"
|
||||
pattern: "formatPrice|formatDualPrice"
|
||||
- from: "src/client/hooks/useExchangeRates.ts"
|
||||
to: "/api/exchange-rates"
|
||||
via: "React Query fetch"
|
||||
pattern: "exchange-rates"
|
||||
- from: "src/client/hooks/useCurrency.ts"
|
||||
to: "src/client/hooks/useSettings.ts"
|
||||
via: "useSetting('currency')"
|
||||
pattern: "useSetting"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Evolve the client-side price formatting, currency hook, and settings UI to support market-aware pricing with dual display.
|
||||
|
||||
Purpose: User-facing currency system — market/currency selector, auto-suggestion, conversion toggle, and dual price display format.
|
||||
Output: Updated formatters, enhanced currency hook, new exchange rates hook, redesigned settings currency section.
|
||||
</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/33-currency-system/33-CONTEXT.md
|
||||
@.planning/phases/33-currency-system/33-UI-SPEC.md
|
||||
|
||||
<interfaces>
|
||||
From src/client/lib/formatters.ts (current):
|
||||
```typescript
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||
```
|
||||
|
||||
From src/client/hooks/useCurrency.ts (current):
|
||||
```typescript
|
||||
export function useCurrency(): Currency;
|
||||
```
|
||||
|
||||
From src/client/hooks/useFormatters.ts (current):
|
||||
```typescript
|
||||
export function useFormatters(): {
|
||||
weight: (grams: number | null) => string;
|
||||
price: (cents: number | null) => string;
|
||||
unit: WeightUnit;
|
||||
currency: Currency;
|
||||
};
|
||||
```
|
||||
|
||||
From src/client/hooks/useSettings.ts (pattern):
|
||||
```typescript
|
||||
export function useSetting(key: string): { data: string | undefined, ... };
|
||||
export function useUpdateSetting(): UseMutationResult<...>;
|
||||
```
|
||||
|
||||
From src/client/lib/api.ts:
|
||||
```typescript
|
||||
export function apiGet<T>(path: string): Promise<T>;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend formatPrice with dual display and create exchange rates hook</name>
|
||||
<files>src/client/lib/formatters.ts, src/client/hooks/useExchangeRates.ts</files>
|
||||
<read_first>src/client/lib/formatters.ts, src/client/hooks/useFormatters.ts, src/client/lib/api.ts</read_first>
|
||||
<action>
|
||||
Update `src/client/lib/formatters.ts`:
|
||||
|
||||
Per D-14: Add `formatDualPrice` function:
|
||||
```typescript
|
||||
export interface DualPriceOptions {
|
||||
sourceCents: number;
|
||||
sourceCurrency: Currency;
|
||||
targetCurrency: Currency;
|
||||
convertedCents: number;
|
||||
}
|
||||
|
||||
export function formatDualPrice(options: DualPriceOptions): { source: string; converted: string } {
|
||||
const source = formatPrice(options.sourceCents, options.sourceCurrency);
|
||||
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency)}`;
|
||||
return { source, converted };
|
||||
}
|
||||
```
|
||||
|
||||
Per D-11: The `~` prefix on converted prices indicates approximation. The `converted` string is always prefixed with `~`.
|
||||
|
||||
Keep existing `formatPrice` unchanged for backward compatibility — all existing callers continue to work. `formatDualPrice` is additive.
|
||||
|
||||
Create `src/client/hooks/useExchangeRates.ts`:
|
||||
```typescript
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
interface ExchangeRates {
|
||||
base: string;
|
||||
date: string;
|
||||
rates: Record<string, number>;
|
||||
}
|
||||
|
||||
export function useExchangeRates() {
|
||||
return useQuery({
|
||||
queryKey: ["exchange-rates"],
|
||||
queryFn: () => apiGet<ExchangeRates>("/api/exchange-rates"),
|
||||
staleTime: 1000 * 60 * 60, // 1 hour client-side stale time
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hour garbage collection
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function convertClientPrice(
|
||||
cents: number,
|
||||
from: string,
|
||||
to: string,
|
||||
rates: Record<string, number>,
|
||||
): number {
|
||||
if (from === to) return cents;
|
||||
const fromRate = rates[from] ?? 1;
|
||||
const toRate = rates[to] ?? 1;
|
||||
return Math.round((cents / fromRate) * toRate);
|
||||
}
|
||||
```
|
||||
|
||||
This provides both a React Query hook for components and a pure conversion function that mirrors the server-side logic.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/lib/formatters.ts exports formatDualPrice alongside existing formatPrice
|
||||
- formatDualPrice returns { source: "€2,000.00", converted: "~$2,160.00" } format
|
||||
- Existing formatPrice function unchanged (backward compatible)
|
||||
- src/client/hooks/useExchangeRates.ts exports useExchangeRates and convertClientPrice
|
||||
- useExchangeRates fetches from /api/exchange-rates with 1h stale time
|
||||
- convertClientPrice(1000, "EUR", "EUR", rates) returns 1000
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "formatDualPrice\|useExchangeRates\|convertClientPrice" src/client/lib/formatters.ts src/client/hooks/useExchangeRates.ts</automated>
|
||||
</verify>
|
||||
<done>Dual price display format and exchange rate hook available for all components</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Evolve useCurrency hook and update settings page with market selector + conversion toggle</name>
|
||||
<files>src/client/hooks/useCurrency.ts, src/client/hooks/useFormatters.ts, src/client/routes/settings.tsx</files>
|
||||
<read_first>src/client/hooks/useCurrency.ts, src/client/hooks/useFormatters.ts, src/client/routes/settings.tsx, src/client/hooks/useSettings.ts</read_first>
|
||||
<action>
|
||||
Per D-12: Update `src/client/hooks/useCurrency.ts`:
|
||||
```typescript
|
||||
import type { Currency } from "../lib/formatters";
|
||||
import { useSetting } from "./useSettings";
|
||||
|
||||
const VALID_CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY", "CAD", "AUD"];
|
||||
|
||||
const CURRENCY_MARKET_MAP: Record<string, string> = {
|
||||
EUR: "EU", USD: "US", GBP: "UK", JPY: "JP", CAD: "CA", AUD: "AU",
|
||||
};
|
||||
|
||||
export interface CurrencyContext {
|
||||
currency: Currency;
|
||||
market: string;
|
||||
showConversions: boolean;
|
||||
}
|
||||
|
||||
export function useCurrency(): CurrencyContext {
|
||||
const { data: currencyData } = useSetting("currency");
|
||||
const { data: showConversionsData } = useSetting("showConversions");
|
||||
|
||||
const currency: Currency = (currencyData && VALID_CURRENCIES.includes(currencyData as Currency))
|
||||
? (currencyData as Currency)
|
||||
: "USD";
|
||||
|
||||
return {
|
||||
currency,
|
||||
market: CURRENCY_MARKET_MAP[currency] ?? currency,
|
||||
showConversions: showConversionsData === "true",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
IMPORTANT: The return type changes from `Currency` to `CurrencyContext`. Update `src/client/hooks/useFormatters.ts` to destructure correctly:
|
||||
```typescript
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const { currency } = useCurrency(); // Destructure currency from CurrencyContext
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||
price: (cents: number | null) => formatPrice(cents, currency),
|
||||
unit,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Also update ALL other files that call `useCurrency()` and expect a plain `Currency` string — search with `grep -rn "useCurrency()" src/client/` and update each to destructure `{ currency }` or `{ currency, market, showConversions }` as needed. The settings.tsx file is the primary consumer beyond useFormatters.
|
||||
|
||||
Per D-13, D-15, D-16: Update `src/client/routes/settings.tsx`:
|
||||
|
||||
1. Change the "Currency" section heading to "Market & Currency" per UI-SPEC
|
||||
2. Change description to "Sets your market region and currency for price display"
|
||||
3. Keep the same pill toggle pattern for currency selection (same bg-gray-100 rounded-full container)
|
||||
4. Add a new "Show Converted Prices" toggle below the currency picker, separated by `border-t border-gray-100`:
|
||||
- Heading: "Show Converted Prices" (text-sm font-medium text-gray-900)
|
||||
- Description: "Display approximate conversions when local price is not available" (text-xs text-gray-500)
|
||||
- Toggle: A simple button/switch that saves `showConversions` setting as "true"/"false" using updateSetting.mutate({ key: "showConversions", value: "true"/"false" })
|
||||
- Toggle styles: `w-10 h-5 rounded-full` container, `bg-gray-200` when off, `bg-blue-500` when on, inner circle `w-4 h-4 rounded-full bg-white shadow-sm` translated right when on
|
||||
|
||||
5. Per D-13: Add auto-suggestion banner above the settings card (only shown when no currency setting exists):
|
||||
- Detect suggested currency from `navigator.language`: parse locale (e.g., "de-DE" → EUR, "en-US" → USD, "en-GB" → GBP, "ja-JP" → JPY, "fr-CA" → CAD, "en-AU" → AUD)
|
||||
- Banner: `bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center justify-between`
|
||||
- Text: LucideIcon "globe" (16px, text-blue-500) + "Based on your location, we suggest {CURRENCY} ({SYMBOL})" (text-sm text-blue-700)
|
||||
- CTA: "Use {SYMBOL}" button (text-sm font-medium text-blue-700 hover:text-blue-800 underline) that saves the currency setting and hides the banner
|
||||
- Use useState for banner visibility, default to showing when `useSetting("currency").data` is undefined
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/hooks/useCurrency.ts exports CurrencyContext interface
|
||||
- useCurrency() returns { currency, market, showConversions } object
|
||||
- src/client/hooks/useFormatters.ts destructures { currency } from useCurrency()
|
||||
- settings.tsx heading reads "Market & Currency"
|
||||
- settings.tsx has "Show Converted Prices" toggle that persists to settings
|
||||
- settings.tsx has auto-suggestion banner using navigator.language when no currency set
|
||||
- All existing components that call useCurrency() still compile (no type errors from return type change)
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run build</automated>
|
||||
</verify>
|
||||
<done>Market/currency selector with auto-suggestion, conversion toggle, and updated currency hook deployed</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser→app | navigator.language used for auto-suggestion — untrusted but low risk (suggestion only) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-33-13 | Spoofing | settings.tsx auto-suggestion | accept | navigator.language is a suggestion only — user explicitly confirms by clicking "Use". No security impact if spoofed. |
|
||||
| T-33-14 | Tampering | useCurrency hook | mitigate | Currency value validated against VALID_CURRENCIES allowlist — invalid values fall back to "USD" |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun run build` succeeds (no TypeScript errors)
|
||||
- Settings page shows Market & Currency with pill toggle
|
||||
- Settings page shows Show Converted Prices toggle
|
||||
- Auto-suggestion banner appears when no currency setting exists
|
||||
- useCurrency() returns CurrencyContext object in all consumers
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Market/currency selector in settings
|
||||
- Conversion toggle in settings
|
||||
- Auto-suggestion based on locale
|
||||
- Dual price format available in formatter
|
||||
- Exchange rates hook ready for components
|
||||
- All existing price displays still work (backward compatible)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/33-currency-system/33-05-SUMMARY.md`
|
||||
</output>
|
||||
255
.planning/phases/33-currency-system/33-06-PLAN.md
Normal file
255
.planning/phases/33-currency-system/33-06-PLAN.md
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
phase: 33-currency-system
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [03, 04, 05]
|
||||
files_modified:
|
||||
- src/client/routes/global-items/$globalItemId.tsx
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
- src/client/components/SetupCard.tsx
|
||||
- src/client/hooks/useGlobalItems.ts
|
||||
- src/server/mcp/tools/index.ts
|
||||
autonomous: true
|
||||
requirements: [D-17, D-18, D-19, D-20, D-21]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Global item detail page shows market prices section with user's market MSRP prominent"
|
||||
- "Global item detail page shows community price stats for user's market"
|
||||
- "Global item detail has collapsible 'Other Markets' section"
|
||||
- "Comparison table normalizes candidate prices to user's currency"
|
||||
- "Converted prices in comparison table marked with ~ prefix"
|
||||
- "SetupCard displays prices with correct currency symbol"
|
||||
- "MCP tools include currency context in price responses"
|
||||
artifacts:
|
||||
- path: "src/client/routes/global-items/$globalItemId.tsx"
|
||||
provides: "Market prices section on catalog detail page"
|
||||
contains: "marketPrices"
|
||||
- path: "src/client/components/ComparisonTable.tsx"
|
||||
provides: "Currency-normalized comparison with conversion labels"
|
||||
contains: "convertClientPrice"
|
||||
key_links:
|
||||
- from: "src/client/routes/global-items/$globalItemId.tsx"
|
||||
to: "/api/market-prices/global-items/:id/prices"
|
||||
via: "React Query fetch for market prices"
|
||||
pattern: "market-prices"
|
||||
- from: "src/client/components/ComparisonTable.tsx"
|
||||
to: "src/client/hooks/useExchangeRates.ts"
|
||||
via: "useExchangeRates + convertClientPrice"
|
||||
pattern: "useExchangeRates|convertClientPrice"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Integrate market-aware pricing into catalog detail pages, comparison tables, setup cards, and MCP tools.
|
||||
|
||||
Purpose: User-facing display of market prices, community data, and currency-normalized comparisons — the visible payoff of the currency system.
|
||||
Output: Updated global item detail with market prices, comparison table with conversion, setup card with currency, MCP tools with currency context.
|
||||
</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/33-currency-system/33-CONTEXT.md
|
||||
@.planning/phases/33-currency-system/33-UI-SPEC.md
|
||||
@.planning/phases/33-currency-system/33-05-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From src/client/hooks/useExchangeRates.ts (Plan 05):
|
||||
```typescript
|
||||
export function useExchangeRates(): UseQueryResult<ExchangeRates>;
|
||||
export function convertClientPrice(cents: number, from: string, to: string, rates: Record<string, number>): number;
|
||||
```
|
||||
|
||||
From src/client/hooks/useCurrency.ts (Plan 05):
|
||||
```typescript
|
||||
export interface CurrencyContext {
|
||||
currency: Currency;
|
||||
market: string;
|
||||
showConversions: boolean;
|
||||
}
|
||||
export function useCurrency(): CurrencyContext;
|
||||
```
|
||||
|
||||
From src/client/lib/formatters.ts (Plan 05):
|
||||
```typescript
|
||||
export function formatDualPrice(options: DualPriceOptions): { source: string; converted: string };
|
||||
```
|
||||
|
||||
From src/client/hooks/useGlobalItems.ts (existing):
|
||||
```typescript
|
||||
export function useGlobalItem(id: number): UseQueryResult<GlobalItem>;
|
||||
```
|
||||
|
||||
From src/client/components/ComparisonTable.tsx (existing):
|
||||
```typescript
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[];
|
||||
resolvedCandidateId: number | null;
|
||||
deltas?: Record<number, CandidateDelta>;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add market prices section to global item detail page</name>
|
||||
<files>src/client/routes/global-items/$globalItemId.tsx, src/client/hooks/useGlobalItems.ts</files>
|
||||
<read_first>src/client/routes/global-items/$globalItemId.tsx, src/client/hooks/useGlobalItems.ts, src/client/lib/api.ts</read_first>
|
||||
<action>
|
||||
Per D-17: Add a "Price" section to the global item detail page.
|
||||
|
||||
First, add a new hook in `src/client/hooks/useGlobalItems.ts`:
|
||||
```typescript
|
||||
export function useGlobalItemPrices(globalItemId: number) {
|
||||
return useQuery({
|
||||
queryKey: ["global-item-prices", globalItemId],
|
||||
queryFn: () => apiGet<{
|
||||
marketPrices: Array<{ market: string; currency: string; priceCents: number; source: string | null }>;
|
||||
}>(`/api/market-prices/global-items/${globalItemId}/prices`),
|
||||
enabled: globalItemId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGlobalItemCommunityStats(globalItemId: number) {
|
||||
return useQuery({
|
||||
queryKey: ["global-item-community-stats", globalItemId],
|
||||
queryFn: () => apiGet<Array<{ market: string; currency: string; medianPrice: number; reportCount: number }>>(`/api/community-prices/${globalItemId}`),
|
||||
enabled: globalItemId > 0,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Then update `src/client/routes/global-items/$globalItemId.tsx`:
|
||||
|
||||
Add a `MarketPricesSection` component within the detail page:
|
||||
- Uses `useCurrency()` to get `{ currency, market }`
|
||||
- Uses `useGlobalItemPrices(id)` and `useGlobalItemCommunityStats(id)`
|
||||
- Uses `useExchangeRates()` for conversion when needed
|
||||
|
||||
Layout per UI-SPEC section 4:
|
||||
1. Section heading: "Price" (`text-sm font-medium text-gray-900`)
|
||||
2. User's market MSRP shown prominently: find marketPrice where market matches user's market
|
||||
- If found: `text-lg font-semibold text-gray-900` + "MSRP ({MARKET})" label in `text-xs text-gray-500 ml-2`
|
||||
- If not found but other markets exist: show converted price from nearest market with dual display format using `formatDualPrice`
|
||||
3. Community stats for user's market: filter communityStats where market matches
|
||||
- Per D-21: "Community ({MARKET}): {SYMBOL}{median} median ({N} reports)" in `text-sm text-gray-700` with report count in `text-xs text-gray-400`
|
||||
- Only show if reportCount >= 3 (server already filters, but handle empty gracefully)
|
||||
4. Collapsible "Other Markets" section:
|
||||
- Use useState for expanded state, default collapsed
|
||||
- Toggle: "Other Markets" text with Lucide `chevron-right`/`chevron-down` icon (14px)
|
||||
- Style: `text-sm text-gray-500 cursor-pointer hover:text-gray-700`
|
||||
- Inner rows: same price/label styling, indented with `pl-4`
|
||||
- Show all market prices except user's market
|
||||
- Show community stats for other markets
|
||||
|
||||
Place this section below the existing weight/price display area in the detail page.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/hooks/useGlobalItems.ts exports useGlobalItemPrices and useGlobalItemCommunityStats
|
||||
- src/client/routes/global-items/$globalItemId.tsx contains a MarketPricesSection component
|
||||
- User's market MSRP shown prominently with market label
|
||||
- Community stats displayed as "Community ({MARKET}): {median} median ({N} reports)"
|
||||
- "Other Markets" section is collapsible and collapsed by default
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run build && grep -c "MarketPricesSection\|useGlobalItemPrices\|useGlobalItemCommunityStats" src/client/routes/global-items/\$globalItemId.tsx src/client/hooks/useGlobalItems.ts</automated>
|
||||
</verify>
|
||||
<done>Global item detail page shows market prices with user's market MSRP, community stats, and collapsible other markets</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update ComparisonTable with currency normalization and update MCP tools</name>
|
||||
<files>src/client/components/ComparisonTable.tsx, src/client/components/SetupCard.tsx, src/server/mcp/tools/index.ts</files>
|
||||
<read_first>src/client/components/ComparisonTable.tsx, src/client/components/SetupCard.tsx, src/server/mcp/tools/index.ts</read_first>
|
||||
<action>
|
||||
Per D-20: Update `src/client/components/ComparisonTable.tsx`:
|
||||
- Import `useCurrency` (for user's preferred currency), `useExchangeRates`, `convertClientPrice` from hooks
|
||||
- In the price rendering section:
|
||||
1. Check if candidate has a different currency than user's preference (via `priceCurrency` field on candidate if available, otherwise assume same currency)
|
||||
2. If different currency: convert using `convertClientPrice(candidate.priceCents, candidate.priceCurrency, userCurrency, rates)`
|
||||
3. Display converted price with `~` prefix in `text-gray-400`: e.g., `~$2,160` instead of plain `$2,160`
|
||||
4. Best-price highlighting (`bg-green-50`) should apply based on converted amounts for apples-to-apples comparison
|
||||
- Add a new "Found Price" row (per D-06) in the ATTRIBUTE_ROWS array:
|
||||
- Key: "foundPrice", Label: "Found Price"
|
||||
- Render: show candidate.foundPriceCents formatted with candidate.foundPriceCurrency if available, else "—"
|
||||
- Include date if available: `text-xs text-gray-400` below the price
|
||||
- Note: The CandidateWithCategory interface may need extending. If the API doesn't yet return foundPriceCents/foundPriceCurrency on candidates, check the thread service response and update the interface to match.
|
||||
|
||||
Per D-18: Update `src/client/components/SetupCard.tsx`:
|
||||
- If SetupCard shows a price total, ensure it uses `useFormatters().price()` which now uses the correct currency
|
||||
- This should already work if the component uses `useFormatters()` — verify and adjust if it uses hardcoded "$" or similar
|
||||
|
||||
Per MCP tool updates: Update `src/server/mcp/tools/index.ts`:
|
||||
- In `list_items` and `get_item` tool responses: include `priceCurrency` field alongside `priceCents`
|
||||
- In `get_setup` tool response: include currency info with totals
|
||||
- Add a new tool `get_exchange_rates`:
|
||||
- Description: "Get current exchange rates for currency conversion"
|
||||
- No parameters required
|
||||
- Returns: `{ base, date, rates }` from getExchangeRates()
|
||||
- In `create_item` and `update_item` tools: accept optional `priceCurrency` parameter
|
||||
- In `add_candidate` and `update_candidate` tools: accept optional `foundPriceCents`, `foundPriceCurrency`, `foundPriceDate` parameters
|
||||
- Follow existing MCP tool patterns for parameter/response structure
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- ComparisonTable.tsx imports useExchangeRates and convertClientPrice
|
||||
- ComparisonTable price cells show ~ prefix when price is converted from different currency
|
||||
- ComparisonTable has "Found Price" row for candidate research prices
|
||||
- SetupCard uses useFormatters().price() for currency-aware display
|
||||
- MCP tools/index.ts contains get_exchange_rates tool definition
|
||||
- MCP list_items and get_item responses include priceCurrency
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run build && grep -c "convertClientPrice\|foundPrice\|get_exchange_rates\|priceCurrency" src/client/components/ComparisonTable.tsx src/server/mcp/tools/index.ts</automated>
|
||||
</verify>
|
||||
<done>Comparison table normalizes currencies, MCP tools include currency context, setup cards display correct currency</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| server→client | Market prices and exchange rates served to public clients |
|
||||
| MCP client→server | MCP tool invocations with currency parameters |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-33-15 | Tampering | ComparisonTable conversion | accept | Client-side conversion uses server-provided rates — worst case is stale rates, not a security issue |
|
||||
| T-33-16 | Information Disclosure | market prices display | accept | Market prices are intentionally public data — MSRP is not sensitive |
|
||||
| T-33-17 | Tampering | MCP priceCurrency param | mitigate | MCP tools validate priceCurrency against known currency list before persisting |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun run build` succeeds (no TypeScript errors)
|
||||
- Global item detail shows market prices section
|
||||
- ComparisonTable normalizes prices to user's currency
|
||||
- MCP get_exchange_rates tool returns rates
|
||||
- All existing tests pass: `bun test`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Catalog detail page shows market prices + community data
|
||||
- Comparison table normalizes and labels converted prices
|
||||
- Setup cards show correct currency
|
||||
- MCP tools expose currency data and exchange rates
|
||||
- Full build succeeds
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/33-currency-system/33-06-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user