@acms-subir/core 0.3.56 → 0.3.58
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 +29 -0
- package/cli/migrations.generated.mjs +24 -5
- package/docs/calendar.md +130 -2
- package/docs/server.md +33 -1
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +75 -15
- package/src/lib/booking/service.ts +4 -2
- package/src/lib/calendar/api.ts +29 -8
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +16 -7
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +2 -1
- package/src/lib/calendar/request-mail.ts +2 -1
- package/src/lib/calendar/request-page.ts +2 -1
- package/src/lib/calendar/request-service.ts +7 -7
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/display/board.ts +6 -5
- package/src/lib/editor/schedule.ts +4 -2
- package/src/lib/mobile/calendar.ts +6 -5
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/mobile/search.ts +2 -1
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +13 -3
- package/src/lib/repo/sql.ts +34 -13
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +34 -0
- package/src/lib/sections.ts +3 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +9 -8
- package/src/pages/admin/calendar/requests.astro +5 -5
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/members/events/index.astro +87 -7
- package/src/server/index.ts +46 -3
- package/src/styles/editor.css +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.58
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c26c7c1: Rooms can have a name in each of the site's other languages. Admin → Calendar → Rooms → a room shows **Name in Español** (one field per extra language) under its name, and the editor API takes `translations: { es: { name } }`. Each place that names a room uses the name in its language, and falls back to the main name:
|
|
8
|
+
|
|
9
|
+
- public event data: `rooms` in `/api/v1/events`, `/api/v1/events/:slug` and search, following `?locale=` (else the site's main language);
|
|
10
|
+
- calendar views and cards, including "Reserved" blocks, and section event items on Spanish pages;
|
|
11
|
+
- the `/request` form and `GET /api/v1/requests/options?locale=`;
|
|
12
|
+
- the TV display (its room columns and legend);
|
|
13
|
+
- the hosts' My events;
|
|
14
|
+
- request emails and the request status page, in the requester's language;
|
|
15
|
+
- booking emails, in the booker's language.
|
|
16
|
+
|
|
17
|
+
The slug stays the same in every language. Staff screens, staff emails and `GET /api/v1/rooms/free` keep the main name. `roomName(room, locale)` in `lib/schema.ts` picks the name, and `es-MX` uses the `es` name. Migration 71 adds `resources.translations` (`'{}'` for existing rooms); changing it refreshes cached pages and API answers. Backups and environment sync carry it.
|
|
18
|
+
|
|
19
|
+
## 0.3.57
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- b9eed5e: Change a repeating event from one of its dates on, and keep the dates before it as they were.
|
|
24
|
+
|
|
25
|
+
- **One event.** When a class moves for good, from Thursdays 6 PM to Wednesdays 5 PM, the dates before the change stay exactly as they were, and it stays one event: the same page, slug, hosts, RSVPs and My events entry. Each change is a segment on the events row (`CalEvent.segments`, migration 70), with its own first date, repeat, rooms and place.
|
|
26
|
+
- **Where.** Change it from the event editor's Single dates (**From here on…**, with **Undo**), or drag a date on the Schedule and choose **This and following dates**. Hosts can ask with **Change from this date on…** on My events, and staff apply it from Requests.
|
|
27
|
+
- **What follows.** Sign-ups move to the new date nearest them within half a week, and each person is emailed the new time with your note. Cancelled, skipped and moved dates follow the same way. Sign-ups with no new date near them are cancelled, with the email. `npx acms docs calendar` has the rule.
|
|
28
|
+
- **Every output.** `/calendar`, the event page ("Weekly on Thursday · From Wed, Jan 9: Weekly on Wednesday"), cards, the TV display, room holds and the app API show the new dates. `calendar.ics` writes one VEVENT per part, as Google does for "this and following". The "When an event changes" rule sends one notice: "From Wed, Jan 9: Weekly on Wednesday, 5:00 PM".
|
|
29
|
+
- **API.** Events carry `segments` (`from`, `start`, `end`, `rrule`, `repeats`, `cancelled`, `note`, `location`, `rooms`). `repeats` is the rule in effect now, and each occurrence has its own `location`. Push data gains `change: 'fromDate' | 'cancelledFrom'`.
|
|
30
|
+
- **Server API.** `@acms-subir/core/server` adds `changeFromDate`, `cancelFromDate`, `restoreFromDate`, `revertFromDate` (staff and `GOOGLE_SYNC`, all idempotent, `quiet` for a sync) and `requestFromDate` for hosts.
|
|
31
|
+
|
|
3
32
|
## 0.3.56
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
|
@@ -283,8 +283,8 @@ var V65_COLUMNS = [
|
|
|
283
283
|
"start_at",
|
|
284
284
|
"end_at"
|
|
285
285
|
];
|
|
286
|
-
var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates"];
|
|
287
|
-
var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates"];
|
|
286
|
+
var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates", "segments"];
|
|
287
|
+
var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates", "segments"];
|
|
288
288
|
var snapshotOf = (cols, row) => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(",")})`;
|
|
289
289
|
var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
|
|
290
290
|
var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
|
|
@@ -331,6 +331,15 @@ var MOVED_DATES_MIGRATION = (contentVersionUpdate) => ({ version: 66, statements
|
|
|
331
331
|
`DROP TRIGGER event_changes_delete`,
|
|
332
332
|
...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates"], [...V65_COLUMNS, "moved_dates"])
|
|
333
333
|
] });
|
|
334
|
+
var SEGMENTS_MIGRATION = (contentVersionUpdate) => ({ version: 70, statements: [
|
|
335
|
+
`ALTER TABLE events ADD COLUMN segments TEXT NOT NULL DEFAULT '[]'`,
|
|
336
|
+
`DROP TRIGGER content_version_events_update`,
|
|
337
|
+
contentVersionUpdate,
|
|
338
|
+
`DROP TRIGGER event_changes_insert`,
|
|
339
|
+
`DROP TRIGGER event_changes_update`,
|
|
340
|
+
`DROP TRIGGER event_changes_delete`,
|
|
341
|
+
...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates", "segments"], [...V65_COLUMNS, "moved_dates", "segments"])
|
|
342
|
+
] });
|
|
334
343
|
|
|
335
344
|
// src/lib/maps/migration.ts
|
|
336
345
|
var PLACES_MIGRATION = { version: 43, statements: [
|
|
@@ -1351,13 +1360,23 @@ var MIGRATIONS = [
|
|
|
1351
1360
|
{ version: 68, statements: REQUEST_TABLE },
|
|
1352
1361
|
// two-way Google Calendar sync: the account, links, pairs, outgoing queue
|
|
1353
1362
|
// (triggers on events) and Recent changes. Not public, so no cache trigger.
|
|
1354
|
-
GCAL_MIGRATION
|
|
1363
|
+
GCAL_MIGRATION,
|
|
1364
|
+
// a series changed from one date on (CalEvent.segments): a column on the
|
|
1365
|
+
// row, so the triggers on events see it, like moved_dates
|
|
1366
|
+
SEGMENTS_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"])[2]),
|
|
1367
|
+
// a room's name in other languages (Resource.translations): public, so a
|
|
1368
|
+
// change refreshes cached pages and API answers
|
|
1369
|
+
{ version: 71, statements: [
|
|
1370
|
+
`ALTER TABLE resources ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
1371
|
+
`DROP TRIGGER content_version_resources_update`,
|
|
1372
|
+
contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"])[2]
|
|
1373
|
+
] }
|
|
1355
1374
|
];
|
|
1356
1375
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1357
1376
|
...CONTENT_VERSION_COLUMNS,
|
|
1358
1377
|
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1359
|
-
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"],
|
|
1360
|
-
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
|
|
1378
|
+
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
|
|
1379
|
+
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"],
|
|
1361
1380
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1362
1381
|
};
|
|
1363
1382
|
var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/docs/calendar.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Calendar: repeats, single dates, rooms, clashes and requests
|
|
2
2
|
|
|
3
|
-
How events repeat, how one date of a series is changed, how
|
|
4
|
-
how people ask for events.
|
|
3
|
+
How events repeat, how one date of a series is changed, how a series changes from a
|
|
4
|
+
date on, how events hold rooms, and how people ask for events.
|
|
5
5
|
Read it with `npx acms docs calendar`. The functions named here come from
|
|
6
6
|
`@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
|
|
7
7
|
API is in `docs/mobile-api-v1.md` in the framework repository.
|
|
@@ -148,6 +148,120 @@ Errors carry a `status`, a message safe to show people, and a `code`:
|
|
|
148
148
|
| 403 | `room-not-allowed` | a host picking a room the event doesn't use and nobody may request |
|
|
149
149
|
| 401, 404 | | who is acting |
|
|
150
150
|
|
|
151
|
+
## From this date on
|
|
152
|
+
|
|
153
|
+
When a class moves for good, say from Thursdays 6 PM to Wednesdays 5 PM, change the
|
|
154
|
+
series **from one of its dates on**. The dates before it stay exactly as they were. It
|
|
155
|
+
stays one event: the same page, slug, hosts, RSVPs and My events entry.
|
|
156
|
+
|
|
157
|
+
Ways to do it:
|
|
158
|
+
|
|
159
|
+
- the event editor's **Single dates** list: **From here on…** on a date (the new first
|
|
160
|
+
date, its times, how it repeats, its rooms and a note);
|
|
161
|
+
- dragging a date of a repeating event on the Schedule, then **This and following
|
|
162
|
+
dates**. **Undo** puts it back;
|
|
163
|
+
- a host's **Change from this date on…** on My events, which is a request staff apply
|
|
164
|
+
from Schedule → List → Requests;
|
|
165
|
+
- `changeFromDate` from site code or a calendar sync.
|
|
166
|
+
|
|
167
|
+
The new first date may come before the date you change from (from Thursday Jan 10 to
|
|
168
|
+
Wednesday Jan 9), but it has to come after the date before it. Leave the repeat as it
|
|
169
|
+
is and it carries on from the new day: "weekly on Thursday" moved to a Wednesday becomes
|
|
170
|
+
"weekly on Wednesday", a COUNT becomes what's left of it, and an end date stays.
|
|
171
|
+
|
|
172
|
+
### What follows the change
|
|
173
|
+
|
|
174
|
+
Each date from the change point on is matched to the new date nearest it, within half
|
|
175
|
+
a week (3½ days). Each new date takes at most one old date, closest pairs first. Then:
|
|
176
|
+
|
|
177
|
+
- its RSVPs move to that date, and each person gets the "new time" email in their own
|
|
178
|
+
language, with your note. Places in line move quietly;
|
|
179
|
+
- a cancelled date stays cancelled on its match, and a skipped one stays skipped;
|
|
180
|
+
- a moved date keeps its new time, as its match's date;
|
|
181
|
+
- a date with no match has its RSVPs cancelled (with the "cancelled" email). Its
|
|
182
|
+
cancellation, skip or move is dropped, and `changeFromDate` lists it in `dropped`.
|
|
183
|
+
|
|
184
|
+
So Thursday → Wednesday moves each week's sign-ups to that week's Wednesday, and twice
|
|
185
|
+
a week → once a week keeps the day that matches and cancels the other.
|
|
186
|
+
|
|
187
|
+
Changing again from a later date replaces every change after it, the way Google's
|
|
188
|
+
"this and following" works. Changing from the first date of an earlier change replaces
|
|
189
|
+
that change. A change that gives exactly the dates the rule before it gives is no
|
|
190
|
+
change at all: it's removed. **Undo** in the editor (`revertFromDate`) removes a change
|
|
191
|
+
the same way, with RSVPs following back.
|
|
192
|
+
|
|
193
|
+
### How it shows
|
|
194
|
+
|
|
195
|
+
- `/calendar`, the event page, cards, the TV display, the app API and room holds show
|
|
196
|
+
the new dates like any other date of the series. Its rooms and place hold from then.
|
|
197
|
+
- The event page's repeat line says the rule in effect and the change still to come:
|
|
198
|
+
"Weekly on Thursday · From Wed, Jan 9: Weekly on Wednesday". Once it has taken over,
|
|
199
|
+
just "Weekly on Wednesday".
|
|
200
|
+
- `calendar.ics` has one VEVENT per part, the way Google writes "this and following":
|
|
201
|
+
the event's own rule keeps its UID and stops with an UNTIL at the end of the day
|
|
202
|
+
before the change,
|
|
203
|
+
and each change is a recurring event of its own with UID `<id>-<from>@<host>` (`from`
|
|
204
|
+
as `YYYYMMDDTHHmm`), with its own cancelled and moved dates. Calendar apps that
|
|
205
|
+
subscribed show both parts.
|
|
206
|
+
- The "When an event changes" rule sends one notice: "Music class · From Wed, Jan 9:
|
|
207
|
+
Weekly on Wednesday, 5:00 PM" (`data.change: 'fromDate'`), or "Cancelled: every date
|
|
208
|
+
from …" (`'cancelledFrom'`).
|
|
209
|
+
- `/api/v1/events` and `/api/calendar/events` carry `segments: [{ from, start, end,
|
|
210
|
+
rrule, repeats, cancelled, note, location, rooms }]` on each event, and `repeats` is
|
|
211
|
+
the rule in effect now. Each occurrence has `location`: its own place (a moved
|
|
212
|
+
date's, or its segment's), `null` for the event's.
|
|
213
|
+
|
|
214
|
+
### Stored on the event row
|
|
215
|
+
|
|
216
|
+
Changes are `CalEvent.segments`, sorted by `from`, on the events row (so its triggers,
|
|
217
|
+
the event_changes outbox and the content version see them):
|
|
218
|
+
|
|
219
|
+
`{ from, start, end, rrule, roomIds?, location?, cancelled, note, by, at }`
|
|
220
|
+
|
|
221
|
+
- `from` is an occurrence key of the rule before it: that rule's dates from that day on
|
|
222
|
+
are gone (so retiming the earlier rule never leaves a second date that day);
|
|
223
|
+
- `start`/`end` are the new part's first date, wall-clock like the event's own;
|
|
224
|
+
- `rrule` is its rule (`''` = just that date);
|
|
225
|
+
- `roomIds`/`location` are its rooms and place (unset = the event's);
|
|
226
|
+
- `cancelled: true` cancels every date of it.
|
|
227
|
+
|
|
228
|
+
Its dates have their own keys, so `cancelledDates`, `movedDates` and `exdates` work on
|
|
229
|
+
them, and so do `moveOccurrence` and `cancelOccurrence`. `seriesParts(event)` splits a
|
|
230
|
+
series into its parts. Dragging the whole series on the Schedule moves every part.
|
|
231
|
+
|
|
232
|
+
Why one event with segments, and not a second event for the later part: hosts, RSVPs,
|
|
233
|
+
the page and slug, My events and the Classes card all stay with the one event, every
|
|
234
|
+
event write path already goes through the row's triggers, and calendar feeds can still
|
|
235
|
+
show the parts as separate series.
|
|
236
|
+
|
|
237
|
+
### Server API
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
changeFromDate(repo, who, eventId, fromKey, { start, end?, rrule?, roomIds?, location?, note? }, ctx, { quiet? })
|
|
241
|
+
→ { event, clashes, changed, rsvps: { moved, cancelled }, dropped }
|
|
242
|
+
cancelFromDate(repo, who, eventId, fromKey, ctx, { quiet? }) // every date from it on, struck through
|
|
243
|
+
restoreFromDate(repo, who, eventId, fromKey, ctx, { quiet? }) // undo that; RSVPs stay cancelled
|
|
244
|
+
revertFromDate(repo, who, eventId, from, ctx, { quiet? }) // remove a change: the rule before it carries on
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`who` is staff ("Create and edit content") or `GOOGLE_SYNC`. Room clashes at the new
|
|
248
|
+
times (180 days ahead) come back in `clashes` as warnings. Hosts get 403
|
|
249
|
+
`host-request`: they send `requestFromDate` instead (`npx acms docs server`). All are
|
|
250
|
+
idempotent (`changed: false`). `quiet` skips the member push; the RSVP emails still go.
|
|
251
|
+
A `fromKey` that a change took over from (no longer a date itself) means that change,
|
|
252
|
+
so a sync can retry safely: the same change again is `changed: false`, whoever sends it
|
|
253
|
+
and whenever. A change whose first date has started can't be replaced or undone
|
|
254
|
+
(`started`), and neither can a cancelled part be restored once its dates have started.
|
|
255
|
+
|
|
256
|
+
| status | code | when |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| 400 | `one-off`, `not-a-date`, `started`, `past`, `bad-time` | as for moves |
|
|
259
|
+
| 400 | `first-date` | changing from the series' very first date (edit the event instead) |
|
|
260
|
+
| 400 | `previous-date` | the new first date isn't after the date before the change |
|
|
261
|
+
| 400 | `bad-rule` | a rule the calendar can't read, or a first date that isn't one of its dates |
|
|
262
|
+
| 403 | `host-request` | a host: send it as a request |
|
|
263
|
+
| 409 | `cancelled-event` | the whole event is cancelled |
|
|
264
|
+
|
|
151
265
|
## Rooms
|
|
152
266
|
|
|
153
267
|
Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
|
|
@@ -169,6 +283,20 @@ Each room also has:
|
|
|
169
283
|
names the room. When it's off, only staff see that an event uses the room.
|
|
170
284
|
- **Anyone can check if it’s free** (`requestable`, off by default): the room answers
|
|
171
285
|
the public free/busy check below.
|
|
286
|
+
- **Name in Español** (and each of the site's other languages; `translations: { es: {
|
|
287
|
+
name } }`): the room's name on pages, app answers, TV screens and emails in that
|
|
288
|
+
language. Without one, the main name shows. The slug stays the same in every
|
|
289
|
+
language. Site code reads it with `roomName(room, locale)` from the schema.
|
|
290
|
+
|
|
291
|
+
Where a room's name follows the language:
|
|
292
|
+
- public event data (`rooms`, with `?locale=` in the app API);
|
|
293
|
+
- calendar views, cards and section items;
|
|
294
|
+
- the request form and `GET /api/v1/requests/options`;
|
|
295
|
+
- the TV display;
|
|
296
|
+
- My events;
|
|
297
|
+
- request emails and booking emails, in the person's language.
|
|
298
|
+
|
|
299
|
+
The admin and staff emails use the main name.
|
|
172
300
|
|
|
173
301
|
### Rooms in event data
|
|
174
302
|
|
package/docs/server.md
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
|
|
19
19
|
moveHostedDate, moveHostedDateBack, requestDateMove, listMyDateRequests, withdrawDateRequest,
|
|
20
20
|
approveDateRequest, declineDateRequest,
|
|
21
|
+
changeFromDate, cancelFromDate, restoreFromDate, revertFromDate, requestFromDate,
|
|
21
22
|
} from '@acms-subir/core/server';
|
|
22
23
|
```
|
|
23
24
|
|
|
@@ -38,6 +39,10 @@ cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteC
|
|
|
38
39
|
restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
39
40
|
moveOccurrence(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
|
|
40
41
|
moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
|
|
42
|
+
changeFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, input: ChangeFromInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
43
|
+
cancelFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
44
|
+
restoreFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
45
|
+
revertFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, from: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
41
46
|
eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
|
|
42
47
|
roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
|
|
43
48
|
|
|
@@ -61,9 +66,10 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
|
|
|
61
66
|
moveHostedDate(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext): Promise<HostMoveResult>
|
|
62
67
|
moveHostedDateBack(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<HostMoveResult>
|
|
63
68
|
requestDateMove(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput | null, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
69
|
+
requestFromDate(repo: Repo, who: ServerUser | null, eventId: string, fromKey: string, input: Omit<ChangeFromInput, 'location'>, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
64
70
|
listMyDateRequests(repo: Repo, who: ServerUser | null, eventId?: string): Promise<RequestRecord[]>
|
|
65
71
|
withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Promise<void>
|
|
66
|
-
approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved
|
|
72
|
+
approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved?: MoveResult; changed?: ChangeFromResult }>
|
|
67
73
|
declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
|
|
68
74
|
|
|
69
75
|
// requests without an account (acms({ requests })): asking needs no account; listing needs
|
|
@@ -84,6 +90,9 @@ interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
|
|
|
84
90
|
type HostMoveResult = { moved: MoveResult } | { request: RequestRecord }
|
|
85
91
|
interface RequestRecord { id: string; kind: 'new' | 'change' | 'move' | 'from-date'; state: 'pending' | 'approved' | 'declined' | 'withdrawn'; source: 'member' | 'web' | 'app'; userId: string; requester: { name: string; email: string; phone: string; locale: string } | null; eventId: string; key: string; data: Record<string, unknown>; note: string; reply: string; reviewedBy: string; reviewedAt: number; createdAt: number; updatedAt: number }
|
|
86
92
|
interface MoveRequestData { start: string; end: string; roomIds?: string[]; note: string; back?: boolean }
|
|
93
|
+
interface FromDateRequestData { start: string; end: string; rrule: string; roomIds?: string[]; note: string }
|
|
94
|
+
interface ChangeFromInput { start: string; end?: string; rrule?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
|
|
95
|
+
interface ChangeFromResult { event: CalEvent; clashes: BusySlot[]; changed: boolean; rsvps: { moved: number; cancelled: number }; dropped: string[] }
|
|
87
96
|
interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
|
|
88
97
|
interface EventClash { hold: Hold; other: Hold }
|
|
89
98
|
interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
|
|
@@ -187,6 +196,29 @@ full table is in `npx acms docs calendar`).
|
|
|
187
196
|
`npx acms docs calendar` has the details: what follows a moved date, how it shows,
|
|
188
197
|
and the error statuses.
|
|
189
198
|
|
|
199
|
+
## From this date on
|
|
200
|
+
|
|
201
|
+
- **`changeFromDate(repo, who, eventId, fromKey, input, ctx, opts?)`**: changes a repeating
|
|
202
|
+
event from one of its dates (`fromKey`, an occurrence key) on. `input.start` is the new
|
|
203
|
+
first date, wall-clock in the event's timezone. It may come before `fromKey` but has to
|
|
204
|
+
come after the date before it. `end` defaults to the dates' length, `rrule` to the rule
|
|
205
|
+
in effect on the new weekday (`''` = just that date), and `roomIds` / `location` to
|
|
206
|
+
what's in effect there. Dates before it stay exactly as they were. RSVPs, cancelled,
|
|
207
|
+
skipped and moved dates follow to the new date nearest them within half a week. RSVPs
|
|
208
|
+
with no new date are cancelled, and `dropped` lists the other dates that had none.
|
|
209
|
+
`clashes` lists what else holds the new rooms over the next 180 days.
|
|
210
|
+
- **`cancelFromDate(...)`** / **`restoreFromDate(...)`**: cancel every date from one on
|
|
211
|
+
(they stay listed, struck through) and undo that.
|
|
212
|
+
- **`revertFromDate(repo, who, eventId, from, ctx)`**: removes the change that took over
|
|
213
|
+
at `from`.
|
|
214
|
+
|
|
215
|
+
All four check `who`: staff ("Create and edit content") or `GOOGLE_SYNC`. A host gets 403
|
|
216
|
+
with code `host-request` and sends **`requestFromDate`** instead (rooms among the event's
|
|
217
|
+
own and the requestable ones). Staff answer it with `approveDateRequest` /
|
|
218
|
+
`declineDateRequest`. All of these are idempotent (`changed: false`), and a `fromKey` that a
|
|
219
|
+
change took over from means that change. The changes are stored on the event as
|
|
220
|
+
`segments`. `npx acms docs calendar` has the rules and the error codes.
|
|
221
|
+
|
|
190
222
|
## Rooms
|
|
191
223
|
|
|
192
224
|
Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { validateRequestSession } from '../lib/auth/request';
|
|
|
11
11
|
import { accessFor } from '../lib/permissions/access';
|
|
12
12
|
import { deniedMessage, type Permission } from '../lib/permissions/catalog';
|
|
13
13
|
import { isStaffRole } from '../lib/roles';
|
|
14
|
-
import { DocTranslations, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
14
|
+
import { DocTranslations, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind, SeriesSegment, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
15
15
|
import { env } from '../lib/env';
|
|
16
16
|
import { CONTENT, collectionUrl } from '../lib/content';
|
|
17
17
|
import { invalidateRedirectCache } from '../middleware';
|
|
@@ -30,8 +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, moveDateBy, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
|
|
33
|
+
import { cancelOccurrence, duplicateEvent, moveDateBy, moveFollowingBy, moveSeries, quickCreate, restoreOccurrence, undoMove } from '../lib/calendar/schedule';
|
|
34
34
|
import { moveOccurrence, moveOccurrenceBack, upcomingDates } from '../lib/calendar/moves';
|
|
35
|
+
import { changeFromDate, changeFromDefaults, revertFromDate, upcomingSegments } from '../lib/calendar/segments';
|
|
35
36
|
import type { BusySlot } from '../lib/booking/holds';
|
|
36
37
|
import { waitUntilFrom } from '../lib/webhooks';
|
|
37
38
|
import { siteOrigin } from '../lib/seo';
|
|
@@ -1153,16 +1154,17 @@ export const server = {
|
|
|
1153
1154
|
}),
|
|
1154
1155
|
// Schedule: drag to another day (and, in the week view, another time:
|
|
1155
1156
|
// `minutes` more). `occurrence` + mode 'one' moves a single date of a
|
|
1156
|
-
// series (a moved date that keeps its RSVPs)
|
|
1157
|
-
//
|
|
1157
|
+
// series (a moved date that keeps its RSVPs), mode 'following' that date
|
|
1158
|
+
// and every one after it (a change from that date on); otherwise the
|
|
1159
|
+
// whole event moves. `warning`: what else takes its rooms at the new time.
|
|
1158
1160
|
move: defineAction({
|
|
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('') }),
|
|
1161
|
+
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', 'following']).default('series'), occurrence: z.string().max(20).default('') }),
|
|
1160
1162
|
handler: async ({ id, days, minutes, mode, occurrence }, ctx) => {
|
|
1161
1163
|
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1162
1164
|
const repo = await getRepo(ctx.locals);
|
|
1163
1165
|
try {
|
|
1164
|
-
if (mode === 'one') {
|
|
1165
|
-
const r = await moveDateBy(repo, who, id, occurrence, days * 1440 + minutes, await publishHooks(ctx));
|
|
1166
|
+
if (mode === 'one' || mode === 'following') {
|
|
1167
|
+
const r = await (mode === 'one' ? moveDateBy : moveFollowingBy)(repo, who, id, occurrence, days * 1440 + minutes, await publishHooks(ctx));
|
|
1166
1168
|
return { id: r.event.id, undo: r.undo, warning: await clashWarning(repo, r.clashes) };
|
|
1167
1169
|
}
|
|
1168
1170
|
const r = await moveSeries(repo, id, days, await publishHooks(ctx), minutes);
|
|
@@ -1178,6 +1180,7 @@ export const server = {
|
|
|
1178
1180
|
location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(2000).default('') }).partial().optional(),
|
|
1179
1181
|
}).nullable() }),
|
|
1180
1182
|
z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
|
|
1183
|
+
z.object({ kind: z.literal('following'), id: z.string(), from: z.string().max(20), previous: z.array(SeriesSegment).max(50) }),
|
|
1181
1184
|
]),
|
|
1182
1185
|
handler: async (undo, ctx) => {
|
|
1183
1186
|
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
@@ -1212,7 +1215,8 @@ export const server = {
|
|
|
1212
1215
|
},
|
|
1213
1216
|
}),
|
|
1214
1217
|
// Event editor → Single dates: the next dates of a series (as saved), each
|
|
1215
|
-
// moved, cancelled or as planned,
|
|
1218
|
+
// moved, cancelled or as planned, the rooms a date can move into, and the
|
|
1219
|
+
// changes from a date on still to come or in effect
|
|
1216
1220
|
dates: defineAction({
|
|
1217
1221
|
input: z.object({ id: z.string(), limit: z.number().int().min(1).max(52).default(12) }),
|
|
1218
1222
|
handler: async ({ id, limit }, ctx) => {
|
|
@@ -1220,7 +1224,46 @@ export const server = {
|
|
|
1220
1224
|
const repo = await getRepo(ctx.locals);
|
|
1221
1225
|
const [e, settings, resources] = await Promise.all([repo.getEvent(id), repo.getSettings(), bookingsEnabled() ? repo.listResources() : Promise.resolve([])]);
|
|
1222
1226
|
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 }))
|
|
1227
|
+
return { allDay: e.allDay, roomIds: e.roomHold.resourceIds, dates: upcomingDates(e, settings, limit), rooms: resources.map((r) => ({ id: r.id, name: r.name })),
|
|
1228
|
+
segments: upcomingSegments(e, settings, new Map(resources.map((r) => [r.id, r.name]))) };
|
|
1229
|
+
},
|
|
1230
|
+
}),
|
|
1231
|
+
// …change the series from one of those dates on (new first date, times,
|
|
1232
|
+
// repeat, rooms; the dates before it stay). `rrule` omitted: the rule in
|
|
1233
|
+
// effect, on the new weekday. `warning`: what else takes the rooms then.
|
|
1234
|
+
changeFromDate: defineAction({
|
|
1235
|
+
input: z.object({ id: z.string(), key: z.string().max(20), start: z.string().max(20), end: z.string().max(20).default(''), rrule: z.string().max(500).optional(),
|
|
1236
|
+
roomIds: z.array(z.string()).max(50).optional(), note: z.string().max(500).default('') }),
|
|
1237
|
+
handler: async ({ id, key, start, end, rrule, roomIds, note }, ctx) => {
|
|
1238
|
+
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1239
|
+
const repo = await getRepo(ctx.locals);
|
|
1240
|
+
try {
|
|
1241
|
+
const r = await changeFromDate(repo, who, id, key, { start, end: end || undefined, rrule, roomIds, note }, await publishHooks(ctx));
|
|
1242
|
+
return { id, changed: r.changed, rsvps: r.rsvps, dropped: r.dropped.length, warning: await clashWarning(repo, r.clashes) };
|
|
1243
|
+
} catch (e) { asActionError(e); }
|
|
1244
|
+
},
|
|
1245
|
+
}),
|
|
1246
|
+
// …what that form starts with for a date (its times, the rule in effect there, its rooms)
|
|
1247
|
+
fromDateDefaults: defineAction({
|
|
1248
|
+
input: z.object({ id: z.string(), key: z.string().max(20) }),
|
|
1249
|
+
handler: async ({ id, key }, ctx) => {
|
|
1250
|
+
await requireAdmin(ctx);
|
|
1251
|
+
const repo = await getRepo(ctx.locals);
|
|
1252
|
+
const d = await changeFromDefaults(repo, id, key);
|
|
1253
|
+
if (!d) throw new ActionError({ code: 'BAD_REQUEST', message: 'That date isn’t part of this event.' });
|
|
1254
|
+
return d;
|
|
1255
|
+
},
|
|
1256
|
+
}),
|
|
1257
|
+
// …and undo one: the rule before it carries on
|
|
1258
|
+
revertFromDate: defineAction({
|
|
1259
|
+
input: z.object({ id: z.string(), from: z.string().max(20) }),
|
|
1260
|
+
handler: async ({ id, from }, ctx) => {
|
|
1261
|
+
const who = actorOf(await requireCan(ctx, 'content.edit'));
|
|
1262
|
+
const repo = await getRepo(ctx.locals);
|
|
1263
|
+
try {
|
|
1264
|
+
const r = await revertFromDate(repo, who, id, from, await publishHooks(ctx));
|
|
1265
|
+
return { id, changed: r.changed, rsvps: r.rsvps };
|
|
1266
|
+
} catch (e) { asActionError(e); }
|
|
1224
1267
|
},
|
|
1225
1268
|
}),
|
|
1226
1269
|
// Schedule: cancel (or restore) one date of a repeating event, at once
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// the page's language (lib/calendar/event-words.ts, also in the theme SDK).
|
|
7
7
|
import type { CalEvent, Calendar, Settings } from '../lib/schema';
|
|
8
8
|
import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
|
|
9
|
-
import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
|
|
10
|
-
|
|
9
|
+
import { addToCalendarLinks, describeSeries, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
|
|
10
|
+
|
|
11
11
|
import { eventCardHtml, safeHref } from '../lib/calendar/cards';
|
|
12
12
|
import { siteLang, siteLocale } from '../lib/dates';
|
|
13
13
|
import { I18N, localePath, requestLocale } from '../lib/i18n';
|
|
@@ -36,7 +36,7 @@ const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, pr
|
|
|
36
36
|
// the whole event, or just this date of a repeating one
|
|
37
37
|
const cancelled = e.cancelled || Boolean(o.cancelled);
|
|
38
38
|
// a moved date may be at its own place
|
|
39
|
-
const where = o.
|
|
39
|
+
const where = o.location ?? e.location;
|
|
40
40
|
const image = eventImage(e, settings);
|
|
41
41
|
const tz = eventTimeZone(e, settings);
|
|
42
42
|
const locale = siteLocale(settings);
|
|
@@ -49,10 +49,11 @@ const ew = eventStrings(lang);
|
|
|
49
49
|
const here = (path: string) => localePath(path, reqLocale);
|
|
50
50
|
const pageUrl = origin + here(eventUrl(e.slug));
|
|
51
51
|
const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
|
|
52
|
-
|
|
52
|
+
// the rule in effect, and a change from a date on still to come (CalEvent.segments)
|
|
53
|
+
const repeats = describeSeries(e, settings, Date.now(), lang, (ymd) => {
|
|
53
54
|
const [y, m, d] = ymd.split('-').map(Number);
|
|
54
55
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
55
|
-
},
|
|
56
|
+
}, locale);
|
|
56
57
|
const ctaHref = safeHref(e.cta.href);
|
|
57
58
|
const mapHref = where.url
|
|
58
59
|
? safeHref(where.url)
|
|
@@ -17,6 +17,8 @@ import '../../styles/schedule.css';
|
|
|
17
17
|
|
|
18
18
|
type View = 'month' | 'week';
|
|
19
19
|
type MoveUndo = { kind: 'series'; id: string; days: number; minutes?: number }
|
|
20
|
+
// this and following dates: the event's segments as they were (lib/calendar/segments.ts)
|
|
21
|
+
| { kind: 'following'; id: string; from: string; previous: { from: string; start: string; end: string; rrule?: string }[] }
|
|
20
22
|
| { kind: 'date'; id: string; key: string; previous: { start: string; end: string; roomIds?: string[]; note: string } | null }
|
|
21
23
|
| { kind: 'split'; id: string; seriesId: string; occurrence: string };
|
|
22
24
|
|
|
@@ -195,12 +197,12 @@ export default function CalendarBoard(props: Props) {
|
|
|
195
197
|
// ---- moving ----------------------------------------------------------------
|
|
196
198
|
const dayLabel = (d: Iso, o: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }) =>
|
|
197
199
|
new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(utc(d));
|
|
198
|
-
async function move(item: BoardItem, days: number, minutes: number, mode: 'series' | 'one', to: Iso) {
|
|
200
|
+
async function move(item: BoardItem, days: number, minutes: number, mode: 'series' | 'one' | 'following', to: Iso) {
|
|
199
201
|
setAsk(null);
|
|
200
202
|
const r = await actions.events.move({ id: item.id, days, minutes, mode, occurrence: item.occurrence });
|
|
201
203
|
if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move the event.', error: true }); return; }
|
|
202
204
|
const when = !item.allDay && (minutes || view === 'week') ? `${dayLabel(to)}, ${shortTime(Math.max(0, Math.min(1439, item.startMin + minutes)))}` : dayLabel(to);
|
|
203
|
-
setToast({ text: `Moved “${item.title}” to ${when}`, undo: r.data.undo as MoveUndo, warning: r.data.warning || undefined });
|
|
205
|
+
setToast({ text: mode === 'following' ? `Moved “${item.title}” to ${when} and every date after it` : `Moved “${item.title}” to ${when}`, undo: r.data.undo as MoveUndo, warning: r.data.warning || undefined });
|
|
204
206
|
await refresh();
|
|
205
207
|
}
|
|
206
208
|
async function moveBack(it: BoardItem) {
|
|
@@ -612,9 +614,10 @@ export default function CalendarBoard(props: Props) {
|
|
|
612
614
|
<div className="sch-peek-layer center" onMouseDown={(e) => { if (e.target === e.currentTarget) setAsk(null); }}>
|
|
613
615
|
<div className="sch-ask" role="alertdialog" aria-label={`Move ${ask.item.title}`}>
|
|
614
616
|
<b>Move “{ask.item.title}”?</b>
|
|
615
|
-
<p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, or every date in the series?</p>
|
|
617
|
+
<p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, that date and the ones after it (the dates before stay), or every date in the series?</p>
|
|
616
618
|
<div className="sch-ask-actions">
|
|
617
619
|
<button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, ask.minutes, 'one', ask.to)}>Only this date</button>
|
|
620
|
+
<button type="button" className="btn" onClick={() => move(ask.item, ask.days, ask.minutes, 'following', ask.to)}>This and following dates</button>
|
|
618
621
|
<button type="button" className="btn" onClick={() => move(ask.item, ask.days, ask.minutes, 'series', ask.to)}>Every date</button>
|
|
619
622
|
<button type="button" className="btn ghost" onClick={() => setAsk(null)}>Cancel</button>
|
|
620
623
|
</div>
|