Merge pull request 'Make expiryDate an internal concern, auto-set to event date + 7 days' (#25) from auto-expiry-date into master
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m9s
CI / build-and-publish (push) Has been skipped

This commit was merged in pull request #25.
This commit is contained in:
2026-03-09 21:33:43 +01:00
18 changed files with 33 additions and 400 deletions

View File

@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
await expect(page.getByText('Title is required.')).toBeVisible()
await expect(page.getByText('Date and time are required.')).toBeVisible()
await expect(page.getByText('Expiry date is required.')).toBeVisible()
})
test('creates an event and redirects to event detail page', async ({ page }) => {
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/description/i).fill('Bring your own drinks')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/location/i).fill('Central Park')
await page.getByLabel(/expiry/i).fill('2026-06-15')
await page.getByRole('button', { name: /create event/i }).click()
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
await page.getByLabel(/title/i).fill('Summer BBQ')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/expiry/i).fill('2026-06-15')
await page.getByRole('button', { name: /create event/i }).click()
await expect(page).toHaveURL(/\/events\/.+/)
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
await page.goto('/create')
await page.getByLabel(/title/i).fill('Test')
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
await page.getByLabel(/expiry/i).fill('2026-06-15')
await page.getByRole('button', { name: /create event/i }).click()

View File

@@ -9,7 +9,6 @@ const fullEvent = {
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
expired: false,
}
test.describe('US1: RSVP submission flow', () => {
@@ -170,16 +169,4 @@ test.describe('US1: RSVP submission flow', () => {
await expect(page.getByText("You're attending!")).not.toBeVisible()
})
test('does not show RSVP bar on expired event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({ ...fullEvent, expired: true })
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByText('This event has ended.')).toBeVisible()
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
})
})

View File

@@ -9,7 +9,6 @@ const fullEvent = {
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
expired: false,
}
test.describe('US-1: View event details', () => {
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
})
})
test.describe('US-2: View expired event', () => {
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({ ...fullEvent, expired: true })
}),
)
await page.goto(`/events/${fullEvent.eventToken}`)
await expect(page.getByText('This event has ended.')).toBeVisible()
})
})
test.describe('US-4: Event not found', () => {
test('shows "event not found" for unknown token', async ({ page, network }) => {
network.use(

View File

@@ -7,7 +7,6 @@ const futureEvent1: StoredEvent = {
eventToken: 'future-aaa',
title: 'Summer BBQ',
dateTime: '2027-06-15T18:00:00Z',
expiryDate: '2027-06-16T00:00:00Z',
organizerToken: 'org-token-1',
}
@@ -15,7 +14,6 @@ const futureEvent2: StoredEvent = {
eventToken: 'future-bbb',
title: 'Team Meeting',
dateTime: '2027-01-10T09:00:00Z',
expiryDate: '2027-01-11T00:00:00Z',
rsvpToken: 'rsvp-token-1',
rsvpName: 'Alice',
}
@@ -24,7 +22,6 @@ const pastEvent: StoredEvent = {
eventToken: 'past-ccc',
title: 'New Year Party',
dateTime: '2025-01-01T00:00:00Z',
expiryDate: '2025-01-02T00:00:00Z',
}
function seedEvents(events: StoredEvent[]): string {
@@ -85,7 +82,6 @@ test.describe('US4: Past Events Appear Faded', () => {
location: '',
timezone: 'UTC',
attendeeCount: 0,
expired: true,
})
}),
)
@@ -199,13 +195,11 @@ test.describe('Temporal Grouping: Section Headers', () => {
eventToken: 'today-1',
title: 'Today Standup',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
expiryDate: '',
}
const laterEvent: StoredEvent = {
eventToken: 'later-1',
title: 'Future Conference',
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
await page.goto('/')
@@ -245,7 +239,6 @@ test.describe('Temporal Grouping: Section Headers', () => {
eventToken: 'today-emph',
title: 'Emphasis Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/')
@@ -262,7 +255,6 @@ test.describe('Temporal Grouping: Date Subheaders', () => {
eventToken: 'today-sub',
title: 'No Subheader Test',
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
expiryDate: '',
}
await page.addInitScript(seedEvents([todayEvent]))
await page.goto('/')
@@ -355,7 +347,6 @@ test.describe('US1: View My Events', () => {
location: '',
timezone: 'UTC',
attendeeCount: 0,
expired: false,
})
}),
)

View File

@@ -15,11 +15,11 @@ const router = createRouter({
const NOW = new Date(2026, 2, 11, 12, 0, 0)
const mockEvents = [
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' },
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
]
vi.mock('../../composables/useEventStorage', () => ({

View File

@@ -6,7 +6,6 @@ function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): Stor
return {
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
title: 'Test Event',
expiryDate: '',
...overrides,
}
}

View File

@@ -43,7 +43,6 @@ describe('useEventStorage', () => {
organizerToken: 'org-456',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
const events = getStoredEvents()
@@ -61,7 +60,6 @@ describe('useEventStorage', () => {
organizerToken: 'org-456',
title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
expect(getOrganizerToken('abc-123')).toBe('org-456')
@@ -79,14 +77,12 @@ describe('useEventStorage', () => {
eventToken: 'event-1',
title: 'First',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'event-2',
title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
})
const events = getStoredEvents()
@@ -102,14 +98,12 @@ describe('useEventStorage', () => {
eventToken: 'abc-123',
title: 'Old Title',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'abc-123',
title: 'New Title',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
const events = getStoredEvents()
@@ -124,7 +118,6 @@ describe('useEventStorage', () => {
eventToken: 'abc-123',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
@@ -154,7 +147,6 @@ describe('useEventStorage', () => {
eventToken: 'abc-123',
title: 'Test',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
expect(getRsvp('abc-123')).toBeUndefined()
@@ -172,14 +164,12 @@ describe('useEventStorage', () => {
eventToken: 'event-1',
title: 'First',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
saveCreatedEvent({
eventToken: 'event-2',
title: 'Second',
dateTime: '2026-07-15T20:00:00+02:00',
expiryDate: '2026-08-15',
})
removeEvent('event-1')
@@ -196,7 +186,6 @@ describe('useEventStorage', () => {
eventToken: 'event-1',
title: 'First',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
})
removeEvent('nonexistent')
@@ -220,8 +209,7 @@ describe('isValidStoredEvent', () => {
eventToken: 'abc-123',
title: 'Birthday',
dateTime: '2026-06-15T20:00:00+02:00',
expiryDate: '2026-07-15',
}),
}),
).toBe(true)
})

View File

@@ -3,7 +3,6 @@ export interface StoredEvent {
organizerToken?: string
title: string
dateTime: string
expiryDate: string
rsvpToken?: string
rsvpName?: string
}
@@ -66,7 +65,7 @@ export function useEventStorage() {
existing.rsvpToken = rsvpToken
existing.rsvpName = rsvpName
} else {
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName })
}
writeEvents(events)
}

View File

@@ -65,21 +65,6 @@
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
</div>
<div class="form-group">
<label for="expiryDate" class="form-label">Expiry Date *</label>
<input
id="expiryDate"
v-model="form.expiryDate"
type="date"
class="form-field glass"
required
:min="tomorrow"
:aria-invalid="!!errors.expiryDate"
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
/>
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
</div>
<button type="submit" class="btn-primary glass" :disabled="submitting">
{{ submitting ? 'Creating…' : 'Create Event' }}
</button>
@@ -90,7 +75,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue'
import { reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
@@ -103,7 +88,6 @@ const form = reactive({
description: '',
dateTime: '',
location: '',
expiryDate: '',
})
const errors = reactive({
@@ -111,31 +95,22 @@ const errors = reactive({
description: '',
dateTime: '',
location: '',
expiryDate: '',
})
const submitting = ref(false)
const serverError = ref('')
const tomorrow = computed(() => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().split('T')[0]
})
function clearErrors() {
errors.title = ''
errors.description = ''
errors.dateTime = ''
errors.location = ''
errors.expiryDate = ''
serverError.value = ''
}
// Clear individual field errors when the user types
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
watch(() => form.description, () => { serverError.value = '' })
watch(() => form.location, () => { serverError.value = '' })
@@ -153,14 +128,6 @@ function validate(): boolean {
valid = false
}
if (!form.expiryDate) {
errors.expiryDate = 'Expiry date is required.'
valid = false
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
errors.expiryDate = 'Expiry date must be in the future.'
valid = false
}
return valid
}
@@ -186,7 +153,6 @@ async function handleSubmit() {
dateTime: dateTimeWithOffset,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: form.location.trim() || undefined,
expiryDate: form.expiryDate,
},
})
@@ -212,7 +178,6 @@ async function handleSubmit() {
organizerToken: data.organizerToken,
title: data.title,
dateTime: data.dateTime,
expiryDate: data.expiryDate,
})
router.push({ name: 'event', params: { eventToken: data.eventToken } })

View File

@@ -25,10 +25,6 @@
<!-- Loaded state -->
<div v-else-if="state === 'loaded' && event" class="detail__content">
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
This event has ended.
</div>
<h1 class="detail__title">{{ event.title }}</h1>
<dl class="detail__meta">
@@ -74,9 +70,9 @@
</div>
</div>
<!-- RSVP bar (only for loaded, non-expired events) -->
<!-- RSVP bar -->
<RsvpBar
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
v-if="state === 'loaded' && event && !isOrganizer"
:has-rsvp="!!rsvpName"
@open="sheetOpen = true"
/>
@@ -412,12 +408,6 @@ onMounted(fetchEvent)
text-align: center;
}
.detail__banner--expired {
background: var(--color-glass);
color: var(--color-text-soft);
backdrop-filter: blur(4px);
}
/* Error / not-found message */
.detail__message {
font-size: 1.1rem;

View File

@@ -44,7 +44,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#description').exists()).toBe(true)
expect(wrapper.find('#dateTime').exists()).toBe(true)
expect(wrapper.find('#location').exists()).toBe(true)
expect(wrapper.find('#expiryDate').exists()).toBe(true)
})
it('has required attribute on required fields', async () => {
@@ -58,7 +57,6 @@ describe('EventCreateView', () => {
expect(wrapper.find('#title').attributes('required')).toBeDefined()
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
})
it('does not have required attribute on optional fields', async () => {
@@ -102,7 +100,6 @@ describe('EventCreateView', () => {
// Fill required fields
await wrapper.find('#title').setValue('My Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
@@ -127,7 +124,7 @@ describe('EventCreateView', () => {
await wrapper.find('form').trigger('submit')
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
// Type into title field
await wrapper.find('#title').setValue('My Event')
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(dateTimeError.textContent).not.toBe('')
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
expect(expiryError.textContent).not.toBe('')
})
it('shows validation errors when submitting empty form', async () => {
@@ -156,7 +150,7 @@ describe('EventCreateView', () => {
const errorElements = wrapper.findAll('[role="alert"]')
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
expect(errorTexts.length).toBeGreaterThanOrEqual(2)
})
it('submits successfully, saves to storage, and navigates to event page', async () => {
@@ -179,7 +173,6 @@ describe('EventCreateView', () => {
title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00',
timezone: 'Europe/Berlin',
expiryDate: '2026-12-24',
},
error: undefined,
response: new Response(),
@@ -198,7 +191,6 @@ describe('EventCreateView', () => {
await wrapper.find('#description').setValue('Come celebrate!')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#location').setValue('Berlin')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
@@ -208,7 +200,6 @@ describe('EventCreateView', () => {
title: 'Birthday Party',
description: 'Come celebrate!',
location: 'Berlin',
expiryDate: '2026-12-24',
}),
})
@@ -217,7 +208,6 @@ describe('EventCreateView', () => {
organizerToken: 'org-456',
title: 'Birthday Party',
dateTime: '2026-12-25T18:00:00+01:00',
expiryDate: '2026-12-24',
})
expect(pushSpy).toHaveBeenCalledWith({
@@ -245,7 +235,6 @@ describe('EventCreateView', () => {
await wrapper.find('#title').setValue('Duplicate Event')
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
await wrapper.find('#expiryDate').setValue('2026-12-24')
await wrapper.find('form').trigger('submit')
await flushPromises()
@@ -256,6 +245,5 @@ describe('EventCreateView', () => {
// Other field errors should not be present
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
})
})

View File

@@ -54,7 +54,6 @@ const fullEvent = {
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
expired: false,
}
function mockLoadedEvent(eventOverrides = {}) {
@@ -124,29 +123,6 @@ describe('EventDetailView', () => {
wrapper.unmount()
})
// Expired state
it('renders "event has ended" banner when expired', async () => {
mockLoadedEvent({ expired: true })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).toContain('This event has ended.')
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
wrapper.unmount()
})
// No expired banner when not expired
it('does not render expired banner when event is active', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
wrapper.unmount()
})
// Not found state
it('renders "event not found" when API returns 404', async () => {
vi.mocked(api.GET).mockResolvedValue({
@@ -229,17 +205,6 @@ describe('EventDetailView', () => {
wrapper.unmount()
})
it('does not show RSVP bar on expired event', async () => {
mockLoadedEvent({ expired: true })
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
wrapper.unmount()
})
it('shows RSVP status bar when localStorage has RSVP', async () => {
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
mockLoadedEvent()