Fix date filtering and timestamp parsing

- parseTimestamp now handles unix seconds, milliseconds, and ISO strings;
  returns NaN for unparseable values instead of 0 (which caused Jan 1 1970)
- Events with unparseable start timestamps are rejected by the parser
- ActivityCard safely handles invalid dates without crashing
- DatePickerStrip day selection now filters activities to that date
- Selecting a day clears temporal pills; selecting a pill clears the day
- Feed shows all activities sorted by date (no more upcoming/past split)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-20 08:09:37 +02:00
commit 0238716acf
5 changed files with 81 additions and 21 deletions

View file

@ -19,12 +19,17 @@ const { t } = useI18n()
const dateDisplay = computed(() => {
const a = props.activity
if (a.type === 'date') {
return format(a.startDate, 'EEE, MMM d')
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
try {
if (a.type === 'date') {
return format(a.startDate, 'EEE, MMM d')
}
const date = format(a.startDate, 'EEE, MMM d')
const time = format(a.startDate, 'HH:mm')
return `${date} \u2022 ${time}`
} catch {
return ''
}
const date = format(a.startDate, 'EEE, MMM d')
const time = format(a.startDate, 'HH:mm')
return `${date} \u2022 ${time}`
})
const categoryLabel = computed(() => {

View file

@ -17,14 +17,12 @@ export function useActivities() {
const subscriptionError = ref<string | null>(null)
let unsubscribe: (() => void) | null = null
// Filtered and sorted activities
// Filtered and sorted activities (from all activities, filters handle time range)
const filteredActivities = computed(() => {
const upcoming = store.upcomingActivities
return filters.applyFilters(upcoming)
})
const pastFilteredActivities = computed(() => {
return filters.applyFilters(store.pastActivities)
const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime()
)
return filters.applyFilters(all)
})
/**
@ -113,7 +111,6 @@ export function useActivities() {
return {
// State
activities: filteredActivities,
pastActivities: pastFilteredActivities,
allActivities: computed(() => store.activities),
isLoading: computed(() => store.isLoading),
isSubscribed,

View file

@ -1,7 +1,7 @@
import { ref, computed } from 'vue'
import {
startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays,
startOfMonth, endOfMonth, addDays, isSameDay,
} from 'date-fns'
import type { Activity } from '../types/activity'
import type { ActivityCategory } from '../types/category'
@ -15,6 +15,7 @@ export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const searchQuery = ref('')
const selectedDate = ref<Date | undefined>(undefined)
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,
@ -28,8 +29,18 @@ export function useActivityFilters() {
function applyFilters(activities: Activity[]): Activity[] {
let result = activities
// Temporal filter
result = applyTemporalFilter(result, temporal.value)
// Specific date filter (from DatePickerStrip) takes priority over temporal
if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate
return a.startDate <= dayEnd && activityEnd >= dayStart
})
} else {
// Temporal filter
result = applyTemporalFilter(result, temporal.value)
}
// Category filter
if (selectedCategories.value.length > 0) {
@ -54,6 +65,16 @@ export function useActivityFilters() {
function setTemporal(value: TemporalFilter) {
temporal.value = value
selectedDate.value = undefined // clear date pick when using temporal pills
}
function selectDate(date: Date) {
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
selectedDate.value = undefined // toggle off
} else {
selectedDate.value = date
temporal.value = 'all' // clear temporal pill when picking a specific date
}
}
function toggleCategory(category: ActivityCategory) {
@ -73,12 +94,14 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = []
searchQuery.value = ''
selectedDate.value = undefined
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
searchQuery.value.trim().length > 0
searchQuery.value.trim().length > 0 ||
selectedDate.value !== undefined
)
return {
@ -86,12 +109,14 @@ export function useActivityFilters() {
temporal,
selectedCategories,
searchQuery,
selectedDate,
filters,
hasActiveFilters,
// Actions
applyFilters,
setTemporal,
selectDate,
toggleCategory,
clearCategories,
resetFilters,

View file

@ -96,6 +96,32 @@ function getTagValues(tags: string[][], tagName: string): string[] {
return tags.filter(t => t[0] === tagName).map(t => t[1])
}
/**
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
* Handles: unix seconds, unix milliseconds, and ISO date strings.
*/
/**
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
* Handles: unix seconds, unix milliseconds, and ISO date strings.
* Returns NaN if unparseable (caller must handle).
*/
function parseTimestamp(value: string): number {
const num = Number(value)
if (!isNaN(num) && num > 0) {
// If the number is unreasonably large, it's likely milliseconds
if (num > 1e12) {
return Math.floor(num / 1000)
}
return num
}
// Try as ISO date string
const date = new Date(value)
if (!isNaN(date.getTime())) {
return Math.floor(date.getTime() / 1000)
}
return NaN
}
/**
* Parse a Nostr event into a CalendarTimeEvent (kind 31923)
*/
@ -108,7 +134,11 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
if (!dTag || !title || !startStr) return null
const startTs = parseTimestamp(startStr)
if (isNaN(startTs)) return null // reject events with unparseable timestamps
const endStr = getTagValue(event.tags, 'end')
const endTs = endStr ? parseTimestamp(endStr) : undefined
const participants: Participant[] = event.tags
.filter(t => t[0] === 'p')
@ -125,8 +155,8 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
summary: getTagValue(event.tags, 'summary'),
content: event.content,
image: getTagValue(event.tags, 'image'),
start: parseInt(startStr, 10),
end: endStr ? parseInt(endStr, 10) : undefined,
start: startTs,
end: isNaN(endTs as number) ? undefined : endTs,
startTzid: getTagValue(event.tags, 'start_tzid'),
endTzid: getTagValue(event.tags, 'end_tzid'),
location: getTagValue(event.tags, 'location'),

View file

@ -33,6 +33,9 @@ const {
selectedCategories,
searchQuery,
hasActiveFilters,
selectedDate,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
resetFilters,
@ -89,12 +92,12 @@ function handleRefresh() {
<!-- Date picker strip (p'a semana style) -->
<div class="mb-4">
<DatePickerStrip />
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
</div>
<!-- Temporal filter pills -->
<div class="mb-4">
<TemporalFilterBar v-model="temporal" />
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- Category filters (collapsible) -->