@acms-subir/core 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/integration.mjs +38 -2
  3. package/package.json +1 -1
  4. package/src/actions/index.ts +146 -2
  5. package/src/components/EventPage.astro +25 -5
  6. package/src/components/calendar/BookingsHead.astro +4 -3
  7. package/src/components/calendar/RoomHolds.tsx +13 -8
  8. package/src/components/editor/EventPanel.tsx +9 -383
  9. package/src/components/editor/LivePane.tsx +16 -3
  10. package/src/components/editor/PostEditor.tsx +59 -46
  11. package/src/components/editor/PreviewOverlay.tsx +7 -1
  12. package/src/components/editor/event/EventSheet.tsx +136 -0
  13. package/src/components/editor/event/LocationField.tsx +266 -0
  14. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  15. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  16. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  17. package/src/components/editor/event/Section.tsx +27 -0
  18. package/src/components/editor/event/WhenCard.tsx +217 -0
  19. package/src/components/editor/event/dates.ts +28 -0
  20. package/src/components/editor/event/registration.ts +39 -0
  21. package/src/components/editor/extensions/form.tsx +60 -0
  22. package/src/components/editor/extensions/slash.tsx +5 -0
  23. package/src/components/editor/frameNav.ts +20 -0
  24. package/src/components/forms/Canvas.tsx +149 -0
  25. package/src/components/forms/FormBuildPage.tsx +155 -0
  26. package/src/components/forms/FormBuilder.tsx +283 -0
  27. package/src/components/forms/FormHeader.astro +31 -0
  28. package/src/components/forms/InsertMenu.tsx +77 -0
  29. package/src/components/forms/Inspector.tsx +232 -0
  30. package/src/components/forms/Outline.tsx +93 -0
  31. package/src/components/forms/ResponsesTable.tsx +123 -0
  32. package/src/components/forms/TypeIcon.tsx +24 -0
  33. package/src/components/forms/dnd.ts +3 -0
  34. package/src/components/forms/meta.ts +84 -0
  35. package/src/components/forms/useFormHistory.ts +41 -0
  36. package/src/layouts/Admin.astro +3 -2
  37. package/src/lib/backup/content.ts +10 -5
  38. package/src/lib/booking/service.ts +6 -2
  39. package/src/lib/calendar/agent-schema.ts +7 -1
  40. package/src/lib/calendar/agent.ts +28 -3
  41. package/src/lib/calendar/api.ts +2 -0
  42. package/src/lib/calendar/ics-write.ts +2 -0
  43. package/src/lib/calendar/ics.ts +4 -0
  44. package/src/lib/calendar/import.ts +3 -3
  45. package/src/lib/calendar/schedule.ts +6 -2
  46. package/src/lib/calendar/seo.ts +4 -1
  47. package/src/lib/content-service.ts +30 -4
  48. package/src/lib/drafts.ts +9 -1
  49. package/src/lib/dynamicBlocks.ts +14 -3
  50. package/src/lib/editor/documents.ts +12 -3
  51. package/src/lib/editor/schemas.ts +4 -3
  52. package/src/lib/forms/events.ts +40 -0
  53. package/src/lib/forms/export.ts +62 -0
  54. package/src/lib/forms/fields.ts +50 -0
  55. package/src/lib/forms/http.ts +29 -0
  56. package/src/lib/forms/render.ts +11 -0
  57. package/src/lib/forms/schema.ts +56 -0
  58. package/src/lib/forms/service.ts +38 -0
  59. package/src/lib/forms/store.ts +78 -0
  60. package/src/lib/forms/validate.ts +71 -0
  61. package/src/lib/maps/config.ts +64 -0
  62. package/src/lib/maps/migration.ts +12 -0
  63. package/src/lib/maps/parse.ts +180 -0
  64. package/src/lib/maps/places.ts +109 -0
  65. package/src/lib/maps/providers/geoapify.ts +36 -0
  66. package/src/lib/maps/providers/google.ts +54 -0
  67. package/src/lib/maps/providers/http.ts +96 -0
  68. package/src/lib/maps/providers/index.ts +37 -0
  69. package/src/lib/maps/providers/locationiq.ts +35 -0
  70. package/src/lib/maps/providers/mapbox.ts +38 -0
  71. package/src/lib/maps/providers/osm.ts +59 -0
  72. package/src/lib/maps/providers/pelias.ts +51 -0
  73. package/src/lib/maps/service.ts +281 -0
  74. package/src/lib/maps/types.ts +35 -0
  75. package/src/lib/mobile/bookings.ts +1 -1
  76. package/src/lib/mobile/schemas.ts +2 -2
  77. package/src/lib/repo/migrations.ts +8 -0
  78. package/src/lib/repo/sql.ts +99 -10
  79. package/src/lib/repo/types.ts +21 -1
  80. package/src/lib/schema.ts +38 -0
  81. package/src/lib/umami.ts +8 -1
  82. package/src/pages/admin/calendar/bookings.astro +12 -7
  83. package/src/pages/admin/calendar/events/[id].astro +5 -2
  84. package/src/pages/admin/calendar/events/index.astro +3 -0
  85. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  86. package/src/pages/admin/calendar/settings.astro +140 -3
  87. package/src/pages/admin/forms/[id]/share.astro +121 -0
  88. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  89. package/src/pages/admin/forms/[id].astro +27 -0
  90. package/src/pages/admin/forms/index.astro +198 -0
  91. package/src/pages/admin/integrations.astro +57 -0
  92. package/src/pages/api/agent/index.ts +1 -1
  93. package/src/pages/api/calendar/docs.ts +2 -0
  94. package/src/pages/api/calendar/events/[id].ts +1 -1
  95. package/src/pages/api/calendar/events/index.ts +1 -1
  96. package/src/pages/api/mcp.ts +1 -1
  97. package/src/pages/book/manage/[token].astro +4 -4
  98. package/src/pages/events/[slug].astro +24 -10
  99. package/src/pages/forms/[id].astro +27 -0
  100. package/src/styles/admin.css +2 -2
  101. package/src/styles/editor.css +275 -34
  102. package/src/styles/form-block.css +38 -0
  103. package/src/styles/forms-admin.css +184 -0
  104. package/src/styles/forms.css +281 -0
  105. package/src/styles/site.css +43 -0
  106. package/src/virtual.d.ts +2 -0
@@ -30,7 +30,7 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
30
30
  sessionUserId && sessionUserId !== OWNER_ENV ? repo.getUser(sessionUserId) : Promise.resolve(null),
31
31
  ]);
32
32
  // pending booking requests, shown as a count on the Bookings nav item
33
- const pendingBookings = bookingsEnabled() ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'] })).length : 0;
33
+ const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
34
34
  const me = {
35
35
  name: sessionUser?.name || 'Admin',
36
36
  role: roleLabels[sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'],
@@ -101,6 +101,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
101
101
  items: [
102
102
  ...collectionItems,
103
103
  { key: 'pages', label: 'Pages', href: '/admin/pages', icon: icons.pages, count: pages.length },
104
+ { key: 'forms', label: 'Forms', href: '/admin/forms', icon: icons.pages, count: null },
104
105
  { key: 'tags', label: 'Tags', href: '/admin/tags', icon: icons.tags, count: null },
105
106
  { key: 'media', label: 'Media', href: '/admin/media', icon: icons.media, count: null },
106
107
  ],
@@ -114,7 +115,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
114
115
  // people, booking links). Sub-pages highlight their home.
115
116
  items: [
116
117
  { key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
117
- ...(bookingsEnabled()
118
+ ...(CALENDAR.enabled
118
119
  ? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
119
120
  : []),
120
121
  ],
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
2
+ import { SavedForm, FormSubmission } from '../forms/schema';
3
+ import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place } from '../schema';
3
4
  import type { ContentRepository } from '../repo/types';
4
5
 
5
6
  // backups from before booking approval modes: a link that didn't confirm on
@@ -10,12 +11,15 @@ const legacyApproval = (l: unknown) =>
10
11
  export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
11
12
  export type BookingRequest = z.infer<typeof BookingRequest>;
12
13
  export const BackupContent = z.object({
13
- format: z.literal('acms-backup'), version: z.number().int().min(1).max(5), exportedAt: z.string().optional(),
14
+ format: z.literal('acms-backup'), version: z.number().int().min(1).max(6), exportedAt: z.string().optional(),
14
15
  settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
15
16
  tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
16
17
  calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
17
18
  bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
19
+ places: z.array(Place).default([]),
20
+ forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
18
21
  }).superRefine((data, ctx) => {
22
+ if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
19
23
  if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
20
24
  const keys = new Set<string>();
21
25
  for (const r of data.bookingRequests ?? []) {
@@ -27,15 +31,16 @@ export const BackupContent = z.object({
27
31
  }
28
32
  });
29
33
  export async function exportContent(repo: ContentRepository, now = Date.now()) {
30
- const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState] = await Promise.all([
34
+ const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState, places, forms, formSubmissions] = await Promise.all([
31
35
  repo.getSettings(), repo.listPages(), repo.listPosts(), repo.listMedia(), repo.listLinks(), repo.listTags(), repo.listNavItems(),
32
36
  repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
37
+ repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
33
38
  ]);
34
39
  return {
35
- format: 'acms-backup' as const, version: 5, exportedAt: new Date(now).toISOString(),
40
+ format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
36
41
  settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
37
42
  pages, posts, media, links, tags, navItems, users, redirects,
38
43
  calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
39
- resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
44
+ resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions,
40
45
  };
41
46
  }
@@ -1,3 +1,4 @@
1
+ import { collectResponse, displayAnswers } from '../forms/service';
1
2
  // Booking workflows shared by the public booking pages, the admin, the REST
2
3
  // API and agent tools, so every path validates, notifies and fires webhooks
3
4
  // the same way. Auto-confirm links confirm on the spot (and show on the
@@ -101,7 +102,7 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
101
102
  id: b.id, kind: b.kind, status: b.status,
102
103
  start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(),
103
104
  resourceId: b.resourceId || null, linkId: b.linkId || null, eventId: b.eventId || null,
104
- name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers,
105
+ name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers, formResponse: b.formResponse,
105
106
  source: b.source, createdAt: new Date(b.createdAt).toISOString(), ...extra,
106
107
  };
107
108
  }
@@ -140,6 +141,7 @@ export interface BookerInput {
140
141
  email: string;
141
142
  phone?: string;
142
143
  answers?: Record<string, string>;
144
+ formVersion?: string;
143
145
  }
144
146
 
145
147
  export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
@@ -217,10 +219,12 @@ export async function createRsvp(
217
219
  if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
218
220
  const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
219
221
  const booker = validateBooker(null, args.booker);
222
+ const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
223
+ if (formResponse) booker.answers = displayAnswers(formResponse);
220
224
  const b = await repo.createBooking({
221
225
  kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
222
226
  start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
223
- notes: '', manageToken: token(), source: args.source ?? 'web',
227
+ formResponse, notes: '', manageToken: token(), source: args.source ?? 'web',
224
228
  }, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
225
229
  if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
226
230
  await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
@@ -12,7 +12,13 @@ const eventFields = {
12
12
  allDay: z.boolean().optional(),
13
13
  timezone: z.string().max(80).optional(),
14
14
  rrule: z.string().max(500).optional(),
15
- location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default('') }).strict().optional(),
15
+ // lat/lng pin the map image; placeId links a saved place (its details are
16
+ // copied in); showMap: false hides the map image on the event page
17
+ location: z.object({
18
+ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default(''),
19
+ lat: z.number().min(-90).max(90).nullable().optional(), lng: z.number().min(-180).max(180).nullable().optional(),
20
+ placeId: z.string().max(100).optional(), showMap: z.boolean().optional(),
21
+ }).strict().refine((l) => (l.lat === undefined || l.lat === null) === (l.lng === undefined || l.lng === null), { message: 'Give both lat and lng, or neither.' }).optional(),
16
22
  cta: z.object({ label: z.string().max(120).default(''), href: z.string().max(1000).default(''), style: z.enum(['primary', 'outline']).default('primary') }).strict().optional(),
17
23
  calendars: z.array(z.string().max(100)).max(20).optional(),
18
24
  // bookable rooms/people (slugs from the booking resources) this event uses:
@@ -14,12 +14,35 @@ import { mergeEventDraft } from '../drafts';
14
14
  import { eventChanged } from './hooks';
15
15
  import { eventToJson } from './api';
16
16
  import { siteLocale } from '../dates';
17
+ import { EventLocation } from '../schema';
18
+ import { ensureMapImages, mergeLocationInput } from '../maps/service';
17
19
 
18
20
  export type EventToolInput = z.infer<(typeof EventToolInputs)[number]>;
19
21
 
20
22
  export interface AgentContext {
21
23
  origin: string;
22
24
  waitUntil?: (p: Promise<unknown>) => void;
25
+ // request locals (maps provider + media storage for map images)
26
+ locals?: unknown;
27
+ }
28
+
29
+ type LocationArg = NonNullable<Extract<EventToolInput, { tool: 'create_event' }>['arguments']['location']>;
30
+
31
+ // A location from the API/agent: a known placeId copies the saved place's
32
+ // details in (typed fields still win), a pin gets its map image. On update,
33
+ // unchanged text keeps the existing pin (mergeLocationInput).
34
+ async function locationFrom(repo: ContentRepository, a: LocationArg, prev: EventLocation, ctx: AgentContext): Promise<EventLocation> {
35
+ let input: Partial<EventLocation> = { ...a };
36
+ if (a.placeId) {
37
+ const place = await repo.getPlace(a.placeId);
38
+ if (!place) throw new ApiError(400, `Unknown place "${a.placeId}".`);
39
+ input = {
40
+ name: a.name || place.name, address: a.address || place.address, url: a.url || place.url,
41
+ lat: a.lat ?? place.lat, lng: a.lng ?? place.lng, placeId: place.id, showMap: a.showMap,
42
+ };
43
+ }
44
+ const loc = mergeLocationInput(prev, input);
45
+ return ensureMapImages(repo, loc, { locals: ctx.locals, origin: ctx.origin });
23
46
  }
24
47
 
25
48
  const editUrl = (id: string) => `/admin/calendar/events/${id}`;
@@ -100,7 +123,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
100
123
  for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
101
124
  const e = await repo.createEvent({
102
125
  title: a.title, slug, ...times, allDay: a.allDay ?? a.start.length === 10, timezone: a.timezone ?? '',
103
- rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', location: a.location, cta: a.cta, tags: a.tags,
126
+ rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', cta: a.cta, tags: a.tags,
127
+ ...(a.location ? { location: await locationFrom(repo, a.location, EventLocation.parse({}), ctx) } : {}),
104
128
  cancelled: a.cancelled ?? false, seoTitle: a.seoTitle, seoDesc: a.seoDesc,
105
129
  calendarIds: (await resolveCalendars(repo, a.calendars)) ?? [],
106
130
  ...(a.rooms ? { roomHold: await resolveRooms(repo, a.rooms) } : {}),
@@ -110,11 +134,12 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
110
134
  return { ...summary(e) };
111
135
  }
112
136
  case 'update_event': {
113
- const { id, content, calendars: slugs, rooms, ...rest } = input.arguments;
114
- checkLinks(rest);
137
+ const { id, content, calendars: slugs, rooms, location, ...rest } = input.arguments;
138
+ checkLinks({ ...rest, location });
115
139
  const before = await repo.getEvent(id);
116
140
  if (!before) throw new ApiError(404, 'Event not found.');
117
141
  const data: Partial<CalEvent> = { ...rest };
142
+ if (location) data.location = await locationFrom(repo, location, mergeEventDraft(before).location, ctx);
118
143
  if (content !== undefined) {
119
144
  data.bodyHtml = renderAgentMarkdown(content);
120
145
  data.bodyJson = '';
@@ -25,6 +25,7 @@ export interface EventJson {
25
25
  exdates: string[];
26
26
  location: CalEvent['location'];
27
27
  cta: CalEvent['cta'];
28
+ rsvp?: CalEvent['rsvp'];
28
29
  image: string;
29
30
  imageAlt: string;
30
31
  tags: string[];
@@ -55,6 +56,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
55
56
  exdates: e.exdates,
56
57
  location: e.location,
57
58
  cta: e.cta,
59
+ rsvp: e.rsvp,
58
60
  image: e.cover ? abs(origin, e.cover) : '',
59
61
  imageAlt: e.coverAlt,
60
62
  tags: e.tags.filter((t) => !t.startsWith('#')),
@@ -92,6 +92,8 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
92
92
  lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
93
93
  const loc = locationLine(e.location);
94
94
  if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
95
+ // RFC 5545 GEO: latitude;longitude
96
+ if (typeof e.location.lat === 'number' && typeof e.location.lng === 'number') lines.push(`GEO:${e.location.lat.toFixed(6)};${e.location.lng.toFixed(6)}`);
95
97
  const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
96
98
  lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
97
99
  const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
@@ -55,6 +55,7 @@ const DAY = 86_400_000;
55
55
  /* ---------------- timezone math (no dependencies) ---------------- */
56
56
 
57
57
  const fmtCache = new Map<string, Intl.DateTimeFormat>();
58
+ const FORMATTER_CACHE_MAX = 64;
58
59
  function tzFormatter(tz: string): Intl.DateTimeFormat | null {
59
60
  let f = fmtCache.get(tz);
60
61
  if (f) return f;
@@ -68,6 +69,9 @@ function tzFormatter(tz: string): Intl.DateTimeFormat | null {
68
69
  return null; // unknown TZID (Outlook's "Customized Time Zone" etc.)
69
70
  }
70
71
  fmtCache.set(tz, f);
72
+ // Intl formatters retain native ICU data; timezone aliases/case variants
73
+ // must not accumulate for the lifetime of a self-hosted server.
74
+ if (fmtCache.size > FORMATTER_CACHE_MAX) fmtCache.delete(fmtCache.keys().next().value!);
71
75
  return f;
72
76
  }
73
77
 
@@ -3,7 +3,7 @@
3
3
  // import action saves as DRAFTS — nothing goes live without a review.
4
4
  // Recurring ICS events keep their RRULE/EXDATEs; per-instance overrides
5
5
  // (RECURRENCE-ID) are skipped, since a native event can't hold them yet.
6
- import type { EventLocation } from '../schema';
6
+ import { EventLocation } from '../schema';
7
7
  import { parseICS, tzParts, type IcsTime, type VEvent } from './ics';
8
8
  import { isValidTimeZone, normalizeEventTimes, parseWall, addWallDays, formatWall } from './events';
9
9
  import { cleanRrule } from './rrule';
@@ -78,7 +78,7 @@ export function eventsFromVEvents(vevents: VEvent[], calTz: string, siteTz: stri
78
78
  exdates: v.exdates.map((t) => wallOf(t, siteTz).wall),
79
79
  excerpt: v.description.slice(0, 300),
80
80
  bodyHtml: paragraphs(v.description),
81
- location: { name: v.location.slice(0, 300), address: '', url: '' },
81
+ location: EventLocation.parse({ name: v.location.slice(0, 300) }),
82
82
  cancelled: v.cancelled,
83
83
  sourceUid: v.uid,
84
84
  });
@@ -192,7 +192,7 @@ export function eventsFromCsv(text: string): { events: ImportedEvent[]; skipped:
192
192
  events.push({
193
193
  title: title.slice(0, 300), ...times, allDay, timezone: '', rrule: '', exdates: [],
194
194
  excerpt: desc.slice(0, 300), bodyHtml: paragraphs(desc),
195
- location: { name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) },
195
+ location: EventLocation.parse({ name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) }),
196
196
  cancelled: false, sourceUid: '',
197
197
  });
198
198
  } catch {
@@ -5,7 +5,8 @@
5
5
  // the editor's "stage until Update" flow is for content edits, not dates.
6
6
  import type { ContentRepository } from '../repo/types';
7
7
  import type { CalEvent } from '../schema';
8
- import { slugify } from '../schema';
8
+ import { EventLocation, slugify } from '../schema';
9
+ import { defaultPlaceLocation } from '../maps/places';
9
10
  import { ApiError } from '../api';
10
11
  import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
11
12
  import { addWallDays, formatWall, parseWall } from './events';
@@ -49,10 +50,13 @@ export interface QuickEvent {
49
50
  export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
50
51
  const title = q.title.trim().slice(0, 300) || 'Untitled event';
51
52
  const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
53
+ // no place typed → the site's default place (if one is set)
54
+ const typed = q.location.trim().slice(0, 300);
55
+ const location = typed ? EventLocation.parse({ name: typed }) : (await defaultPlaceLocation(repo)) ?? EventLocation.parse({});
52
56
  const created = await repo.createEvent({
53
57
  title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
54
58
  calendarIds: cal?.kind === 'native' ? [cal.id] : [],
55
- location: { name: q.location.trim().slice(0, 300), address: '', url: '' },
59
+ location,
56
60
  });
57
61
  if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
58
62
  // times + repeat go through the editor's validation (end defaults, pairs)
@@ -24,8 +24,11 @@ export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | '
24
24
  return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
25
25
  };
26
26
  const online = Boolean(e.location.url && isMeetingUrl(e.location.url));
27
+ const pin = typeof e.location.lat === 'number' && typeof e.location.lng === 'number'
28
+ ? { geo: { '@type': 'GeoCoordinates', latitude: e.location.lat, longitude: e.location.lng } }
29
+ : {};
27
30
  const place = e.location.name || e.location.address
28
- ? { '@type': 'Place', name: e.location.name || e.location.address, ...(e.location.address ? { address: e.location.address } : {}) }
31
+ ? { '@type': 'Place', name: e.location.name || e.location.address, ...(e.location.address ? { address: e.location.address } : {}), ...pin }
29
32
  : null;
30
33
  const virtual = online ? { '@type': 'VirtualLocation', url: e.location.url } : null;
31
34
  const location = place && virtual ? [place, virtual] : place ?? virtual;
@@ -1,5 +1,6 @@
1
1
  import type { ContentRepository } from './repo/types';
2
- import type { CalEvent, Page, Post, WidgetInstance } from './schema';
2
+ import { FormDefinition } from './forms/schema';
3
+ import type { CalEvent, EventLocation, Page, Post, WidgetInstance } from './schema';
3
4
  import { slugify } from './schema';
4
5
  import { ApiError } from './api';
5
6
  import { CONTENT, collectionUrl } from './content';
@@ -9,6 +10,8 @@ import { eventChanged } from './calendar/hooks';
9
10
  import { normalizePageLocale } from './i18n';
10
11
  import { siteOrigin } from './seo';
11
12
  import { waitUntilFrom } from './webhooks';
13
+ import { mergeEventDraft } from './drafts';
14
+ import { ensureMapImages, mergeLocationInput } from './maps/service';
12
15
 
13
16
  // Saving posts, pages and events: the one implementation behind the admin
14
17
  // editor (astro:actions) and the editor-app API (/api/v1/admin/documents).
@@ -27,7 +30,12 @@ export interface SaveHooks {
27
30
 
28
31
  export type PostSave = Parameters<ContentRepository['updatePost']>[1];
29
32
  export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blocks'>;
30
- export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt'>>;
33
+ // location may be partial: missing pin fields are filled from the current
34
+ // location (see mergeLocationInput) — older clients send name/address/url only
35
+ export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location'>> & {
36
+ location?: Partial<EventLocation>;
37
+ };
38
+ type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
31
39
 
32
40
  const live = (status: string) => status === 'published' || status === 'scheduled';
33
41
 
@@ -107,8 +115,21 @@ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks
107
115
 
108
116
  // Validate + normalize an event save. Times are checked as a PAIR against the
109
117
  // merged (staged) state so a half-edited range can't slip through.
110
- export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventSave> {
111
- const out: EventSave = { ...data };
118
+ export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventClean> {
119
+ const { location, ...rest } = data;
120
+ const out: EventClean = { ...rest };
121
+ // Older editor clients resend the original RSVP shape. Omission must not
122
+ // erase questions that were authored in a newer editor.
123
+ if (out.rsvp && !Object.hasOwn(out.rsvp, 'form')) {
124
+ const previous = mergeEventDraft(before).rsvp.form;
125
+ if (previous) out.rsvp = { ...out.rsvp, form: previous };
126
+ }
127
+ if (out.rsvp?.form !== undefined) {
128
+ const form = FormDefinition.safeParse(out.rsvp.form);
129
+ if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
130
+ out.rsvp = { ...out.rsvp, form: form.data };
131
+ }
132
+ if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
112
133
  if (out.slug !== undefined) {
113
134
  const base = slugify(out.slug || out.title || before.title);
114
135
  let slug = base;
@@ -178,6 +199,11 @@ export async function saveEvent(repo: ContentRepository, id: string, data: Event
178
199
  const before = await repo.getEvent(id);
179
200
  if (!before) throw new ApiError(404, 'Event not found.');
180
201
  const clean = await cleanEventInput(repo, data, before);
202
+ // static map images follow the pin (fetched once per pin, never blocking)
203
+ if (clean.location) {
204
+ const settings = await repo.getSettings();
205
+ clean.location = await ensureMapImages(repo, clean.location, { locals: hooks.locals, origin: siteOrigin(settings, hooks.url) });
206
+ }
181
207
  // live events stage edits until Update, exactly like posts
182
208
  const transitions = clean.status !== undefined && clean.status !== before.status;
183
209
  if (live(before.status) && !transitions) {
package/src/lib/drafts.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CalEvent, NavItem, Page, Post, Settings } from './schema';
2
+ import { EventLocation } from './schema';
2
3
 
3
4
  // Pending-edit ("draft") snapshots for live documents. A published or
4
5
  // scheduled post/page never receives content edits directly: they accumulate
@@ -44,7 +45,14 @@ export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post
44
45
  return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
45
46
  }
46
47
  export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<CalEvent, EventDraftKey>> {
47
- return pick(draft, EVENT_DRAFT_KEYS) as Partial<Pick<CalEvent, EventDraftKey>>;
48
+ const out = pick(draft, EVENT_DRAFT_KEYS) as Partial<Pick<CalEvent, EventDraftKey>>;
49
+ // snapshots staged before maps support lack the pin fields: fill defaults
50
+ // so merged events always carry a complete location
51
+ if (out.location !== undefined) {
52
+ const loc = EventLocation.safeParse(out.location);
53
+ if (loc.success) out.location = loc.data; else delete out.location;
54
+ }
55
+ return out;
48
56
  }
49
57
  export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
50
58
  return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
@@ -19,7 +19,7 @@ import { renderEventCards, type EventCardAttrs } from './calendar/cards';
19
19
  // <div data-dyn="events" data-mode="upcoming" data-calendar="youth"
20
20
  // data-event="" data-limit="3" data-layout="grid" data-show-image="1"></div>
21
21
 
22
- const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events)"[^>]*>\s*<\/div>/g;
22
+ const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events|form)"[^>]*>\s*<\/div>/g;
23
23
 
24
24
  const esc = (s: string) =>
25
25
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -133,11 +133,12 @@ function eventCardAttrs(tag: string): EventCardAttrs {
133
133
 
134
134
  export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
135
135
  html = stampHeadingAnchors(html);
136
+ const hasForms = html.includes('data-dyn="form"');
136
137
  const hasPostList = html.includes('data-dyn="post-list"');
137
138
  const hasCalendar = html.includes('data-dyn="calendar"');
138
139
  const hasToc = html.includes('data-dyn="toc"');
139
140
  const hasEvents = html.includes('data-dyn="events"');
140
- if (!hasPostList && !hasCalendar && !hasToc && !hasEvents) return html;
141
+ if (!hasPostList && !hasCalendar && !hasToc && !hasEvents && !hasForms) return html;
141
142
  const [posts, settings] = await Promise.all([
142
143
  hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
143
144
  repo.getSettings(),
@@ -160,9 +161,19 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
160
161
  await Promise.all(tags.map(async (tag) => calendarHtml.set(tag, await renderEventCards(eventCardAttrs(tag), repo, settings, now))));
161
162
  }
162
163
 
164
+ if (hasForms) {
165
+ const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter(t => t.includes('data-dyn="form"')))];
166
+ await Promise.all(tags.map(async tag => {
167
+ const form = await repo.forms.get(attr(tag, 'form'));
168
+ // A same-origin frame gives each visitor an uncached form/receipt even
169
+ // when the containing content page is served from a shared cache.
170
+ calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
171
+ ? `<iframe class="pc-form-embed" src="/forms/${encodeURIComponent(form.id)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
172
+ }));
173
+ }
163
174
  const anchored = html;
164
175
  return html.replace(PLACEHOLDER, (tag) => {
165
- if (tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
176
+ if (tag.includes('data-dyn="form"') || tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
166
177
  if (tag.includes('data-dyn="toc"')) return renderToc(anchored, attr(tag, 'title'));
167
178
  const source = attr(tag, 'source') || 'latest';
168
179
  const tagName = attr(tag, 'tag');
@@ -1,3 +1,4 @@
1
+ import { FormDefinition } from '../forms/schema';
1
2
  import { z } from 'zod';
2
3
  import type { ContentRepository } from '../repo/types';
3
4
  import type { CalEvent, DocTranslations, Page, Post } from '../schema';
@@ -10,6 +11,7 @@ import { draftSavedAt, mergeEventDraft, mergePageDraft, mergePostDraft, pickEven
10
11
  import { localeHomeUrl } from '../i18n';
11
12
  import { pageQuery } from '../mobile/http';
12
13
  import { applyEventUpdate, applyPageUpdate, applyPostUpdate, saveEvent, savePage, savePost, type SaveHooks } from '../content-service';
14
+ import { defaultPlaceLocation } from '../maps/places';
13
15
 
14
16
  // Posts, pages and events for editor apps. Reads show the document as it
15
17
  // will look after Update (staged edits merged); writes go through the same
@@ -134,7 +136,9 @@ export async function createDoc(repo: ContentRepository, input: z.infer<typeof C
134
136
  catch (err) { throw new ApiError(400, err instanceof Error ? err.message : 'Invalid start time.'); }
135
137
  let slug = base;
136
138
  for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
137
- return repo.createEvent({ title: input.title, slug, start: times.start, end: times.end, allDay: input.allDay ?? !input.start.includes('T') });
139
+ // new events start at the site's default place, like the admin
140
+ const place = await defaultPlaceLocation(repo);
141
+ return repo.createEvent({ title: input.title, slug, start: times.start, end: times.end, allDay: input.allDay ?? !input.start.includes('T'), ...(place ? { location: place } : {}) });
138
142
  }
139
143
 
140
144
  // ---- save ----------------------------------------------------------------
@@ -148,10 +152,15 @@ export const PostFields = z.object({ ...common, excerpt: text(5000), tags: z.arr
148
152
  export const PageFields = z.object({ ...common, isHome: z.boolean(), locale: text(20) }).partial().strict();
149
153
  export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.array(text(100)).max(100), cover: text(2000), coverAlt: text(1000), featured: z.boolean(),
150
154
  start: text(40), end: text(40), allDay: z.boolean(), timezone: text(100), rrule: text(500), exdates: z.array(text(40)).max(500), cancelled: z.boolean(),
151
- location: z.object({ name: text(300), address: text(500), url: text(1000) }).strict(),
155
+ location: z.object({
156
+ name: text(300), address: text(500), url: text(1000),
157
+ // optional pin fields; omitted = kept while the text is unchanged
158
+ lat: z.number().min(-90).max(90).nullable(), lng: z.number().min(-180).max(180).nullable(),
159
+ placeId: text(100), showMap: z.boolean(), mapImage: text(500), mapImageDark: text(500),
160
+ }).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
152
161
  cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
153
162
  calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
154
- rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).strict(),
163
+ rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50), form: FormDefinition.optional() }).strict(),
155
164
  }).partial().strict();
156
165
  export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
157
166
 
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { FormDefinition } from '../forms/schema';
2
3
  import { Booking } from '../schema';
3
4
 
4
5
  // Response shapes of the editor-app API (OpenAPI components, client types).
@@ -40,8 +41,8 @@ export const EditorDocument = EditorDocumentSummary.extend({
40
41
  authorIds: z.array(str), locale: str.nullable(), isHome: bool, publishedAt: date.nullable(), scheduledAt: date.nullable(), createdAt: date,
41
42
  event: z.object({
42
43
  start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
43
- location: z.object({ name: str, address: str, url: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
44
- calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num }),
44
+ location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
45
+ calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, form: FormDefinition.optional() }),
45
46
  }).nullable(),
46
47
  languages: z.array(str),
47
48
  });
@@ -79,7 +80,7 @@ export const EditorMedia = z.object({
79
80
  });
80
81
  export const EditorMediaList = z.object({ items: z.array(EditorMedia), pagination: Pagination });
81
82
 
82
- export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true, notes: true })
83
+ export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true, notes: true })
83
84
  .extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date, updatedAt: date, title: str });
84
85
  export const EditorBookingList = z.object({ pending: num.int(), items: z.array(EditorBooking), pagination: Pagination });
85
86
 
@@ -0,0 +1,40 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import type { CalEvent } from '../schema';
3
+ import { ApiError } from '../api';
4
+ import { sha256Hex } from '../api-keys';
5
+ import { createRsvp } from '../booking/service';
6
+ import { collectResponse, formVersion } from './service';
7
+ import { starter } from './schema';
8
+ import { readAnswers } from './http';
9
+
10
+ // Older custom EventTemplate components only post name/email/seats. Keep
11
+ // that exact legacy case working; customized forms require versioned markup.
12
+ export async function prepareLegacyEventForm(event: CalEvent, start: number, data: FormData) {
13
+ if (!event.rsvp.form && !data.has('requestId') && !data.has('formVersion') && !data.has('occurrence')) {
14
+ data.set('requestId', crypto.randomUUID());
15
+ data.set('formVersion', await formVersion(starter('simple')));
16
+ data.set('occurrence', String(start));
17
+ }
18
+ }
19
+
20
+ export async function submitEventForm(repo: ContentRepository, event: CalEvent, occurrence: { start: number; end: number }, data: FormData, requestId: string, flow: Parameters<typeof createRsvp>[2]) {
21
+ if (event.status !== 'published') throw new ApiError(404, 'Event not found.');
22
+ if (Number(data.get('occurrence')) !== occurrence.start) throw new ApiError(409, 'The event time has changed. Review the date and try again.');
23
+ const seats = Number(data.get('seats') || 1);
24
+ if (!Number.isInteger(seats) || seats < 1 || seats > event.rsvp.maxSeats) throw new ApiError(400, 'Choose a valid number of seats.');
25
+ const definition = event.rsvp.form ?? starter('simple');
26
+ const booker = { name: String(data.get('name') || ''), email: String(data.get('email') || ''), answers: readAnswers(data, definition), formVersion: String(data.get('formVersion') || '') };
27
+ const idempotency = { keyHash: await sha256Hex(`event-form:${requestId}`), requestHash: await sha256Hex(JSON.stringify({ event: event.id, start: occurrence.start, seats, booker })) };
28
+ const replay = async () => {
29
+ const record = await repo.getBookingRequest(idempotency.keyHash);
30
+ if (!record) return null;
31
+ if (record.requestHash !== idempotency.requestHash) throw new ApiError(409, 'This registration was already sent with different details. Reload to register again.');
32
+ const booking = await repo.getBooking(record.bookingId);
33
+ if (!booking) throw new ApiError(410, 'This registration was removed. Reload to register again.');
34
+ return booking;
35
+ };
36
+ const existing = await replay(); if (existing) return existing;
37
+ await collectResponse(definition, booker.answers, booker.formVersion);
38
+ try { return await createRsvp(repo, { event, ...occurrence, seats, booker }, { ...flow, idempotency }); }
39
+ catch (e) { const existing = await replay(); if (existing) return existing; throw e; }
40
+ }
@@ -0,0 +1,62 @@
1
+ // Pure helpers for the admin Responses screen and its CSV export. Kept out of
2
+ // service.ts so the public submit path stays small.
3
+ import type { FormField, FormSubmission } from './schema';
4
+
5
+ type Field = Pick<FormField, 'id' | 'type' | 'label'>;
6
+
7
+ // Questions that collect an answer (section headings are layout only).
8
+ export const answerFields = <F extends Field>(fields: F[]) => fields.filter(f => (f.type as string) !== 'heading');
9
+
10
+ // One spreadsheet cell: yes/no for consent-style boxes, multi-choice answers
11
+ // (stored newline-separated) joined with commas; paragraphs keep their lines.
12
+ export function cellText(field: Pick<FormField, 'type'> | undefined, value: string | undefined): string {
13
+ if (!field) return '';
14
+ const type = field.type as string;
15
+ if (type === 'checkbox' || type === 'consent') return value === 'yes' ? 'Yes' : 'No';
16
+ if (type === 'textarea') return value ?? '';
17
+ return (value ?? '').split(/\r?\n/).map(s => s.trim()).filter(Boolean).join(', ');
18
+ }
19
+
20
+ // v1…vN in first-seen order; the live definition is the next number when no
21
+ // response has used it yet. Unknown hashes fall back to a short hash.
22
+ export function versionLabeler(seen: string[], current?: string) {
23
+ const order = [...seen];
24
+ if (current && !order.includes(current)) order.push(current);
25
+ const index = new Map(order.map((v, i) => [v, i + 1]));
26
+ return (v: string) => (index.has(v) ? `v${index.get(v)}` : v.slice(0, 7));
27
+ }
28
+
29
+ // Wide export: one row per response. Columns are the current questions first,
30
+ // then questions only found in older snapshots (labelled from the newest one).
31
+ export function wideRows(current: Field[], rows: FormSubmission[], version: (v: string) => string = v => v.slice(0, 7)): string[][] {
32
+ const cols: { id: string; label: string }[] = answerFields(current).map(f => ({ id: f.id, label: f.label }));
33
+ const known = new Set(cols.map(c => c.id));
34
+ for (const r of rows) for (const f of answerFields(r.response.definition.fields)) {
35
+ if (known.has(f.id)) continue;
36
+ known.add(f.id); cols.push({ id: f.id, label: f.label });
37
+ }
38
+ const used = new Set(['Reference', 'Submitted', 'Version']);
39
+ const header = cols.map(c => {
40
+ const label = used.has(c.label) ? `${c.label} (${c.id})` : c.label;
41
+ used.add(label); return label;
42
+ });
43
+ return [
44
+ ['Reference', 'Submitted', 'Version', ...header],
45
+ ...rows.map(r => {
46
+ const byId = new Map(r.response.definition.fields.map(f => [f.id, f]));
47
+ return [r.id, new Date(r.createdAt).toISOString(), version(r.response.version), ...cols.map(c => cellText(byId.get(c.id), r.response.answers[c.id]))];
48
+ }),
49
+ ];
50
+ }
51
+
52
+ export function relativeTime(ts: number, now = Date.now(), timeZone?: string): string {
53
+ const s = Math.floor((now - ts) / 1000);
54
+ if (s < 60) return 'Just now';
55
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
56
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
57
+ if (s < 86400 * 2) return 'Yesterday';
58
+ if (s < 86400 * 7) return `${Math.floor(s / 86400)}d ago`;
59
+ const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', ...(new Date(ts).getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) };
60
+ try { return new Date(ts).toLocaleDateString('en-US', { ...opts, timeZone }); }
61
+ catch { return new Date(ts).toLocaleDateString('en-US', opts); }
62
+ }