mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 23:29:39 +02:00
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:
parent
9d2c7ad954
commit
732f2b8edd
5 changed files with 283 additions and 8 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue