feat(activities): capture optional start/end time on event creation

Fold a time into the existing event_start_date / event_end_date strings
("2026-05-25" or "2026-05-25T10:00") rather than introducing parallel
fields. Presence of "T" toggles which NIP-52 kind the events-extension
publisher emits (31922 date-only vs 31923 time-based).

CreateEventDialog gets optional HH:MM inputs next to the start date and
the (already-collapsible) end date — stacked below sm breakpoint so the
iPhone SE doesn't get the time pushed off-screen by the native date
input's intrinsic min-width.

EventsPage.formatDate shows the time portion when present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-20 01:24:15 +02:00
commit 691f8df830
3 changed files with 75 additions and 23 deletions

View file

@ -62,7 +62,9 @@ const formSchema = toTypedSchema(z.object({
name: z.string().min(1, "Title is required").max(200, "Title too long"), name: z.string().min(1, "Title is required").max(200, "Title too long"),
info: z.string().max(2000, "Description too long").optional().default(''), info: z.string().max(2000, "Description too long").optional().default(''),
event_start_date: z.string().min(1, "Start date is required"), event_start_date: z.string().min(1, "Start date is required"),
event_start_time: z.string().optional().default(''),
event_end_date: z.string().optional().default(''), event_end_date: z.string().optional().default(''),
event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
banner: z.string().optional().default(''), banner: z.string().optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
@ -76,7 +78,9 @@ const form = useForm({
name: '', name: '',
info: '', info: '',
event_start_date: '', event_start_date: '',
event_start_time: '',
event_end_date: '', event_end_date: '',
event_end_time: '',
location: '', location: '',
banner: '', banner: '',
currency: 'sat', currency: 'sat',
@ -85,6 +89,15 @@ const form = useForm({
} }
}) })
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
// into the events-extension wire format: date-only when no time given,
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
// the "T" delimiter.
function foldDateTime(date: string, time: string): string {
if (!date) return ''
return time ? `${date}T${time}` : date
}
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
@ -144,13 +157,21 @@ const onSubmit = form.handleSubmit(async (formValues) => {
try { try {
const eventData: CreateEventRequest = { const eventData: CreateEventRequest = {
name: formValues.name, name: formValues.name,
event_start_date: formValues.event_start_date, event_start_date: foldDateTime(
formValues.event_start_date,
formValues.event_start_time
),
wallet: preferredWallet.id, wallet: preferredWallet.id,
} }
// Optional fields only include if provided // Optional fields only include if provided
if (formValues.info) eventData.info = formValues.info if (formValues.info) eventData.info = formValues.info
if (formValues.event_end_date) eventData.event_end_date = formValues.event_end_date if (formValues.event_end_date) {
eventData.event_end_date = foldDateTime(
formValues.event_end_date,
formValues.event_end_time
)
}
if (formValues.location) eventData.location = formValues.location if (formValues.location) eventData.location = formValues.location
if (formValues.banner) eventData.banner = formValues.banner if (formValues.banner) eventData.banner = formValues.banner
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
@ -207,16 +228,28 @@ const handleOpenChange = (open: boolean) => {
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Start date (required) --> <!-- Start date (required) + optional time -->
<FormField v-slot="{ componentField }" name="event_start_date"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormItem> <FormField v-slot="{ componentField }" name="event_start_date">
<FormLabel>Start date *</FormLabel> <FormItem class="sm:col-span-2 min-w-0">
<FormControl> <FormLabel>Start date *</FormLabel>
<Input type="date" :min="today" v-bind="componentField" /> <FormControl>
</FormControl> <Input type="date" :min="today" class="w-full" v-bind="componentField" />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
</FormField> </FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="event_start_time">
<FormItem class="min-w-0">
<FormLabel>Start time</FormLabel>
<FormControl>
<Input type="time" class="w-full" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Description (optional, visible) --> <!-- Description (optional, visible) -->
<FormField v-slot="{ componentField }" name="info"> <FormField v-slot="{ componentField }" name="info">
@ -329,16 +362,28 @@ const handleOpenChange = (open: boolean) => {
<CollapsibleContent class="space-y-4 pt-2"> <CollapsibleContent class="space-y-4 pt-2">
<Separator /> <Separator />
<FormField v-slot="{ componentField }" name="event_end_date"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormItem> <FormField v-slot="{ componentField }" name="event_end_date">
<FormLabel>End date</FormLabel> <FormItem class="sm:col-span-2 min-w-0">
<FormControl> <FormLabel>End date</FormLabel>
<Input type="date" :min="today" v-bind="componentField" /> <FormControl>
</FormControl> <Input type="date" :min="today" class="w-full" v-bind="componentField" />
<FormDescription class="text-xs">Defaults to start date if not set</FormDescription> </FormControl>
<FormMessage /> <FormDescription class="text-xs">Defaults to start date</FormDescription>
</FormItem> <FormMessage />
</FormField> </FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="event_end_time">
<FormItem class="min-w-0">
<FormLabel>End time</FormLabel>
<FormControl>
<Input type="time" class="w-full" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View file

@ -44,6 +44,10 @@ export interface TicketPaymentStatus {
/** /**
* LNbits events extension event (database-backed ticketed event). * LNbits events extension event (database-backed ticketed event).
* Corresponds to the Event model in the events extension. * Corresponds to the Event model in the events extension.
*
* event_start_date / event_end_date are ISO 8601 either date-only
* ("2026-05-19") or with a time ("2026-05-19T18:30"). Presence of "T"
* switches the publisher between NIP-52 kind 31922 and 31923.
*/ */
export interface TicketedEvent { export interface TicketedEvent {
id: string id: string

View file

@ -33,7 +33,10 @@ function formatDate(dateStr: string | null | undefined) {
if (!dateStr) return 'Date not available' if (!dateStr) return 'Date not available'
const date = new Date(dateStr) const date = new Date(dateStr)
if (isNaN(date.getTime())) return 'Invalid date' if (isNaN(date.getTime())) return 'Invalid date'
return format(date, 'MMMM do, yyyy') // Presence of "T" in the wire value marks a time-based event (NIP-52
// kind 31923 on our publisher). Show time only when one was set.
const hasTime = dateStr.includes('T')
return format(date, hasTime ? 'MMMM do, yyyy p' : 'MMMM do, yyyy')
} }
function handlePurchaseClick(event: { function handlePurchaseClick(event: {