Skip to content
Draft
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
6 changes: 4 additions & 2 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ jobs:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Run unit and E2E tests
run: npm run test:ci
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
Expand Down
2,132 changes: 1,898 additions & 234 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --write --plugin-search-dir=. .",
"test": "vitest run",
"test:ci": "npm test && npm run test:e2e",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:chromium": "playwright test --project=chromium",
Expand Down Expand Up @@ -48,7 +51,8 @@
"tailwindcss": "^4.1.4",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"vite": "^6.3.4"
"vite": "^6.3.4",
"vitest": "^1.6.1"
},
"type": "module",
"dependencies": {
Expand All @@ -59,6 +63,7 @@
"layerchart": "^1.0.11",
"picomatch": "^4.0.3",
"topojson-client": "^3.1.0",
"us-atlas": "^3.0.1"
"us-atlas": "^3.0.1",
"world-atlas": "^2.0.2"
}
}
25 changes: 2 additions & 23 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,19 @@ import { defineConfig, devices } from '@playwright/test';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
/**
* Global test timeout (ms)
* Increased to 30 seconds to accommodate slow loads
*/
timeout: 30000,
timeout: 45000,
testDir: './tests',
/* Run tests in files in parallel */
testIgnore: ['tests/unit/**'],
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
/**
* Shared settings for all tests. Sets baseURL for page.goto and API requests.
*/
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},

/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
Expand Down Expand Up @@ -76,10 +59,6 @@ export default defineConfig({
// },
],

/**
* Run the SvelteKit dev server before starting Playwright tests.
* Uses Vite default port 5173.
*/
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/components/Footer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<NavMenuItem href="/breweries">Breweries</NavMenuItem>
</div>

<div class="px-5 py-2">
<NavMenuItem href="/dashboard">Dashboard</NavMenuItem>
</div>

<div class="px-5 py-2">
<NavMenuItem href="/documentation">Docs</NavMenuItem>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/lib/components/MobileNav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
<NavMenuItem href="/breweries" {toggleMenu} isMobile={true}
>Breweries</NavMenuItem
>
<NavMenuItem href="/dashboard" {toggleMenu} isMobile={true}
>Dashboard</NavMenuItem
>
<NavMenuItem href="/documentation" {toggleMenu} isMobile={true}
>Docs</NavMenuItem
>
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/Nav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
</div>
<div class="hidden md:flex md:space-x-10">
<NavMenuItem href="/breweries">Breweries</NavMenuItem>
<NavMenuItem href="/dashboard">Dashboard</NavMenuItem>
<NavMenuItem href="/documentation">Docs</NavMenuItem>
<NavMenuItem href="/faq">FAQ</NavMenuItem>
<NavMenuItem href="/news">News</NavMenuItem>
Expand Down
94 changes: 94 additions & 0 deletions src/lib/components/WorldChoropleth.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts">
import { feature } from 'topojson-client';
import world110m from 'world-atlas/countries-110m.json';
import { geoPath, geoNaturalEarth1 } from 'd3-geo';

type CountryDatum = { countryId: string; label: string; count: number };

const { data } = $props<{
data: {
counts: CountryDatum[];
};
}>();

const idMap: Record<string, number> = {
'united-states': 840,
ireland: 372,
portugal: 620,
austria: 40,
singapore: 702,
poland: 616,
'south-korea': 410,
germany: 276,
italy: 380,
japan: 392,
sweden: 752,
// UK constituent countries map to the United Kingdom feature (826)
england: 826,
scotland: 826,
};

const projection = geoNaturalEarth1().scale(165).translate([480, 250]);
const path = geoPath(projection);

const world = world110m as unknown as { type: string; objects: any };
const countries = $derived(
feature(world as any, world.objects.countries).features as any[]
);

const countById = $derived(() => {
const m = new Map<number, number>();
for (const c of data.counts) {
const id = idMap[c.countryId];
if (id && c.count > 0) m.set(id, (m.get(id) ?? 0) + c.count);
}
return m;
});

const maxCount = $derived(
Array.from(countById().values()).reduce((m, v) => (v > m ? v : m), 0)
);

// NOTE: Logarithmic normalization so extreme values (e.g., US) don't dominate
const logDenom = $derived(Math.log1p(maxCount));

function colorFor(value: number | undefined) {
if (!value || maxCount === 0) return '#e5e7eb';
const t = Math.min(1, Math.max(0, Math.log1p(value) / (logDenom || 1)));
if (t > 0.8) return '#b45309';
if (t > 0.6) return '#d97706';
if (t > 0.4) return '#f59e0b';
if (t > 0.2) return '#fbbf24';
return '#fcd34d';
}

// Extra diagnostics: surface unknown mappings and mapped samples
$effect(() => {
if (!data?.counts?.length) return;
const unknown = data.counts.filter((c) => !idMap[c.countryId]).map((c) => c.countryId);
if (unknown.length) {
console.warn('[WorldChoropleth] unknown countryId(s) in data.counts:', Array.from(new Set(unknown)));
}
if (countById().size === 0) {
const sample = data.counts.slice(0, 10).map((c) => ({ id: c.countryId, mapped: idMap[c.countryId], count: c.count }));
console.debug('[WorldChoropleth] sample mapping preview:', sample);
}
});
</script>

<svg viewBox="0 0 960 500" class="w-full h-auto">
<g>
{#each countries as f}
{@const fid = typeof f.id === 'string' ? parseInt(f.id, 10) : f.id}
{@const value = countById().get(fid)}
<path
d={path(f)}
fill={colorFor(value)}
stroke="#ffffff"
stroke-width="0.5"
>
<title>{value ? `${value.toLocaleString()} breweries` : ''}</title>
</path>
{/each}
</g>
</svg>
5 changes: 3 additions & 2 deletions src/lib/stores/breweries.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ export function getHasNextPage() {
export function getHasPreviousPage() {
return hasPreviousPage;
}

// Optimistic setters for UI responsiveness during navigation
export function setPage(page: number) {
store.meta.page = Math.max(1, Math.floor(page)).toString();
}
Expand All @@ -65,6 +63,9 @@ export function setSearchQuery(query: string) {
export function setLoading(loading: boolean) {
store.loading = loading;
}
export function setTotalBreweries(total: number) {
store.meta.total = Math.max(0, Math.floor(total)).toString();
}

export function initializeStore(
initialBreweries: Brewery[] = [],
Expand Down
142 changes: 142 additions & 0 deletions src/lib/subdivisions-to-country.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { mappings, titleCase } from './utils';

function normalize(str: string) {
return str
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}

function fixKnownVariants(str: string) {
const map: Record<string, string> = {
niederosterreich: 'niederoesterreich',
oberosterreich: 'oberoesterreich',
karnten: 'kaernten',
};
return map[str] ?? str;
}

const c = mappings.countries;
const countryId = (k: keyof typeof mappings.countries) => c[k].id;

const usSubdivisions = [
'alabama', 'alaska', 'arizona', 'arkansas', 'california', 'colorado', 'connecticut', 'delaware', 'district of columbia', 'florida', 'georgia', 'hawaii', 'idaho', 'illinois', 'indiana', 'iowa', 'kansas', 'kentucky', 'louisiana', 'maine', 'maryland', 'massachusetts', 'michigan', 'minnesota', 'mississippi', 'missouri', 'montana', 'nebraska', 'nevada', 'new hampshire', 'new jersey', 'new mexico', 'new york', 'north carolina', 'north dakota', 'ohio', 'oklahoma', 'oregon', 'pennsylvania', 'rhode island', 'south carolina', 'south dakota', 'tennessee', 'texas', 'utah', 'vermont', 'virginia', 'washington', 'west virginia', 'wisconsin', 'wyoming',
];

const irelandSubs = [
'carlow',
'clare',
'cork',
'donegal',
'dublin',
'galway',
'kerry',
'kildare',
'kilkenny',
'laois',
'limerick',
'longford',
'louth',
'mayo',
'meath',
'monaghan',
'offaly',
'roscommon',
'sligo',
'tipperary',
'waterford',
'westmeath',
'wexford',
'wicklow',
];

const portugalSubs = [
'aveiro',
'beja',
'coimbra',
'faro',
'leiria',
'lisboa',
'portalegre',
'porto',
];

const austriaSubs = [
'kaernten',
'niederoesterreich',
'oberoesterreich',
'salzburg',
'steiermark',
];

const swedenSubs = [
'halland',
'blekinge',
];

const englandSubs = [
'east sussex',
'west sussex',
];
const scotlandSubs = [
'argyll',
'bute',
'east dunbartonshire',
'west dunbartonshire',
];

const southKoreaSubs = [
'busan',
'chungcheongbukdo',
'chungcheongnamdo',
'daejeon',
'deagu',
'gangwondo',
'gwangju',
'gyeonggido',
'gyeongsangbukdo',
'gyeongsangnamdo',
'incheon',
'jejudo',
'jeollabukdo',
'jeollanamdo',
'seoul',
];

const germanySubs = ['berlin'];
const italySubs = ['bolzano'];
const japanSubs = ['osaka'];
const singaporeSubs = ['singapore'];
const polandSubs = ['dolnoslaskie'];

const lookup = new Map<string, string>();

for (const name of usSubdivisions) lookup.set(name, countryId('united_states'));
for (const name of irelandSubs) lookup.set(name, countryId('ireland'));
for (const name of portugalSubs) lookup.set(name, countryId('portugal'));
for (const name of austriaSubs) lookup.set(name, countryId('austria'));
for (const name of swedenSubs) lookup.set(name, countryId('sweden'));
for (const name of englandSubs) lookup.set(name, countryId('england'));
for (const name of scotlandSubs) lookup.set(name, countryId('scotland'));
for (const name of southKoreaSubs) lookup.set(name, countryId('south_korea'));
for (const name of germanySubs) lookup.set(name, countryId('germany'));
for (const name of italySubs) lookup.set(name, countryId('italy'));
for (const name of japanSubs) lookup.set(name, countryId('japan'));
for (const name of singaporeSubs) lookup.set(name, countryId('singapore'));
for (const name of polandSubs) lookup.set(name, countryId('poland'));

export function mapSubdivisionToCountryId(input: string): string | 'unknown' {
const n1 = normalize(input);
const n = fixKnownVariants(n1);
return lookup.get(n) ?? 'unknown';
}

export function countryIdToLabel(id: string): string {
for (const key of Object.keys(c)) {
const k = key as keyof typeof mappings.countries;
if (c[k].id === id) return c[k].label;
}
return titleCase(id);
}
Loading