@acms-subir/core 0.3.46 → 0.3.48

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 (51) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +76 -11
  8. package/src/components/EventPage.astro +18 -12
  9. package/src/components/calendar/CalendarBoard.tsx +43 -19
  10. package/src/components/editor/event/EventSheet.tsx +2 -0
  11. package/src/components/editor/event/SingleDates.tsx +123 -0
  12. package/src/lib/booking/holds.ts +7 -7
  13. package/src/lib/calendar/api.ts +11 -1
  14. package/src/lib/calendar/board.ts +7 -3
  15. package/src/lib/calendar/cards.ts +14 -11
  16. package/src/lib/calendar/change-migration.ts +40 -15
  17. package/src/lib/calendar/data.ts +34 -12
  18. package/src/lib/calendar/event-words.ts +133 -0
  19. package/src/lib/calendar/events.ts +94 -9
  20. package/src/lib/calendar/feeds.ts +11 -3
  21. package/src/lib/calendar/filter.ts +140 -0
  22. package/src/lib/calendar/ics-write.ts +25 -2
  23. package/src/lib/calendar/moves.ts +291 -0
  24. package/src/lib/calendar/render.ts +26 -18
  25. package/src/lib/calendar/schedule.ts +73 -61
  26. package/src/lib/email/booking-words.ts +7 -1
  27. package/src/lib/email/service.ts +6 -1
  28. package/src/lib/email/templates.ts +6 -2
  29. package/src/lib/mobile/calendar.ts +32 -14
  30. package/src/lib/mobile/http.ts +5 -3
  31. package/src/lib/mobile/schemas.ts +7 -3
  32. package/src/lib/push/service.ts +44 -10
  33. package/src/lib/push/types.ts +2 -0
  34. package/src/lib/repo/migrations.ts +5 -2
  35. package/src/lib/repo/sql.ts +28 -10
  36. package/src/lib/repo/types.ts +6 -3
  37. package/src/lib/schema.ts +21 -0
  38. package/src/lib/sections.ts +5 -2
  39. package/src/lib/sync/catalog.ts +1 -0
  40. package/src/pages/api/calendar/events/index.ts +24 -12
  41. package/src/pages/calendar/[slug].ics.ts +8 -2
  42. package/src/pages/calendar/feed.ics.ts +9 -2
  43. package/src/pages/calendar/index.astro +104 -48
  44. package/src/pages/embed/calendar/[slug].astro +29 -9
  45. package/src/pages/members/_Layout.astro +1 -0
  46. package/src/pages/members/events/index.astro +1 -1
  47. package/src/server/index.ts +33 -0
  48. package/src/styles/editor.css +19 -0
  49. package/src/styles/schedule.css +2 -0
  50. package/src/styles/site.css +16 -0
  51. package/src/theme-sdk/calendar.ts +3 -2
@@ -1,36 +1,56 @@
1
1
  ---
2
2
  import Site from '../../../layouts/Site.astro';
3
3
  import { getRepo } from '../../../lib/repo';
4
- import { CALENDAR } from '../../../lib/calendar/config';
4
+ import { siteLang } from '../../../lib/dates';
5
+ import { I18N, localePath } from '../../../lib/i18n';
6
+ import { isExtraLocale } from '../../../lib/translations';
7
+ import { eventStrings } from '../../../lib/calendar/event-words';
8
+ import { CALENDAR, eventUrl } from '../../../lib/calendar/config';
5
9
  import { calendarWindow, renderCalendarView } from '../../../lib/calendar/render';
6
10
  import { collectViewEvents } from '../../../lib/calendar/data';
7
11
  import { upcomingEvents, eventCardHtml } from '../../../lib/calendar/cards';
12
+ import { readEventFilter, resolveEventFilter } from '../../../lib/calendar/filter';
8
13
 
9
14
  // Embeddable calendar for other websites:
10
15
  // <iframe src="https://site/embed/calendar/all?view=month" …>
11
16
  // slug 'all' = every public calendar. view: month | week | list | cards.
12
- // Links open in the parent window; height is posted to the parent so a
13
- // small script can auto-size the iframe.
17
+ // The /calendar page's filters work here too (?tag=Class, ?notag=,
18
+ // ?cal=a,b to add calendars, ?room=), and ?lang=es picks one of the site's
19
+ // languages (embeds are never under /es). Links open in the parent window;
20
+ // height is posted to the parent so a small script can auto-size the iframe.
14
21
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
15
22
  const repo = await getRepo(Astro.locals);
16
- const settings = await repo.getSettings();
23
+ const base = await repo.getSettings();
17
24
  const slug = Astro.params.slug ?? 'all';
18
25
  const cal = slug === 'all' ? null : await repo.getCalendarBySlug(slug);
19
26
  if (slug !== 'all' && (!cal || cal.visibility === 'private')) return new Response(null, { status: 404 });
27
+ const langParam = Astro.url.searchParams.get('lang') ?? '';
28
+ const reqLocale = I18N.enabled ? (I18N.locales.includes(langParam) ? langParam : I18N.defaultLocale) : '';
29
+ // the layout's <html lang> reads the request's language
30
+ if (reqLocale) (Astro.locals as { acmsLocale?: string }).acmsLocale = reqLocale;
31
+ const settings = isExtraLocale(reqLocale) ? { ...base, locale: reqLocale } : base;
32
+ const lang = reqLocale || siteLang(settings);
33
+ const w = eventStrings(lang).calendar;
34
+
35
+ const query = readEventFilter(Astro.url.searchParams);
36
+ const filter = cal ? { ...query, cal: [cal.slug, ...query.cal] } : query;
37
+ const [calendars, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
38
+ const resolved = resolveEventFilter(filter, { calendars, resources });
20
39
  const view = (['month', 'week', 'list', 'cards'] as const).find((v) => v === Astro.url.searchParams.get('view')) ?? 'month';
21
40
  const now = Date.now();
22
41
  let html: string;
23
42
  if (view === 'cards') {
24
- const items = await upcomingEvents(repo, settings, now, { calendarIds: cal ? [cal.id] : undefined, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
25
- html = `<section class="pc-ev-cards" data-layout="grid">${items.map((o) => eventCardHtml(o, settings, { showImage: true })).join('') || '<p class="pc-ev-empty">No upcoming events.</p>'}</section>`;
43
+ const items = await upcomingEvents(repo, settings, now, { filter: resolved, locale: lang, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
44
+ const cards = items.map((o) => eventCardHtml(o, settings, { showImage: true, href: localePath(eventUrl(o.event.slug), reqLocale) })).join('');
45
+ html = '<section class="pc-ev-cards" data-layout="grid">' + (cards || '<p class="pc-ev-empty">' + w.noUpcoming + '</p>') + '</section>';
26
46
  } else {
27
- const { events, calendars } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', ...(cal ? { calendarIds: [cal.id] } : {}) });
28
- html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: !cal && calendars.length > 1 });
47
+ const { events, calendars: shown } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', filter: resolved, locale: lang });
48
+ html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: shown.length > 1 });
29
49
  }
30
50
  Astro.response.headers.set('Content-Security-Policy', "frame-ancestors *");
31
51
  ---
32
52
 
33
- <Site title={cal?.name ?? 'Calendar'} bare noindex>
53
+ <Site title={cal?.name ?? w.title} bare noindex>
34
54
  <base target="_top" />
35
55
  <div class="pc-embed" set:html={html} />
36
56
  <script is:inline>
@@ -62,6 +62,7 @@ const { title, user } = Astro.props;
62
62
  .pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
63
63
  .pc-mem-dates li.is-cancelled > span { color: var(--muted); }
64
64
  .pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
65
+ .pc-mem-dates em.is-moved { color: var(--accent); font-weight: 500; }
65
66
  .pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
66
67
  .pc-mem-past .pc-mem-list { margin-top: 10px; }
67
68
  /* the request form */
@@ -175,7 +175,7 @@ const pastShown = past.slice(0, 10);
175
175
  const off = e.cancelledDates.includes(o.key);
176
176
  return (
177
177
  <li class={o.cancelled ? 'is-cancelled' : undefined}>
178
- <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}</span>
178
+ <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>Moved from {when({ ...o, start: o.moved.from.start, end: o.moved.from.end })}</em>}</span>
179
179
  {e.rrule && !e.cancelled && o.start > now && (
180
180
  <form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
181
181
  <input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
@@ -37,6 +37,7 @@ export interface ServerUser {
37
37
  export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
39
  export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
40
+ export type { MoveInput, MoveResult } from '../lib/calendar/moves';
40
41
 
41
42
  export interface ListEventsOptions {
42
43
  /** only published events (default: every event except archived ones) */
@@ -172,6 +173,38 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
172
173
  return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
173
174
  }
174
175
 
176
+ // ---- moved dates -------------------------------------------------------------
177
+ // One date of a repeating event at another time (and maybe other rooms or
178
+ // another place), keeping its key, its RSVPs (moved with it; each person gets
179
+ // the "new time" email with their cancel link) and its hosts. These check who
180
+ // is acting: staff ("Create and edit content") and GOOGLE_SYNC may move any
181
+ // date, and a room taken at the new time comes back in `clashes` as a
182
+ // warning; a host of the event may move its dates only while the rooms are
183
+ // free (409 otherwise), and staff are emailed about it.
184
+
185
+ /** The actor for a calendar sync acting with staff rights (a change already made elsewhere): clashes only warn. */
186
+ export const GOOGLE_SYNC = 'google-sync';
187
+
188
+ /**
189
+ * Move one date (by its occurrence key) to `input.start` (and `end`, else it
190
+ * keeps its length; wall-clock in the event's timezone, like `start`).
191
+ * `roomIds`/`location`/`note` replace that date's own (omit to keep them).
192
+ * Applies at once, even on a published event. `quiet` skips the app notice
193
+ * and the staff email (a sync catching up); RSVP emails still go. Errors have
194
+ * a `status`: 400 (not a repeating event, not one of its dates, started or a
195
+ * past time), 401, 404, 409 (a cancelled date, or a taken room for a host).
196
+ */
197
+ export async function moveOccurrence(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
198
+ const { moveOccurrence: move } = await import('../lib/calendar/moves');
199
+ return move(internal(repo), who, eventId, occurrenceKey, input, { url: context.url, locals: context.locals }, opts);
200
+ }
201
+
202
+ /** Put a moved date back where the series has it. Same rules; idempotent. */
203
+ export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
204
+ const { moveOccurrenceBack: back } = await import('../lib/calendar/moves');
205
+ return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
206
+ }
207
+
175
208
  // ---- rooms -----------------------------------------------------------------
176
209
  // Events hold rooms (CalEvent.roomHold) while they happen. These find where
177
210
  // two things want the same room. They don't check permissions, and their
@@ -1328,3 +1328,22 @@ html.pe-evr-lock { overflow: hidden; }
1328
1328
  .pe-evs-wrap { --evs-pad: 14px; padding-top: 18px; }
1329
1329
  .pe-evsec input:where(:not([type='checkbox'], [type='radio'])), .pe-evsec select, .pe-evsec textarea, .pe-loc-search input, .pe-evsec input.mono { font-size: 16px; }
1330
1330
  }
1331
+
1332
+ /* Event settings → Single dates (SingleDates.tsx): move or cancel one date */
1333
+ .pe-evd { list-style: none; margin: 0; padding: 0; }
1334
+ .pe-evd-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; padding: 8px 0; border-top: 1px solid var(--line); }
1335
+ .pe-evd-row:first-child { border-top: 0; }
1336
+ .pe-evd-main { display: flex; flex-direction: column; min-width: 0; font-size: 13.5px; font-variant-numeric: tabular-nums; }
1337
+ .pe-evd-state { font-size: 12px; color: var(--text-2); }
1338
+ .pe-evd-row.is-cancelled .pe-evd-when { color: var(--muted); }
1339
+ .pe-evd-row.is-cancelled .pe-evd-state { color: var(--red); }
1340
+ .pe-evd-row.is-moved .pe-evd-state { color: var(--accent); }
1341
+ .pe-evd-acts { display: flex; flex-wrap: wrap; gap: 6px; }
1342
+ .pe-evd-form { flex-basis: 100%; display: flex; flex-direction: column; gap: 10px; margin: 4px 0 6px; padding: 12px; border-radius: 10px; background: var(--panel-2, var(--soft)); }
1343
+ .pe-evd-fields { display: flex; flex-wrap: wrap; gap: 10px; }
1344
+ .pe-evd-fields .pe-field { flex: 1 1 130px; margin: 0; }
1345
+ .pe-evd-rooms { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; }
1346
+ .pe-evd-rooms legend { font-size: 12px; color: var(--text-2); margin-bottom: 4px; padding: 0; }
1347
+ .pe-evd-room { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
1348
+ .pe-evd-formacts { display: flex; gap: 8px; }
1349
+ .pe-ev-note.is-error { color: var(--red); }
@@ -205,6 +205,8 @@ kbd { font: 10.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--
205
205
  .sch-toast button { border: 0; background: none; color: var(--accent); font: inherit; font-weight: 650; cursor: pointer; padding: 0; }
206
206
  :root[data-theme='dark'] .sch-toast { background: #2e2b27; color: var(--text); border: 1px solid var(--line-strong); }
207
207
  .sch-toast.err { background: var(--red); color: #fff; }
208
+ /* a room clash after a move: both events keep the room */
209
+ .sch-toast-warn { display: block; max-width: 340px; font-size: 12px; color: var(--amber, #c98a16); }
208
210
 
209
211
  /* ---- quick add ---- */
210
212
  .qa-layer { position: fixed; inset: 0; z-index: 75; }
@@ -806,6 +806,10 @@ li::marker { color: var(--muted); }
806
806
  @layer base {
807
807
  .pc-cal-event.is-cancelled b, .pc-cal-row.is-cancelled .ti { text-decoration: line-through; color: var(--cal-muted); }
808
808
  .pc-cal-pop-flag { display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--danger); margin-bottom: 3px; }
809
+ /* a moved date: flagged, never struck through */
810
+ .pc-cal-pop-flag.is-moved, .pc-ev-flag.is-moved { color: var(--accent); }
811
+ .pc-cal-pop-moved { font-size: 12.5px; color: var(--cal-muted); margin-top: 2px; }
812
+ .pc-cal-event.is-moved b::before { content: '↷ '; }
809
813
  .pc-cal-pop-link { display: inline-block; margin-top: 9px; font-weight: 600; font-size: 12.5px; color: var(--cal-accent); text-decoration: none; }
810
814
  .pc-cal-pop-link:hover { text-decoration: underline; }
811
815
 
@@ -867,6 +871,8 @@ li::marker { color: var(--muted); }
867
871
  .pc-ev-page.is-cancelled .pc-ev-title { text-decoration: line-through; text-decoration-thickness: 2px; color: var(--muted); }
868
872
  .pc-ev-cancelled { background: color-mix(in srgb, var(--danger) 10%, var(--panel)); border-radius: var(--r-sm); padding: 10px 14px; font-size: 14.5px; margin: 0 0 16px; }
869
873
  .pc-ev-cancelled b { color: var(--danger); }
874
+ .pc-ev-moved { background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border-radius: var(--r-sm); padding: 10px 14px; font-size: 14.5px; margin: 0 0 16px; }
875
+ .pc-ev-moved b { color: var(--accent); }
870
876
  .pc-ev-lede { font-size: 19px; line-height: 1.55; color: var(--muted); margin: 0 0 22px; }
871
877
  .pc-ev-side {
872
878
  position: sticky; top: 96px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
@@ -930,6 +936,16 @@ li::marker { color: var(--muted); }
930
936
  .pc-calpage-chips a:hover { color: var(--ink); }
931
937
  .pc-calpage-chips a.is-on { color: var(--ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--ink); }
932
938
  .pc-calpage-chips i { width: 7px; height: 7px; border-radius: 2px; }
939
+ /* active filters from the address (tag, left-out tag, room): removable chips */
940
+ .pc-calpage-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 16px; }
941
+ .pc-calpage-filter {
942
+ display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 11px; border-radius: var(--r-sm);
943
+ background: var(--soft); color: var(--ink); font-size: 13px; font-weight: 550; text-decoration: none;
944
+ }
945
+ .pc-calpage-filter svg { width: 13px; height: 13px; color: var(--muted); }
946
+ .pc-calpage-filter:hover svg { color: var(--ink); }
947
+ .pc-calpage-clear { padding: 0 6px; font-size: 13px; color: var(--muted); text-decoration: none; }
948
+ .pc-calpage-clear:hover { color: var(--ink); }
933
949
  .pc-calpage-tools { display: flex; gap: 8px; align-items: center; margin-left: auto; }
934
950
  .pc-calpage-views { display: inline-flex; background: var(--soft); border-radius: 10px; padding: 3px; }
935
951
  .pc-calpage-views a { padding: 5px 12px; border-radius: 7px; font-size: 13px; font-weight: 550; color: var(--muted); text-decoration: none; }
@@ -29,9 +29,10 @@ export interface UpcomingEventsOptions {
29
29
  /**
30
30
  * Occurrence start/end are Unix milliseconds; event retains its authored
31
31
  * fields except private custom fields. `cancelled` is true when the whole
32
- * event or just this date is cancelled.
32
+ * event or just this date is cancelled. `moved` is set when this date of a
33
+ * series was moved here: `from` (its old start and end) and staff's `note`.
33
34
  */
34
- export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>;
35
+ export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>;
35
36
 
36
37
  /** Server-side helper for Astro templates and endpoints. No repository access needed. */
37
38
  export async function upcomingEvents(