feat(activities): hide past events by default + "Past events" filter chip #77

Merged
padreug merged 2 commits from past-events-filter into dev 2026-05-25 09:49:34 +00:00
8 changed files with 119 additions and 23 deletions

View file

@ -59,6 +59,8 @@ const messages: LocaleMessages = {
thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
},
categories: {
concert: 'Concert',
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
buyAnotherTicket: 'Buy another ticket',
viewMyTickets: 'View in My Tickets',
soldOut: 'Sold Out',
pastEvent: 'This event has already happened',
free: 'Free',
},
tickets: {

View file

@ -59,6 +59,8 @@ const messages: LocaleMessages = {
thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
},
categories: {
concert: 'Concierto',
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
buyAnotherTicket: 'Comprar otro boleto',
viewMyTickets: 'Ver en Mis boletos',
soldOut: 'Agotado',
pastEvent: 'Este evento ya pasó',
free: 'Gratis',
},
tickets: {

View file

@ -59,6 +59,8 @@ const messages: LocaleMessages = {
thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
},
categories: {
concert: 'Concert',
@ -104,6 +106,7 @@ const messages: LocaleMessages = {
buyAnotherTicket: 'Acheter un autre billet',
viewMyTickets: 'Voir dans Mes billets',
soldOut: 'Épuisé',
pastEvent: 'Cet événement est déjà passé',
free: 'Gratuit',
},
tickets: {

View file

@ -60,6 +60,8 @@ export interface LocaleMessages {
thisMonth: string
myTickets: string
hosting: string
pastEvents: string
past: string
}
categories: Record<string, string>
detail: {
@ -79,6 +81,7 @@ export interface LocaleMessages {
buyAnotherTicket: string
viewMyTickets: string
soldOut: string
pastEvent: string
free: string
}
tickets: {

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User, CheckCircle2 } from 'lucide-vue-next'
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -58,6 +58,13 @@ const placeholderBg = computed(() => {
const hue = hash % 360
return `hsl(${hue}, 40%, 85%)`
})
const isPast = computed(() => {
const a = props.activity
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
</script>
<template>
@ -121,6 +128,22 @@ const placeholderBg = computed(() => {
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<!-- Past badge shown when the activity has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }}
</Badge>
</div>
<CardContent class="p-4 flex-1 flex flex-col gap-2">

View file

@ -29,6 +29,14 @@ export function useActivityFilters() {
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), activities that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this picking a
* specific past date shows that day's activities regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,
@ -41,7 +49,9 @@ export function useActivityFilters() {
function applyFilters(activities: Activity[]): Activity[] {
let result = activities
// Specific date filter (from DatePickerStrip) takes priority over temporal
// Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse activities for any day they choose.
if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value)
@ -52,6 +62,16 @@ export function useActivityFilters() {
} else {
// Temporal filter
result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to
// past-only. Composes with temporal pills: "This Week" +
// showPast=true shows only the days already passed this week.
const now = new Date()
result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate
return showPast.value ? activityEnd < now : activityEnd >= now
})
}
// Category filter
@ -104,6 +124,7 @@ export function useActivityFilters() {
selectedDate.value = undefined
onlyOwnedTickets.value = false
onlyHosting.value = false
showPast.value = false
}
function toggleOwnedTickets() {
@ -114,12 +135,17 @@ export function useActivityFilters() {
onlyHosting.value = !onlyHosting.value
}
function togglePast() {
showPast.value = !showPast.value
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
selectedDate.value !== undefined ||
onlyOwnedTickets.value ||
onlyHosting.value
onlyHosting.value ||
showPast.value
)
return {
@ -129,6 +155,7 @@ export function useActivityFilters() {
selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
filters,
hasActiveFilters,
@ -140,6 +167,7 @@ export function useActivityFilters() {
clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters,
}
}

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone } from 'lucide-vue-next'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
@ -31,12 +31,14 @@ const {
selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters,
subscribe,
} = useActivities()
@ -81,27 +83,40 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- Role filter chips narrow the feed to activities the user
has skin in. Hidden when logged out (nothing to filter on).
"My tickets" = attending; "Hosting" = organizing. -->
<div v-if="isAuthenticated" class="mb-4 flex flex-wrap gap-2">
<!-- Role + past-events filter chips. The role chips ("My tickets",
"Hosting") narrow the feed to activities the signed-in user
has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an
account. -->
<div class="mb-4 flex flex-wrap gap-2">
<template v-if="isAuthenticated">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</template>
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
@click="togglePast"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
<History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }}
</Button>
</div>

View file

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine,
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
@ -130,6 +130,17 @@ const canBuyTicket = computed(() => {
return info.available === undefined || info.available > 0
})
// Past events can't be bought into. The notice below replaces the
// buy CTA so the flow is unambiguous date alone is easy to miss
// on a long detail page.
const isPast = computed(() => {
const a = activity.value
if (!a) return false
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
const showPurchaseDialog = ref(false)
function openPurchaseDialog() {
@ -320,7 +331,14 @@ function goToMyTickets() {
</Button>
</div>
<div v-if="canBuyTicket">
<div
v-if="isPast"
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
>
<History class="w-4 h-4 shrink-0" />
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
</div>
<div v-else-if="canBuyTicket">
<Button
class="w-full gap-1.5"
size="lg"