feat: support optional start/end time on events

event_start_date / event_end_date now accept either YYYY-MM-DD (date-only)
or YYYY-MM-DDTHH:MM (ISO datetime). The NIP-52 publisher switches kind
on the "T" delimiter: kind 31922 (date-based, YYYY-MM-DD start/end) when
absent, kind 31923 (time-based, unix-timestamp start/end + day-granularity
D tags) when present. Delete events match the original publish kind.

Closing-date parsing accepts both formats. The LNbits admin form gains
optional HH:MM inputs alongside each date picker; they fold into the
wire-format string on submit and split back on edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-20 01:22:38 +02:00
commit 4aa90d80ad
5 changed files with 137 additions and 32 deletions

View file

@ -30,9 +30,11 @@ class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required) name: str # title (required)
info: str = "" # description (optional) info: str = "" # description (optional)
closing_date: str | None = None # defaults to event_end_date closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
event_start_date: str # required # ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
event_end_date: str | None = None # defaults to event_start_date # Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
event_start_date: str
event_end_date: str | None = None # same format as event_start_date
currency: str = "sat" currency: str = "sat"
amount_tickets: int = 0 # 0 = unlimited / not ticketed amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free price_per_ticket: float = 0 # 0 = free

View file

@ -1,14 +1,17 @@
""" """
NIP-52 calendar event publishing for the events extension. NIP-52 calendar event publishing for the events extension.
Builds kind 31922 (date-based) calendar events from the Event model, Builds NIP-52 calendar events from the Event model, signs them with the
signs them with the event creator's Account keypair, and publishes creator's Account keypair, and publishes via the NostrClient.
via the NostrClient to nostrclient relays.
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
when event_start_date / event_end_date include a time component.
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
""" """
import time import time
from datetime import datetime, timezone
import coincurve import coincurve
from loguru import logger from loguru import logger
@ -17,26 +20,60 @@ from .models import Event
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
def _has_time(value: str | None) -> bool:
"""ISO 8601 datetime strings contain a 'T' between date and time."""
return value is not None and "T" in value
def _to_unix(value: str) -> int:
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
""" """
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event. Convert an Event model to a NIP-52 calendar event.
Tags: Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
d - event.id (addressable identifier) date-based (kind 31922). Tags:
d - event.id
title - event.name title - event.name
start - event.event_start_date (ISO date string) start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - event.event_end_date (optional) end - same encoding (optional)
image - event.banner (optional) image, location, t (categories) - optional
Content: event.info (description) Content: event.info
""" """
time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922
start_value = (
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
)
tags = [ tags = [
["d", event.id], ["d", event.id],
["title", event.name], ["title", event.name],
["start", event.event_start_date], ["start", start_value],
] ]
end_unix: int | None = None
if event.event_end_date: if event.event_end_date:
tags.append(["end", event.event_end_date]) end_value = (
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
)
tags.append(["end", end_value])
if time_based:
end_unix = _to_unix(event.event_end_date)
if time_based:
start_unix = _to_unix(event.event_start_date)
start_day = start_unix // 86400
end_day = (end_unix // 86400) if end_unix is not None else start_day
for day in range(start_day, end_day + 1):
tags.append(["D", str(day)])
if event.banner: if event.banner:
tags.append(["image", event.banner]) tags.append(["image", event.banner])
if event.location: if event.location:
@ -47,7 +84,7 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
nostr_event = NostrEvent( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=int(time.time()),
kind=31922, kind=kind,
tags=tags, tags=tags,
content=event.info or "", content=event.info or "",
) )
@ -59,15 +96,17 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
""" """
Build a kind 5 delete event for a published NIP-52 calendar event. Build a kind 5 delete event for a published NIP-52 calendar event.
Uses an 'a' tag to reference the parameterized replaceable event Uses an 'a' tag to reference the parameterized replaceable event per
(kind 31922) per NIP-09. NIP-09. The referenced kind must match what we published 31923 for
time-based events, 31922 for date-only.
""" """
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
nostr_event = NostrEvent( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=int(time.time()),
kind=5, kind=5,
tags=[ tags=[
["a", f"31922:{pubkey}:{event.id}"], ["a", f"{referenced_kind}:{pubkey}:{event.id}"],
], ],
content="Event canceled", content="Event canceled",
) )

View file

@ -233,11 +233,39 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}) })
}, },
foldDateTime(day, time) {
// Combine separate date/time inputs into the wire format
// expected by the events extension: "YYYY-MM-DD" or
// "YYYY-MM-DDTHH:MM" (time is optional).
if (!day) return null
return time ? `${day}T${time}` : day
},
splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to.
if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)}
},
sendEventData() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data const data = {...this.formDialog.data}
data.event_start_date = this.foldDateTime(
data.event_start_day,
data.event_start_time
)
data.event_end_date = this.foldDateTime(
data.event_end_day,
data.event_end_time
)
delete data.event_start_day
delete data.event_start_time
delete data.event_end_day
delete data.event_end_time
if (data.extra && !data.extra.promo_codes) { if (data.extra && !data.extra.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '') .filter(code => code.trim() !== '')
@ -253,10 +281,22 @@ window.PageEvents = {
openEventDialog(data = false) { openEventDialog(data = false) {
if (data && data.id) { if (data && data.id) {
this.formDialog.data = {...data} const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_date)
this.formDialog.data = {
...data,
event_start_day: start.day,
event_start_time: start.time,
event_end_day: end.day,
event_end_time: end.time
}
} else { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats', currency: 'sats',
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: { extra: {
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,

View file

@ -471,28 +471,46 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_date" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
></q-input> ></q-input>
</div> </div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_time"
type="time"
hint="Optional"
></q-input>
</div>
</div> </div>
<div class="row"> <div class="row q-col-gutter-sm">
<div class="col-4">Event ends</div> <div class="col-4">Event ends</div>
<div class="col-8"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_end_date" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
></q-input> ></q-input>
</div> </div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_time"
type="time"
hint="Optional"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
@ -650,8 +668,8 @@
formDialog.data.name == null || formDialog.data.name == null ||
formDialog.data.info == null || formDialog.data.info == null ||
formDialog.data.closing_date == null || formDialog.data.closing_date == null ||
formDialog.data.event_start_date == null || formDialog.data.event_start_day == null ||
formDialog.data.event_end_date == null || formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null || formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null formDialog.data.price_per_ticket == null
" "

View file

@ -140,9 +140,15 @@ async def api_get_event(event_id: str) -> Event:
# closing_date is filled in by create_event (defaults to end_date or # closing_date is filled in by create_event (defaults to end_date or
# start_date) but the field is typed Optional, so guard for the typechecker. # start_date) but the field is typed Optional, so guard for the typechecker.
closing_date = event.closing_date or event.event_end_date or event.event_start_date closing_date = event.closing_date or event.event_end_date or event.event_start_date
is_window_open = datetime.now(timezone.utc) < datetime.strptime( # Accept either YYYY-MM-DD or full ISO 8601 datetime (e.g. event_end_date
closing_date, "%Y-%m-%d" # may carry a time component since v1.3.0-aio.3).
).replace(tzinfo=timezone.utc) try:
closing_dt = datetime.fromisoformat(closing_date)
except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
is_window_open = datetime.now(timezone.utc) < closing_dt
is_min_tickets_met = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )