diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4fc2812..8132532 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -89,12 +89,12 @@ This is a crowdfunding platform for games with a developer theme. The applicatio The application lives at the repository root: - `db/`: Drizzle schema, migrations, transforms, seed, and `games.csv` -- `src/lib/`: Node SQLite client (`db.ts`) and data-access helpers (`games.ts`) -- `src/components/`: reusable `.astro` components +- `src/lib/`: Node SQLite client (`db.ts`) and data-access helpers (`games.ts` with category/publisher filtering, `categories.ts`, `publishers.ts`) +- `src/components/`: reusable `.astro` components (including `GameFilters.astro` for category/publisher filtering) - `src/layouts/`: Astro layout templates -- `src/pages/`: Astro page routes (`index.astro` listing, `game/[id].astro`, `404.astro`, `about.astro`) +- `src/pages/`: Astro page routes (`index.astro` listing with filters, `game/[id].astro`, `404.astro`, `about.astro`) - `src/styles/`: CSS and Tailwind configuration - `src/types/`: TypeScript interfaces (Game, Publisher, Category) -- `e2e-tests/`: Playwright E2E tests (home, games, accessibility) +- `e2e-tests/`: Playwright E2E tests (home, games, accessibility, filters) - `drizzle.config.ts`, `vitest.config.ts`, `astro.config.mjs`, `playwright.config.ts`: tooling config - `README.md`: Project documentation diff --git a/DOCUMENT.S.md b/DOCUMENT.S.md new file mode 100644 index 0000000..02ef7b7 --- /dev/null +++ b/DOCUMENT.S.md @@ -0,0 +1,7 @@ +# Useful Docs + +- Tokens optimization: https://olivomarco.github.io/github-copilot-token-optimization/#comparisons +- Agentic DevOps Workshop: https://copilot-dev-days.github.io/agentic-workflows-workshop/step.html?step=readme +- Spec Kit: https://github.com/github/spec-kit +- HVE Core: https://github.com/microsoft/hve-core +- Awesome Copilot: https://awesome-copilot.github.com/ diff --git a/README.md b/README.md index 219b666..9e1c39d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +Basic workshop url: https://github-samples.github.io/copilot-workshops/vscode/0-prerequisites/ +Agentic devops workshop: https://copilot-dev-days.github.io/agentic-workflows-workshop/step.html?step=readme + +Free hack: +image +image + + + # Tailspin Toys Tailspin Toys is a crowdfunding platform for games with a developer theme. The project is a website for a fictional game crowd-funding company, built as a single [Astro](https://astro.build/) site (fully prerendered/static output) styled with [Tailwind CSS](https://tailwindcss.com/). Its data lives in a local SQLite database accessed through [Drizzle ORM](https://orm.drizzle.team/) and Node.js's built-in SQLite driver; pages query the database directly in frontmatter at build time, so there is no separate backend service. @@ -12,6 +21,11 @@ Tailspin Toys is a crowdfunding platform for games with a developer theme. The p The database is migrated and seeded automatically before `dev`/`build` (via the `predev`/`prebuild` npm scripts) and is written to the gitignored `tailspin.db` file. +## Features + +- **Game catalog** (`/`) — lists all games as cards, each linking to a details page. +- **Category & publisher filtering** — the homepage includes filter controls (`src/components/GameFilters.astro`) so visitors can narrow the catalog by one or more categories and/or a publisher; filters combine (category AND publisher, with OR between multiple selections in the same group). Since the site is fully prerendered, filtering runs client-side over the already-rendered game grid — no rebuild or server round-trip is needed. The underlying data-access helpers (`getAllGames` in `src/lib/games.ts`, plus `getAllCategories`/`getAllPublishers` in `src/lib/categories.ts`/`src/lib/publishers.ts`) also support filtering directly for reuse and testing. + ## Using this template This repository is a GitHub template. When you create a new repository from it, a one-time **Bootstrap template issues** workflow (`.github/workflows/bootstrap-issues.yml`) runs automatically on the first push to `main` and opens a set of starter issues describing suggested first features. Each issue is defined by a Markdown file in `.github/bootstrap-issues/` — the first heading becomes the issue title and the remaining content becomes the body — so you can edit, add, or remove files there to control which issues are created. diff --git a/e2e-tests/filters.spec.ts b/e2e-tests/filters.spec.ts new file mode 100644 index 0000000..69195c2 --- /dev/null +++ b/e2e-tests/filters.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Game Filtering', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('games-grid')).toBeVisible(); + }); + + test('should display category and publisher filter controls', async ({ page }) => { + await test.step('Verify the filter panel and groups are visible', async () => { + await expect(page.getByTestId('game-filters')).toBeVisible(); + await expect(page.getByRole('group', { name: 'Category' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Publisher' })).toBeVisible(); + }); + + await test.step('Verify filter options are unchecked by default', async () => { + const strategyCheckbox = page.getByRole('checkbox', { name: 'Strategy' }); + await expect(strategyCheckbox).not.toBeChecked(); + }); + }); + + test('should filter games by a single category', async ({ page }) => { + const totalCount = await page.getByTestId('game-card').count(); + + await test.step('Select the Strategy category filter', async () => { + await page.getByRole('checkbox', { name: 'Strategy' }).check(); + }); + + await test.step('Verify only Strategy games remain visible', async () => { + const visibleCards = page.locator('[data-testid="game-card"]:visible'); + const categories = await visibleCards.locator('[data-testid="game-category"]').allTextContents(); + expect(categories.length).toBeGreaterThan(0); + expect(categories.every((category) => category === 'Strategy')).toBeTruthy(); + }); + + await test.step('Verify the results count is announced', async () => { + const visibleCount = await page.locator('[data-testid="game-card"]:visible').count(); + await expect(page.getByTestId('filter-results-count')).toHaveText(`Showing ${visibleCount} of ${totalCount} games`); + }); + }); + + test('should combine multiple categories with OR semantics', async ({ page }) => { + await test.step('Select two category filters', async () => { + await page.getByRole('checkbox', { name: 'Strategy' }).check(); + await page.getByRole('checkbox', { name: 'Puzzle' }).check(); + }); + + await test.step('Verify only games from either category remain visible', async () => { + const visibleCards = page.locator('[data-testid="game-card"]:visible'); + const categories = await visibleCards.locator('[data-testid="game-category"]').allTextContents(); + expect(categories.length).toBeGreaterThan(0); + expect(categories.every((category) => category === 'Strategy' || category === 'Puzzle')).toBeTruthy(); + }); + }); + + test('should filter games by publisher', async ({ page }) => { + await test.step('Select a publisher filter', async () => { + const publisherCheckbox = page.getByTestId('publisher-filter').getByRole('checkbox').first(); + const publisherName = await publisherCheckbox.getAttribute('aria-label'); + await publisherCheckbox.check(); + + await test.step('Verify only games from that publisher remain visible', async () => { + const visibleCards = page.locator('[data-testid="game-card"]:visible'); + const publisherNames = await visibleCards.locator('[data-testid="game-publisher"]').allTextContents(); + expect(publisherNames.length).toBeGreaterThan(0); + expect(publisherNames.every((name) => name === publisherName)).toBeTruthy(); + }); + }); + }); + + test('should combine category and publisher filters with AND semantics', async ({ page }) => { + await test.step('Select a category and a publisher filter', async () => { + await page.getByRole('checkbox', { name: 'Strategy' }).check(); + await page.getByRole('checkbox', { name: 'GitHub Games' }).check(); + }); + + await test.step('Verify only games matching both filters remain visible', async () => { + const visibleCards = page.locator('[data-testid="game-card"]:visible'); + await expect(visibleCards).toHaveCount(1); + await expect(visibleCards.getByTestId('game-category')).toHaveText('Strategy'); + await expect(visibleCards.getByTestId('game-publisher')).toHaveText('GitHub Games'); + }); + }); + + test('should clear all filters and restore the full game list', async ({ page }) => { + const totalCount = await page.getByTestId('game-card').count(); + + await test.step('Apply filters', async () => { + await page.getByRole('checkbox', { name: 'Strategy' }).check(); + await page.getByRole('checkbox', { name: 'GitHub Games' }).check(); + await expect(page.locator('[data-testid="game-card"]:visible')).toHaveCount(1); + }); + + await test.step('Clear filters and verify all games are visible again', async () => { + await page.getByTestId('clear-filters-button').click(); + await expect(page.locator('[data-testid="game-card"]:visible')).toHaveCount(totalCount); + await expect(page.getByRole('checkbox', { name: 'Strategy' })).not.toBeChecked(); + await expect(page.getByRole('checkbox', { name: 'GitHub Games' })).not.toBeChecked(); + }); + }); + + test('should support keyboard interaction with filter checkboxes', async ({ page }) => { + const strategyCheckbox = page.getByRole('checkbox', { name: 'Strategy' }); + + await test.step('Focus and toggle the checkbox with the keyboard', async () => { + await strategyCheckbox.focus(); + await expect(strategyCheckbox).toBeFocused(); + await page.keyboard.press('Space'); + await expect(strategyCheckbox).toBeChecked(); + }); + + await test.step('Verify the grid updates in response', async () => { + const categories = await page.locator('[data-testid="game-card"]:visible [data-testid="game-category"]').allTextContents(); + expect(categories.every((category) => category === 'Strategy')).toBeTruthy(); + }); + }); +}); diff --git a/src/components/GameCard.astro b/src/components/GameCard.astro index da71b54..77d8532 100644 --- a/src/components/GameCard.astro +++ b/src/components/GameCard.astro @@ -16,6 +16,8 @@ const { game } = Astro.props; data-testid="game-card" data-game-id={game.id} data-game-title={game.title} + data-category-id={game.category?.id} + data-publisher-id={game.publisher?.id} >
diff --git a/src/components/GameFilters.astro b/src/components/GameFilters.astro new file mode 100644 index 0000000..f5de26d --- /dev/null +++ b/src/components/GameFilters.astro @@ -0,0 +1,124 @@ +--- +import type { Category, Publisher } from '../types/game'; + +interface Props { + categories: Category[]; + publishers: Publisher[]; +} + +const { categories, publishers } = Astro.props; +--- + +
+
+

Filter games

+ +
+ +
+
+ Category +
+ {categories.map((category) => ( + + ))} +
+
+ +
+ Publisher +
+ {publishers.map((publisher) => ( + + ))} +
+
+
+ +

+
+ + diff --git a/src/lib/categories.test.ts b/src/lib/categories.test.ts new file mode 100644 index 0000000..22f21de --- /dev/null +++ b/src/lib/categories.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDatabase } from '../../db/test-helpers'; +import { categories } from '../../db/schema'; +import type { Database } from './db'; +import { getAllCategories } from './categories'; + +describe('categories data-access helpers', () => { + let db: Database; + + beforeEach(async () => { + db = await createTestDatabase(); + }); + + it('returns all categories ordered by name', async () => { + await db.insert(categories).values({ name: 'Strategy', description: 'cat' }); + await db.insert(categories).values({ name: 'Arcade', description: 'cat' }); + + const all = await getAllCategories(db); + expect(all.map((c) => c.name)).toEqual(['Arcade', 'Strategy']); + expect(all[0]).toEqual({ id: expect.any(Number), name: 'Arcade' }); + }); + + it('returns an empty array when there are no categories', async () => { + expect(await getAllCategories(db)).toEqual([]); + }); +}); diff --git a/src/lib/categories.ts b/src/lib/categories.ts new file mode 100644 index 0000000..6f89a40 --- /dev/null +++ b/src/lib/categories.ts @@ -0,0 +1,17 @@ +import { asc } from 'drizzle-orm'; +import type { Category } from '../types/game'; +import { categories } from '../../db/schema'; +import type { Database } from './db'; + +/** + * Returns all categories with their IDs and names, ordered by name. + * + * @param db - The database connection to query. + * @returns The categories' IDs and names. + */ +export async function getAllCategories(db: Database): Promise { + return db + .select({ id: categories.id, name: categories.name }) + .from(categories) + .orderBy(asc(categories.name)); +} diff --git a/src/lib/games.test.ts b/src/lib/games.test.ts index efea6b6..9458bb8 100644 --- a/src/lib/games.test.ts +++ b/src/lib/games.test.ts @@ -64,3 +64,101 @@ describe('games data-access helpers', () => { expect(await getGameById(db, 99999)).toBeNull(); }); }); + +async function seedFilterableGames(db: Database): Promise<{ + strategyId: number; + puzzleId: number; + pubOneId: number; + pubTwoId: number; +}> { + const [strategy] = await db + .insert(categories) + .values({ name: 'Strategy', description: 'cat' }) + .returning({ id: categories.id }); + const [puzzle] = await db + .insert(categories) + .values({ name: 'Puzzle', description: 'cat' }) + .returning({ id: categories.id }); + const [pubOne] = await db + .insert(publishers) + .values({ name: 'Pub One', description: 'pub' }) + .returning({ id: publishers.id }); + const [pubTwo] = await db + .insert(publishers) + .values({ name: 'Pub Two', description: 'pub' }) + .returning({ id: publishers.id }); + + await db.insert(games).values({ + title: 'Strategy One', + description: 'd', + starRating: 4, + categoryId: strategy.id, + publisherId: pubOne.id, + }); + await db.insert(games).values({ + title: 'Strategy Two', + description: 'd', + starRating: 4, + categoryId: strategy.id, + publisherId: pubTwo.id, + }); + await db.insert(games).values({ + title: 'Puzzle One', + description: 'd', + starRating: 4, + categoryId: puzzle.id, + publisherId: pubOne.id, + }); + await db.insert(games).values({ + title: 'Puzzle Two', + description: 'd', + starRating: 4, + categoryId: puzzle.id, + publisherId: pubTwo.id, + }); + + return { strategyId: strategy.id, puzzleId: puzzle.id, pubOneId: pubOne.id, pubTwoId: pubTwo.id }; +} + +describe('getAllGames filtering', () => { + let db: Database; + + beforeEach(async () => { + db = await createTestDatabase(); + }); + + it('returns all games when no filters are provided', async () => { + await seedFilterableGames(db); + expect(await getAllGames(db)).toHaveLength(4); + }); + + it('filters games by a single category', async () => { + const { strategyId } = await seedFilterableGames(db); + const filtered = await getAllGames(db, { categoryIds: [strategyId] }); + expect(filtered.map((g) => g.title)).toEqual(['Strategy One', 'Strategy Two']); + }); + + it('filters games by multiple categories (OR within the group)', async () => { + const { strategyId, puzzleId } = await seedFilterableGames(db); + const filtered = await getAllGames(db, { categoryIds: [strategyId, puzzleId] }); + expect(filtered).toHaveLength(4); + }); + + it('filters games by publisher', async () => { + const { pubOneId } = await seedFilterableGames(db); + const filtered = await getAllGames(db, { publisherIds: [pubOneId] }); + expect(filtered.map((g) => g.title)).toEqual(['Puzzle One', 'Strategy One']); + }); + + it('combines category and publisher filters (AND across groups)', async () => { + const { strategyId, pubTwoId } = await seedFilterableGames(db); + const filtered = await getAllGames(db, { categoryIds: [strategyId], publisherIds: [pubTwoId] }); + expect(filtered.map((g) => g.title)).toEqual(['Strategy Two']); + }); + + it('returns an empty array when no games match the combined filters', async () => { + const { puzzleId, pubOneId } = await seedFilterableGames(db); + const filtered = await getAllGames(db, { categoryIds: [puzzleId], publisherIds: [pubOneId + 999] }); + expect(filtered).toEqual([]); + }); +}); diff --git a/src/lib/games.ts b/src/lib/games.ts index 4ed218e..5362cc5 100644 --- a/src/lib/games.ts +++ b/src/lib/games.ts @@ -1,8 +1,21 @@ -import { eq, asc } from 'drizzle-orm'; +import { eq, asc, and, inArray, type SQL } from 'drizzle-orm'; import type { Database } from './db'; import { games, categories, publishers } from '../../db/schema'; import type { Game } from '../types/game'; +/** Optional filters for narrowing the game list by category and/or publisher. */ +export interface GameFilters { + categoryIds?: number[]; + publisherIds?: number[]; +} + +function buildGameFilterCondition(filters?: GameFilters): SQL | undefined { + return and( + filters?.categoryIds?.length ? inArray(games.categoryId, filters.categoryIds) : undefined, + filters?.publisherIds?.length ? inArray(games.publisherId, filters.publisherIds) : undefined, + ); +} + const gameSelection = { id: games.id, title: games.title, @@ -50,9 +63,11 @@ function baseGamesQuery(db: Database) { .leftJoin(publishers, eq(games.publisherId, publishers.id)); } -/** All games ordered by title. */ -export async function getAllGames(db: Database): Promise { - const rows = await baseGamesQuery(db).orderBy(asc(games.title)); +/** All games ordered by title, optionally narrowed by category and/or publisher. */ +export async function getAllGames(db: Database, filters?: GameFilters): Promise { + const rows = await baseGamesQuery(db) + .where(buildGameFilterCondition(filters)) + .orderBy(asc(games.title)); return rows.map(mapGame); } diff --git a/src/lib/publishers.test.ts b/src/lib/publishers.test.ts new file mode 100644 index 0000000..28b6439 --- /dev/null +++ b/src/lib/publishers.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDatabase } from '../../db/test-helpers'; +import { publishers } from '../../db/schema'; +import type { Database } from './db'; +import { getAllPublishers } from './publishers'; + +describe('publishers data-access helpers', () => { + let db: Database; + + beforeEach(async () => { + db = await createTestDatabase(); + }); + + it('returns all publishers ordered by name', async () => { + await db.insert(publishers).values({ name: 'Zed Games', description: 'pub' }); + await db.insert(publishers).values({ name: 'Acme Games', description: 'pub' }); + + const all = await getAllPublishers(db); + expect(all.map((p) => p.name)).toEqual(['Acme Games', 'Zed Games']); + expect(all[0]).toEqual({ id: expect.any(Number), name: 'Acme Games' }); + }); + + it('returns an empty array when there are no publishers', async () => { + expect(await getAllPublishers(db)).toEqual([]); + }); +}); diff --git a/src/lib/publishers.ts b/src/lib/publishers.ts new file mode 100644 index 0000000..c70d65f --- /dev/null +++ b/src/lib/publishers.ts @@ -0,0 +1,17 @@ +import { asc } from 'drizzle-orm'; +import type { Publisher } from '../types/game'; +import { publishers } from '../../db/schema'; +import type { Database } from './db'; + +/** + * Returns all publishers with their IDs and names, ordered by name. + * + * @param db - The database connection to query. + * @returns The publishers' IDs and names. + */ +export async function getAllPublishers(db: Database): Promise { + return db + .select({ id: publishers.id, name: publishers.name }) + .from(publishers) + .orderBy(asc(publishers.name)); +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 5ad3cfa..60da4e2 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,12 +3,18 @@ import Layout from '../layouts/Layout.astro'; import GameCard from '../components/GameCard.astro'; import PageHero from '../components/PageHero.astro'; import EmptyState from '../components/EmptyState.astro'; +import GameFilters from '../components/GameFilters.astro'; import { getDatabase } from '../lib/db'; import { getAllGames } from '../lib/games'; +import { getAllCategories } from '../lib/categories'; +import { getAllPublishers } from '../lib/publishers'; export const prerender = true; -const games = await getAllGames(getDatabase()); +const db = getDatabase(); +const games = await getAllGames(db); +const categories = await getAllCategories(db); +const publishers = await getAllPublishers(db); --- @@ -24,10 +30,19 @@ const games = await getAllGames(getDatabase()); {games.length === 0 ? ( ) : ( -
- {games.map((game) => )} -
+ <> + {(categories.length > 0 || publishers.length > 0) && ( + + )} +
+ {games.map((game) => )} +
+ + )}
+