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:
parent
021198ab0f
commit
0238716acf
5 changed files with 81 additions and 21 deletions
|
|
@ -19,12 +19,17 @@ const { t } = useI18n()
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
const a = props.activity
|
const a = props.activity
|
||||||
|
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
|
||||||
|
try {
|
||||||
if (a.type === 'date') {
|
if (a.type === 'date') {
|
||||||
return format(a.startDate, 'EEE, MMM d')
|
return format(a.startDate, 'EEE, MMM d')
|
||||||
}
|
}
|
||||||
const date = format(a.startDate, 'EEE, MMM d')
|
const date = format(a.startDate, 'EEE, MMM d')
|
||||||
const time = format(a.startDate, 'HH:mm')
|
const time = format(a.startDate, 'HH:mm')
|
||||||
return `${date} \u2022 ${time}`
|
return `${date} \u2022 ${time}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,12 @@ export function useActivities() {
|
||||||
const subscriptionError = ref<string | null>(null)
|
const subscriptionError = ref<string | null>(null)
|
||||||
let unsubscribe: (() => void) | 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 filteredActivities = computed(() => {
|
||||||
const upcoming = store.upcomingActivities
|
const all = store.activities.sort(
|
||||||
return filters.applyFilters(upcoming)
|
(a, b) => a.startDate.getTime() - b.startDate.getTime()
|
||||||
})
|
)
|
||||||
|
return filters.applyFilters(all)
|
||||||
const pastFilteredActivities = computed(() => {
|
|
||||||
return filters.applyFilters(store.pastActivities)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -113,7 +111,6 @@ export function useActivities() {
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
activities: filteredActivities,
|
activities: filteredActivities,
|
||||||
pastActivities: pastFilteredActivities,
|
|
||||||
allActivities: computed(() => store.activities),
|
allActivities: computed(() => store.activities),
|
||||||
isLoading: computed(() => store.isLoading),
|
isLoading: computed(() => store.isLoading),
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||||
startOfMonth, endOfMonth, addDays,
|
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||||
} from 'date-fns'
|
} from 'date-fns'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
import type { ActivityCategory } from '../types/category'
|
import type { ActivityCategory } from '../types/category'
|
||||||
|
|
@ -15,6 +15,7 @@ export function useActivityFilters() {
|
||||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||||
const selectedCategories = ref<ActivityCategory[]>([])
|
const selectedCategories = ref<ActivityCategory[]>([])
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const selectedDate = ref<Date | undefined>(undefined)
|
||||||
|
|
||||||
const filters = computed<ActivityFilters>(() => ({
|
const filters = computed<ActivityFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
|
|
@ -28,8 +29,18 @@ export function useActivityFilters() {
|
||||||
function applyFilters(activities: Activity[]): Activity[] {
|
function applyFilters(activities: Activity[]): Activity[] {
|
||||||
let result = activities
|
let result = activities
|
||||||
|
|
||||||
|
// 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
|
// Temporal filter
|
||||||
result = applyTemporalFilter(result, temporal.value)
|
result = applyTemporalFilter(result, temporal.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Category filter
|
// Category filter
|
||||||
if (selectedCategories.value.length > 0) {
|
if (selectedCategories.value.length > 0) {
|
||||||
|
|
@ -54,6 +65,16 @@ export function useActivityFilters() {
|
||||||
|
|
||||||
function setTemporal(value: TemporalFilter) {
|
function setTemporal(value: TemporalFilter) {
|
||||||
temporal.value = value
|
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) {
|
function toggleCategory(category: ActivityCategory) {
|
||||||
|
|
@ -73,12 +94,14 @@ export function useActivityFilters() {
|
||||||
temporal.value = DEFAULT_FILTERS.temporal
|
temporal.value = DEFAULT_FILTERS.temporal
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
|
selectedDate.value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters = computed(() =>
|
const hasActiveFilters = computed(() =>
|
||||||
temporal.value !== 'all' ||
|
temporal.value !== 'all' ||
|
||||||
selectedCategories.value.length > 0 ||
|
selectedCategories.value.length > 0 ||
|
||||||
searchQuery.value.trim().length > 0
|
searchQuery.value.trim().length > 0 ||
|
||||||
|
selectedDate.value !== undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -86,12 +109,14 @@ export function useActivityFilters() {
|
||||||
temporal,
|
temporal,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
selectedDate,
|
||||||
filters,
|
filters,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
applyFilters,
|
applyFilters,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
|
selectDate,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,32 @@ function getTagValues(tags: string[][], tagName: string): string[] {
|
||||||
return tags.filter(t => t[0] === tagName).map(t => t[1])
|
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)
|
* 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
|
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 endStr = getTagValue(event.tags, 'end')
|
||||||
|
const endTs = endStr ? parseTimestamp(endStr) : undefined
|
||||||
|
|
||||||
const participants: Participant[] = event.tags
|
const participants: Participant[] = event.tags
|
||||||
.filter(t => t[0] === 'p')
|
.filter(t => t[0] === 'p')
|
||||||
|
|
@ -125,8 +155,8 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
|
||||||
summary: getTagValue(event.tags, 'summary'),
|
summary: getTagValue(event.tags, 'summary'),
|
||||||
content: event.content,
|
content: event.content,
|
||||||
image: getTagValue(event.tags, 'image'),
|
image: getTagValue(event.tags, 'image'),
|
||||||
start: parseInt(startStr, 10),
|
start: startTs,
|
||||||
end: endStr ? parseInt(endStr, 10) : undefined,
|
end: isNaN(endTs as number) ? undefined : endTs,
|
||||||
startTzid: getTagValue(event.tags, 'start_tzid'),
|
startTzid: getTagValue(event.tags, 'start_tzid'),
|
||||||
endTzid: getTagValue(event.tags, 'end_tzid'),
|
endTzid: getTagValue(event.tags, 'end_tzid'),
|
||||||
location: getTagValue(event.tags, 'location'),
|
location: getTagValue(event.tags, 'location'),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ const {
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
selectedDate,
|
||||||
|
selectDate,
|
||||||
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
|
@ -89,12 +92,12 @@ function handleRefresh() {
|
||||||
|
|
||||||
<!-- Date picker strip (p'a semana style) -->
|
<!-- Date picker strip (p'a semana style) -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<DatePickerStrip />
|
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temporal filter pills -->
|
<!-- Temporal filter pills -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<TemporalFilterBar v-model="temporal" />
|
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category filters (collapsible) -->
|
<!-- Category filters (collapsible) -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue