feat(citycorners): add city and platform statistics

- Add getCityStats() and getPlatformStats() query helpers
- City home: stats panel showing location count, map coverage,
  contributor count, and top category breakdown
- City discovery: platform stats bar (total cities, locations,
  contributors) and per-city badges (location count, contributors,
  top categories)
- i18n strings for all stats labels (DE + EN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 14:50:26 +02:00
parent 9d2c7ad954
commit 732f2b8edd
5 changed files with 283 additions and 8 deletions

View file

@ -109,3 +109,71 @@ export function getLocationCountByCity(locations: LocalLocation[]): Map<string,
}
return counts;
}
/** Stats for a single city. */
export interface CityStats {
locationCount: number;
categoryCounts: Record<string, number>;
topCategories: { category: string; count: number }[];
contributorCount: number;
hasCoordinates: number;
recentLocations: LocalLocation[];
}
/** Compute stats for a city's locations. */
export function getCityStats(locations: LocalLocation[]): CityStats {
const categoryCounts: Record<string, number> = {};
const contributors = new Set<string>();
let hasCoordinates = 0;
for (const loc of locations) {
categoryCounts[loc.category] = (categoryCounts[loc.category] || 0) + 1;
if ((loc as any).createdBy) contributors.add((loc as any).createdBy);
if (loc.latitude && loc.longitude) hasCoordinates++;
}
const topCategories = Object.entries(categoryCounts)
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
const recentLocations = [...locations]
.sort((a, b) => {
const aTime = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0;
const bTime = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0;
return bTime - aTime;
})
.slice(0, 3);
return {
locationCount: locations.length,
categoryCounts,
topCategories,
contributorCount: contributors.size,
hasCoordinates,
recentLocations,
};
}
/** Stats summary for the city discovery page. */
export interface PlatformStats {
totalCities: number;
totalLocations: number;
totalContributors: number;
}
/** Compute platform-wide stats. */
export function getPlatformStats(cities: LocalCity[], locations: LocalLocation[]): PlatformStats {
const contributors = new Set<string>();
for (const loc of locations) {
if ((loc as any).createdBy) contributors.add((loc as any).createdBy);
}
for (const city of cities) {
if (city.createdBy) contributors.add(city.createdBy);
}
return {
totalCities: cities.length,
totalLocations: locations.length,
totalContributors: contributors.size,
};
}

View file

@ -20,7 +20,16 @@
"add": "Stadt hinzufügen",
"empty": "Noch keine Städte. Sei der Erste!",
"locationsCount": "{count} Orte",
"noLocationsYet": "Noch keine Orte"
"noLocationsYet": "Noch keine Orte",
"contributors": "{count} Beitragende",
"contributorsOne": "1 Beitragender",
"onMap": "{count} auf der Karte",
"recentlyAdded": "Zuletzt hinzugefügt",
"stats": "Statistiken",
"totalCities": "{count} Städte",
"totalLocations": "{count} Orte",
"totalContributors": "{count} Beitragende",
"topCategories": "Top-Kategorien"
},
"cityAdd": {
"title": "Neue Stadt anlegen",

View file

@ -20,7 +20,16 @@
"add": "Add a city",
"empty": "No cities yet. Be the first!",
"locationsCount": "{count} places",
"noLocationsYet": "No places yet"
"noLocationsYet": "No places yet",
"contributors": "{count} contributors",
"contributorsOne": "1 contributor",
"onMap": "{count} on the map",
"recentlyAdded": "Recently added",
"stats": "Statistics",
"totalCities": "{count} cities",
"totalLocations": "{count} places",
"totalContributors": "{count} contributors",
"topCategories": "Top categories"
},
"cityAdd": {
"title": "Add a new city",

View file

@ -7,6 +7,9 @@
useAllLocations,
searchCities,
getLocationCountByCity,
getPlatformStats,
filterByCity,
getCityStats,
} from '$lib/data/queries';
const allCities = useAllCities();
@ -15,6 +18,7 @@
let searchQuery = $state('');
let locationCounts = $derived(getLocationCountByCity(allLocations.value));
let platformStats = $derived(getPlatformStats(allCities.value, allLocations.value));
let filtered = $derived(searchCities(allCities.value, searchQuery));
</script>
@ -41,6 +45,39 @@
{/if}
</header>
<!-- Platform stats -->
{#if platformStats.totalCities > 0}
<div class="mb-6 flex flex-wrap gap-4 rounded-xl border border-border bg-background-card p-4">
<div class="flex items-center gap-2">
<span class="text-lg">🏙️</span>
<div>
<p class="text-lg font-semibold text-foreground">{platformStats.totalCities}</p>
<p class="text-xs text-foreground-secondary">{$_('nav.cities')}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-lg">📍</span>
<div>
<p class="text-lg font-semibold text-foreground">{platformStats.totalLocations}</p>
<p class="text-xs text-foreground-secondary">{$_('home.title')}</p>
</div>
</div>
{#if platformStats.totalContributors > 0}
<div class="flex items-center gap-2">
<span class="text-lg">👥</span>
<div>
<p class="text-lg font-semibold text-foreground">{platformStats.totalContributors}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.totalContributors', {
values: { count: platformStats.totalContributors },
})}
</p>
</div>
</div>
{/if}
</div>
{/if}
<!-- Search -->
<div class="mb-6">
<input
@ -99,13 +136,54 @@
{city.description}
</p>
{/if}
<div class="mt-2 text-xs text-foreground-secondary/60">
{@const count = locationCounts.get(city.id) || 0}
{#if count > 0}
{$_('cities.locationsCount', { values: { count } })}
{:else}
{$_('cities.noLocationsYet')}
{@const count = locationCounts.get(city.id) || 0}
{@const cityStats = getCityStats(filterByCity(allLocations.value, city.id))}
<div class="mt-2 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0zM19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
{count}
</span>
{#if cityStats.contributorCount > 0}
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-400"
>
<svg
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0"
/>
</svg>
{cityStats.contributorCount}
</span>
{/if}
{#each cityStats.topCategories.slice(0, 2) as { category }}
<span
class="rounded-full bg-background-card-hover px-2 py-0.5 text-xs text-foreground-secondary"
>
{$_(`categories.${category}`)}
</span>
{/each}
</div>
</div>
</a>

View file

@ -10,6 +10,7 @@
getFavoriteIds,
filterByCity,
filterByCategory,
getCityStats,
} from '$lib/data/queries';
import { isOpenNow } from '$lib/opening-hours';
import type { LocalCity } from '$lib/data/local-store';
@ -51,6 +52,7 @@
);
let filtered = $derived(filterByCategory(cityLocations, selectedCategory));
let stats = $derived(getCityStats(cityLocations));
let citySlug = $derived($page.params.slug);
@ -97,6 +99,115 @@
</a>
</header>
<!-- City stats -->
{#if stats.locationCount > 0}
<div class="mb-6 rounded-xl border border-border bg-background-card p-4">
<div class="flex flex-wrap gap-6">
<!-- Location count -->
<div class="flex items-center gap-2">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
</div>
<div>
<p class="text-lg font-semibold text-foreground">{stats.locationCount}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.locationsCount', { values: { count: stats.locationCount } })}
</p>
</div>
</div>
<!-- On map -->
{#if stats.hasCoordinates > 0}
<div class="flex items-center gap-2">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg bg-green-500/10 text-green-600 dark:text-green-400"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z"
/>
</svg>
</div>
<div>
<p class="text-lg font-semibold text-foreground">{stats.hasCoordinates}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.onMap', { values: { count: stats.hasCoordinates } })}
</p>
</div>
</div>
{/if}
<!-- Contributors -->
{#if stats.contributorCount > 0}
<div class="flex items-center gap-2">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</div>
<div>
<p class="text-lg font-semibold text-foreground">{stats.contributorCount}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.contributors', { values: { count: stats.contributorCount } })}
</p>
</div>
</div>
{/if}
</div>
<!-- Top categories breakdown -->
{#if stats.topCategories.length > 1}
<div class="mt-3 flex flex-wrap gap-1.5 border-t border-border pt-3">
{#each stats.topCategories as { category, count }}
<span
class="inline-flex items-center gap-1 rounded-full bg-background px-2.5 py-1 text-xs text-foreground-secondary"
>
{$_(`categories.${category}`)}
<span class="font-medium text-foreground">{count}</span>
</span>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Category filter pills -->
<div class="mb-6 flex flex-wrap gap-2">
<button