@acms-subir/core 0.3.50 → 0.3.51

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.51
4
+
5
+ ### Patch Changes
6
+
7
+ - 1efe99e: Section event items (an events source, as theme sections and the apps' native sections receive them) carry `rooms`: the public rooms each date holds, as `{ slug, name, color }`. A moved date lists its own rooms, and only rooms set to "Name it on public events" are included, with `color` `null` when a room has none. A Classes card can now name the room without staff typing it into the place. `upcomingEvents()` in the theme SDK returns `rooms` too, as `{ id, slug, name, color }`. The library's Upcoming events section shows "Moved · " before a moved date, with "Moved from …" and the note. `EventItem` and its sibling types now live in `lib/section-items.ts`, which has no imports, and `EventItemRoom` is exported from `@acms-subir/core/theme`.
8
+
9
+ The app API's `event.repeats` (`/api/v1/events`, `/api/v1/events/:slug` and search) now follows `?locale=`: "Todos los sábados, hasta el 1 jun 2030" instead of always English. Without a locale it uses the site's main language, and the until date is written in that language too. `eventToJson` takes a `locale` option for it. The REST API and webhooks keep English.
10
+
3
11
  ## 0.3.50
4
12
 
5
13
  ### Patch Changes
@@ -55,7 +55,7 @@ const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astr
55
55
  </section>}
56
56
  ```
57
57
 
58
- `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them, and a date moved to another time carries `moved` (`{ from: { start, end }, note }`) so templates can say "Moved from …". `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
58
+ `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them, and a date moved to another time carries `moved` (`{ from: { start, end }, note }`) so templates can say "Moved from …". `rooms` lists the public rooms each date holds (`{ id, slug, name, color }`, only rooms set to show on public events). Section `EventItem`s from an events source carry `moved` (`{ from: "Thu, Oct 1 · 6:00 PM", note }` or `null`) and `rooms` (`{ slug, name, color }[]`, `color` `null` when the room has none). `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
59
59
 
60
60
  Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
61
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,8 +24,9 @@ const fit = data.imageFit === 'contain' ? 'contain' : 'cover';
24
24
  ? <a class="upev-media" href={e.url} tabindex="-1" aria-hidden="true"><img src={image.src} alt="" loading="lazy" /></a>
25
25
  : <span class="upev-date" aria-hidden="true"><b>{e.day}</b>{e.month}</span>}
26
26
  <div class="upev-body">
27
- <span class="upev-when">{e.cancelled ? 'Cancelled · ' : ''}{e.when}</span>
27
+ <span class="upev-when">{e.cancelled ? 'Cancelled · ' : e.moved ? 'Moved · ' : ''}{e.when}</span>
28
28
  <h3 class="upev-title"><a href={e.url}>{e.title}</a></h3>
29
+ {!e.cancelled && e.moved && <span class="upev-moved">Moved from {e.moved.from}{e.moved.note ? ` · ${e.moved.note}` : ''}</span>}
29
30
  {data.showSummary && e.summary && <p class="upev-summary">{e.summary}</p>}
30
31
  {data.showLocation !== false && e.location && <span class="upev-loc">{e.location}</span>}
31
32
  </div>
@@ -59,5 +60,6 @@ const fit = data.imageFit === 'contain' ? 'contain' : 'cover';
59
60
  .upev-title a { color: inherit; text-decoration: none; }
60
61
  .upev-title a:hover { text-decoration: underline; }
61
62
  .upev-loc { font-size: 0.9rem; color: var(--muted); }
63
+ .upev-moved { font-size: 0.9rem; color: var(--accent, inherit); }
62
64
  .is-cancelled .upev-title { text-decoration: line-through; opacity: 0.7; }
63
65
  </style>
@@ -59,8 +59,16 @@ export function roomsToJson(ids: string[], resources: Map<string, Resource> | un
59
59
 
60
60
  const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
61
61
 
62
- // resources: every room by id (rooms stay [] without it)
63
- export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource> } = {}): EventJson {
62
+ // a repeat's "until" date in that language ("12 jun 2027")
63
+ const untilDate = (locale: string) => (ymd: string) => {
64
+ const [y, m, d] = ymd.split('-').map(Number);
65
+ try { return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); }
66
+ catch { return ymd; }
67
+ };
68
+
69
+ // resources: every room by id (rooms stay [] without it); locale: the
70
+ // language of the repeat line ("Cada semana el sábado"; English without it)
71
+ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource>; locale?: string } = {}): EventJson {
64
72
  return {
65
73
  id: e.id,
66
74
  slug: e.slug,
@@ -76,7 +84,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
76
84
  allDay: e.allDay,
77
85
  timezone: e.timezone || siteTz || 'UTC',
78
86
  rrule: e.rrule,
79
- repeats: describeRepeat(e.rrule, e.start),
87
+ repeats: describeRepeat(e.rrule, e.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale),
80
88
  exdates: e.exdates,
81
89
  cancelledDates: e.cancelledDates ?? [],
82
90
  movedDates: movedDatesToJson(e, opts.includePrivate),
@@ -8,7 +8,7 @@ import { eventStrings } from './event-words';
8
8
  import { eventImage } from './images';
9
9
  import { eventUrl } from './config';
10
10
  import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
11
- import { collectViewEvents } from './data';
11
+ import { collectViewEvents, type ViewEvent } from './data';
12
12
  import type { ResolvedEventFilter } from './filter';
13
13
 
14
14
  export interface EventCardAttrs {
@@ -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'>)[] = [];
83
+ const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'> & 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 } : {}) });
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 ?? [] });
88
88
  if (out.length >= opts.limit) break;
89
89
  }
90
90
  return out;
@@ -11,6 +11,7 @@ import { sha256Hex } from '../api-keys';
11
11
  import type { PublicContext } from './route';
12
12
  import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
13
13
  import { siteOrigin } from '../seo';
14
+ import { siteLang } from '../dates';
14
15
  import { safeUrl } from './content';
15
16
  import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
16
17
  import { localizeDoc } from '../translations';
@@ -116,7 +117,8 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
116
117
  }
117
118
  // e is already localized; publicCalendars limits which calendars are named
118
119
  export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
119
- const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources });
120
+ // the repeat line in the request's language, else the site's main one
121
+ const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources, locale: locale || siteLang(settings) });
120
122
  event.url = localizedUrl(event.url, origin, locale);
121
123
  // Only configured public event fields; no undeclared imported metadata, no private fields.
122
124
  event.fields = publicEventFields(e.fields);
@@ -0,0 +1,24 @@
1
+ // The item shapes section data carries (lib/sections.ts fills them): what a
2
+ // theme's section components and the apps' native sections receive.
3
+ // No imports, so other packages can typecheck against them
4
+ // (packages/native keeps its own copy; test/section-items.test.ts there
5
+ // keeps the two in step).
6
+
7
+ export interface LinkValue { label: string; href: string; external: boolean }
8
+ /** a room this date holds, named only when set to show on public events; color null = none */
9
+ export interface EventItemRoom { slug: string; name: string; color: string | null }
10
+ export interface EventItem {
11
+ title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
12
+ start: string; end: string; allDay: boolean; cancelled: boolean;
13
+ /** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
14
+ moved: { from: string; note: string } | null;
15
+ /** the public rooms this date holds (a moved date's own, when it moved into others) */
16
+ rooms: EventItemRoom[];
17
+ /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
18
+ /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
19
+ cta: { label: string; href: string } | null;
20
+ }
21
+ export interface PostAuthor { name: string; url: string; avatar: string }
22
+ // excerpt: the post's own, else the first ~160 characters of its text
23
+ export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
24
+ export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
@@ -28,20 +28,8 @@ type Instance = z.infer<typeof SectionInstanceSchema>;
28
28
  type Definition = z.infer<typeof SectionDefinition>;
29
29
  type FieldDef = Definition['fields'][string];
30
30
 
31
- export interface LinkValue { label: string; href: string; external: boolean }
32
- export interface EventItem {
33
- title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
34
- start: string; end: string; allDay: boolean; cancelled: boolean;
35
- /** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
36
- moved: { from: string; note: string } | null;
37
- /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
38
- /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
39
- cta: { label: string; href: string } | null;
40
- }
41
- export interface PostAuthor { name: string; url: string; avatar: string }
42
- // excerpt: the post's own, else the first ~160 characters of its text
43
- export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
44
- export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
31
+ import type { BookingItem, EventItem, LinkValue, PostAuthor, PostItem } from './section-items';
32
+ export type { BookingItem, EventItem, EventItemRoom, LinkValue, PostAuthor, PostItem } from './section-items';
45
33
 
46
34
  /** apply a theme migration when the stored instance is older than the definition */
47
35
  export function migrateSection(theme: Pick<Theme, 'sections' | 'migrations'>, s: Instance): Instance {
@@ -148,7 +136,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
148
136
  return {
149
137
  title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
150
138
  location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
151
- allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
139
+ allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null,
140
+ rooms: (o.rooms ?? []).map((r) => ({ slug: r.slug, name: r.name, color: r.color || null })), when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
152
141
  cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
153
142
  };
154
143
  });
@@ -1,5 +1,6 @@
1
1
  import { CALENDAR } from '../lib/calendar/config';
2
2
  import type { Occurrence } from '../lib/calendar/events';
3
+ import type { ViewEvent } from '../lib/calendar/data';
3
4
 
4
5
  export { eventUrl } from '../lib/calendar/config';
5
6
 
@@ -31,8 +32,9 @@ export interface UpcomingEventsOptions {
31
32
  * fields except private custom fields. `cancelled` is true when the whole
32
33
  * event or just this date is cancelled. `moved` is set when this date of a
33
34
  * series was moved here: `from` (its old start and end) and staff's `note`.
35
+ * `rooms` are the public rooms this date holds ({ id, slug, name, color }).
34
36
  */
35
- export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>;
37
+ export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'> & Pick<ViewEvent, 'rooms'>;
36
38
 
37
39
  /** Server-side helper for Astro templates and endpoints. No repository access needed. */
38
40
  export async function upcomingEvents(
@@ -19,7 +19,7 @@ export interface EventProps { settings: Settings; event: CalEvent; html: string;
19
19
  * `acms theme types <slug>` writes a typed Data interface per section.
20
20
  */
21
21
  export interface SectionProps<D = Record<string, unknown>> { data: D; variant: string; settings: Settings; page: Page }
22
- export type { LinkValue, EventItem, PostItem, PostAuthor, BookingItem } from '../lib/sections';
22
+ export type { LinkValue, EventItem, EventItemRoom, PostItem, PostAuthor, BookingItem } from '../lib/sections';
23
23
  /** Props for templates/header.astro and templates/footer.astro. */
24
24
  export type { SiteChrome, ChromeLink } from '../lib/site-chrome';
25
25
  // core's chrome labels for any language (a template already gets them as site.words)