Skip to content

Commit 3c5d422

Browse files
hhkaosclaude
andcommitted
polish(editor): clarify feed title/url vs. organizers in feed settings
feed.title/feed.url name who PUBLISHES the feed; feed.organizers names who ORGANIZES the events in it — distinct per the OTE v0.3 spec, but for the common case (one community publishing its own events) the two end up holding near-identical values, reading as pointless duplication rather than the intentional distinction it is. Feed settings now splits into two headed groups: "About this feed" (title/description/url) and "Defaults for your events" (license, textLanguage, organizers), the latter carrying one shared note explaining the replace-not-merge inheritance pattern all three follow — instead of three fields with no framing and, previously, no visible relationship to each other. Organizers specifically gets three more pieces, DOM-inserted after renderRepeaterField's own label (same "patch the mounted subtree" technique already used this session to fix that field's info-tooltip copy, since REPEATER_SPECS.organizers is shared with the event form's own organizers field and can't carry feed-specific text): - A plain-language note adapted from the spec's own wording distinguishing the two fields. - "Use the same details as above" — visible only while organizers is empty, appends one row prefilled from Title/URL, left fully editable. - An aggregator warning, shown when lib/feed-config.ts's new likelyAggregatorFeed(events) detects 3+ distinct organizer sets among events that declare their own (computed from the already-loaded event list when the view opens) — explaining that the spec requires leaving the field empty in that case, since filling it would misattribute every aggregated event to whoever runs the feed. Deliberately conservative: a single co-organized "guest" event alongside a one-community feed — the normal case the spec documents — produces exactly 2 distinct sets, not 3, so it never fires on that. v1 scope is automatic detection only, no manual "this is an aggregator" toggle — not a real ote.config.json field, so nothing to save it into. Also extracted #warnings' banner styling into a reusable .warning-box class for the new organizers warning, rather than duplicating it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrGeVXHTNNdPi7MCgSageG
1 parent 5ddcc5f commit 3c5d422

6 files changed

Lines changed: 196 additions & 4 deletions

File tree

apps/editor/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ <h2 data-i18n="dialog.feedSettings.title">Feed settings</h2>
178178
Edits ote.config.json's feed block — the profile and other editor
179179
settings in that file aren't touched.
180180
</p>
181+
182+
<h3 data-i18n="dialog.feedSettings.aboutGroup">About this feed</h3>
181183
<div class="field">
182184
<label for="feed-title" data-i18n="dialog.feedSettings.titleLabel">Title</label>
183185
<input id="feed-title" type="text" />
@@ -192,6 +194,13 @@ <h2 data-i18n="dialog.feedSettings.title">Feed settings</h2>
192194
<label for="feed-url" data-i18n="dialog.feedSettings.urlLabel">URL</label>
193195
<input id="feed-url" type="url" />
194196
</div>
197+
198+
<h3 data-i18n="dialog.feedSettings.defaultsGroup">Defaults for your events</h3>
199+
<p class="hint" data-i18n="dialog.feedSettings.defaultsGroupNote">
200+
Every event inherits these unless it declares its own — an event
201+
that does replaces the value entirely, it's never merged with
202+
what's set here.
203+
</p>
195204
<div class="field pair">
196205
<div>
197206
<label for="feed-license" data-i18n="dialog.feedSettings.licenseLabel">License</label>

apps/editor/src/i18n/es.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export const es: Record<string, string> = {
4646
"dialog.feedSettings.title": "Configuración del feed",
4747
"dialog.feedSettings.hint":
4848
"Edita el bloque feed de ote.config.json — el perfil y otros ajustes del editor en ese fichero no se tocan.",
49+
"dialog.feedSettings.aboutGroup": "Sobre este feed",
50+
"dialog.feedSettings.defaultsGroup": "Valores por defecto para tus eventos",
51+
"dialog.feedSettings.defaultsGroupNote":
52+
"Todo evento hereda estos valores salvo que declare el suyo propio — y si lo declara, lo sustituye por completo, nunca se combina con lo que hay aquí.",
4953
"dialog.feedSettings.titleLabel": "Título",
5054
"dialog.feedSettings.descriptionLabel": "Descripción",
5155
"dialog.feedSettings.urlLabel": "URL",
@@ -65,6 +69,11 @@ export const es: Record<string, string> = {
6569
"El idioma en el que están escritos el título/descripción de tu feed (y el nombre/descripción de tus eventos). Solo hace falta si usas traducciones.",
6670
"dialog.feedSettings.organizersInfo":
6771
"Quién organiza tu comunidad, por defecto. Cada evento hereda esta lista salvo que declare sus propios organizadores, que entonces la REEMPLAZAN, no se combinan con ella.",
72+
"dialog.feedSettings.organizersNote":
73+
"El título/URL de arriba dicen quién publica este feed; organizers, quién organiza los eventos que contiene. Si este feed solo publica tus propios eventos, probablemente coincidan. Solo son distintos si agregas eventos de otras comunidades — en ese caso, deja este campo vacío (ver aviso abajo).",
74+
"dialog.feedSettings.organizersQuickFill": "Usar los mismos datos que arriba",
75+
"dialog.feedSettings.organizersAggregatorWarning":
76+
"Aviso: los eventos de este feed declaran organizadores bastante distintos entre sí. Si este feed agrega eventos de otras comunidades, deja este campo vacío — la spec lo exige: rellenarlo atribuiría cada evento agregado a quien lleva este feed, no a quien realmente lo organiza.",
6877
"dialog.review.title": "Revisar y enviar",
6978
"dialog.review.hintRepo":
7079
"Esto abre un issue de GitHub prerrellenado en el repositorio de destino; una persona mantenedora (o la automatización del repositorio) lo convierte en un pull request.",

apps/editor/src/lib/feed-config.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cleanRow, isRowEmpty } from "./event-json.js";
2-
import type { OrganizerRow, OteConfig } from "./types.js";
2+
import type { OrganizerRow, OteConfig, OteEvent } from "./types.js";
33

44
/**
55
* Flat, all-string form model for ote.config.json's `feed` block — same
@@ -102,3 +102,23 @@ export function toOteConfigJson(
102102

103103
return { ...(rawConfig ?? {}), feed };
104104
}
105+
106+
/**
107+
* True when 3+ distinct organizer sets appear among events that declare
108+
* their own `organizers` — the spec's own signal that a feed is (or is
109+
* becoming) an aggregator, where `feed.organizers` must stay empty (filling
110+
* it would misattribute every event to whoever runs the feed, not who
111+
* actually organizes each one). Deliberately conservative: a single
112+
* co-organized event alongside a one-community feed — the normal case the
113+
* spec itself documents (repeat the feed's own community plus the guest) —
114+
* produces exactly 2 distinct sets (the feed's own + the one guest event's),
115+
* not 3; this never fires on that case.
116+
*/
117+
export function likelyAggregatorFeed(events: readonly OteEvent[]): boolean {
118+
const signatures = new Set<string>();
119+
for (const event of events) {
120+
if (!event.organizers || event.organizers.length === 0) continue;
121+
signatures.add([...event.organizers].map((o) => o.name).sort().join("|"));
122+
}
123+
return signatures.size >= 3;
124+
}

apps/editor/src/main.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import {
3737
emptyFeedConfigState,
3838
fromOteConfig,
39+
likelyAggregatorFeed,
3940
toOteConfigJson,
4041
} from "./lib/feed-config.js";
4142
import {
@@ -408,14 +409,39 @@ async function startEditor(repo: string | null): Promise<void> {
408409
}
409410

410411
let feedState = emptyFeedConfigState();
412+
// Computed once per open (from the already-loaded event list, no new
413+
// fetch) — see the feedSettingsOpen handler below.
414+
let aggregatorLikely = false;
411415

412416
function renderFeedOrganizers(): void {
413417
feedOrganizersSlot.textContent = "";
418+
419+
// "Use the same details as above" — visible only while organizers is
420+
// still empty; appends one row prefilled from Title/URL, left fully
421+
// editable. Declared before renderRepeaterField so its onArrayChange
422+
// callback can toggle this button's visibility without a full rebuild.
423+
const quickFillBtn = document.createElement("button");
424+
quickFillBtn.type = "button";
425+
quickFillBtn.className = "secondary organizers-quick-fill";
426+
quickFillBtn.textContent = t(
427+
"dialog.feedSettings.organizersQuickFill",
428+
"Use the same details as above",
429+
);
430+
quickFillBtn.hidden = feedState.organizers.length > 0;
431+
quickFillBtn.addEventListener("click", () => {
432+
feedState.organizers = [
433+
...feedState.organizers,
434+
{ name: feedState.title, url: feedState.url, email: "", type: "" },
435+
];
436+
renderFeedOrganizers(); // deliberate, infrequent action — a full rebuild here is fine
437+
});
438+
414439
const rendered = renderRepeaterField(
415440
"organizers",
416441
feedState.organizers as unknown as Record<string, string>[],
417442
(_key, items) => {
418443
feedState.organizers = items as unknown as OrganizerRow[];
444+
quickFillBtn.hidden = feedState.organizers.length > 0;
419445
},
420446
() => "",
421447
);
@@ -430,6 +456,37 @@ async function startEditor(repo: string | null): Promise<void> {
430456
"Who runs your community, by default. Every event inherits this list unless it declares its own organizers, which then REPLACES it, not merges.",
431457
);
432458
}
459+
460+
// title/url above name who PUBLISHES the feed; this field is who
461+
// ORGANIZES the events in it — the same distinction REPEATER_SPECS'
462+
// own info text can't carry without leaking feed-specific wording
463+
// into the event form that reuses it, so it's a separate note here.
464+
const note = document.createElement("p");
465+
note.className = "hint organizers-feed-note";
466+
note.textContent = t(
467+
"dialog.feedSettings.organizersNote",
468+
"Title/URL above name who publishes this feed; organizers is who organizes the events in it. If this feed only publishes your own events, they probably match. They only differ once you aggregate events from other communities — leave this empty in that case (see the notice below).",
469+
);
470+
471+
const label = rendered.querySelector("label");
472+
if (label) {
473+
// Inserted in reverse of the desired final order — each .after()
474+
// on the same reference node pushes the previous insertion down.
475+
label.after(quickFillBtn);
476+
if (aggregatorLikely) {
477+
const warning = document.createElement("div");
478+
warning.className = "warning-box organizers-warning";
479+
const p = document.createElement("p");
480+
p.textContent = t(
481+
"dialog.feedSettings.organizersAggregatorWarning",
482+
"Heads up: the events in this feed declare noticeably different organizers. If this feed aggregates events from other communities, leave this field empty — the spec requires it: filling it would attribute every aggregated event to whoever runs this feed, not who actually organizes each one.",
483+
);
484+
warning.append(p);
485+
label.after(warning);
486+
}
487+
label.after(note);
488+
}
489+
433490
feedOrganizersSlot.append(rendered);
434491
}
435492

@@ -449,6 +506,7 @@ async function startEditor(repo: string | null): Promise<void> {
449506

450507
feedSettingsOpen.addEventListener("click", () => {
451508
feedState = fromOteConfig(config);
509+
aggregatorLikely = likelyAggregatorFeed(listed.map((entry) => entry.event));
452510
feedTitleInput.value = feedState.title;
453511
feedDescriptionInput.value = feedState.description;
454512
feedUrlInput.value = feedState.url;

apps/editor/styles.css

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ header h1 {
9696

9797
/* --- warnings ------------------------------------------------------------ */
9898

99-
#warnings {
99+
#warnings,
100+
.warning-box {
100101
background: #fff8e1;
101102
border: 1px solid #e6c65c;
102103
border-radius: var(--radius);
@@ -105,7 +106,8 @@ header h1 {
105106
font-size: 0.9rem;
106107
}
107108

108-
#warnings p {
109+
#warnings p,
110+
.warning-box p {
109111
margin: 0.2rem 0;
110112
}
111113

@@ -1654,6 +1656,38 @@ footer {
16541656
font-size: 1.2rem;
16551657
}
16561658

1659+
#feed-settings-view h3 {
1660+
margin: 1.5rem 0 0.25rem;
1661+
padding-top: 1.25rem;
1662+
border-top: 1px solid var(--border);
1663+
font-size: 0.95rem;
1664+
font-weight: 600;
1665+
}
1666+
1667+
#feed-settings-view h3:first-of-type {
1668+
margin-top: 1rem;
1669+
padding-top: 0;
1670+
border-top: none;
1671+
}
1672+
1673+
#feed-settings-view h3 + .hint {
1674+
margin-bottom: 1rem;
1675+
}
1676+
1677+
/* Note/warning/quick-fill inserted after the (reused, event-form) organizers
1678+
repeater's own label — see main.ts's renderFeedOrganizers. */
1679+
.organizers-feed-note {
1680+
margin: 0.3rem 0 0.75rem;
1681+
}
1682+
1683+
.organizers-quick-fill {
1684+
margin-bottom: 0.75rem;
1685+
}
1686+
1687+
.warning-box.organizers-warning {
1688+
margin: 0.5rem 0 0.75rem;
1689+
}
1690+
16571691
#feed-settings-view .actions {
16581692
margin-top: 1.25rem;
16591693
}

apps/editor/test/feed-config.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { describe, expect, it } from "vitest";
33
import {
44
emptyFeedConfigState,
55
fromOteConfig,
6+
likelyAggregatorFeed,
67
toOteConfigJson,
78
} from "../src/lib/feed-config.js";
8-
import type { OteConfig } from "../src/lib/types.js";
9+
import type { OteConfig, OteEvent } from "../src/lib/types.js";
910

1011
// Shaped after OpenTechEvents/ote-template's real ote.config.json (checked
1112
// via the GitHub API while planning this feature): top-level `_comment*`
@@ -145,3 +146,64 @@ describe("toOteConfigJson", () => {
145146
expect(feed.organizers).toBeUndefined();
146147
});
147148
});
149+
150+
describe("likelyAggregatorFeed", () => {
151+
const eventWithOrganizers = (id: string, names: string[]): OteEvent =>
152+
({
153+
id,
154+
name: id,
155+
timezone: "Europe/Madrid",
156+
startDate: "2026-06-11T18:30",
157+
organizers: names.map((name) => ({ name })),
158+
}) as unknown as OteEvent;
159+
160+
it("no events → false", () => {
161+
expect(likelyAggregatorFeed([])).toBe(false);
162+
});
163+
164+
it("events with no organizers of their own → false", () => {
165+
const events = [
166+
{ id: "a", name: "a", timezone: "Europe/Madrid", startDate: "2026-06-11T18:30" },
167+
{ id: "b", name: "b", timezone: "Europe/Madrid", startDate: "2026-07-11T18:30" },
168+
] as unknown as OteEvent[];
169+
expect(likelyAggregatorFeed(events)).toBe(false);
170+
});
171+
172+
it("every event sharing the same organizer set → false", () => {
173+
const events = [
174+
eventWithOrganizers("a", ["PyAlmería"]),
175+
eventWithOrganizers("b", ["PyAlmería"]),
176+
eventWithOrganizers("c", ["PyAlmería"]),
177+
];
178+
expect(likelyAggregatorFeed(events)).toBe(false);
179+
});
180+
181+
it("one community feed plus a single co-organized guest event (2 sets) → false, the normal case", () => {
182+
const events = [
183+
eventWithOrganizers("a", ["PyAlmería"]),
184+
eventWithOrganizers("b", ["PyAlmería"]),
185+
// The one co-organized event: the community plus a guest.
186+
eventWithOrganizers("c", ["PyAlmería", "Django Girls Almería"]),
187+
];
188+
expect(likelyAggregatorFeed(events)).toBe(false);
189+
});
190+
191+
it("3+ genuinely distinct organizer sets → true", () => {
192+
const events = [
193+
eventWithOrganizers("a", ["PyAlmería"]),
194+
eventWithOrganizers("b", ["GDG Madrid"]),
195+
eventWithOrganizers("c", ["React Barcelona"]),
196+
];
197+
expect(likelyAggregatorFeed(events)).toBe(true);
198+
});
199+
200+
it("organizer order within an event doesn't create a false distinct set", () => {
201+
const events = [
202+
eventWithOrganizers("a", ["Alice", "Bob"]),
203+
eventWithOrganizers("b", ["Bob", "Alice"]),
204+
eventWithOrganizers("c", ["Carol"]),
205+
];
206+
// Only 2 distinct sets ({Alice,Bob} and {Carol}) once order is ignored.
207+
expect(likelyAggregatorFeed(events)).toBe(false);
208+
});
209+
});

0 commit comments

Comments
 (0)