feat(citycorners): add open/closed badges, map category filter, opening hours

- Add isOpenNow() utility that checks current time against opening hours
- Show "Open now" / "Closed" badge on location cards and detail page
- Add category filter pills to the map page (click to filter markers)
- Add opening hours to seed data for cafés, bars, restaurant, shops, museums
- Add missing category colors to detail page
- i18n: openNow, closedNow, filterAll (DE/EN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 15:09:09 +01:00
parent 491c71e2b8
commit 89e32d4798
7 changed files with 311 additions and 40 deletions

View file

@ -56,6 +56,15 @@ async function seed() {
latitude: 47.6589,
longitude: 9.1795,
imageUrl: '/images/ophelia.jpg',
openingHours: {
mo: 'closed',
tu: 'closed',
we: '18:30 - 22:00',
th: '18:30 - 22:00',
fr: '18:30 - 22:00',
sa: '18:30 - 22:00',
su: 'closed',
},
},
// === SHOPS ===
@ -68,6 +77,15 @@ async function seed() {
latitude: 47.6615,
longitude: 9.1742,
imageUrl: '/images/lago.jpg',
openingHours: {
mo: '09:30 - 20:00',
tu: '09:30 - 20:00',
we: '09:30 - 20:00',
th: '09:30 - 20:00',
fr: '09:30 - 20:00',
sa: '09:30 - 20:00',
su: 'closed',
},
},
// === MUSEUMS ===
@ -80,6 +98,15 @@ async function seed() {
address: 'Rosgartenstraße 3-5, 78462 Konstanz',
latitude: 47.6612,
longitude: 9.1753,
openingHours: {
mo: 'closed',
tu: '10:00 - 18:00',
we: '10:00 - 18:00',
th: '10:00 - 18:00',
fr: '10:00 - 18:00',
sa: '10:00 - 17:00',
su: '10:00 - 17:00',
},
},
{
name: 'Archäologisches Landesmuseum',
@ -89,6 +116,15 @@ async function seed() {
address: 'Benediktinerplatz 5, 78467 Konstanz',
latitude: 47.6637,
longitude: 9.1801,
openingHours: {
mo: 'closed',
tu: '10:00 - 18:00',
we: '10:00 - 18:00',
th: '10:00 - 18:00',
fr: '10:00 - 18:00',
sa: '10:00 - 18:00',
su: '10:00 - 18:00',
},
},
// === CAFÉS ===
@ -101,6 +137,15 @@ async function seed() {
address: 'Hussenstraße 13, 78462 Konstanz',
latitude: 47.6609,
longitude: 9.1749,
openingHours: {
mo: '08:00 - 18:00',
tu: '08:00 - 18:00',
we: '08:00 - 18:00',
th: '08:00 - 18:00',
fr: '08:00 - 18:00',
sa: '09:00 - 18:00',
su: '10:00 - 17:00',
},
},
{
name: 'Café Wessenberg',
@ -111,6 +156,15 @@ async function seed() {
address: 'Wessenbergstraße 41, 78462 Konstanz',
latitude: 47.6614,
longitude: 9.1739,
openingHours: {
mo: '07:30 - 18:30',
tu: '07:30 - 18:30',
we: '07:30 - 18:30',
th: '07:30 - 18:30',
fr: '07:30 - 18:30',
sa: '08:00 - 18:00',
su: '09:00 - 17:00',
},
},
{
name: 'Café Gessler 1159',
@ -121,6 +175,15 @@ async function seed() {
address: 'Bodanstraße 9, 78462 Konstanz',
latitude: 47.6608,
longitude: 9.173,
openingHours: {
mo: '06:30 - 19:00',
tu: '06:30 - 19:00',
we: '06:30 - 19:00',
th: '06:30 - 19:00',
fr: '06:30 - 19:00',
sa: '07:00 - 18:00',
su: '08:00 - 17:00',
},
},
{
name: 'Voglhaus Café',
@ -131,6 +194,15 @@ async function seed() {
address: 'Wessenbergstraße 8, 78462 Konstanz',
latitude: 47.6619,
longitude: 9.1744,
openingHours: {
mo: '09:00 - 18:00',
tu: '09:00 - 18:00',
we: '09:00 - 18:00',
th: '09:00 - 18:00',
fr: '09:00 - 18:00',
sa: '09:00 - 18:00',
su: '10:00 - 17:00',
},
},
{
name: 'Café Herr Hase',
@ -141,6 +213,15 @@ async function seed() {
address: 'Niederburggasse 2, 78462 Konstanz',
latitude: 47.6623,
longitude: 9.1762,
openingHours: {
mo: '08:30 - 17:00',
tu: '08:30 - 17:00',
we: '08:30 - 17:00',
th: '08:30 - 17:00',
fr: '08:30 - 17:00',
sa: '09:00 - 17:00',
su: 'closed',
},
},
// === BARS ===
@ -153,6 +234,15 @@ async function seed() {
address: 'Bodanstraße 18, 78462 Konstanz',
latitude: 47.6611,
longitude: 9.1736,
openingHours: {
mo: '18:00 - 01:00',
tu: '18:00 - 01:00',
we: '18:00 - 01:00',
th: '18:00 - 02:00',
fr: '18:00 - 03:00',
sa: '18:00 - 03:00',
su: 'closed',
},
},
{
name: 'Shamrock Irish Pub',
@ -163,6 +253,15 @@ async function seed() {
address: 'Bodanstraße 28, 78462 Konstanz',
latitude: 47.6607,
longitude: 9.1728,
openingHours: {
mo: '17:00 - 01:00',
tu: '17:00 - 01:00',
we: '17:00 - 01:00',
th: '17:00 - 01:00',
fr: '17:00 - 02:00',
sa: '15:00 - 02:00',
su: '15:00 - 00:00',
},
},
{
name: 'Seekuh',
@ -173,6 +272,15 @@ async function seed() {
address: 'Konradigasse 1, 78462 Konstanz',
latitude: 47.6632,
longitude: 9.1773,
openingHours: {
mo: '17:00 - 01:00',
tu: '17:00 - 01:00',
we: '17:00 - 01:00',
th: '17:00 - 02:00',
fr: '17:00 - 03:00',
sa: '15:00 - 03:00',
su: '15:00 - 00:00',
},
},
{
name: 'Brauhaus Johann Albrecht',
@ -183,6 +291,15 @@ async function seed() {
address: 'Konradigasse 2, 78462 Konstanz',
latitude: 47.663,
longitude: 9.177,
openingHours: {
mo: '11:00 - 23:00',
tu: '11:00 - 23:00',
we: '11:00 - 23:00',
th: '11:00 - 23:00',
fr: '11:00 - 00:00',
sa: '11:00 - 00:00',
su: '11:00 - 22:00',
},
},
{
name: 'Schwarze Katz',
@ -193,6 +310,15 @@ async function seed() {
address: 'Katzgasse 7, 78462 Konstanz',
latitude: 47.6617,
longitude: 9.1752,
openingHours: {
mo: 'closed',
tu: '19:00 - 01:00',
we: '19:00 - 01:00',
th: '19:00 - 02:00',
fr: '19:00 - 03:00',
sa: '19:00 - 03:00',
su: 'closed',
},
},
// === PARKS ===

View file

@ -68,7 +68,9 @@
"website": "Webseite",
"phone": "Telefon",
"openingHours": "Öffnungszeiten",
"closed": "Geschlossen"
"closed": "Geschlossen",
"openNow": "Jetzt geöffnet",
"closedNow": "Geschlossen"
},
"days": {
"mo": "Montag",
@ -116,7 +118,8 @@
"locateMe": "Mein Standort",
"yourLocation": "Du bist hier",
"geolocationNotSupported": "Standortbestimmung wird nicht unterstützt.",
"geolocationError": "Standort konnte nicht ermittelt werden."
"geolocationError": "Standort konnte nicht ermittelt werden.",
"filterAll": "Alle"
},
"search": {
"placeholder": "Ort suchen...",

View file

@ -68,7 +68,9 @@
"website": "Website",
"phone": "Phone",
"openingHours": "Opening hours",
"closed": "Closed"
"closed": "Closed",
"openNow": "Open now",
"closedNow": "Closed"
},
"days": {
"mo": "Monday",
@ -116,7 +118,8 @@
"locateMe": "My location",
"yourLocation": "You are here",
"geolocationNotSupported": "Geolocation is not supported.",
"geolocationError": "Could not determine location."
"geolocationError": "Could not determine location.",
"filterAll": "All"
},
"search": {
"placeholder": "Search places...",

View file

@ -0,0 +1,31 @@
const DAY_KEYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const;
/**
* Check if a location is currently open based on its opening hours.
* Returns null if no opening hours are provided.
*/
export function isOpenNow(openingHours?: Record<string, string> | null): boolean | null {
if (!openingHours || Object.keys(openingHours).length === 0) return null;
const now = new Date();
const dayKey = DAY_KEYS[now.getDay()];
const hours = openingHours[dayKey];
if (!hours || hours === 'closed') return false;
// Parse "HH:MM - HH:MM" format
const match = hours.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/);
if (!match) return null;
const [, openH, openM, closeH, closeM] = match;
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const openMinutes = parseInt(openH) * 60 + parseInt(openM);
const closeMinutes = parseInt(closeH) * 60 + parseInt(closeM);
// Handle overnight hours (e.g., 22:00 - 03:00)
if (closeMinutes < openMinutes) {
return currentMinutes >= openMinutes || currentMinutes < closeMinutes;
}
return currentMinutes >= openMinutes && currentMinutes < closeMinutes;
}

View file

@ -4,6 +4,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
interface Location {
id: string;
@ -15,6 +16,7 @@
latitude?: number;
longitude?: number;
imageUrl?: string;
openingHours?: Record<string, string>;
createdBy?: string;
}
@ -256,11 +258,25 @@
{/if}
<div class="p-4">
<span
class="mb-1 inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
{$_(`categories.${location.category}`)}
</span>
<div class="mb-1 flex flex-wrap items-center gap-1.5">
<span class="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{$_(`categories.${location.category}`)}
</span>
{@const openStatus = isOpenNow(location.openingHours)}
{#if openStatus === true}
<span
class="inline-block rounded-full bg-green-500/10 px-2 py-0.5 text-xs text-green-600 dark:text-green-400"
>
{$_('detail.openNow')}
</span>
{:else if openStatus === false}
<span
class="inline-block rounded-full bg-red-500/10 px-2 py-0.5 text-xs text-red-500 dark:text-red-400"
>
{$_('detail.closedNow')}
</span>
{/if}
</div>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{location.name}
</h2>

View file

@ -7,6 +7,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
interface TimelineEntry {
year: string;
@ -66,6 +67,13 @@
restaurant: '#dc2626',
shop: '#16a34a',
museum: '#9333ea',
cafe: '#b45309',
bar: '#ea580c',
park: '#15803d',
beach: '#0891b2',
hotel: '#4f46e5',
event_venue: '#db2777',
viewpoint: '#0ea5e9',
};
let isOwner = $derived(
@ -337,14 +345,28 @@
</div>
{/if}
<!-- Category badge -->
<div class="absolute bottom-4 left-4">
<!-- Category badge + open status -->
<div class="absolute bottom-4 left-4 flex items-center gap-2">
<span
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
style="background: {categoryColors[location.category] || '#6b7280'}cc"
>
{$_(`category.${location.category}`)}
</span>
{@const openStatus = isOpenNow(location.openingHours)}
{#if openStatus === true}
<span
class="rounded-full bg-green-500/90 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
>
{$_('detail.openNow')}
</span>
{:else if openStatus === false}
<span
class="rounded-full bg-red-500/80 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
>
{$_('detail.closedNow')}
</span>
{/if}
</div>
</div>

View file

@ -21,6 +21,21 @@
let map: any = null;
let locating = $state(false);
let locationError = $state('');
let selectedCategory = $state<string | null>(null);
const categoryKeys = [
'sight',
'restaurant',
'shop',
'museum',
'cafe',
'bar',
'park',
'beach',
'hotel',
'event_venue',
'viewpoint',
];
const categoryColors: Record<string, string> = {
sight: '#2563eb',
@ -36,36 +51,36 @@
viewpoint: '#0ea5e9',
};
onMount(async () => {
try {
const res = await fetch(api('/locations'));
const data = await res.json();
locations = data.locations;
} catch (err) {
console.error('Failed to load locations:', err);
let allMarkers: any[] = [];
let markerLayer: any = null;
let leafletLib: any = null;
function updateMarkers() {
if (!map || !leafletLib) return;
const L = leafletLib;
// Remove existing markers
if (markerLayer) {
map.removeLayer(markerLayer);
}
for (const m of allMarkers) {
map.removeLayer(m);
}
allMarkers = [];
if (!browser) return;
const filtered = selectedCategory
? locations.filter((l) => l.category === selectedCategory)
: locations;
const L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
const useCluster = filtered.length >= 10;
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
const useCluster = locations.length >= 10;
let markerLayer: any;
if (useCluster) {
const { default: MCG } = await import('leaflet.markercluster');
if (useCluster && (L as any).markerClusterGroup) {
markerLayer = (L as any).markerClusterGroup();
} else {
markerLayer = null;
}
for (const loc of locations) {
for (const loc of filtered) {
if (loc.latitude && loc.longitude) {
const color = categoryColors[loc.category] || '#6b7280';
@ -91,6 +106,7 @@
markerLayer.addLayer(marker);
} else {
marker.addTo(map);
allMarkers.push(marker);
}
}
}
@ -98,6 +114,45 @@
if (useCluster && markerLayer) {
map.addLayer(markerLayer);
}
}
onMount(async () => {
try {
const res = await fetch(api('/locations?limit=100'));
const data = await res.json();
locations = data.locations;
} catch (err) {
console.error('Failed to load locations:', err);
}
if (!browser) return;
leafletLib = await import('leaflet');
const L = leafletLib;
await import('leaflet/dist/leaflet.css');
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
try {
await import('leaflet.markercluster');
} catch {
// clustering not available
}
updateMarkers();
});
// Re-render markers when category filter changes
$effect(() => {
const _ = selectedCategory;
if (map && leafletLib) {
updateMarkers();
}
});
function handleLocateMe() {
@ -187,12 +242,27 @@
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">{locationError}</div>
{/if}
<div class="legend mb-4 flex flex-wrap gap-3">
{#each Object.entries(categoryColors) as [key, color]}
<div class="flex items-center gap-1.5 text-sm text-foreground-secondary">
<div class="w-3 h-3 rounded-full" style="background:{color}"></div>
{$_(`category.${key}`)}
</div>
<div class="mb-4 flex flex-wrap gap-2">
<button
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (selectedCategory = null)}
>
{$_('map.filterAll')}
</button>
{#each categoryKeys as cat}
<button
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (selectedCategory = selectedCategory === cat ? null : cat)}
>
<div class="w-2.5 h-2.5 rounded-full" style="background:{categoryColors[cat]}"></div>
{$_(`category.${cat}`)}
</button>
{/each}
</div>