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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' 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 BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -58,6 +58,13 @@ const placeholderBg = computed(() => {
const hue = hash % 360 const hue = hash % 360
return `hsl(${hue}, 40%, 85%)` 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> </script>
<template> <template>
@ -121,6 +128,22 @@ const placeholderBg = computed(() => {
> >
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </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> </div>
<CardContent class="p-4 flex-1 flex flex-col gap-2"> <CardContent class="p-4 flex-1 flex flex-col gap-2">

View file

@ -29,6 +29,14 @@ export function useActivityFilters() {
* which `useActivities.tagOwnership()` populates. * which `useActivities.tagOwnership()` populates.
*/ */
const onlyHosting = ref(false) 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>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -41,7 +49,9 @@ 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 // 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) { if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value) const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value) const dayEnd = endOfDay(selectedDate.value)
@ -52,6 +62,16 @@ export function useActivityFilters() {
} else { } else {
// Temporal filter // Temporal filter
result = applyTemporalFilter(result, temporal.value) 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 // Category filter
@ -104,6 +124,7 @@ export function useActivityFilters() {
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false onlyOwnedTickets.value = false
onlyHosting.value = false onlyHosting.value = false
showPast.value = false
} }
function toggleOwnedTickets() { function toggleOwnedTickets() {
@ -114,12 +135,17 @@ export function useActivityFilters() {
onlyHosting.value = !onlyHosting.value onlyHosting.value = !onlyHosting.value
} }
function togglePast() {
showPast.value = !showPast.value
}
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
temporal.value !== 'all' || temporal.value !== 'all' ||
selectedCategories.value.length > 0 || selectedCategories.value.length > 0 ||
selectedDate.value !== undefined || selectedDate.value !== undefined ||
onlyOwnedTickets.value || onlyOwnedTickets.value ||
onlyHosting.value onlyHosting.value ||
showPast.value
) )
return { return {
@ -129,6 +155,7 @@ export function useActivityFilters() {
selectedDate, selectedDate,
onlyOwnedTickets, onlyOwnedTickets,
onlyHosting, onlyHosting,
showPast,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -140,6 +167,7 @@ export function useActivityFilters() {
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,
toggleHosting, toggleHosting,
togglePast,
resetFilters, resetFilters,
} }
} }

View file

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

View file

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
@ -130,6 +130,17 @@ const canBuyTicket = computed(() => {
return info.available === undefined || info.available > 0 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) const showPurchaseDialog = ref(false)
function openPurchaseDialog() { function openPurchaseDialog() {
@ -320,7 +331,14 @@ function goToMyTickets() {
</Button> </Button>
</div> </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 <Button
class="w-full gap-1.5" class="w-full gap-1.5"
size="lg" size="lg"