Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions specs/attendees/writing-to-the-people-who-booked.feature
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,42 @@ Feature: An owner writes to the people who booked
Given the owner has an email provider of their own
And 1 person has booked onto "the Gig"
Then "the Gig" offers a way to write to the people who booked

@rule:attendees.a-message-can-be-aimed-at-one-day
Rule: A message can be aimed at one day of a listing booked by the day
A listing booked by the day holds several days' worth of people, and news
about one of those days is not news for the rest. The owner can aim a
message at a single day, and everyone the site writes to is somebody that
day belongs to. Aiming at the listing itself still reaches all of them, so
the day is an extra way to choose rather than the only one.

@case:writing.one-day-hears-and-the-others-do-not
Scenario: The owner writes to one day
Given the owner has an email provider of their own
And "Rachel" has booked onto "the Course" for day 1
And "Marco" has booked onto "the Course" for day 8
When the owner writes to "the Course" on day 1 saying "We are in the small hall tonight."
Then the owner is shown that it would reach 1 person
When the owner sends it
Then it was written to "Rachel"
And nothing was written to "Marco"

@case:writing.the-whole-listing-still-reaches-every-day
Scenario: The owner writes to the listing rather than to a day
Given the owner has an email provider of their own
And "Rachel" has booked onto "the Course" for day 1
And "Marco" has booked onto "the Course" for day 8
When the owner writes to "the Course" saying "The term ends a week early."
Then the owner is shown that it would reach 2 people
When the owner sends it
Then it was written to "Rachel"
And it was written to "Marco"

@case:writing.a-stay-hears-about-every-day-it-covers
Scenario: A booking covering several days hears about each of them
Given the owner has an email provider of their own
And "Priya" has booked onto "the Hall" from day 1 for 3 days
When the owner writes to "the Hall" on day 3 saying "The car park is closed."
Then the owner is shown that it would reach 1 person
When the owner sends it
Then it was written to "Priya"
17 changes: 2 additions & 15 deletions src/features/admin/listing-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { listingMoneyTotals } from "#shared/accounting/listing-money-totals.ts";
import { emptyRange } from "#shared/accounting/range.ts";
import { resolveRecipientEmails } from "#shared/bulk-email.ts";
import { getEffectiveDomain } from "#shared/config.ts";
import { formatDateLabel } from "#shared/dates.ts";
import {
type ActivityLogEntry,
getListingActivityLog,
Expand Down Expand Up @@ -60,6 +59,7 @@ import { ListingRosterPanel } from "#templates/admin/listings/roster.tsx";
import type { AttendeeFilter } from "#templates/admin/listings/types.ts";
import type { TableQuestionData } from "#templates/attendee-table/types.ts";
import {
dateOptionsFor,
filterByDate,
loadGroupContext,
loadListingQuestionData,
Expand Down Expand Up @@ -154,19 +154,6 @@ const loadDecryptedListingAttendees = async (
return decryptAttendees(attendeesRaw, privateKey);
};

/** The distinct booking dates present on a daily listing, ascending, as the
* roster's date-picker options; empty for a non-daily listing. */
const availableDatesFor = (
listing: ListingWithCount,
attendees: Attendee[],
): { value: string; label: string }[] => {
if (listing.listing_type !== "daily") return [];
const dates = [
...new Set(attendees.map((a) => a.date).filter((d): d is string => !!d)),
].sort((a, b) => a.localeCompare(b));
return dates.map((value) => ({ label: formatDateLabel(value), value }));
};

/** The Overview's answer summary needs only the questions and each attendee's
* chosen answer ids (counted per option) — never the free-text answers. So it
* reads the choice ids scoped to the listing in SQL, decrypting nothing.
Expand Down Expand Up @@ -278,7 +265,7 @@ export const loadListingRosterPanel = async (
activeFilter,
allowedDomain: getEffectiveDomain(),
attendees: filteredByDate,
availableDates: availableDatesFor(listing, attendees),
availableDates: dateOptionsFor(listing, attendees),
childNames: (childrenLinks.listingsByKey.get(listing.id) ?? []).map(
(child) => child.name,
),
Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/listing-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import { panelTab, writeFormTab } from "#routes/admin/entity-write-tab.ts";
import { type AuthSession, requireContentOr } from "#routes/auth.ts";
import type { AdminFeatureKey } from "#shared/admin-features.ts";
import { targetQuery } from "#shared/bulk-email-targets.ts";
import { targetQuery } from "#shared/bulk-email-targets/registry.ts";
import { settings } from "#shared/db/settings.ts";
import { isStorageEnabled } from "#shared/storage.ts";
import {
Expand Down
39 changes: 25 additions & 14 deletions src/features/admin/listings-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "#fp";
import { getDateFilter } from "#routes/admin/actions.ts";
import type { AuthSession } from "#routes/auth.ts";
import { formatDateLabel } from "#shared/dates.ts";
import { coveredDays, formatDateLabel } from "#shared/dates.ts";
import { getGroupRemainingByGroupId } from "#shared/db/attendees/capacity/groups.ts";
import { getGroupsByIds, listingGroups } from "#shared/db/groups.ts";
import {
Expand All @@ -28,26 +28,39 @@ import { getQuestionsForListing } from "#shared/db/questions/queries.ts";
import type { ResponseHandler } from "#shared/response-steps.ts";
import { requireRequestPrivateKey } from "#shared/session-private-key.ts";
import type { Attendee, ListingWithCount } from "#shared/types.ts";
import type { GroupContext } from "#templates/admin/listings/types.ts";
import type {
DateOption,
GroupContext,
} from "#templates/admin/listings/types.ts";

/** Filter attendees by date for daily listings */
/** Keep the attendees a chosen day belongs to. A booking counts for every day
* it covers, so day 2 of a three-day stay lists that stay. */
export const filterByDate = (
attendees: Attendee[],
date: string | null,
): Attendee[] =>
date ? filter((a: Attendee) => a.date === date)(attendees) : attendees;
date
? filter((a: Attendee) => coveredDays(a.date, a.end_date).includes(date))(
attendees,
)
: attendees;

/** Collect unique dates from attendees, sorted ascending */
const getUniqueDates: (
attendees: Attendee[],
) => { value: string; label: string }[] = pipe(
map((a: Attendee) => a.date),
(dates) => compact(dates),
const getUniqueDates: (attendees: Attendee[]) => DateOption[] = pipe(
(attendees: Attendee[]) =>
attendees.flatMap((a: Attendee) => coveredDays(a.date, a.end_date)),
(dates: string[]) => compact(dates),
(dates) => unique(dates),
sort((a, b) => a.localeCompare(b)),
map((d) => ({ label: formatDateLabel(d), value: d })),
map((d: string) => ({ label: formatDateLabel(d), value: d })),
);

/** The roster's date-picker options; empty for a listing not booked by the day. */
export const dateOptionsFor = (
listing: ListingWithCount,
attendees: Attendee[],
): DateOption[] =>
listing.listing_type === "daily" ? getUniqueDates(attendees) : [];

/** Get date filter and filtered attendees for daily listings */
const applyDateFilter = (
listing: ListingWithCount,
Expand All @@ -56,10 +69,8 @@ const applyDateFilter = (
) => {
const dateFilter =
listing.listing_type === "daily" ? getDateFilter(request) : null;
const availableDates =
listing.listing_type === "daily" ? getUniqueDates(attendees) : [];
return {
availableDates,
availableDates: dateOptionsFor(listing, attendees),
dateFilter,
filteredByDate: filterByDate(attendees, dateFilter),
};
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/listings-table.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"listings_table.booking_qr": "Booking QR",
"listings_table.no_email_attendees": "No attendees have an email address",
"listings_table.export_csv": "Export CSV",
"listings_table.email_this_date": "Email this date's attendees",
"listings_table.column.name.label": "Name",
"listings_table.column.name.header": "Listing name",
"listings_table.column.name.description": "Shows the listing name and image. The name links to the listing details.",
Expand Down
Loading