Add map and calendar views (Phase 5)

Map view: Leaflet + OpenStreetMap with markers from NIP-52 geohash
tags. Click popup navigates to activity detail. Auto-fits bounds to
show all markers. Geohash decoding via ngeohash added to Activity
model conversion.

Calendar view: Month grid with activity dots (up to 3 per day).
Click a day to see activities listed below the calendar. Month
navigation with prev/next buttons.

New dependencies: leaflet, @types/leaflet, ngeohash, @types/ngeohash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-20 12:41:38 +02:00
commit 4926c3a313
7 changed files with 408 additions and 11 deletions

43
package-lock.json generated
View file

@ -18,8 +18,10 @@
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fuse.js": "^7.0.0",
"leaflet": "^1.9.4",
"light-bolt11-decoder": "^3.2.0",
"lucide-vue-next": "^0.474.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.10.4",
"pinia": "^2.3.1",
"qr-scanner": "^1.4.2",
@ -48,6 +50,8 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.12",
"@types/leaflet": "^1.9.21",
"@types/ngeohash": "^0.6.8",
"@types/node": "^22.18.1",
"@types/qrcode": "^1.5.5",
"@types/rollup-plugin-visualizer": "^4.2.3",
@ -5065,6 +5069,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@ -5082,6 +5093,23 @@
"@types/node": "*"
}
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/ngeohash": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz",
"integrity": "sha512-A90x3HMwE1yXbWCnd0ztHzv8rAQPjwTzX2diYI/6OrWm/3oairDaehw5WPWJFgZ+8+J/OuF99IbipmMa2le6tQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.18.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz",
@ -9775,6 +9803,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -10905,6 +10939,15 @@
"node": ">= 0.6"
}
},
"node_modules/ngeohash": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
"license": "MIT",
"engines": {
"node": ">=v0.2.0"
}
},
"node_modules/nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",

View file

@ -30,8 +30,10 @@
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fuse.js": "^7.0.0",
"leaflet": "^1.9.4",
"light-bolt11-decoder": "^3.2.0",
"lucide-vue-next": "^0.474.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.10.4",
"pinia": "^2.3.1",
"qr-scanner": "^1.4.2",
@ -60,6 +62,8 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.12",
"@types/leaflet": "^1.9.21",
"@types/ngeohash": "^0.6.8",
"@types/node": "^22.18.1",
"@types/qrcode": "^1.5.5",
"@types/rollup-plugin-visualizer": "^4.2.3",

View file

@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, format, isSameMonth, isSameDay,
addMonths, subMonths,
} from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import type { Activity } from '../types/activity'
const props = defineProps<{
activities: Activity[]
}>()
const emit = defineEmits<{
selectDate: [date: Date]
selectActivity: [activity: Activity]
}>()
const currentMonth = ref(new Date())
const monthLabel = computed(() => format(currentMonth.value, 'MMMM yyyy'))
const weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
const calendarDays = computed(() => {
const monthStart = startOfMonth(currentMonth.value)
const monthEnd = endOfMonth(currentMonth.value)
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 })
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 })
return eachDayOfInterval({ start: calStart, end: calEnd })
})
// Map of date string -> activities on that day
const activityDayMap = computed(() => {
const map = new Map<string, Activity[]>()
for (const activity of props.activities) {
const key = format(activity.startDate, 'yyyy-MM-dd')
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(activity)
}
return map
})
function getActivitiesForDay(date: Date): Activity[] {
const key = format(date, 'yyyy-MM-dd')
return activityDayMap.value.get(key) ?? []
}
function getDotCount(date: Date): number {
return Math.min(getActivitiesForDay(date).length, 3)
}
const selectedDay = ref<Date | null>(null)
const selectedDayActivities = computed(() => {
if (!selectedDay.value) return []
return getActivitiesForDay(selectedDay.value)
})
function selectDay(date: Date) {
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
selectedDay.value = null
} else {
selectedDay.value = date
emit('selectDate', date)
}
}
function prevMonth() {
currentMonth.value = subMonths(currentMonth.value, 1)
selectedDay.value = null
}
function nextMonth() {
currentMonth.value = addMonths(currentMonth.value, 1)
selectedDay.value = null
}
</script>
<template>
<div class="space-y-4">
<!-- Month navigation -->
<div class="flex items-center justify-between">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
<ChevronLeft class="h-4 w-4" />
</Button>
<h2 class="text-lg font-semibold text-foreground">{{ monthLabel }}</h2>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="nextMonth">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
<!-- Weekday headers -->
<div class="grid grid-cols-7 gap-0">
<div
v-for="day in weekDays"
:key="day"
class="text-center text-xs font-medium text-muted-foreground py-1"
>
{{ day }}
</div>
</div>
<!-- Calendar grid -->
<div class="grid grid-cols-7 gap-0">
<button
v-for="date in calendarDays"
:key="date.toISOString()"
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
:class="{
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
'bg-muted/50': isSameDay(date, new Date()) && !(selectedDay && isSameDay(date, selectedDay)),
'hover:bg-muted': !(selectedDay && isSameDay(date, selectedDay)),
}"
@click="selectDay(date)"
>
<span class="text-sm">{{ format(date, 'd') }}</span>
<!-- Activity dots -->
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
<div
v-for="i in getDotCount(date)"
:key="i"
class="w-1 h-1 rounded-full"
:class="selectedDay && isSameDay(date, selectedDay) ? 'bg-primary-foreground' : 'bg-primary'"
/>
</div>
</button>
</div>
<!-- Selected day activities -->
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
<h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d') }}
<span v-if="selectedDayActivities.length > 0" class="ml-1">
({{ selectedDayActivities.length }})
</span>
</h3>
<div v-if="selectedDayActivities.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
No activities on this day
</div>
<div
v-for="activity in selectedDayActivities"
:key="activity.nostrEventId"
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
@click="emit('selectActivity', activity)"
>
<img
v-if="activity.image"
:src="activity.image"
:alt="activity.title"
class="w-12 h-12 rounded object-cover shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
<p class="text-xs text-muted-foreground truncate">
{{ activity.type === 'time' ? format(activity.startDate, 'HH:mm') : '' }}
{{ activity.location ? `· ${activity.location}` : '' }}
</p>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { Activity } from '../types/activity'
const props = defineProps<{
activities: Activity[]
center?: { lat: number; lng: number }
zoom?: number
}>()
const router = useRouter()
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
let markerGroup: L.LayerGroup | null = null
// Fix Leaflet default icon paths (broken by bundlers)
const defaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
function initMap() {
if (!mapContainer.value) return
const defaultCenter = props.center ?? { lat: 46.6034, lng: 1.8883 } // France
const defaultZoom = props.zoom ?? 5
map = L.map(mapContainer.value).setView(
[defaultCenter.lat, defaultCenter.lng],
defaultZoom
)
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)
markerGroup = L.layerGroup().addTo(map)
updateMarkers()
}
function updateMarkers() {
if (!map || !markerGroup) return
markerGroup.clearLayers()
const geoActivities = props.activities.filter(a => a.coordinates)
for (const activity of geoActivities) {
const { lat, lng } = activity.coordinates!
const marker = L.marker([lat, lng], { icon: defaultIcon })
const popupContent = `
<div style="min-width: 200px; cursor: pointer;" class="activity-popup">
${activity.image ? `<img src="${activity.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(activity.title)}</div>
${activity.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(activity.location)}</div>` : ''}
<div style="font-size: 12px; color: #888;">📅 ${activity.startDate.toLocaleDateString()}</div>
</div>
`
marker.bindPopup(popupContent)
marker.on('click', () => {
marker.openPopup()
})
// Navigate on popup click
marker.on('popupopen', () => {
const popup = marker.getPopup()
if (popup) {
const el = popup.getElement()
const content = el?.querySelector('.activity-popup')
if (content) {
(content as HTMLElement).onclick = () => {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
}
}
})
markerGroup.addLayer(marker)
}
// Fit bounds if there are markers
if (geoActivities.length > 0) {
const bounds = L.latLngBounds(
geoActivities.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
)
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
watch(() => props.activities, updateMarkers, { deep: true })
onMounted(() => {
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
</script>
<template>
<div ref="mapContainer" class="w-full h-full min-h-[400px] rounded-lg z-0" />
</template>

View file

@ -1,3 +1,4 @@
import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
@ -82,6 +83,7 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
endDate: event.end ? new Date(event.end * 1000) : undefined,
timezone: event.startTzid,
location: event.location,
coordinates: decodeGeohash(event.geohash),
geohash: event.geohash,
category,
tags: event.hashtags,
@ -117,6 +119,7 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
startDate: parseIsoDate(event.start),
endDate: event.end ? parseIsoDate(event.end) : undefined,
location: event.location,
coordinates: decodeGeohash(event.geohash),
geohash: event.geohash,
category,
tags: event.hashtags,
@ -124,3 +127,13 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
createdAt: new Date(event.createdAt * 1000),
}
}
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
if (!geohash) return undefined
try {
const { latitude, longitude } = ngeohash.decode(geohash)
return { lat: latitude, lng: longitude }
} catch {
return undefined
}
}

View file

@ -1,13 +1,27 @@
<script setup lang="ts">
import { CalendarDays } from 'lucide-vue-next'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useActivities } from '../composables/useActivities'
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { allActivities, subscribe } = useActivities()
onMounted(() => {
subscribe()
})
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Calendar</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<CalendarDays class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Calendar view coming in Phase 5</p>
</div>
<div class="container mx-auto px-4 py-6 max-w-lg">
<ActivityCalendarView
:activities="allActivities"
@select-activity="handleSelectActivity"
/>
</div>
</template>

View file

@ -1,13 +1,44 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { Map } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import ActivityMap from '../components/ActivityMap.vue'
const { allActivities, isLoading, subscribe } = useActivities()
const geoActivities = computed(() =>
allActivities.value.filter(a => a.coordinates)
)
onMounted(() => {
subscribe()
})
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Map</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Loading overlay -->
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- No geotagged activities -->
<div v-else-if="!isLoading && geoActivities.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Map view coming in Phase 5</p>
<p class="text-muted-foreground">No geotagged activities found</p>
<p class="text-sm text-muted-foreground/70 mt-1">Activities with location data will appear as markers on the map</p>
</div>
<!-- Map -->
<ActivityMap
v-else
:activities="geoActivities"
class="flex-1"
/>
<!-- Activity count -->
<div v-if="geoActivities.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
{{ geoActivities.length }} activit{{ geoActivities.length === 1 ? 'y' : 'ies' }} on map
</div>
</div>
</template>