@acms-subir/core 0.3.55 → 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 +29 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- 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 +212 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- 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/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- 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/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -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);
|
|
@@ -28,12 +44,22 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
28
44
|
};
|
|
29
45
|
useEffect(() => { if (eventId && repeats) void load(); }, [eventId, repeats, limit]);
|
|
30
46
|
|
|
31
|
-
if (!eventId || !repeats || !data || !data.dates.length) return null;
|
|
32
|
-
|
|
33
47
|
const open = (d: DateRow) => {
|
|
34
48
|
setNote(null);
|
|
35
49
|
setForm({ key: d.key, day: datePart(d.start), endDay: datePart(d.end), from: timePart(d.start), to: timePart(d.end), roomIds: d.roomIds, note: d.moved?.note ?? '' });
|
|
36
50
|
};
|
|
51
|
+
// ?move=<key> (approving a request to change one date, Calendar → Requests)
|
|
52
|
+
// opens that date's Move…, looking further ahead when it isn't in the first few
|
|
53
|
+
const [wanted, setWanted] = useState(() => (typeof location === 'undefined' ? '' : new URLSearchParams(location.search).get('move') ?? ''));
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!wanted || !data) return;
|
|
56
|
+
const d = data.dates.find((x) => x.key === wanted);
|
|
57
|
+
if (d) { open(d); setWanted(''); requestAnimationFrame(() => document.getElementById('ev-dates')?.scrollIntoView({ block: 'start' })); }
|
|
58
|
+
else if (limit < 52) setLimit(52);
|
|
59
|
+
else setWanted('');
|
|
60
|
+
}, [data, wanted]);
|
|
61
|
+
|
|
62
|
+
if (!eventId || !repeats || !data || !data.dates.length) return null;
|
|
37
63
|
const run = async (key: string, work: () => Promise<{ error?: { message: string }; data?: object }>, done: string) => {
|
|
38
64
|
setBusy(key);
|
|
39
65
|
const r = await work();
|
|
@@ -44,15 +70,50 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
44
70
|
setNote({ text: warning ? `${done} ${warning}` : done });
|
|
45
71
|
await load();
|
|
46
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
|
+
};
|
|
47
81
|
const save = (f: Form) => {
|
|
48
82
|
const start = data.allDay ? f.day : `${f.day}T${f.from}`;
|
|
49
83
|
// a time that ends before it starts runs past midnight
|
|
50
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
|
+
}
|
|
51
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.');
|
|
52
95
|
};
|
|
53
96
|
|
|
54
97
|
return (
|
|
55
|
-
<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
|
+
)}
|
|
56
117
|
<ul className="pe-evd">
|
|
57
118
|
{data.dates.map((d) => (
|
|
58
119
|
<li key={d.key} className={`pe-evd-row${d.cancelled ? ' is-cancelled' : d.moved ? ' is-moved' : ''}`}>
|
|
@@ -62,11 +123,12 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
62
123
|
{d.moved && <small className="pe-evd-state">Moved from {d.moved.from}{d.moved.note ? ` · “${d.moved.note}”` : ''}</small>}
|
|
63
124
|
</div>
|
|
64
125
|
<div className="pe-evd-acts">
|
|
65
|
-
{d.cancelled ? (
|
|
126
|
+
{d.partCancelled ? null : d.cancelled ? (
|
|
66
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>
|
|
67
128
|
) : (
|
|
68
129
|
<>
|
|
69
|
-
<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>
|
|
70
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>}
|
|
71
133
|
<button type="button" className="pe-btn ghost sm" disabled={busy === d.key}
|
|
72
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>
|
|
@@ -76,7 +138,7 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
76
138
|
{form?.key === d.key && (
|
|
77
139
|
<form className="pe-evd-form" onSubmit={(e) => { e.preventDefault(); void save(form); }}>
|
|
78
140
|
<div className="pe-evd-fields">
|
|
79
|
-
<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>
|
|
80
142
|
{data.allDay ? (
|
|
81
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>
|
|
82
144
|
) : (
|
|
@@ -86,9 +148,17 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
86
148
|
</>
|
|
87
149
|
)}
|
|
88
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>}
|
|
89
159
|
{data.rooms.length > 0 && (
|
|
90
160
|
<fieldset className="pe-evd-rooms">
|
|
91
|
-
<legend>Rooms for this date</legend>
|
|
161
|
+
<legend>{form.following ? 'Rooms from then on' : 'Rooms for this date'}</legend>
|
|
92
162
|
{data.rooms.map((r) => (
|
|
93
163
|
<label key={r.id} className="pe-evd-room">
|
|
94
164
|
<input type="checkbox" checked={form.roomIds.includes(r.id)}
|
|
@@ -98,11 +168,11 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
98
168
|
))}
|
|
99
169
|
</fieldset>
|
|
100
170
|
)}
|
|
101
|
-
<label className="pe-field"><span>Note <em>shown with the date, optional</em></span>
|
|
102
|
-
<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 })} />
|
|
103
173
|
</label>
|
|
104
174
|
<div className="pe-evd-formacts">
|
|
105
|
-
<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>
|
|
106
176
|
<button type="button" className="pe-btn ghost sm" onClick={() => setForm(null)}>Never mind</button>
|
|
107
177
|
</div>
|
|
108
178
|
</form>
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -5,6 +5,7 @@ import { BRAND } from '../lib/brand';
|
|
|
5
5
|
import { CONTENT } from '../lib/content';
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
7
|
import { ANNOUNCEMENTS } from '../lib/announcements/config';
|
|
8
|
+
import { REQUESTS } from '../lib/calendar/request-config';
|
|
8
9
|
import { OWNER_ENV } from '../lib/auth';
|
|
9
10
|
import { validateRequestSession } from '../lib/auth/request';
|
|
10
11
|
import { accessFor } from '../lib/permissions/access';
|
|
@@ -36,6 +37,8 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
|
36
37
|
]);
|
|
37
38
|
// pending booking requests, shown as a count on the Bookings nav item
|
|
38
39
|
const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
|
|
40
|
+
// event requests waiting for staff (acms({ requests })): a count on Requests
|
|
41
|
+
const pendingAsks = REQUESTS.enabled ? await repo.requests.count({ states: ['pending'] }) : 0;
|
|
39
42
|
const me = {
|
|
40
43
|
name: sessionUser?.name || 'Admin',
|
|
41
44
|
role: roleLabel(sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'),
|
|
@@ -74,6 +77,7 @@ const icons: Record<string, string> = {
|
|
|
74
77
|
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
|
|
75
78
|
shield: 'M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4',
|
|
76
79
|
calendar: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
|
|
80
|
+
inbox: 'M3 13h5l1.5 3h5L16 13h5M5.5 5h13L21 13v6H3v-6z',
|
|
77
81
|
calendars: 'M4 6h.01M4 12h.01M4 18h.01M8 6h12M8 12h12M8 18h12',
|
|
78
82
|
bookings: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
79
83
|
resources: 'M3 21V9l9-5 9 5v12M9 21v-6h6v6',
|
|
@@ -130,6 +134,9 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
130
134
|
: []),
|
|
131
135
|
// TV screens: saved display links (lib/display), with private details behind their key
|
|
132
136
|
...(can('schedule.manage') ? [{ key: 'calendar:screens', label: 'Screens', href: '/admin/calendar/screens', icon: icons.monitor, count: null }] : []),
|
|
137
|
+
...(REQUESTS.enabled && can('content.edit')
|
|
138
|
+
? [{ key: 'calendar:requests', label: 'Requests', href: '/admin/calendar/requests', icon: icons.inbox, count: pendingAsks || null, attention: pendingAsks > 0 }]
|
|
139
|
+
: []),
|
|
133
140
|
],
|
|
134
141
|
}]
|
|
135
142
|
: []),
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource
|
|
|
7
7
|
import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
|
|
8
8
|
import { describeRepeat } from './rrule';
|
|
9
9
|
import { localizeForm } from '../forms/localize';
|
|
10
|
+
import { currentPart, seriesParts } from './events';
|
|
10
11
|
|
|
11
12
|
export interface EventJson {
|
|
12
13
|
id: string;
|
|
@@ -23,7 +24,10 @@ export interface EventJson {
|
|
|
23
24
|
allDay: boolean;
|
|
24
25
|
timezone: string;
|
|
25
26
|
rrule: string;
|
|
27
|
+
// the repeat in words for the part of the series in effect now (a segment's, once it took over)
|
|
26
28
|
repeats: string;
|
|
29
|
+
// the series changed from a date on (CalEvent.segments), in order; [] = never
|
|
30
|
+
segments: SegmentJson[];
|
|
27
31
|
exdates: string[];
|
|
28
32
|
cancelledDates: string[];
|
|
29
33
|
// single dates at another time, by original occurrence key: the new wall
|
|
@@ -44,6 +48,18 @@ export interface EventJson {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface RoomJson { slug: string; name: string; color: string | null }
|
|
51
|
+
/** One segment of a series: from which date (an occurrence key), its first date, rule, words, rooms and place (null = the event's) */
|
|
52
|
+
export interface SegmentJson {
|
|
53
|
+
from: string;
|
|
54
|
+
start: string;
|
|
55
|
+
end: string;
|
|
56
|
+
rrule: string;
|
|
57
|
+
repeats: string;
|
|
58
|
+
cancelled: boolean;
|
|
59
|
+
note: string;
|
|
60
|
+
location: Pick<CalEvent['location'], 'name' | 'address' | 'url'> | null;
|
|
61
|
+
rooms: RoomJson[];
|
|
62
|
+
}
|
|
47
63
|
export type PublicMovedDate = Pick<MovedDate, 'start' | 'end' | 'note'> & Partial<MovedDate>;
|
|
48
64
|
|
|
49
65
|
/** moved dates as public data: where and why, not who */
|
|
@@ -85,8 +101,13 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
85
101
|
allDay: e.allDay,
|
|
86
102
|
timezone: e.timezone || siteTz || 'UTC',
|
|
87
103
|
rrule: e.rrule,
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
// the part of the series in effect now (a change from a date on, once it took over), in the request's language
|
|
105
|
+
repeats: ((p) => describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale))(currentPart(e, { timezone: siteTz })),
|
|
106
|
+
segments: seriesParts(e).slice(1).map((p) => ({
|
|
107
|
+
from: p.from, start: p.start, end: p.end, rrule: p.rrule, repeats: describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale), cancelled: p.cancelled, note: p.note,
|
|
108
|
+
location: p.location ? { name: p.location.name, address: p.location.address, url: p.location.url } : null,
|
|
109
|
+
rooms: roomsToJson(p.roomIds, opts.resources, opts.includePrivate),
|
|
110
|
+
})), exdates: e.exdates,
|
|
90
111
|
cancelledDates: e.cancelledDates ?? [],
|
|
91
112
|
movedDates: movedDatesToJson(e, opts.includePrivate),
|
|
92
113
|
location: e.location,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
6
|
import type { CalEvent, Settings } from '../schema';
|
|
7
7
|
import { collectViewEvents } from './data';
|
|
8
|
-
import { occurrenceKey, safeColor } from './events';
|
|
8
|
+
import { occurrenceKey, partOf, safeColor } from './events';
|
|
9
9
|
import { tzParts, zonedToUtc } from './ics';
|
|
10
10
|
import { addDays, isIsoDay, type GridItem, type Iso } from './grid';
|
|
11
11
|
import { bookingsEnabled } from './config';
|
|
@@ -134,7 +134,8 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
134
134
|
calendarName: cal?.name ?? '',
|
|
135
135
|
status: e ? eventBoardStatus(e) : 'feed',
|
|
136
136
|
recurring: Boolean(e?.rrule),
|
|
137
|
-
|
|
137
|
+
// the rule of the part of the series this date is in (CalEvent.segments)
|
|
138
|
+
repeat: e?.rrule ? ((p) => describeRepeat(p.rrule, p.start))(partOf(e, { segment: v.segment })) : '',
|
|
138
139
|
rooms: e ? (v.rooms?.map((r) => r.id) ?? e.roomHold.resourceIds).map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
|
|
139
140
|
roomsHeld: e ? holdsRooms(e) : false,
|
|
140
141
|
occurrence,
|
|
@@ -28,7 +28,7 @@ const esc = (s: string) =>
|
|
|
28
28
|
export const safeHref = (h: string) => (/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(h.trim()) ? h.trim() : '');
|
|
29
29
|
|
|
30
30
|
export function eventCardHtml(
|
|
31
|
-
o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved'] },
|
|
31
|
+
o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved']; location?: Occurrence['location'] },
|
|
32
32
|
settings: Settings,
|
|
33
33
|
opts: { showImage: boolean; showCta?: boolean; href?: string; heading?: 'h2' | 'h3' }
|
|
34
34
|
): string {
|
|
@@ -48,7 +48,7 @@ export function eventCardHtml(
|
|
|
48
48
|
? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(image.src)}" alt="${esc(image.alt)}" loading="lazy" /></a>`
|
|
49
49
|
: `<a class="pc-ev-card-media is-tile" href="${esc(href)}" tabindex="-1" aria-hidden="true"><span class="pc-ev-tile"><b>${esc(f.month)}</b><span>${esc(f.day)}</span></span></a>`
|
|
50
50
|
: '';
|
|
51
|
-
const loc = locationLine(o.
|
|
51
|
+
const loc = locationLine(o.location ?? e.location);
|
|
52
52
|
const ctaHref = safeHref(e.cta.href);
|
|
53
53
|
const cta =
|
|
54
54
|
opts.showCta && e.cta.label && ctaHref && !cancelled
|
|
@@ -80,11 +80,11 @@ export async function upcomingEvents(
|
|
|
80
80
|
displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
|
|
81
81
|
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
|
|
82
82
|
const seen = new Set<string>();
|
|
83
|
-
const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'> & Pick<ViewEvent, 'rooms'>)[] = [];
|
|
83
|
+
const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved' | 'location'> & Pick<ViewEvent, 'rooms'>)[] = [];
|
|
84
84
|
for (const v of events) {
|
|
85
85
|
if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
|
|
86
86
|
seen.add(v.event.id);
|
|
87
|
-
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}), rooms: v.rooms ?? [] });
|
|
87
|
+
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}), ...(v.place ? { location: v.place } : {}), rooms: v.rooms ?? [] });
|
|
88
88
|
if (out.length >= opts.limit) break;
|
|
89
89
|
}
|
|
90
90
|
return out;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Each migration's column lists are frozen: a later events column joins by a
|
|
17
17
|
// new migration that recreates these triggers through eventChangeTriggers()
|
|
18
|
-
// (the test that sorts every column fails until then). v66 added moved_dates
|
|
18
|
+
// (the test that sorts every column fails until then). v66 added moved_dates,
|
|
19
|
+
// v70 segments.
|
|
19
20
|
const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
20
21
|
|
|
21
22
|
// v65's lists
|
|
@@ -27,9 +28,9 @@ const V65_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cove
|
|
|
27
28
|
'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
|
|
28
29
|
|
|
29
30
|
/** the columns copied into each snapshot (what the webhook payload and push rules read) */
|
|
30
|
-
export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates'];
|
|
31
|
+
export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates', 'segments'];
|
|
31
32
|
/** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
|
|
32
|
-
export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates'];
|
|
33
|
+
export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates', 'segments'];
|
|
33
34
|
/** columns no change is recorded for */
|
|
34
35
|
export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
|
|
35
36
|
|
|
@@ -88,3 +89,16 @@ export const MOVED_DATES_MIGRATION = (contentVersionUpdate: string) => ({ versio
|
|
|
88
89
|
`DROP TRIGGER event_changes_delete`,
|
|
89
90
|
...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates'], [...V65_COLUMNS, 'moved_dates']),
|
|
90
91
|
] });
|
|
92
|
+
|
|
93
|
+
// v70: a series changed from one date on (CalEvent.segments: its rule, times,
|
|
94
|
+
// rooms and place from then), a column on the row like moved_dates: public,
|
|
95
|
+
// so it bumps the content version and its changes are recorded
|
|
96
|
+
export const SEGMENTS_MIGRATION = (contentVersionUpdate: string) => ({ version: 70, statements: [
|
|
97
|
+
`ALTER TABLE events ADD COLUMN segments TEXT NOT NULL DEFAULT '[]'`,
|
|
98
|
+
`DROP TRIGGER content_version_events_update`,
|
|
99
|
+
contentVersionUpdate,
|
|
100
|
+
`DROP TRIGGER event_changes_insert`,
|
|
101
|
+
`DROP TRIGGER event_changes_update`,
|
|
102
|
+
`DROP TRIGGER event_changes_delete`,
|
|
103
|
+
...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates', 'segments'], [...V65_COLUMNS, 'moved_dates', 'segments']),
|
|
104
|
+
] });
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// details, lib/display: published events only, private calendars when it
|
|
8
8
|
// names them, bookings with the booker's name, never emails, phones or notes).
|
|
9
9
|
import type { ContentRepository } from '../repo/types';
|
|
10
|
-
import { roomColor, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
10
|
+
import { roomColor, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
|
|
11
11
|
import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
|
|
12
12
|
import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
|
|
13
13
|
import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
|
|
@@ -31,6 +31,10 @@ export interface ViewEvent extends CalendarEvent {
|
|
|
31
31
|
key?: string;
|
|
32
32
|
// this date was moved here: where it was (UTC ms) and the public note
|
|
33
33
|
moved?: OccurrenceMove;
|
|
34
|
+
// native events changed from a date on: the segment this date belongs to (its `from` key)
|
|
35
|
+
segment?: string;
|
|
36
|
+
// native events: the date's own place (moved there, or its segment's), when not the event's
|
|
37
|
+
place?: EventLocation;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// color: '#rrggbb', or '' when the room has none
|
|
@@ -115,7 +119,8 @@ export async function collectViewEvents(
|
|
|
115
119
|
events.push({
|
|
116
120
|
start: o.start, end: o.end, allDay: o.allDay, days: o.days,
|
|
117
121
|
title: e.title || words.untitled,
|
|
118
|
-
|
|
122
|
+
// the date's own place (moved there, or its segment's), else the event's
|
|
123
|
+
location: locationLine(o.location ?? e.location),
|
|
119
124
|
description: e.excerpt,
|
|
120
125
|
cal: cal ? idx.get(cal.id) ?? 0 : 0,
|
|
121
126
|
ref: e.id,
|
|
@@ -129,6 +134,8 @@ export async function collectViewEvents(
|
|
|
129
134
|
rooms: rooms(o.roomIds),
|
|
130
135
|
key: o.key,
|
|
131
136
|
...(o.moved ? { moved: o.moved } : {}),
|
|
137
|
+
...(o.segment ? { segment: o.segment } : {}),
|
|
138
|
+
...(o.location ? { place: o.location } : {}),
|
|
132
139
|
});
|
|
133
140
|
}
|
|
134
141
|
|
|
@@ -22,6 +22,9 @@ export interface EventStrings {
|
|
|
22
22
|
moved: string;
|
|
23
23
|
dateMoved: string;
|
|
24
24
|
dateMovedNote: string;
|
|
25
|
+
/** the series changed from a date on (describeSeries): "From {date}: {repeat}", and every date cancelled from one on */
|
|
26
|
+
fromDate: string;
|
|
27
|
+
cancelledFrom: string;
|
|
25
28
|
/** the side column's landmark label */
|
|
26
29
|
details: string;
|
|
27
30
|
joinOnline: string;
|
|
@@ -135,6 +138,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
135
138
|
cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
|
|
136
139
|
dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
|
|
137
140
|
moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
|
|
141
|
+
fromDate: 'From {date}: {repeat}', cancelledFrom: 'Cancelled from {date}',
|
|
138
142
|
details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
|
|
139
143
|
directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
|
|
140
144
|
addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
|
|
@@ -168,6 +172,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
168
172
|
cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
|
|
169
173
|
dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
|
|
170
174
|
moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
|
|
175
|
+
fromDate: 'Desde el {date}: {repeat}', cancelledFrom: 'Cancelado desde el {date}',
|
|
171
176
|
details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
|
|
172
177
|
directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
|
|
173
178
|
addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
|
|
@@ -201,6 +206,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
201
206
|
cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
|
|
202
207
|
dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
|
|
203
208
|
moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
|
|
209
|
+
fromDate: 'À partir du {date} : {repeat}', cancelledFrom: 'Annulé à partir du {date}',
|
|
204
210
|
details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
|
|
205
211
|
directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
|
|
206
212
|
addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
|
|
@@ -234,6 +240,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
234
240
|
cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
|
|
235
241
|
dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
|
|
236
242
|
moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
|
|
243
|
+
fromDate: 'Ab {date}: {repeat}', cancelledFrom: 'Abgesagt ab {date}',
|
|
237
244
|
details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
|
|
238
245
|
directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
|
|
239
246
|
addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
|
|
@@ -267,6 +274,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
267
274
|
cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
|
|
268
275
|
dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
|
|
269
276
|
moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
|
|
277
|
+
fromDate: 'A partir de {date}: {repeat}', cancelledFrom: 'Cancelado a partir de {date}',
|
|
270
278
|
details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
|
|
271
279
|
directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
|
|
272
280
|
addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
|