@acms-subir/core 0.3.56 → 0.3.57
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 +13 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +116 -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/calendar/api.ts +23 -2
- 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 +9 -2
- 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-service.ts +5 -5
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +24 -0
- package/src/lib/sections.ts +1 -1
- 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/api/calendar/events/index.ts +2 -1
- package/src/pages/members/events/index.astro +84 -6
- package/src/server/index.ts +46 -3
- package/src/styles/editor.css +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.57
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b9eed5e: Change a repeating event from one of its dates on, and keep the dates before it as they were.
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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".
|
|
13
|
+
- **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'`.
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
3
16
|
## 0.3.56
|
|
4
17
|
|
|
5
18
|
### 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,12 +1360,15 @@ 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])
|
|
1355
1367
|
];
|
|
1356
1368
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1357
1369
|
...CONTENT_VERSION_COLUMNS,
|
|
1358
1370
|
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"],
|
|
1371
|
+
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
|
|
1360
1372
|
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
|
|
1361
1373
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1362
1374
|
};
|
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
|
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>
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { actions } from 'astro:actions';
|
|
3
3
|
import { Section } from './Section';
|
|
4
|
+
import { describeRepeat, rruleMap, WEEKDAY_CODES, weekdayOf } from '../../../lib/calendar/rrule';
|
|
4
5
|
|
|
5
6
|
// Event settings → Single dates: the next dates of a saved repeating event,
|
|
6
|
-
// each with Move… and Cancel (or Move back / Restore). These
|
|
7
|
-
// like on the Schedule, without touching unpublished edits: a
|
|
8
|
-
// its sign-ups (they're emailed the new time) and its hosts
|
|
9
|
-
// (lib/calendar/moves.ts).
|
|
7
|
+
// each with Move…, From here on… and Cancel (or Move back / Restore). These
|
|
8
|
+
// apply at once, like on the Schedule, without touching unpublished edits: a
|
|
9
|
+
// moved date keeps its sign-ups (they're emailed the new time) and its hosts
|
|
10
|
+
// (lib/calendar/moves.ts). From here on… changes the series from that date
|
|
11
|
+
// on, and the dates before it stay (lib/calendar/segments.ts); the changes
|
|
12
|
+
// are listed above the dates with Undo. The list shows the event as saved.
|
|
10
13
|
|
|
11
|
-
interface DateRow { key: string; start: string; end: string; when: string; cancelled: boolean; moved: { from: string; note: string } | null; roomIds: string[] }
|
|
12
|
-
interface
|
|
13
|
-
interface
|
|
14
|
+
interface DateRow { key: string; start: string; end: string; when: string; cancelled: boolean; partCancelled: boolean; moved: { from: string; note: string } | null; roomIds: string[] }
|
|
15
|
+
interface SegmentRow { from: string; when: string; repeats: string; rooms: string[]; cancelled: boolean; note: string; current: boolean }
|
|
16
|
+
interface Data { allDay: boolean; roomIds: string[]; dates: DateRow[]; rooms: { id: string; name: string }[]; segments: SegmentRow[] }
|
|
17
|
+
interface Form { key: string; day: string; endDay: string; from: string; to: string; roomIds: string[]; note: string; following?: { rrule: string; keep: string } }
|
|
18
|
+
|
|
19
|
+
// the repeats a change from a date on can take, for its first day: the rule
|
|
20
|
+
// in effect moved there (`keep`), or a common one, ending as `keep` ends
|
|
21
|
+
function repeatOptions(day: string, keep: string): { value: string; label: string }[] {
|
|
22
|
+
const wd = weekdayOf(day);
|
|
23
|
+
const code = WEEKDAY_CODES[wd.dow];
|
|
24
|
+
const p = rruleMap(keep);
|
|
25
|
+
const ends = p.COUNT ? `;COUNT=${p.COUNT}` : p.UNTIL ? `;UNTIL=${p.UNTIL}` : '';
|
|
26
|
+
const rules = [`FREQ=WEEKLY;BYDAY=${code}`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=${code}`, `FREQ=MONTHLY;BYDAY=${wd.nth >= 5 ? -1 : wd.nth}${code}`, `FREQ=MONTHLY;BYMONTHDAY=${wd.day}`, 'FREQ=DAILY']
|
|
27
|
+
.map((r) => r + ends).filter((r) => r !== keep);
|
|
28
|
+
return [{ value: keep, label: `${describeRepeat(keep, day) || 'Same as now'} (as now)` }, ...rules.map((r) => ({ value: r, label: describeRepeat(r, day) })), { value: '', label: 'Just this date (no more after it)' }];
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
const datePart = (w: string) => w.slice(0, 10);
|
|
16
32
|
const timePart = (w: string) => w.slice(11, 16);
|
|
@@ -54,15 +70,50 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
54
70
|
setNote({ text: warning ? `${done} ${warning}` : done });
|
|
55
71
|
await load();
|
|
56
72
|
};
|
|
73
|
+
// From here on…: the date's times, the rule in effect there and its rooms to start with
|
|
74
|
+
const openFollowing = async (d: DateRow) => {
|
|
75
|
+
setNote(null);
|
|
76
|
+
const r = await actions.events.fromDateDefaults({ id: eventId, key: d.key });
|
|
77
|
+
if (r.error || !r.data) { setNote({ text: r.error?.message ?? 'Couldn’t open that date.', error: true }); return; }
|
|
78
|
+
const x = r.data;
|
|
79
|
+
setForm({ key: d.key, day: datePart(x.start), endDay: datePart(x.end), from: timePart(x.start), to: timePart(x.end), roomIds: x.roomIds, note: '', following: { rrule: x.rrule, keep: x.rrule } });
|
|
80
|
+
};
|
|
57
81
|
const save = (f: Form) => {
|
|
58
82
|
const start = data.allDay ? f.day : `${f.day}T${f.from}`;
|
|
59
83
|
// a time that ends before it starts runs past midnight
|
|
60
84
|
const end = data.allDay ? (f.endDay >= f.day ? f.endDay : f.day) : `${f.to <= f.from ? nextDay(f.day) : f.day}T${f.to}`;
|
|
85
|
+
if (f.following) {
|
|
86
|
+
// the kept rule follows the new first day on the server; a chosen one is sent as it is
|
|
87
|
+
const rrule = f.following.rrule === f.following.keep ? undefined : f.following.rrule;
|
|
88
|
+
return run(f.key, async () => {
|
|
89
|
+
const r = await actions.events.changeFromDate({ id: eventId, key: f.key, start, end, rrule, roomIds: f.roomIds, note: f.note });
|
|
90
|
+
if (r.data && !r.data.changed) return { data: { warning: '' } };
|
|
91
|
+
return r;
|
|
92
|
+
}, 'Changed from this date on. Dates before it stay as they were, and people who signed up were emailed their new dates.');
|
|
93
|
+
}
|
|
61
94
|
return run(f.key, () => actions.events.moveDate({ id: eventId, key: f.key, start, end, roomIds: f.roomIds, note: f.note }), 'Moved. People who signed up were emailed the new time.');
|
|
62
95
|
};
|
|
63
96
|
|
|
64
97
|
return (
|
|
65
|
-
<Section id="ev-dates" title="Single dates" help="Move or cancel one date. It applies at once, even on a published event, and people who signed up
|
|
98
|
+
<Section id="ev-dates" title="Single dates" help="Move or cancel one date, or change every date from one on. It applies at once, even on a published event, and people who signed up are emailed.">
|
|
99
|
+
{data.segments.length > 0 && (
|
|
100
|
+
<ul className="pe-evd pe-evd-segs">
|
|
101
|
+
{data.segments.map((g) => (
|
|
102
|
+
<li key={g.from} className={`pe-evd-row${g.cancelled ? ' is-cancelled' : ' is-moved'}`}>
|
|
103
|
+
<div className="pe-evd-main">
|
|
104
|
+
<span className="pe-evd-when">{g.cancelled ? `Cancelled from ${g.when}` : `From ${g.when}: ${g.repeats}`}</span>
|
|
105
|
+
{(g.rooms.length > 0 || g.note) && <small className="pe-evd-state">{[g.rooms.join(', '), g.note && `“${g.note}”`].filter(Boolean).join(' · ')}</small>}
|
|
106
|
+
</div>
|
|
107
|
+
{!g.current && (
|
|
108
|
+
<div className="pe-evd-acts">
|
|
109
|
+
<button type="button" className="pe-btn sm" disabled={busy === g.from}
|
|
110
|
+
onClick={() => { if (confirm(`Undo the change from ${g.when}? Those dates go back to the pattern before it, and people who signed up are emailed.`)) void run(g.from, () => actions.events.revertFromDate({ id: eventId, from: g.from }), 'Change undone.'); }}>Undo</button>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ul>
|
|
116
|
+
)}
|
|
66
117
|
<ul className="pe-evd">
|
|
67
118
|
{data.dates.map((d) => (
|
|
68
119
|
<li key={d.key} className={`pe-evd-row${d.cancelled ? ' is-cancelled' : d.moved ? ' is-moved' : ''}`}>
|
|
@@ -72,11 +123,12 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
72
123
|
{d.moved && <small className="pe-evd-state">Moved from {d.moved.from}{d.moved.note ? ` · “${d.moved.note}”` : ''}</small>}
|
|
73
124
|
</div>
|
|
74
125
|
<div className="pe-evd-acts">
|
|
75
|
-
{d.cancelled ? (
|
|
126
|
+
{d.partCancelled ? null : d.cancelled ? (
|
|
76
127
|
<button type="button" className="pe-btn sm" disabled={busy === d.key} onClick={() => run(d.key, () => actions.events.cancelDate({ id: eventId, occurrence: d.key, restore: true }), 'Restored.')}>Restore</button>
|
|
77
128
|
) : (
|
|
78
129
|
<>
|
|
79
|
-
<button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key ? setForm(null) : open(d))} aria-expanded={form?.key === d.key}>Move…</button>
|
|
130
|
+
<button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key && !form.following ? setForm(null) : open(d))} aria-expanded={form?.key === d.key && !form.following}>Move…</button>
|
|
131
|
+
<button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key && form.following ? setForm(null) : void openFollowing(d))} aria-expanded={Boolean(form?.key === d.key && form.following)}>From here on…</button>
|
|
80
132
|
{d.moved && <button type="button" className="pe-btn sm" disabled={busy === d.key} onClick={() => run(d.key, () => actions.events.moveDateBack({ id: eventId, key: d.key }), 'Moved back. People who signed up were emailed.')}>Move back</button>}
|
|
81
133
|
<button type="button" className="pe-btn ghost sm" disabled={busy === d.key}
|
|
82
134
|
onClick={() => { if (confirm(`Cancel ${d.when}? It shows as cancelled straight away, and people who signed up are emailed.`)) void run(d.key, () => actions.events.cancelDate({ id: eventId, occurrence: d.key, restore: false }), 'Cancelled.'); }}>Cancel date</button>
|
|
@@ -86,7 +138,7 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
86
138
|
{form?.key === d.key && (
|
|
87
139
|
<form className="pe-evd-form" onSubmit={(e) => { e.preventDefault(); void save(form); }}>
|
|
88
140
|
<div className="pe-evd-fields">
|
|
89
|
-
<label className="pe-field"><span>{data.allDay ? 'From' : 'Date'}</span><input type="date" required value={form.day} onChange={(e) => setForm({ ...form, day: e.target.value })} /></label>
|
|
141
|
+
<label className="pe-field"><span>{form.following ? 'First date' : data.allDay ? 'From' : 'Date'}</span><input type="date" required value={form.day} onChange={(e) => setForm({ ...form, day: e.target.value })} /></label>
|
|
90
142
|
{data.allDay ? (
|
|
91
143
|
<label className="pe-field"><span>To</span><input type="date" required value={form.endDay} min={form.day} onChange={(e) => setForm({ ...form, endDay: e.target.value })} /></label>
|
|
92
144
|
) : (
|
|
@@ -96,9 +148,17 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
96
148
|
</>
|
|
97
149
|
)}
|
|
98
150
|
</div>
|
|
151
|
+
{form.following && (
|
|
152
|
+
<label className="pe-field"><span>Repeats</span>
|
|
153
|
+
<select value={form.following.rrule} onChange={(e) => setForm({ ...form, following: { ...form.following!, rrule: e.target.value } })}>
|
|
154
|
+
{repeatOptions(form.day, form.following.keep).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
155
|
+
</select>
|
|
156
|
+
</label>
|
|
157
|
+
)}
|
|
158
|
+
{form.following && <p className="pe-evd-hint">The new first date can be in the same week as this one. Dates before it stay as they are. Sign-ups, cancelled and moved dates go to the nearest new date within 3½ days; sign-ups with none are cancelled. People are emailed.</p>}
|
|
99
159
|
{data.rooms.length > 0 && (
|
|
100
160
|
<fieldset className="pe-evd-rooms">
|
|
101
|
-
<legend>Rooms for this date</legend>
|
|
161
|
+
<legend>{form.following ? 'Rooms from then on' : 'Rooms for this date'}</legend>
|
|
102
162
|
{data.rooms.map((r) => (
|
|
103
163
|
<label key={r.id} className="pe-evd-room">
|
|
104
164
|
<input type="checkbox" checked={form.roomIds.includes(r.id)}
|
|
@@ -108,11 +168,11 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
108
168
|
))}
|
|
109
169
|
</fieldset>
|
|
110
170
|
)}
|
|
111
|
-
<label className="pe-field"><span>Note <em>shown with the date, optional</em></span>
|
|
112
|
-
<input type="text" maxLength={500} value={form.note} placeholder=
|
|
171
|
+
<label className="pe-field"><span>Note <em>{form.following ? 'in the email to people who signed up, optional' : 'shown with the date, optional'}</em></span>
|
|
172
|
+
<input type="text" maxLength={500} value={form.note} placeholder={form.following ? 'We meet on Wednesdays from now on' : 'Moved for the youth retreat'} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
|
113
173
|
</label>
|
|
114
174
|
<div className="pe-evd-formacts">
|
|
115
|
-
<button type="submit" className="pe-btn primary sm" disabled={busy === form.key}>{busy === form.key ? 'Moving…' : 'Move this date'}</button>
|
|
175
|
+
<button type="submit" className="pe-btn primary sm" disabled={busy === form.key}>{busy === form.key ? (form.following ? 'Changing…' : 'Moving…') : form.following ? 'Change from this date on' : 'Move this date'}</button>
|
|
116
176
|
<button type="button" className="pe-btn ghost sm" onClick={() => setForm(null)}>Never mind</button>
|
|
117
177
|
</div>
|
|
118
178
|
</form>
|