@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.
- package/CHANGELOG.md +47 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +14 -11
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +34 -12
- package/src/lib/calendar/event-words.ts +133 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +26 -18
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +32 -14
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/events/index.ts +24 -12
- package/src/pages/calendar/[slug].ics.ts +8 -2
- package/src/pages/calendar/feed.ics.ts +9 -2
- package/src/pages/calendar/index.astro +104 -48
- package/src/pages/embed/calendar/[slug].astro +29 -9
- package/src/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +16 -0
- package/src/theme-sdk/calendar.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.48
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9505051: Moved dates: one date of a repeating event can move to another day, time or room and stay that date, keeping its key, its sign-ups, its hosts and its page. Moving one date used to split it off into a copy.
|
|
8
|
+
|
|
9
|
+
**Where staff move a date:**
|
|
10
|
+
|
|
11
|
+
- the event editor's new **Single dates** list: the next dates, each with **Move…** (date, times, rooms for that date and a public note), **Cancel date**, and **Move back** or **Restore**. These apply at once, even on a published event, and leave unpublished edits alone;
|
|
12
|
+
- the Schedule: dragging one date of a series stores a moved date, and in the week view a drag moves by time as well as day (in 15-minute steps, whole series too). **Undo** reverses it, and a moved date's pop-up shows "Moved here from Thu, Jan 10 · 6:00 PM" with **Move back**. When another event, booking or block holds the room at the new time, the toast says so.
|
|
13
|
+
|
|
14
|
+
**What follows a moved date:**
|
|
15
|
+
|
|
16
|
+
- It comes out at its new time everywhere with its original key and `moved: { from: { start, end }, note }`. It's labelled **Moved** with the old time and the note on the event page ("Moved from Thu, Jan 10 · 6:00 PM."), on `/calendar` and event cards, and on the hosts' My events. The words are in English, Spanish, French, German and Portuguese.
|
|
17
|
+
- `?on=` finds it by its new day or the day it moved from.
|
|
18
|
+
- `calendar.ics` and the event's `.ics` get an override (`RECURRENCE-ID` of the old date with the new `DTSTART`), so calendar apps move that one date.
|
|
19
|
+
- Its RSVPs move with it. Each person with a seat gets a "This date moved" email in their own language, with the note and the button to view or cancel their spot; places in line move quietly. Reminders and "starting soon" go at the new time.
|
|
20
|
+
- Its room hold moves to the new time, and to the date's own rooms when it moved into others.
|
|
21
|
+
- The "When an event changes" push rule announces it, keeping the 10-minute merge: "Music class · Moved to Wed, Jan 9, 5:00 PM (was Thu, Jan 10, 6:00 PM)". Moving it back is announced too, and several moved dates are listed by day.
|
|
22
|
+
|
|
23
|
+
A date is moved or cancelled, never both. Cancelling a moved date cancels it at its new time and drops the move; a cancelled date must be restored before it can move. Moving the whole series keeps a moved date at its new time, under its new key.
|
|
24
|
+
|
|
25
|
+
**API:**
|
|
26
|
+
|
|
27
|
+
- `/api/v1/events`, `/api/v1/events/:slug`, `/api/v1/search` and `/api/calendar/events` occurrences carry `moved` (`null` when not moved), and `/api/calendar/events` occurrences also carry their `key`.
|
|
28
|
+
- Events carry the public `movedDates` (`{ start, end, note }` by original key). Staff callers (an `events:read` API key, the agent tools) also see the date's rooms, place and who moved it. Moves fire the `event.updated` webhook.
|
|
29
|
+
- New in `@acms-subir/core/server`:
|
|
30
|
+
- `moveOccurrence(repo, who, eventId, key, { start, end?, roomIds?, location?, note? }, ctx, { quiet? })` and `moveOccurrenceBack(...)`. Both check who is acting: staff ("Create and edit content") and the new `GOOGLE_SYNC` actor (a calendar sync acting with staff rights) may move any date, and get a room taken at the new time back as `clashes`, a warning. A host of the event may move its dates only while the rooms are free (409 otherwise), and staff (`members.notify`, else the owners) are emailed "Date moved".
|
|
31
|
+
- `quiet: true` skips the push and that staff email, for a sync catching up; the RSVP emails still go.
|
|
32
|
+
- Moving a date to where it already is returns `changed: false` and writes, sends and emails nothing, so a sync can retry safely. Cancelling an already cancelled date, or restoring one that isn't cancelled, is likewise a no-op, now even after the date has passed.
|
|
33
|
+
- Refusals carry a `code` beside the `status`: `one-off`, `not-a-date`, `started`, `past`, `original-passed`, `bad-time`, `cancelled-event`, `cancelled-date`, `room-taken`. The table is in `npx acms docs calendar`.
|
|
34
|
+
- Occurrences carry `moved`, calendar views carry `key` and `moved`, and holds follow a moved date's own rooms.
|
|
35
|
+
|
|
36
|
+
Migration 66 adds `events.moved_dates`, a column on the events row, so row-level triggers on `events` see moves. It recreates the `event_changes` triggers (from 0.3.46) with it, so a move or move-back sends `event.updated` and the change push through that queue, and a quiet move is recorded as quiet. Moves are included in backups and environment sync. `npx acms docs calendar` has a Moved dates section, and `npx acms docs server` covers the two new functions.
|
|
37
|
+
|
|
38
|
+
## 0.3.47
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- 26f2060: The public calendar can be filtered from its address, and speaks the page's language.
|
|
43
|
+
|
|
44
|
+
**Filters.** `/calendar?tag=Class` shows only classes, `/calendar?notag=Class&cal=events` shows the Events calendar without them, and `/calendar?room=chapel` shows what happens in a room. `tag` keeps events with any of its tags, `notag` (alias `excludeTag`) leaves them out, `cal` (alias `calendar`) now takes several calendars, and `room` takes room slugs. Each can repeat (`tag=a&tag=b`) or be comma-separated (`tag=a,b`); different filters must all match. Tags keep their rules (a name in any case, or its slug; internal `#` tags never match publicly). Only rooms shown on public events match, and a private calendar or a hidden room answers exactly like one that doesn't exist, so a link can't probe for them. Events from linked feed calendars and "Reserved" booking blocks have no tags: `tag` leaves them out, `notag` keeps them, and a room filter keeps a room's own Reserved blocks.
|
|
45
|
+
|
|
46
|
+
On `/calendar` the calendar chips now switch calendars on and off, so several can be picked; tags, left-out tags and rooms show as removable chips with **Clear filters**; and view links, chips and **Subscribe** keep the filters. A filtered page subscribes to `/calendar.ics` with the same filters (one calendar alone keeps its own feed). The same parameters work on `/embed/calendar/<slug|all>` (`cal` adds calendars to the one in the address), `/calendar.ics`, `/calendar/<slug>.ics` (`tag`, `notag`, `room`), `/api/v1/events` (now documented in the OpenAPI document; repeating them is allowed, other parameters still may not repeat) and `/api/calendar/events`, where a key with `events:read` also matches private calendars, every room and `#` tags. An unknown, private or hidden calendar or room is a 404 on the APIs and feeds, and an empty calendar with its chip on the page.
|
|
47
|
+
|
|
48
|
+
**Languages.** `/es/calendar` is in Spanish like event pages: the headings, chips, views, Subscribe menu, agenda, empty states, the grid's Today/Upcoming/Details and the dates and time zone. Event titles use their translations, and every link on the page (views, chips, events, cards) stays under `/es`. French, German and Portuguese work the same way. Embeds take `?lang=es` (one of the site's languages). The calendar card ("No calendar connected", "Calendar unavailable right now", "Open in Google Calendar") and event cards ("Untitled event", "No upcoming events") also speak their dates' language.
|
|
49
|
+
|
|
3
50
|
## 0.3.46
|
|
4
51
|
|
|
5
52
|
### Patch Changes
|
|
@@ -84,24 +84,24 @@ function trigger(name, action, table, when, event, data) {
|
|
|
84
84
|
function content(kind) {
|
|
85
85
|
const table = `${kind}s`;
|
|
86
86
|
const fields = kind === "post" ? ["collection", "slug", "title", "status", "body", "body_json", "body_html", "excerpt", "tags", "cover", "cover_alt", "seo_title", "seo_desc", "featured", "author_id", "template", "published_at"] : ["slug", "title", "status", "is_home", "locale", "blocks", "body_json", "body_html", "seo_title", "seo_desc", "template"];
|
|
87
|
-
const
|
|
87
|
+
const snapshot = (row) => `json_object('id',${row}.id,'slug',${row}.slug,'title',${row}.title,'status',${row}.status,
|
|
88
88
|
${kind === "post" ? `'collection',${row}.collection` : `'locale',${row}.locale,'isHome',json(CASE WHEN ${row}.is_home THEN 'true' ELSE 'false' END)`})`;
|
|
89
|
-
const
|
|
89
|
+
const changed = fields.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(" OR ");
|
|
90
90
|
const event = `CASE WHEN OLD.status!='published' THEN '${kind}.published'
|
|
91
91
|
WHEN NEW.status!='published' THEN '${kind}.unpublished' ELSE '${kind}.updated' END`;
|
|
92
92
|
return [
|
|
93
|
-
trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${
|
|
93
|
+
trigger(`webhook_${kind}_insert`, "INSERT", table, "NEW.status='published'", `'${kind}.published'`, `json_object('${kind}',${snapshot("NEW")})`),
|
|
94
94
|
trigger(
|
|
95
95
|
`webhook_${kind}_update`,
|
|
96
96
|
"UPDATE",
|
|
97
97
|
table,
|
|
98
|
-
`(OLD.status='published' OR NEW.status='published') AND (${
|
|
98
|
+
`(OLD.status='published' OR NEW.status='published') AND (${changed})`,
|
|
99
99
|
event,
|
|
100
|
-
`json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${
|
|
101
|
-
ELSE json_set(${
|
|
102
|
-
'previous',json(CASE WHEN OLD.status='published' THEN ${
|
|
100
|
+
`json_object('${kind}',json(CASE WHEN NEW.status='published' THEN ${snapshot("NEW")}
|
|
101
|
+
ELSE json_set(${snapshot("OLD")},'$.status',NEW.status) END),
|
|
102
|
+
'previous',json(CASE WHEN OLD.status='published' THEN ${snapshot("OLD")} ELSE 'null' END))`
|
|
103
103
|
),
|
|
104
|
-
trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${
|
|
104
|
+
trigger(`webhook_${kind}_delete`, "DELETE", table, "OLD.status='published'", `'${kind}.deleted'`, `json_object('${kind}',${snapshot("OLD")})`)
|
|
105
105
|
];
|
|
106
106
|
}
|
|
107
107
|
var media = (row) => `json_object('media',json_object('id',${row}.id,'key',${row}.key,'filename',${row}.filename,
|
|
@@ -220,7 +220,7 @@ var ROOM_HOLD_MIGRATION = { version: 41, statements: [
|
|
|
220
220
|
|
|
221
221
|
// src/lib/calendar/change-migration.ts
|
|
222
222
|
var now4 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
223
|
-
var
|
|
223
|
+
var V65_SNAPSHOT = [
|
|
224
224
|
"id",
|
|
225
225
|
"slug",
|
|
226
226
|
"title",
|
|
@@ -251,7 +251,7 @@ var EVENT_CHANGE_SNAPSHOT = [
|
|
|
251
251
|
"created_at",
|
|
252
252
|
"updated_at"
|
|
253
253
|
];
|
|
254
|
-
var
|
|
254
|
+
var V65_COLUMNS = [
|
|
255
255
|
"slug",
|
|
256
256
|
"title",
|
|
257
257
|
"body_json",
|
|
@@ -283,12 +283,25 @@ var EVENT_CHANGE_COLUMNS = [
|
|
|
283
283
|
"start_at",
|
|
284
284
|
"end_at"
|
|
285
285
|
];
|
|
286
|
-
var
|
|
286
|
+
var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates"];
|
|
287
|
+
var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates"];
|
|
288
|
+
var snapshotOf = (cols, row) => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(",")})`;
|
|
287
289
|
var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
|
|
288
290
|
var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
|
|
289
291
|
var record = (id, before, after) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
|
|
290
292
|
VALUES (${id},${before},${after},${quiet},${now4});`;
|
|
291
|
-
|
|
293
|
+
function eventChangeTriggers(snapshotCols, changedCols) {
|
|
294
|
+
const changed = changedCols.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
|
|
295
|
+
return [
|
|
296
|
+
`CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
|
|
297
|
+
BEGIN ${record("NEW.id", "NULL", snapshotOf(snapshotCols, "NEW"))} END`,
|
|
298
|
+
`CREATE TRIGGER event_changes_update AFTER UPDATE ON events
|
|
299
|
+
WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
|
|
300
|
+
BEGIN ${record("NEW.id", snapshotOf(snapshotCols, "OLD"), snapshotOf(snapshotCols, "NEW"))} END`,
|
|
301
|
+
`CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
|
|
302
|
+
BEGIN ${record("OLD.id", snapshotOf(snapshotCols, "OLD"), "NULL")} END`
|
|
303
|
+
];
|
|
304
|
+
}
|
|
292
305
|
var skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
|
|
293
306
|
var ledger = `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
294
307
|
SELECT 'event:'||NEW.id,'event',NEW.id,NULL,${skipped},NULL,${now4},${now4}
|
|
@@ -302,19 +315,22 @@ var EVENT_CHANGES_MIGRATION = { version: 65, statements: [
|
|
|
302
315
|
`CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
|
|
303
316
|
`CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
|
|
304
317
|
`INSERT INTO push_control (id,quiet) VALUES (1,0)`,
|
|
305
|
-
|
|
306
|
-
BEGIN ${record("NEW.id", "NULL", snapshot("NEW"))} END`,
|
|
307
|
-
`CREATE TRIGGER event_changes_update AFTER UPDATE ON events
|
|
308
|
-
WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
|
|
309
|
-
BEGIN ${record("NEW.id", snapshot("OLD"), snapshot("NEW"))} END`,
|
|
310
|
-
`CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
|
|
311
|
-
BEGIN ${record("OLD.id", snapshot("OLD"), "NULL")} END`,
|
|
318
|
+
...eventChangeTriggers(V65_SNAPSHOT, V65_COLUMNS),
|
|
312
319
|
`DROP TRIGGER push_event_insert`,
|
|
313
320
|
`DROP TRIGGER push_event_publish`,
|
|
314
321
|
`CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
|
|
315
322
|
`CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
|
|
316
323
|
WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`
|
|
317
324
|
] };
|
|
325
|
+
var MOVED_DATES_MIGRATION = (contentVersionUpdate) => ({ version: 66, statements: [
|
|
326
|
+
`ALTER TABLE events ADD COLUMN moved_dates TEXT NOT NULL DEFAULT '{}'`,
|
|
327
|
+
`DROP TRIGGER content_version_events_update`,
|
|
328
|
+
contentVersionUpdate,
|
|
329
|
+
`DROP TRIGGER event_changes_insert`,
|
|
330
|
+
`DROP TRIGGER event_changes_update`,
|
|
331
|
+
`DROP TRIGGER event_changes_delete`,
|
|
332
|
+
...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates"], [...V65_COLUMNS, "moved_dates"])
|
|
333
|
+
] });
|
|
318
334
|
|
|
319
335
|
// src/lib/maps/migration.ts
|
|
320
336
|
var PLACES_MIGRATION = { version: 43, statements: [
|
|
@@ -566,8 +582,8 @@ var CONTENT_VERSION_COLUMNS = {
|
|
|
566
582
|
};
|
|
567
583
|
var BUMP_CONTENT_VERSION = "UPDATE settings SET content_version = content_version + 1 WHERE id = 1;";
|
|
568
584
|
function contentVersionTriggers(table, columns) {
|
|
569
|
-
const
|
|
570
|
-
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${
|
|
585
|
+
const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(" OR ");
|
|
586
|
+
const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(", ")} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
|
|
571
587
|
if (table === "settings") return [update];
|
|
572
588
|
return [
|
|
573
589
|
`CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
|
|
@@ -1247,12 +1263,15 @@ var MIGRATIONS = [
|
|
|
1247
1263
|
] },
|
|
1248
1264
|
// every change to a published event, from any write path, queued for its
|
|
1249
1265
|
// side effects (webhooks, "event changed" pushes, slug redirects)
|
|
1250
|
-
EVENT_CHANGES_MIGRATION
|
|
1266
|
+
EVENT_CHANGES_MIGRATION,
|
|
1267
|
+
// single dates of a series moved (CalEvent.movedDates): a column on the
|
|
1268
|
+
// row, so the triggers on events (content version, event_changes) see a move
|
|
1269
|
+
MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2])
|
|
1251
1270
|
];
|
|
1252
1271
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1253
1272
|
...CONTENT_VERSION_COLUMNS,
|
|
1254
1273
|
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1255
|
-
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"],
|
|
1274
|
+
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"],
|
|
1256
1275
|
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
|
|
1257
1276
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1258
1277
|
};
|
package/docs/calendar.md
CHANGED
|
@@ -49,6 +49,86 @@ stays listed, struck through as "Cancelled", so nobody wonders where it went:
|
|
|
49
49
|
|
|
50
50
|
The keys are stored in `cancelledDates`. `restoreOccurrence` undoes a cancellation.
|
|
51
51
|
|
|
52
|
+
## Moved dates
|
|
53
|
+
|
|
54
|
+
One date of a series can move to another day, time or room and stay that same date.
|
|
55
|
+
Its key, its sign-ups, its hosts and its page all stay with it. Ways to move a date:
|
|
56
|
+
|
|
57
|
+
- the event editor's **Single dates** list: **Move…** (date, times, rooms and a public
|
|
58
|
+
note), next to **Cancel date**;
|
|
59
|
+
- dragging one date on the Schedule. The week view moves by time as well as day.
|
|
60
|
+
**Undo** and the date's **Move back** put it back;
|
|
61
|
+
- `moveOccurrence` from site code.
|
|
62
|
+
|
|
63
|
+
A moved date:
|
|
64
|
+
|
|
65
|
+
- comes out at its new time everywhere, keeping its original key, with `moved: {
|
|
66
|
+
from: { start, end }, note }`;
|
|
67
|
+
- is labelled **Moved** with its old time and the note on the event page, `/calendar`,
|
|
68
|
+
event cards, the app API (`occurrences[].moved`) and the hosts' My events;
|
|
69
|
+
- takes `?on=` with either day: its new one or the one it moved from;
|
|
70
|
+
- is an override in `calendar.ics` (same UID, `RECURRENCE-ID` of the old date, the new
|
|
71
|
+
`DTSTART`/`DTEND`), so calendar apps move that one date;
|
|
72
|
+
- holds its rooms at the new time: its own `roomIds` when it moved into other rooms,
|
|
73
|
+
else the event's;
|
|
74
|
+
- takes its RSVPs with it. Their start moves too, and each person with a seat gets the
|
|
75
|
+
"new time" email in their own language, with the note and the button to view or
|
|
76
|
+
cancel their spot. Places in line move quietly;
|
|
77
|
+
- gets reminders and "starting soon" notices at the new time;
|
|
78
|
+
- is announced by the "When an event changes" rule, still merged over 10 minutes:
|
|
79
|
+
"Music class · Moved to Wed, Jan 9, 5:00 PM (was Thu, Jan 10, 6:00 PM)". Moving it
|
|
80
|
+
back is announced the same way.
|
|
81
|
+
|
|
82
|
+
A date is moved or cancelled, never both. To move a cancelled date, restore it first.
|
|
83
|
+
Cancelling a moved date cancels it at its new time (its RSVPs too) and drops the move.
|
|
84
|
+
Moving the whole series keeps a moved date at its new time, under its new key.
|
|
85
|
+
|
|
86
|
+
Moves are stored on the event row as `movedDates`, keyed by the original occurrence key:
|
|
87
|
+
`{ start, end, roomIds?, location?, note, by, at }`, with `start`/`end` in wall-clock
|
|
88
|
+
time like the event's own. There's no per-date title. Public JSON shows `start`, `end`
|
|
89
|
+
and `note`. `by` (who moved it: a user id, or `google-sync`) and `at` are for staff.
|
|
90
|
+
|
|
91
|
+
### Who can move a date
|
|
92
|
+
|
|
93
|
+
`moveOccurrence(repo, who, eventId, key, { start, end?, roomIds?, location?, note? }, ctx, { quiet? })`
|
|
94
|
+
and `moveOccurrenceBack(repo, who, eventId, key, ctx, { quiet? })` check who is acting:
|
|
95
|
+
|
|
96
|
+
- **Staff** with "Create and edit content" may move any date. If another event, booking,
|
|
97
|
+
block or busy calendar takes the rooms at the new time, the move still happens, and
|
|
98
|
+
the result's `clashes` lists what else is there.
|
|
99
|
+
- **`GOOGLE_SYNC`** is a calendar sync acting with staff rights. A clash only warns,
|
|
100
|
+
since the change already happened in Google.
|
|
101
|
+
- **A host of the event** may move its dates while the rooms are free; a taken room
|
|
102
|
+
refuses the move (409). Staff (`members.notify`, else the owners) are emailed about
|
|
103
|
+
each move a host makes.
|
|
104
|
+
- Anyone else gets 404, and nobody signed in gets 401.
|
|
105
|
+
|
|
106
|
+
`end` defaults to the date's own length. `roomIds`, `location` and `note` replace the
|
|
107
|
+
date's own when given, and omitting them keeps what it had. `quiet: true` skips the app
|
|
108
|
+
notice and the staff email, for a sync catching up on changes people already know about.
|
|
109
|
+
The RSVP emails still go, because those people's own sign-ups moved.
|
|
110
|
+
|
|
111
|
+
Moving a date to exactly where it already is (same times, rooms, place and note), or
|
|
112
|
+
moving back a date that isn't moved, changes nothing and returns `changed: false`: no
|
|
113
|
+
write, webhook, push or email. A sync can retry, or re-apply what it already applied,
|
|
114
|
+
safely. Cancelling a date that's already cancelled, or restoring one that isn't, is a
|
|
115
|
+
no-op too, even after the date has passed.
|
|
116
|
+
|
|
117
|
+
Errors carry a `status`, a message safe to show people, and a `code`:
|
|
118
|
+
|
|
119
|
+
| status | code | when |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| 400 | `one-off` | the event doesn't repeat |
|
|
122
|
+
| 400 | `not-a-date` | the key isn't one of its dates |
|
|
123
|
+
| 400 | `started` | the date has started or passed |
|
|
124
|
+
| 400 | `past` | the new time has passed |
|
|
125
|
+
| 400 | `original-passed` | moving back to a time that has passed |
|
|
126
|
+
| 400 | `bad-time` | the times aren't valid, or don't fit an all-day or timed event |
|
|
127
|
+
| 409 | `cancelled-event` | the whole event is cancelled |
|
|
128
|
+
| 409 | `cancelled-date` | the date is cancelled (restore it first) |
|
|
129
|
+
| 409 | `room-taken` | a host moving into a taken room |
|
|
130
|
+
| 401, 404 | | who is acting |
|
|
131
|
+
|
|
52
132
|
## Rooms
|
|
53
133
|
|
|
54
134
|
Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
|
package/docs/server.md
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getRepo, currentUser,
|
|
13
13
|
listEvents, getEvent, listOccurrences,
|
|
14
14
|
cancelOccurrence, restoreOccurrence,
|
|
15
|
+
moveOccurrence, moveOccurrenceBack, GOOGLE_SYNC,
|
|
15
16
|
eventRoomClashes, roomBusy,
|
|
16
17
|
listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
|
|
17
18
|
withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
|
|
@@ -33,6 +34,8 @@ getEvent(repo: Repo, id: string): Promise<CalEvent | null>
|
|
|
33
34
|
listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
|
|
34
35
|
cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
35
36
|
restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
37
|
+
moveOccurrence(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
|
|
38
|
+
moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
|
|
36
39
|
eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
|
|
37
40
|
roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
|
|
38
41
|
|
|
@@ -48,7 +51,9 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
|
|
|
48
51
|
|
|
49
52
|
type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
|
|
50
53
|
interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
|
|
51
|
-
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[] }
|
|
54
|
+
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[]; moved?: { from: { start: number; end: number }; note: string; location?: EventLocation } }
|
|
55
|
+
interface MoveInput { start: string; end?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
|
|
56
|
+
interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
|
|
52
57
|
interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
|
|
53
58
|
interface EventClash { hold: Hold; other: Hold }
|
|
54
59
|
interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
|
|
@@ -56,8 +61,8 @@ interface WriteContext { url: URL; locals: unknown }
|
|
|
56
61
|
```
|
|
57
62
|
|
|
58
63
|
The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
|
|
59
|
-
`OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash
|
|
60
|
-
`BusySlot` are exported too.
|
|
64
|
+
`OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash`,
|
|
65
|
+
`BusySlot`, `MoveInput` and `MoveResult` are exported too.
|
|
61
66
|
|
|
62
67
|
- **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
|
|
63
68
|
(or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
|
|
@@ -128,6 +133,26 @@ another day (the admin's drag-to-move), its cancelled dates move with it. If its
|
|
|
128
133
|
or rule is edited instead, keys that no longer match an occurrence are ignored, not an
|
|
129
134
|
error.
|
|
130
135
|
|
|
136
|
+
## Moved dates
|
|
137
|
+
|
|
138
|
+
- **`moveOccurrence(repo, who, eventId, key, input, ctx, opts?)`**: moves one date of a
|
|
139
|
+
repeating event to `input.start`, wall-clock in the event's timezone (`end` defaults
|
|
140
|
+
to the date's own length). It applies at once, keeps the date's key, moves its RSVPs
|
|
141
|
+
(each person is emailed) and its room hold, and shows it as "Moved" with its old time
|
|
142
|
+
everywhere. It checks `who`: staff ("Create and edit content") and `GOOGLE_SYNC` may
|
|
143
|
+
move any date, and get what else takes its rooms then in `clashes`. A host of the
|
|
144
|
+
event may move it only while its rooms are free (409 otherwise), and staff are
|
|
145
|
+
emailed. `opts.quiet` skips the app notice and that staff email.
|
|
146
|
+
- **`moveOccurrenceBack(...)`**: puts it back where the series has it. Idempotent.
|
|
147
|
+
|
|
148
|
+
Moving a date to where it already is returns `changed: false` without writing or
|
|
149
|
+
sending anything, so retries are safe. Errors carry `status` and `code` (`one-off`,
|
|
150
|
+
`not-a-date`, `started`, `past`, `bad-time`, `cancelled-date`, `room-taken`…; the
|
|
151
|
+
full table is in `npx acms docs calendar`).
|
|
152
|
+
|
|
153
|
+
`npx acms docs calendar` has the details: what follows a moved date, how it shows,
|
|
154
|
+
and the error statuses.
|
|
155
|
+
|
|
131
156
|
## Rooms
|
|
132
157
|
|
|
133
158
|
Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
|
package/docs/theme-authoring.md
CHANGED
|
@@ -55,7 +55,7 @@ const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astr
|
|
|
55
55
|
</section>}
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
`calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
|
|
58
|
+
`calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them, and a date moved to another time carries `moved` (`{ from: { start, end }, note }`) so templates can say "Moved from …". `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
|
|
59
59
|
|
|
60
60
|
Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
|
|
61
61
|
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -30,7 +30,9 @@ import { loadFeed } from '../lib/calendar/ics';
|
|
|
30
30
|
import { NATIVE_ALL } from '../lib/calendar/render';
|
|
31
31
|
import { collectViewEvents } from '../lib/calendar/data';
|
|
32
32
|
import { loadBoard } from '../lib/calendar/board';
|
|
33
|
-
import { cancelOccurrence, duplicateEvent,
|
|
33
|
+
import { cancelOccurrence, duplicateEvent, moveDateBy, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
|
|
34
|
+
import { moveOccurrence, moveOccurrenceBack, upcomingDates } from '../lib/calendar/moves';
|
|
35
|
+
import type { BusySlot } from '../lib/booking/holds';
|
|
34
36
|
import { waitUntilFrom } from '../lib/webhooks';
|
|
35
37
|
import { siteOrigin } from '../lib/seo';
|
|
36
38
|
import type { CalEvent } from '../lib/schema';
|
|
@@ -56,6 +58,21 @@ async function requireAdmin(ctx: ActionAPIContext) {
|
|
|
56
58
|
throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
|
|
57
59
|
}
|
|
58
60
|
}
|
|
61
|
+
// the person acting, for the rules that check who it is (lib/calendar/moves.ts)
|
|
62
|
+
const actorOf = (access: Awaited<ReturnType<typeof requireCan>>) => ({ id: access.userId, can: (p: string) => access.can(p as Permission) });
|
|
63
|
+
// "Hall is also taken then: Choir · 6:30 – 7:30 PM" ('' when it's free)
|
|
64
|
+
async function clashWarning(repo: Awaited<ReturnType<typeof getRepo>>, clashes: BusySlot[]): Promise<string> {
|
|
65
|
+
if (!clashes.length) return '';
|
|
66
|
+
const [settings, resources] = await Promise.all([repo.getSettings(), repo.listResources()]);
|
|
67
|
+
const names = new Map(resources.map((r) => [r.id, r.name]));
|
|
68
|
+
const time = new Intl.DateTimeFormat(siteLocale(settings), { hour: 'numeric', minute: '2-digit', timeZone: settings.timezone || 'UTC' });
|
|
69
|
+
const what = (c: BusySlot) => c.kind === 'calendar' ? 'its busy calendar' : c.kind === 'block' ? `blocked${c.title ? ` (${c.title})` : ''}` : c.title || 'a booking';
|
|
70
|
+
const first = clashes[0];
|
|
71
|
+
const more = clashes.length > 1 ? ` and ${clashes.length - 1} more` : '';
|
|
72
|
+
let range: string;
|
|
73
|
+
try { range = time.formatRange(first.start, first.end); } catch { range = `${time.format(first.start)} – ${time.format(first.end)}`; }
|
|
74
|
+
return `${names.get(first.resourceId) ?? 'The room'} is also taken then: ${what(first)} · ${range}${more}. Both keep the room.`;
|
|
75
|
+
}
|
|
59
76
|
// …and that their role grants the permission (Team → Roles)
|
|
60
77
|
async function requireCan(ctx: ActionAPIContext, permission: Permission) {
|
|
61
78
|
const access = await accessFor(ctx.request, ctx.cookies, await getRepo(ctx.locals));
|
|
@@ -1134,28 +1151,76 @@ export const server = {
|
|
|
1134
1151
|
try { const e = await quickCreate(repo, input, await publishHooks(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
|
|
1135
1152
|
},
|
|
1136
1153
|
}),
|
|
1137
|
-
// Schedule: drag to another day
|
|
1138
|
-
//
|
|
1154
|
+
// Schedule: drag to another day (and, in the week view, another time:
|
|
1155
|
+
// `minutes` more). `occurrence` + mode 'one' moves a single date of a
|
|
1156
|
+
// series (a moved date that keeps its RSVPs); otherwise the whole event
|
|
1157
|
+
// moves. `warning`: what else takes its rooms at the new time.
|
|
1139
1158
|
move: defineAction({
|
|
1140
|
-
input: z.object({ id: z.string(), days: z.number().int(), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
|
|
1141
|
-
handler: async ({ id, days, mode, occurrence }, ctx) => {
|
|
1142
|
-
await requireCan(ctx, 'content.edit');
|
|
1159
|
+
input: z.object({ id: z.string(), days: z.number().int(), minutes: z.number().int().min(-1440).max(1440).default(0), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
|
|
1160
|
+
handler: async ({ id, days, minutes, mode, occurrence }, ctx) => {
|
|
1161
|
+
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1143
1162
|
const repo = await getRepo(ctx.locals);
|
|
1144
1163
|
try {
|
|
1145
|
-
|
|
1146
|
-
|
|
1164
|
+
if (mode === 'one') {
|
|
1165
|
+
const r = await moveDateBy(repo, who, id, occurrence, days * 1440 + minutes, await publishHooks(ctx));
|
|
1166
|
+
return { id: r.event.id, undo: r.undo, warning: await clashWarning(repo, r.clashes) };
|
|
1167
|
+
}
|
|
1168
|
+
const r = await moveSeries(repo, id, days, await publishHooks(ctx), minutes);
|
|
1169
|
+
return { id: r.event.id, undo: r.undo, warning: '' };
|
|
1147
1170
|
} catch (e) { asActionError(e); }
|
|
1148
1171
|
},
|
|
1149
1172
|
}),
|
|
1150
1173
|
undoMove: defineAction({
|
|
1151
1174
|
input: z.union([
|
|
1152
|
-
z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int() }),
|
|
1175
|
+
z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int(), minutes: z.number().int().optional() }),
|
|
1176
|
+
z.object({ kind: z.literal('date'), id: z.string(), key: z.string().max(20), previous: z.object({
|
|
1177
|
+
start: z.string().max(20), end: z.string().max(20), roomIds: z.array(z.string()).max(50).optional(), note: z.string().max(500).default(''),
|
|
1178
|
+
location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(2000).default('') }).partial().optional(),
|
|
1179
|
+
}).nullable() }),
|
|
1153
1180
|
z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
|
|
1154
1181
|
]),
|
|
1155
1182
|
handler: async (undo, ctx) => {
|
|
1156
|
-
await requireCan(ctx, 'content.edit');
|
|
1183
|
+
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1184
|
+
const repo = await getRepo(ctx.locals);
|
|
1185
|
+
try { await undoMove(repo, who, undo as Parameters<typeof undoMove>[2], await publishHooks(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
|
|
1186
|
+
},
|
|
1187
|
+
}),
|
|
1188
|
+
// Event editor → Dates: move one date (new date, time, rooms and a public
|
|
1189
|
+
// note), at once like cancelling it. `warning`: what else takes its rooms then.
|
|
1190
|
+
moveDate: defineAction({
|
|
1191
|
+
input: z.object({ id: z.string(), key: z.string().max(20), start: z.string().max(20), end: z.string().max(20).default(''),
|
|
1192
|
+
roomIds: z.array(z.string()).max(50).optional(), note: z.string().max(500).default('') }),
|
|
1193
|
+
handler: async ({ id, key, start, end, roomIds, note }, ctx) => {
|
|
1194
|
+
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1195
|
+
const repo = await getRepo(ctx.locals);
|
|
1196
|
+
try {
|
|
1197
|
+
const r = await moveOccurrence(repo, who, id, key, { start, end: end || undefined, roomIds, note }, await publishHooks(ctx));
|
|
1198
|
+
return { id, warning: await clashWarning(repo, r.clashes) };
|
|
1199
|
+
} catch (e) { asActionError(e); }
|
|
1200
|
+
},
|
|
1201
|
+
}),
|
|
1202
|
+
// …and put a moved date back where the series has it
|
|
1203
|
+
moveDateBack: defineAction({
|
|
1204
|
+
input: z.object({ id: z.string(), key: z.string().max(20) }),
|
|
1205
|
+
handler: async ({ id, key }, ctx) => {
|
|
1206
|
+
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1207
|
+
const repo = await getRepo(ctx.locals);
|
|
1208
|
+
try {
|
|
1209
|
+
const r = await moveOccurrenceBack(repo, who, id, key, await publishHooks(ctx));
|
|
1210
|
+
return { id, warning: await clashWarning(repo, r.clashes) };
|
|
1211
|
+
} catch (e) { asActionError(e); }
|
|
1212
|
+
},
|
|
1213
|
+
}),
|
|
1214
|
+
// Event editor → Single dates: the next dates of a series (as saved), each
|
|
1215
|
+
// moved, cancelled or as planned, and the rooms a date can move into
|
|
1216
|
+
dates: defineAction({
|
|
1217
|
+
input: z.object({ id: z.string(), limit: z.number().int().min(1).max(52).default(12) }),
|
|
1218
|
+
handler: async ({ id, limit }, ctx) => {
|
|
1219
|
+
await requireAdmin(ctx);
|
|
1157
1220
|
const repo = await getRepo(ctx.locals);
|
|
1158
|
-
|
|
1221
|
+
const [e, settings, resources] = await Promise.all([repo.getEvent(id), repo.getSettings(), bookingsEnabled() ? repo.listResources() : Promise.resolve([])]);
|
|
1222
|
+
if (!e) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
|
|
1223
|
+
return { allDay: e.allDay, roomIds: e.roomHold.resourceIds, dates: upcomingDates(e, settings, limit), rooms: resources.map((r) => ({ id: r.id, name: r.name })) };
|
|
1159
1224
|
},
|
|
1160
1225
|
}),
|
|
1161
1226
|
// Schedule: cancel (or restore) one date of a repeating event, at once
|
|
@@ -35,6 +35,8 @@ interface Props {
|
|
|
35
35
|
const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
|
|
36
36
|
// the whole event, or just this date of a repeating one
|
|
37
37
|
const cancelled = e.cancelled || Boolean(o.cancelled);
|
|
38
|
+
// a moved date may be at its own place
|
|
39
|
+
const where = o.moved?.location ?? e.location;
|
|
38
40
|
const image = eventImage(e, settings);
|
|
39
41
|
const tz = eventTimeZone(e, settings);
|
|
40
42
|
const locale = siteLocale(settings);
|
|
@@ -52,13 +54,13 @@ const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
|
|
|
52
54
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
53
55
|
}, lang);
|
|
54
56
|
const ctaHref = safeHref(e.cta.href);
|
|
55
|
-
const mapHref =
|
|
56
|
-
? safeHref(
|
|
57
|
-
:
|
|
58
|
-
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([
|
|
57
|
+
const mapHref = where.url
|
|
58
|
+
? safeHref(where.url)
|
|
59
|
+
: where.address
|
|
60
|
+
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([where.name, where.address].filter(Boolean).join(', '))}`
|
|
59
61
|
: '';
|
|
60
62
|
// static map (made when the event was saved; see lib/maps/service.ts)
|
|
61
|
-
const loc =
|
|
63
|
+
const loc = where;
|
|
62
64
|
const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
|
|
63
65
|
const directions = showMap ? safeHref(directionsUrl(loc)) : '';
|
|
64
66
|
const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
|
|
@@ -76,7 +78,7 @@ const I = {
|
|
|
76
78
|
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 8h.01',
|
|
77
79
|
video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
|
|
78
80
|
};
|
|
79
|
-
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(
|
|
81
|
+
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(where.url);
|
|
80
82
|
// the RSVP block speaks the page's language too
|
|
81
83
|
const w = rsvpStrings(lang);
|
|
82
84
|
const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
|
|
@@ -91,6 +93,9 @@ const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesA
|
|
|
91
93
|
const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
|
|
92
94
|
const closedLine = deadline && !rsvp!.open && !cancelled && rsvp!.closesAt! <= Date.now() ? fill(w.closedOn, deadline) : '';
|
|
93
95
|
const dateCancelled = fill(ew.dateCancelled, { date: f.date });
|
|
96
|
+
// "Moved from Thu, Jan 10 · 6:00 PM." (all-day: the date)
|
|
97
|
+
const movedFrom = o.moved ? formatOccurrence({ start: o.moved.from.start, end: o.moved.from.end, allDay: o.allDay, days: o.days }, tz, locale) : null;
|
|
98
|
+
const dateMoved = movedFrom ? fill(ew.dateMoved, { from: o.allDay ? movedFrom.date : movedFrom.range }) : '';
|
|
94
99
|
const showing = fill(ew.showing, { date: f.date });
|
|
95
100
|
const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
|
|
96
101
|
---
|
|
@@ -113,6 +118,7 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
|
|
|
113
118
|
<h1 class="pc-ev-title">{e.title}</h1>
|
|
114
119
|
{e.cancelled && <p class="pc-ev-cancelled"><b>{ew.eventCancelled}</b> {ew.eventCancelledNote}</p>}
|
|
115
120
|
{!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>{dateCancelled}</b> {ew.dateCancelledNote}</p>}
|
|
121
|
+
{!cancelled && dateMoved && <p class="pc-ev-moved"><b>{dateMoved}</b> {o.moved!.note || ew.dateMovedNote}</p>}
|
|
116
122
|
{e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
|
|
117
123
|
<div class="pc-ev-body post-content" set:html={html} />
|
|
118
124
|
</div>
|
|
@@ -125,15 +131,15 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
|
|
|
125
131
|
<span>{f.time}</span>
|
|
126
132
|
</div>
|
|
127
133
|
</div>
|
|
128
|
-
{(
|
|
134
|
+
{(where.name || where.address || where.url) && (
|
|
129
135
|
<div class="pc-ev-fact">
|
|
130
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={online && !
|
|
136
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={online && !where.address ? I.video : I.pin} /></svg>
|
|
131
137
|
<div>
|
|
132
|
-
{
|
|
133
|
-
{(
|
|
138
|
+
{where.name && <b>{where.name}</b>}
|
|
139
|
+
{(where.address || mapHref) && (
|
|
134
140
|
<span>
|
|
135
|
-
{
|
|
136
|
-
{mapHref && <>{
|
|
141
|
+
{where.address}
|
|
142
|
+
{mapHref && <>{where.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? ew.joinOnline : ew.map}</a></>}
|
|
137
143
|
</span>
|
|
138
144
|
)}
|
|
139
145
|
</div>
|