From 89e32d4798fd0372f86bf39503dd4c2a0f403dd1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 25 Mar 2026 15:09:09 +0100 Subject: [PATCH] feat(citycorners): add open/closed badges, map category filter, opening hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/citycorners/apps/backend/src/db/seed.ts | 126 +++++++++++++++++ .../apps/web/src/lib/i18n/locales/de.json | 7 +- .../apps/web/src/lib/i18n/locales/en.json | 7 +- .../apps/web/src/lib/opening-hours.ts | 31 +++++ .../apps/web/src/routes/(app)/+page.svelte | 26 +++- .../routes/(app)/locations/[id]/+page.svelte | 26 +++- .../web/src/routes/(app)/map/+page.svelte | 128 ++++++++++++++---- 7 files changed, 311 insertions(+), 40 deletions(-) create mode 100644 apps/citycorners/apps/web/src/lib/opening-hours.ts diff --git a/apps/citycorners/apps/backend/src/db/seed.ts b/apps/citycorners/apps/backend/src/db/seed.ts index d45a571d8..2f128bc25 100644 --- a/apps/citycorners/apps/backend/src/db/seed.ts +++ b/apps/citycorners/apps/backend/src/db/seed.ts @@ -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 === diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json index 1272ab55c..39ab7d34a 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json @@ -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...", diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json index f53839532..b3675edef 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json @@ -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...", diff --git a/apps/citycorners/apps/web/src/lib/opening-hours.ts b/apps/citycorners/apps/web/src/lib/opening-hours.ts new file mode 100644 index 000000000..15e0cabf1 --- /dev/null +++ b/apps/citycorners/apps/web/src/lib/opening-hours.ts @@ -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 | 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; +} diff --git a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte index 941053dc8..0d1887a82 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte @@ -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; createdBy?: string; } @@ -256,11 +258,25 @@ {/if}
- - {$_(`categories.${location.category}`)} - +
+ + {$_(`categories.${location.category}`)} + + {@const openStatus = isOpenNow(location.openingHours)} + {#if openStatus === true} + + {$_('detail.openNow')} + + {:else if openStatus === false} + + {$_('detail.closedNow')} + + {/if} +

{location.name}

diff --git a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte index 2d0501b56..3e4331504 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte @@ -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 @@
{/if} - -
+ +
{$_(`category.${location.category}`)} + {@const openStatus = isOpenNow(location.openingHours)} + {#if openStatus === true} + + {$_('detail.openNow')} + + {:else if openStatus === false} + + {$_('detail.closedNow')} + + {/if}
diff --git a/apps/citycorners/apps/web/src/routes/(app)/map/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/map/+page.svelte index 792392dde..6a7a2f5c1 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/map/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/map/+page.svelte @@ -21,6 +21,21 @@ let map: any = null; let locating = $state(false); let locationError = $state(''); + let selectedCategory = $state(null); + + const categoryKeys = [ + 'sight', + 'restaurant', + 'shop', + 'museum', + 'cafe', + 'bar', + 'park', + 'beach', + 'hotel', + 'event_venue', + 'viewpoint', + ]; const categoryColors: Record = { 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: '© OpenStreetMap', - 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: '© OpenStreetMap', + 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 @@
{locationError}
{/if} -
- {#each Object.entries(categoryColors) as [key, color]} -
-
- {$_(`category.${key}`)} -
+
+ + {#each categoryKeys as cat} + {/each}