diff --git a/frontend/app/api/update/meeting/route.ts b/frontend/app/api/update/meeting/route.ts index d8bf9b9e..11a46d96 100644 --- a/frontend/app/api/update/meeting/route.ts +++ b/frontend/app/api/update/meeting/route.ts @@ -668,6 +668,7 @@ async function handleScopedEdit( endDateTime: newMeeting.endDateTime, email: newMeeting.email, calType: newMeeting.calType, + fellowship: newMeeting.fellowship ?? null, modeType: newMeeting.modeType, room: newMeeting.room, // Always Active -- see the identical comment on `candidate` above. @@ -897,7 +898,8 @@ function submitsAnchorEdits( submitted.isRecurring !== existing.isRecurring || new Date(submitted.startDateTime).getTime() !== existing.startDateTime.getTime() || new Date(submitted.endDateTime).getTime() !== existing.endDateTime.getTime() || - [...submitted.calType].sort().join("|") !== [...existing.calType].sort().join("|") + [...submitted.calType].sort().join("|") !== [...existing.calType].sort().join("|") || + text(submitted.fellowship) !== text(existing.fellowship) ) { return true; } @@ -1151,6 +1153,7 @@ async function handleLinkedScheduleCreate( endDateTime, email: anchor.email, calType: anchor.calType, + fellowship: anchor.fellowship ?? null, modeType: linkedSchedule.modeType, room: candidate.room, zoomRoom: candidate.zoomRoom, diff --git a/frontend/app/api/write/meeting/route.ts b/frontend/app/api/write/meeting/route.ts index d04a7da0..8af84aa8 100644 --- a/frontend/app/api/write/meeting/route.ts +++ b/frontend/app/api/write/meeting/route.ts @@ -656,6 +656,7 @@ const createMeeting = async (request: Request) => { group: meetingData.group, email: meetingData.email, calType: meetingData.calType, + fellowship: meetingData.fellowship ?? null, // Mirrors the primary schedule's status rather than being pinned Active: both // rows are born here, together, so there is no prior suspension for this row to // wrongly inherit -- and a family whose halves disagreed would have one schedule diff --git a/frontend/app/components/meeting-form/EditMeeting.tsx b/frontend/app/components/meeting-form/EditMeeting.tsx index 7c2ec2cd..562e3c39 100644 --- a/frontend/app/components/meeting-form/EditMeeting.tsx +++ b/frontend/app/components/meeting-form/EditMeeting.tsx @@ -74,6 +74,7 @@ const EditMeetingSidebar: React.FC = description: inputDescriptionValue, setDescription: setDescriptionValue, room: selectedRoom, calTypes: selectedCalTypes, + fellowship, setFellowship, zoomRoom: selectedZoomRoom, setZoomRoom: setSelectedZoomRoom, zoomHost: selectedZoomHost, setZoomHost: setSelectedZoomHost, isRecurring, @@ -517,29 +518,39 @@ const EditMeetingSidebar: React.FC = /> } meetingTypeDropdown={ -
- - - -
- {CAL_TYPE_OPTIONS.map(type => ( - handleCalTypeToggle(type)} - color={CAL_TYPE_COLOR} - uncheckedBg="#fff" - compact={compact} - /> - ))} +
+
+ + + +
+ {CAL_TYPE_OPTIONS.map(type => ( + handleCalTypeToggle(type)} + color={CAL_TYPE_COLOR} + uncheckedBg="#fff" + compact={compact} + /> + ))} +
+ {selectedCalTypes.includes("Other") && ( + + )}
} zoomRoomDropdown={ diff --git a/frontend/app/components/meeting-form/NewMeeting.tsx b/frontend/app/components/meeting-form/NewMeeting.tsx index 74db2c02..09e004b3 100644 --- a/frontend/app/components/meeting-form/NewMeeting.tsx +++ b/frontend/app/components/meeting-form/NewMeeting.tsx @@ -57,6 +57,7 @@ const NewMeetingSidebar = React.forwardRef } meetingTypeDropdown={ -
- - - -
- {CAL_TYPE_OPTIONS.map(type => ( - handleCalTypeToggle(type)} - color={CAL_TYPE_COLOR} - uncheckedBg="#fff" - compact - /> - ))} +
+
+ + + +
+ {CAL_TYPE_OPTIONS.map(type => ( + handleCalTypeToggle(type)} + color={CAL_TYPE_COLOR} + uncheckedBg="#fff" + compact + /> + ))} +
+ {selectedCalTypes.includes("Other") && ( + + )}
} zoomRoomDropdown={ diff --git a/frontend/hooks/useMeetingForm.ts b/frontend/hooks/useMeetingForm.ts index e3ee747f..ee171b22 100644 --- a/frontend/hooks/useMeetingForm.ts +++ b/frontend/hooks/useMeetingForm.ts @@ -206,7 +206,8 @@ function computeDefaultTime(): { time: string; rolledToNextDay: boolean } { // append in click order), so unchecking and rechecking a category isn't an "edit". function snapshotFields(values: { title: string; mode: string; date: string; time: string; email: string; - description: string; room: string; calTypes: string[]; zoomRoom: string; zoomHost: string; + description: string; room: string; calTypes: string[]; fellowship: string; + zoomRoom: string; zoomHost: string; }): string { return JSON.stringify({ ...values, calTypes: [...values.calTypes].sort() }); } @@ -248,6 +249,10 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti : initialMeeting.calType ? [initialMeeting.calType as unknown as string] : [] : [] ); + // Custom fellowship text behind the "Other" category checkbox. Kept even while "Other" is + // unchecked so re-checking restores what was typed; buildMeetingPayload nulls it out of the + // payload whenever "Other" isn't selected. + const [fellowship, setFellowship] = useState(initialMeeting?.fellowship ?? ""); // Every existing Remote meeting today has a non-null zoomRoom (the old rules required // it), but Remote no longer collects/shows this field -- don't resubmit a stale value // the new UI can't display or let the user clear. @@ -320,7 +325,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti // rather than re-derived from initialMeeting -- a brand-new form's computed date/time // defaults count as untouched too. const [fieldBaseline, setFieldBaseline] = useState(() => - snapshotFields({ title, mode, date, time, email, description, room, calTypes, zoomRoom, zoomHost }) + snapshotFields({ title, mode, date, time, email, description, room, calTypes, fellowship, zoomRoom, zoomHost }) ); // Must be stable: RecurringMeeting.tsx's effect depends on this callback, and an @@ -464,6 +469,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti description: "", room: "", calTypes: [] as string[], + fellowship: "", zoomRoom: "", zoomHost: "", }; @@ -475,6 +481,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti setDescription(resetValues.description); setRoom(resetValues.room); setCalTypes(resetValues.calTypes); + setFellowship(resetValues.fellowship); setZoomRoom(resetValues.zoomRoom); setZoomHost(resetValues.zoomHost); setIsRecurring(false); @@ -638,6 +645,9 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti zoomRoom, zoomHost: zoomHost || null, calType: calTypes, + // Nulled whenever "Other" isn't selected, so unchecking the category can't leave a + // ghost prefix on external titles. + fellowship: calTypes.includes("Other") && fellowship.trim() ? fellowship.trim() : null, status, room, isRecurring, @@ -715,7 +725,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti // because the update route refuses to apply an edit to the meeting and add a linked schedule // in one request (they're two writes) and so gates the "Add another mode" trigger on it. const isAnchorDirty = - snapshotFields({ title, mode, date, time, email, description, room, calTypes, zoomRoom, zoomHost }) !== fieldBaseline || + snapshotFields({ title, mode, date, time, email, description, room, calTypes, fellowship, zoomRoom, zoomHost }) !== fieldBaseline || isRecurrenceDirty; // A composed-but-unsaved linked schedule is an unsaved change like any other -- without this @@ -753,6 +763,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti description, setDescription, room, setRoom, calTypes, setCalTypes, + fellowship, setFellowship, zoomRoom, setZoomRoom, zoomHost, setZoomHost, isRecurring, setIsRecurring, diff --git a/frontend/prisma/migrations/20260823120000_add_meeting_fellowship/migration.sql b/frontend/prisma/migrations/20260823120000_add_meeting_fellowship/migration.sql new file mode 100644 index 00000000..24d26d98 --- /dev/null +++ b/frontend/prisma/migrations/20260823120000_add_meeting_fellowship/migration.sql @@ -0,0 +1,3 @@ +-- Custom fellowship name for meetings whose calType includes "Other"; AA/Al-Anon derive from +-- calType at title-build time, so no backfill is needed. +ALTER TABLE "Meeting" ADD COLUMN "fellowship" TEXT; diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma index 8cef05d4..4a52908f 100644 --- a/frontend/prisma/schema.prisma +++ b/frontend/prisma/schema.prisma @@ -30,6 +30,10 @@ model Meeting { mid String @unique title String calType String[] + // Custom fellowship name, entered only when calType includes "Other". AA/Al-Anon are never + // stored here -- they derive from calType when building external titles (see + // util/meetings/linkedSchedules.ts fellowshipPrefixedTitle). + fellowship String? description String creator String group String diff --git a/frontend/services/googleCalendar.ts b/frontend/services/googleCalendar.ts index d9f8f4ea..567f0611 100644 --- a/frontend/services/googleCalendar.ts +++ b/frontend/services/googleCalendar.ts @@ -2,7 +2,7 @@ import "server-only"; import { google } from "googleapis"; import { IMeeting, IRecurrencePattern } from "../types/models"; import { getETDayBounds, convertETToUTC } from "../util/date/timeUtils"; -import { buildLinkedScheduleLabel, LINKED_SCHEDULE_MODE_LABEL } from "../util/meetings/linkedSchedules"; +import { buildLinkedScheduleLabel, fellowshipPrefixedTitle, LINKED_SCHEDULE_MODE_LABEL } from "../util/meetings/linkedSchedules"; export const calendarIdForCategory: Record = { AA: process.env.GOOGLE_CALENDAR_AA ?? "", @@ -135,7 +135,7 @@ const CALENDAR_SINGLE_TITLE_SUFFIX = LINKED_SCHEDULE_MODE_LABEL; // TODO(linked-schedules PR5): removing a linked schedule is a plain row soft-delete, which // leaves the SURVIVOR's events on the two-schedule name until it is next written. function buildEventTitle(meeting: IMeeting, family: IMeeting[]): string { - return buildLinkedScheduleLabel(meeting.title, meeting, family, CALENDAR_SINGLE_TITLE_SUFFIX); + return buildLinkedScheduleLabel(fellowshipPrefixedTitle(meeting), meeting, family, CALENDAR_SINGLE_TITLE_SUFFIX); } // family: the meeting's linked-schedule family (util/meetings/linkedSchedules.ts), for the diff --git a/frontend/services/zoom.ts b/frontend/services/zoom.ts index 91733ee2..f63e6cb7 100644 --- a/frontend/services/zoom.ts +++ b/frontend/services/zoom.ts @@ -3,7 +3,7 @@ import { Prisma } from "@prisma/client"; import { IMeeting } from "../types/models"; import { expandOccurrences, findResourceConflicts, findFirstFreePoolHost, getPoolHostLoads, OccurrenceInput } from "../util/meetings/resourceOverlap"; import { isSharedZoomScheduleCompatible } from "../util/meetings/sharedZoomSchedule"; -import { buildLinkedScheduleLabel, isZoomBearing, resolveFamilyRows } from "../util/meetings/linkedSchedules"; +import { buildLinkedScheduleLabel, fellowshipPrefixedTitle, isZoomBearing, resolveFamilyRows } from "../util/meetings/linkedSchedules"; import { prisma } from "../lib/prisma"; const ZOOM_BASE_API = process.env.NEXT_PUBLIC_ZOOM_BASE_API ?? "https://api.zoom.us/v2"; @@ -337,7 +337,7 @@ const ZOOM_SINGLE_TOPIC_SUFFIX: Record = { // Meeting.zoomTopic: a null column keeps meaning "auto, recompute from the current family." function zoomTopicFor(meeting: IMeeting, family: IMeeting[] = []): string { if (meeting.zoomTopic) return meeting.zoomTopic; - return buildLinkedScheduleLabel(meeting.title, meeting, family, ZOOM_SINGLE_TOPIC_SUFFIX); + return buildLinkedScheduleLabel(fellowshipPrefixedTitle(meeting), meeting, family, ZOOM_SINGLE_TOPIC_SUFFIX); } function buildZoomMeetingBody(meeting: IMeeting, family: IMeeting[] = []) { diff --git a/frontend/tests/component/NewMeeting.test.tsx b/frontend/tests/component/NewMeeting.test.tsx index 28bfac5a..779d1f3e 100644 --- a/frontend/tests/component/NewMeeting.test.tsx +++ b/frontend/tests/component/NewMeeting.test.tsx @@ -226,3 +226,20 @@ describe("NewMeetingSidebar linked schedule", () => { expect(within(draft).getByRole("button", { name: "Saturday" })).toBeEnabled(); }); }); + +describe("fellowship input behind the Other category", () => { + it("appears only while Other is checked", async () => { + const ref = React.createRef(); + renderNewMeeting(ref); + await act(async () => {}); + + expect(screen.queryByPlaceholderText("Fellowship name (optional)")).toBeNull(); + + const checkboxes = screen.getByTestId("meeting-type-checkboxes"); + fireEvent.click(within(checkboxes).getByLabelText("Other")); + expect(screen.getByPlaceholderText("Fellowship name (optional)")).toBeInTheDocument(); + + fireEvent.click(within(checkboxes).getByLabelText("Other")); + expect(screen.queryByPlaceholderText("Fellowship name (optional)")).toBeNull(); + }); +}); diff --git a/frontend/tests/component/useMeetingForm.test.tsx b/frontend/tests/component/useMeetingForm.test.tsx index ea912cd8..c27b78cd 100644 --- a/frontend/tests/component/useMeetingForm.test.tsx +++ b/frontend/tests/component/useMeetingForm.test.tsx @@ -236,3 +236,42 @@ describe("useMeetingForm linked draft against a non-weekly recurrence", () => { expect(result.current.isScheduleConfirmed).toBe(false); }); }); + +describe("useMeetingForm fellowship field", () => { + it("submits trimmed fellowship only while Other is checked", () => { + const { result } = renderHook(() => useMeetingForm(baseMeeting)); + act(() => { + result.current.handleCalTypeToggle("Other"); + result.current.setFellowship(" NA "); + }); + expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBe("NA"); + + // Unchecking Other nulls the payload value even though the typed text is kept in state, + // so a ghost prefix can't linger on external titles. + act(() => result.current.handleCalTypeToggle("Other")); + expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBeNull(); + expect(result.current.fellowship).toBe(" NA "); + }); + + it("empty fellowship submits null and stays optional (no validation error)", () => { + const { result } = renderHook(() => useMeetingForm(baseMeeting)); + act(() => result.current.handleCalTypeToggle("Other")); + expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBeNull(); + expect(result.current.getValidationErrors()).toEqual([]); + }); + + it("editing fellowship marks the form dirty; resetForm clears it", () => { + const { result } = renderHook(() => useMeetingForm(baseMeeting)); + expect(result.current.isDirty).toBe(false); + act(() => result.current.setFellowship("NA")); + expect(result.current.isDirty).toBe(true); + act(() => result.current.resetForm()); + expect(result.current.fellowship).toBe(""); + }); + + it("seeds fellowship from the stored meeting", () => { + const { result } = renderHook(() => useMeetingForm({ ...baseMeeting, calType: ["Other"], fellowship: "NA" })); + expect(result.current.fellowship).toBe("NA"); + expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBe("NA"); + }); +}); diff --git a/frontend/tests/integration/update-meeting-scoped-edit.test.ts b/frontend/tests/integration/update-meeting-scoped-edit.test.ts index 9fa8fa7f..09a42bd5 100644 --- a/frontend/tests/integration/update-meeting-scoped-edit.test.ts +++ b/frontend/tests/integration/update-meeting-scoped-edit.test.ts @@ -240,7 +240,7 @@ test("editScope 'this' excludes the occurrence on the parent and creates a detac const { meeting } = await seedWeeklySeries({ googleCalendarEventIds: { AA: "parent-event-aa" } }); const occurrenceDate = occurrence(2).start; - const response = await putMeeting(scopedPayload(meeting.mid, "this", occurrenceDate, { title: "Just This Week" })); + const response = await putMeeting(scopedPayload(meeting.mid, "this", occurrenceDate, { title: "Just This Week", calType: ["AA", "Other"], fellowship: "NA" })); expect(response.status).toBe(200); const body = await response.json(); expect(body.newMid).toBeTruthy(); @@ -256,6 +256,7 @@ test("editScope 'this' excludes the occurrence on the parent and creates a detac expect(created?.isRecurring).toBe(false); expect(created?.splitFromMid).toBe(meeting.mid); expect(created?.title).toBe("Just This Week"); + expect(created?.fellowship).toBe("NA"); expect(created?.zid).toBe(meeting.zid); expect(created?.zoomHost).toBe(meeting.zoomHost); expect(created?.zoomManaged).toBe(meeting.zoomManaged); diff --git a/frontend/tests/integration/write-meeting-route.test.ts b/frontend/tests/integration/write-meeting-route.test.ts index b123cd4b..fabd9663 100644 --- a/frontend/tests/integration/write-meeting-route.test.ts +++ b/frontend/tests/integration/write-meeting-route.test.ts @@ -775,3 +775,17 @@ test("a missing access token persists an error status instead of leaving googleS expect(afterSync?.googleSyncError).toBeTruthy(); expect(mockedCreateCalendarEvent).not.toHaveBeenCalled(); }); + +test("fellowship is persisted through the zod parse and Prisma create", async () => { + mockedCreateCalendarEvent.mockResolvedValue({ id: "fake-event-id", error: null }); + const payload = buildMeetingPayload({ calType: ["AA", "Other"], fellowship: "NA", room: "Fellowship Room" }); + const response = await POST(new Request("http://localhost/api/write/meeting", { + method: "POST", + body: JSON.stringify(payload), + })); + expect(response.status).toBe(201); + + const prisma = getTestPrismaClient(); + const stored = await prisma.meeting.findUnique({ where: { mid: payload.mid } }); + expect(stored?.fellowship).toBe("NA"); +}); diff --git a/frontend/tests/unit/googleCalendar.test.ts b/frontend/tests/unit/googleCalendar.test.ts index 5dc866ab..f5a3323d 100644 --- a/frontend/tests/unit/googleCalendar.test.ts +++ b/frontend/tests/unit/googleCalendar.test.ts @@ -180,26 +180,26 @@ describe("buildEventBody — event title", () => { }); it("names a lone meeting with its own mode suffix, byte-for-byte as it always has", () => { - expect(buildEventBody(buildMeeting({ modeType: "Hybrid" })).summary).toBe("Test Meeting - Hybrid"); - expect(buildEventBody(buildMeeting({ modeType: "In Person" })).summary).toBe("Test Meeting - In Person"); + expect(buildEventBody(buildMeeting({ modeType: "Hybrid" })).summary).toBe("AA Test Meeting - Hybrid"); + expect(buildEventBody(buildMeeting({ modeType: "In Person" })).summary).toBe("AA Test Meeting - In Person"); // Remote reads as "Zoom Only" -- ICR's meetings are never fully unattended. - expect(buildEventBody(buildMeeting({ modeType: "Remote" })).summary).toBe("Test Meeting - Zoom Only"); + expect(buildEventBody(buildMeeting({ modeType: "Remote" })).summary).toBe("AA Test Meeting - Zoom Only"); }); it("leaves an unrecognised mode's title bare rather than inventing a suffix", () => { - expect(buildEventBody(buildMeeting({ modeType: "Telepathic" })).summary).toBe("Test Meeting"); + expect(buildEventBody(buildMeeting({ modeType: "Telepathic" })).summary).toBe("AA Test Meeting"); }); it("keeps the lone-meeting suffix for a family of one, the shape almost every meeting has", () => { const remote = familyRow("m-remote", "Remote", ["Monday"]); - expect(buildEventBody(remote, [remote]).summary).toBe("One Day at a Time - Zoom Only"); + expect(buildEventBody(remote, [remote]).summary).toBe("AA One Day at a Time - Zoom Only"); }); it("names both schedules on every member's event, so the two calendars agree", () => { const hybrid = familyRow("m-hybrid", "Hybrid", ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]); const remote = familyRow("m-remote", "Remote", ["Saturday"]); const family = [hybrid, remote]; - const expected = "One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"; + const expected = "AA One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"; // Each member keeps its own event with its own dates and RRULE, but both events are named // after the whole family -- the same name the family's shared Zoom meeting carries. @@ -212,14 +212,14 @@ describe("buildEventBody — event title", () => { const remote = familyRow("m-remote", "Remote", ["Sunday"]); expect(buildEventBody(remote, [remote, inPerson]).summary) - .toBe("One Day at a Time - In Person Sat - Zoom Only Sun"); + .toBe("AA One Day at a Time - In Person Sat - Zoom Only Sun"); }); it("keeps the lone-meeting name when the other rows are the same mode (a scoped edit's split children)", () => { const parent = familyRow("m-parent", "Hybrid", ["Monday"]); const child = familyRow("m-child", "Hybrid", ["Monday"]); - expect(buildEventBody(parent, [parent, child]).summary).toBe("One Day at a Time - Hybrid"); + expect(buildEventBody(parent, [parent, child]).summary).toBe("AA One Day at a Time - Hybrid"); }); it("names the in-flight edit's days, not the copy of it still stored in the family", () => { @@ -228,7 +228,7 @@ describe("buildEventBody — event title", () => { const remote = familyRow("m-remote", "Remote", ["Saturday"]); expect(buildEventBody(edited, [stored, remote]).summary) - .toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + .toBe("AA One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); }); }); diff --git a/frontend/tests/unit/linkedSchedules.test.ts b/frontend/tests/unit/linkedSchedules.test.ts index 8f651342..76619f3d 100644 --- a/frontend/tests/unit/linkedSchedules.test.ts +++ b/frontend/tests/unit/linkedSchedules.test.ts @@ -7,6 +7,7 @@ import { canLinkSchedule, claimedDaysFor, familyMembers, + fellowshipPrefixedTitle, getLinkedFamily, isDetachedSplitChild, isZoomBearing, @@ -330,3 +331,35 @@ describe("isZoomBearing", () => { expect(isZoomBearing({ modeType: "" })).toBe(false); }); }); + +describe("fellowshipPrefixedTitle", () => { + const titled = (calType: string[], fellowship: string | null = null) => + fellowshipPrefixedTitle({ title: "Serenity Now", calType, fellowship }); + + test("prefixes a single fellowship from calType", () => { + expect(titled(["AA"])).toBe("AA Serenity Now"); + expect(titled(["Al-Anon"])).toBe("Al-Anon Serenity Now"); + }); + + test("concatenates multiple fellowships in fixed AA-first order regardless of click order", () => { + expect(titled(["Al-Anon", "AA"])).toBe("AA/Al-Anon Serenity Now"); + }); + + test("Other contributes the custom fellowship text", () => { + expect(titled(["Other"], "NA")).toBe("NA Serenity Now"); + expect(titled(["AA", "Other"], "NA")).toBe("AA/NA Serenity Now"); + }); + + test("Other with empty or whitespace text contributes nothing", () => { + expect(titled(["Other"], null)).toBe("Serenity Now"); + expect(titled(["Other"], " ")).toBe("Serenity Now"); + }); + + test("custom text without Other checked is ignored -- unchecking the category drops the prefix", () => { + expect(titled([], "NA")).toBe("Serenity Now"); + }); + + test("no categories leaves the title unchanged", () => { + expect(titled([])).toBe("Serenity Now"); + }); +}); diff --git a/frontend/tests/unit/suspension.test.ts b/frontend/tests/unit/suspension.test.ts index 914518b3..35e143c7 100644 --- a/frontend/tests/unit/suspension.test.ts +++ b/frontend/tests/unit/suspension.test.ts @@ -51,6 +51,7 @@ function buildMeeting(overrides: Partial = {}): Omit { @@ -399,7 +399,7 @@ describe("linked-schedule family topics and recurrence (via the outgoing request await updateZoomMeeting("zid-shared", hybrid, [hybrid, inPerson]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Wed - In Person Thu-Fri"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Hybrid Mon-Wed - In Person Thu-Fri"); }); it("names an In Person + Remote family from the Remote member that holds the Zoom meeting", async () => { @@ -409,7 +409,7 @@ describe("linked-schedule family topics and recurrence (via the outgoing request await updateZoomMeeting("zid-shared", remote, [inPerson, remote]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - In Person Sat - Zoom Only Sun"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - In Person Sat - Zoom Only Sun"); }); it("orders segments Hybrid / In Person / Remote regardless of the family's own order", async () => { @@ -421,7 +421,7 @@ describe("linked-schedule family topics and recurrence (via the outgoing request // depend on which row triggered the write or how the rows came back from the database. await updateZoomMeeting("zid-shared", remote, [remote, hybrid]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); }); it("leaves a single-schedule meeting's topic byte-identical to the mode suffix it has today", async () => { @@ -429,20 +429,20 @@ describe("linked-schedule family topics and recurrence (via the outgoing request const remote = familyRow("m-1", "Remote", ["Monday"]); await createZoomMeeting(remote, "host@test.icr"); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Zoom Only"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Zoom Only"); // A family of one (the overwhelmingly common case, as getLinkedFamily returns it) is the // same path -- no hierarchy, no trailing day label. await createZoomMeeting(remote, "host@test.icr", [remote]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Zoom Only"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Zoom Only"); const hybrid = familyRow("m-1", "Hybrid", ["Monday"]); await createZoomMeeting(hybrid, "host@test.icr", [hybrid]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Hybrid"); const inPerson = familyRow("m-1", "In Person", ["Monday"]); await createZoomMeeting(inPerson, "host@test.icr", [inPerson]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time"); }); it("keeps a pinned zoomTopic verbatim even for a linked family", async () => { @@ -461,7 +461,7 @@ describe("linked-schedule family topics and recurrence (via the outgoing request await updateZoomMeeting("zid-shared", parent, [parent, splitChild]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Hybrid"); }); it("excludes an In-Person member's weekdays from Zoom's recurrence union", async () => { @@ -474,7 +474,7 @@ describe("linked-schedule family topics and recurrence (via the outgoing request const body = getCapturedBody(); // Mon-Fri only: Saturday meets in person, so Zoom must not list an occurrence for it. expect(body?.recurrence).toEqual({ type: 2, repeat_interval: 1, weekly_days: "2,3,4,5,6", end_times: 0 }); - expect(body?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - In Person Sat"); + expect(body?.topic).toBe("AA One Day at a Time - Hybrid Mon-Fri - In Person Sat"); }); it("names the family the same way when the caller passes only the other rows", async () => { @@ -485,7 +485,7 @@ describe("linked-schedule family topics and recurrence (via the outgoing request // the row being written is added to it, never counted twice. await updateZoomMeeting("zid-shared", hybrid, [remoteSaturday()]); - expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + expect(getCapturedBody()?.topic).toBe("AA One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); }); it("uses the in-flight row rather than its stored copy when the family already contains it", async () => { @@ -498,6 +498,6 @@ describe("linked-schedule family topics and recurrence (via the outgoing request const body = getCapturedBody(); expect(body?.recurrence).toEqual({ type: 2, repeat_interval: 1, weekly_days: "2,3,4,5,6,7", end_times: 0 }); - expect(body?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + expect(body?.topic).toBe("AA One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); }); }); diff --git a/frontend/types/models.ts b/frontend/types/models.ts index 79ffe8d7..751252e1 100644 --- a/frontend/types/models.ts +++ b/frontend/types/models.ts @@ -26,6 +26,9 @@ interface IMeeting { zoomPasscode?: string | null; zoomInvitation?: string | null; calType: string[]; + // Custom fellowship name (only meaningful while calType includes "Other"); AA/Al-Anon derive + // from calType when building external titles. + fellowship?: string | null; modeType: string; room: string; status?: string; diff --git a/frontend/util/meetings/linkedSchedules.ts b/frontend/util/meetings/linkedSchedules.ts index 0d5beca2..03a2dc5c 100644 --- a/frontend/util/meetings/linkedSchedules.ts +++ b/frontend/util/meetings/linkedSchedules.ts @@ -281,6 +281,29 @@ export function resolveFamilyRows(meeting: TRow, f : [meeting, ...family]; } +// Category values that name a fellowship directly; "Other" instead reveals the free-text +// Meeting.fellowship column. Fixed order so the prefix is stable regardless of the order the +// category checkboxes were clicked in. +const FELLOWSHIP_CAL_TYPES = ["AA", "Al-Anon"] as const; + +/** + * The fellowship-prefixed base title external services display: + * `"AA/Al-Anon Serenity Now"`. AA and Al-Anon come straight from calType; a calType of + * "Other" contributes the custom `fellowship` text instead (nothing when it's empty). With no + * fellowship at all the title passes through unchanged. Like {@link buildLinkedScheduleLabel}, + * the prefix uses the caller's own row, so family members' names agree only while their + * calType/fellowship columns do. + */ +export function fellowshipPrefixedTitle( + meeting: Pick, +): string { + const calType = meeting.calType ?? []; + const parts: string[] = FELLOWSHIP_CAL_TYPES.filter((name) => calType.includes(name)); + const custom = calType.includes("Other") ? meeting.fellowship?.trim() : ""; + if (custom) parts.push(custom); + return parts.length ? `${parts.join("/")} ${meeting.title}` : meeting.title; +} + // How each mode names itself inside a family label. Only Remote is renamed ("Zoom Only") -- // ICR's meetings are never fully unattended, so "Remote" would read as unhosted. export const LINKED_SCHEDULE_MODE_LABEL: Record = { diff --git a/frontend/util/meetings/meetingValidation.ts b/frontend/util/meetings/meetingValidation.ts index a6f75e36..44c33f36 100644 --- a/frontend/util/meetings/meetingValidation.ts +++ b/frontend/util/meetings/meetingValidation.ts @@ -90,6 +90,7 @@ export const meetingSchema = z.object({ zoomPasscode: z.string().nullable().optional(), zoomInvitation: z.string().nullable().optional(), calType: z.array(z.string()), + fellowship: z.string().max(100).nullable().optional(), modeType: z.string(), room: z.string(), status: z.string().optional(),