Skip to content

Commit 72fe142

Browse files
committed
Add rich demo seed data
1 parent 9f10976 commit 72fe142

11 files changed

Lines changed: 1260 additions & 30 deletions

File tree

README.md

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
| **🎯 Taste Profile Quiz** | Rate 10 well-known games and answer preference questions to build a personalized recommendation engine |
1818
| **🤖 Personalized Recommendations** | Content-based filtering scores games on complexity, theme, mechanics, and player count match |
1919
| **🎭 Mood-Based Discovery** | Browse by "Quick Party Game", "Deep Strategy", "Cozy Two-Player", and 5 more moods |
20-
| **🔍 Search & Filter** | Search 55+ real board games with filters for players, complexity, and playtime |
21-
| **📋 Game Detail Pages** | Full info, price comparison across retailers, reviews, similar games, play logging |
20+
| **🔍 Search & Filter** | Search 85+ real board games with filters for players, complexity, and playtime |
21+
| **📋 Game Detail Pages** | Full info, current prices, seeded deals, reviews, similar games, and play logging |
2222
| **📚 Collection Management** | Track owned games and wishlist with shelf view and sorting |
23-
| **📝 Play Logging** | Log plays with date, player count, winner, rating, and notes |
24-
| **📊 Stats Dashboard** | Charts for plays per month, complexity distribution, top categories, ratings |
25-
| **💰 Price Tracker** | Compare prices across Amazon, Target, and more with deal alert setup |
23+
| **📝 Play Logging** | Log plays with date, player count, winner, optional score, rating, and notes |
24+
| **📊 Stats Dashboard** | Charts for plays per month, complexity distribution, top categories, ratings, and recent scores |
25+
| **💰 Price Tracker** | Compare retailer snapshots, surface active deals, and set deal alerts |
2626

2727
## 🚀 Quick Start
2828

@@ -34,7 +34,7 @@ npm install
3434
npm run dev
3535
```
3636

37-
Open [http://localhost:3000](http://localhost:3000) — that's it! The SQLite database is created and seeded automatically with 55 real board games on first run.
37+
Open [http://localhost:3000](http://localhost:3000) — that's it! In non-production environments the SQLite database is created and reseeded automatically on first access with a rich demo dataset (85 board games, 12 personas, play history, reviews, collections, wishlists, price alerts, and active deals).
3838

3939
## 🛠 Tech Stack
4040

@@ -65,9 +65,12 @@ src/
6565
│ ├── price-alerts/ # Deal alert subscriptions
6666
│ └── health/ # Service health check
6767
├── components/ # Shared UI components
68-
├── data/games.ts # 55 real board games seed data
68+
├── data/
69+
│ ├── games.ts # Base board game catalog data
70+
│ └── seed/ # Seed catalog, personas, and deal definitions
6971
├── lib/
70-
│ ├── db.ts # SQLite schema, init, seed logic
72+
│ ├── db.ts # SQLite schema, init, and migrations
73+
│ ├── seed.ts # Idempotent transactional seed orchestration
7174
│ ├── recommendations.ts # Taste profile + scoring engine
7275
│ ├── moods.ts # Mood filter definitions
7376
│ ├── session.ts # Opaque session token management
@@ -92,32 +95,43 @@ SQLite with the following tables:
9295

9396
| Table | Purpose |
9497
| --- | --- |
95-
| `games` | Game catalog (55 seeded entries) |
96-
| `price_history` | Retailer prices per game |
97-
| `users` | User accounts (demo single-user MVP) |
98+
| `games` | Game catalog (85 seeded entries) |
99+
| `price_history` | Historical retailer price snapshots per game |
100+
| `game_deals` | Current seeded deals with discount metadata |
101+
| `users` | Seeded personas plus runtime-created users |
98102
| `sessions` | Opaque session tokens |
99103
| `quiz_answers` | Game ratings and preference answers |
100104
| `collection` | Owned/wishlist items |
101-
| `play_logs` | Play history with ratings |
105+
| `play_logs` | Play history with winners, ratings, and optional scores |
102106
| `reviews` | User game reviews |
103107
| `price_alerts` | Deal alert subscriptions |
108+
| `seed_metadata` | Seed dataset version tracking |
104109

105110
## 🎮 Seed Data
106111

107-
The database includes 55 real board games spanning all complexity levels:
112+
The demo dataset is intentionally rich and deterministic:
108113

109-
- **Heavy**: Gloomhaven, Brass: Birmingham, Spirit Island
110-
- **Medium-Heavy**: Terraforming Mars, Scythe, Twilight Imperium
111-
- **Medium**: Wingspan, 7 Wonders, Pandemic Legacy
112-
- **Gateway**: Catan, Ticket to Ride, Azul
113-
- **Light/Party**: Codenames, Coup, Love Letter
114+
- **85 real games** with accurate mechanics, themes, player counts, playtimes, and complexity spread from **1.0 (`No Thanks!`)** to **5.0 (`Advanced Squad Leader`)**
115+
- **12 user personas** with distinct tastes, collection sizes, wishlists, quiz answers, and price alerts
116+
- **216 seeded play logs** with dates, player counts, winners, ratings, and scores
117+
- **60 seeded reviews** with short, medium, and long-form text
118+
- **Historical price snapshots** for every retailer listing plus **active deals** for a large portion of the catalog
119+
- **Edge cases** including unicode names (`zoë_meeples`, `señor_carton`, `Café`, `Jórvík`) and long descriptions for heavier narrative titles
120+
121+
### Seed Behavior
122+
123+
- **Guarded in production** by default — automatic reseeding only runs outside production unless `GAMESCOUT_ALLOW_PRODUCTION_SEED=1`
124+
- **Idempotent** — the seeder truncates seed-managed tables and re-inserts a known-good dataset version
125+
- **Transactional** — catalog, personas, logs, reviews, and pricing data are inserted in a single transaction
126+
- **Conventionally placed** — catalog and persona definitions live in `src/data/seed/`, while orchestration lives in `src/lib/seed.ts`
114127

115128
## 📜 Available Scripts
116129

117130
| Command | Description |
118131
| --- | --- |
119132
| `npm run dev` | Start development server with hot reload |
120133
| `npm run build` | Create optimized production build |
134+
| `GAMESCOUT_ALLOW_PRODUCTION_SEED=1 npm run start` | Allow one-time automatic seeding while running the production server |
121135
| `npm run start` | Run production server |
122136
| `npm run lint` | Run ESLint checks |
123137
| `npm run lint:types` | TypeScript type checking (`tsc --noEmit`) |

src/app/api/games/[id]/route.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* collection status, average review score, and play logs.
66
*/
77

8-
import { getDb, parseGame, type GameRow, type PriceRow, type ReviewRow, GAME_COLUMNS, GAME_LIST_COLUMNS } from "@/lib/db";
8+
import { getDb, parseGame, type DealRow, type GameRow, type PriceRow, type ReviewRow, GAME_COLUMNS, GAME_LIST_COLUMNS } from "@/lib/db";
99
import { getUserId } from "@/lib/session";
1010

1111
export const dynamic = "force-dynamic";
@@ -33,9 +33,27 @@ export async function GET(
3333
// Prices
3434
const prices = db
3535
.prepare(
36-
`SELECT id, game_id, retailer, price, url, updated_at FROM price_history WHERE game_id = ? ORDER BY price ASC`
36+
`SELECT ph.id, ph.game_id, ph.retailer, ph.price, ph.url, ph.updated_at
37+
FROM price_history ph
38+
JOIN (
39+
SELECT retailer, MAX(updated_at) AS updated_at
40+
FROM price_history
41+
WHERE game_id = ?
42+
GROUP BY retailer
43+
) latest ON latest.retailer = ph.retailer AND latest.updated_at = ph.updated_at
44+
WHERE ph.game_id = ?
45+
ORDER BY ph.price ASC`
3746
)
38-
.all(Number(id)) as PriceRow[];
47+
.all(Number(id), Number(id)) as PriceRow[];
48+
49+
const deals = db
50+
.prepare(
51+
`SELECT id, game_id, retailer, title, sale_price, msrp, discount_pct, url, starts_at, ends_at, coupon_code, featured
52+
FROM game_deals
53+
WHERE game_id = ?
54+
ORDER BY featured DESC, discount_pct DESC, sale_price ASC`
55+
)
56+
.all(Number(id)) as DealRow[];
3957

4058
// Reviews
4159
const reviews = db
@@ -75,13 +93,14 @@ export async function GET(
7593
// Play logs for this game
7694
const playLogs = db
7795
.prepare(
78-
`SELECT pl.id, pl.played_at, pl.players, pl.winner, pl.rating, pl.notes, g.name as game_name FROM play_logs pl JOIN games g ON pl.game_id = g.id WHERE pl.user_id = ? AND pl.game_id = ? ORDER BY pl.played_at DESC`
96+
`SELECT pl.id, pl.played_at, pl.players, pl.winner, pl.rating, pl.score, pl.notes, g.name as game_name FROM play_logs pl JOIN games g ON pl.game_id = g.id WHERE pl.user_id = ? AND pl.game_id = ? ORDER BY pl.played_at DESC`
7997
)
8098
.all(userId, Number(id));
8199

82100
return Response.json({
83101
game,
84102
prices,
103+
deals,
85104
reviews,
86105
similar,
87106
collectionStatus: collectionStatus?.status || null,

src/app/api/play-logs/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function GET() {
1919

2020
const logs = db
2121
.prepare(
22-
`SELECT pl.id, pl.user_id, pl.game_id, pl.played_at, pl.players, pl.winner, pl.rating, pl.notes, pl.created_at, g.name as game_name, g.thumbnail_url
22+
`SELECT pl.id, pl.user_id, pl.game_id, pl.played_at, pl.players, pl.winner, pl.rating, pl.score, pl.notes, pl.created_at, g.name as game_name, g.thumbnail_url
2323
FROM play_logs pl
2424
JOIN games g ON pl.game_id = g.id
2525
WHERE pl.user_id = ?
@@ -81,6 +81,7 @@ export async function POST(request: Request) {
8181
players?: number;
8282
winner?: string;
8383
rating?: number;
84+
score?: number;
8485
notes?: string;
8586
};
8687
try {
@@ -89,7 +90,7 @@ export async function POST(request: Request) {
8990
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
9091
}
9192

92-
const { gameId, playedAt, players, winner, rating, notes } = body;
93+
const { gameId, playedAt, players, winner, rating, score, notes } = body;
9394

9495
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
9596
if (!gameId || !playedAt || !dateRegex.test(playedAt) || isNaN(Date.parse(playedAt))) {
@@ -104,6 +105,10 @@ export async function POST(request: Request) {
104105
return Response.json({ error: "rating must be an integer between 1 and 10" }, { status: 400 });
105106
}
106107

108+
if (score !== undefined && (!Number.isInteger(score) || score < 0 || score > 9999)) {
109+
return Response.json({ error: "score must be an integer between 0 and 9999" }, { status: 400 });
110+
}
111+
107112
if (winner !== undefined && typeof winner !== "string") {
108113
return Response.json({ error: "winner must be a string" }, { status: 400 });
109114
}
@@ -117,8 +122,8 @@ export async function POST(request: Request) {
117122

118123
const result = db
119124
.prepare(
120-
`INSERT INTO play_logs (user_id, game_id, played_at, players, winner, rating, notes)
121-
VALUES (@userId, @gameId, @playedAt, @players, @winner, @rating, @notes)`
125+
`INSERT INTO play_logs (user_id, game_id, played_at, players, winner, rating, score, notes)
126+
VALUES (@userId, @gameId, @playedAt, @players, @winner, @rating, @score, @notes)`
122127
)
123128
.run({
124129
userId,
@@ -127,6 +132,7 @@ export async function POST(request: Request) {
127132
players: players || null,
128133
winner: sanitizedWinner,
129134
rating: rating || null,
135+
score: score ?? null,
130136
notes: sanitizedNotes,
131137
});
132138

src/app/games/[id]/_components/PlayLogSection.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export function PlayLogSection({
126126
{log.rating}/10
127127
</span>
128128
)}
129+
{log.score !== null && log.score !== undefined && (
130+
<span className="text-xs text-sky-400">
131+
🎯 {log.score} pts
132+
</span>
133+
)}
129134
</div>
130135
{log.notes && (
131136
<p className="text-xs text-zinc-500 mt-1 italic">

src/app/games/[id]/_components/PriceComparison.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
22

3-
import type { PriceInfo } from "@/types";
3+
import type { DealInfo, PriceInfo } from "@/types";
44

55
interface PriceComparisonProps {
66
prices: PriceInfo[];
7+
deals: DealInfo[];
78
showAlertForm: boolean;
89
alertSet: boolean;
910
alertPrice: string;
@@ -18,6 +19,7 @@ interface PriceComparisonProps {
1819

1920
export function PriceComparison({
2021
prices,
22+
deals,
2123
showAlertForm,
2224
alertSet,
2325
alertPrice,
@@ -81,6 +83,51 @@ export function PriceComparison({
8183
))}
8284
</div>
8385

86+
{deals.length > 0 && (
87+
<div className="mb-4 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
88+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-amber-300">
89+
Active Deals
90+
</h3>
91+
<div className="space-y-3">
92+
{deals.map((deal) => (
93+
<div key={deal.id} className="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 p-3 sm:flex-row sm:items-center sm:justify-between">
94+
<div>
95+
<div className="flex flex-wrap items-center gap-2">
96+
{deal.featured === 1 && (
97+
<span className="rounded-full bg-amber-400 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-black">
98+
Featured
99+
</span>
100+
)}
101+
<span className="font-medium text-white">{deal.title}</span>
102+
<span className="text-xs text-zinc-500">{deal.retailer}</span>
103+
</div>
104+
<p className="mt-1 text-xs text-zinc-400">
105+
Save {deal.discount_pct}% until {new Date(deal.ends_at).toLocaleDateString()}
106+
{deal.coupon_code ? ` · Code ${deal.coupon_code}` : ""}
107+
</p>
108+
</div>
109+
<div className="flex items-center gap-3">
110+
<div className="text-right">
111+
<p className="text-sm text-zinc-500 line-through">${deal.msrp.toFixed(2)}</p>
112+
<p className="text-lg font-bold text-amber-300">${deal.sale_price.toFixed(2)}</p>
113+
</div>
114+
{deal.url && (
115+
<a
116+
href={deal.url}
117+
target="_blank"
118+
rel="noopener noreferrer"
119+
className="rounded-lg bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-amber-300"
120+
>
121+
View deal →
122+
</a>
123+
)}
124+
</div>
125+
</div>
126+
))}
127+
</div>
128+
</div>
129+
)}
130+
84131
{/* Deal Alert */}
85132
{!alertSet && !showAlertForm && (
86133
<button

src/app/games/[id]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export default function GameDetailPage({
278278
);
279279
}
280280

281-
const { game, prices, reviews, similar, playLogs, avgReview } = data;
281+
const { game, prices, deals, reviews, similar, playLogs, avgReview } = data;
282282

283283
return (
284284
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -317,6 +317,7 @@ export default function GameDetailPage({
317317

318318
<PriceComparison
319319
prices={prices}
320+
deals={deals}
320321
showAlertForm={showAlertForm}
321322
alertSet={alertSet}
322323
alertPrice={alertPrice}

src/app/stats/_components/RecentPlaysTable.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export function RecentPlaysTable({ logs }: RecentPlaysTableProps) {
2929
<th className="pb-3 pr-4">Date</th>
3030
<th className="pb-3 pr-4">Players</th>
3131
<th className="pb-3 pr-4">Winner</th>
32-
<th className="pb-3">Rating</th>
32+
<th className="pb-3 pr-4">Rating</th>
33+
<th className="pb-3">Score</th>
3334
</tr>
3435
</thead>
3536
<tbody className="divide-y divide-zinc-800">
@@ -52,7 +53,7 @@ export function RecentPlaysTable({ logs }: RecentPlaysTableProps) {
5253
<td className="py-3 pr-4 text-yellow-400">
5354
{log.winner || "—"}
5455
</td>
55-
<td className="py-3">
56+
<td className="py-3 pr-4">
5657
{log.rating ? (
5758
<span className="text-emerald-400 font-medium">
5859
{log.rating}/10
@@ -61,6 +62,9 @@ export function RecentPlaysTable({ logs }: RecentPlaysTableProps) {
6162
<span className="text-zinc-600"></span>
6263
)}
6364
</td>
65+
<td className="py-3 text-sky-400">
66+
{log.score ?? "—"}
67+
</td>
6468
</tr>
6569
))}
6670
</tbody>

0 commit comments

Comments
 (0)