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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions DOCUMENT.S.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Useful Docs

- Tokens optimization: https://olivomarco.github.io/github-copilot-token-optimization/#comparisons
Comment on lines +1 to +3
- 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/
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
<img width="1238" height="646" alt="image" src="https://github.com/user-attachments/assets/889908e1-2d72-42c5-be0c-6382d6d4b023" />
<img width="1243" height="646" alt="image" src="https://github.com/user-attachments/assets/7f891afb-1d87-4db1-af38-cefa93edab60" />



# 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.
Expand All @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions e2e-tests/filters.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
2 changes: 2 additions & 0 deletions src/components/GameCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
<div class="p-6 relative">
<div class="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
Expand Down
124 changes: 124 additions & 0 deletions src/components/GameFilters.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
import type { Category, Publisher } from '../types/game';

interface Props {
categories: Category[];
publishers: Publisher[];
}

const { categories, publishers } = Astro.props;
---

<div
class="mb-8 bg-slate-800/60 backdrop-blur-sm rounded-xl border border-slate-700/50 p-6"
data-testid="game-filters"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-slate-100">Filter games</h2>
<button
type="button"
id="clear-filters-button"
class="text-sm font-medium text-blue-400 hover:text-blue-300 rounded px-2 py-1 transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:outline-none"
data-testid="clear-filters-button"
>
Clear filters
</button>
</div>

<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<fieldset data-testid="category-filter">
<legend class="text-sm font-medium text-slate-300 mb-2">Category</legend>
<div class="flex flex-wrap gap-2">
{categories.map((category) => (
<label class="inline-flex items-center gap-2 bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-1.5 text-sm text-slate-200 cursor-pointer hover:border-blue-500/50 transition-colors duration-200">
<input
type="checkbox"
value={category.id}
class="rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
data-testid="category-filter-option"
data-filter-group="category"
aria-label={category.name}
/>
{category.name}
</label>
))}
</div>
</fieldset>

<fieldset data-testid="publisher-filter">
<legend class="text-sm font-medium text-slate-300 mb-2">Publisher</legend>
<div class="flex flex-wrap gap-2">
{publishers.map((publisher) => (
<label class="inline-flex items-center gap-2 bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-1.5 text-sm text-slate-200 cursor-pointer hover:border-blue-500/50 transition-colors duration-200">
<input
type="checkbox"
value={publisher.id}
class="rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
data-testid="publisher-filter-option"
data-filter-group="publisher"
aria-label={publisher.name}
/>
{publisher.name}
</label>
))}
</div>
</fieldset>
</div>

<p class="mt-4 text-sm text-slate-400" role="status" aria-live="polite" data-testid="filter-results-count"></p>
</div>

<script>
// Client-side filtering over the fully prerendered game grid (no server round-trip).
const filtersRoot = document.querySelector('[data-testid="game-filters"]');
const grid = document.querySelector('[data-testid="games-grid"]');
const noResults = document.querySelector('[data-testid="no-results"]');
const resultsCount = document.querySelector('[data-testid="filter-results-count"]');
const clearButton = document.getElementById('clear-filters-button');

if (filtersRoot && grid) {
const cards = Array.from(grid.querySelectorAll('[data-testid="game-card"]'));
const checkboxes = Array.from(filtersRoot.querySelectorAll('input[type="checkbox"]'));

function selectedValues(group: string): string[] {
return checkboxes
.filter((checkbox): checkbox is HTMLInputElement => checkbox instanceof HTMLInputElement)
.filter((checkbox) => checkbox.dataset.filterGroup === group && checkbox.checked)
.map((checkbox) => checkbox.value);
}

function applyFilters(): void {
const categoryIds = selectedValues('category');
const publisherIds = selectedValues('publisher');
let visibleCount = 0;

for (const card of cards) {
if (!(card instanceof HTMLElement)) continue;
const matchesCategory = categoryIds.length === 0 || categoryIds.includes(card.dataset.categoryId ?? '');
const matchesPublisher = publisherIds.length === 0 || publisherIds.includes(card.dataset.publisherId ?? '');
const visible = matchesCategory && matchesPublisher;
card.classList.toggle('hidden', !visible);
if (visible) visibleCount++;
}

const filtersActive = categoryIds.length > 0 || publisherIds.length > 0;
noResults?.classList.toggle('hidden', visibleCount !== 0);
if (resultsCount) {
resultsCount.textContent = filtersActive
? `Showing ${visibleCount} of ${cards.length} games`
: '';
}
}

checkboxes.forEach((checkbox) => {
checkbox.addEventListener('change', applyFilters);
});

clearButton?.addEventListener('click', () => {
checkboxes.forEach((checkbox) => {
if (checkbox instanceof HTMLInputElement) checkbox.checked = false;
});
applyFilters();
});
}
</script>
26 changes: 26 additions & 0 deletions src/lib/categories.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
17 changes: 17 additions & 0 deletions src/lib/categories.ts
Original file line number Diff line number Diff line change
@@ -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<Category[]> {
return db
.select({ id: categories.id, name: categories.name })
.from(categories)
.orderBy(asc(categories.name));
}
Loading