↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Onboarding guide

21. ADC Conference - UI

What this chapter covers. This is the consumer half of the "write-once UI, render everywhere" story (primer §2): the Blazor pages, page-local collaborators, and per-entity HTTP services that turn the Conference REST surface (G20) into the screens an organizer, a speaker, a sponsor, or an anonymous attendee actually touches. Everything here lives in the per-module Razor Class Library MMCA.ADC.Conference.UI (under MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/, the path every File:line citation below is relative to), which, like every consumer UI, assembles the reusable primitives taught in G15 (Common UI Framework) into concrete pages. There is very little new infrastructure here: the value is in seeing how a fourteen-area feature surface (events, sessions, speakers, conference categories and their items, questions, rooms, sponsors, partners, activities, session materials, feedback moderation, public browsing, session selection, and the conference landing page) is composed from the framework's list-page base, typed HTTP service base, validation bridge, device-capability abstractions, and module system. The headline lens is [Rubric §18, UI Architecture & Component Design], which assesses component reuse, separation of presentation from data access, and a coherent composition model. Because the same Razor components compile into the Blazor Server, WebAssembly, and .NET MAUI hybrid heads, this one library renders the conference across web, Android, iOS, macOS, and Windows with no per-platform reimplementation. [Rubric §22, Responsive & Cross-Browser/Device].

Folders follow the workspace's feature-by-folder rule with a technical root: pages live under Pages/<Aggregate>/ (Pages/Events/, Pages/Sessions/, Pages/Partners/, with the anonymous family nested one level deeper as Pages/Public/<Aggregate>/) and services under Services/<Aggregate>/, so a citation's folder names the feature area it belongs to.

The layering inside the UI: a page never touches HttpClient

Each page is a .razor + .razor.cs code-behind pair that depends on a UI service interface, never on HttpClient and never on the API's internals. The ten CRUD-shaped entities (events, sessions, speakers, conference categories, category items, questions, rooms, sponsors, partners, activities) each get a service deriving from Common's EntityServiceBase<TEntityDTO, TIdentifierType> and exposing the IEntityService<TEntityDTO, TIdentifierType> contract: EventService, SessionService, SpeakerService, ConferenceCategoryService, CategoryItemService, QuestionService, RoomService, SponsorService, PartnerService, and ActivityService (Services/Events/EventService.cs:22, Services/Sessions/SessionService.cs:10, Services/Speakers/SpeakerService.cs:19, Services/Categories/ConferenceCategoryService.cs:10, Services/Categories/CategoryItemService.cs:10, Services/Questions/QuestionService.cs:10, Services/Rooms/RoomService.cs:14, Services/Sponsors/SponsorService.cs:10, Services/Partners/PartnerService.cs:10, Services/Activities/ActivityService.cs:10). They inherit GetAllAsync/GetPagedAsync/GetByIdAsync/AddAsync/UpdateAsync/DeleteAsync and only add the handful of bespoke verbs the conference needs. Seven add nothing at all: ActivityService, SponsorService, PartnerService, SessionService, QuestionService, CategoryItemService, and ConferenceCategoryService are fourteen-line files whose entire job is to bind an endpoint name to a DTO and an identifier alias (Services/Partners/PartnerService.cs:11 to :12 is the whole of one), and their interfaces (IActivityUIService at Services/Activities/IActivityUIService.cs:9, ISponsorUIService at Services/Sponsors/ISponsorUIService.cs:9, IPartnerUIService at Services/Partners/IPartnerUIService.cs:9, and siblings) are equally empty extensions of the generic contract.

Three services show where extension goes. EventService layers PublishAsync, UnpublishAsync, RefreshFromSessionizeAsync, and RefreshFromSessionizeWithCodeAsync onto the inherited CRUD (Services/Events/EventService.cs:48, :60, :72, :93). The first two carry the loaded row version as an If-Match header built by ConcurrencyETag (Services/Events/EventService.cs:58, :70), so a publish races against a concurrent edit at the API rather than in the browser. The fourth is a small orchestration worth reading: an edited Sessionize code has to be persisted before the import runs (the import reads the code off the stored event), and the import rewrites the event's children and refresh stamp, so the method persists, imports, reloads, and hands back both halves as one SessionizeRefreshOutcome (Services/Events/EventService.cs:93 to :122, record at Services/Events/SessionizeRefreshOutcome.cs:13). That is the recurring shape: one call, one Result, one failure branch for the page. RoomService overrides AddAsync to reshape the POST body, because the API's AddRoomRequest contract names the key RoomId while the DTO calls it Id (Services/Rooms/RoomService.cs:18 to :40), and adds a two-argument DeleteAsync that passes the owning event on the query string (Services/Rooms/RoomService.cs:42). SpeakerService adds LinkUserAsync/UnlinkUserAsync for binding a speaker record to an identity account (Services/Speakers/SpeakerService.cs:44, :55). Both EventService and SpeakerService also override the inherited AddAsync/UpdateAsync/DeleteAsync, and only to drop this scope's memoized lookup once a write succeeds (Services/Events/EventService.cs:31 to :46, helper at :125; Services/Speakers/SpeakerService.cs:27 to :42, helper at :64): the organizers who write bypass the server's output cache, so the client memo was the only staleness they could see (Services/Events/EventService.cs:16 to :21). Publish and unpublish invalidate the event lookup the same way, and a Sessionize refresh drops the speaker lookup as well, because the import rewrites speakers (Services/Events/EventService.cs:52, :64, :86 to :87).

Nothing in this layer throws for a server answer. Every call funnels through the framework's HttpResultExecutor and ProblemDetailsResultReader, so a transport fault and a ProblemDetails body both arrive as a failed Result with a typed error the page can branch on (Services/Speakers/SpeakerLookupService.cs:92 and :101, Services/Sessions/Selection/SessionSelectionService.cs:21 and :31, Services/Feedback/OrganizerFeedbackService.cs:28 and :38). [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the page binds to a DTO contract (EventDTO, SessionDTO, SpeakerDTO, SponsorDTO, ActivityDTO) plus an interface, and the wire envelope is the uniform PagedCollectionResult<T> / CollectionResult<T> the API returns for every entity.

The list pages: two bases, one recipe

Twelve list screens hang off DataGridListPageBase<TDto>, which supplies server-side paging against MudDataGrid<T>, cancellation lifecycle, loading and load-failed state, filter and sort extraction from MudBlazor's GridState<T>, toast error surfacing, saved page/rows-per-page/scroll restoration, and viewport-driven mobile rendering that swaps the grid for a MobileInfiniteScrollList<TItem>. Six derive from it directly, because their rows are not scoped to a conference event: EventList, SessionList, QuestionList, ConferenceCategoryList, PublicEventList, and PublicSessionList (Pages/Events/EventList.razor.cs:17, Pages/Sessions/SessionList.razor.cs:22, Pages/Questions/QuestionList.razor.cs:12, Pages/Categories/ConferenceCategoryList.razor.cs:12, Pages/Public/Events/PublicEventList.razor.cs:30, Pages/Public/Sessions/PublicSessionList.razor.cs:24). A concrete page reduces to overriding Title, GridRef, SaveFilters/RestoreFilters, and a LoadServerData delegate that calls its service's GetPagedAsync, with delete-and-confirm delegated to the shared ListPageActions helper.

The other six list pages scope their rows to one conference event, and that repetition earned its own ADC-local base: EventFilteredListPageBase<TDto> (Pages/Common/EventFilteredListPageBase.cs:25), used by RoomList, SpeakerList, SponsorList, PartnerList, ActivityList, and PublicSpeakerList (Pages/Rooms/RoomList.razor.cs:13, Pages/Speakers/SpeakerList.razor.cs:19, Pages/Sponsors/SponsorList.razor.cs:19, Pages/Partners/PartnerList.razor.cs:19, Pages/Activities/ActivityList.razor.cs:20, Pages/Public/Speakers/PublicSpeakerList.razor.cs:32). It injects IEventLookupService (Pages/Common/EventFilteredListPageBase.cs:27), starts the event load before the first await of OnInitializedAsync and exposes it as EventsLoadTask (:51, armed at :145, awaited at :138) because the grid's first ServerData call can race ahead of initialization, and gives derived pages WaitForEventsAsync and ApplyEventFilter to close that race deterministically (:193, :196). It seals SaveFilters/RestoreFilters and hands pages SavePageFilters/RestorePageFilters instead (Pages/Common/EventFilteredListPageBase.cs:87, :90, :99, :111), so the eventId half of the saved state is written once, with the explicit "all" sentinel that distinguishes a deliberate clear from no saved state at all (:106, read back at :120). Default resolution is one method: a restored id that still exists wins, a dangling one falls back to the live-or-next event computed by CurrentEventSelector against the clock read through the injected TimeProvider (:183, clock at :188), and a page whose scope is locked is always pinned to that computed default (:170 to :190, opt-out property at :57, overridden by PublicSpeakerList to "only privileged readers choose" at Pages/Public/Speakers/PublicSpeakerList.razor.cs:52). Two details are worth internalizing. The event picker renders only once the component is interactive (Pages/Common/EventFilteredListPageBase.cs:73): an SSR prerender would paint a fully formed picker into static HTML whose clicks are silently swallowed until the interactive runtime attaches, which under InteractiveAuto's WebAssembly leg is a whole runtime boot later. And a failed lookup is remembered rather than swallowed (:43, :164), so a page that must fail closed can branch instead of falling through to an unscoped query. [Rubric §19, State Management & Data Flow] and [Rubric §23, Front-End Performance & Rendering].

Whether that event filter needs any translation at all depends on the entity. PartnerList is the simplest case in the family: EventId is a real Partner column, so the filter goes straight through the generic filter pipeline with no virtual-key resolution, and the page reduces to a title, a grid reference, a search box saved through SavePageFilters/RestorePageFilters, and a localized type column (Pages/Partners/PartnerList.razor.cs:19, rationale at :14 to :17, filters at :39 to :43, type label at :37). ActivityList is the same shape (Pages/Activities/ActivityList.razor.cs:20). The speaker lists are the hard case: a speaker's event association travels through join tables, so their event filter is a virtual key resolved server-side (Pages/Public/Speakers/PublicSpeakerList.razor.cs:18 to :19).

Two public lists deliberately opt out of the grid entirely. PublicSponsorList and PublicActivityList are plain ComponentBase pages (Pages/Public/Sponsors/PublicSponsorList.razor.cs:21, Pages/Public/Activities/PublicActivityList.razor.cs:22), because a sponsor roster and a social programme are bounded (both cap the fetch at 200 rows, Pages/Public/Sponsors/PublicSponsorList.razor.cs:24, Pages/Public/Activities/PublicActivityList.razor.cs:25) and render as tier-grouped logo cards and a chronological programme rather than a sortable table, so each fetches one page and orders it in memory (Pages/Public/Sponsors/PublicSponsorList.razor.cs:106 to :116, Pages/Public/Activities/PublicActivityList.razor.cs:112 to :114). PublicSpeakerList splits the difference: it keeps the base class's page-based mobile fetch path but appends each page to an accumulating list and hangs Common's InfiniteScrollSentinel below its card grid, twelve cards per chunk so a full chunk fills whole rows (Pages/Public/Speakers/PublicSpeakerList.razor.cs:24, :35).

Detail pages, form models, and one declaration of the rules

The organizer detail pages have no ADC-local base: all nine inherit Common's DetailPageBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:26) from markup, and it owns what every one of them would otherwise repeat verbatim: the page-scoped CancellationTokenSource with its dispose pattern (DetailPageBase.cs:28, :52), a LatestLoadGuard for route-driven reloads (documented at :9 to :11), and the inline edit-mode lifecycle, IsEditing/IsDirty plus the BeginEdit/EndEdit transitions that keep the dirty flag from being left set behind a closed editor (:46, :49, :68, :75). The nine are EventDetail, SessionDetail, SpeakerDetail, ConferenceCategoryDetail, RoomDetail, QuestionDetail, SponsorDetail, PartnerDetail, and ActivityDetail (@inherits at Pages/Events/EventDetail.razor:9, Pages/Sessions/SessionDetail.razor:9, Pages/Speakers/SpeakerDetail.razor:10, Pages/Categories/ConferenceCategoryDetail.razor:3, Pages/Rooms/RoomDetail.razor:7, Pages/Questions/QuestionDetail.razor:7, Pages/Sponsors/SponsorDetail.razor:8, Pages/Partners/PartnerDetail.razor:7, Pages/Activities/ActivityDetail.razor:7), and the dirty flag feeds Common's UnsavedChangesGuard directly. [Rubric §24, Forms, Validation & UX Safety].

Editable fields are not loose page state. Each entity declares an abstract form model carrying its DataAnnotations, and the create page and the detail page's inline editor each bind a sealed subclass of it: EventFormModel with EventCreateModel and EventEditModel (Pages/Events/EventFormModel.cs:27, Pages/Events/EventCreateModel.cs:11, Pages/Events/EventEditModel.cs:11), and the same triad for sessions, speakers, rooms, questions, sponsors, partners, activities, and conference categories (SessionFormModel Pages/Sessions/SessionFormModel.cs:24, SpeakerFormModel Pages/Speakers/SpeakerFormModel.cs:25, RoomFormModel Pages/Rooms/RoomFormModel.cs:24, QuestionFormModel Pages/Questions/QuestionFormModel.cs:24, SponsorFormModel Pages/Sponsors/SponsorFormModel.cs:25, PartnerFormModel Pages/Partners/PartnerFormModel.cs:18 with PartnerCreateModel Pages/Partners/PartnerCreateModel.cs:10 and PartnerEditModel Pages/Partners/PartnerEditModel.cs:10, ActivityFormModel Pages/Activities/ActivityFormModel.cs:26, ConferenceCategoryFormModel Pages/Categories/ConferenceCategoryFormModel.cs:24). The rules live once: the MudForm fields bridge to the annotations through Common's ModelValidation rather than repeating Required/RequiredError per field, the length caps come from the DTO's own constants, and every ErrorMessage is a resource key resolved by the localizing DataAnnotationsModelValidator (ADR-027). PartnerFormModel is the compact worked example: its four text rules take their caps from PartnerDTO.NameMaxLength, DescriptionMaxLength, LogoUrlMaxLength, and WebsiteUrlMaxLength (Pages/Partners/PartnerFormModel.cs:28, :32, :37, :42), the missing-value key is a single const shared by the model's [Required] and the field's RequiredError affordance so the two can never drift (:24, :27), the two URL fields carry Common's AbsoluteUrl rule (:36, :41), and the one enum field defaults to PartnerType.Community on the stated rationale that, unlike a sponsorship tier, a partner type carries no commercial weight (:45 to :49). The subclasses own only the direction of travel: EventCreateModel.ToNew() builds the posted DTO and always as an unpublished draft, because publishing is a separate gesture on the detail page (Pages/Events/EventCreateModel.cs:25 to :42), while EventEditModel.LoadFrom(source) copies the loaded event into the editor in one call and ToUpdated(source) writes the edited values over the identity, row version, and published state that were loaded (Pages/Events/EventEditModel.cs:18, :48). Because both pages bind the same EventFormFields.razor component to an instance of the same base, a value the create page accepts is a value the detail page accepts. [Rubric §1, SOLID] and [Rubric §15, Best Practices & Code Quality]. The one model with no create page to mirror follows the same discipline anyway: ConferenceCategoryItemEditModel declares the inline item form's rules against CategoryItemDTO's own length constant (Pages/Categories/ConferenceCategoryItemEditModel.cs:19 to :31).

Two of the event form's fields cannot be checked by a length cap, and each gets a client rule that mirrors the server's so the form gives the verdict the API would rather than sending the user on a round trip to be refused. IanaTimeZoneAttribute resolves the typed identifier with the non-throwing TimeZoneInfo.TryFindSystemTimeZoneById (Pages/Events/IanaTimeZoneAttribute.cs:30, check at :48), which .NET answers from the same IANA set on the Blazor Server host, the WebAssembly head, and the MAUI heads (:23 to :27); a typo such as America/New York used to pass every client rule, and the time zone is the one field on that form where the refusal is not obvious from the value (:6 to :10). SessionizeCodeAttribute calls the shared SessionizeCodeFormat.IsValid from the module's Shared project rather than restating the charset (Pages/Events/SessionizeCodeAttribute.cs:23, call at :41), because that charset is a security control and not a cosmetic one: the import resolves a relative URI from the stored code, so a value carrying a slash or a colon could redirect the import to a foreign host, and a client copy that drifted from the server's would be worse than no client rule at all (:10 to :16). Both rules pass on null, empty, and whitespace, leaving presence to [Required] so a blank required field shows one message instead of two (IanaTimeZoneAttribute.cs:43 to :46, SessionizeCodeAttribute.cs:36 to :39), and both emit ErrorMessage unchanged rather than formatting it, which is exactly what lets a model declare a resource key there and have the localizing validator resolve it (IanaTimeZoneAttribute.cs:15 to :21). [Rubric §24, Forms, Validation & UX Safety].

Container and presentational split, and page-local collaborators

The behaviour-heavy screens do not keep everything in one code-behind: the page stays the container (data fetching, filter and paging state, service calls) and hands rendering to presentational children that receive parameters and raise callbacks. PublicSessionList is the fullest example, splitting into PublicSessionListFilterBar (privileged event picker or locked chip, debounced title search, room picker, All Sessions / My Schedule toggle, share action, Pages/Public/Sessions/PublicSessionListFilterBar.razor.cs:16) and PublicSessionListView (the mobile card list, the desktop grid, and the inline bookmark stars, Pages/Public/Sessions/PublicSessionListView.razor.cs:26), which exposes Grid and ReloadAsync back to the page so the base class's grid plumbing keeps working unchanged. The same split shows up on the category detail page via ConferenceCategoryItemsPanel, which owns the item table and its inline add/edit rows and hands the reloaded aggregate back through CategoryChanged (Pages/Categories/ConferenceCategoryItemsPanel.razor.cs:22, :32), on the speaker detail page via SpeakerCategoryItemsPanel (Pages/Speakers/SpeakerCategoryItemsPanel.razor.cs:18), and on the selection dashboard via SessionSelectionSpeakerOverlap and SessionSelectionAiScores (Pages/Sessions/Selection/SessionSelectionSpeakerOverlap.razor.cs:11, Pages/Sessions/Selection/SessionSelectionAiScores.razor.cs:12).

A second, quieter move keeps the code-behinds small: pure logic is lifted out into page-local collaborators, plain classes beside the page rather than registered services, each explicitly extracted to keep the component within the [Rubric §18] line budget. SessionSelectionDisplay holds the score colors and filter predicates the dashboard and its two children share (Pages/Sessions/Selection/SessionSelectionDisplay.cs:13, score color at :20); SessionStatusDisplay is the one status-to-color map for session status chips, shared by the organizer session list and the selection dashboard's children so a status reads the same color on every page (Pages/Sessions/SessionStatusDisplay.cs:11, map at :13, read at Pages/Sessions/SessionList.razor:87, Pages/Sessions/Selection/SessionSelectionAiScores.razor:79, Pages/Sessions/Selection/SessionSelectionSpeakerOverlap.razor:68); SessionSelectionFilters holds the five bound filter values and the option lists derived from the loaded board (Pages/Sessions/Selection/SessionSelectionFilters.cs:13, projection in SessionSelectionFilterOptions at Pages/Sessions/Selection/SessionSelectionFilterOptions.cs:12); PublicSessionListFilterState translates that page's filters to and from the persisted string map (Pages/Public/PublicSessionListFilterState.cs:12), and four more classes beside that page hold its event catalog, its bookmark state, its event-scope retry, and its overlapping grid loads (the public rendering section below walks them); PublicScheduleRoomOptions builds the room picker out of events the page already loaded, so narrowing by room costs no extra fetch, and re-validates the active room against the newly scoped list rather than leaving a filter that matches nothing (Pages/Public/PublicScheduleRoomOptions.cs:13); SessionLookups caches the session detail page's dictionaries per page instance, re-fetching rooms when the session's event changes (Pages/Sessions/SessionLookups.cs:32); SpeakerUserSearch fans three parallel IUserUIService lookups out and unions them, because GetPagedAsync ANDs its filters server-side and one call with all three set would return the empty intersection (Pages/Speakers/SpeakerUserSearch.cs:12, :42 to :44); FeedbackQuestionLoader pages until TotalItems is reached so a feedback report cannot silently truncate, with 100 rows per request and a 20-page runaway guard (Pages/Feedback/FeedbackQuestionLoader.cs:17, :20, :26); and ADCHomeContent holds the landing page's editorial data (Pages/Home/ADCHomeContent.cs:15). All of them are testable without rendering anything. [Rubric §28, Front-End Testing] and [Rubric §14, Testability].

Child-and-join entities: a thin POST/DELETE base

Sessions, speakers, and events own join relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write carries a parent id. These get four near-identical services (EventSpeakerService, SessionSpeakerService, SessionCategoryItemService, SpeakerCategoryItemService) over the shared ChildEntityServiceBase, which was hoisted out of this module into MMCA.Common.UI so every consumer module can reuse it (the note is left in place at Services/Common/ChildEntityServices.cs:89). Each Conference join service reduces to supplying its endpoint and adding typed AddAsync/DeleteAsync wrappers over the base's two verbs (Services/Common/ChildEntityServices.cs:22, :35, :48, :61), and their four interfaces live together in one file (Services/Common/IChildEntityUIService.cs:14, :26, :38, :50). Note the hard-won detail, now enforced by a helper rather than by discipline: the add payload always names the parent explicitly (new { EventId = eventId, SpeakerId = speakerId }, Services/Common/ChildEntityServices.cs:26), and every removal appends its owning aggregate's id through ChildEntityDeletePath (Services/Common/ChildEntityServices.cs:76, called at :29, :42, :55, :68). The join controllers remove a row by loading the parent aggregate and asking it to drop the child, so a DELETE without the parent binds it to default, finds nothing, and answers 404 while the UI reports a generic failure (Services/Common/ChildEntityServices.cs:14 to :21).

Display-enrichment lookups: the GetAll-vs-GetById populator gap, worked around in the UI

Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an event name beside a room, a sponsor, a partner, or an activity. Three lookup services fill that role, SpeakerLookupService, EventLookupService, and CategoryItemLookupService (behind ISpeakerLookupService, IEventLookupService, and ICategoryItemLookupService). Each reads its whole entity set through Common's PagedReadAll, which pages the /paged endpoint in stable id order with children and foreign keys suppressed until the server's reported total is reached, because a single read truncates silently at the API's 500-row page-size clamp (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:19, page size at :22, URL at :43, rationale at :13 to :17); each page is a GET through IdempotentReadRetry, and the rows fold into a Dictionary of lightweight projection records, SpeakerInfo, EventInfo, CategoryItemInfo (Services/Speakers/SpeakerLookupService.cs:29, read at :91 to :105, fold at :119 to :129; Services/Events/EventLookupService.cs:33; Services/Categories/CategoryItemLookupService.cs:12; records at Services/Speakers/ISpeakerLookupService.cs:9, Services/Events/IEventLookupService.cs:14, Services/Categories/ICategoryItemLookupService.cs:9). EventLookupService and SpeakerLookupService both memoize their read per scope for five minutes, matched to the server-side output-cache TTL, and cache the task rather than the result, so the overlapping loads EventFilteredListPageBase starts share one in-flight fetch and an attendee moving between the public session list and a session page pays one speaker read rather than one per navigation; no lock guards the cache because a scope's component callbacks run on one logical thread (Services/Events/EventLookupService.cs:14 to :24, TTL at :43, TimeProvider injected at :33, reuse check at :78; Services/Speakers/SpeakerLookupService.cs:14 to :26, TTL at :39). The speaker memo is per scope rather than per process because its payload is role-dependent (BR-239: a privileged caller sees speakers the public projection hides), so it must never be shared across users (Services/Speakers/SpeakerLookupService.cs:22 to :24). Only a successful fetch is stamped with an expiry, so a faulted, canceled, or failed read is retried on the next call rather than cached; each interface exposes an Invalidate that the write services call, and a generation counter keeps a fetch superseded by an invalidation from stamping the expiry of its replacement (Services/Events/IEventLookupService.cs:35, Services/Speakers/ISpeakerLookupService.cs:26, Services/Events/EventLookupService.cs:59, :117). EventInfo is the one projection that grew a feature-specific field: SponsorshipPacketUrl is an optional trailing parameter defaulting to null precisely so the many call sites that need only identity and dates stayed unchanged, and only the public sponsor page reads it (Services/Events/IEventLookupService.cs:10 to :21, read at Pages/Public/Sponsors/PublicSponsorList.razor.cs:88). The organizer detail pages use the same map for a plain display concern: PartnerDetail shows the owning event's name and falls back to the raw id when the lookup is unavailable, rather than failing the page over a label (Pages/Partners/PartnerDetail.razor.cs:24, :38 to :41). This is a deliberate client-side join over the navigation-populator (ADR-002) gap between the API's list and by-id read shapes.

One page needed three of these together, and got a composite rather than three nullable caches and three failure branches: SpeakerDetailLookupService gathers the category items, their owning category titles, and the question texts into a single SpeakerDetailLookups record answered as one Result, short-circuiting on the first failure (Services/Speakers/SpeakerDetailLookupService.cs:13, record at Services/Speakers/ISpeakerDetailLookupService.cs:14, interface at :25). The same "one call, one failure branch" instinct produced SessionizeRefreshOutcome above and SessionLookups beside the session detail page.

Three feature areas that go beyond CRUD

First, the speaker self-service dashboard. SpeakerDashboard sits behind a plain [Authorize] attribute and is gated on the speaker_id JWT claim, showing the linked speaker's sessions, per-session bookmark counts, and feedback (Pages/Speakers/SpeakerDashboard.razor.cs:23). The sessions are narrowed to the current or next event, resolved through IEventLookupService and CurrentEventSelector, falling back to all of the speaker's sessions when no event resolves (Pages/Speakers/SpeakerDashboard.razor.cs:17 to :18, :126 to :130, resolver at :169). It leans on SpeakerDashboardService (behind ISpeakerDashboardUIService at Services/Speakers/ISpeakerDashboardUIService.cs:10), whose session read pushes the speaker filter server-side onto the virtual SpeakerId key, caps the page at 100 rows, and appends a per-call cache-bust parameter so this one read is a guaranteed miss against the shared sessions output cache and a just-made assignment shows immediately, while public list reads keep their cache (Services/Speakers/SpeakerDashboardService.cs:14, :19, :35, :38). Its bookmark counts come back from one batched endpoint rather than one hop per session (Services/Speakers/SpeakerDashboardService.cs:54), and it derives from Common's AuthenticatedServiceBase so its calls carry the bearer token and run through the shared retry policy. [Rubric §12, Performance & Scalability].

Second, organizer feedback moderation (BR-53). OrganizerEventFeedback and OrganizerSessionFeedback let organizers review and delete answers through OrganizerEventFeedbackService and OrganizerSessionFeedbackService (Pages/Feedback/OrganizerEventFeedback.razor.cs:18, Pages/Feedback/OrganizerSessionFeedback.razor.cs:18, services at Services/Feedback/OrganizerFeedbackService.cs:15, :70, behind IOrganizerEventFeedbackUIService and IOrganizerSessionFeedbackUIService at Services/Feedback/IOrganizerFeedbackUIService.cs:11, :27). The read is the unscoped organizer view, paged through PagedReadAll until the reported total, so the report never aggregates a truncated answer set (Services/Feedback/OrganizerFeedbackService.cs:25 to :27), and each delete passes the parent id explicitly on the query string to satisfy the controller's binding (Services/Feedback/OrganizerFeedbackService.cs:52, :107). [Rubric §11, Security]: the scoping is server-side and the pages carry [Authorize(Roles = "Organizer")], not a client-side hide.

Third, QR self-service. SpeakerQr renders a full-screen code a speaker can hold up at the podium, and the code itself needs no backend call: the speaker comes from the speaker_id claim and the payload is built locally, so the code renders on the prerender pass as readily as on the interactive one (Pages/Speakers/SpeakerQr.razor.cs:24, rationale at :15 to :17, claim read at :56). The one read is the name printed beside the code, so whoever scans can see whose profile they are about to open: the linked speaker's full name through ISpeakerUIService, whose self-read carve-out serves a speaker their own profile even while it is not public, with the account name standing in when that read fails (:17 to :19, fallback at :54, read at :66). The payload is always the absolute public URL from Common's IPublicLinkBuilder (Pages/Speakers/SpeakerQr.razor.cs:26, :62), never the WebView-internal origin, or a code scanned off the MAUI head would open for nobody else. The framework's QrCodeButton puts the same capability on five organizer and public print surfaces: public event detail (Pages/Public/Events/PublicEventDetail.razor:31), public session detail (Pages/Public/Sessions/PublicSessionDetail.razor:51), public speaker detail (Pages/Public/Speakers/PublicSpeakerDetail.razor:47), room detail (Pages/Rooms/RoomDetail.razor:61), and sponsor detail (Pages/Sponsors/SponsorDetail.razor:74).

Session materials, a resource deliberately not shaped as CRUD

Speakers publish the decks, handouts, and links that go with their own sessions, and anyone downloads them from the public session page (ADR-123, speaker session assets). This is the one area here that does not get an IEntityService<,>. ISessionAssetUIService declares exactly the five operations the API offers, GetBySessionAsync, AddLinkAsync, UploadFileAsync, UpdateAsync, and DeleteAsync (Services/SessionAssets/ISessionAssetUIService.cs:32, :41, :55, :72, :82), with the rationale recorded on the interface itself (:11 to :16): the endpoint has no paged list, no lookup, and no by-id read (an asset is only ever fetched as part of its session's list), and its two creates are different verbs on different routes. Declaring only what exists keeps a page from reaching for a CRUD method with no endpoint behind it. The two write bodies sit beside the service as SessionAssetLinkRequest and SessionAssetUpdateRequest (Services/SessionAssets/SessionAssetRequests.cs:14, :35) rather than being reused from the Application layer, because the UI RCL references the module's Shared project only (SessionAssetRequests.cs:7 to :13); neither carries the caller's identity, which the API stamps from the token, so a request body can never claim to be a speaker it is not. [Rubric §9, API & Contract Design].

SessionAssetService derives from Common's AuthenticatedServiceBase (Services/SessionAssets/SessionAssetService.cs:26) and is the clearest worked example in this library of per-verb transport policy. Adding a link appends a row, so that POST carries an Idempotency-Key and keeps the shared retry policy (:61, :74). The file POST carries the key but has no retry at all: a picked file's stream is single-shot (neither a browser file input nor a native picker rewinds), so a second attempt would post an exhausted body, while a repeated key replays the stored response instead of re-uploading 50 MB over venue wifi (:86 to :91, :108, :135). The upload streams straight from the browser file into the multipart body with the cap stated to OpenReadStream, because Blazor's 512 KB default would otherwise truncate every real deck (:112, note at :110 to :111), and the four part names match the controller's IFormFile parameter and its [FromForm] arguments exactly (:128 to :133). The update is conditional (the ADR-035 rule): the edited row version travels as If-Match through ConcurrencyETag, and a DTO carrying no token sends no header, so the API refuses with 428 rather than overwriting another editor's change (:154 to :156, header applied at :209 to :212). Both the key and the precondition are default headers on a client built once per logical operation, so every retry attempt states the same pair (:200, reasoning at :193 to :199). [Rubric §29, Resilience].

Three components consume that service. SessionAssetsPanel is the management half (Pages/SessionAssets/SessionAssetsPanel.razor.cs:31), a component rather than a page, so the organizer session detail page and a speaker's own dashboard host it by saying only which session and whether the viewer may manage it (parameters at :36, :42). CanManage decides only what the panel offers: the right itself is re-checked by the API on every write (a privileged role, or a speaker whose speaker_id claim is on the session), so a viewer who reaches a hidden control still gets SessionAsset.Forbidden (:20 to :22). [Rubric §11, Security]. The panel enforces the two limits client-side as courtesy rather than as the rule: the per-session cap closes the add controls and short-circuits both writes (:69, :110, :144, the constant SessionAssetLimits.MaxAssetsPerSession = 10 at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/SessionAssets/SessionAssetLimits.cs:27), and the size check runs before the post so a speaker does not wait out a whole 50 MB upload on venue wifi to be told no (:151, cap at SessionAssetLimits.cs:11). Every refusal is worded from the API's machine-readable error code through a resource-key table, so SessionAsset.UnsupportedFormat, SessionAsset.LimitReached, and SessionAsset.StorageNotConfigured each read in the selected language instead of echoing the server's English sentence (ADR-027); Http.413 is the one transport status in that table, because Kestrel refuses an over-cap body before the friendly check ever runs (:252, entry at :279). And every write funnels through one MutateAsync shape: run it, report the refusal or the success, run the caller's own reset, reload the list (:214, value-returning adapter at :245).

SessionAssetComposer is the "add a material" half, split out of that panel so the panel keeps the list, the writes, and the status region while the composer owns only the draft the speaker is typing (Pages/SessionAssets/SessionAssetComposer.razor.cs:24, rationale at :6 to :9). It performs no write of its own: a well-formed link is raised on OnAddLink with its address already normalized, a refused address is raised on OnInvalidLinkAddress so the panel words the refusal in its own single aria-live region rather than growing a second one, and a picked file is raised untouched on OnFileSelected before any size or format check (:33 to :40, remarks at :12 to :17). The draft is cleared by the panel through ClearDraft rather than optimistically here, because only the panel knows whether the write actually succeeded and an EventCallback cannot report an outcome back to its raiser (:52 to :57, remarks at :18 to :22, called at SessionAssetsPanel.razor.cs:128 with the reference held at :67). One small detail earns its comment: the file input's id is per-instance rather than fixed, because a page can render one panel per session and a duplicated id would point the label at the wrong picker (:42 to :44). [Rubric §18, UI Architecture & Component Design].

SessionAssetsDownloadList is the public read-only half, embedded in the public session page (Pages/SessionAssets/SessionAssetsDownloadList.razor.cs:18, hosted at Pages/Public/Sessions/PublicSessionDetail.razor:121). It is anonymous because the list endpoint is, and a reader who cannot see the session gets an empty list rather than a refusal. A load failure here is deliberately silent: the materials are supplementary to the session page, so the section simply does not render rather than raising an alert for something the reader never asked for (:13 to :17, :48). Both components reload only when the session id actually changes (:36 to :43, panel at SessionAssetsPanel.razor.cs:71 to :78), and both swallow OperationCanceledException from disposal or the InteractiveAuto render-mode transition (SessionAssetsDownloadList.razor.cs:50). The presentation decisions they would otherwise duplicate live once in SessionAssetDisplay (Pages/SessionAssets/SessionAssetDisplay.cs:13): the icon that names an asset by kind and by format so a reader can tell a deck from an archive before clicking (:27), and a byte count split into an amount plus the resource key naming its unit, which the caller composes through its own localizer so a Spanish reader reads "1,4 MB" (:56). The extension is read off the stored blob name first, because a blob URL can carry a signature query string and taking the extension from that would yield the query rather than the format (SessionAssetDisplay.cs:79 to :86). [Rubric §18, UI Architecture & Component Design] and [Rubric §27, Internationalization & Localization].

Session-selection decision support, the asynchronous edge

The most behaviour-rich page is the organizer-only SessionSelectionDashboard (Pages/Sessions/Selection/SessionSelectionDashboard.razor.cs:16), which renders category distribution, speaker overlap, locality breakdown, and AI content-similarity scoring over an event's session pool via SessionSelectionService (Services/Sessions/Selection/SessionSelectionService.cs:14, behind ISessionSelectionUIService). Every load is stamped with a monotonic generation rather than an event id, so switching away from an event and back still discards the first response even though both carry the same id: the generation is snapshotted before the fetch, re-checked before the board is replaced or an error banner is painted, and re-checked again before the spinner is cleared. Filter options are recomputed from the returned SessionSelectionDashboardDTO itself. [Rubric §19, State Management & Data Flow].

Scoring is the asynchronous edge. ScoreSessionsAsync POSTs to the scoring endpoint with no retry pipeline, because starting a run is not idempotent and a retried POST could queue a second run behind the first (Services/Sessions/Selection/SessionSelectionService.cs:46 to :48). Because AI scoring of every eligible session can take minutes, the API does not run the pass inline: SessionSelectionController schedules a ScoreEventSessionsInternalCommand through the durable IInternalCommandScheduler and answers 202 Accepted with no body once it is queued (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:148 to :144, command at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsInternalCommand.cs:38), so the service reads the status before the body reader (which treats a body-less 2xx as a failure) and maps it to a sentinel ScoreEventSessionsResultDTO with SessionsScored = -1 (Services/Sessions/Selection/SessionSelectionService.cs:54 to :57). The page turns that sentinel, and only that sentinel, into a fire-and-forget polling session, leaving its scoring flag set for the poll loop to clear while every other branch clears it inline.

The loop itself is factored into three pieces, which is the part worth studying. ScorePollSession runs the polling (Pages/Sessions/Selection/ScorePollSession.cs:36, RunAsync at :51), re-reading the cadence through the host on every tick so a bUnit test can shrink it (:69) and abandoning the session the moment the page's generation moves (:70, :76). ScorePollHost is the record of callbacks through which that loop raises its UI side effects, so no rendering decision lives in the loop and none of the loop lives on the component (Pages/Sessions/Selection/ScorePollSession.cs:19). And ScorePollTracker is the pure state machine that turns each observation into a ScorePollSignal: keep polling, apply-and-continue, all sessions scored, counts stable long enough, or no scores at all within the zero-progress budget (Pages/Sessions/Selection/ScorePollTracker.cs:33, signal enum at :8). Its budgets are explicit constants, each with its sizing reason beside it: 225 polls for a 30-minute cap at the 8-second cadence (:36), 5 consecutive fetch failures (:43), 15 zero-progress polls, which must outlast the internal-command poll that picks the run up (60 seconds in production) plus the first scoring call (:51, reason at :45 to :50), and 6 stable polls before completion, long enough to outlast one 30-second scoring call and its save so a single slow call mid-pass does not read as the end of the run (:58, reason at :53 to :57). Progress counts only scores this run wrote: the session receives each already-scored session's ScoredOn as a baseline when the run starts and counts against it on every tick (Pages/Sessions/Selection/ScorePollSession.cs:48, :99). Because the service answers a server error with a failed Result rather than an exception, the consecutive-failure budget is fed from the result branch, or a persistently failing endpoint would poll silently to the cap without ever reporting (Pages/Sessions/Selection/ScorePollSession.cs:86, success read at :93). [Rubric §6, CQRS & Event-Driven] and [Rubric §29, Resilience].

Public versus authenticated rendering, and the offline-first path

A recurring [Rubric §11, Security] pattern: the same conference entity is exposed through two page families. No page under Pages/Public/ carries an @attribute [Authorize] at all, and those reads are output-cached at the API; the organizer family gates on the Organizer role in markup (Pages/Events/EventList.razor:2). PublicSessionList shows the nuance well. It is read-only for anonymous users (BR-43), but an authenticated user gets inline bookmark stars and a My Schedule toggle wired through the optional ISessionBookmarkUIService; because Blazor's [Inject] has no optional mode (an unregistered service throws at render), the page declares that dependency as a nullable property and resolves it via IServiceProvider.GetService, so it stays null when the Engagement module is disabled (Pages/Public/Sessions/PublicSessionList.razor.cs:35 to :37, resolved at :98). [Rubric §7, Microservices Readiness]. Who counts as privileged is one shared call: PublicReadAudience awaits the cascaded authentication state and tests it against the privileged-reader list ConferenceReadAudience, degrading a faulted state to the public (least-privileged) audience while letting a cancellation propagate, so an unreadable auth state never breaks a public page (Pages/Public/PublicReadAudience.cs:10, IsPrivilegedReaderAsync at :18, role test at :28, fault filter at :31), and the public session, speaker, and event pages all ask it rather than repeating the check (Pages/Public/Sessions/PublicSessionEventCatalog.cs:60 on the session list's behalf, Pages/Public/Speakers/PublicSpeakerList.razor.cs:97, Pages/Public/Events/PublicEventList.razor.cs:72, Pages/Public/Events/PublicEventDetail.razor.cs:67). Non-privileged readers are always locked to the computed current or next event via CurrentEventDefaults, so a shared privileged URL cannot pin an attendee to a different or unpublished event (Pages/Public/Sessions/PublicSessionEventCatalog.cs:129 to :144); when the events fetch failed and no scope resolved, a public reader's session query fails closed instead of running unscoped (PublicSessionEventCatalog.cs:69, :92, refused at Pages/Public/Sessions/PublicSessionList.razor.cs:257, :292), and only a privileged reader's resolved event choice is persisted at all (PublicSessionList.razor.cs:74, Pages/Public/PublicSessionListFilterState.cs:12).

PublicSessionList itself stays a container by handing its state to four page-local collaborators beside it, each kept out of the page for the code-behind size convention (Pages/Public/Sessions/PublicSessionList.razor.cs:43 to :50). PublicSessionEventCatalog loads the reader's audience, the events with their rooms, and the speaker lookup, resolves the default event, rescopes the room picker, and adds the title, event, and room filters to every query, while the page keeps only the toasts (Pages/Public/Sessions/PublicSessionEventCatalog.cs:17, load at :54, filters at :105, toasts at PublicSessionList.razor.cs:148 to :158). PublicSessionBookmarkState owns who the reader is and which sessions they starred; it starts eagerly from initialization because the grid's first ServerData call can race ahead of it, and a failed bookmark read in My Schedule mode is re-issued once and then surfaced as an error rather than as an empty schedule (Pages/Public/Sessions/PublicSessionBookmarkState.cs:14, StartLoad at :38, EnsureAsync at :51, started at PublicSessionList.razor.cs:121 to :122). PublicSessionEventScope holds the events lookup the grid loads wait on: each load re-issues a failed lookup at most once, loads that waited on the same failed lookup share one retry, and its Pending flag holds the load-failed state (and its Retry button) back while a lookup may still succeed (Pages/Public/Sessions/PublicSessionEventScope.cs:14, Pending at :23, EnsureAsync at :36, read at PublicSessionList.razor.cs:163 to :169). And NewestLoadTracker<T> answers overlapping grid loads with the newest load's result, because MudDataGrid applies whichever ServerData call completes last rather than the newest one, and a load superseded while it waited on the event scope would otherwise paint its own empty page (Pages/Public/Sessions/NewestLoadTracker.cs:14, rationale at :7 to :12, Track at :24, wired at PublicSessionList.razor.cs:211 to :212). The page also refuses to show a signed-out reader the full programme under a "My Schedule" title: on the interactive pass the ?mine=true deep link sends them to sign in first, and a restored My Schedule state is cleared once the reader is known to be anonymous (PublicSessionList.razor.cs:101 to :112, :140 to :143, :88 to :94). [Rubric §19, State Management & Data Flow] and [Rubric §18, UI Architecture & Component Design].

The single write on the public session detail page is extracted into its own component rather than growing the page: SessionBookmarkButton is the add-to / remove-from My Schedule toggle for one session, given only a session id and resolving everything else itself, so the page holds no bookmark state at all (Pages/Public/Sessions/SessionBookmarkButton.razor.cs:27, parameter at :38, hosted at Pages/Public/Sessions/PublicSessionDetail.razor:157). It repeats the optional-dependency move verbatim, resolving the Engagement-owned service through IServiceProvider.GetService and no-opping on every action when it is absent, so a deployment without Engagement renders exactly the same inert button it always did (:29, :33, resolved at :56, no-op guards at :75 and :109, reasoning at :18 to :25). It also skips its read during SSR prerender for the same reason the hosting pages skip theirs: under InteractiveAuto the interactive instance re-runs OnParametersSetAsync, so every visit would fetch the reader's bookmarks twice, and the prerender pass renders the unset star (:59 to :64). [Rubric §23, Front-End Performance & Rendering].

The public event page carries the one piece of third-party embedding in this library, and it is classified rather than trusted. VenueMapLinks decides whether a stored venue map URL is a Google Maps embed link, which Google serves only inside an iframe and which answers "The Google Maps Embed API must be used in an iframe" when opened as a top-level page (Pages/Public/Events/VenueMapLinks.cs:10, doc note at :3 to :9). The test is deliberately narrow: https only, one of three Google hosts, and either a /maps/embed path or a /maps path carrying output=embed (:16, :23 to :41); anything else keeps opening as an ordinary external link (Pages/Public/Events/PublicEventDetail.razor:73, :93, external link at :104). The host allow-list is not cosmetic: the page CSP's frame-src has to permit exactly those origins, a coupling recorded next to the array (VenueMapLinks.cs:14 to :15). The companion BuildSearchUrl escapes the venue street address into a regular (non-embed) Maps search link, and answers null when there is no address to search for, so the affordance is absent rather than dead (:48 to :51, used at PublicEventDetail.razor:86). PublicEventDetail is Pages/Public/Events/PublicEventDetail.razor.cs:18. [Rubric §11, Security].

The offline-first behaviour that used to sit in the page now lives behind an interface. IPublicSessionScheduleService / PublicSessionScheduleService run the live paged query and compose it with the framework's OfflineFirstPageSnapshot<TItem>, keeping the last successful first page in the device-local cache so a dead venue network still shows a programme (ADR-042 Wave 3, Services/Public/IPublicSessionScheduleService.cs:34, Services/Public/PublicSessionScheduleService.cs:19). The live path is never altered: the snapshot is consulted only when a fetch fails, a failure with nothing cached travels on to the grid's own handling unchanged, and the page owns only the "showing cached data" banner it raises through a callback (Pages/Public/Sessions/PublicSessionList.razor.cs:298, :305 to :312). The request is built once, so the fallback judges exactly the request a live fetch would send and never answers a My Schedule or narrowed request with the cached programme (:247 to :255). The one other door to the snapshot is a page whose query cannot run at all (a failed bookmark read, or an unresolved public scope): offline, the cached programme answers before either gate fails the page, since a dead venue network fails both reads alike, while online the snapshot never answers (:272 to :283). One page request is one SessionSchedulePageRequest (Services/Public/IPublicSessionScheduleService.cs:20), and My Schedule rides it as a set of bookmarked ids that scope the query server-side with an Id IN (...) filter rather than over-fetching and filtering in memory; an empty schedule short-circuits before the service is reached, so it can never overwrite the cached programme with nothing (Pages/Public/Sessions/PublicSessionList.razor.cs:262 to :267, live fetch at :269).

The rest of the device-capability layer (G26) shows up as injected interfaces that no-op on the web heads: the star toggle fires IHapticFeedbackService (Pages/Public/Sessions/PublicSessionListView.razor.cs:29, and again on the extracted toggle at Pages/Public/Sessions/SessionBookmarkButton.razor.cs:31), the filter bar shares a schedule screenshot through IScreenshotService and IShareService (Pages/Public/Sessions/PublicSessionListFilterBar.razor.cs:16), the public activity page opens directions through IMapNavigationService (Pages/Public/Activities/PublicActivityList.razor.cs:31), and /conference/sessions?mine=true is a deep link the MAUI head's home-screen quick action targets, which beats the saved page state when present (Pages/Public/Sessions/PublicSessionList.razor.cs:64, applied at :101). [Rubric §29, Resilience] and [Rubric §22, Responsive & Cross-Browser/Device].

Sponsors, partners, and activities, three feature areas in miniature

The sponsor surface is a compact tour of every pattern above. Organizers manage the roster through SponsorList / SponsorCreate / SponsorDetail: the list is an EventFilteredListPageBase<SponsorDTO> that inherits the default-event resolution and persistence wholesale (Pages/Sponsors/SponsorList.razor.cs:19), and the detail page edits every field except the owning event, on the stated rationale that moving a sponsorship between events is a create plus a delete (Pages/Sponsors/SponsorDetail.razor.cs:19). Attendees see the same data twice. PublicSponsorList resolves the featured event, filters the roster to it, and groups by SponsorTier ascending (package order) then by Sort and name, so the render order is deterministic rather than insertion-dependent (Pages/Public/Sponsors/PublicSponsorList.razor.cs:106 to :116); the featured event is chosen among published events only, because the events read is unscoped for privileged callers and could otherwise feature a draft (:79 to :80), and when the event publishes no packet URL the sponsorship call to action is hidden entirely rather than offering a dead link (Pages/Public/Sponsors/PublicSponsorList.razor.cs:88). ADCHome renders the same roster as a logo strip under the same tier-then-sort-then-name rule, reading it through the typed ISponsorUIService with a server-side filter to the featured event, so a second published edition's sponsors cannot bleed onto the landing page (Pages/Home/ADCHome.razor.cs:239, paged read at :248, filter built at :322 to :326, grouping at :263 to :267), and any failure leaves the strip empty and the call to action standing. The read passes an explicit 200-row roster cap because the paged endpoints default to 10 rows (Pages/Home/ADCHome.razor.cs:42, rationale at :38 to :41).

Partners are the sponsor shape run a second time, and they are the cheapest available proof that the composition actually is open for extension. The organizer trio is PartnerList / PartnerCreate / PartnerDetail (Pages/Partners/PartnerList.razor.cs:19, Pages/Partners/PartnerCreate.razor.cs:19, Pages/Partners/PartnerDetail.razor.cs:19) over PartnerService behind IPartnerUIService, an empty extension of the generic CRUD contract (Services/Partners/PartnerService.cs:10, Services/Partners/IPartnerUIService.cs:9). Where sponsors carry a commercial tier, partners carry a PartnerType, and the two pages differ from their sponsor counterparts only in that one field and its localized label (Pages/Partners/PartnerList.razor.cs:37, Pages/Partners/PartnerDetail.razor.cs:35). The create page makes the owning event a required field, precisely because the detail page refuses to move a partner afterwards, and pre-selects the current or next event through CurrentEventSelector so the common case is one click (Pages/Partners/PartnerCreate.razor.cs:14 to :18, default at :63 to :68, Pages/Partners/PartnerDetail.razor.cs:14 to :17). On the attendee side, ADCHome renders an announced-partners band grouped by type with Community first and each group ordered by Sort then name, read through the typed IPartnerUIService with the same server-side event filter, and any failure hides the whole band (Pages/Home/ADCHome.razor.cs:95, loaded at :152, fetched at :294, grouped at :308 to :312, remarks at :280 to :284). The whole area cost one nav entry, one route triple, and no edit at all to the module's service registration, for the reason the last section gives.

The activities area (the social programme: pre-conference party, coffee connect, after-party, closing ceremony) repeats the shape with two twists. ActivityCreate, ActivityDetail, and ActivityList are the organizer trio, with the create page defaulting the owning event to the current or next one through CurrentEventSelector (Pages/Activities/ActivityCreate.razor.cs:20, Pages/Activities/ActivityDetail.razor.cs:20, Pages/Activities/ActivityList.razor.cs:20). Because EventId is a real Activity column, the list's event filter needs no virtual-key resolution and goes straight through the base class's ApplyEventFilter, unlike the speaker list, whose event filter travels as a virtual key resolved server-side through the join tables (Pages/Public/Speakers/PublicSpeakerList.razor.cs:18 to :19). PublicActivityList renders the same programme chronologically for attendees, ordering by start time, then sort order, then name, so ties are deterministic (Pages/Public/Activities/PublicActivityList.razor.cs:112 to :114), and offers the directions affordance for an activity that carries its own off-site venue (:31).

The landing page

ADCHome is the conference front door, shared by the web and MAUI heads; both keep their own copy of the editorial assets under wwwroot/images, so the default serves both and neither overrides the ImageBasePath parameter (Pages/Home/ADCHome.razor.cs:19 to :20, :64 to :66, parameter at :69). It reads the events list through the typed IEventUIService, the same contract the organizer pages use, and features the live-or-next published event via CurrentEventSelector, while a failed read leaves the fallback defaults in place; drafts are filtered out client-side, because the endpoint scopes only non-privileged callers to published events and an organizer's read would otherwise feature an unpublished edition (Pages/Home/ADCHome.razor.cs:52, read at :204, selection at :206 to :215). With the sponsor and partner reads going through their typed services as well, the page declares no wire shapes of its own: its dependencies are the three injected UI services plus the TimeProvider every clock read goes through (Pages/Home/ADCHome.razor.cs:51 to :61). Three rendering decisions are worth internalizing. First, during SSR prerender it skips the backend fetches and the timer entirely and renders the static fallback, because an untimed server-side call to a cold backend would block the prerender and therefore the post-login navigation (Pages/Home/ADCHome.razor.cs:143, loads at :150 to :152). Second, the per-second countdown ticking lives in a child component behind a render fence, so this page arms only a single one-shot timer for the Live-to-Ended flip (Pages/Home/ADCHome.razor.cs:154 to :156, :171, timer at :185), classifying the moment into the EventPhase enum Upcoming/Live/Ended from the event's own time zone and going through CurrentEventSelector.ToUtc so the spring-forward gap at a midnight boundary cannot throw out of the render path (Pages/Home/ADCHome.razor.cs:328 to :349, enum at :99). Third, the fallback date is a named constant with an explicit warning that it must track the published event date, since a stale value makes the hero date and the countdown visibly jump once the real event loads (Pages/Home/ADCHome.razor.cs:49, warning at :46 to :48). [Rubric §23, Front-End Performance & Rendering]. The editorial content it renders (the keynote, the eight-track catalog, and the two pre-conference workshops) is held as static records in ADCHomeContent: KeynoteSpeakerInfo, ConferenceTrackInfo, and PreConferenceWorkshopInfo (Pages/Home/ADCHomeContent.cs:86, :95, :103, data at :18, :35, and :64); the workshop record carries only proper nouns plus a resource-key stem, so its audience and description lines stay localized (Pages/Home/ADCHomeContent.cs:99 to :103). The separate ticketing page for that workshop day is a fixed product fact rather than an event field, and says so where it sits (Pages/Home/ADCHome.razor.cs:34, rationale at :25 to :30).

Routes, navigation, and localized strings

All paths are centralized in ConferenceRoutePaths, a static catalogue of literal routes and id-parameterized builder methods (EventDetails(id), PublicSessionDetails(id), SponsorDetails(id), PartnerDetails(id), ActivityDetails(id), EventFeedbackOrganizer(id), and so on) typed against the module's identifier aliases and formatted culture-invariantly, so a route change happens in one file (ConferenceRoutePaths.cs:8 to :73; the partner triple at :38 to :40 is what a new area adds here). Two entries in that file are deliberate duplicates of routes owned by Engagement.UI, SponsorVisitLink and RoomCheckInLink (ConferenceRoutePaths.cs:64, :65), because Conference.UI must not reference Engagement.UI yet the organizer print surfaces need those links to encode into a QR; the reason is recorded inline at ConferenceRoutePaths.cs:60 to :63. [Rubric §25, Navigation, Routing & Information Architecture]. User-facing strings are not inline English: every page injects an IStringLocalizer<TPage> in its markup (Pages/Events/EventList.razor:6) and resolves labels and snackbar messages through L["..."] over co-located .resx resource pairs, including enum labels composed as keys (Pages/Partners/PartnerList.razor.cs:37). Where a string is deliberately left untranslated (the conference brand name, a postal address, a ticketing URL, the English-only editorial content) the code carries an explicit // i18n: allow marker with a reason (Pages/Home/ADCHome.razor.cs:35, :106, :110, Pages/Home/ADCHomeContent.cs:10 to :13). [Rubric §27, Internationalization & Localization] assesses externalized strings and culture-aware formatting; this area embodies it under ADR-027, which superseded the single-locale ADR-011 (primer §6).

How it all plugs into the shell

Two registration types wire the area in. ConferenceUIModule implements Common's IUIModule (the front-end counterpart of the IModule back-end contract): it declares the module's seventeen NavItem entries, whose labels are resource keys (Nav.Events, Nav.Dashboard, and so on) resolved by the shared NavMenu at render time against the co-located ConferenceUIModule.resx pair (ConferenceUIModule.cs:14, :16 to :18). Those seventeen split three ways: five public entries for everyone, Events, Sessions, Speakers, Sponsors, and Activities (ConferenceUIModule.cs:21 to :25), two speaker_id-claim-gated entries in the user section, the dashboard and the speaker's own QR (ConferenceUIModule.cs:28, :29), and an Organizer-role-gated admin group of ten, Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, Partners, Activities, and Session Selection (ConferenceUIModule.cs:32 to :41); it then exposes its assembly so the host can discover the Razor routes (ConferenceUIModule.cs:44).

The companion DependencyInjection extension AddConferenceUI() (a C# extension(IServiceCollection) member, primer §4) is the one call a host makes (MMCA.ADC.Conference.UI/DependencyInjection.cs:19, extension block at :21, method at :27). It delegates the prologue to Common's AddUIModule<ConferenceUIModule>(), which scans the module assembly for every IEntityService<,> implementation as scoped and registers the descriptor as a singleton IUIModule (MMCA.ADC.Conference.UI/DependencyInjection.cs:31), then explicitly registers what a scan cannot infer: the four child-entity services (:34 to :37), the speaker dashboard (:40), the two organizer feedback services (:43, :44), session selection (:47), the session-materials service, which is outside the scan for exactly the reason its interface records (:52, note at :49 to :51), the offline-first schedule service (:55), the three lookup services (:58 to :60), and the composite speaker-detail lookup (:64). Last, it TryAdds TimeProvider.System as a singleton, because the pages' current-event selection, the home countdown, and the event lookup cache all read the clock through TimeProvider, yet no UI head registers one and the Engagement UI's own TryAdd is skipped when that module is disabled (:70, reasoning at :66 to :69). Public share links are not registered here: IPublicLinkBuilder comes from the framework's own AddUIShared, and the MAUI head overrides that registration afterwards so shared links always point at the web app, a note left in place where the registration used to be (MMCA.ADC.Conference.UI/DependencyInjection.cs:72 to :75). Because the scan covers the entity services, the partner area arrived with no edit to this file at all, and because the module contributes its own nav and assembly, the shell folds it in with no edit to the shell either. [Rubric §1, SOLID] (Open/Closed) and [Rubric §18, UI Architecture]. Read the per-type sections that follow for the mechanics of each page, model, and service; the bUnit and Playwright tests that exercise this library live in the testing chapter (G28).

ConferenceRoutePaths

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:8 · Level 0 · class (static)

  • What it is: one static class holding every Conference UI route, as public static readonly string constants for fixed paths and small factory methods for id-bearing paths. It covers the organizer management routes, the public attendee routes, the speaker surfaces, and the two QR landing links, so no @page directive or NavigateTo call has to hard-code a URL.
  • Depends on: no first-party types. It uses the module's identifier aliases (EventIdentifierType, SessionIdentifierType, SpeakerIdentifierType, ConferenceCategoryIdentifierType, QuestionIdentifierType, RoomIdentifierType, SponsorIdentifierType, PartnerIdentifierType, ActivityIdentifierType) that the Conference Shared project declares as global using (see the primer on identifier-type aliases, and ADR-048), plus System.Globalization.CultureInfo (:1).
  • Concept introduced: a centralized navigation vocabulary. [Rubric §25, Navigation and Information Architecture] assesses whether routes form a coherent, role-aware information architecture instead of scattered magic strings; this class is that story in miniature. The paths split into two deliberate namespaces mirroring the module's two audiences: organizers work under bare prefixes (/events :10, /sessions :14, /speakers :18, /conferencecategories :22, /questions :26, /rooms :30, /sponsors :34, /partners :38, /activities :42) while attendees work under a /conference/... prefix (PublicSessions :47, PublicEvents :48, PublicSpeakers :51, PublicSponsors :53, PublicActivities :54). Detail routes are methods rather than constants because they interpolate a typed id: EventDetails(EventIdentifierType id) (:12) builds /events/{id} with string.Create(CultureInfo.InvariantCulture, ...) so an integer id can never be formatted with a culture-specific group separator. [Rubric §27, Internationalization] shows up here as the negative case: URLs are the one place culture-aware formatting must be suppressed.
  • Walkthrough: the file is a flat list grouped by entity, each group contributing a list route, a create route, and a details factory: events (:10-12), sessions (:14-16), speakers (:18-20), conference categories (:22-24), questions (:26-28), rooms (:30-32), sponsors (:34-36), partners (:38-40), activities (:42-44). The public attendee block follows (:46-54), and it carries two detail factories of its own, PublicEventDetails (:49) and PublicSessionDetails (:50), so an attendee-facing deep link never borrows an organizer route. Then the speaker surfaces SpeakerDashboard and SpeakerQr (:57-58), the two QR self-service links, the organizer feedback factories, and the selection dashboard.
    • The partners routes follow the same shape as every other organizer entity (:38-40): Partners, PartnerCreate, and PartnerDetails(PartnerIdentifierType id), inserted between sponsors and activities so the entity list stays alphabetically adjacent to its neighbor in both the route file and ConferenceUIModule's nav list.
    • The two QR links are deliberate duplicates (:64-65). SponsorVisitLink and RoomCheckInLink build /engage/sponsors/{id} and /engage/rooms/{id}, but those two pages are owned by Engagement.UI. The comment (:60-63) records the reason: Conference.UI must not reference Engagement.UI, yet the organizer print surfaces need the URL to encode into a QR code, and EngagementRoutePaths duplicates a Conference session route the same way. Both are consumed by a QrCodeButton on the sponsor and room detail pages (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:70, MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:58).
    • Feedback routes nest under their parent entity: EventFeedbackOrganizer gives /events/{id}/feedback and SessionFeedbackOrganizer gives /sessions/{id}/feedback (:68-69), so the URL itself expresses the ownership hierarchy. SessionSelectionDashboard closes the file (:72).
    • Two factories differ from the rest: SpeakerDetails (:20) and PublicSpeakerDetails (:52) use plain interpolation rather than string.Create(CultureInfo.InvariantCulture, ...), because SpeakerIdentifierType is a Guid whose ToString() is already culture-invariant.
  • Why it's built this way: if the admin prefix ever moves (say /events becomes /admin/events), editing the one constant propagates the change to every navigation call, with no grep-and-replace and no risk of a stale link. Keeping the parameterized routes as methods typed against the identifier aliases means a wrong-entity id is a compile error, not a 404.
  • Where it's used: every Conference UI Blazor page's @page directive and NavigationManager.NavigateTo call, the "see all sponsors" link on the landing page (ADCHome.razor:308), the NavItems collection in ConferenceUIModule (ConferenceUIModule.cs:21-40), and even one Engagement page, which links back to PublicSponsors (MMCA.ADC.Engagement.UI/Pages/Sponsors/SponsorVisit.razor:42).

IanaTimeZoneAttribute

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC.Conference.UI/Pages/Events/IanaTimeZoneAttribute.cs:30 · Level 0 · sealed class (ValidationAttribute)

  • What it is: a client-side DataAnnotations rule that a string is a valid IANA time zone identifier (for example America/New_York), rather than any free-form text. An empty or whitespace value passes (:43-46), so it composes with [Required] rather than duplicating it.
  • Depends on: System.ComponentModel.DataAnnotations (ValidationAttribute, ValidationResult, ValidationContext) and System.TimeZoneInfo (externals only, no first-party types).
  • Concept introduced: validating client-side against the same authority the runtime trusts. IsValid (:39-55) does not maintain its own list of legal identifiers; it calls TimeZoneInfo.TryFindSystemTimeZoneById (:48), the same lookup the .NET runtime itself uses to resolve a zone. That keeps the client rule and the server's actual interpretation of the value in permanent agreement, at the cost of the rule depending on the OS's installed time zone database rather than a fixed, portable list. [Rubric §24, Forms, Validation and UX Safety] assesses whether a form rejects only truly illegal input; this does, because "illegal" is defined by the same authority that will later parse the value.
  • Walkthrough: the constructor (:33-36) seeds a default English message. IsValid (:39-55) guards the null ValidationContext with ArgumentNullException.ThrowIfNull (:41), returns success on anything that is not a non-empty string (:43-46), returns success when TryFindSystemTimeZoneById finds the zone (:48-51), and otherwise builds a ValidationResult carrying ErrorMessage against the failing member name (:53-54).
  • Why it's built this way: EventFormModel's TimeZone property already carries [Required]; layering this attribute on top rejects a syntactically present but meaningless value ("Nowhere") before the record ever reaches the server, where the same check would otherwise run for the first time.
  • Where it's used: EventFormModel.TimeZone (MMCA.ADC.Conference.UI/Pages/Events/EventFormModel.cs:54), resolved at render time through the page's IStringLocalizer per ADR-027.

SessionizeCodeAttribute

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC.Conference.UI/Pages/Events/SessionizeCodeAttribute.cs:23 · Level 1 · sealed class (ValidationAttribute)

  • What it is: a client-side DataAnnotations rule that an event's optional Sessionize import code contains only letters, digits, underscores, and hyphens. Like IanaTimeZoneAttribute, an empty or whitespace value passes, so it composes with the field being optional rather than required.
  • Depends on: System.ComponentModel.DataAnnotations (externals), plus the first-party SessionizeCodeFormat helper (:41) that owns the actual character-class check.
  • Concept introduced: the attribute as a thin DataAnnotations adapter over a plain format check. The attribute itself holds no regular expression or character-class logic; it delegates to SessionizeCodeFormat.IsValid(code) (:41) and only translates the answer into a ValidationResult. That keeps the format rule reusable outside DataAnnotations (for example from a non-Blazor caller) while still surfacing it as a normal form attribute here. [Rubric §15, Best Practices and Code Quality]: the format rule has exactly one implementation, which this attribute wraps rather than duplicates.
  • Walkthrough: the constructor (:26-29) seeds the default message ("may contain only letters, digits, underscores and hyphens"). IsValid (:32-48) guards the null context (:34), passes on anything that is not a non-empty string (:36-39), passes when SessionizeCodeFormat.IsValid accepts the code (:41-44), and otherwise returns a ValidationResult against the failing member (:46-47).
  • Why it's built this way: the Sessionize import code is embedded in a URL the server builds when refreshing an event, so rejecting an unsafe character client-side avoids a round trip that the server would reject for the same reason.
  • Where it's used: EventFormModel.SessionizeCode (MMCA.ADC.Conference.UI/Pages/Events/EventFormModel.cs:59).

ConferenceUIModule

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14 · Level 3 · class (sealed)

  • What it is: the Conference module's UI descriptor. It contributes the navigation items for the whole conference capability (public Events/Sessions/Speakers/Sponsors/Activities, the claim-gated speaker Dashboard and QR page, and an organizer admin group covering Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, Partners, Activities, and Session Selection) and exposes its assembly so the host can discover the module's routable Blazor components.
  • Depends on: IUIModule (the contract it implements), NavItem and NavSection (the nav vocabulary from MMCA.Common.UI.Common), RoleNames (the Organizer role string), ConferenceRoutePaths (the URLs), plus MudBlazor Icons and System.Reflection.Assembly (externals, :1, :5) and the co-located ConferenceUIModule.resx / ConferenceUIModule.es.resx pair.
  • Concept introduced: the modular-UI descriptor, the front-end analogue of IModule. [Rubric §18, UI Architecture and Component Design] assesses whether UI is composed from cohesive, self-describing modules rather than a hard-coded master shell; a module declaring its own menu is exactly that, and it is the Open/Closed half of [Rubric §1, SOLID]: enabling a module adds its navigation with no edit to the shell. [Rubric §25, Navigation and Information Architecture] is served because the items are role- and claim-aware and grouped into sections. [Rubric §11, Security] applies with an important caveat: hiding a nav item is UX only. The services still enforce authorization server-side, so the claim and role here are not the security boundary. Per ADR-027 the Title and Group strings are resource keys, not literals: typeof(ConferenceUIModule) in the TitleResource position on every item tells the shared NavMenu to resolve them against the co-located .resx at render time, which the file's own comment records (:16-17). The record's own documentation adds the failure mode (MMCA.Common.UI/Common/NavItem.cs:14-18): a key the resource type does not declare renders as the raw string, so a not-yet-translated entry is legible rather than blank.
  • Walkthrough: NavItems (:18-42) is an IReadOnlyList<NavItem> initialized with a collection expression in three tiers, seventeen items in all.
    • Public (:21-25): five items for everyone, anonymous included, pointing at the /conference/... routes: Events, Sessions, Speakers, Sponsors, Activities. They carry no RequiredRole and no Section, so they default to NavSection.General (MMCA.Common.UI/Common/NavItem.cs:16).
    • Speaker (:28-29): the Dashboard and the QR page, both carrying RequiredClaim: "speaker_id" and Section: NavSection.User, so they appear only for a user whose JWT links them to a speaker record and they render in the user menu rather than the main list.
    • Organizer (:32-41): ten items, each carrying RoleNames.Organizer, Section: NavSection.Admin, and Group: "Nav.Group.Conference" so they fold into one labelled admin group. The new Nav.Partners entry (:38) sits between Nav.Sponsors and Nav.Activities, pointing at ConferenceRoutePaths.Partners with Icons.Material.Filled.Diversity3, matching the position of the routes themselves in ConferenceRoutePaths; the tier ends with the Session Selection entry (:41).
    • Assembly (:44) returns typeof(ConferenceUIModule).Assembly so the host's Blazor router can discover this library's routable components. Note that "Events", "Sessions", "Speakers", "Sponsors", and "Activities" each appear twice in the list, once public and once organizer, differing only in route and gating: the same label serves two audiences with two destinations. Partners has no public counterpart, so it appears once.
  • Why it's built this way: mirroring the backend IModule pattern on the UI side keeps the app extensible. A host that boots without the Conference module simply has no conference nav and no conference routes, with no conditional code anywhere in the shell. The class also leaves AppBarComponentTypes and LayoutComponentTypes at their interface defaults (MMCA.Common.UI/Common/Interfaces/IUIModule.cs:20-23): Conference contributes no app-bar badge and no root overlay.
  • Where it's used: registered as a singleton IUIModule by this module's DependencyInjection through AddUIModule<ConferenceUIModule>() (DependencyInjection.cs:23, implemented at MMCA.Common.UI/DependencyInjection.cs:320-329) and aggregated by the shared UI navigation builder in group 15.

EventFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventFormModel.cs:27 · Level 3 · abstract class (form model)

  • What it is: the editable shape of a conference event, declared once and bound by two different screens: the organizer create page and the detail page's inline editor. It is a plain DataAnnotations-decorated class with no Blazor, no service, and no MudBlazor reference.
  • Depends on: EventDTO for every length cap and QuestionModerationDefault for the moderation field (MMCA.ADC.Conference.Shared.Events, line 2), plus AbsoluteUrlAttribute from MMCA.Common.UI.Validation (line 4), OptionalEmailAttribute from the same namespace, and the co-located IanaTimeZoneAttribute and SessionizeCodeAttribute. Externals: System.ComponentModel.DataAnnotations (Required, MaxLength).
  • Concept introduced, the shared form model as the single declaration of a form's rules. A Blazor form can end up declaring its rules in three places at once: on the DTO, on each MudTextField's Required / RequiredError / MaxLength attributes, and again in a hand-written check inside the submit handler. This type collapses the first two. Each property carries its own [Required] / [MaxLength] / [OptionalEmail] / [AbsoluteUrl] (lines 44-89), and two of the properties add a format rule of their own: IanaTimeZoneAttribute on TimeZone (line 54) and SessionizeCodeAttribute on SessionizeCode (line 59). The pages bridge MudBlazor to them through ModelValidation.For rather than repeating a rule per field. The two ErrorMessage resource keys that name a missing value are hoisted to consts, NameRequiredKey (line 33) and TimeZoneRequiredKey (line 40), so the model's rule and the field's own RequiredError affordance quote the same key instead of two string literals that can drift. Every other ErrorMessage is a bare resource key too, resolved at render time by the page's DataAnnotationsModelValidator wrapped around that page's IStringLocalizer (ADR-027, Website/docs-src/adr/027-multi-locale-i18n.md). [Rubric §24, Forms, Validation & UX Safety] assesses whether a form can express only legal input and says why in place: the caps here are not invented, they are EventDTO.NameMaxLength and its siblings (lines 45, 49, 55, 60, 64, 69, 73, 78, 83, 88), so the client rejects exactly what the server column would reject. [Rubric §15, Best Practices & Code Quality] assesses whether one change lands in one place: adding a field to the event form means one property here plus one control in the shared EventFormFields component, and both screens get it. [Rubric §27, Internationalization]: no message is a literal, every one is a key (ADR-011, Website/docs-src/adr/011-single-locale-i18n.md, revisited by ADR-027).
  • Walkthrough
    • Two resource-key consts (lines 34, 41) for the required-value messages.
    • Ten annotated text properties (lines 44-89): Name and TimeZone are required and non-nullable with string.Empty defaults; the eight optional ones are nullable. TimeZone also carries [IanaTimeZone] (line 54) so a value that is present but not a real zone identifier is rejected before it reaches the server, and the optional SessionizeCode carries [SessionizeCode] (line 59) for the same reason. OrganizerContactEmail adds [OptionalEmail] (line 77), the framework attribute that validates the address only when one is entered, and VenueMapUrl, SponsorshipPacketUrl, and TicketingUrl all add [AbsoluteUrl] (lines 68, 82, 87), the framework attribute that rejects a relative or scheme-less URL.
    • Three unannotated properties (lines 92, 95, 98): StartDate, EndDate, and QuestionModerationDefault. The class doc (lines 19-24) is explicit that this is deliberate: DataAnnotations cannot express a date-range rule or a picker's required affordance, so the pickers keep their own required markup and the pages keep the cross-field date check, but the values still live on the one model rather than as loose page fields.
  • Why it's built this way: the create page and the detail editor edit the same thing, so a value one accepts must be a value the other accepts. Making the model abstract and letting the two concrete models add only their save shape (see EventCreateModel and EventEditModel) makes that guarantee structural instead of a convention someone has to remember.
  • Where it's used: EventCreateModel and EventEditModel derive from it; EventCreate and EventDetail bind an instance to the shared EventFormFields component (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventFormFields.razor).

EventCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventCreateModel.cs:11 · Level 4 · sealed class (form model)

  • What it is: the create-side concrete of EventFormModel. It adds exactly two things to the shared base: the time zone a new event opens on, and the method that turns the entered values into the DTO the create posts.
  • Depends on: EventFormModel and EventDTO (line 1). No services, no Blazor.
  • Concept introduced, the model owns the save shape. The recurring alternative is a page handler that news up a DTO inline from a dozen backing fields. Moving that into ToNew means the "what a create posts" decision is one readable object initializer instead of a paragraph inside an async Task, and it is unit-testable without a renderer. [Rubric §14, Testability] assesses whether behavior can be exercised without its host: this class has no Blazor surface at all. [Rubric §18, UI Architecture & Component Design]: the code-behind keeps orchestration (validate, post, navigate) and delegates shape.
  • Walkthrough
    • The parameterless constructor (line 14) seeds TimeZone = "America/New_York", the conference's home zone, so the common case needs no edit. That is the entire create-specific default.
    • ToNew() (lines 25-42) builds an EventDTO with Id = default (line 28), converts the two DateTime? pickers with DateOnly.FromDateTime (lines 31-32), copies the ten text fields across, and hard-codes IsPublished = false (line 41). The null-forgiving StartDate!.Value is safe because the page runs its date guards first; the <remarks> (lines 21-24) says exactly that.
    • Note what ToNew does not carry: QuestionModerationDefault is on the base but is not written into the created DTO, so a new event takes whatever default the server applies. Only EventEditModel posts it.
  • Why it's built this way: Id = default hands identifier assignment to the server, and the unpublished default exists so an organizer can fill in venue details and refresh from Sessionize before anything is publicly visible. Publishing is a separate deliberate gesture on EventDetail.
  • Where it's used: instantiated as the _model field of EventCreate (.../Pages/Event/EventCreate.razor.cs:44) and consumed by its CreateEventAsync (.../Pages/Event/EventCreate.razor.cs:84).

EventEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventEditModel.cs:11 · Level 4 · sealed class (form model)

  • What it is: the edit-side concrete of EventFormModel, used by EventDetail's inline editor. It is a round trip in two methods: LoadFrom fills the editor from the loaded event, ToUpdated produces the DTO the update posts.
  • Depends on: EventFormModel and EventDTO (line 1).
  • Concept introduced, shadow editing through a model instead of shadow fields. The live loaded EventDTO is never mutated: opening the editor copies it into this model, and only a validated save produces a new DTO. Because both directions are one method, a newly added property cannot be carried by one path and dropped by the other, which is exactly the failure mode a field-by-field transcription in the page invites (class doc, lines 13-15). [Rubric §8, Data Architecture] assesses whether concurrency is handled deliberately: ToUpdated re-sends the loaded RowVersion (line 55), the client half of the optimistic-concurrency token in Website/docs-src/adr/035-optimistic-concurrency.md, so a stale edit is rejected rather than silently overwriting a concurrent one. [Rubric §11, Security] assesses whether a client can move state through a path it should not: IsPublished is copied straight off the source (line 68), never off the form, so the edit form structurally cannot publish or unpublish.
  • Walkthrough
    • LoadFrom(EventDTO source) (lines 18-35): ArgumentNullException.ThrowIfNull (line 20), then copies the ten text fields, converts the two DateOnly values back to DateTime at TimeOnly.MinValue for the pickers (lines 24-25), and copies QuestionModerationDefault (line 34).
    • ToUpdated(EventDTO source) (lines 48-71): builds a new EventDTO carrying identity and concurrency from the source (Id line 54, RowVersion line 55, IsPublished line 68), the edited text and dates from the model, and the edited QuestionModerationDefault (line 69). Same null-forgiving date access, same <remarks> justification (lines 44-47).
    • The class doc (lines 8-9) records the deliberate omission: no published flag on the form, because publishing and unpublishing are their own buttons.
  • Why it's built this way: an update endpoint reads a full DTO, so something must write every property; doing it in one place is the only way to keep the read path and the write path in agreement as fields are added.
  • Where it's used: the _model field of EventDetail (.../Pages/Event/EventDetail.razor.cs:53), driven by its StartEditing (.../Pages/Event/EventDetail.razor.cs:119) and SaveChangesAsync (.../Pages/Event/EventDetail.razor.cs:156).

EventCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventCreate.razor.cs:16 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer form that creates a conference event. It binds an EventCreateModel to the shared EventFormFields block, validates, posts the record unpublished, and redirects to the new event's detail page. It is the place where the Conference create-form shape is taught.
  • Depends on: IEventUIService (line 18), IToastService (line 20), EventCreateModel (line 50), ModelValidation plus DataAnnotationsModelValidator (line 44), ConferenceRoutePaths, ResultUiExtensions.NotifyOnFailure (line 105), and ErrorMessages (line 78). Externals: Blazor ([Inject], NavigationManager, OnInitialized), MudBlazor (MudForm, BreadcrumbItem, Icons.Material.Filled.Home), the UnsavedChangesGuard, EventFormFields, and ErrorSummary components wired in the template (.../Pages/Event/EventCreate.razor:10, 26, 41), and the IStringLocalizer<EventCreate> the template injects as L (.../Pages/Event/EventCreate.razor:6).
  • Concept introduced, the partial-class code-behind create form. Every Conference page is a .razor template plus a .razor.cs partial holding the injected services, backing fields, and handlers. The create leg layers five recurring mechanisms on that split, and every other create page in this group repeats them:
    1. Cancel-on-disposal: a CancellationTokenSource _cts (line 22) is read through a LifetimeToken property (line 27, _cts.LifetimeToken()) and passed to the service call (line 99), then cancelled plus disposed through the standard Dispose(bool) pattern (lines 127-143), so an in-flight save cannot resolve against a torn-down component. The property exists because reading Token off a disposed source throws ObjectDisposedException; LifetimeToken() hands a handler that resumes after disposal an already-cancelled token instead, so it exits through its OperationCanceledException path (comment, lines 24-26; MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26).
    2. Model-declared validation: OnInitialized builds one delegate, ModelValidation.For(_model, new DataAnnotationsModelValidator(L)) (line 44), and the template hands that single delegate to every field on EventFormFields (.../Pages/Event/EventCreate.razor:26). No Required or MaxLength is written in markup; the rules live on EventFormModel and the ErrorMessage keys are resolved through L (ADR-027).
    3. Validate-then-submit: await _form.ValidateAsync() followed by an IsValid guard (lines 75-80), then two hand-written cross-field checks DataAnnotations cannot express, both dates present (lines 82-86) and end not before start (lines 90-94). The second one's comment (lines 88-89) is explicit that it mirrors the server rule so the organizer sees it without a round trip. The IsSaving property (line 47) disables the button for the round trip and is always cleared in finally (lines 117-120).
    4. An unsaved-changes guard: _isDirty is set by MarkDirty() (line 66), passed to EventFormFields as OnFieldChanged (.../Pages/Event/EventCreate.razor:27), and read by the UnsavedChangesGuard component (.../Pages/Event/EventCreate.razor:10). It is cleared before the success redirect (line 109) so the guard does not block the page's own navigation.
    5. Two-tier failure handling: a failed Result is captured in _saveResult (line 100) and notified through created.NotifyOnFailure(Toast, L) (line 105), so the wording reaches both the snackbar and, inline, the template's ErrorSummary; the comment (lines 103-104) explains why the inline copy matters, the snackbar can time out while the reader is still reading the form. OperationCanceledException is swallowed as expected during disposal or an InteractiveAuto render-mode transition (lines 113-116). Website/docs-src/adr/056-blazor-render-mode-strategy.md is the record behind that second catch. [Rubric §24, Forms, Validation & UX Safety] assesses client validation, unsaved-change protection, and safe submits: this page validates before posting, tracks dirty state, and guards navigation away. [Rubric §18, UI Architecture & Component Design]: the code-behind holds orchestration, the model holds rules and shape, the shared field component holds markup. [Rubric §11, Security]: the route is organizer-only via @attribute [Authorize(Roles = "Organizer")] (.../Pages/Event/EventCreate.razor:2). [Rubric §27, Internationalization]: every label, breadcrumb, and toast reads through L (for example L["Snackbar.Created"], line 110), per ADR-011 and ADR-027.
  • Walkthrough
    • OnInitialized (lines 33-45) builds the three-item breadcrumb trail (Home, the events list, a disabled "Create" leaf, lines 36-41) and wires the validation delegate (line 44).
    • _model (line 50) is a single EventCreateModel, whose constructor already seeded the time zone; the page keeps no per-field backing state of its own.
    • CreateEventAsync (lines 68-121) clears _saveResult (line 73), validates, runs the two date guards, sets IsSaving, posts _model.ToNew() with AddAsync (line 99), clears _isDirty, toasts success, and navigates to ConferenceRoutePaths.EventDetails(createdEvent.Id) (line 111) using the id the server returned.
    • NavigateToList (line 123) is the cancel path back to /events.
    • The template's ErrorSummary (.../Pages/Event/EventCreate.razor:41-42) collapses duplicates: its own comment (lines 36-40) notes MudBlazor validates a For-bound field through the model annotations as well as through the shared delegate, so one empty field can report the same rule twice, once localized and once as the bare key. Resolving every entry and de-duplicating leaves one wording per broken rule.
  • Why it's built this way: a new event starts unpublished so an organizer can complete venue details and refresh from Sessionize before anything is publicly visible; publishing is a separate deliberate action on EventDetail. Binding the same EventFormFields block that the detail editor binds is what makes "a value the create page accepts is a value the detail page accepts" true by construction rather than by review.
  • Where it's used: the /events/create route (.../Pages/Event/EventCreate.razor:1), reached from EventList's create button; on success it hands off to EventDetail.

EventList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventList.razor.cs:17 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer browse page for events: a server-paged, server-sorted grid on desktop, an infinite-scroll card list on mobile, with a name search, a delete-with-confirmation action, and navigation into create and detail. It is the simplest inheritor of the shared list base and the place where the Conference list-page shape is taught.
  • Depends on: extends DataGridListPageBase<TDto> over EventDTO (line 16) and injects IEventUIService (line 21). It uses ListPageActions (lines 40, 72), ErrorMessages (line 78), ConferenceRoutePaths, and the MobileInfiniteScrollList<TItem>, DeleteConfirmation, and ListNoRecordsContent components from MMCA.Common.UI. The toast service and the IsMobile flag come from the base.
  • Concept introduced, the two-layout list page over one shared base. A Conference list page is short because everything hard lives in DataGridListPageBase<TDto>. The derived page supplies five things and nothing else:
    1. A grid reference: _dataGrid captured via @ref and surfaced through the overridden GridRef (lines 24-25), which the base needs to restore rows-per-page after first render.
    2. Filter persistence: SaveFilters / RestoreFilters (lines 33-37) write and read the page's own search string, and the base persists them so filters survive navigation, refresh, and a shared link. [Rubric §25, Navigation & Information Architecture].
    3. The fetch delegates: LoadServerData (lines 48-57) hands the base a lambda that calls EventService.GetPagedAsync and an additional-filters callback that appends Name contains <search> (lines 53-57). LoadServerDataAsync in the base owns cancellation-token resetting, page-index conversion, sort extraction, the IsLoading and LoadFailed flags, and uniform error reporting.
    4. A mobile fetch: FetchMobilePage (lines 60-66) repeats the same filter build for the card list, which pages by "load more" instead of a pager and sorts by Name asc (line 65). [Rubric §22, Responsive & Cross-Browser]: the page renders two genuinely different layouts off the base's IsMobile flag rather than reflowing one grid (.../Pages/Event/EventList.razor:31).
    5. Actions: DeleteEventAsync (lines 71-79) delegates the confirm, delete, toast, and reload sequence to ListPageActions.DeleteWithConfirmationAsync, and ReloadActiveLayoutAsync (lines 39-40) dispatches a refresh to whichever layout is live. The failure path is worth noting: when a fetch fails the base sets LoadFailed, and the template feeds it to ListNoRecordsContent with an OnRetry handler wired to RetryLoadAsync (line 28, .../Pages/Event/EventList.razor:131), so a failed load renders an inline retry instead of an empty list that reads as "no events". [Rubric §19, State Management & Data Flow]. Data access follows Website/docs-src/adr/094-client-entity-data-access.md: the page never touches HttpClient, only the typed I*UIService client, and expresses filters as operator-plus-value pairs the server model binder understands.
  • Walkthrough
    • Title and EntityName (lines 18-19) come from the injected localizer; Title is the abstract member the base uses in its error messages.
    • OnSearchChanged (lines 42-46) stores the text and reloads the active layout, so typing drives a server round trip rather than a client-side filter over the current page.
    • OnMobileCardClick (line 68) and NavigateToDetails (lines 84-85) both route through ConferenceRoutePaths.EventDetails(id), and NavigateToCreate (lines 81-82) opens EventCreate.
    • The grid declares three sortable PropertyColumns over Name, StartDate, and EndDate (.../Pages/Event/EventList.razor:79, 87, 96), with Name carrying InitialDirection="SortDirection.Ascending" so the first page arrives ordered.
  • Why it's built this way: many list pages across the workspace share this base, so browse behavior, state persistence, and error handling stay identical everywhere and a new list page costs a few dozen lines.
  • Where it's used: the /events organizer route (.../Pages/Event/EventList.razor:1-2, Authorize(Roles = "Organizer")); rows open EventDetail and the create button opens EventCreate.

EventDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventDetail.razor.cs:20 · Level 8 · class (Blazor code-behind)

  • What it is: the organizer's event console. It loads one event by route id and offers four distinct operations on it: inline edit, publish/unpublish, refresh from Sessionize, and delete. It is the richest detail page in the Conference UI in terms of verbs, and the place where the detail-page shape is taught.
  • Depends on: DetailPageBase (@inherits, .../Pages/Event/EventDetail.razor:6), IEventUIService (line 24) for all four operations, IToastService (line 26), EventEditModel (line 54), LatestLoadGuard through the base's LoadGuard (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43); EventDTO, RefreshFromSessionizeResultDTO, and QuestionModerationDefault; ConferenceRoutePaths, ErrorMessages (line 99), ResultUiExtensions.NotifyOnFailure (lines 108, 173, 180, 341), DomainHelper's Parse<T> extension (line 4), and the DeleteConfirmation, UnsavedChangesGuard, EventFormFields, and ErrorSummary components (.../Pages/Event/EventDetail.razor:11, 37, 51, 197).
  • Concept introduced, load-once-on-parameters over a shared detail base. Four mechanisms combine here and recur in every detail page in this group:
    1. A shared base owns the boring half. DetailPageBase supplies PageToken (the page-scoped cancellation token with its dispose pattern) and the edit-mode lifecycle (IsEditing, IsDirty, BeginEdit, EndEdit), so this file contains no CancellationTokenSource and no Dispose at all. It also supplies LoadGuard, a LatestLoadGuard the load below uses so only the newest load may write the page.
    2. Route id as a string: the id arrives as [Parameter] public string Id (line 28) and is converted with Id.Parse<EventIdentifierType>() (line 90), so the page compiles unchanged whichever primitive the alias maps to (ADR-048, ADR-085).
    3. Load once per id, newest load wins: OnParametersSetAsync compares against _loadedId and returns early when the id is unchanged (lines 70-80), so a re-render does not refetch. When the id does change, LoadEventAsync starts with LoadGuard.Begin() (line 85), which supersedes any load still in flight, and a response that is no longer current is dropped (lines 91-94).
    4. Shadow editing through a model: StartEditing copies the loaded record into EventEditModel and calls BeginEdit (lines 126-135); CancelEditing is just EndEdit (line 137). The live Event object is never mutated until a validated save succeeds. [Rubric §24, Forms, Validation & UX Safety]. [Rubric §8, Data Architecture] assesses a deliberate concurrency strategy: every mutating call re-sends the loaded RowVersion, on save through EventEditModel.ToUpdated and on publish/unpublish explicitly (lines 220-221), which is the client half of the optimistic-concurrency token described in Website/docs-src/adr/035-optimistic-concurrency.md. [Rubric §6, CQRS & Event-Driven]: publish and unpublish are not IsPublished flag edits, they are their own named service operations (PublishAsync / UnpublishAsync, .../Services/IEventUIService.cs:13, 15), so each state transition stays a use case the server can guard on its own terms. [Rubric §13, Observability & Operability]: the Sessionize refresh returns a RefreshFromSessionizeResultDTO held in _refreshResult (line 64) so the organizer sees what the import actually did.
  • Walkthrough
    • OnInitialized (lines 34-46) builds breadcrumbs and wires the validation delegate against _model (line 45), exactly as EventCreate does.
    • LoadEventAsync (lines 82-124): LoadGuard.Begin() yields a token and a generation (line 85), and GetByIdAsync(id, true, token) fetches with children (line 90); a response from a superseded generation returns without touching the page (lines 91-94). A NotFound result clears Event and toasts ErrorMessages.NotFound (lines 96-100); any other failure goes through NotifyOnFailure (line 108) so the server's own message reaches the user; success seeds _sessionizeCode (line 104). The finally clears IsLoading only for the current generation (lines 117-122), because an unconditional clear would let a superseded response switch off the spinner the newer load just turned on (comment, lines 117-118).
    • SaveChangesAsync (lines 139-197): validate the form, re-check both dates present and end not before start (lines 153-165, the same two guards EventCreate runs), post _model.ToUpdated(Event) (line 170), then refetch the record (line 177) so the page shows server truth rather than the values it just sent, toast, and EndEdit.
    • PublishAsync / UnpublishAsync (lines 199-201) both delegate to SetPublishedAsync(bool) (lines 208-249), which picks the failure message up front (line 215), calls the chosen named operation with the current RowVersion (lines 219-221), refetches, and reports the reload miss with the same message as the toggle itself, since the toggle is what the user asked for (doc comment, lines 203-207). A failed toggle shows ActionErrorMessage(toggled, failureMessage) (line 227): a refusal the API stated, such as a stale row version answering 412 with Concurrency.PreconditionFailed, shows its own localized message (comment, lines 224-226).
    • ActionErrorMessage(Result, fallback) (lines 261-269) is the shared rule for a failed user action: an ErrorType.Unexpected result (a 500, a transport failure, a timeout) shows the generic fallback rather than raw diagnostic text, anything else shows result.LocalizedErrorMessage(L) and falls back only when that is null (ADR-027 Decision 9 carve-out, per its doc comment). ErrorType is the classification it tests.
    • SessionizeCodeError (lines 251-259) is the Sessionize panel's own format check: null while the code is blank or well formed, otherwise the same Validation.SessionizeCodeFormat message the Edit form shows, since SessionizeCodeAttribute applies the same SessionizeCodeFormat rule.
    • RefreshFromSessionizeAsync (lines 271-322) first rejects a malformed code before any call, toasting SessionizeCodeError (lines 278-284), because the persist step would refuse it after a round trip with a less specific message. It then hands the whole gesture to one service call, RefreshFromSessionizeWithCodeAsync(Event, _sessionizeCode, PageToken) (line 292). The service owns persist-then-import-then-reload and returns a single outcome carrying both the summary and the refreshed event (lines 309-310), so the page keeps one failure message for the whole gesture. The failure branch toasts through ActionErrorMessage (line 295) and does one more thing: because the code persist and the refresh stamp can both commit before the import fails, moving the row version, it refetches the stored event and adopts it when the read succeeds (lines 301-304). The comment (lines 297-300) names the payoff: the next refresh, save, or publish carries the current RowVersion instead of hitting a 409 until a reload, while the typed _sessionizeCode is left as entered so a retry re-persists it. This is the same client half of ADR-035 described above, applied to a partially committed gesture.
    • DeleteEventAsync (lines 324-352): confirm through _deleteConfirm.ShowAsync(Event.Name) (line 331), delete, then navigate back to the list.
  • Why it's built this way: an event is the root aggregate of the whole conference, so publishing, importing, and editing carry separate audit meaning rather than collapsing into one PUT; keeping each as its own service call is what lets the server enforce its own rules per transition. Pushing the three-step Sessionize gesture into the service (rather than sequencing it in the page) is the same instinct applied one level down.
  • Where it's used: the /events/{Id} organizer route (.../Pages/Event/EventDetail.razor:1-2), reached from EventList rows and from EventCreate's success redirect. Its "view feedback" button opens OrganizerEventFeedback (.../Pages/Event/EventDetail.razor:135-136).
  • Caveats / not-in-source: what the Sessionize refresh imports, and how it reconciles existing rows, is server-side; this page only shows the returned summary.

DependencyInjection

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI/DependencyInjection.cs:19 · Level 6 · class (static)

  • What it is: the Conference UI composition root. Its single AddConferenceUI() method is the one call a host makes to register every Conference UI service (the per-entity CRUD services by assembly scan, then the child-entity, dashboard, feedback, selection, session-asset, offline-schedule, and lookup services explicitly) plus the module descriptor, and a fallback TimeProvider for the clock-reading pages.

  • Depends on: ConferenceUIModule and, through AddUIModule<T> (MMCA.Common.UI/DependencyInjection.cs:320-329), Scrutor's assembly-scanning API and the open generic IEntityService<TEntityDTO, TIdentifierType>. Then this module's own service contracts: IEventSpeakerUIService, ISessionSpeakerUIService, ISessionCategoryItemUIService, ISpeakerCategoryItemUIService, ISpeakerDashboardUIService, IOrganizerEventFeedbackUIService, IOrganizerSessionFeedbackUIService, ISessionSelectionUIService, ISessionAssetUIService, IPublicSessionScheduleService, ISpeakerLookupService, IEventLookupService, ICategoryItemLookupService, and ISpeakerDetailLookupService.

  • Concept introduced: the extension(IServiceCollection) registration block, half convention and half explicit. [Rubric §3, Clean Architecture] and [Rubric §15, Best Practices & Code Quality] both come down to keeping wiring at the edges; this file is the module's one wiring point. It uses the C# preview extension-type syntax extension(IServiceCollection services) (:21) to hang AddConferenceUI (:27) off IServiceCollection, the same idiom every module's DependencyInjection uses. The convention half is delegated to AddUIModule<ConferenceUIModule>() (:31), which does two things in one call (MMCA.Common.UI/DependencyInjection.cs:323-329): a Scrutor scan of this assembly registering every IEntityService<,> implementation AsImplementedInterfaces().WithScopedLifetime(), and the singleton registration of the descriptor itself. Registering AsImplementedInterfaces is what makes a page able to inject the narrow per-entity interface rather than the open generic, and it means adding a new entity service needs no edit here.

  • Walkthrough: the scan runs first (:31), then the method registers by hand exactly the services the scan cannot see, because they do not implement IEntityService<,>:

    • four child-entity managers for the join relationships (:34-37);
    • the speaker dashboard service (:40);
    • the two organizer feedback moderation services, tagged BR-53 in the comment (:42-44);
    • the session-selection decision-support service (:47);
    • the session-asset service, registered explicitly because the endpoint has no paged list, no lookup, and no by-id read, so the service declares only the five operations the API actually offers, per the comment (:49-52);
    • the offline-first page fetch for the public session schedule, tagged to ADR-042 Wave 3 (:54-55);
    • three cross-module lookup services (:57-60);
    • the composite lookup for the speaker detail page (:64), whose comment states its purpose precisely (:62-63): one call and one failure branch over the three lookups that page needs together;
    • and TryAddSingleton(TimeProvider.System) (:70). The comment above it (:66-69) gives the reason: the pages' current-event selection, the home countdown, and the event lookup cache read the clock through TimeProvider, no UI head registers one (the heads never call AddInfrastructure, where the framework's registration lives), and the Engagement UI's own TryAdd is skipped when that module is disabled. TryAdd means whichever module registers first wins and the second call is a no-op, so the two UI modules can each guarantee the clock without colliding.

    Every per-service registration is AddScoped; the singletons are the descriptor and the TimeProvider, both stateless, so one instance per container is correct. The method returns services for chaining (:76).

  • Why it's built this way: scanning the uniform bulk and spelling out the one-off collaborators keeps registration short without hiding the non-trivial wiring. The closing comment (:72-75) documents what this file deliberately does not register: IPublicLinkBuilder comes from the framework, where AddUIShared TryAdd-registers the browser-origin builder (MMCA.Common.UI/DependencyInjection.cs:194-196), and the MAUI head overrides it afterwards with AddCommonMauiPublicLinkBuilder() (MMCA.Common.UI.Maui/DependencyInjection.cs:140, called at MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:145) so last-registration-wins points shared links at the configured public web URL. Recording an ordering dependency that lives in another repository, right where a reader would otherwise expect the registration, is the cheap version of [Rubric §34, Architecture Governance and Documentation].

  • Where it's used: called once during startup by each of the three UI heads: the Blazor Server host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:171), the WebAssembly client (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:59), and the MAUI host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:133), alongside the other modules' AddXxxUI() extensions.

PublicSessionListFilterState

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterState.cs:12 · Level 0 · class (internal static)

  • What it is: the translation layer between PublicSessionList's live filter fields and the flat string-to-string map that DataGridListPageBase<TDto> persists per page. Two static methods, Save and Restore, and no state of its own (PublicSessionListFilterState.cs:12).
  • Depends on: only System.Globalization (:1) and the RoomIdentifierType / EventIdentifierType aliases. No component base, no injected service, no fetch. The class doc records the placement rule (:5-10): it is kept beside the page, like PublicScheduleRoomOptions, so the code-behind holds only the live filter fields and the handlers that change them.
  • Concept introduced, persisted filter state as a pure, audience-aware codec. The list-page base can only persist strings, so every page that saves filters needs a codec. Writing it as a static pair rather than as two page methods buys three things worth naming.
    1. A sentinel that distinguishes "cleared" from "never set". Save writes "all" when a privileged reader has explicitly cleared the event filter (:42), because an absent key means "no saved state, apply the default" while an empty string would be ambiguous. Restore reads that sentinel back as an explicit null plus a resolved flag (:66-70). [Rubric §19, State Management & Data Flow] (assesses that restored state is distinguishable from absent state).
    2. Persistence itself is audience-scoped. persistEventId is a caller-supplied flag (:33) and the parameter doc states the rule (:22-26): only a privileged reader persists an event choice, because everyone else is always locked to the computed current or next event, so persisting it would only leak into shared URLs. The room filter, by contrast, is persisted for every audience, and the roomId doc says why in one sentence (:17-20): a room only ever narrows within the reader's own event. [Rubric §26, Front-End Security] (assesses that a client-persisted preference cannot widen what a reader sees) and [Rubric §11, Security].
    3. A restore never destroys a value it cannot read. Every branch in Restore is guarded by a TryGetValue plus a TryParse, and each parameter is passed in and returned out unchanged when the map has nothing usable (:57-90), so a corrupt or partial map degrades to "keep what the page already has" instead of resetting the view. [Rubric §24, Forms, Validation & UX Safety].
  • Walkthrough
    • Save (:27-44): writes search verbatim (:35), the My Schedule toggle as an invariant bool string (:36), the room id as an invariant number or empty (:37), and the event id only when persistEventId is true, with the "all" sentinel for an explicit clear (:39-43).
    • Restore (:57-91): parses eventId first, accepting "all" case-insensitively as an explicit clear and any invariant integer as a pick, setting eventFilterResolved in both cases so the page knows not to recompute the default (:64-76); then roomId (:78-82), then mySchedule compared case-insensitively against "true" (:84-87); search falls back to empty (:89). The five values leave as one named tuple (:57, returned at :90) that the caller destructures straight into its fields.
    • The method doc records the one thing Restore deliberately does not do (:46-49): a restored room that the resolved event does not offer is dropped afterwards by the page's room-options rescope, not here, because this type never sees the events.
  • Why it's built this way: the codec is pure and total, so its rules (the sentinel, the audience gate, the keep-on-unparseable behavior) can be exercised without a renderer, and the page keeps a two-line SaveFilters / RestoreFilters pair instead of forty lines of string handling. [Rubric §14, Testability] and [Rubric §1, SOLID].
  • Where it's used: called only from PublicSessionList's sealed base overrides, SaveFilters (PublicSessionList.razor.cs:78-81, passing persistEventId: _isPrivileged && _eventFilterResolved) and RestoreFilters (PublicSessionList.razor.cs:84-87).

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/VenueMapLinks.cs:10 · Level 0 · internal static class

  • What it is: a small, stateless helper that decides how a stored venue map URL should render: as an embedded iframe, or as a plain outbound search link.
  • Depends on: nothing first-party. Externals: System.Uri and System.StringComparer.
  • Concept introduced, an allow-list of embeddable hosts. IsGoogleMapsEmbed (lines 159-177) only returns true for an https URL whose host is in the fixed EmbedHosts array (line 152: www.google.com, google.com, maps.google.com) and whose path is either /maps/embed or /maps with an output=embed query parameter. The in-code comment (lines 150-151) ties this directly to the page's CSP: the frame-src directive (BlazorCsp:FrameSources in the UI.Web host's appsettings.json) has to allow exactly these origins, so the allow-list here and the CSP origin list are two places that encode the same decision. [Rubric #26, Front-End Security] assesses exactly this: an arbitrary stored URL is never framed, only a URL from a host both this check and the CSP agree on.
  • Walkthrough
    • IsGoogleMapsEmbed (lines 159-177) parses the URL, rejects anything not https or not on EmbedHosts, then accepts an /maps/embed path outright or an /maps path whose query string carries output=embed (case-insensitive on both the pair and its value).
    • BuildSearchUrl (lines 184-187) builds a plain (non-embed) Google Maps search URL from a venue address, Uri.EscapeDataString-encoding the trimmed address, or returns null when the address is blank.
  • Why it's built this way: a Google Maps embed URL only renders correctly inside an iframe (Google refuses to open it as a page directly), so the venue detail page needs to know, from the stored URL alone, which rendering the value calls for, without ever framing a URL from a host that was not agreed on in advance.
  • Where it's used: PublicEventDetail's markup calls IsGoogleMapsEmbed to decide whether to show the expand/collapse map toggle at all, and BuildSearchUrl to render the always-available "open in Google Maps" fallback link (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventDetail.razor:73,86).

PublicReadAudience

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicReadAudience.cs:10 · Level 2 · class (internal static)

  • What it is: the one privileged-reader check the public Conference pages share. A single method, IsPrivilegedReaderAsync (PublicReadAudience.cs:18), answers whether the reader behind the cascaded authentication state holds one of ConferenceReadAudience.PrivilegedRoles (class doc, :6-9).
  • Depends on: Microsoft.AspNetCore.Components.Authorization for AuthenticationState (:1) and ConferenceReadAudience from MMCA.ADC.Conference.Shared.Authorization (:2, read at :28). No injected service and no state.
  • Concept introduced, a fail-closed audience read that still honors cancellation. Four pages branch on "is this reader an Organizer or a ContentEditor"; keeping the read in one method fixes the semantics in one place, and the method doc states them (:12-17):
    1. No cascaded state means the public audience. A null task returns false before any await (:20-23).
    2. The state is awaited, never read synchronously (:27), so a token that hydrates asynchronously from the HttpOnly cookie is settled before the role test at :28 runs. The callers' own comments explain why that ordering matters for them (see PublicEventList and PublicEventDetail).
    3. A faulted state degrades to the narrowest audience, a cancellation does not. The catch filter is ex is not OperationCanceledException (:31), so an unreadable auth state returns false (:34) and the public page still renders, while a cancellation propagates to the caller's own teardown handling. The CA1031 suppression carries its reason inline (:30). [Rubric §26, Front-End Security] (fail closed to the narrower audience) and [Rubric §11, Security].
  • Walkthrough: IsPrivilegedReaderAsync(Task<AuthenticationState>?) (:18-36): null guard (:20-23), await the task (:27), then PrivilegedRoles.Any(authState.User.IsInRole) (:28); the filtered catch (:31-35) returns false.
  • Why it's built this way: the audience decision gates what a public page shows (a grid, a breadcrumb, a redirect), so its edge cases (no state, a faulted state, a cancelled state) must answer identically on every page. One internal static helper makes that a property of the code rather than of four copies kept in step by hand, and it is unit-testable without a renderer. [Rubric §14, Testability].
  • Where it's used: PublicEventList (PublicEventList.razor.cs:72), PublicEventDetail (PublicEventDetail.razor.cs:67), PublicSessionList (PublicSessionList.razor.cs:121) and PublicSpeakerList (PublicSpeakerList.razor.cs:97), each assigning _isPrivileged from it. PublicReadAudienceTests pins the eight behaviors, including both cancellation paths propagating (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicReadAudienceTests.cs:79, :89) and a faulted state reading as non-privileged (:69).

PublicEventDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventDetail.razor.cs:18 · Level 8 · class (Blazor code-behind)

  • What it is: the read-only public view of one event: venue information, rooms, support contacts, and the conference-day conveniences (copy the Wi-Fi details, open directions, a distance-to-venue hint, a collapsible venue map, a QR code for the page itself). For a public visitor it is also the landing page of the whole conference, because PublicEventList redirects them here.
  • Depends on: IEventUIService (:20), EventDTO, IToastService (:22), PublicReadAudience (:67, which reads ConferenceReadAudience on the page's behalf), ConferenceRoutePaths (:77, :172, :174, :176), DomainHelper's Id.Parse<T> extension for the route string (:119, defined at MMCA.Common/Source/Core/MMCA.Common.Shared/Extensions/DomainHelper.cs:30), VenueMapLinks (called from the markup, not the code-behind, at PublicEventDetail.razor:73,86) to decide the venue-map rendering, and four device-capability abstractions: IClipboardService, IMapNavigationService, IGeolocationService, IGeocodingService (:23-26), whose GeoPoint results supply the DistanceKmTo used at :235. It also reads IConfiguration for the host-wide support contacts (:27, :64-65), and the LifetimeToken() extension on CancellationTokenSource from MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26.
  • Concept introduced, the load guard, the audience-shaped breadcrumb trail, and best-effort progressive enhancement. Three mechanisms are worth extracting.
    1. Load once per id, and let the newest load win. The route value arrives as [Parameter] string Id (:29), and OnParametersSetAsync compares it against _loadedId (:96-105) so a re-render does not refetch. On top of that, each LoadEventAsync call opens a fresh generation and cancellation token through _load.Begin() (:110), and both the fetch (:120) and the distance hint (:148) run under that token; every await point is re-checked against _load.IsCurrent(generation) (:121, :223, :229, :163), so a superseded fetch both drops its results and is actually cancelled, not merely ignored. The field doc explains why the generation and not the route id is authoritative (:85-90): _loadedId is stamped synchronously before the await, so two rapid route changes would otherwise let the later-completing fetch paint the wrong event. The finally is guarded by the same test (:159-167), because an unconditional clear would let a superseded response switch off the spinner the newer load just turned on. The load also resets _distanceMiles to null on entry (:112-114): the hint belongs to the previous event, and a venue-less or unsupported event never recomputes it, so without the reset the previous event's distance would stay on screen. [Rubric #19, State Management & Data Flow].
    2. The breadcrumb trail depends on the audience, and the audience is awaited first. OnInitializedAsync resolves privileged status through PublicReadAudience.IsPrivilegedReaderAsync (:67) before building the trail (:62-81), then adds the "Events" crumb only for a privileged reader (:75-78). The doc comment states both halves of the reasoning (:54-61): a public visitor was redirected to this page by the event list, so an Events crumb would bounce them straight back here, and the access token hydrates asynchronously from the HttpOnly cookie, so reading roles synchronously would render the wrong trail and correct it on the next render. A faulted auth read is treated as non-privileged by the shared helper, which lets a cancellation propagate instead (PublicReadAudience.cs:31). [Rubric #25, Navigation & Information Architecture] (assesses that navigation affordances lead somewhere the reader can actually use) and [Rubric #26, Front-End Security] (fail closed to the narrower audience).
    3. Every capability is optional. TryComputeDistanceAsync (:215-237) returns early when geolocation or geocoding is unsupported or the venue address is blank (:217-220), and again on any null result or superseded generation (:223, :229), so a denied permission or an offline geocoder simply leaves the hint off. The doc comment states the rule plainly: this must never block the page (:210-214). [Rubric #29, Resilience & Business Continuity] (assesses degradation when an optional dependency is absent) and [Rubric #26, Front-End Security] (a location read is soft and unblocking, never a gate on content). These come from the device-capability layer of ADR-042. A fourth detail is a small but real configuration rule: a per-event OrganizerContactEmail wins over the host-wide Support:Email, and it is re-evaluated on every load so navigating between events never leaves the previous organizer's address on screen (:141-146). [Rubric #15, Best Practices & Code Quality]: a conference can publish its own contact without a redeploy. A fifth is how the one-shot device calls get their token: LifetimeToken (:35-38) reads the source through LifetimeToken() instead of _cts.Token, because reading Token off a disposed source throws ObjectDisposedException; disposal cancels before it disposes, so a handler that resumes after the reader navigated away receives an already-cancelled token and stops through its OperationCanceledException path.
  • Walkthrough
    • LoadEventAsync (:107-168): begin a fresh generation and token from _load.Begin() (:110), reset _distanceMiles (:112-114), parse the id (:119), fetch with children under that token (GetByIdAsync(eventId, true, token), :120), and on failure clear Event so a failed navigation never leaves the previous event on screen, toasting the not-found wording for a 404 and the page's fixed load-failure key otherwise (:126-135); on success reset _showMap to false (:139, so navigating to a different event never leaves the previous one's map expanded), resolve the support address (:141-146), and run the distance hint, passing both the generation and the same token (:148). OperationCanceledException is swallowed as expected teardown (:151-154) and a broad catch toasts the same load-failure key (:155-158).
    • _showMap (:51-52) and ToggleMap (:178) back a collapse/expand toggle for the venue map: the markup only offers it when VenueMapLinks.IsGoogleMapsEmbed recognizes the stored VenueMapUrl (PublicEventDetail.razor:73), and only actually renders the iframe when _showMap is true (PublicEventDetail.razor:90-99), so third-party map content does not load until the reader asks for it. VenueMapLinks.BuildSearchUrl always renders the plain "open in Google Maps" link beside the toggle as a fallback (PublicEventDetail.razor:86-89).
    • CopyWifiAsync (:182-193): copies Event.WiFiInfo through the clipboard abstraction under LifetimeToken (:189) and reports success or failure with one toast whose severity flips on the result (:190-192).
    • OpenDirectionsAsync (:195-208): native heads launch the platform maps app, browsers open a maps site, again under LifetimeToken (:203); a false return raises a warning.
    • TryComputeDistanceAsync (:215-237): takes the load generation and a CancellationToken from the caller rather than reading _cts itself, geocodes the venue under that token (:222), reads the current-or-last-known position the same way (:228), converts kilometres to miles with an explicit named constant (:234-235), and calls StateHasChanged() (:236) because the value arrives after the render that requested it; the markup renders it to one decimal in the viewer's culture (PublicEventDetail.razor:58-61).
    • Navigation helpers (:170-180) route back to the list (privileged readers only, per the comment at :170-171), on to the public schedule, on to the activities page, and to the event feedback form. Disposal (:239-262) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :33, plus disposing the LatestLoadGuard itself (:252).
  • Why it's built this way: the public event page is the one an attendee opens while standing in the building, so its extras (Wi-Fi, directions, distance, the venue map) are worth having, none of them is worth failing the page over, and the map specifically stays collapsed by default so the table stays compact and no third-party (Google) content loads until the reader chooses to see it (PublicEventDetail.razor:75-79 in-code comment).
  • Where it's used: the /conference/events/{Id} route (PublicEventDetail.razor:1), reached from PublicEventList either as a grid row (privileged) or as a replace: true redirect (everyone else); its markup also renders the QrCodeButton for this page's own public link (PublicEventDetail.razor:30).

PublicEventList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventList.razor.cs:30 · Level 9 · class (Blazor code-behind)

  • What it is: the /conference/events route, where the audience decides whether a list is shown at all. A privileged reader (Organizer or ContentEditor) gets the full grid of published and unpublished events; every other visitor is redirected to the current or next event's detail page (class doc, PublicEventList.razor.cs:13-29).
  • Depends on: extends DataGridListPageBase<TDto> closed over EventDTO (:30); IEventUIService and IEventLookupService (:34-35), an injected TimeProvider (:37), PublicReadAudience (:72), CurrentEventSelector (:91), EventInfo (:81), ConferenceRoutePaths (:103, :147), MobileInfiniteScrollList<TItem> (:46, rendered at PublicEventList.razor:23), ListPageActions (:121), and Result in the mobile fetch signature (:137). Server-side the audience split is enforced by PublishedEventSpecification.
  • Concept introduced, the audience gate as a routing decision, and the three-state render. This page is the sharpest example in the group of a UI decision that has to wait for identity.
    1. Resolve the audience before deciding anything. OnInitializedAsync awaits PublicReadAudience.IsPrivilegedReaderAsync over the cascading Task<AuthenticationState> first (:72). The comment (:69-71) and the class doc (:21-25) both name the failure this prevents: on all three heads (Blazor Server, WebAssembly, MAUI) the access token hydrates asynchronously from the HttpOnly cookie, so a synchronous role read would see an anonymous principal and bounce an organizer off their own list. A faulted read is treated as non-privileged by the shared helper (PublicReadAudience.cs:31). [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that an authorization-shaped branch reads a settled principal, and fails to the narrower audience).
    2. Three states, and one of them renders nothing. _showGrid (:53) opens the search box and the layout switch for a privileged reader (:74-78); _showEmpty (:61) is set only when nothing is published anywhere, so there is no redirect target and the page has to stay and say so (:107-109); and the redirect path deliberately leaves both false (:98-105), so the page stays blank until the navigation takes effect instead of flashing a list on the way out. The field doc spells this out (:55-60). [Rubric §18, UI Architecture & Component Design] (assesses that intermediate states are designed rather than incidental).
    3. replace: true on the redirect. The comment (:100-102) records the exact trap it prevents: left in the history stack, Back from the event detail would land here and redirect straight forward again, trapping the visitor on the detail page (NavigateTo at :103). [Rubric §25, Navigation & Information Architecture] (assesses that the Back button keeps working). The redirect target is computed with CurrentEventSelector.SelectCurrentOrNext, passing the four accessors explicitly because the lookup returns EventInfo rather than EventDTO, which the comment calls out (:87-96). "Now" comes from the injected TimeProvider (TimeProvider.GetUtcNow().UtcDateTime, :96) rather than the static system clock, so the live-window decision reads a replaceable clock. It is the same live-window math every other landing surface uses, so a visitor always lands on the conference that is actually happening. A failed lookup is non-critical and leaves events null (:80-85), which falls through to the empty state rather than to a broken redirect.
  • Walkthrough
    • GridRef (:42) exposes the captured grid so the base can restore rows-per-page and current page; RetryLoadAsync (:45) re-runs the fetch from the inline error state the base renders when LoadFailed is set (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:53). [Rubric §29, Resilience & Business Continuity].
    • SaveFilters / RestoreFilters (:112-116) persist the one search term; OnSearchChanged (:118-122) stores it and reloads whichever layout is active through ListPageActions.ReloadActiveLayoutAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ListPageActions.cs:26).
    • LoadServerData (:124-134) passes showCancelSnackbar: false (:134), so a superseded fetch (the reader typed another character) is silent rather than raising a toast, and turns the search string into a Name contains server filter (:131-132).
    • FetchMobilePage (:137-144) is the parallel infinite-scroll path, hard-sorted by Name ascending; OnMobileCardClick (:146-147) routes to PublicEventDetail.
  • Why it's built this way: a public visitor cares about the conference that is running or coming up, not about a roster of past editions, while an organizer curating the catalog needs every row including the unpublished ones. One route serving both is cheaper than two, provided the audience is known before the branch is taken.
  • Where it's used: the /conference/events route (PublicEventList.razor:1). Every non-privileged arrival leaves immediately for PublicEventDetail; privileged rows and cards navigate to the same page.
  • Caveats / not-in-source: the published-only scoping of the underlying reads for non-privileged callers (BR-108, class doc :26-27) is enforced in the Conference API through PublishedEventSpecification, not on this page.

ConferenceTrackInfo

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHomeContent.cs:95 · Level 0 · record (sealed, internal)

  • What it is: one row of the landing page's track catalogue: a track Name, an Icon (a MudBlazor icon path constant), and a Topics string listing the track's subject areas (:81).
  • Depends on: no first-party types. The Icon values are MudBlazor Icons.Material.Filled.* constants (external, imported at :1).
  • Concept introduced: this is one of three static-content records held by ADCHomeContent; the two-tier content model is taught under KeynoteSpeakerInfo and reused by PreConferenceWorkshopInfo.
  • Walkthrough: a three-property positional record. The whole catalogue is the ADCHomeContent.Tracks property, an IReadOnlyList<ConferenceTrackInfo> initialized inline as a collection expression (:34-52) with eight entries (:36, :38, :40, :42, :44, :46, :48, :50), each spanning two lines: the track name and its icon constant on the first, the topics blurb on the second. They run from "Foundations (Beginner & Student)" (:36-37) to "Career, Leadership & Community" (:50-51), with the two AI tracks, languages, web/mobile/cross-platform, security, and game/XR in between. Every Icon is a MudBlazor Icons.Material.Filled.* constant picked per track (School, Psychology, AutoAwesome, Code, Devices, Security, SportsEsports, Groups). Storing the icon as a string (rather than a RenderFragment or an enum) is what keeps the record a plain data type: the markup passes it straight to <MudIcon Icon="@track.Icon"> (ADCHome.razor:241).
  • Why it's built this way: the track list changes once per conference cycle and is editorial rather than transactional, so it lives in the assembly instead of behind an API call or a CMS; the remark on ADCHomeContent names the track catalog alongside the keynote bio as deliberately English-only editorial content, with the localized chrome around it (:10-14). The collection is a get-only static property, so it is initialized once per process, not per render.
  • Where it's used: ADCHomeContent.Tracks (:34), rendered as the track grid in ADCHome.razor (:233-250): one MudItem/MudCard per entry, keyed by track.Name (ADCHome.razor:236), showing the icon (:241), the name (:243), and the topics line (:245).

EventPhase

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:99 · Level 0 · enum (private)

  • What it is: the three-state classification of the featured event relative to now: Upcoming, Live, Ended (:71-76). It is the single switch the landing page's hero renders from.
  • Depends on: nothing.
  • Concept introduced: deriving a render state from a clock instead of storing it. [Rubric §19, State Management and Data Flow] assesses whether UI state is derived from a single source of truth or duplicated into flags. There is no IsLive boolean anywhere on the page: UpdateCountdown() recomputes _phase from DateTime.UtcNow against the converted UTC window every time it runs (:261-267), and the markup branches on that one field. Recomputing rather than storing means a stale phase is impossible after a timer callback, a parameter change, or the interactive render pass that follows prerender.
  • Walkthrough: the assignment is a switch expression over now (:262-267): now < _startUtc gives Upcoming, now < _endUtc gives Live, anything later gives Ended. ArmPhaseTimerForEventEnd() reads it as its guard, returning immediately unless the phase is Live (:144-147), which is what makes the Live-to-Ended timer a single one-shot rather than a recurring tick. In the markup the loading branch comes first (ADCHome.razor:32-35, a PageLoadingState while _isLoading), then Upcoming renders the HomeCountdown child (ADCHome.razor:36-41), Live renders the "event live" chip plus a button to /happening-now (ADCHome.razor:42-56), and Ended renders the post-event chip (ADCHome.razor:57-65). The hero's ticketing button sits outside that branch (ADCHome.razor:67-80), so it shows in every phase the event publishes a URL.
  • Why it's built this way: three named states read far better at the call site than nested date comparisons, and keeping the enum private to the component signals it is a view concern, not a domain concept. The domain's own notion of a live window lives server-side and in CurrentEventSelector.
  • Where it's used: the _phase field on ADCHome (:63) and its Razor markup only.

KeynoteSpeakerInfo

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHomeContent.cs:86 · Level 0 · record (sealed, internal)

  • What it is: the keynote block's content: the speaker's Name, Title (their role), an optional Badge (a recognition chip rendered above the title, e.g. "Microsoft MVP"), the TalkTitle, an optional PhotoFileName, and BioParagraphs as an IReadOnlyList<string> (:86-92).
  • Depends on: no first-party types. BCL only.
  • Concept introduced: the two-tier content model of a landing page. The page splits its content into dynamic data fetched from the API (dates, venue, name, ticketing link, sponsors, as EventDTO and SponsorDTO through IEventUIService and ISponsorUIService, ADCHome.razor.cs:52-55 and :80-83) and editorial data compiled into the assembly (keynote, tracks, and workshops, held by ADCHomeContent as this record and its siblings ConferenceTrackInfo and PreConferenceWorkshopInfo). [Rubric §23, Front-End Performance and Rendering] is the payoff: the keynote, the workshop cards, and the track grid render on the first frame with zero network dependency, so a cold or unreachable backend degrades only the countdown, the hero ticketing button, and the sponsor strip, never the page. [Rubric §27, Internationalization] is the deliberate exception: the holder class carries a written remark recording that this English-only editorial content is the same copy the API would serve, while the chrome around it is localized instead (:10-14). That convention is how ADR-027 distinguishes "not yet translated" from "intentionally untranslated".
  • Walkthrough: a six-property positional record. The single instance is the get-only ADCHomeContent.Keynote property, initialized inline (:18-31): Jared Rhodes, Title: "Principal Consultant, EPAM Systems", Badge: "Microsoft MVP", the talk "More Software, Different Work", PhotoFileName: "jared-rhodes.webp" (:26), and a three-paragraph abstract (:28-30). Splitting the recognition into its own Badge property rather than folding it into Title (as an earlier draft did, joining "Microsoft MVP" and the role into one string) lets the markup render the badge as a separate chip and omit it entirely for a keynote with none, since Badge is nullable. BioParagraphs is a list rather than one string so the template can emit each paragraph in its own element instead of relying on whitespace preservation (ADCHome.razor:203-206). The record carries only the portrait's file name, not a path: the usable src is composed from the head-specific ImageBasePath parameter through KeynoteImageSrc, which returns $"{ImageBasePath}/speakers/{fileName}" or null when no file name is supplied (ADCHome.razor.cs:56-57). That split exists because a head could package its assets elsewhere, and the null case is still guarded in the markup so the card renders name and title without a portrait (ADCHome.razor:179-192).
  • Why it's built this way: the keynote changes once per conference cycle, so a database round-trip and an admin screen would be pure overhead. The image choice is the interesting half. The comment above the file name (:22-25) records that the shipped asset is a WebP derivative sized to the display resolution, 640px square against a 300px CSS frame so it still has headroom at 2x device pixel ratio, weighing 17 KB against 306 KB for the 2048px source JPEG, and that because both hosts resolve it through ImageBasePath the file must ship in both wwwroots (the Web client and the MAUI head). The original JPEG stays in the repo as the re-encode source. The markup adds loading="lazy" with the reason inline (ADCHome.razor:182-184): the keynote sits below the hero, so it is never the largest-contentful-paint element, and MudImage splats unmatched attributes onto the rendered img, so the hint actually reaches the DOM. Both are [Rubric §23, Front-End Performance and Rendering] in the small.
  • Where it's used: ADCHomeContent.Keynote (:18), read by ADCHome.KeynoteImageSrc (ADCHome.razor.cs:56-57) and rendered in the keynote section of ADCHome.razor (:163-211): the portrait (:185-190), the alt text and name (:186, :193), the badge chip and the role (:195), the talk title (:202), and the paragraphs (:203-206).

PreConferenceWorkshopInfo

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHomeContent.cs:103 · Level 0 · record (sealed, internal)

  • What it is: one pre-conference workshop card: a resource-key stem Key, the workshop Title, a Subtitle, the Presenter name, an Icon, and a DetailsUrl for the workshop's public detail page (:103-109). Two instances make up the whole workshops section.
  • Depends on: no first-party types. The Icon values are MudBlazor Icons.Material.Filled.* constants (external).
  • Concept introduced: the half-localized content record. [Rubric §27, Internationalization] assesses whether user-facing text resolves through resources rather than sitting in code, and this record is the interesting middle case that the other two content records do not show. Instead of choosing "all in code" or "all in resources", it splits by kind of string: proper nouns and public-site addresses stay in code (workshop titles, subtitles, presenter names, and the DetailsUrl) because translating a talk title, a person's name, or a URL would be wrong, while the prose that describes each workshop lives in the .resx pair. The bridge is Key, documented on the record itself: the markup composes Workshops.{Key}.Audience and Workshops.{Key}.Description at render time (ADCHome.razor:136, :139), and those keys exist in both locales (MMCA.ADC.Conference.UI/Pages/Home/ADCHome.resx:26-29, with the Spanish twins in ADCHome.es.resx). One consequence worth noticing: adding a workshop means adding four resource entries per locale, and a typo in Key fails at runtime as a missing resource rather than at compile time.
  • Walkthrough: a six-property positional record. The catalogue is the get-only ADCHomeContent.Workshops property (ADCHomeContent.cs:64-78) with two entries: "AiReadyDotNet", "Build an AI-Ready .NET App with an AI Pair", subtitled "One Day, the Whole SDLC with an AI Pair", presented by Ivan Ball-llovera with the SmartToy icon, linking to https://www.atldevcon.com/workshops/ivan-ball-llovera; and "SoftwareFactory", "Beyond the Basics: Starting Your Software Factory", subtitled "Automated Multi-Agent Development Pipelines", presented by Tim Rayburn with the PrecisionManufacturing icon, linking to https://www.atldevcon.com/workshops/tim-rayburn. The doc comment above the property states the split in one sentence. The property and the record's file are wrapped in an S1075 suppression pair, with the reason inline: the detail addresses are published pages on the public conference site, not environment-dependent paths, so there is nothing to configure. The markup renders the list as a two-column grid (ADCHome.razor:120-145), keyed by workshop.Key (:123), each card showing the icon in a circle (:128), the title (:130), the localized Workshops.PresenterLabel formatted with the presenter name (:132-134), the audience line (:135-137), and the description (:138-140).
  • Why it's built this way: the workshop day runs before the conference day, and the section is placed between the hero and the keynote so the page reads in the same order as the event, which the markup comment records (ADCHome.razor:86-88). Two facts the cards would otherwise repeat, the schedule and the venue, are hoisted into one shared logistics line above the grid (ADCHome.razor:107-118), which is why they are resources (Workshops.Schedule, Workshops.Venue, ADCHome.resx:21-22) rather than record fields. Ticketing is the other deliberate asymmetry: the workshop day sells on its own TicketLeap page, so its call to action reads the PreConferenceTicketingUrl constant (ADCHome.razor.cs:29-30) and always renders (ADCHome.razor:147-159), while the hero's conference-day button reads TicketingUrl off the featured event and hides itself when absent. DetailsUrl is a newer addition than the ticketing constant: it gives each card a link to the presenter's own detail page on the public conference site rather than only naming the presenter inline. [Rubric §26, Front-End Security]: the workshop ticketing button opens with Target="_blank" together with rel="noopener noreferrer" (ADCHome.razor:155).
  • Where it's used: ADCHomeContent.Workshops (:64) and the workshops section of ADCHome.razor (:85-161) only.

ADCHomeContent

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHomeContent.cs:15 · Level 1 · class (static, internal)

  • What it is: the landing page's editorial content, lifted out of the code-behind into its own file: the keynote card, the conference track catalog, and the pre-conference workshop catalog, as three get-only static properties.
  • Depends on: KeynoteSpeakerInfo, ConferenceTrackInfo, and PreConferenceWorkshopInfo (all three declared in this same file, :86, :95, :103), plus MudBlazor's Icons.Material.Filled.* constants (:1).
  • Concept introduced: separating content from orchestration inside one page. [Rubric §18, UI Architecture and Component Design] assesses whether a component has one job and whether its structure makes that job legible. The class summary states the intent verbatim (:5-9): the content is kept beside the page, like the other page-local data holders, so the code-behind stays orchestration only. That is the practical difference between a 313-line code-behind that reads as a lifecycle (fetch, classify, arm a timer, dispose) and one where three paragraphs of conference copy sit between OnInitializedAsync and Dispose. [Rubric §15, Best Practices & Code Quality] is the follow-on: editing the track list for next year's conference touches exactly one file that contains no logic, so the review is a content review. The other reason this is a distinct type rather than a region: the internal accessibility. The three records and the holder are all internal sealed / internal static, so they are visible to the page and to nothing outside the assembly, but unlike the private inner records of ADCHome they can live in their own file. [Rubric §27, Internationalization]: the class remark (:10-14) is where the deliberate English-only decision for this content is recorded, per ADR-027.
  • Walkthrough: three static get-only properties, each initialized once at type initialization.
    • Keynote (:18-31): a single KeynoteSpeakerInfo, including the WebP-derivative comment that explains the shipped portrait (:22-25).
    • Tracks (:34-52): an IReadOnlyList<ConferenceTrackInfo> collection expression with the eight conference tracks.
    • Workshops (:64-78): an IReadOnlyList<PreConferenceWorkshopInfo> with the two pre-conference workshops, above the doc comment that spells out which fields are code and which resolve from resources, and wrapped in the S1075 suppression pair for the two DetailsUrl addresses (:63, :79).
  • Why it's built this way: exposing the three as { get; } properties rather than public fields means the collections are initialized exactly once per process and shared by every circuit on the server head, which matters because the server head renders this page for every connected user. Returning IReadOnlyList<T> rather than an array keeps the markup's @foreach honest about the collection being read-only.
  • Where it's used: ADCHome only: KeynoteImageSrc reads ADCHomeContent.Keynote.PhotoFileName (ADCHome.razor.cs:56-57), and the markup reads all three (ADCHome.razor:121 for the workshop grid, :186, :193, :195, :202, :203 for the keynote card, :234 for the track grid).

PublicScheduleRoomOptions

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicScheduleRoomOptions.cs:13 · Level 3 · class (internal static)

  • What it is: the pure functions behind the public schedule's Room picker and Room column. Given the events PublicSessionList has already loaded, IndexNames builds the id-to-name map the list renders with, and Scope returns the ordered room options for the active event filter plus the room filter that survives that scoping (PublicScheduleRoomOptions.cs:20, :37).
  • Depends on: RoomDTO and EventDTO from MMCA.ADC.Conference.Shared.Events (:1), plus the RoomIdentifierType and EventIdentifierType aliases. Nothing else: no injected service, no fetch, no component base.
  • Concept introduced, the derived filter option set with a self-healing selection. Two ideas are worth extracting from a sixty-line file.
    1. Derive, do not fetch. The class doc states the rule (:5-9): the page already reads /events?includeChildren=true, so its rooms are in memory and narrowing the schedule by room costs zero extra round trips. The same doc records the security consequence that comes for free: that events read is published-only for non-privileged audiences server-side, so an unpublished event's rooms can never reach the picker. [Rubric §23, Front-End Performance & Rendering] (assesses avoidable network work per interaction) and [Rubric §26, Front-End Security] (assesses that a client-derived option set cannot widen what the server already scoped).
    2. A selection that cannot go stale. The last block of Scope (:53-55) re-validates selectedRoomId against the freshly scoped list and returns null when the room is no longer offered. The comment names both ways that happens (:51-52): the reader switched events, or a stale choice was restored from saved page state by PublicSessionListFilterState. Without it, an unreachable room id would filter every session out and the reader would see an empty schedule with no visible cause. [Rubric §19, State Management & Data Flow] (assesses that derived state is reconciled rather than left to drift) and [Rubric §24, Forms, Validation & UX Safety].
  • Walkthrough
    • IndexNames (:18-27): a nested loop over every loaded event's Rooms, adding each Id-to-Name pair with TryAdd (:24) so a room that two loaded events both reference is indexed once rather than throwing. The map it fills is the page's own _roomNames dictionary, added to in place (:17), which is what the list's Room column and mobile card line read.
    • Scope (:37-58): scoping (:42-44), where a non-null eventId takes that event's Rooms (an unknown id yields an empty list through the ?? [] fallback) and a null id, which only a privileged reader viewing every event can produce, takes the union across all loaded events; shaping (:46-49), DistinctBy(r => r.Id) because the union can repeat a room, then OrderBy(Sort).ThenBy(Name, StringComparer.OrdinalIgnoreCase) so the picker order is the organizer's intended order with a deterministic case-insensitive tiebreak; reconciliation (:53-55) and the tuple return (:57), which the caller destructures straight into its two fields.
  • Why it's built this way: keeping this out of the page makes it a plain static function over data, directly unit-testable without a renderer, and it keeps the page's own code down to one line (PublicSessionList.razor.cs:168-169). [Rubric §14, Testability] and [Rubric §1, SOLID].
  • Where it's used: IndexNames is called once per events load (PublicSessionList.razor.cs:136); Scope is called by that page's RefreshRoomOptions (:151-152), from the initial load (:147) and from every event-filter change (:216). The options it returns are passed down to PublicSessionListFilterBar's Rooms parameter and the surviving id to its SelectedRoomId.

ADCHome

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:22 · Level 9 · class (sealed partial component)

  • What it is: the conference landing page: hero with a live countdown and a ticketing button, pre-conference workshops, keynote, track catalogue, sponsor strip with a sponsorship call to action, a partners band, and venue block. It loads the published events through the UI service layer to find which event to feature, classifies that event as Upcoming/Live/Ended, loads that event's sponsors and partners, and renders the rest from the compiled-in editorial content in ADCHomeContent. It is shared verbatim by the Web and MAUI heads, and its class doc records that both heads serve the static images from their own site root, so neither overrides ImageBasePath today (:9-16).

  • Depends on: IEventUIService (:51-52), ISponsorUIService (:54-55), and IPartnerUIService (:57-58) for the three reads, an injected TimeProvider (:60-61) as the page's only clock, EventDTO for the featured event (:89), SponsorDTO and PartnerDTO for the grouped strips (:91-92, :94-95), EventPhase (a private inner enum, :99-104), ADCHomeContent with its KeynoteSpeakerInfo, ConferenceTrackInfo, and PreConferenceWorkshopInfo records, CurrentEventSelector from MMCA.ADC.Conference.Shared.Events, SponsorTier and PartnerType, and ConferenceRoutePaths for the "see all sponsors" link (ADCHome.razor:308). Externals: IStringLocalizer<ADCHome> injected in the markup as L (ADCHome.razor:1), System.Threading.Timer, TimeZoneInfo, MudBlazor, the Blazor RendererInfo API, and the LifetimeToken() extension on CancellationTokenSource (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26). It composes one first-party child component, HomeCountdown (ADCHome.razor:40), which lives in the same folder as a single .razor file with no code-behind.

  • Concept introduced: rendering correctly across the prerender and interactive passes. [Rubric §23, Front-End Performance and Rendering] assesses whether a page avoids wasted renders and blocking work; this component is the chapter's clearest case study, and both of its decisions are recorded in the code comments. See ADR-056 for the render-mode strategy these two decisions live inside.

    • Skip the fetch during prerender. OnInitializedAsync checks RendererInfo.IsInteractive and, when false, sets _isLoading = false, computes the countdown from defaults, and returns without touching the network (:143-148). The comment (:138-142) states why: an untimed server-side call to a cold or unreachable backend would block the prerender, and therefore the page load and the post-login NavigateTo("/"), indefinitely. The static fallback renders immediately and the interactive pass loads the real event. Prerender is a one-shot static render, so the timer is moot there. [Rubric §29, Resilience and Business Continuity] is the same point from the availability angle.
    • Fence the per-second re-render. The ticking digits live in the HomeCountdown child, which owns its own timer, so this page arms only a single one-shot Timer for the Live-to-Ended flip (:167-186). The comment records the alternative: a 1-second timer would re-render the entire landing page, the largest static page in the app, for the whole event, per circuit, just to catch one transition. The child goes further still: it ticks once a minute while more than 65 minutes remain and switches to once a second only for the final hour (MMCA.ADC.Conference.UI/Pages/Home/HomeCountdown.razor:32, :52-56, :59, :70-74), and it too refuses to start a timer unless RendererInfo.IsInteractive (HomeCountdown.razor:51-52).

    Four more rubric threads run through it. [Rubric §22, Responsive and Cross-Browser/Device]: one component compiles into the Blazor Server, WebAssembly, and MAUI heads, with the per-head difference reduced to the ImageBasePath parameter (:69). [Rubric §27, Internationalization]: user-facing chrome resolves through L[...], while the strings that must not be translated carry explicit // i18n: allow markers with reasons (the ticketing URL, the brand name, the postal address). [Rubric §20, Design System and Theming]: the page's scoped stylesheet is a single shared copy rendered by both heads, and an architecture fitness test embeds it and fails the build if it re-hardcodes the brand hex instead of using var(--mmca-primary) (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Ui/BrandColorTokenTests.cs:12-17, embedded via MMCA.ADC.Architecture.Tests.csproj:11-13). [Rubric §14, Testability]: every read of "now" (featured-event selection :215, the end-of-event timer :178, the phase switch :344) goes through the injected TimeProvider rather than DateTime.UtcNow, so the event choice and the phase share one clock that a test host can replace.

  • Walkthrough, in lifecycle order:

    • Constants and state (:24-97): the PreConferenceTicketingUrl constant placed first because SA1203 requires constants before fields, with its doc block and S1075 suppression (:24-36), then MaxRoster (:38-42), a fixed upper bound of 200 on one event's sponsor or partner roster passed explicitly to the paged reads because the underlying endpoints default to 10 rows per page and a conference announces dozens, not thousands. EventStartTime (:44) and FallbackStartDate (:46-49) follow. Then the three injected UI services (:51-58), the injected TimeProvider (:60-61), the ImageBasePath parameter (:63-69) and the derived KeynoteImageSrc (:75-76), a CancellationTokenSource (:78) with a LifetimeToken accessor over it (:80-83), the one-shot _phaseTimer, the computed _startUtc/_endUtc, _phase, the nullable _event (EventDTO?, :89), the grouped _sponsorTiers over SponsorDTO (:91-92) and _partnerGroups over PartnerDTO (:94-95), _isLoading (starting true), and a _disposed guard the timer callback checks (:97). LifetimeToken exists because reading Token off a disposed source throws ObjectDisposedException; Dispose cancels before it disposes, so a load that resumes after the reader navigated away gets an already-cancelled token and stops through its OperationCanceledException path (comment :80-82).
    • Derived display properties (:106-113): EventName, EventDescription, VenueAddress, and MapSearchUrl are each _event?.X ?? <fallback>, so the page is fully renderable before and without a successful fetch. MapSearchUrl builds a Google Maps search URL with Uri.EscapeDataString over the address.
    • HeroTitleParts() (:115-132): splits the event name so the hero can accent the keyword between "Atlanta " and " Conference" (in "2026 Atlanta Developers Conference" it accents "Developers"). It uses IndexOf/LastIndexOf with StringComparison.Ordinal and falls back to rendering the whole name plain when the name does not match the brand shape, which is why an arbitrary event name never renders broken markup; the markup branches on the accent being non-empty (ADCHome.razor:19-26).
    • OnInitializedAsync (:134-157): creates the CTS, takes the prerender short-circuit described above, otherwise awaits LoadEventAsync(), LoadSponsorsAsync(), then LoadPartnersAsync() in sequence (both the sponsor and partner calls need the featured event id) and arms the phase timer.
    • LoadEventAsync (:199-227): calls EventService.GetAllAsync(cancellationToken: LifetimeToken) (:204) and, on a successful Result, picks the event with CurrentEventSelector.SelectCurrentOrNext(...) passing three accessor lambdas plus TimeProvider.GetUtcNow().UtcDateTime (:210-215). The candidates are events.Where(e => e.IsPublished) (:211): the endpoint scopes only non-privileged callers to published events, while an Organizer or ContentEditor reads it unscoped, so a draft edition has to be filtered out here or the landing page would feature it (comment :206-209, tagged OBS-7). The comment is also the reason it is not a FirstOrDefault: the endpoint returns events unordered, so a naive first-item pick would pin the oldest seeded event. A failed read (API unavailable) surfaces as an unsuccessful Result handled by the if, which simply leaves the fallback defaults in place; the only exception caught is OperationCanceledException, meaning the component was disposed mid-load (:218-221). The finally block always clears _isLoading and recomputes the countdown (:222-226), so no failure path leaves a spinner on screen.
    • LoadSponsorsAsync (:229-275): returns immediately when no event was featured (:241-244), then calls SponsorService.GetPagedAsync filtered to the featured event, pageNumber: 1, pageSize: MaxRoster, sorted by Sort ascending, under LifetimeToken (:248-254), and on a successful page reduces sponsors.Items to a tier-grouped list: tiers ascending in package order (Platinum first), each tier ordered by Sort then Name (:256-269, comment at :258-259). The event scoping happens server-side; the method's remarks record that the endpoint filters to the featured event the way PublicSponsorList does, so a second published edition's sponsors never bleed onto this page (:232-238). Its one catch arm is OperationCanceledException (:271-274) and, on any unsuccessful page, _sponsorTiers simply stays at its previous value, which is a supported render state rather than an error state.
    • LoadPartnersAsync (:277-320): the same shape as LoadSponsorsAsync one method up: returns immediately with no featured event (:287-290), otherwise calls PartnerService.GetPagedAsync the same way, again under LifetimeToken (:294-300), and reduces a successful page's partners.Items to the type-grouped list, Community first (:302-314). Its one catch arm mirrors the sponsor load (:316-319) and an unsuccessful page leaves _partnerGroups empty, which hides the whole partners band rather than rendering a partial one.
    • EventFilter (:322-326): a small static helper that builds the one-entry {"EventId": ("equals", eventId)} filter dictionary both GetPagedAsync calls pass, formatting the id with CultureInfo.InvariantCulture so the filter value is stable regardless of the host's culture.
    • UpdateCountdown (:328-351): converts the event's local start and end into UTC using TimeZoneInfo.FindSystemTimeZoneById(timeZoneId) with "America/New_York" as the default (:334, :340), calling CurrentEventSelector.ToUtc rather than ConvertTimeToUtc because the midnight end boundary does not exist in zones that transition at 00:00 and a raw conversion would throw out of the render path (comment :336-339; MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:89-94). now is read from TimeProvider (:344) and _phase is then assigned from the switch described under EventPhase (:345-350).
    • Phase timing: OnCountdownElapsedAsync (:160-165) is the EventCallback the HomeCountdown child raises at zero (HomeCountdown.razor:82), which recomputes the phase, re-arms, and calls InvokeAsync(StateHasChanged). ArmPhaseTimerForEventEnd (:171-186) returns unless the phase is Live and the remaining time, measured against TimeProvider.GetUtcNow() (:178), is positive, then disposes any prior timer and schedules one callback at untilEnd with Timeout.InfiniteTimeSpan as the period, meaning fire once and never repeat (:185). OnEventEnded (:188-197) checks _disposed before re-rendering.
    • FormatEventDate (:353-360): formats the date with a pattern read from a resource (L["Hero.DateFormat"]) against CultureInfo.CurrentCulture, so both the layout and the month names follow the selected language (ADR-027).
    • Dispose (:362-369): sets _disposed, cancels and disposes the CTS, and both stops (Change(-1, -1)) and disposes the phase timer. Stopping before disposing is what prevents a callback already in flight from touching a torn-down component.
  • Why it's built this way: the landing page is the app's most-hit surface and the post-login destination, so its correctness budget is dominated by two failure modes that have nothing to do with its content: a slow backend blocking the prerender, and a per-second render loop multiplied by every connected circuit. Both are solved structurally (skip the fetch, fence the tick) rather than by tuning, and every dynamic block has a defined empty state, so the page is never blank. Routing the three reads through IEventUIService, ISponsorUIService, and IPartnerUIService keeps the event-scoping filter server-side and lets the page consume the same Result-based service layer every other page in the group uses. The two conditional calls to action follow the same discipline: the hero ticketing button and the sponsorship packet block each render only when the featured event publishes the corresponding URL, and hide entirely otherwise rather than offering a dead link, which the markup comments state at both sites.

  • Where it's used: resolved as the home component by each head's ADCHomePageContent. The Web client points ComponentType straight at this shared component (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:13); the MAUI head points at a thin local wrapper page that renders <ADCHome /> with no parameters (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:10, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHome.razor:6). [Rubric §28, Front-End Testing]: a bUnit suite renders the page directly (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Home/ADCHomeTests.cs), covering the hero eyebrow and title class, the workshop cards and their links, the ticketing call to action with and without a URL (:163, :220), and the partners band ordering and empty state (:256, :306). Two suites hold it to account from outside as well: the brand-token fitness test above and the E2E pseudo-localization sentinel, which probes this page's Location.OpenInMaps resource and settles on the always-rendered .location-section, precisely because that button is static markup rather than event-load-gated (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/PseudoLocalizationTests.cs:35-48).

  • Caveats / not-in-source: the page's own countdown window is not identical to the selector's. UpdateCountdown starts the event at EventStartTime = 08:00 local (:44, applied at :332), while CurrentEventSelector starts its live window at midnight local (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:71). Both end at midnight after the last day (CurrentEventSelector.cs:70; :333 here). So between midnight and 08:00 on day one, the selector already treats the event as live while the hero still shows a countdown. Whether that is intended is not determinable from source. Also note the two hard-coded fallbacks used when no event loads: the date 2026-10-17 (:46-49), whose comment warns it must track the published event date or the hero date and countdown visibly jump once the real event arrives, and the venue address (:110).

SessionAssetComposer

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/SessionAssetComposer.razor.cs:24 · Level 0 · class (Blazor code-behind)

  • What it is: the input surface for one new session material, add-a-link or pick-a-file, extracted out of SessionAssetsPanel as its own component. It owns the two link text fields and validates and normalizes the typed address before ever raising an event; the panel still owns the cap check, the actual write, and every status message.
  • Depends on: SessionAssetsPanel as its only host (line 43, _fileInputId), and the Blazor primitives EventCallback<T> and InputFileChangeEventArgs/IBrowserFile (lines 35, 38, 41, 80). No first-party service or domain type.
  • Concept introduced, a composer that validates before it emits. Rather than raise a raw string and let the host re-validate, AddLinkAsync (lines 60-78) checks CanSubmitLink (lines 50-51) and normalizes the address through TryNormalizeWebUrl (lines 100-125) itself, and only ever raises one of two outcomes: OnAddLink with the trimmed title and the normalized absolute URL, or OnInvalidLinkAddress with no payload at all, leaving the host to word the refusal. [Rubric #19, State Management & Data Flow] assesses where UI state is owned: the draft fields (_newLinkTitle, _newLinkUrl, lines 47-48) live entirely in the composer, and ClearDraft (lines 54-58) is the only way the host reaches back in, called only once a write has actually succeeded (the host's AddLinkAsync calls _composer?.ClearDraft() after MutateAsync reports success).
  • Walkthrough
    • Disabled (line 32) is one flag the host sets whenever every control should render inert (at the session's material cap or mid-write), rather than the composer re-deriving that from state it does not own.
    • _fileInputId (line 45) is generated per instance with Guid.NewGuid():N, because a page renders one composer per session and a fixed id would collide across composers and mis-point the file input's label.
    • AddLinkAsync (lines 60-78) re-checks CanSubmitLink, normalizes through TryNormalizeWebUrl, echoes the normalized URL back into _newLinkUrl (line 75, so the speaker sees the address that will actually be stored) before raising OnAddLink with the trimmed title and normalized URL.
    • OnFileSelectedAsync (lines 80-88) forwards the picked IBrowserFile through OnFileSelected; it does no size or format check itself, that stays the host's job against SessionAssetLimits.
    • TryNormalizeWebUrl (lines 100-125) accepts a value that already parses as an absolute http/https URL as-is (Ordinal scheme comparison, lines 107-108, matching the domain invariant and the FluentValidation rule that guard the same value server-side), and otherwise prefixes a scheme-less value with https:// and re-parses (lines 111-122), so a speaker pasting github.com/me/repo is treated as a web address rather than rejected outright.
  • Why it's built this way: pulling the add-link fields and their normalization out of the panel keeps the panel's MutateAsync gateway focused on the write itself, and gives the input surface a ClearDraft the host can call only when a write actually lands, so a refused write leaves the speaker with what they typed instead of silently emptying the fields.
  • Where it's used: rendered inside SessionAssetsPanel's markup as the add-material affordance.
  • Caveats / not-in-source: TryNormalizeWebUrl duplicates the method of the same name and shape that used to live on SessionAssetsPanel itself (removed by the same change that added this component); whether a shared helper was considered instead of the duplication is not determinable from source.

NewestLoadTracker<T>

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/NewestLoadTracker.cs:14 · Level 0 · class (internal sealed, generic)

  • What it is: a tiny tracker that makes overlapping asynchronous loads all resolve to the result of the newest one. Track records a load as the newest and returns a task that completes with the newest load's result (NewestLoadTracker.cs:24-28).
  • Depends on: nothing beyond Task<T>; the only state is _newest (:16).
  • Concept introduced, newest wins without cancelling. A grid can issue a second ServerData call while the first is still waiting on a prerequisite. Rather than let the superseded call paint its own (possibly empty) page, NewestResultAsync (:30-40) awaits its own load, then loops while _newest is a different task, switching to it and awaiting it, until the task it just finished is the newest. Every caller therefore returns the same final data. The doc (:18-22) states the contract: call Track before anything yields, so an earlier load resuming afterwards already sees this one as its successor. [Rubric §19, State Management & Data Flow].
  • Walkthrough
    • Track(load) (:24-28) assigns _newest = load (:26) and returns NewestResultAsync(load) (:27), so the assignment happens synchronously on the caller's turn.
    • NewestResultAsync (:30-40): the while (_newest is { } newest && !ReferenceEquals(newest, load)) loop (:33) advances load and data (:35-36) until nothing newer exists, then returns data (:39).
  • Why it's built this way: cancelling the older load is not an option here because the older load is the grid's own pending request; making it answer with the newer result removes the visible flash of an empty page without touching the grid.
  • Where it's used: held as _gridLoads and applied in PublicSessionList.LoadServerData (PublicSessionList.razor.cs:44, :211-212), where it fixes the U-35 case of a load superseded while waiting on the event scope.

PublicSessionEventScope

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionEventScope.cs:14 · Level 0 · class (internal sealed)

  • What it is: the coordinator for the page's events lookup. It remembers the initial lookup task, reports whether a lookup is still pending, and lets overlapping grid loads await it, re-issuing it at most once while the scope is unresolved so the Retry button heals a transient failure (PublicSessionEventScope.cs:18-22, :31-35).
  • Depends on: nothing beyond Task and delegates supplied by the caller (scopeUnresolved, reissue, onReissued, :36); state is _lookup and _resolutionsInFlight (:16-17).
  • Concept introduced, a shared single re-issue. Several grid loads can wait on the same failed lookup. EnsureAsync (:36-68) counts itself in _resolutionsInFlight (:38), awaits the current lookup (:45), and if the lookup it waited on has since been replaced by a sibling load's re-issue it joins that new one instead and marks itself as retried (:47-53). It only re-issues when it has not retried and the scope is still unresolved (:55-62), storing the new task in _lookup and calling onReissued so the page can re-render its pending state. The result is at most one extra lookup however many loads overlap. [Rubric §29, Resilience & Business Continuity].
  • Walkthrough
    • Pending (:23) is true while _lookup has not completed or any EnsureAsync call is still resolving, because a resolving load may yet re-issue a failed lookup (doc :19-22).
    • Start(lookup) (:27) records the page's initial lookup and returns it for the caller to await.
    • EnsureAsync (:36-68) decrements _resolutionsInFlight in a finally (:65-67), so Pending cannot stick after a fault.
  • Why it's built this way: the page previously held the lookup task and the retry rule itself; pulling it out gives the retry-race behavior a home that can be exercised without rendering the grid.
  • Where it's used: held as _eventScope by PublicSessionList (PublicSessionList.razor.cs:43); Start is called in OnInitializedAsync (:128), Pending backs EventsLookupPending (:163), and EnsureAsync backs EnsureEventScopeAsync (:178-179).

SessionAssetDisplay

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/SessionAssetDisplay.cs:13 · Level 2 · internal static class

  • What it is: a small, stateless helper shared by the two session-materials components: it picks the icon for an asset and formats a byte count for display. Everything on it is a pure function over its arguments.
  • Depends on: SessionAssetDTO and SessionAssetKind (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/SessionAssets/SessionAssetDTO.cs, .../SessionAssets/SessionAssetKind.cs), SessionAssetLimits.MaxFileBytes (line 28). Externals: MudBlazor Icons.Material.Filled.* (lines 45, 52-58), System.Globalization.CultureInfo (line 80).
  • Concept introduced, deriving display facts instead of storing them. Neither the icon nor the formatted size is persisted anywhere; both are computed on demand from the asset's Kind and stored byte count, so a rendering change (a new icon, a different rounding) touches this one file and nothing in the data model. [Rubric §15, Best Practices & Code Quality] assesses whether a number lives in one place: the upload cap shown in the UI hint and the over-size message is MaxFileMegabytes (line 28), derived from SessionAssetLimits.MaxFileBytes by division rather than restated as a literal, so the client hint and the server-enforced cap cannot drift apart. [Rubric §27, Internationalization] assesses whether user-facing text is externalized: FormatSize returns the numeric amount and a resource key ("Size.Megabytes", "Size.Kilobytes", "Size.Bytes"), not a formatted sentence, so the caller composes it through its own localizer and a Spanish reader sees "1,4 MB" rather than an English-punctuated string (doc, lines 62-65).
  • Walkthrough
    • IconFor(SessionAssetDTO asset) (lines 39-60) null-guards (line 41), returns the link icon outright for SessionAssetKind.Link (lines 43-46), and otherwise switches on ExtensionOf(asset) over a closed set of upper-cased extensions (.PDF, .PPTX, .DOCX, .XLSX, .ZIP, .MD/.TXT, lines 50-59), falling back to a generic file icon. The in-code comment (lines 48-49) explains the upper-casing: the comparison is over a closed set the code owns, so SonarAnalyzer's CA1308 wants the round-trip-safe casing rather than a culture-sensitive lower-case.
    • FormatSize(long sizeBytes) (lines 68-81) picks the largest unit the value clears (megabytes, then kilobytes, then bytes, lines 70-80) and rounds through Round (lines 83-84), which formats with "0.#" under CultureInfo.CurrentCulture so the decimal separator matches the active locale.
    • ExtensionOf(SessionAssetDTO asset) (lines 86-103) reads the extension off the stored blob name first, and only falls back to parsing the asset's Url when the blob name is blank (lines 93-98). The doc comment states why the order matters (lines 86-89): a blob URL can carry a signature query string, and taking the extension from the URL first would yield the query string rather than the file's actual format.
  • Why it's built this way: both helpers are pure and side-effect-free, so a component that needs an icon or a formatted size calls a static method instead of duplicating the switch or the rounding logic; keeping the file extension check on the blob name first (not the URL) is what keeps the icon correct for a signed download link.
  • Where it's used: IconFor renders the material icon on SessionAssetsPanel and SessionAssetsDownloadList; FormatSize backs DescribeSize on both of those components (.../SessionAssets/SessionAssetsPanel.razor.cs:486-490, .../SessionAssets/SessionAssetsDownloadList.razor.cs:157-161); MaxFileMegabytes is quoted in SessionAssetsPanel's over-size message (.../SessionAssetsPanel.razor.cs:333).
  • Caveats / not-in-source: the extension switch (lines 50-59) recognizes six formats explicitly; any other extension, including one the API itself accepts, falls back to the generic file icon. Whether the set of icons is meant to track the server's allowed-format list is not determinable from this source.

PublicSessionListFilterBar

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionListFilterBar.razor.cs:16 · Level 3 · class (Blazor code-behind)

  • What it is: the presentational filter bar for PublicSessionList: the privileged-reader Filter-by-Event picker (or the locked "Showing" chip for everyone else), the debounced title search box, the Room picker, the All Sessions / My Schedule toggle, and the share-my-schedule action (class doc, PublicSessionListFilterBar.razor.cs:9-15).
  • Depends on: EventDTO and RoomDTO (:2); IScreenshotService, IShareService, and IToastService (:18-20). Its Rooms option set is produced by PublicScheduleRoomOptions.
  • Concept introduced, the container/presentational split. The bar owns no filter state. Every value arrives as a [Parameter] and every change leaves through a matching EventCallback: IsPrivileged (:26), Events (:29), SelectedEventId / SelectedEventIdChanged (:32, :35), SearchString / SearchStringChanged (:38, :41), Rooms (:47), SelectedRoomId / SelectedRoomIdChanged (:50, :53), and ShowMyScheduleOnly / ShowMyScheduleOnlyChanged (:56, :59). The page stays the single source of truth and the bar is a pure view over it, with no lifecycle method of its own. [Rubric §18, UI Architecture & Component Design] (assesses decomposition and separation of layout from behavior) and [Rubric §19, State Management & Data Flow] (assesses where mutable state lives): with nothing to initialize, the bar cannot drift from the data the grid actually fetched. Three details reward a close read. The parameter is IsPrivileged, not "is organizer", because the privileged read audience is a role set (ConferenceReadAudience) rather than one role. Rooms documents its own empty case (:43-46): an empty list hides the Room picker entirely, because an event with no rooms has nothing to narrow by, so a control with no meaningful options is removed rather than shown disabled ([Rubric §24, Forms, Validation & UX Safety]). And the localizer injected in the markup is IStringLocalizer<PublicSessionList>, not one of its own (PublicSessionListFilterBar.razor:3), so the split into three components did not split the page's resource file into three. [Rubric §27, Internationalization].
  • Walkthrough
    • GetSelectedEventName() (:61-62): resolves the chip label from the passed-in Events list, returning empty when nothing is selected.
    • ShareScheduleAsync() (:64-85): captures the current view to a file through IScreenshotService and hands it to IShareService as image/png (:68-70); a null capture or a failed share collapses into one warning toast (:72), and the || short-circuit means a null path never reaches the share call. The whole body sits in a try that swallows OperationCanceledException as expected disposal or an InteractiveAuto transition (:75-78) and turns any other exception into the same warning toast (:79-84): it runs from an OnClick callback, where on the native heads an escaped exception is fatal to the host (comment :81-82, tag A-08). This is a native-head capability (ADR-042 Wave 3) that degrades quietly on the web. [Rubric §29, Resilience & Business Continuity].
  • Why it's built this way: pushing all filter state to the page means the same chrome can sit above both the desktop grid and the mobile card list without either layout owning a second copy of the filters.
  • Where it's used: rendered once by PublicSessionList (PublicSessionList.razor:11-21); its callbacks land on that page's OnEventFilterChanged, OnSearchChanged, OnRoomFilterChanged and OnMyScheduleToggled handlers (PublicSessionList.razor.cs:182-204).

PublicSessionBookmarkState

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionBookmarkState.cs:14 · Level 4 · class (internal sealed, primary constructor)

  • What it is: the reader-and-bookmarks state for PublicSessionList: whether the reader is signed in, their user id, and the dictionary of starred sessions. The same dictionary instance is handed to PublicSessionListView, which patches it in place as stars toggle (doc PublicSessionBookmarkState.cs:27-30).
  • Depends on: the cascaded Task<AuthenticationState>? and the optional ISessionBookmarkUIService as primary-constructor parameters (:14-16); ClaimsPrincipalExtensions's GetUserId (:79); Result for the kept failure (:19).
  • Concept introduced, a load whose failure is kept rather than thrown. The state is built from the page's already-resolved authentication task and bookmark service, so it has no [Inject] surface of its own. StartLoad (:38-42) runs LoadAsync and stores the task, because the page must start it eagerly: the grid's first ServerData call can race ahead of initialization (doc :33-36). [Rubric §19, State Management & Data Flow] (assesses where mutable state lives and how loads are ordered).
  • Walkthrough
    • LoadAsync (:67-97) returns immediately when there is no authentication task or no bookmark service (:69-72), so a disabled Engagement module leaves the reader anonymous with an empty dictionary. Otherwise it awaits the authentication state, reads the id through GetUserId (:79, accepting both the sub claim and the NameIdentifier form the bearer handler maps it to), sets CurrentUserId and IsAuthenticated (:81-82), and loads the bookmarked ids (:86). The failure is stored in _loadFailure (:87) and a success replaces BookmarkedSessions (:88-91). OperationCanceledException is swallowed as expected disposal or an InteractiveAuto transition (:94-97).
    • EnsureAsync(showMyScheduleOnly) (:51-65) awaits the load task and returns null unless My Schedule is active for a signed-in reader whose read failed (:58-61). In that case it re-issues the load once (:63) and returns the still-held failure (:64), so the page shows an error, not an empty schedule (doc :44-48). Plain browsing is unaffected: a failed read only leaves the stars unset (comment :83-85). [Rubric §29, Resilience & Business Continuity].
  • Why it's built this way: My Schedule is the one view that cannot be answered without the bookmark read, so the failure has to be kept somewhere the fetch path can see it. Holding it here keeps that rule out of the page.
  • Where it's used: created per page initialization as _bookmarks (PublicSessionList.razor.cs:121-122), awaited by FetchSessionsAsync through EnsureAsync (:245), and read for IsAuthenticated and BookmarkedSessions when building the request (:255, :264).

SessionAssetsDownloadList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/SessionAssetsDownloadList.razor.cs:18 · Level 5 · class (Blazor code-behind, IDisposable)

  • What it is: the read-only, attendee-facing list of a session's slides, links and files. It loads once per session and renders nothing organizers-only, no add/rename/delete affordances at all.
  • Depends on: ISessionAssetUIService (line 20 injected field), SessionIdentifierType (line 23 parameter), SessionAssetDisplay.FormatSize (line 58), and the LifetimeToken extension on the CancellationTokenSource (line 30). Externals: Blazor [Inject]/[Parameter], System.Threading.CancellationTokenSource.
  • Concept introduced, none new. This component is the minimal half of the pattern SessionAssetsPanel teaches in full: load-once-per-session, swallow OperationCanceledException during disposal, dispose the _cts exactly once. Reading the two side by side is the point: this one keeps only the load and render path and drops every mutation. [Rubric §19, State Management] assesses whether fetched state is invalidated deliberately: OnParametersSetAsync (lines 36-54) returns immediately when _loadedSessionId == SessionId (lines 38-41), the same reload guard the mutable panel uses, so re-rendering the same session never refetches.
  • Walkthrough
    • OnParametersSetAsync (lines 36-54) sets _loadedSessionId (line 43), then awaits SessionAssetService.GetBySessionAsync(SessionId, cancellationToken: LifetimeToken) (line 47), where LifetimeToken (line 30, comment lines 27-29) is _cts.LifetimeToken(): reading Token off a disposed source throws ObjectDisposedException, and because Dispose cancels before it disposes, a load that resumes after the user navigated away gets an already-cancelled token and stops through its OperationCanceledException catch (line 50) instead. It leaves bypassCache at its false default so the attendee read is served from the shared output cache (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionAssets/ISessionAssetUIService.cs:25-34), and unwraps the Result with TryGetValue, falling back to an empty list on failure (line 48) rather than surfacing an error state.
    • DescribeSize(long sizeBytes) (lines 56-60) forwards to SessionAssetDisplay.FormatSize and resolves the returned unit key through the component's own localizer (L[unitKey, amount].Value, line 59).
    • The _disposed-guarded Dispose(bool) / Dispose pair (lines 62-84) cancels and disposes _cts exactly once, identical in shape to every other component in this group.
    • The per-instance _headingId (line 31) exists for the same reason as on SessionAssetsPanel: a page can render more than one materials list, and a fixed id would break the label/heading association across instances.
  • Why it's built this way: an attendee never edits a session's materials, so giving them a component with no mutation surface at all (rather than the organizer panel with CanManage="false") means there is no upload input, no delete button and no edit state to accidentally expose in a public view.
  • Where it's used: rendered on the public session detail page wherever attendee-visible materials are shown, keyed by the session's identifier.
  • Caveats / not-in-source: a failed load renders an empty list with no visible error (line 48), so an attendee cannot distinguish "this session has no materials" from "the materials failed to load".

SessionAssetsPanel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/SessionAssetsPanel.razor.cs:31 · Level 5 · class (Blazor code-behind, IDisposable)

  • What it is: the organizer- and speaker-facing manager for a session's materials: add a link, upload a file, rename or delete an asset, all behind a shared cap and a shared failure-to-message mapping. A CanManage parameter toggles it into the same read-only rendering SessionAssetsDownloadList provides. Its add-link and pick-a-file inputs live in a child SessionAssetComposer (line 67 field _composer); this panel keeps the cap check, the write itself, and every status message.
  • Depends on: ISessionAssetUIService (line 33 injected field), SessionIdentifierType (line 36 parameter), SessionAssetComposer (line 67), SessionAssetDisplay for MaxFileMegabytes and FormatSize, SessionAssetLimits.MaxAssetsPerSession (line 69), SessionAssetLinkRequest (lines 115-121) and SessionAssetUpdateRequest (lines 186-190), the LifetimeToken extension on _cts (line 49 property), and Result (line 214). Externals: Blazor ([Inject], [Parameter], IBrowserFile), MudBlazor (Severity), and a DeleteConfirmation component (line 66) from MMCA.Common.UI.Components.
  • Concept introduced, one mutation gateway for every write. Add-link, upload-file, rename and delete are four different service calls, but they all route through MutateAsync (lines 214-238, plus the Task<Result<T>>-adapting overload at lines 244-245): set busy, run the operation, report the failure through DescribeFailure or the success through a localized toast state, reload the list, clear busy in a finally. A caller supplies only the operation delegate, the success resource key, and an optional post-success callback (AddLinkAsync clears the composer's draft, SaveTitleAsync calls CancelEditing). [Rubric #24, Forms, Validation & UX Safety] assesses whether a rejection is explained in place: FailureResourceKeys (lines 264-280) maps every machine-readable error code the four write paths can return to a resource key, with the field's own doc comment (lines 247-263) explaining two edge cases directly in the source (lines 252-255 and 258-261): Http.413 is the one transport-level status in the table, because Kestrel refuses an over-cap request body before the friendly SessionAsset.InvalidUpload domain check ever runs, which is the one case the client-side size check (line 151) cannot catch (a body inflated past the cap by multipart framing); and a title refusal is listed under two different codes because two layers can raise it, the domain invariant (SessionAsset.Title.*) and the request validator's FluentValidation defaults (NotEmptyValidator / MaximumLengthValidator), which the request validator leaves unwrapped rather than remapping. DescribeFailure (lines 287-298) falls back to one generic sentence for any code not in the table (a transport fault, a concurrency refusal, an unexpected
    1. rather than surfacing the server's raw text. [Rubric #27, Internationalization] assesses whether every user-facing string is externalized: every key in FailureResourceKeys and every success key passed into MutateAsync is a resource lookup through L[...], never an inline sentence.
  • Walkthrough
    • IsAtCap (line 69) is the one gating boolean the panel itself computes; the markup passes it into the composer as Disabled alongside the busy flag.
    • LifetimeToken (line 49, comment lines 46-48) is _cts.LifetimeToken(): reading Token off a disposed source throws ObjectDisposedException, and because Dispose cancels before it disposes, a load or handler that resumes after the user navigated away gets a cancelled token and stops through its OperationCanceledException path. Every service call below (load, add link, upload, rename, delete) passes it instead of _cts.Token.
    • OnParametersSetAsync / LoadAsync (lines 71-105) mirror SessionAssetsDownloadList's reload guard, but differ in two ways. The fetch passes bypassCache: true (line 87): the list endpoint is served under a shared output-cache policy, so an owner's read could otherwise hit an entry built for an anonymous caller and miss a material they just added, and the flag makes the UI service add a unique query parameter that is a guaranteed cache miss (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionAssets/ISessionAssetUIService.cs:25-34). And on a failed fetch this version keeps the list empty and sets a status message (SetStatus, line 95), unlike the read-only list's silent fallback.
    • AddLinkAsync (lines 107-129) takes the (Title, Url) tuple the composer already normalized, re-checks only IsAtCap, builds a SessionAssetLinkRequest with SortOrder = _assets.Count (line 120), and clears the composer's draft (_composer?.ClearDraft()) only once MutateAsync reports the write succeeded, so a refused write leaves the speaker with what they typed (in-code comment, lines 123-124).
    • ReportInvalidLinkAddress (lines 131-137) words the composer's own refusal of a typed address. The doc comment explains why the wording stays here rather than moving into the composer: every outcome of every action reaches the one aria-live status region below, and the sentence stays beside the matching API refusal it mirrors in FailureResourceKeys.
    • OnFileSelectedAsync (lines 139-162) takes the composer's forwarded IBrowserFile directly, rejects an over-cap file client-side against SessionAssetLimits.MaxFileBytes (line 151) before ever posting, with a title defaulted by DefaultTitleFor (lines 312-324), which strips the extension and falls back to the raw file name only if the stripped result is blank, capped at SessionAssetDTO.TitleMaxLength.
    • StartEditing / CancelEditing (lines 164-175) toggle the inline rename state; SaveTitleAsync (lines 177-196) echoes the asset's own SortOrder back unchanged (in-code comment: omitting it would silently move the material to the top) and posts through UpdateAsync with the asset's RowVersion for optimistic concurrency.
    • DeleteAsync (lines 198-208) confirms through the injected DeleteConfirmation component rather than a native browser confirm() (in-code comment: unstyled, untranslated and unreachable from the MAUI head), then deletes through MutateAsync.
    • The _disposed-guarded dispose pair follows the same shape as every other component in this group.
  • Why it's built this way: four writes with the same busy/report/reload shape and a shared failure vocabulary are cheaper to keep correct as one gateway method than as four hand-written try/catch blocks, and centralizing FailureResourceKeys is what lets a new server error code be wired to a message in one place instead of four. Extracting the add-link fields into SessionAssetComposer keeps this gateway focused on the write itself rather than also owning the draft the write is built from.
  • Where it's used: rendered on the organizer's and the speaker's session detail surfaces, with CanManage distinguishing an editable view from a read-only preview; SessionAssetDisplay.IconFor and FormatSize supply the icon and size text for every rendered asset.
  • Caveats / not-in-source: CanManage="false" renders the same read-only shape SessionAssetsDownloadList provides independently, so the codebase carries two components capable of a read-only materials list rather than one component the other wraps. Whether that duplication is deliberate is not determinable from this source.

PublicSessionListView

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionListView.razor.cs:26 · Level 5 · class (Blazor code-behind)

  • What it is: the presentational session-list view for PublicSessionList: the mobile infinite-scroll card list and the desktop server-paged data grid, including the inline bookmark stars and their toggle flow (class doc, PublicSessionListView.razor.cs:15-25).
  • Depends on: SessionDTO, the optional ISessionBookmarkUIService (:60), SpeakerInfo (:69), Result in the mobile fetch delegate's signature (:78), ConferenceRoutePaths (:185), MobileInfiniteScrollList<TItem> (:80, rendered at PublicSessionListView.razor:6), ListPageActions (:94), IToastService and IHapticFeedbackService (:28-29), plus MudBlazor's MudDataGrid<T> / GridState<T> / GridData<T> and NavigationManager.
  • Concept introduced, the presentational child that patches container-owned state in place. Like PublicSessionListFilterBar, the view owns no fetch or filter state: the page hands down its ServerData and FetchPageResult delegates (:75, :78), its paging parameters (:42-48), the speaker and room lookups (:69, :72), and the shared BookmarkedSessions dictionary (:66). The subtlety is that the view mutates that dictionary in place when a star is toggled (AddBookmarkAsync writes BookmarkedSessions[sessionId] = bookmark.Id at :170, RemoveBookmarkAsync removes at :150), so the page's My Schedule fetch, which reads the same dictionary to scope the query, sees the change without a round trip. The class doc names the sibling that uses the same pattern, SessionLivePollPanel (:20-21). It also exposes the captured Grid reference (:90) and ReloadAsync() (:93-94) so the page's DataGridListPageBase<TDto> plumbing keeps restoring rows-per-page and current page unchanged. [Rubric §18, UI Architecture & Component Design] and [Rubric §19, State Management & Data Flow]: state has exactly one owner (the page) and one mutation point (this component). The class doc also records a deliberate omission (:23-24): the list shows no track or category chips, because the detail page is where a session's categories are read and the list stays scannable on time, speakers, and room. [Rubric §25, Navigation & Information Architecture].
  • Walkthrough
    • IsBookmarked (:96-97) is a dictionary lookup, so star state costs nothing per row.
    • CanBookmark (:139-143): a session is bookmarkable only when the user is authenticated, the Engagement-owned service resolved, the session is not a service session, and SessionStatuses.IsEligible accepts its status. The comment (:136-138) records this as the BR-49 allow-list (unset or "Accepted"), the same rule the server-side bookmark validation applies, precisely so the UI never shows a star the server would then reject. [Rubric §11, Security] and [Rubric §24, Forms, Validation & UX Safety].
    • ToggleBookmarkAsync (:110-137): guards re-entry with a per-session HashSet whose Add doubles as the guard test (:112, field at :83), fires Haptics.Click() (:116, a no-op off native heads), then removes or adds, catching OperationCanceledException as expected teardown or an InteractiveAuto transition (:129-132) and clearing the guard entry in the finally (:135). The per-session guard is a fixed defect worth reading: the comment at :81-82 records that a single global in-flight flag made one slow toggle swallow every other star's click, so the list stopped responding until that request came back.
    • RemoveBookmarkAsync (:139-158): a delete that comes back not-found is treated as success, because a bookmark that is already gone still leaves the user where they asked to be (:143-148, the tolerance expressed as removed.IsFailure && !removed.IsNotFound() over ResultUiExtensions.IsNotFound, MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:340); it then clears the entry, toasts, and reloads when the My Schedule view is active so the removed row disappears (:150-157).
    • AddBookmarkAsync (:160-172): a create that did not come back with a bookmark leaves the star unset, so the page reports a warning rather than a success toast that would contradict its own UI (:162-168).
    • GetSpeakerList (:174-182) maps a session's SessionSpeakers to display names through the passed-in lookup, skipping ids the lookup does not know; OnMobileCardClick (:184-185) routes to PublicSessionDetail through ConferenceRoutePaths.
  • Why it's built this way: separating the grid and card layouts from the page's fetch-and-filter logic lets one bookmark implementation serve both, while the page remains the owner of every piece of state either layout renders.
  • Where it's used: rendered by PublicSessionList, which holds it as _view (PublicSessionList.razor.cs:41, captured at PublicSessionList.razor:31) and reads _view?.Grid for its GridRef override (:70) and _view?.ReloadAsync() for every filter change (:232).

SessionBookmarkButton

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/SessionBookmarkButton.razor.cs:27 · Level 5 · class (Blazor code-behind)

  • What it is: the standalone bookmark star for one session, extracted out of PublicSessionDetail so that page no longer has to inject the Engagement-owned bookmark service or carry a reader's authentication state just to draw one toggle.
  • Depends on: optionally ISessionBookmarkUIService, resolved through IServiceProvider (:29); IToastService and IHapticFeedbackService (:30-31); the SessionId parameter typed as SessionIdentifierType (:38); UserIdentifierType and UserSessionBookmarkIdentifierType for its local state (_currentUserId and _bookmarkId, :48-50); and ClaimsPrincipalExtensions's GetUserId, read off the cascading AuthenticationStateTask (:35, :86).
  • Concept introduced, an optional cross-module service resolved per component instance rather than per page. Like the pages that used to hold this logic, Blazor's [Inject] has no optional mode (an unregistered service throws at render), so BookmarkService is resolved with ServiceProvider.GetService<T>() in OnInitialized (:53-57) and every call site null-checks before using it. Because the resolution now happens once per rendered star instead of once per hosting page, moving it here is what let PublicSessionDetail drop its own copy of the same pattern entirely. [Rubric section 7, Microservices Readiness] (assesses that a module can be switched off without breaking its consumers) and [Rubric section 18, UI Architecture & Component Design] (assesses decomposition: one focused component owns one piece of optional behavior instead of every host page repeating it).
  • Walkthrough
    • LifetimeToken (:45, comment :41-44) is _cts.LifetimeToken(): reading Token off a disposed source throws ObjectDisposedException, and because Dispose cancels before it disposes, a read or toggle that resumes after the reader navigated away gets a cancelled token and ends through its OperationCanceledException catch. The bookmark read and both toggle writes pass it (:94, :122, :137).
    • OnParametersSetAsync (:59-71) skips the read during SSR prerender for the reason every other interactive page states: under InteractiveAuto the interactive instance re-runs this method, so without the guard every visit would fetch the reader's bookmarks twice; the prerender pass renders the unset star. It then reloads only when SessionId actually changed (:64, :69-70).
    • LoadBookmarkStateAsync (:73-105): awaits the cascading authentication state, reads the identifier through GetUserId (:86, accepting both the sub claim and the NameIdentifier form the bearer handler maps it to, and parsing invariantly), then loads the bookmarked ids; a failed read is non-critical and leaves the star unset (:93-99).
    • ToggleBookmarkAsync (:107-157): a single _isTogglingBookmark re-entry guard, a haptic click (:113, a no-op off native heads), then a delete that tolerates an already-gone (404) bookmark as a successful remove (:120-127), or a create that reports a warning rather than a success toast when the response comes back without a bookmark body (:137-142). Both branches catch OperationCanceledException as expected component disposal or an InteractiveAuto transition, and clear the re-entry guard in the finally (:153-156).
    • Disposal (:159-184) is the standard cancel-on-disposal pattern over the component's own CancellationTokenSource (:40).
  • Why it's built this way: a bookmark toggle is genuinely optional (it needs the Engagement module, which ADC can run without), and every page that renders a session benefits from the same fail-soft behavior, so the toggle is a component with its own lifecycle instead of logic duplicated per host.
  • Where it's used: rendered by PublicSessionDetail for its single session's star. PublicSessionListView keeps its own inline bookmark implementation (a per-session HashSet re-entry guard over a shared BookmarkedSessions dictionary, not this component), because a list needs to mutate one shared dictionary in place across many rows rather than own independent per-row state.

PublicSessionDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionDetail.razor.cs:24 · Level 9 · class (Blazor code-behind)

  • What it is: the public read-only view of one session (speakers, categories, room and wayfinding) plus the contextual actions an authenticated attendee gets: the bookmark toggle (rendered through SessionBookmarkButton), the feedback link, a listen-aloud button, and the Live entry point when the Engagement module is present and the session is inside its event's live window.
  • Depends on: ISessionUIService, ISpeakerLookupService, IRoomUIService, ICategoryItemLookupService (:26-29), IEventLookupService and an injected BCL TimeProvider for the live-window check (:34-35); optionally ISessionLiveUIService (field :40, resolved at :58); ITextToSpeechService (:33); SessionDTO, RoomDTO, IToastService (:31), CurrentEventSelector.GetLiveWindowUtc (:270-273), ConferenceRoutePaths (:62, :278), and DomainHelper's Id.Parse<T> (:130). The bookmark toggle itself, including ISessionBookmarkUIService and the ClaimsPrincipalExtensions-based user lookup this page used to hold, now lives entirely in SessionBookmarkButton, which the page renders and no longer injects services for.
  • Concept introduced, optional cross-module service resolved through the container. Blazor's [Inject] has no optional mode (an unregistered service throws at render), so the Engagement-owned ISessionLiveUIService is resolved with ServiceProvider.GetService<T>() in OnInitialized and left null when that module is disabled (field :40, resolved at :58). The markup only renders the Live button when SessionLive resolved. [Rubric section 7, Microservices Readiness] (assesses that a module can be switched off without breaking its consumers): the Conference page degrades to a plain read-only session view when Engagement is absent, rather than failing to render. [Rubric section 3, Clean Architecture]: the dependency is on an interface owned by the other module's UI contract, never on its internals. The page repeats two mechanisms taught above. The prerender skip (:94-102) carries the same reasoning as the other public detail pages: under InteractiveAuto the interactive instance re-runs OnParametersSetAsync, so without the guard every visit fetched the session, all speakers, all category items and the room twice, and it names the category-item read as the expensive one, a full-table read per view; now that the bookmark toggle moved to its own component, this page's own prerender skip no longer covers bookmark state. [Rubric section 23, Front-End Performance & Rendering]. The load-tracking field is a LatestLoadGuard (:75), which pairs the generation check with a per-load CancellationTokenSource: LoadSessionAsync calls _load.Begin() (:116) to get back both the token and the generation, so starting a new load also cancels the fetch already in flight rather than merely letting its result drop once _load.IsCurrent(generation) (re-checked at :131) comes back false. The field doc explains why the generation rather than the route id is authoritative (:69-74), and Dispose(bool) disposes the guard alongside the page's own _cts (:323). The page's own token is read through LifetimeToken (:49, comment :45-48), which is _cts.LifetimeToken(): reading Token off a disposed source throws, and since Dispose cancels first, a late resume gets a cancelled token instead; the listen-aloud call uses it (:302). It also repeats the BR-49 status allow-list as IsStatusIneligible (:88-91), with the comment pointing at SessionStatuses.IsEligible as the same rule the server applies.
  • Walkthrough
    • LoadSessionAsync (:113-184): right after Begin() it resets the derived per-session state (_room, _roomName, _speakerNames, _categoryNames, _isLiveNow, :121-125) so a session with no room, or a failed enrichment lookup, never shows the previous session's room, speakers, categories or live state (comment :118-120, tag L156). It then fetches the session with children (:130), clears Session and toasts not-found-versus-load-failed on failure (:136-145), then runs three resolvers in a single short-circuiting condition (:150-152) so any one failing raises one load-failure toast (:154-160), each resolver taking the load's own cancellation token, and finally ResolveLiveWindowAsync (:162). The remaining broad catch (Exception) (:168) no longer guards a bookmark-state read (that moved into SessionBookmarkButton); its comment records that every resolve step above answers with a Result, so nothing here is expected to throw, and a fault that does must still leave the reader with a message rather than a blank card.
    • ResolveSpeakerNamesAsync (:190-202) and ResolveCategoryNamesAsync (:205-221) join the session's child collections against the two lookup services, skipping ids the lookup does not know; the category resolver prefixes the owning category title when present, so a chip reads "Level: Intermediate".
    • ResolveRoomAsync (:228-250): returns success immediately for a session with no room, otherwise fetches the room including wayfinding info (BR-94) and treats a not-found as a tolerable miss that leaves the wayfinding block empty, which the markup renders field by field. It no longer clears _room / _roomName on a miss: LoadSessionAsync already reset them up front.
    • ResolveLiveWindowAsync (:259-276): decides whether the session is inside its event's live window. It looks the event up through IEventLookupService (:262), re-checks the generation, and returns with _isLiveNow false when the lookup failed or the event is unknown (:263-268). Otherwise CurrentEventSelector.GetLiveWindowUtc turns the event's start date, end date and time zone into a UTC range from the first day 00:00 to the day after the last (doc :252-258, the same window the server enforces), compared against TimeProvider.GetUtcNow().UtcDateTime (:274-275). A missing Live button is deliberately not worth a toast. [Rubric section 29, Resilience & Business Continuity].
    • ToggleListenAsync (:285-308): text to speech over the description, where the same button stops playback; SpeakAsync completes when playback finishes or StopAsync cancels it, and the finally clears _isSpeaking either way. [Rubric section 21, Accessibility] (assesses alternative modalities for content) and ADR-042 Wave 3.
    • Navigation (:278, :280) returns to the schedule or opens the session feedback form; disposal is the standard cancel-on-disposal pattern over the CancellationTokenSource this page still owns directly (:44; it has not been moved onto DetailPageBase the way SponsorDetail has), now also disposing the LatestLoadGuard (:323).
  • Why it's built this way: this is the page an attendee opens in a hallway, so the expensive lookups are done once per id, the optional Live capability fails soft, and the bookmark toggle is a self-contained component so the page's own load path no longer has to carry a user's authentication state just to draw one star.
  • Where it's used: the /conference/sessions/{Id} route, reached from PublicSessionListView rows and cards and from PublicSpeakerDetail; its markup renders SessionBookmarkButton for the star and a QrCodeButton for its own public link.

PublicSessionEventCatalog

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionEventCatalog.cs:17 · Level 10 · class (internal sealed)

  • What it is: the page-state holder for everything PublicSessionList knows about events, rooms and speakers: the loaded events with their rooms, the Room picker options, a room-name index, the speaker lookup, and the event and room filter selection (PublicSessionEventCatalog.cs:22-43). It was extracted out of the page, which now holds one instance as _catalog (PublicSessionList.razor.cs:47).
  • Depends on: IEventUIService and ISpeakerLookupService as LoadAsync arguments (:54-59), a BCL TimeProvider (:57), the cascaded Task<AuthenticationState> (:58), PublicReadAudience (:60), PublicScheduleRoomOptions (:75, :127), CurrentEventDefaults (:142), SpeakerInfo (:34), and EventDTO / RoomDTO.
  • Concept introduced, state plus its rules in a plain class. The catalog takes its collaborators as method arguments rather than injecting them, so the page keeps ownership of the [Inject] surface and the catalog stays a plain object whose rules (who is privileged, which event is the default, what a failed fetch means) sit beside the data they govern. [Rubric §18, UI Architecture & Component Design] (assesses decomposition of a page into focused units).
  • Walkthrough
    • LoadAsync (:54-83) resolves privilege through PublicReadAudience (:60), fetches events with children using CancellationToken.None (:63), and on success indexes every event's rooms into RoomNames and loads the speaker lookup (:71-78). A failed events fetch is remembered in _eventsLoadFailed (:69) and deliberately skips the speaker lookup, because it only labels sessions of the events that failed to load (comment :66-68). It then resolves the default event filter and rescopes the Room picker (:80-81) and returns which reads failed as (EventsFailed, SpeakersFailed) (:82) so the page can toast.
    • ScopeUnresolvedForPublicReader (:92) is !IsPrivileged && !SelectedEventId.HasValue && _eventsLoadFailed: true only for a non-privileged reader with no selected event because the fetch failed. A successful fetch that finds no current or next event stays unscoped, and the doc (:85-91) records that the view also renders its load-failed state with Retry while this holds, because a pre-rendered empty grid could otherwise read as "No sessions found.". [Rubric §29, Resilience & Business Continuity].
    • SelectEvent (:96-100) switches the event filter and rescopes the Room picker in one call.
    • ApplyFilters (:105-123) adds Title contains, EventId equals and RoomId equals to the query's filter dictionary. The comment on the room branch (:117-118) notes that Session.RoomId is a real nullable column, so it rides the generic filter pipeline like EventId.
    • ResolveDefaultEventFilter (:129-144): a privileged reader keeps a restored id only if it still exists in the loaded events (:135-140); everyone else is always locked to CurrentEventDefaults.SelectCurrentOrNext evaluated against timeProvider.GetUtcNow().UtcDateTime (:142), so a shared privileged URL can never pin an attendee to a different or unpublished event (comment :131-134). [Rubric §11, Security].
  • Why it's built this way: the audience rule and the fail-closed rule are the page's security-relevant logic, and a plain class can hold them where the page's unit tests reach them without rendering a grid.
  • Where it's used: held as _catalog by PublicSessionList (PublicSessionList.razor.cs:47), which loads it in LoadEventsAndResolveDefaultAsync (:150), saves and restores its selection through PublicSessionListFilterState (:71-84), and applies its filters to every fetch (:314-315).

PublicSessionList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionList.razor.cs:24 · Level 11 · class (Blazor code-behind)

  • What it is: the public conference schedule and the most heavily wired page in this unit. It is the container half of a three-part page (this class, PublicSessionListFilterBar, PublicSessionListView), and it now delegates its state to four small helpers: PublicSessionEventCatalog for events, rooms, speakers and the event and room filters, PublicSessionBookmarkState for who the reader is and what they starred, PublicSessionEventScope for the in-flight events lookup, and NewestLoadTracker<T> for racing grid loads. The page itself keeps the search string, the My Schedule flag and the server-paged fetch (class doc, PublicSessionList.razor.cs:15-23).
  • Depends on: extends DataGridListPageBase<TDto> closed over SessionDTO (:24); IPublicSessionScheduleService (:28) with its SessionSchedulePageRequest (:249), IEventUIService and ISpeakerLookupService (:29-30), an injected BCL TimeProvider (:32), NavigationManager for the sign-in redirect (:33, :143), the optional ISessionBookmarkUIService (:37); the four helpers above (:43-50); PublicSessionListFilterState (:72, :80), ConferenceRoutePaths (:143), and Result (:232). Event, room and speaker types (EventDTO, RoomDTO, SpeakerInfo), PublicReadAudience, PublicScheduleRoomOptions and CurrentEventDefaults are now used inside PublicSessionEventCatalog, and ClaimsPrincipalExtensions's GetUserId inside PublicSessionBookmarkState.
  • Concept introduced, the container page with racing loads, a dual-branch fetch, and a fail-closed scope. Everything the sibling list pages do once, this page does twice and then adds a mode switch.
    1. Two startup tasks, both awaited by the fetch path. OnInitializedAsync (:96-137) creates _bookmarks and starts its load (:121-122), then starts the events load through _eventScope.Start(...) (:128), both before the first await that matters. The comments (:115-120, :124-127) name the failure each guards: the MudDataGrid's first ServerData call can run ahead of initialization, notably on in-app back-navigation where there is no SSR prerender to supply grid data, and a half-initialized (not yet authenticated) state would make the My Schedule branch silently fall through to fetching all sessions. LoadScopedPageAsync (:215-226) awaits EnsureEventScopeAsync (:219) before entering the base's LoadServerDataAsync (:221), because ApplyAdditionalFilters runs inside it, and FetchSessionsAsync (:232-270) awaits the bookmark load through _bookmarks.EnsureAsync (:245). [Rubric §19, State Management & Data Flow].
    2. Racing grid loads resolve to the newest. LoadServerData (:211-212) wraps each load in _gridLoads.Track(LoadScopedPageAsync(state)). NewestLoadTracker<T> records the load as the newest before anything yields, so a load superseded while it waited on the event scope answers with the newest load's result instead of painting its own empty page (doc :208-210, tag U-35). [Rubric §19, State Management & Data Flow].
    3. Two fetch branches, both truly server-paged. In My Schedule mode with bookmarks present, the page passes the bookmarked ids as MyScheduleSessionIds on the SessionSchedulePageRequest (:255) and the service turns them into a server-side Id IN (...) filter so the server still pages (MMCA.ADC.Conference.UI/Services/Public/IPublicSessionScheduleService.cs:14-19). The request is built once (:249-255), so the offline fallback judges exactly the request a live fetch would send (comment :247-248, tag M208). An empty bookmark set short-circuits to ([], 0) (:264-267) rather than issuing a query that would return the whole catalog, and returns before the offline snapshot is written so an empty schedule never overwrites the cached programme (comment :262-263). [Rubric §12, Performance & Scalability].
    4. Audience-scoped filter persistence. Only privileged readers persist an event choice, expressed as persistEventId: _catalog.IsPrivileged && _catalog.EventFilterResolved in the call to PublicSessionListFilterState.Save (:71-74). The default-event rule itself (everyone else locked to the current or next event) lives in PublicSessionEventCatalog.ResolveDefaultEventFilter. [Rubric §11, Security] and [Rubric §26, Front-End Security].
    5. Fail closed, with a retry that heals. When the public scope is unresolved, or the bookmark read failed in My Schedule mode, FetchSessionsAsync routes to AnswerUnqueryablePageAsync (:257-260, :275-293). Offline, the cached programme answers first because a dead venue network fails both reads alike (doc :272-274, tag A-09; :280). Otherwise a bookmark failure is returned as a failed Result carrying the same errors (:285-288), and an unresolved scope throws an InvalidOperationException rather than issue an unscoped session query (:292); the comment (:290-291) records that the base turns the exception into the failed grid state with a retry, which re-resolves the scope first. EnsureEventScopeAsync (:178-179) delegates to PublicSessionEventScope.EnsureAsync, which re-issues a failed events lookup at most once and lets overlapping loads share that one re-issue. EventsLookupPending (:163) and ShowLoadFailed (:169) hold the load-failed state back while a lookup is still in flight (doc :165-168, tag U-35), and ScopeFailureShown (:172) does not count an unresolved scope as a failure when the offline snapshot is on screen. [Rubric §29, Resilience & Business Continuity] and [Rubric §11, Security].
    6. A deep link that beats saved state, unless the reader is signed out. [SupplyParameterFromQuery(Name = "mine")] (:64-65) carries the MAUI head's home-screen quick action into the My Schedule view, and OnInitializedAsync applies it after the base has restored saved page state so intent wins (:101-103). When the reader is not authenticated, the interactive pass redirects to sign in instead of showing the full programme under a "My Schedule" title (:105-112, tag A-23): RedirectToLogin (:140-144) sets _redirectingToLogin and navigates to /login with a returnUrl back to ?mine=true, and FetchSessionsAsync returns an empty page while that flag is set (:240-243). [Rubric §25, Navigation & Information Architecture] and ADR-042 Wave 2.
    7. A restored My Schedule is not an intent (L155). ClearMyScheduleForAnonymousReader (:88-94) drops _showMyScheduleOnly once the reader is known to be anonymous (_readerKnownAnonymous, :54, set at :135). It runs after the initial load (:136) and again at the end of RestoreFilters (:78-84, remark :77), because the base page's later sessionStorage restore can re-apply a stale flag. [Rubric §25, Navigation & Information Architecture].
    8. The offline snapshot lives in the service, not the page. The section comment (:295-297) states the division: IPublicSessionScheduleService keeps the last successful first page of the programme so a dead venue network still shows a schedule, and only the banner stays here. The page passes OnCacheStateChanged into the fetch (:269) and that handler flips ShowingCachedData (:307), calling StateHasChanged() only on the raise, because the clear rides the render the grid does for the fresh rows anyway (:305-312). The chip itself is markup (PublicSessionList.razor:23-29). [Rubric §29, Resilience & Business Continuity] and ADR-042 Wave 3.
  • Walkthrough
    • SaveFilters / RestoreFilters (:71-84) delegate to PublicSessionListFilterState, reading and writing the catalog's SelectedEventId, EventFilterResolved and SelectedRoomId rather than page fields.
    • LoadEventsAndResolveDefaultAsync (:148-159): one call to _catalog.LoadAsync (:150), then a single toast for whichever read failed, the events error taking precedence over the speakers error (:151-158). One children-loaded events fetch plus one speaker lookup replace per-row enrichment calls. [Rubric §23, Front-End Performance & Rendering].
    • Filter handlers (:182-204) each update one value (the search string, the catalog's selection, or the My Schedule flag) and call ReloadViewAsync (:206), which forwards to the view child's ReloadAsync() and no-ops when the child is not yet rendered.
    • ApplyAdditionalFilters (:314-315) forwards to _catalog.ApplyFilters(filters, _searchString); the Title, EventId and RoomId filters are described under PublicSessionEventCatalog. The room branch is worth reading against PublicSpeakerList: Session.RoomId is a real nullable column, so it rides the generic filter pipeline with no virtual-key interception in the controller, unlike the speaker page's EventId.
    • FetchMobilePage (:318-327) awaits the same EnsureEventScopeAsync (:320), builds the same filters, and reuses FetchSessionsAsync, passing an explicit title-ascending sort (:326) so the infinite-scroll list matches the desktop grid's initial sort; the API's own default is start time. Both layouts share one fetch implementation including its offline path and its fail-closed guard.
    • The optional Engagement service is resolved with GetService (:98) for the same reason as on PublicSessionDetail: [Inject] has no optional mode (:35-36).
  • Why it's built this way: this is the highest-traffic page of the conference, viewed on bad networks by both anonymous browsers and signed-in attendees managing a personal schedule. That drives every design decision visible here: server-side everything, one enrichment fetch, ordering guarantees around the grid's eager first call, newest-load-wins when loads overlap, an audience-locked event filter that refuses to run unscoped when its scope could not be loaded, a room filter derived from data already in hand, and a cached last-known-good first page owned by the service rather than the page.
  • Where it's used: the /conference/sessions route (PublicSessionList.razor:1, matching ConferenceRoutePaths.PublicSessions at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:47), including the ?mine=true deep link; it renders PublicSessionListFilterBar (PublicSessionList.razor:11-21) and PublicSessionListView (:31) and routes onward to PublicSessionDetail.

ScorePollSignal

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollTracker.cs:8 · Level 0 · enum (internal)

  • What it is: the five-valued verdict that ScorePollTracker returns for one observation of the AI-scoring poll loop. It says what should happen next: keep waiting, re-render with fresh data, or stop and report an outcome.
  • Depends on: nothing. It is a bare internal enum declared above the tracker in the same file (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollTracker.cs:8), visible to the bUnit test project through the project's InternalsVisibleTo (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/MMCA.ADC.Conference.UI.csproj:9).
  • Concept introduced: the decision/effect split. [Rubric §18, UI Architecture] assesses whether components stay thin and free of tangled control flow; naming each outcome as an enum member is how that is achieved here. The decision (what did this poll mean?) is computed by a pure state machine, ScorePollTracker, and the effects (toast, re-render, stop the loop) are applied in a single switch inside ScorePollSession, so neither half has to know the other's internals. [Rubric §14, Testability] follows for free: the signal sequence for a synthetic count series can be asserted without rendering anything.
  • Walkthrough: Continue (ScorePollTracker.cs:11) means nothing changed this tick, keep polling. Progressed (:12) means new scores arrived, so apply the fresh board, re-render, and keep polling. CompletedAll (:15) means every session now has a score, so apply and finish successfully. CompletedStable (:19) means the count has been unchanged long enough to call scoring done, with the success-versus-partial wording decided from coverage. GaveUpNoScores (:22) means no score was ever produced inside the zero-progress budget, so fail loudly rather than wait out the full cap.
  • Why it's built this way: the poll loop has four terminal outcomes that each need a different user-facing message (complete, partial, timed out, never started). An enum makes the exhaustive switch in ScorePollSession readable and keeps that failure vocabulary in one place.
  • Where it's used: returned by ScorePollTracker.RegisterFetch (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollTracker.cs:96) and consumed by ScorePollSession.HandleSignalAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollSession.cs:138-168).

SessionSelectionDisplay

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionDisplay.cs:13 · Level 1 · class (static, internal)

  • What it is: a stateless helper holding the display and filter-matching rules shared by the session-selection dashboard and its two presentational sub-components. It answers four kinds of question with no side effects: what color a score chip should be, whether a locality tier counts as local or is the "Unknown" sentinel, and whether a session passes the active score-tier or status filter. Status chip colors now come from SessionStatusDisplay.
  • Depends on: the MudBlazor Color enum (external NuGet, imported at SessionSelectionDisplay.cs:2) and SessionStatuses for the eligibility rule. No other first-party types: it is deliberately dependency-light so both SessionSelectionSpeakerOverlap and SessionSelectionAiScores can call the same predicates, and so can the page markup itself.
  • Concept introduced: extracting view logic into testable pure functions. [Rubric §18, UI Architecture] rewards keeping decision logic out of .razor markup so it can be unit-tested and reused; [Rubric §14, Testability] is the same point from the other side. Every method here is static and total (each switch has a default arm), so the same input always yields the same color or boolean regardless of component state.
  • Walkthrough: UnknownLocalityTier (SessionSelectionDisplay.cs:19, the literal "Unknown") is the tier name the dashboard handler reports for a speaker with no resolvable locality; it is a sentinel rather than data, so IsUnknownTier (:21-22, an ordinal equality test) lets the page swap in the localized Chip.Unknown label instead of showing the raw string. IsLocalTier (:24-27) folds three locality substrings (Atlanta, Georgia, Surrounding, all matched with StringComparison.OrdinalIgnoreCase) into one "is this speaker local" test. GetScoreColor (:29-35) buckets a decimal score into four bands (at or above 8.0 success, 6.0 info, 4.0 warning, otherwise error). ScoreMatchesFilter (:37-48) turns a filter token ("9.0" down to "3.0", plus "<3.0") into a threshold predicate, with an unrecognized token matching everything; <3.0 is the only strict-less-than case. MatchesAcceptedFilter (:50-51) now compares against SessionStatuses.Accepted instead of a literal, and SessionMatchesStatus (:53-56) delegates the null-status case to SessionStatuses.IsEligible rather than inlining the null-or-Accepted check; every other filter is still a plain case-insensitive equality test.
  • Why it's built this way: the two sibling sections filter over different DTO shapes but must agree on what "score tier 8.0" or "status Accepted" means; hoisting the rules here guarantees they never drift apart. Routing the status literal and the eligibility rule through SessionStatuses keeps this file and SessionSelectionFilterOptions from drifting on what "Accepted" means. The tier tokens it understands are exactly the values SessionSelectionFilters.ScoreTierOptions offers in the picker.
  • Where it's used: by SessionSelectionSpeakerOverlap (SessionMatchesStatus and ScoreMatchesFilter, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionSpeakerOverlap.razor.cs:62 and :69), by SessionSelectionAiScores (MatchesAcceptedFilter and ScoreMatchesFilter, .../SessionSelectionAiScores.razor.cs:48 and :63), and directly from markup for chip coloring: .../SessionSelectionAiScores.razor:72 and :76, .../SessionSelectionSpeakerOverlap.razor:45, :61, :65, and the locality tiles on the page itself (.../SessionSelectionDashboard.razor:194).

ScorePollTracker

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollTracker.cs:33 · Level 1 · class (sealed, internal)

  • What it is: the pure state machine behind the dashboard's fire-and-forget AI-scoring poll loop. It counts progress, stability, zero-progress polls, and consecutive failures, and converts each observation into a ScorePollSignal. It performs no I/O and touches no UI.
  • Depends on: nothing first-party except the co-located ScorePollSignal enum it returns and, for CountScoredSince, SessionAiScoreDTO; its own state is four int fields.
  • Concept introduced: taming a fire-and-forget loop with an explicit budget. [Rubric §12, Performance and Scalability] and [Rubric §29, Resilience and Business Continuity] both ask whether long-running work has bounded cost and a defined give-up path, and this class is where those bounds are written down as named constants instead of being scattered through a component. The class doc names the motivation directly (ScorePollTracker.cs:27-32): the state machine was extracted from the page code-behind so the component keeps only the UI side effects, which is the [Rubric §18, UI Architecture] concern about component size. [Rubric §14, Testability] applies because the whole loop policy can be exercised by calling RegisterFetch with a synthetic count series, with no timers and no rendering.
  • Walkthrough: two public constants set the outer limits. MaxPolls = 225 (ScorePollTracker.cs:36, documented as a 30-minute cap at 225 polls times an 8-second interval) and MaxConsecutiveFailures = 5 (:41), whose doc comment explains why failures are tolerated at all: the polling task is fire-and-forget, so an escaping exception would be unobserved and would wedge the Score button until a full reload. Two private constants set the inner heuristics, both widened by a later fix: ZeroProgressLimit = 15 (:49, roughly two minutes with no score written by this run, sized to outlast the 60-second internal-command poll that picks a scoring run up plus its first call, or a healthy run reads as producing nothing) and StablePollsForCompletion = 6 (:55, roughly 48 seconds, sized to outlast one scoring call's 30-second framework timeout plus its save, or a single slow call mid-pass reads as the end of the run). Four fields carry the state (:57-60): _previousCount, _stablePolls, _zeroProgressPolls, _consecutiveFailures. ResetFailures (:63) zeroes the failure counter after any successful fetch; RegisterFailure (:69-73) increments it and returns true once the budget is exhausted. CountScoredSince(scores, baseline) (:75) is a static helper added alongside the widened limits: it counts a session as scored-this-run when it is missing from the baseline dictionary or its ScoredOn moved, because the scoring runner replaces each session's score in place, so the raw dashboard count cannot tell a re-run's progress from its starting point. RegisterFetch(currentCount, totalSessions) (:82-108, taking the scored-this-run count rather than the raw dashboard count) is the core: a zero count increments _zeroProgressPolls and returns GaveUpNoScores at the limit, otherwise Continue (:84-90); any nonzero count clears the zero-progress counter (:92); a count greater than the previous one advances _previousCount, resets _stablePolls, and returns CompletedAll when the count has reached totalSessions (guarded by totalSessions > 0) or Progressed otherwise (:94-101); an unchanged count increments _stablePolls and returns CompletedStable at the (now six-poll) stability window (:103-110); anything else returns Continue (:112).
  • Why it's built this way: server-side AI scoring is a batch whose duration depends on an external model, so the UI has no completion event to await and must infer completion from the score count. Treating completion as either full coverage or a run of unchanged polls yields an answer even when some sessions fail to score, and the separate zero-progress budget turns the common credential-failure case into a fast, loud error rather than a long silence. Both windows were widened, and CountScoredSince was added, once a re-run over an already-scored event was found to read the flat raw count as instant stability and report every session it had not yet reached as failed.
  • Where it's used: instantiated once per scoring run by ScorePollSession.RunAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollSession.cs:57), whose for loop bounds itself with ScorePollTracker.MaxPolls (:57); CountScoredSince is called from the same file (:64).
  • Caveats / not-in-source: the latency claim in the MaxPolls comment ("enough for ~200+ sessions at typical Haiku latency", ScorePollTracker.cs:35) is a code comment, not a measurement recorded in this repo.

ScorePollHost

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollSession.cs:19 · Level 4 · record (sealed, internal)

  • What it is: the five-callback contract a ScorePollSession uses to reach back into the page that started it. The polling session owns no rendering: everything it needs from the component (the current load generation, the poll cadence, applying a board, re-rendering, finishing with a message) arrives as a delegate in this record.
  • Depends on: SessionSelectionDashboardDTO (the payload of ApplyFreshDashboard) and ToastSeverity (the severity of the finish message), plus BCL Func<>, Action<>, Task, and TimeSpan. It is a positional record with no body.
  • Concept introduced: the callback record as an inverted UI port. [Rubric §1, SOLID] assesses dependency inversion and interface segregation: rather than handing the loop a reference to the whole component (which would let it touch any field and would make it untestable outside a renderer), the component passes exactly the five capabilities the loop needs, each as its own delegate. [Rubric §19, State Management] is the reason this shape exists at all: two of the five members are readers, not values. CurrentGeneration and PollInterval are Func<> rather than captured snapshots so the loop re-reads the page's live state on every tick, which is what lets an event switch supersede a running session mid-flight and lets a bUnit test shrink the cadence after the loop has already started.
  • Walkthrough: CurrentGeneration (ScorePollSession.cs:20) reads the page's _loadGeneration counter, the value the loop compares its own generation against to decide it has been superseded. PollInterval (:21) reads the page's cadence, re-read every tick (:15). ApplyFreshDashboard (:22) is an Action<SessionSelectionDashboardDTO, int>: it hands a freshly polled SessionSelectionDashboardDTO to the page together with the number of scores the current run has written so far (:16), and the page swaps the board in, stores that count for the progress display, and recomputes the derived state. The count travels separately because a rescore rewrites each session's score in place, so the board's own score list cannot measure the run's progress. RenderAsync (:23) is the component's InvokeAsync(StateHasChanged), so the re-render is marshalled onto the renderer's synchronization context from whatever thread the loop resumed on. FinishScoring (:24) ends the scoring session and surfaces the outcome as a toast, taking the message and a ToastSeverity.
  • Why it's built this way: the doc comment states the rule (:9-13): the loop raises side effects, the page performs them, so StateHasChanged, the toasts, and the generation counter that supersedes a stale session all stay on the component. Making the port a record of delegates instead of an interface keeps the wiring to one expression at the call site and avoids a second implementation existing only for tests.
  • Where it's used: constructed inline by SessionSelectionDashboard.PollForScoresAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionDashboard.razor.cs:261-266) and consumed throughout ScorePollSession.

SessionSelectionAiScores

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionAiScores.razor.cs:12 · Level 4 · class (partial component)

  • What it is: the presentational AI-scores section of the selection dashboard. It renders the "Score Sessions with AI" action with its in-progress state and the per-session score table, narrowed by the parent's active filters. The scoring flow itself stays on the containing page and is triggered upward through an EventCallback.
  • Depends on: SessionSelectionDashboardDTO and its SessionAiScoreDTO rows (imported at SessionSelectionAiScores.razor.cs:2), SessionSelectionDisplay for the shared predicates, and the Blazor [Parameter] and EventCallback infrastructure from Microsoft.AspNetCore.Components (external, :1).
  • Concept introduced: lifting the action up via EventCallback. [Rubric §19, State Management] favors child components that raise intent rather than own the operation; here the child never calls a service. The scoring trigger is exposed as [Parameter] public EventCallback ScoreRequested (:24) alongside an IsScoring flag the parent flips (:15) and a ScoredThisRun count the parent feeds from the polling run (:22), so the long-running scoring loop, its cancellation, and its toasts all live in SessionSelectionDashboard while this section only shows the button and the progress state. Like its sibling it is otherwise a pure function of its parameters (:14-29): the whole Dashboard DTO, the scoring flag and count, plus the same five filter strings, each defaulting to empty.
  • Walkthrough: ScoredThisRun (:22) carries its reason in its doc comment (:17-21): a rescore rewrites each session's score in place, so Dashboard.AiScores still holds every previous score while the run is in flight, and only this count measures the run's progress. ScoredShown (:31) clamps the count to Dashboard.TotalSessions, and ScoringPercent (:33-34) turns it into a percentage, guarding a zero total. The markup's in-progress alert renders both: the "N / total sessions scored" line and a named progress bar whose share also reads as text (.../SessionSelectionAiScores.razor:20-39). HasActiveFilters (:36-39) is a cheap short-circuit over the five filter strings. FilteredAiScores (:41-53) returns an empty list when the board has no scores yet (:45-46), returns Dashboard.AiScores untouched when no filter is active (:48-49), and otherwise materializes ApplyAiScoreFilters into an array with a collection expression (:51). ApplyAiScoreFilters (:55-77) is a straight pipeline of Where clauses over the flat score rows: status through a single call to SessionSelectionDisplay.SessionMatchesStatus (:59-62); locality against the row's SpeakerLocalities collection (:64-65); category against SessionCategories (:67-68); level as a case-insensitive equality on SessionLevel (:70-71); and score tier via ScoreMatchesFilter on OverallScore (:73-74). The markup colors each row's status and score chip through SessionStatusDisplay and SessionSelectionDisplay (.../SessionSelectionAiScores.razor:78 and :82).
  • Why it's built this way: the score table is a flat DTO list, so its filter pipeline is simpler than the speaker section's nested rebuild; sharing SessionSelectionDisplay keeps the two sections' notion of "matches this filter" identical even though their data shapes differ. Keeping the pipeline lazy until one final materialization avoids an intermediate array per filter stage ([Rubric §23, Front-End Performance]).
  • Where it's used: rendered inside SessionSelectionDashboard's markup (.../SessionSelectionDashboard.razor:241-249), which binds ScoredThisRun to the page's _scoredThisRun (:243) and its ScoreRequested callback to the page's ScoreSessionsAsync (.../SessionSelectionDashboard.razor.cs:164); covered by SessionSelectionAiScoresTests in the Conference UI bUnit tier (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Sessions/Selection/SessionSelectionAiScoresTests.cs, which sets ScoredThisRun at :129).

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SessionSelectionFilterOptions

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionFilterOptions.cs:12 · Level 4 · record (sealed, internal)

  • What it is: the four dropdown option lists the dashboard's filters bind to (statuses, localities, categories, levels), together with the pure projection that derives them from a loaded board. It holds no page state: it is the answer to "given this dashboard, what can the organizer filter by?".
  • Depends on: SessionSelectionDashboardDTO as the projection input (imported at SessionSelectionFilterOptions.cs:2), reading its SpeakerOverlap.Speakers, AiScores, SpeakerLocality, and CategoryDistribution.Categories collections. Externally it is LINQ plus StringComparer.OrdinalIgnoreCase.
  • Concept introduced: the derived-option projection as a static factory on a record. [Rubric §19, State Management] distinguishes stored state from derived state; option lists are derived, so they are computed by one static method from the board and never edited afterwards. [Rubric §14, Testability] follows: the whole "what shows up in the pickers" rule set is one pure function of a DTO. The four IReadOnlyList<string> members (:12-15) are positional record parameters, so instances are immutable by construction.
  • Walkthrough: a private static readonly Empty instance (:17) is the null-board answer, so callers never get null lists and no allocation happens on the not-yet-loaded path. From(SessionSelectionDashboardDTO?) (:20-54) returns Empty for a null board (:22-25), then derives each list. Statuses (:28-33) come from the union of every multi-session speaker's sessions and every AI-score row, with null normalized to SessionStatuses.Accepted (the shared constant, replacing a literal), then de-duplicated and ordered case-insensitively. Localities (:35-37) are the LocalityTier values from the board's locality breakdown, ordered the same way. The level group is picked out by a CategoryTitle.Contains("Level") match (:39-40), and its items become the level list (:42-44); if no such group exists the list is empty. Categories (:46-51) then take every other category group's items, so the Level values do not appear twice in two different pickers, de-duplicated and ordered. All four use collection expressions over LINQ, materializing once at the end.
  • Why it's built this way: the option lists must reflect only what the loaded event actually contains, otherwise the organizer can select a filter that matches nothing. Deriving statuses from both the speaker-overlap sessions and the score rows covers both child sections, and splitting the Level group out of the category options mirrors the two separate pickers in the markup (.../SessionSelectionDashboard.razor:90-107).
  • Where it's used: called only by SessionSelectionFilters.ComputeOptions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionFilters.cs:74), which copies the four lists onto the page's filter state.
  • Caveats / not-in-source: the level group is identified by a title substring match, so it depends on the conference's category naming ("Level"). The category titles themselves come from the Conference data, not from this file.

ScorePollSession

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/ScorePollSession.cs:36 · Level 5 · class (sealed, internal)

  • What it is: one fire-and-forget AI-score polling run for the selection dashboard. It drives ScorePollTracker (the pure counters) over repeated dashboard fetches and turns each resulting ScorePollSignal into the page's UI side effects through a ScorePollHost.
  • Depends on: ISessionSelectionUIService for the fetch, IStringLocalizer for the outcome messages, and a ScorePollHost for the callbacks, all primary-constructor parameters (ScorePollSession.cs:36-39). Internally it uses ScorePollTracker, ScorePollSignal, SessionSelectionDashboardDTO's SessionAiScoreDTO rows, and ToastSeverity; externally Task.Delay, CancellationToken, and the Conference EventIdentifierType/SessionIdentifierType aliases.
  • Concept introduced: the generation guard against a superseded async loop. [Rubric §19, State Management] asks whether asynchronous work can corrupt state it no longer owns. A polling run belongs to the event that was selected when scoring started; if the organizer switches events, the page bumps a generation counter and this loop, which re-reads that counter through host.CurrentGeneration(), exits instead of painting the previous event's board (and its toasts) over the new selection. Note the counter is checked twice per tick, once after the delay and once after the fetch (:70-73 and :76-79), because both are await points where the selection can move. [Rubric §29, Resilience and Business Continuity] covers the second concept here: because ISessionSelectionUIService answers every non-2xx response and transport fault with a failed Result rather than an exception (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/Selection/ISessionSelectionUIService.cs:11-23), the consecutive-failure budget has to be fed from the result path, and the code comment says exactly why (:81-85): left on the exception path it would be dead code and a persistently failing endpoint would poll silently to MaxPolls without ever reporting.
  • Walkthrough: RunAsync(eventId, baseline, generation, cancellationToken) (:51-121) takes a baseline (each already-scored session's ScoredOn before the run started, documented at :48) so progress is measured as scores this run wrote, not the raw dashboard count. It creates a tracker (:57) and two locals, lastFresh and lastScoredThisRun (:62-63), remembering the last board a poll fetched: the accepted run cleared the replaced scores from view, so every exit path must put the server's board back (the code cites OBS-14), otherwise the board would read "No AI scores yet." until a reload. It then loops up to ScorePollTracker.MaxPolls times (:65). Each iteration awaits host.PollInterval() under the page's disposal token (:69), checks the generation (:70-73), fetches the board through selectionService.GetDashboardAsync (:75), and checks the generation again (:76-79). A failed Result feeds tracker.RegisterFailure(), and when the budget is exhausted the run calls RestoreLastBoard, reports Snackbar.ScoringPollFailed as an error and returns (:86-91); a failure inside budget falls through to continue because TryGetValue yields nothing (:93-96). A successful fetch resets the failure counter (:98), computes scoredThisRun via ScorePollTracker.CountScoredSince(fresh.AiScores, baseline) (:99), records it with the board in the two locals (:100-101), and passes it (not the raw count) to both tracker.RegisterFetch and HandleSignalAsync; a true return means the run is over and the finish callback has already fired (:102-105). OperationCanceledException exits quietly, which is component disposal (:107-111). Falling out of the loop means the cap was reached, so, unless the run has meanwhile been superseded (:114-117), it restores the last board and reports Snackbar.ScoringTimedOut as a warning (:119-120). RestoreLastBoard (:126-132) calls host.ApplyFreshDashboard(lastFresh, scoredThisRun) only when some poll succeeded. HandleSignalAsync(fresh, scoredThisRun, signal) (:142-172) is the effects half of the split introduced under ScorePollSignal: GaveUpNoScores now applies the fresh board first (the run wrote nothing new but the server still holds the earlier scores) and then finishes with an error (:146-150); Progressed calls host.ApplyFreshDashboard(fresh, scoredThisRun) and re-renders but keeps going (:152-155); CompletedAll applies the board with the same count, re-renders, and finishes with a success message carrying scoredThisRun (:157-161); CompletedStable applies the board with the count and defers to FinishStable(fresh, scoredThisRun) (:163-166); and Continue does nothing (:168-170). Every apply passes scoredThisRun through the ScorePollHost callback, which is how the page's progress display learns the run's own count rather than inferring it from a score list that still holds the previous run's rows. FinishStable(fresh, currentCount) (:177-190) compares the caller-computed count against TotalSessions and reports either Snackbar.ScoringPartial with the missed count as a warning or Snackbar.ScoringComplete as a success.
  • Why it's built this way: the class doc states the placement rule (:26-32): the session was extracted from the page code-behind under the rubric §18 line cap, and it stays beside the page rather than moving into ISessionSelectionUIService because every step it takes is a rendering decision, not a data-access one. Every user-visible string is a resource key resolved through the injected localizer, so the outcome messages follow a language switch like the rest of the page (ADR-027, Website/docs-src/adr/027-multi-locale-i18n.md).
  • Where it's used: constructed and run by SessionSelectionDashboard.PollForScoresAsync, which passes the caller's baseline snapshot to RunAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionDashboard.razor.cs:270). Its supersede behavior is asserted end to end by WhenEventChangesMidScoring_ThePollingSessionStopsWithoutTouchingTheNewBoard (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Sessions/Selection/SessionSelectionStaleResponseTests.cs:217).
  • Caveats / not-in-source: the resource keys used here (Snackbar.ScoringPollFailed, Snackbar.ScoringNoScores, Snackbar.ScoringComplete, Snackbar.ScoringPartial, Snackbar.ScoringTimedOut) resolve against the page's localizer, which the caller supplies; their text lives in SessionSelectionDashboard.resx and SessionSelectionDashboard.es.resx, not in this file.

SessionSelectionFilters

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionFilters.cs:13 · Level 5 · class (sealed, internal)

  • What it is: the dashboard's filter state object. It holds the five selected values the markup two-way binds to (an empty string means "all") plus the four option lists derived from the loaded board, and it exposes the localized score-tier choices.
  • Depends on: SessionSelectionFilterOptions for the derivation, SessionSelectionDashboardDTO as the input to ComputeOptions, and IStringLocalizer (external, SessionSelectionFilters.cs:1) for the tier labels.
  • Concept introduced: the bindable state object as a component field. [Rubric §19, State Management] is the whole point: five separate _filterX fields on the page would be five things to reset, five things to pass down, and five things to keep in step with their option lists. Collapsing them into one object lets the markup bind @bind-Value="_filters.Status" directly (.../SessionSelectionDashboard.razor:70) and lets the page reset or recompute everything with one call. [Rubric §18, UI Architecture] is the stated motive in the class doc (:6-12): extracted from the code-behind under the line cap so the component keeps only its load and scoring orchestration. [Rubric §27, i18n] shows in ScoreTierOptions, which is a method taking a localizer rather than a stored list, "so the labels follow a language switch" (:42-45, ADR-027).
  • Walkthrough: the five selections are plain get; set; string properties defaulting to empty (:16-28): Status, Locality, Category, Level, ScoreTier. The four option lists (:31-40) are IReadOnlyList<string> with private set, so only this class can replace them; they start empty. ScoreTierOptions(IStringLocalizer) (:47-58) is static and returns nine label/value tuples pairing a localized label with the token SessionSelectionDisplay.ScoreMatchesFilter understands: the empty string for "all", then "9.0" down to "3.0", then "<3.0" for Poor; the markup enumerates it directly (.../SessionSelectionDashboard.razor:112). Reset() (:61-68) clears all five selections back to "all". ComputeOptions(SessionSelectionDashboardDTO?) (:72-79) delegates the projection to SessionSelectionFilterOptions.From and copies its four lists across, so a null board yields four empty lists rather than a null reference.
  • Why it's built this way: separating selection (mutable, bound to the UI) from projection (pure, derived from data) means a reload can recompute the options without disturbing the selections, and a fresh load can reset both with two calls. The page uses exactly that asymmetry: SessionSelectionDashboard.LoadDashboardAsync calls Reset() then ComputeOptions(...) for a new board (.../SessionSelectionDashboard.razor.cs:133-134), while ApplyFreshDashboard calls only ComputeOptions(...) mid-poll (:240), so the organizer's active filters survive a polling update.
  • Where it's used: one instance is a readonly field on SessionSelectionDashboard (.../SessionSelectionDashboard.razor.cs:43), bound by the five MudSelect pickers in the page markup (.../SessionSelectionDashboard.razor:70-115) and passed down as parameters to SessionSelectionSpeakerOverlap (:221-225) and SessionSelectionAiScores (:231-235).

SessionSelectionSpeakerOverlap

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionSpeakerOverlap.razor.cs:11 · Level 7 · class (partial component)

  • What it is: the presentational "speakers with multiple sessions" section of the selection dashboard. It lists each multi-session speaker with their locality and per-session status and score chips, narrowed to whatever filters the parent dashboard currently has active.
  • Depends on: MultiSessionSpeaker and SpeakerSessionSummary (the DTOs it renders, imported at SessionSelectionSpeakerOverlap.razor.cs:2), SessionSelectionDisplay for the shared predicates, the SessionIdentifierType alias, and the Blazor [Parameter] infrastructure (external, :1).
  • Concept introduced: the presentational (dumb) child component. [Rubric §19, State Management] distinguishes components that own state from components that only render state passed in; this is the second kind. It has no service injections and no mutable fields, only [Parameter] inputs (:13-19): the Speakers list, an AiScoreLookup dictionary from session id to score, and the five filter strings, each defaulting to empty. All state flows down from SessionSelectionDashboard, which makes this component a pure function of its parameters. [Rubric §18, UI Architecture] is served by keeping the filtering in the code-behind and the template thin.
  • Walkthrough: HasActiveFilters (:21-24) is a cheap short-circuit: when every filter string is empty the component returns Speakers unfiltered and only sorts. FilteredSpeakerOverlap (:26-36) is the computed view the markup binds to; it applies filters when any are set, then orders speakers case-insensitively by name (:34). ApplySpeakerFilters (:38-59) works in two passes: the locality filter drops whole speakers by comparing LocalityCategory ?? "Unknown" against the selection (:42-46); then, if any session-level filter is set, a record with expression rebuilds each speaker's Sessions collection keeping only matching sessions, and speakers left with zero sessions are dropped (:48-56). SessionMatchesFilters (:61-65) ands together the status test (delegated to SessionSelectionDisplay.SessionMatchesStatus), a category and a level test that both search the session's CategoryItemNames, and the score-tier test. SessionMatchesScoreTier (:67-69) looks the session up in AiScoreLookup and returns false when the session has no score yet, so an active score-tier filter hides unscored sessions.
  • Why it's built this way: rebuilding the speaker record with a filtered Sessions list (rather than hiding rows in markup) means the "drop empty speakers" rule and the sort both operate on already-filtered data, so the rendered list and any counts derived from it stay consistent. The with copy leaves the source DTOs untouched, which matters because the parent keeps rendering from the same board.
  • Where it's used: rendered inside SessionSelectionDashboard's markup (.../SessionSelectionDashboard.razor:219-225), fed the board's speakers, the page's _aiScoreLookup, and the five values off SessionSelectionFilters; covered by SessionSelectionSpeakerOverlapTests in the Conference UI bUnit tier.

SessionSelectionDashboard

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionDashboard.razor.cs:16 · Level 9 · class (partial component)

  • What it is: the organizer decision-support page for choosing a conference program. It picks an event (defaulting to the current or next one), loads its decision-support DTO (category distribution, speaker overlap, content similarity, locality breakdown, AI scores), owns the filter state the two child sections read, and starts the asynchronous "score all sessions with AI" flow.
  • Depends on: ISessionSelectionUIService (loads the board and starts scoring), IEventLookupService (the event picker source), IToastService, and the BCL TimeProvider (the clock behind the default-event pick), all injected as properties (SessionSelectionDashboard.razor.cs:18-21); SessionSelectionDashboardDTO, EventInfo, CurrentEventSelector, ConferenceRoutePaths, ToastSeverity, and the co-located SessionSelectionFilters, ScorePollSession, and ScorePollHost. It composes SessionSelectionSpeakerOverlap and SessionSelectionAiScores in its markup, and the page is routed and role-gated in the .razor half (@page "/sessions/selection-dashboard" with [Authorize(Roles = "Organizer")], .../SessionSelectionDashboard.razor:1-2).
  • Concept introduced: the smart (container) component that owns state and lifecycle, and the load-generation counter. This is the counterpart to the two presentational sections above. [Rubric §19, State Management] is fully exercised: the component holds the loaded DTO, the selected event id, the filter object, the _aiScoreLookup, the scoring flag and the current run's score count, and the loading and error flags (:23-48), and passes what the children need down as parameters. The generation counter _loadGeneration (:45) is the concept worth learning here: any await in a Blazor page is a window in which the user can change the selection, so every asynchronous result must prove it is still wanted before it writes anything. The field's doc comment (:39-44) explains why the generation and not the event id is authoritative: switching away from an event and back must still discard the first response even though both carry the same id. [Rubric §18, UI Architecture] is served by splitting a large page into a container, two presentational children, an extracted filter object, and an extracted polling session. [Rubric §14, Testability] shows in internal TimeSpan PollInterval (:243, eight seconds by default), documented as internal precisely so bUnit tests can shrink the cadence and exercise the loop quickly (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Sessions/Selection/SessionSelectionDashboardTests.cs:362). [Rubric §27, i18n] applies throughout: every user-visible string resolves through the injected IStringLocalizer<SessionSelectionDashboard> (.../SessionSelectionDashboard.razor:5) against the co-located SessionSelectionDashboard.resx and its .es.resx translation (ADR-027). [Rubric §11, Security] applies with the usual caveat that the Organizer role attribute is a UX gate; the services behind it enforce authorization independently.
  • Walkthrough: the component implements IDisposable and owns a CancellationTokenSource (:23) that every service call threads through, but never reads .Token off it directly: the private LifetimeToken property (:28) returns _cts.LifetimeToken(), because reading Token from a disposed source throws ObjectDisposedException, whereas Dispose cancels before it disposes, so a load or handler resuming after navigation gets a cancelled token and exits through its OperationCanceledException path (comment at :24-27; the Common architecture rule ComponentsReadTheirTokenThroughLifetimeToken enforces the habit, MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Ui/ArchitectureRules.LifetimeTokens.cs:28). Dispose cancels and disposes it exactly once via the guarded _disposed pattern (:295-317). OnInitializedAsync (:55-95) builds the three breadcrumbs (:57-62), loads the events (:66), and defaults the picker through CurrentEventSelector.SelectCurrentOrNext (:75-80: live now, else next upcoming, else most recently ended), passing TimeProvider.GetUtcNow().UtcDateTime as "now" (:80) from the injected clock rather than reading DateTime.UtcNow directly; then it loads the board. OperationCanceledException is swallowed as disposal and IsLoading is cleared in the finally (:87-94). OnEventSelectedAsync (:97-113) bumps _loadGeneration before the selection moves (:102), so both the load path and the clear path supersede anything in flight. LoadDashboardAsync (:115-162) is the generation pattern in full: it snapshots the generation and the requested id (:122-123), turns the spinner on, fetches with LifetimeToken (:131), and then returns early if superseded (:135-136, with the comment noting that a superseded failure must not paint an error banner over a board that loaded fine); on success it stores the board, calls _filters.Reset() and _filters.ComputeOptions(...), and rebuilds the score lookup (:138-144); and even the finally is generation-guarded so a stale response cannot switch off the spinner a newer load just turned on (:155-161). LocalityTierLabel (:164-167) renders a locality tier for the locality tiles: tier names are data shown as stored, except the handler's "Unknown" sentinel, which SessionSelectionDisplay.IsUnknownTier detects so the page shows the localized Chip.Unknown label the speaker table already uses. RebuildAiScoreLookup (:169-172) projects the score rows into a SessionId to OverallScore dictionary for the speaker section. ScoreSessionsAsync (:174-240) sets _isScoring, captures the current generation and eventId before any await (:184-185, because the picker stays enabled while the start request is in flight, so the answer is judged against the event selected when scoring began), snapshots a baseline dictionary of each visible score's SessionId to its ScoredOn, and resets _scoredThisRun to zero (:191-193); the comment there gives both reasons: the scoring runner rewrites each session's score in place, so progress is only measurable as scores whose timestamp moved, and the scores stay on screen until the server accepts the run, so a failed start leaves the board exactly as it was. It then calls the service with LifetimeToken (:197); if the generation moved while the request was in flight, the run belongs to the old event, so it clears _isScoring and returns without wiping the board or polling (:198-204). A failed Result (including the 409 already-running or queue-full conflict) toasts an error and clears the flag; a SessionsScored == -1 result means the server accepted the work asynchronously, so it toasts "started", only now clears the replaced scores from view with a with expression and rebuilds the lookup so the table and the overlap chips empty together, and launches the fire-and-forget PollForScoresAsync(baseline, eventId, generation) (:213-223); and a synchronous result toasts the scored and failed counts and reloads. The closing comment documents why there is no finally here: only the accepted asynchronous path leaves _isScoring set, handing ownership of the flag to the polling task (every other branch, the superseded one included, clears it, :237-240). PollForScoresAsync(baseline, eventId, generation) (:250-278) takes the event and generation captured at start, rather than re-reading _selectedEventId and _loadGeneration when the task begins, and builds a ScorePollSession with the service, the localizer, and a ScorePollHost wired to _loadGeneration, PollInterval, ApplyFreshDashboard, InvokeAsync(StateHasChanged), and FinishScoring (:258-266; the host's generation callback still reads the live _loadGeneration), runs session.RunAsync(eventId, baseline, generation, LifetimeToken) (:270), and clears _isScoring in a finally so the Score button always comes back whatever path exits the loop. ApplyFreshDashboard(fresh, scoredThisRun) (:280-286) swaps the board in, stores the run's count in _scoredThisRun for the SessionSelectionAiScores progress display (bound at .../SessionSelectionDashboard.razor:243), and recomputes the options and the lookup, deliberately without resetting the filters. FinishScoring (:288-293) clears the flag, shows the toast, and requests a re-render.
  • Why it's built this way: AI scoring is a long, failure-prone batch that depends on an external model with variable latency, so the page cannot block on it and there is no server push channel for this surface. Delegating the loop to ScorePollSession and the counting policy to ScorePollTracker leaves this class holding only lifecycle, state, and UI effects, which is what keeps a page with this much behavior inside the rubric §18 size expectation. Cancelling the token on disposal stops the loop from outliving the page, and the generation counter stops it from outliving its event.
  • Where it's used: the organizer route /sessions/selection-dashboard, reachable from the admin navigation registered by ConferenceUIModule; covered by SessionSelectionDashboardTests and SessionSelectionStaleResponseTests in the Conference UI bUnit tier (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Sessions/Selection/).
  • Caveats / not-in-source: the -1 sentinel on SessionsScored is what distinguishes a deferred scoring start from a synchronous one; its meaning is documented on ISessionSelectionUIService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/Selection/ISessionSelectionUIService.cs:15-20) and produced by the server-side handler, not by this component. The model used for scoring, its per-session timeout, and its failure modes are not determinable from this file.

CategoryItemInfo

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/ICategoryItemLookupService.cs:9 · Level 0 · record

  • What it is: a four-field projection record carrying everything a Conference page needs to render one category item: Id, Name, CategoryId, and the parent category's CategoryTitle (ICategoryItemLookupService.cs:9-13). It is the value type of the category-item lookup dictionary, not a wire contract and not a domain type.
  • Depends on: no first-party types. Its two id parameters use the Conference identifier aliases CategoryItemIdentifierType and ConferenceCategoryIdentifierType, both int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8-9). BCL string.
  • Concept introduced, the UI lookup projection. [Rubric §18, UI Architecture] (assesses whether the front end keeps its own display-shaped models instead of dragging server contracts through the render tree; this record is a UI-owned shape with three display fields and one key). [Rubric §23, Front-End Performance] (assesses avoiding per-row round-trips: a list page holds category item ids but must render category item names, so the page pre-loads a dictionary of these records once and indexes it during render instead of issuing one request per row). [Rubric §19, State Management] (the dictionary is page-local state loaded once, not an ambient store). The same shape repeats for events and speakers, see EventInfo and SpeakerInfo.
  • Walkthrough: a positional record with four members and no body.
    • Id (ICategoryItemLookupService.cs:10): the dictionary key.
    • Name (line 11): the item's display name, for example a track or level value.
    • CategoryId (line 12): the owning category, which lets a page group items by category without a second fetch.
    • CategoryTitle (line 13): the parent category's title, denormalized onto the item. It is filled in by CategoryItemLookupService from a separate categories request and falls back to string.Empty when the parent title is not found (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/CategoryItemLookupService.cs:80-81).
  • Why it's built this way: the denormalized CategoryTitle exists so a page can render the combined label without holding a second dictionary. SessionLookups does exactly that: string.IsNullOrEmpty(item.CategoryTitle) ? item.Name : $"{item.CategoryTitle}: {item.Name}" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionLookups.cs:181), and PublicSessionDetail uses the same expression (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionDetail.razor.cs:218). The empty-title fallback keeps the label sensible rather than rendering a stray colon.
  • Where it's used: produced by CategoryItemLookupService behind ICategoryItemLookupService; consumed by SessionLookups (SessionLookups.cs:35), which serves SessionDetail, by PublicSessionDetail (PublicSessionDetail.razor.cs:29), and, gathered with two sibling dictionaries, by SpeakerDetailLookups.

ChildEntityDeletePath

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:76 · Level 0 · class

  • What it is: an internal static helper with a single generic method that builds the "{childId}?{parent}={parentId}" suffix the join endpoints' DELETE takes (ChildEntityServices.cs:76-87). It is the smallest type in this group and the one that decides whether a removal works at all.
  • Depends on: no first-party types. BCL Convert, CultureInfo.InvariantCulture (ChildEntityServices.cs:1), Uri.EscapeDataString, string.Concat.
  • Concept introduced, the parent-scoped child delete. [Rubric §9, API & Contract Design] (assesses whether the client speaks the URL shape the server actually implements). The join controllers remove a row by loading the OWNING aggregate and asking it to drop the child, so the parent id is not decoration: the class doc records that a DELETE sent without it binds the parent id to its default value, that aggregate is never found, and the API answers 404 while the UI reports a generic failure (ChildEntityServices.cs:14-21, :71-75). [Rubric §15, Best Practices & Code Quality]: the whole difference between a working removal and a 404 lives in this one place instead of being concatenated at four call sites. [Rubric §26, Front-End Security] in its smallest form: the parent id is percent-encoded before it reaches the query string.
  • Walkthrough: one method, For<TChildId, TParentId>(TChildId childId, string parentName, TParentId parentId) (ChildEntityServices.cs:78), constrained where TChildId : notnull and where TParentId : notnull (lines 79-80). The body is a single string.Concat (lines 81-86) that formats the child id with Convert.ToString(..., CultureInfo.InvariantCulture) (invariant so an int id never picks up a culture-specific group separator), appends "?", the caller's parameter name and "=", then the parent id through the same invariant conversion wrapped in Uri.EscapeDataString. Both conversions fall back to string.Empty on a null result. The generic parameters matter because Conference ids are not one type: SpeakerIdentifierType is a System.Guid while every other alias here is int (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7-25).
  • Why it's built this way: the shared ChildEntityServiceBase appends whatever string it is given straight onto the endpoint, var url = $"{endpoint}/{id}" (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:75), so the query has to be baked into the "id" argument. Keeping that formatting in one internal helper rather than in the framework base keeps the base generic while making the Conference-specific convention explicit.
  • Where it's used: all four join services in the same file, each supplying its own parent parameter name: EventSpeakerService with "eventId" (ChildEntityServices.cs:29), SessionSpeakerService with "sessionId" (line 42), SessionCategoryItemService with "sessionId" (line 55), and SpeakerCategoryItemService with "speakerId" (line 68).

ICategoryItemLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/ICategoryItemLookupService.cs:18 · Level 3 · interface

  • What it is: the contract for the category-item lookup: fetch everything once, hand back a dictionary wrapped in a Result (ICategoryItemLookupService.cs:18-22).
  • Depends on: CategoryItemInfo (the dictionary value), the CategoryItemIdentifierType alias, and Result<T> from MMCA.Common.Shared.Abstractions (ICategoryItemLookupService.cs:1). BCL Task, IReadOnlyDictionary, CancellationToken.
  • Concept introduced, the bulk-then-index lookup contract. [Rubric §23, Front-End Performance] (assesses how many round-trips a render costs; one call plus O(1) dictionary reads replaces one request per referenced id) and [Rubric §1, SOLID] (a single-method interface is the smallest dependency a page can take, and it is what the UI tests substitute). Returning IReadOnlyDictionary<,> rather than a list is the point of the contract: the caller is told, by the type, that this is a key-indexed cache to read from, not a collection to search. The Result<T> envelope is the second half: a failed fetch is a value the page branches on, not an exception, which is the workspace-wide Result pattern taught in 00-primer.md.
  • Walkthrough: one member, GetAllAsync(CancellationToken cancellationToken = default) returning Task<Result<IReadOnlyDictionary<CategoryItemIdentifierType, CategoryItemInfo>>> (ICategoryItemLookupService.cs:20-21). The cancellation token is optional and defaulted, so page code can pass its own component-scoped token.
  • Why it's built this way: pages depend on this interface, never on the HTTP class, so the two requests the implementation actually makes (categories, then a 10000-page-size items read) stay an implementation detail (CategoryItemLookupService.cs:22-42).
  • Where it's used: implemented by CategoryItemLookupService, registered scoped at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:60 under the "cross-module lookup services" comment, and injected into SessionLookups (SessionLookups.cs:35), PublicSessionDetail (PublicSessionDetail.razor.cs:29), and SpeakerDetailLookupService (SpeakerDetailLookupService.cs:14).

IEventSpeakerUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/IChildEntityUIService.cs:14 · Level 3 · interface

  • What it is: the UI contract for managing EventSpeaker join rows, that is, attaching a speaker to an event and detaching one (IChildEntityUIService.cs:14-18). It is the first of four structurally identical join contracts declared in this one file.

  • Depends on: EventSpeakerDTO (the created row it returns), the EventIdentifierType, SpeakerIdentifierType, and EventSpeakerIdentifierType aliases, and Result / Result<T>. BCL Task, CancellationToken.

  • Concept introduced, the add/remove-only join contract. [Rubric §9, API & Contract Design] (assesses whether a contract exposes the operations the resource actually supports; a join row has no fields to edit, so there is no Update and no GetById, only Add and Delete). [Rubric §18, UI Architecture] (a page edits an association through a typed service, not by hand-rolling a POST). [Rubric §1, SOLID]: four narrow interfaces instead of one generic IJoinService<,> means each page injects exactly the association it edits, and the implementations keep strongly typed parameter names (eventId, speakerId) rather than two anonymous ids. Note the asymmetry that makes these contracts safe. AddAsync takes the two parent ids, while DeleteAsync takes the owning aggregate's id and the join row's own id, because the API removes a row by loading that aggregate and asking it to drop the child; the doc comment states plainly that a call without the parent id deletes nothing and answers 404 (IChildEntityUIService.cs:8-13). The formatting of that pair is ChildEntityDeletePath's job.

  • Walkthrough

    • AddAsync(eventId, speakerId, ct) (IChildEntityUIService.cs:16): returns Task<Result<EventSpeakerDTO>>, the created row read back from the response body.
    • DeleteAsync(eventId, id, ct) (line 17): takes the event id and the EventSpeakerIdentifierType and returns Task<Result>, a non-generic Result, so a 404 arrives as an ErrorType.NotFound failure rather than as a bare false (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:62-70).
  • The family: all four contracts in this file share that shape and differ only in the ids and the DTO.

    Type File:Line Add parameters Returns / delete key
    IEventSpeakerUIService IChildEntityUIService.cs:14 eventId, speakerId EventSpeakerDTO / eventId + EventSpeakerIdentifierType
    ISessionSpeakerUIService IChildEntityUIService.cs:26 sessionId, speakerId SessionSpeakerDTO / sessionId + SessionSpeakerIdentifierType
    ISessionCategoryItemUIService IChildEntityUIService.cs:38 sessionId, categoryItemId SessionCategoryItemDTO / sessionId + SessionCategoryItemIdentifierType
    ISpeakerCategoryItemUIService IChildEntityUIService.cs:50 speakerId, categoryItemId SpeakerCategoryItemDTO / speakerId + SpeakerCategoryItemIdentifierType
  • Why it's built this way: keeping the four as separate interfaces in one file makes the family obvious to a reader while still giving DI four distinct binding targets (DependencyInjection.cs:34-37). The implementations are equally uniform: each derives from the shared ChildEntityServiceBase and supplies only its resource root (ChildEntityServices.cs:23,36,49,62).

  • Where it's used: implemented by EventSpeakerService (ChildEntityServices.cs:22-23) and registered scoped at DependencyInjection.cs:34.

  • Caveats / not-in-source: unlike its three siblings, this contract has no page consumer in the current source. It is registered and implemented, but no Razor component injects IEventSpeakerUIService today, so the event-to-speaker association is reachable only through the API. The three session and speaker join contracts are all wired into pages.

ISessionCategoryItemUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/IChildEntityUIService.cs:38 · Level 3 · interface

  • What it is: the join contract for tagging a session with a category item and untagging it (IChildEntityUIService.cs:38-42).
  • Depends on: SessionCategoryItemDTO, the SessionIdentifierType, CategoryItemIdentifierType, and SessionCategoryItemIdentifierType aliases, and Result / Result<T>.
  • Concept: the add/remove-only join contract taught at IEventSpeakerUIService, including the family table and the parent-scoped delete. [Rubric §9, API & Contract Design].
  • Walkthrough: AddAsync(sessionId, categoryItemId, ct) returning Task<Result<SessionCategoryItemDTO>> (line 40) and DeleteAsync(sessionId, id, ct) returning Task<Result> (line 41).
  • Where it's used: implemented by SessionCategoryItemService (ChildEntityServices.cs:48-49), registered at DependencyInjection.cs:36, injected into SessionDetail (SessionDetail.razor.cs:33), which pairs it with ICategoryItemLookupService, reached through SessionLookups, to offer the untagged items as choices.

ISessionSpeakerUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/IChildEntityUIService.cs:26 · Level 3 · interface

  • What it is: the join contract for adding a speaker to a session and removing one (IChildEntityUIService.cs:26-30).
  • Depends on: SessionSpeakerDTO, the SessionIdentifierType, SpeakerIdentifierType, and SessionSpeakerIdentifierType aliases, and Result / Result<T>.
  • Concept: see IEventSpeakerUIService for the shared shape and the family table. [Rubric §18, UI Architecture]. This is the one member of the family whose two ids in AddAsync are of different CLR types, an int session and a Guid speaker, which is why the family was written as four typed interfaces rather than one generic pair of ids.
  • Walkthrough: AddAsync(sessionId, speakerId, ct) returning Task<Result<SessionSpeakerDTO>> (line 28) and DeleteAsync(sessionId, id, ct) returning Task<Result> (line 29).
  • Where it's used: implemented by SessionSpeakerService (ChildEntityServices.cs:35-36), registered at DependencyInjection.cs:35, injected into SessionDetail (SessionDetail.razor.cs:32).

ISpeakerCategoryItemUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/IChildEntityUIService.cs:50 · Level 3 · interface

  • What it is: the join contract for tagging a speaker with a category item, which is how speaker attributes such as locality are modeled, and untagging one (IChildEntityUIService.cs:50-54).
  • Depends on: SpeakerCategoryItemDTO, the SpeakerIdentifierType, CategoryItemIdentifierType, and SpeakerCategoryItemIdentifierType aliases, and Result / Result<T>.
  • Concept: see IEventSpeakerUIService. [Rubric §9, API & Contract Design]. Its delete is the one that carries a Guid parent id into the query string, which is why ChildEntityDeletePath is generic and escapes the parent value (ChildEntityServices.cs:68).
  • Walkthrough: AddAsync(speakerId, categoryItemId, ct) returning Task<Result<SpeakerCategoryItemDTO>> (line 52) and DeleteAsync(speakerId, id, ct) returning Task<Result> (line 53).
  • Where it's used: implemented by SpeakerCategoryItemService (ChildEntityServices.cs:61-62), registered at DependencyInjection.cs:37, injected into SpeakerCategoryItemsPanel (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerCategoryItemsPanel.razor.cs:20), the component SpeakerDetail hosts.

CategoryItemLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/CategoryItemLookupService.cs:12 · Level 4 · class (sealed)

  • What it is: the implementation behind ICategoryItemLookupService. It reads the categories in one API call and the items in as many paged calls as the item total needs, then joins them in memory into a dictionary of CategoryItemInfo (CategoryItemLookupService.cs:12-86).
  • Depends on: CategoryItemInfo (what it emits), CategoryItemDTO and ConferenceCategoryDTO (the wire shapes it reads), CollectionResult<T> and PagedCollectionResult<T> (the two envelopes those endpoints return), HttpResultExecutor and ProblemDetailsResultReader (the two halves of the railway conversion), PagedReadAll (the page-until-total loop and the lookup URL format) and IdempotentReadRetry (the transient-failure retry on each GET), and BCL IHttpClientFactory. Note what it does not take: no token storage (CategoryItemLookupService.cs:12), so this is an unauthenticated read, unlike the entity services that derive from AuthenticatedServiceBase.
  • Concept introduced, the client-side join. [Rubric §23, Front-End Performance] assesses whether the front end minimizes round trips for a render, and [Rubric §8, Data Architecture] assesses where denormalization happens. The category-item endpoint returns a CategoryId but not the category's title, and the UI wants the pair. Rather than asking the server for a nested projection or fetching the parent per item, the client pulls both collections in full and does the join itself, producing the denormalized CategoryItemInfo. The cost is one round trip for the categories plus one per 500 items (PagedReadAll.PageSize, MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:22), independent of how many rows the page renders. The second concept here is the two-part railway conversion that every lookup in this family uses: ProblemDetailsResultReader converts a response into a Result with the server's original ErrorType intact, while HttpResultExecutor converts the absence of a response (refused connection, DNS failure, dropped socket, client timeout) into a failure, and deliberately lets caller cancellation propagate as an exception rather than reporting a disposed page as an error (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:12-24). Both halves are what let this method be honestly typed as returning a Result.
  • Walkthrough: one public method, GetAllAsync(ct) (CategoryItemLookupService.cs:15-47), with the whole body wrapped in HttpResultExecutor.ExecuteAsync (:17).
    • Resolves the named "APIClient" HttpClient from the factory in a using (:20), so the per-call handler lifetime is the factory's concern.
    • GETs conferencecategories?includeFKs=false&includeChildren=false through the private GetAsync<T> helper into a CollectionResult<ConferenceCategoryDTO> (:22-25). This one is the non-paged envelope: the category list is small and unpaged.
    • Short-circuits on failure, re-wrapping the errors under the dictionary's type parameter (:27-31), so a failed first call is never silently treated as "no categories".
    • BuildCategoryTitles folds the categories into a Dictionary<ConferenceCategoryIdentifierType, string>, null-guarding the collection (:33, :58-71).
    • Reads every item through PagedReadAll.ReadAllAsync (:37-42), handing it a per-page fetch that GETs PagedReadAll.LookupPageUrl("categoryitems", pageNumber) into a PagedCollectionResult<CategoryItemDTO> (:38-41). That URL is the /paged endpoint in Id ascending order, 500 rows a page, with foreign keys and children excluded (PagedReadAll.cs:43-46). The source comment states why (:35-36): the GET-all endpoint ignores pageSize and stops silently at the API's maximum page size, so only paging to the reported total is unbounded. ReadAllAsync stops on an empty page or once the accumulated count reaches TotalItemCount, and returns the first failing page's errors unchanged (PagedReadAll.cs:57-90).
    • Map projects the accumulated List<CategoryItemDTO> through BuildLookup (:44-45), which loops the items, resolves each parent title with TryGetValue falling back to string.Empty, and stores one CategoryItemInfo per id (:73-85).
    • The private GetAsync<T> (:49-56) is the shared read step for both calls: one IdempotentReadRetry.GetAsync (:54), which re-sends the GET on a transient failure through the same retry policy AuthenticatedServiceBase applies (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/IdempotentReadRetry.cs:18-44), then one ProblemDetailsResultReader.ReadAsync<T> (:55).
  • Why it's built this way: includeFKs=false&includeChildren=false keeps both payloads flat and small, which is the whole point of a lookup fetch, no navigation graphs, only the display fields. The stable Id sort means consecutive pages neither skip nor repeat a row (PagedReadAll.cs:30-34). The retry exists because this service has no base class: without IdempotentReadRetry an anonymous lookup would get a single attempt, and a GET is safe to re-send. The TryGetValue fallback (:80) means a missing or newly added category degrades to an unprefixed label instead of throwing mid-render.
  • Where it's used: registered scoped in AddConferenceUI against ICategoryItemLookupService (DependencyInjection.cs:60); injected into SessionDetail (Pages/Sessions/SessionDetail.razor.cs:31, passed on at :58) and PublicSessionDetail (Pages/Public/Sessions/PublicSessionDetail.razor.cs:27), taken as a constructor argument by SessionLookups (Pages/Sessions/SessionLookups.cs:35), and composed by SpeakerDetailLookupService as the first of its loads (Services/Speakers/SpeakerDetailLookupService.cs:14,22). Its page-1 URL is also one of the lookup paths SelfHttpOutputCacheWarmupTask replays to warm the server's output cache, which is why the URL format is treated as a byte-for-byte contract (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:36-41, PagedReadAll.cs:33-34).
  • Caveats / not-in-source: the item read is bounded by PagedReadAll's runaway guard of 40 pages (PagedReadAll.cs:28), so beyond 20,000 category items the lookup would stop without an error. The categories call sends no page size at all (:24) and is not paged, so it depends on that endpoint returning the full set. Nothing memoizes the result inside this class: each GetAllAsync call re-issues every request, and the scoped registration only means one instance per request or circuit scope, not one fetch. Its sibling EventLookupService does memoize, so the two lookups are not interchangeable on that point.

ICategoryItemUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/ICategoryItemUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the categoryitems REST resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to CategoryItemDTO and CategoryItemIdentifierType (ICategoryItemUIService.cs:9-11).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> (imported at ICategoryItemUIService.cs:2) and CategoryItemDTO (imported at ICategoryItemUIService.cs:1).
  • Concept: identical shape to IActivityUIService; see it for the marker-interface and assembly-scan rationale. [Rubric §18, UI Architecture].
  • Walkthrough: no members. The doc comment (ICategoryItemUIService.cs:6-8) repeats the "uses generic CRUD" formula.
  • Where it's used: implemented by CategoryItemService; injected into ConferenceCategoryItemsPanel, the editor for the items belonging to a category (Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor.cs:24). It is the one CRUD marker in this family with a single consumer: category items are only ever managed from inside their parent category, never as a top-level list. Read-side enrichment of category items goes through the separate ICategoryItemLookupService instead.

IConferenceCategoryUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/IConferenceCategoryUIService.cs:9 · Level 4 · interface

CategoryItemService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/CategoryItemService.cs:10 · Level 5 · class (sealed)

  • What it is: the concrete HTTP service for the categoryitems resource, structurally identical to ActivityService but bound to CategoryItemDTO and CategoryItemIdentifierType (CategoryItemService.cs:10-14). It implements ICategoryItemUIService.
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>, ITokenStorageService, CategoryItemDTO; BCL IHttpClientFactory.
  • Concept: identical to ActivityService; see it for the thin-leaf rationale. The only differences are the resource root "categoryitems" (CategoryItemService.cs:12), the DTO plus identifier alias, and the interface it satisfies. [Rubric §15, Best Practices & Code Quality].
  • Walkthrough: no members. The base call passes "categoryitems" alongside the factory and token storage (CategoryItemService.cs:10-12), and the same line declares ICategoryItemUIService.
  • Where it's used: picked up by the same assembly scan as its siblings (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31) and resolved through ICategoryItemUIService in ConferenceCategoryItemsPanel, the category detail page's items editor (Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor.cs:24).

ConferenceCategoryService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/ConferenceCategoryService.cs:10 · Level 5 · class (sealed)

  • What it is: the concrete HTTP service for the conferencecategories resource, structurally identical to CategoryItemService but bound to ConferenceCategoryDTO and ConferenceCategoryIdentifierType (ConferenceCategoryService.cs:10-14). It implements IConferenceCategoryUIService.
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>, ITokenStorageService, ConferenceCategoryDTO.
  • Concept: identical to ActivityService; see it for the thin-leaf rationale. The only differences are the resource root "conferencecategories" (ConferenceCategoryService.cs:12), the DTO plus identifier alias, and the interface it satisfies. [Rubric §15, Best Practices & Code Quality]. Reading these three classes back to back is the clearest evidence of what the shared base buys: three resources, twelve lines of code, zero duplicated HTTP handling.
  • Walkthrough: no members; the base call passes "conferencecategories" alongside the factory and token storage (ConferenceCategoryService.cs:10-12).
  • Where it's used: picked up by the same assembly scan as its siblings (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31) and resolved through IConferenceCategoryUIService in ConferenceCategoryList (Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16), ConferenceCategoryDetail (Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:23), ConferenceCategoryCreate (Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:13) and ConferenceCategoryItemsPanel (Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor.cs:23).

EventSpeakerService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:22 · Level 5 · class (sealed)

  • What it is: the HTTP service for the EventSpeaker join entity: add (POST) or remove (DELETE) a speaker on an event (ChildEntityServices.cs:22-30). It is the first of four structurally identical join-entity services in one file and implements IEventSpeakerUIService.
  • Depends on: ChildEntityServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:19, hoisted out of this file into the shared namespace, as the trailing comment records at ChildEntityServices.cs:89-90); ChildEntityDeletePath for the delete suffix; ITokenStorageService; EventSpeakerDTO; Result; the EventIdentifierType / SpeakerIdentifierType / EventSpeakerIdentifierType aliases.
  • Concept introduced, the join-entity UI service. A join (association) entity has no rich lifecycle: you create the link or remove it, and there is no detail page and no update. So it does not use the full-CRUD EntityServiceBase<TEntityDTO, TIdentifierType>; it uses the leaner ChildEntityServiceBase, whose whole surface is three protected helpers: PostAsync<TResponse>, which posts and reads the created DTO back (ChildEntityServiceBase.cs:36-44), a valueless PostAsync for an endpoint that answers 204 (:52-60), and DeleteByIdAsync (:70-79). All three create their client through AuthenticatedServiceBase.CreateAuthenticatedClientAsync() (:40,56,74), so the join endpoints carry the same bearer token as their parent CRUD endpoints, and all three wrap the call in HttpResultExecutor and read the response through ProblemDetailsResultReader, so a missing join row arrives as a typed ErrorType.NotFound failure the caller can tell apart from a genuine failure (ChildEntityServiceBase.cs:62-65). [Rubric §18, UI Architecture] (assesses whether pages depend on well-factored typed services rather than raw HttpClient; the tag editor calls one method per direction) and [Rubric §2, Design Patterns] (the same base-plus-thin-leaf factoring as the CRUD family).
  • Concept introduced, the parent id on every removal. The file-level remarks (ChildEntityServices.cs:14-21) are worth reading in full: the join controllers remove a row by loading the parent aggregate and asking it to drop the child, so the parent id in the query string is not decoration. A DELETE sent without it binds the parent id to its default value, that aggregate is never found, and the API answers 404 while the UI reports a generic failure. That is why every DeleteAsync in this file takes the owning aggregate's id as well as the join row's own, and why the suffix is built in exactly one place, ChildEntityDeletePath (ChildEntityServices.cs:76-87), which concatenates the child id, the parent parameter name and the URL-escaped parent id, all formatted with CultureInfo.InvariantCulture (:81-86). [Rubric §15, Best Practices & Code Quality] (assesses whether an easily-wrong detail is centralized instead of repeated at each call site).
  • Walkthrough (ChildEntityServices.cs:22)
    • The base call supplies the "eventspeakers" endpoint (ChildEntityServices.cs:23).
    • AddAsync(eventId, speakerId, ct) (ChildEntityServices.cs:25-26): PostAsync<EventSpeakerDTO> with the anonymous payload new { EventId, SpeakerId }, returning Result<EventSpeakerDTO>.
    • DeleteAsync(eventId, id, ct) (ChildEntityServices.cs:28-29): DeleteByIdAsync over ChildEntityDeletePath.For(id, "eventId", eventId), returning a valueless Result.
  • Why it's built this way: modeling each many-to-many link as its own tiny service over a shared child base keeps the add/remove surface uniform and each payload strongly typed, without the CRUD surface a join row does not need.
  • Where it's used: registered explicitly at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:34 (join services are not IEntityService<,> implementations, so the assembly scan does not see them).
  • Caveats / not-in-source: unlike the CRUD services, none of the ChildEntityServiceBase helpers runs inside the Polly RetryPolicy (the calls at ChildEntityServiceBase.cs:41,57,76 go straight to the HttpClient), so a transient failure on a link edit surfaces immediately rather than being retried. Note also that in the current tree no Conference page injects IEventSpeakerUIService: the interface, this implementation and the DI line are its only references, so the event-speaker link is registered and callable but has no page consumer today.

SessionCategoryItemService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:48 · Level 5 · class (sealed)

  • What it is: the HTTP service for the SessionCategoryItem join entity: add (POST) or remove (DELETE) a category-item tag on a session (ChildEntityServices.cs:48-56). It implements ISessionCategoryItemUIService.
  • Depends on: ChildEntityServiceBase; ChildEntityDeletePath; ITokenStorageService; SessionCategoryItemDTO; Result; the SessionIdentifierType / CategoryItemIdentifierType / SessionCategoryItemIdentifierType aliases.
  • Concept: cross-reference the join-entity mechanics and the parent-id rule taught at EventSpeakerService. [Rubric §18, UI Architecture].
  • Walkthrough (ChildEntityServices.cs:48)
    • The base call supplies the "sessioncategoryitems" endpoint (ChildEntityServices.cs:49).
    • AddAsync(sessionId, categoryItemId, ct) (ChildEntityServices.cs:51-52): PostAsync<SessionCategoryItemDTO>(new { SessionId, CategoryItemId }), returning the created SessionCategoryItemDTO in a Result<T>.
    • DeleteAsync(sessionId, id, ct) (ChildEntityServices.cs:54-55): DeleteByIdAsync over ChildEntityDeletePath.For(id, "sessionId", sessionId), so the owning session id rides along in the query string.
  • Why it's built this way: modeling each many-to-many link as its own tiny service over a shared child base keeps the add/remove surface uniform and each payload strongly typed, without the CRUD surface a join row does not need.
  • Where it's used: registered explicitly at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:36, and injected into SessionDetail's "categories on this session" editor (Pages/Session/SessionDetail.razor.cs:28).

SessionSpeakerService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:35 · Level 5 · class (sealed)

  • What it is: the HTTP service for the SessionSpeaker join entity: add (POST) or remove (DELETE) a speaker on a session (ChildEntityServices.cs:35-43). It is the structural twin of EventSpeakerService, SessionCategoryItemService and SpeakerCategoryItemService, differing only in endpoint, payload keys, DTO and parent parameter name. It implements ISessionSpeakerUIService.
  • Depends on: ChildEntityServiceBase; ChildEntityDeletePath; ITokenStorageService; SessionSpeakerDTO; Result; the SessionIdentifierType / SpeakerIdentifierType / SessionSpeakerIdentifierType aliases.
  • Concept: cross-reference the join-entity mechanics and the parent-id rule taught at EventSpeakerService. [Rubric §18, UI Architecture].
  • Walkthrough (ChildEntityServices.cs:35)
    • The base call supplies the "sessionspeakers" endpoint (ChildEntityServices.cs:36).
    • AddAsync(sessionId, speakerId, ct) (ChildEntityServices.cs:38-39): PostAsync<SessionSpeakerDTO>(new { SessionId, SpeakerId }).
    • DeleteAsync(sessionId, id, ct) (ChildEntityServices.cs:41-42): DeleteByIdAsync over ChildEntityDeletePath.For(id, "sessionId", sessionId).
  • Why it's built this way: same rationale as the other join services, a tiny per-link service over the shared child base keeps the add/remove surface uniform and strongly typed.
  • Where it's used: registered explicitly at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:35, and injected into SessionDetail's "speakers on this session" editor (Pages/Session/SessionDetail.razor.cs:27). The rows it creates are also what the server joins on when SpeakerDashboardService filters sessions by the virtual SpeakerId key.

SpeakerCategoryItemService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Common · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:61 · Level 5 · class (sealed)

  • What it is: the HTTP service for the SpeakerCategoryItem join entity: add (POST) or remove (DELETE) a category-item tag on a speaker, which is the mechanism behind speaker categorization such as locality (ChildEntityServices.cs:61-69). It is the fourth structurally identical join service in the file and implements ISpeakerCategoryItemUIService.
  • Depends on: ChildEntityServiceBase; ChildEntityDeletePath; ITokenStorageService; SpeakerCategoryItemDTO; Result; the SpeakerIdentifierType / CategoryItemIdentifierType / SpeakerCategoryItemIdentifierType aliases.
  • Concept: cross-reference the join-entity mechanics and the parent-id rule taught at EventSpeakerService. [Rubric §18, UI Architecture].
  • Walkthrough (ChildEntityServices.cs:61)
    • The base call supplies the "speakercategoryitems" endpoint (ChildEntityServices.cs:62).
    • AddAsync(speakerId, categoryItemId, ct) (ChildEntityServices.cs:64-65): PostAsync<SpeakerCategoryItemDTO>(new { SpeakerId, CategoryItemId }).
    • DeleteAsync(speakerId, id, ct) (ChildEntityServices.cs:67-68): DeleteByIdAsync over ChildEntityDeletePath.For(id, "speakerId", speakerId).
  • Why it's built this way: same shared-child-base rationale as the other three join services. The four differ only in the resource name, the two id fields in the payload, and the parent parameter name in the delete path, which is exactly the amount of code the base cannot supply.
  • Where it's used: registered explicitly at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:37, and injected into SpeakerCategoryItemsPanel, the speaker detail page's category editor (Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:18).

EventInfo

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/IEventLookupService.cs:14 · Level 0 · record

  • What it is: the event-side lookup projection: identity, name, the date window, the event's time zone, its published flag, and an optional sponsorship packet URL (IEventLookupService.cs:14-21). Beyond name enrichment it is the input to the "which event is the current one" decision that several pages make on load.
  • Depends on: no first-party types. EventIdentifierType is int in this module (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8); BCL DateOnly, string, bool.
  • Concept: the same UI lookup projection introduced by CategoryItemInfo. What is new here is that the projection carries decision fields, not only display fields. [Rubric §18, UI Architecture] and [Rubric §19, State Management]: pages feed StartDate, EndDate, and TimeZone into the shared CurrentEventSelector to pick the current or next event, so the "default event filter" rule lives in one shared selector while the record just supplies its inputs.
  • Walkthrough: a positional record with seven members.
    • Id, Name (IEventLookupService.cs:15-16): key plus display name.
    • StartDate, EndDate (lines 17-18): DateOnly bounds of the event.
    • TimeZone (line 19): the event's time zone id, passed to the selector so "current" is evaluated in the event's own zone rather than the browser's.
    • IsPublished (line 20).
    • SponsorshipPacketUrl (line 21): string? with a default of null. The remarks block (IEventLookupService.cs:9-13) records why: the many call sites that need only identity and dates stay unchanged, and only the public sponsor page reads it, to decide whether the sponsorship call to action renders at all.
  • Why it's built this way: adding the optional parameter last with a default kept a wide set of construction sites source-compatible while the sponsor feature landed. [Rubric §15, Best Practices & Code Quality] (assesses whether a shape can grow without a ripple edit). EventLookupService is the single place that fills every member (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventLookupService.cs:131).
  • Where it's used: PublicSponsorList resolves the current or next event and then reads SponsorshipPacketUrl off it (PublicSponsorList.razor.cs:88); every page that injects IEventLookupService indexes the dictionary of these records to render event names, including EventFilteredListPageBase<TDto> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Common/EventFilteredListPageBase.cs:27), which supplies the shared event filter to its derived list pages.

SessionAssetLinkRequest

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionAssets/SessionAssetRequests.cs:14 · Level 0 · record

  • What it is: the request DTO for adding one link-type session asset from the UI: the session it belongs to, the display title, the URL, and its sort order (SessionAssetRequests.cs:14-27).
  • Depends on: no first-party types. SessionIdentifierType is int in this module (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15); BCL string, int.
  • Concept: a plain positional-free record class with init-only, required members for the two values that must be supplied (Title, Url) and defaulted members for the rest (SessionAssetRequests.cs:25-38). [Rubric §24, Forms/Validation/UX Safety]: the required modifier makes a missing title or URL a compile error at every construction site rather than a runtime validation surprise, before the request even reaches SessionAssetLinkRequestValidator on the server.
  • Walkthrough: four members.
    • SessionId (line 17): the owning session, defaults to the type's default (0).
    • Title (line 20): required string, the display title on the public session page.
    • Url (line 23): required string, the absolute http or https URL the link points at.
    • SortOrder (line 26): int, the display order within the session's asset list.
  • Why it's built this way: the panel builds one of these per "add link" gesture and posts it through ISessionAssetUIService.AddLinkAsync, which is the client half of the same shape the API validates (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/AddLink/SessionAssetLinkRequestValidator.cs).
  • Where it's used: constructed by MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/SessionAssetsPanel.razor.cs and passed to ISessionAssetUIService.AddLinkAsync, implemented by SessionAssetService.

SessionAssetUpdateRequest

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionAssets/SessionAssetRequests.cs:35 · Level 0 · record

  • What it is: the request DTO for editing one session asset's title, sort order and (for a link) URL (SessionAssetRequests.cs:35-45).
  • Depends on: no first-party types. BCL string, int.
  • Concept: the sibling of SessionAssetLinkRequest, but Url here is optional (string?) rather than required, because the same update shape covers a FILE asset too, and a file's URL cannot be edited from the client, only its title and order (SessionAssetRequests.cs:42-44).
  • Walkthrough: three members.
    • Title (line 38): required string, the new display title.
    • SortOrder (line 41): int, the new display order.
    • Url (line 44): string?, the new URL for a LINK asset, or null to leave it unchanged. The doc comment states the "leave unchanged" meaning explicitly.
  • Why it's built this way: one request shape serves both asset kinds instead of two near-duplicate DTOs, at the cost of Url being meaningless for a FILE asset; the panel only ever populates it for a link edit.
  • Where it's used: constructed by MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/SessionAssetsPanel.razor.cs and passed to ISessionAssetUIService.UpdateAsync, implemented by SessionAssetService.

SessionSchedulePageRequest

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Public/IPublicSessionScheduleService.cs:20 · Level 0 · record

  • What it is: a sealed record describing one page request from the public session list: the grid's filters, the 1-based page number, the page size, the optional sort column and direction, and the optional set of bookmarked session ids that scopes the "My Schedule" view (IPublicSessionScheduleService.cs:20-26).
  • Depends on: no first-party types. SessionIdentifierType is int (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15). BCL Dictionary<string, (string Operator, string Value)> and IReadOnlyCollection<T>.
  • Concept introduced, the parameter object for a grid fetch. [Rubric §9, API & Contract Design] (assesses whether a call's inputs are named and typed rather than a positional run of primitives: six arguments collapse into one record whose members carry the intent). [Rubric §18, UI Architecture]: the record is what lets the offline-first fetch live in a service instead of in the page, because a page's ServerData delegate can hand its whole request over in one object. The filter dictionary's tuple value, (string Operator, string Value), is the MMCA.Common filter convention the paged query endpoints understand.
  • Walkthrough: a positional record with six members and no body.
    • Filters (IPublicSessionScheduleService.cs:21): the grid filters the page already assembled.
    • Page, PageSize (lines 22-23): the 1-based page and rows per page.
    • SortColumn, SortDirection (lines 24-25): both string?, null when the grid is unsorted.
    • MyScheduleSessionIds (line 26): IReadOnlyCollection<SessionIdentifierType>?. The param doc (lines 14-19) is the load-bearing part: when the "My Schedule" view is active for an authenticated user this holds the bookmarked ids so the query is scoped server-side with an Id IN (...) filter and the server still pages; it is null for the normal browse path, and it is never empty, because an empty schedule has no query to run and the page answers it without reaching the service.
  • Why it's built this way: PublicSessionScheduleService turns MyScheduleSessionIds into an extra "Id" filter with the IN operator before delegating to the paged session query (PublicSessionScheduleService.cs:135-140), which is why the "never empty" invariant is documented on the record rather than defended in the service: an empty IN list would be a query that cannot match. [Rubric §12, Performance & Scalability]: the alternative the doc comment rules out, pulling a 500-row page and filtering in memory, also reported a wrong total past 500 rows (PublicSessionScheduleService.cs:124-127).
  • Where it's used: constructed by PublicSessionList inside its unified fetch delegate (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionList.razor.cs:321-328) and consumed by IPublicSessionScheduleService.FetchPageAsync.

IEventLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/IEventLookupService.cs:26 · Level 3 · interface

  • What it is: the event counterpart of ICategoryItemLookupService: one call returning an event-keyed dictionary of EventInfo, plus an Invalidate() hook that drops the memo (IEventLookupService.cs:26-36).
  • Depends on: EventInfo, the EventIdentifierType alias, and Result<T>.
  • Concept: identical bulk-then-index shape, see ICategoryItemLookupService. [Rubric §23, Front-End Performance]. This is the most widely injected of the three lookups because so many Conference surfaces are event-scoped, and it is the only one whose implementation memoizes: the remarks on EventLookupService explain that the cached unit is the Task, not the result, so concurrent callers in one scope share a single in-flight fetch (EventLookupService.cs:12-32), with a five-minute TTL matched to the server's "EventsCache" output-cache policy (EventLookupService.cs:36-43) and an explicit Invalidate() for this scope's own writes (EventLookupService.cs:62-68).
  • Walkthrough: two members.
    • GetAllAsync(CancellationToken cancellationToken = default) returning Task<Result<IReadOnlyDictionary<EventIdentifierType, EventInfo>>> (IEventLookupService.cs:28-29).
    • void Invalidate() (IEventLookupService.cs:36): drops the memoized lookup so the next GetAllAsync refetches. The doc comment (:31-35) names the callers: the write services after a successful create, update, delete, publish, unpublish or Sessionize refresh. The reason is that privileged writers bypass the server output cache, so the client memo would otherwise be the only staleness they could see.
  • Where it's used: implemented by EventLookupService, registered scoped at DependencyInjection.cs:59, and injected into a wide set of Conference pages, including EventFilteredListPageBase<TDto> (EventFilteredListPageBase.cs:27), RoomDetail (RoomDetail.razor.cs:21), RoomCreate (RoomCreate.razor.cs:16), SessionCreate (SessionCreate.razor.cs:23), SessionDetail (SessionDetail.razor.cs:29), ActivityDetail (ActivityDetail.razor.cs:25), ActivityCreate (ActivityCreate.razor.cs:23), SpeakerDashboard (SpeakerDashboard.razor.cs:27), SessionSelectionDashboard (SessionSelectionDashboard.razor.cs:19), OrganizerEventFeedback (OrganizerEventFeedback.razor.cs:22), PublicActivityList (PublicActivityList.razor.cs:30), PublicEventList (PublicEventList.razor.cs:36), PublicSponsorList (PublicSponsorList.razor.cs:29), and the sponsor admin pages (SponsorDetail.razor.cs:24, SponsorCreate.razor.cs:22). It is also consumed from the Engagement module's event feedback page, EventFeedback (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/EventFeedback.razor.cs:24), which is why DependencyInjection.cs:57 labels these "cross-module lookup services".

IPublicSessionScheduleService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Public/IPublicSessionScheduleService.cs:34 · Level 3 · interface

  • What it is: the offline-first page fetch for the public session schedule (IPublicSessionScheduleService.cs:34-70). One method runs the live paged query and keeps the last successful FIRST page in the device-local cache, so a dead venue network still shows a programme (doc comment, lines 28-33); a second reads that cache alone for a page that cannot run its query.
  • Depends on: SessionSchedulePageRequest, SessionDTO, and Result<T>. BCL Action<bool>, Task, CancellationToken, and a named tuple return type (IReadOnlyList<SessionDTO> Items, int TotalItems).
  • Concept introduced, offline-first read-through with an explicit blast radius. [Rubric §29, Resilience & Business Continuity] (assesses whether a surface degrades usefully when a dependency is gone; here the conference programme survives a venue network outage). [Rubric §19, State Management] and [Rubric §18, UI Architecture]: the caching decision lives in the service while the page keeps only the banner flag, so the component stays a renderer. The doc comment is precise about how narrow the fallback is, and that precision is the teaching point: the live path is never affected, the snapshot is only consulted when a fetch fails, and only for page 1 while the device reports itself offline (lines 30-33). This is the ADR-042 device-capability abstraction at work (Website/docs-src/adr/042-device-capability-abstraction.md): the implementation composes OfflineFirstPageSnapshot<TItem> over ILocalCacheStore and IConnectivityStatusService (PublicSessionScheduleService.cs:19-34), so the same page code runs on a head that has no local store at all.
  • Walkthrough: two members.
    • TryReadOfflineSnapshotAsync(SessionSchedulePageRequest request, Action<bool> onCacheStateChanged, CancellationToken cancellationToken = default) returning Task<(IReadOnlyList<SessionDTO> Items, int TotalItems)?> (IPublicSessionScheduleService.cs:66-69). It reads the offline snapshot without a live fetch, for a page that cannot run its query at all because its event scope or bookmarks could not be read (doc comment :56-65). It answers only on the fallback's terms: the unfiltered programme, page 1, device offline. A My Schedule or narrowed request gets no snapshot, and null means the snapshot cannot answer. onCacheStateChanged is raised with true when it does.
    • FetchPageAsync(SessionSchedulePageRequest request, Action<bool> onCacheStateChanged, CancellationToken cancellationToken = default) returning Task<Result<(IReadOnlyList<SessionDTO> Items, int TotalItems)>> (IPublicSessionScheduleService.cs:51-54).
    • request (line 52): the page to fetch, see SessionSchedulePageRequest.
    • onCacheStateChanged (line 53): a callback, raised with true when the snapshot answered, so the page shows its "cached schedule" banner and re-renders, and with false when a live fetch succeeded (documented at lines 45-49). Passing a callback rather than exposing a property keeps the service free of any rendering knowledge.
    • The failure contract is spelled out in the method doc (lines 36-43): a successful first page is written back to the snapshot, a failed fetch falls back to it, and a failure with nothing cached travels on to the caller unchanged, a failed Result as a failed result and a throwing cache store as a rethrow, so the grid's own handling still applies. The write-back is narrower than "any first page": only the unfiltered programme is written or answered from, so a "My Schedule" page or a page narrowed by any filter other than the event scope never overwrites the snapshot and is never answered with it (lines 38-41). The implementation matches that on every branch (PublicSessionScheduleService.cs:43-75) and enforces the rule through its private IsProgrammePage check (PublicSessionScheduleService.cs:121-122).
  • Why it's built this way: extracting the offline behavior behind an interface is what let the page keep a single fetch delegate for both the browse and "My Schedule" views. The page comment says it directly: only the banner stays in the page, the service decides when it is warranted (PublicSessionList.razor.cs:295-297).
  • Where it's used: implemented by PublicSessionScheduleService, registered scoped at DependencyInjection.cs:55 under an explicit ADR-042 Wave 3 comment (line 54), and injected into PublicSessionList (PublicSessionList.razor.cs:28), which calls its FetchPageAsync from the page fetch (PublicSessionList.razor.cs:269) and its TryReadOfflineSnapshotAsync when a page cannot run its query (PublicSessionList.razor.cs:280).

ISessionAssetUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionAssets/ISessionAssetUIService.cs:17 · Level 3 · interface

  • What it is: the UI-service contract for a session's materials: list, add a link, upload a file, update, and delete (ISessionAssetUIService.cs:17-85). Unlike the generic entity-service pattern elsewhere in this chapter, this contract is hand-written rather than derived from IEntityService<,>, because a session asset is not a plain CRUD resource: it has two creation paths (link vs. uploaded file) and a conditional update.
  • Depends on: SessionAssetDTO, SessionAssetLinkRequest, SessionAssetUpdateRequest, Result<T>; BCL IBrowserFile (Microsoft.AspNetCore.Components.Forms), byte[], CancellationToken.
  • Concept: the conditional update carries the same If-Match shape as IEventUIService's transitions: UpdateAsync takes a required byte[] rowVersion and refuses the write with 428 Precondition Required when it is empty (ISessionAssetUIService.cs:63-65), the same ADR-035 optimistic-concurrency contract. The list call is deliberately forgiving instead: GetBySessionAsync is anonymous, and a session the caller cannot see answers with an empty list rather than a failure, because it backs the public session page as well as the organizer panel (ISessionAssetUIService.cs:19-23). Because that list is served under a shared output-cache policy, the owner's management view needs a way past it: the bypassCache flag (:25-29) exists because a lookup can hit an entry built for an anonymous caller, so the speaker's or organizer's own read adds a unique query parameter that is a guaranteed cache miss. [Rubric §19, State Management] (assesses whether cached state can go stale in front of the user who just changed it).
  • Walkthrough: five members.
    • GetBySessionAsync(sessionId, bypassCache = false, ct) (:32-35): Task<Result<IReadOnlyList<SessionAssetDTO>>>. The default keeps the cached public read; only the owner's management view passes true.
    • AddLinkAsync(request, ct) (:41-43): Task<Result<SessionAssetDTO>>, takes a SessionAssetLinkRequest.
    • UploadFileAsync(sessionId, title, file, sortOrder, ct) (:55-60): Task<Result<SessionAssetDTO>>. The doc comment (:45-54) frames it as streaming the picked file straight into the multipart body rather than buffering the whole deck in the circuit.
    • UpdateAsync(id, rowVersion, request, ct) (:72-76): Task<Result<SessionAssetDTO>>, conditional on rowVersion as described above.
    • DeleteAsync(id, ct) (:82-84): Task<Result>. The doc comment notes a file asset also schedules its blob for removal (:78).
  • Why it's built this way: separating AddLinkAsync from UploadFileAsync instead of one polymorphic "add" call keeps the link path a small JSON POST and the file path a multipart stream, which matters because the upload path deliberately skips the shared retry policy (see SessionAssetService).
  • Where it's used: implemented by SessionAssetService, registered in MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:52, and injected into SessionAssetsPanel (SessionAssetsPanel.razor.cs:33, which lists with bypassCache: true at line 82) and SessionAssetsDownloadList (SessionAssetsDownloadList.razor.cs:20, which keeps the cached read) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/).

SessionizeRefreshOutcome

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/SessionizeRefreshOutcome.cs:13 · Level 3 · record (sealed)

  • What it is: the two-field payload the event detail page gets back from one Sessionize refresh gesture: Summary, the per-entity counts and warnings the import reported, and Event, the event reloaded after the import including its fresh row version (SessionizeRefreshOutcome.cs:11-13).
  • Depends on: RefreshFromSessionizeResultDTO and EventDTO, both from MMCA.ADC.Conference.Shared.Events (SessionizeRefreshOutcome.cs:1).
  • Concept introduced, the multi-step gesture packaged as one result. A Sessionize refresh is three server calls (persist an edited code, run the import, reload the event), and the doc comment states why the two payloads travel together: so the page reads one Result and reports one failure, instead of branching on a persist, an import, and a reload with the same message three times (SessionizeRefreshOutcome.cs:5-9). [Rubric §19, State Management] assesses whether the UI's copy of server state stays consistent after a write: the reloaded event is part of the success value, so a page that handles the outcome cannot forget to refresh its row version and then fail its next conditional update. [Rubric §9, API & Contract Design] assesses whether a client contract models the operation rather than the transport; this record exists because "refresh" is one user gesture even though it is three HTTP calls.
  • Walkthrough: a positional sealed record with two members and no behavior (SessionizeRefreshOutcome.cs:13). Value equality and with come from the record declaration; there is no constructor body, no validation, and no mapping code. It is a transport-shaped tuple with names.
  • Why it's built this way: the import rewrites the event's child data and its refresh stamp, so the caller's copy of the event is stale the moment the import succeeds. The service comment says exactly that and concludes "hand back the reloaded event rather than a bare summary" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventService.cs:116-117).
  • Where it's used: produced by IEventUIService.RefreshFromSessionizeWithCodeAsync (IEventUIService.cs:29), built by EventService (EventService.cs:121), and consumed by EventDetail, which unpacks both members in one branch: _refreshResult = refreshed.Summary, Event = refreshed.Event (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventDetail.razor.cs:292-311).

EventLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventLookupService.cs:33 · Level 4 · class (sealed)

  • What it is: the implementation behind IEventLookupService: a paged read of the whole events collection projected into a dictionary of EventInfo, memoized per scope behind a single-flight cache (EventLookupService.cs:33-136). It is the most-injected service in this chapter, so the caching is not an optimization detail, it is the reason the pattern scales.
  • Depends on: EventInfo, EventDTO, PagedCollectionResult<T>, PagedReadAll, IdempotentReadRetry, HttpResultExecutor, ProblemDetailsResultReader; BCL IHttpClientFactory, TimeProvider, TimeSpan, DateTimeOffset, Task.WaitAsync. The primary constructor takes the client factory and a TimeProvider and, like its category-item sibling, no token storage (EventLookupService.cs:33), so this is an unauthenticated read whose clock is injectable.
  • Concept introduced, the scope-lifetime single-flight cache. Four mechanisms interlock, and the first three are documented in the class remarks (EventLookupService.cs:12-32).
    • The cached unit is the Task, not the result (:46). Two callers in the same scope that ask while a fetch is in flight share that one fetch instead of starting one each. The remarks explain why overlap is the normal case rather than the edge case: EventFilteredListPageBase<TDto> starts the load before its first await via BeginEventsLoad (Pages/Common/EventFilteredListPageBase.cs:137, defined at :145) and a derived page awaits it later.
    • A TTL stamped only on success (:114-120). _cachedUntil starts at DateTimeOffset.MinValue (:53), which is what makes a faulted task, a cancelled one and a failed Result all expire immediately rather than caching a non-answer; a transient outage heals on the next page instead of being remembered for five minutes. CacheTtl is five minutes (:43), matched to the server-side "EventsCache" output-cache policy. The doc comment is now more precise than "can never be staler": for a reader the origin serves from that cache, the entry is no staler than the origin would be, but a privileged reader bypasses the server cache, which is why the memo is also dropped on this scope's own writes (:36-42).
    • Explicit invalidation with a generation guard (:59-68, :93, :117). Invalidate() (part of IEventLookupService) bumps a private _generation counter, nulls _cached and resets _cachedUntil to MinValue. An in-flight fetch simply completes unobserved and the next read starts a fresh one. FetchAsync captures _generation on entry (:93) and stamps _cachedUntil only when result.IsSuccess && generation == _generation (:117), so a fetch that an Invalidate superseded cannot stamp a TTL onto the fetch that replaced it. EventService is the caller here (EventService.cs:125-134).
    • No lock (:22-25). The service is registered scoped in both heads (DependencyInjection.cs:59), and Blazor's renderer dispatches every component callback for one scope on a single logical thread, so the read-modify-write at :73-83 never interleaves with itself. This is a case where the hosting model, not a synchronization primitive, is the safety argument, and the code says so rather than leaving the reader to infer it. [Rubric §23, Front-End Performance] assesses redundant network work in a session: a user walking four grids pays one round trip for the event list instead of four. [Rubric §19, State Management] assesses freshness policy: bounding the client cache by the origin's own TTL means the two can never disagree in the direction that matters. [Rubric §12, Performance & Scalability] covers the single-flight collapse under concurrency.
  • Walkthrough
    • CacheTtl (:43): static readonly TimeSpan of five minutes.
    • _cached (:46): the shared fetch, in flight or completed and still fresh.
    • _cachedUntil (:53): when _cached stops being reusable.
    • _generation (:59): bumped by Invalidate(), read by FetchAsync to decide whether it may stamp.
    • Invalidate() (:62-68): increments _generation, nulls _cached, resets _cachedUntil.
    • GetAllAsync(ct) (:70-89): snapshots _cached into a local (:73); starts a new FetchAsync() when the entry is null, or completed and past its TTL as read from the injected timeProvider.GetUtcNow() (:78-83), resetting _cachedUntil first; then returns the shared task guarded by the caller's token. The final line is the subtle one: the cached task is created without a caller token and awaited under one via entry.WaitAsync(cancellationToken) (:88), so a page disposing mid-load abandons only its own await and never cancels the fetch the other pages in the scope are sharing (:85-87).
    • FetchAsync() (:91-123): captures the current generation (:93), then reads every page through PagedReadAll.ReadAllAsync until the reported total (:95-111), because the GET-all endpoint ignores pageSize and stops silently at the API's maximum page size (:95-96). Each page is one HttpResultExecutor.ExecuteAsync call that GETs PagedReadAll.LookupPageUrl("events", pageNumber) through IdempotentReadRetry.GetAsync (:102-105) and reads it through ProblemDetailsResultReader (:107-108), with CancellationToken.None throughout. The combined list is mapped through BuildLookup (:112), and the TTL is stamped from timeProvider.GetUtcNow() only when the result is a success, the payload is in hand and the generation is unchanged, so the TTL measures the age of the data rather than the age of the attempt (:114-120).
    • BuildLookup(events) (:125-135): takes the accumulated List<EventDTO> and projects each DTO into an EventInfo carrying all seven members including SponsorshipPacketUrl (:131), keyed by evt.Id.
  • Why it's built this way: the projection is the boundary between the transport contract and the UI's own model, so a change to EventDTO that does not affect these seven fields never reaches the pages ([Rubric §15, Best Practices & Code Quality]). The remarks also record a deliberate non-consolidation: the sibling read paths (LiveEventService, ADCHome, and the Conference service's SelfHttpOutputCacheWarmupTask) keep their own URLs and their own caching because they ask narrower questions of the same controller, and collapsing them onto this URL family would trade one saved round trip for a wider payload on every one of them (EventLookupService.cs:26-31).
  • Where it's used: registered scoped against IEventLookupService (DependencyInjection.cs:59). It is injected across the Conference UI, including EventFilteredListPageBase<TDto> (Pages/Common/EventFilteredListPageBase.cs:27, which is what puts it behind every event-filtered grid), SessionDetail (:29), SessionCreate (:23), RoomDetail (:21), RoomCreate (:16), ActivityDetail (:25), ActivityCreate (:23), SponsorDetail (:24), SponsorCreate (:22), PartnerDetail (:24), PartnerCreate (:22), SpeakerDashboard (:26), SessionSelectionDashboard (:19), OrganizerEventFeedback (:22), PublicEventList (:35), PublicActivityList (:29), PublicSponsorList (:28), and SessionLookups (Pages/Sessions/SessionLookups.cs:33). It also crosses a module boundary into Engagement's EventFeedback page (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/EventFeedback.razor.cs:24). Pages that need a default event filter feed its Values to CurrentEventSelector along with the StartDate / EndDate / TimeZone accessors.
  • Caveats / not-in-source: the read has no fixed row ceiling, since PagedReadAll keeps requesting pages until the reported total (EventLookupService.cs:95-111); the page size it uses is PagedReadAll's concern, not visible in this class. The cache is per scope, so a Blazor Server circuit and a WebAssembly app instance each hold their own copy. Invalidate() only drops the copy in the scope that called it: an event edited from another circuit or device is not reflected here until the five minutes elapse, and a write through this scope's EventService clears it immediately.

IEventUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/IEventUIService.cs:11 · Level 4 · interface

  • What it is: the UI-service contract for the events resource. Unlike the plain CRUD markers, it extends the generic surface with four event-specific operations: publish, unpublish, a Sessionize refresh, and the composed refresh gesture (IEventUIService.cs:11-32).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> bound to EventDTO, RefreshFromSessionizeResultDTO (the import summary), SessionizeRefreshOutcome (the composed payload), and Result (IEventUIService.cs:1-3). BCL Task, CancellationToken, byte[].
  • Concept introduced, extending the generic UI service with resource-specific verbs. [Rubric §9, API & Contract Design] assesses whether non-CRUD state transitions get first-class, intention-revealing operations instead of being forced through a generic update. Publish and unpublish are lifecycle transitions on an event and refresh triggers an external Sessionize sync, none of which is a CRUD Update, so they earn their own methods mapped to dedicated WebAPI endpoints (the doc comment, IEventUIService.cs:7-10, says exactly this). The second concept is in the signatures: both transitions take a required byte[] rowVersion (IEventUIService.cs:13,15), the optimistic-concurrency token the client echoes back from the EventDTO it acted on. The implementation turns it into an If-Match header via ConcurrencyETag.Format (Services/EventService.cs:29,41), so a publish decided against a stale view comes back as a conflict rather than applying silently. That makes this contract a [Rubric §8, Data Architecture] touch point as well: concurrency control reaches all the way up into the UI service signature instead of stopping at the database. The rationale is ADR-035, which describes the ETag on the read and the conditional If-Match on the write as one exchange.
  • Walkthrough: four declared members, all returning Result values.
    • PublishAsync(EventIdentifierType id, byte[] rowVersion, CancellationToken) (IEventUIService.cs:13), returns Task<Result>: success is "the transition applied", with no payload.
    • UnpublishAsync(EventIdentifierType id, byte[] rowVersion, CancellationToken) (IEventUIService.cs:15), the mirror transition, same shape.
    • RefreshFromSessionizeAsync(EventIdentifierType id, CancellationToken) (IEventUIService.cs:17), returns Task<Result<RefreshFromSessionizeResultDTO>>. It takes no rowVersion: a Sessionize pull is not a lifecycle transition on the event row.
    • RefreshFromSessionizeWithCodeAsync(EventDTO current, string? sessionizeCode, CancellationToken) (IEventUIService.cs:29-32), returns Task<Result<SessionizeRefreshOutcome>>. The doc comment is the interesting part (IEventUIService.cs:19-28): this member runs the whole organizer refresh gesture, persisting the Sessionize code when the organizer changed it, calling RefreshFromSessionizeAsync, then reloading the event so the caller renders the server's new state. The three steps share one failure, so the page reports "refresh failed" once instead of repeating the branch per call. The current parameter is documented as carrying the row version the update is conditional on.
  • Why it's built this way: the extra verbs live on the interface so the concrete EventService is the only place that knows the endpoint URLs, and pages stay transport-agnostic. Putting the three-step gesture on the contract (rather than orchestrating it in the page) is the same move ISpeakerDetailLookupService makes for reads: one call, one failure branch, one success value.
  • Where it's used: implemented by EventService; injected into EventList (Pages/Event/EventList.razor.cs:21), EventDetail (Pages/Event/EventDetail.razor.cs:23, which is the only caller of RefreshFromSessionizeWithCodeAsync, at :247), EventCreate (Pages/Event/EventCreate.razor.cs:17), SessionList (Pages/Session/SessionList.razor.cs:25), PublicEventList (:35), PublicEventDetail (:20), PublicSessionList (:32) and PublicSpeakerDetail (:24).

SessionAssetService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.SessionAssets · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionAssets/SessionAssetService.cs:26 · Level 4 · class (sealed)

  • What it is: the implementation behind ISessionAssetUIService: list, add a link, upload a file, conditionally update, and delete, all against the SessionAssets endpoint (SessionAssetService.cs:26-216).
  • Depends on: ISessionAssetUIService (implemented interface); AuthenticatedServiceBase for the authenticated client; HttpResultExecutor and ProblemDetailsResultReader for the request/response plumbing; ConcurrencyETag for the If-Match header; SessionAssetLinkRequest, SessionAssetUpdateRequest, SessionAssetDTO; BCL IHttpClientFactory, IBrowserFile, MultipartFormDataContent, CultureInfo.InvariantCulture.
  • Concept: the upload path is the teaching point. UploadFileAsync carries no retry policy at all (SessionAssetService.cs:96-143), unlike every other method here, which runs its request through the shared RetryPolicy. The doc comment explains why: a picked file's stream is single-shot, neither a browser file input nor a native picker rewinds it, so a retried attempt would post an exhausted body. The Idempotency-Key header covers the case the retry existed for instead, because a repeated key replays the stored server response rather than re-uploading tens of megabytes over venue wifi (:86-91). [Rubric §29, Resilience & Business Continuity] assesses whether a retry strategy actually fits the operation it wraps; here the right answer is to not retry the transport call and to rely on idempotency at the server instead. [Rubric §24, Forms/Validation/UX Safety]: the 50 MB cap is declared once, in SessionAssetLimits.MaxFileBytes, and stated to OpenReadStream so Blazor's own 512 KB default cannot silently truncate a real slide deck (:110-112); the suppressed Sonar rule S5693 on UploadFileAsync (:92-95) documents that the length IS limited, just not by the pattern the rule looks for.
  • Walkthrough
    • Endpoint = "SessionAssets" (:29).
    • GetBySessionAsync(sessionId, bypassCache, ct) (:32-57): GETs SessionAssets?sessionId={id} through HttpResultExecutor.ExecuteAsync, using the plain authenticated client (no idempotency key, no If-Match, since it is a read), and reads the array through ProblemDetailsResultReader (:43-54). When bypassCache is true the URL gains a &_={Guid.NewGuid():N} parameter (:39-41), so the owner's read is a guaranteed miss against the shared output cache, the same pattern the speaker dashboard uses (:37-38); the public download list keeps the cached read.
    • AddLinkAsync(request, ct) (:64-83): POSTs the SessionAssetLinkRequest to SessionAssets/link through a client built with a fresh Idempotency-Key and no If-Match (:73), so a retried add cannot publish the same link twice.
    • UploadFileAsync(sessionId, title, file, sortOrder, ct) (:96-143): opens the file's stream capped at SessionAssetLimits.MaxFileBytes (:112); builds a MultipartFormDataContent with parts named file, sessionId, title, sortOrder (:130-133), matching the controller's IFormFile parameter and its three [FromForm] arguments exactly, since a renamed part fails binding; posts directly with httpClient.PostAsync rather than through RetryPolicy (:135-136), for the single-shot-stream reason above.
    • UpdateAsync(id, rowVersion, request, ct) (:146-171): builds the If-Match header only when rowVersion is non-empty (:156), so an empty token sends no header and the server answers 428 rather than the write overwriting another editor's change; PUTs to SessionAssets/{id}.
    • DeleteAsync(id, ct) (:174-191): DELETEs SessionAssets/{id} through the plain authenticated client.
    • CreateRequestClientAsync(idempotencyKey, ifMatch) (:200-215): the shared client builder for a write. Both headers are set once, as default request headers on a client built once per logical operation, so every RetryPolicy attempt for that call states the same idempotency key and the same precondition: a duplicate arrival dedups at the server, and a write that lost the concurrency race keeps failing the same precondition instead of eventually succeeding against a version the caller never saw.
  • Why it's built this way: the interface exists because a session asset is not one CRUD resource, it is two creation paths (a cheap link POST and an expensive multipart upload) plus a conditional update, and each earns a method shaped for what it actually sends over the wire rather than a single generic Create/Update pair.
  • Where it's used: registered against ISessionAssetUIService in MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:52, and injected into SessionAssetsPanel (SessionAssetsPanel.razor.cs:33, the one caller passing bypassCache: true, at line 82) and SessionAssetsDownloadList (SessionAssetsDownloadList.razor.cs:20) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionAssets/).

EventService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Events · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventService.cs:22 · Level 5 · class (sealed)

  • What it is: the concrete HTTP service for the events resource. Unlike its plain-CRUD siblings it is not body-less: on top of the inherited CRUD it adds the four event-specific operations that map to dedicated WebAPI endpoints, publish, unpublish, Sessionize refresh, and the composed refresh-with-code gesture (EventService.cs:22-135). It also overrides the inherited AddAsync, UpdateAsync and DeleteAsync (:31-46) so that every successful write drops the scope's memoized event lookup (see the cache-invalidation concept below). It implements IEventUIService, which itself extends the generic IEntityService<EventDTO, EventIdentifierType> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/IEventUIService.cs:11).
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> for the CRUD surface, the Endpoint property (EntityServiceBase.cs:51) and the protected SendRequestAsync dispatch (EntityServiceBase.cs:351,381); ConcurrencyETag to render the row version as a weak entity tag (EventService.cs:29,41); EventDTO, RefreshFromSessionizeResultDTO and SessionizeRefreshOutcome; Result; ITokenStorageService; IEventLookupService and ISpeakerLookupService, the two memoized lookups it invalidates (primary constructor, EventService.cs:22-26); BCL CultureInfo.InvariantCulture.
  • Concept introduced, write-through invalidation of the scope memo. The organizers who write bypass the server's output cache, so the client-side memo in EventLookupService would be the only staleness they could see. Every successful write therefore calls eventLookup.Invalidate() through the private generic helper InvalidateEventsOnSuccess<TResult>(TResult result) where TResult : Result (:125-134), which passes the result through untouched and invalidates only when result.IsSuccess. It wraps the overridden AddAsync, UpdateAsync and DeleteAsync (:31-46) and the publish and unpublish transitions (:52,64). Sessionize refresh is the one call that invalidates two memos: on success it drops both the event lookup and the speaker lookup (:83-88), because the import rewrites the event and its speakers. A failed call invalidates nothing, so a refused write leaves the cache valid. [Rubric §19, State Management] (assesses whether client state is dropped when the server state it mirrors changes).
  • Concept introduced, the conditional state-transition call. Publish and unpublish are not CRUD: they are named transitions the server owns, so they POST to {Endpoint}/{id}/publish and {Endpoint}/{id}/unpublish with no body at all (EventService.cs:53-57,65-69). What they do carry is an If-Match header holding the event's current row version, rendered by ConcurrencyETag.Format as a weak tag such as W/"AAAAAAAAB9E=" (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:41-45). The tag is weak on purpose: a strong tag would promise byte-for-byte equality of the representation, while all this token answers is "is this still the same version of the resource", which is exactly what If-Match asks (ConcurrencyETag.cs:11-18). So a publish issued against a stale copy of the event is refused by the server rather than silently overwriting a concurrent edit (ADR-035). [Rubric §9, API & Contract Design] (assesses whether state changes are modeled as intent-named resources rather than as generic updates) and [Rubric §8, Data Architecture] (assesses how concurrent writers are arbitrated; here by a version token on the wire, not by a last-writer-wins update).
  • Concept introduced, composing a multi-call gesture behind one Result. RefreshFromSessionizeWithCodeAsync is the second idea worth teaching here. The organizer's "refresh from Sessionize" button is really three server calls: persist an edited Sessionize code, run the import, reload the event. Doing that in the page would mean three failure branches printing the same message. Instead the service runs the sequence and returns one Result carrying a SessionizeRefreshOutcome, so the page reads one value and reports one failure (EventService.cs:93-123, contract documented at IEventUIService.cs:19-28). [Rubric §18, UI Architecture] (assesses whether orchestration lives in a service rather than in component code) and [Rubric §24, Forms/Validation/UX Safety] (one failure branch means one message, and no half-reported gesture).
  • Walkthrough
    • AddAsync(entity, ct), UpdateAsync(entity, ct), DeleteAsync(id, ct) (EventService.cs:31-46): overrides of the base CRUD, each InvalidateEventsOnSuccess(await base.XAsync(...)).
    • PublishAsync(id, rowVersion, ct) (EventService.cs:48-58): a body-less POST to {Endpoint}/{id}/publish built with string.Create(CultureInfo.InvariantCulture, ...) (:54), dispatched through the base's valueless SendRequestAsync with ifMatch: ConcurrencyETag.Format(rowVersion) (:58), the whole call wrapped in InvalidateEventsOnSuccess (:52). The base attaches the header and turns the response into a Result.
    • UnpublishAsync(id, rowVersion, ct) (EventService.cs:60-70): the exact mirror against {Endpoint}/{id}/unpublish (:66), same conditional header (:70), same invalidation (:64).
    • RefreshFromSessionizeAsync(id, ct) (EventService.cs:72-91): POSTs {Endpoint}/{id}/refresh (:76-81) through the value-returning SendRequestAsync<T> and yields the import summary as a Result<RefreshFromSessionizeResultDTO>. No If-Match here: an import is not a conditional edit of the caller's copy. On success it invalidates both the event and the speaker lookup (:83-88).
    • RefreshFromSessionizeWithCodeAsync(current, sessionizeCode, ct) (EventService.cs:93-123): guards a null event (:98); compares the edited code against the stored one with StringComparison.Ordinal and, only when they differ, persists it first through the overridden UpdateAsync on a with-copy of the DTO, because the import reads the code off the stored event (:101-103); short-circuits on a failed persist (:104-107); runs the import and short-circuits again on failure using TryGetValue (:110-114); then reloads the event through the inherited GetByIdAsync(id, true, ct) (:118), because the import rewrites the event's child data and its refresh stamp, so the caller's copy is stale the moment it succeeds (:116-117); and finally pairs the summary with the reloaded event into a SessionizeRefreshOutcome (:120-122). Every branch returns the underlying call's own Errors, so nothing is re-worded on the way out. Because the persist goes through the overridden UpdateAsync, the code-change path invalidates the event lookup before the import runs.
    • InvalidateEventsOnSuccess<TResult>(result) (EventService.cs:125-134): the private helper.
  • Why it's built this way: publish, unpublish and refresh are server-owned transitions with their own endpoints and their own authorization, so they are additive methods on the event's interface rather than flags smuggled through a PUT. The composed method exists because the reload after an import is not optional: returning a bare summary would leave the page holding an event whose row version no longer matches, and the next conditional write would fail.
  • Where it's used: registered by the AddUIModule<ConferenceUIModule>() entity-service scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31), since IEventUIService derives from IEntityService<,>. It is injected as IEventUIService into EventList (Pages/Event/EventList.razor.cs:21), EventDetail (Pages/Event/EventDetail.razor.cs:23), EventCreate (Pages/Event/EventCreate.razor.cs:17), PublicEventList (Pages/Public/PublicEventList.razor.cs:35), PublicEventDetail (Pages/Public/PublicEventDetail.razor.cs:20), PublicSpeakerDetail (Pages/Public/PublicSpeakerDetail.razor.cs:24), SessionList (Pages/Session/SessionList.razor.cs:25) and PublicSessionList (Pages/Public/PublicSessionList.razor.cs:32). The transition methods are called from EventDetail: publish and unpublish at Pages/Event/EventDetail.razor.cs:206-207, and the composed refresh at :247.

PublicSessionScheduleService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Public/PublicSessionScheduleService.cs:19 · Level 5 · class (sealed)

  • What it is: the offline-first page fetch behind the public session schedule. It runs the live paged session query and keeps the last successful first page in the device-local cache, so a dead venue network still shows a programme (PublicSessionScheduleService.cs:19-165). It implements IPublicSessionScheduleService.
  • Depends on: ISessionUIService as the live query (constructor, PublicSessionScheduleService.cs:20), so it composes over SessionService rather than issuing HTTP itself; ILocalCacheStore and IConnectivityStatusService (:21-22); OfflineFirstPageSnapshot<TItem> as the snapshot mechanism (:30-31); SessionSchedulePageRequest as its request record; SessionDTO; Result.
  • Concept introduced, offline-first as a composition, not a mode. The service is a decorator over a query, not a second data path: the live call always runs first, and the snapshot is consulted only when that call did not produce a page. The framework piece it composes, OfflineFirstPageSnapshot<TItem>, narrows the fallback deliberately: CanServe is true only when the device reports itself offline, the store is available, and the requested page is page 1 (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:43), and RememberAsync records only page 1 (:41-45). That is what keeps the live path unaffected: there is no scenario in which a stale snapshot outranks a live answer. ILocalCacheStore persists on the MAUI and WebAssembly heads and reports itself unavailable on Blazor Server, where SSR always has the live API (OfflineFirstPageSnapshot.cs:11-12), so the same code is a no-op on the server head rather than a branch. This is the device-capability abstraction of ADR-042 applied to a list surface. [Rubric §29, Resilience & Business Continuity] (assesses whether the app degrades usefully when a dependency is gone; a venue with no signal still renders yesterday's first page) and [Rubric §19, State Management] (the banner callback keeps the user aware of which data they are looking at).
  • Concept introduced, only the programme is remembered, and only the programme is answered from. Page 1 alone is not a sufficient condition for the write-back or the read. The private IsProgrammePage check (PublicSessionScheduleService.cs:121-122) admits a page only when it is not "My Schedule" and carries no filter key other than the event scope (EventId, the key PublicSessionList writes). Any other key, such as a title search, a room or a grid column filter, marks a narrowed view that must never overwrite the cached programme, nor be answered from it, while a different sort on page 1 still counts as the programme (doc comment, :113-119). The check now gates every snapshot read as well as the write: without the read gate, a failed "My Schedule" fetch would be answered with the whole programme as its own result (comment at :58-59). [Rubric §19, State Management].
  • Concept introduced, scoping "My Schedule" server-side. The private QueryAsync shows the third idea. When the "My Schedule" view is active, the request carries the attendee's bookmarked session ids, and the service copies the grid's filter dictionary and adds an Id IN (...) filter built from those ids (PublicSessionScheduleService.cs:135-140), then lets the server page the result (:142-145). The doc comment records what this replaced and why it matters: pulling a 500-row page and filtering in memory also reported a wrong total past 500 rows (:124-127). [Rubric §12, Performance & Scalability].
  • Walkthrough
    • ScheduleCacheKey = "conference.publicSessions.page1" (PublicSessionScheduleService.cs:28): the device-local cache key, unique to this surface. The comment states the consequence of changing it (:24-27): every already-cached schedule is orphaned. The uniqueness requirement is the framework's, since a shared key would let one list serve another list's rows (OfflineFirstPageSnapshot.cs:17-21).
    • _snapshot (PublicSessionScheduleService.cs:33-34): one OfflineFirstPageSnapshot<SessionDTO> built from the store, the connectivity probe and that key, held for the service's scoped lifetime.
    • FetchPageAsync(request, onCacheStateChanged, ct) (PublicSessionScheduleService.cs:37-76), the only public method, and it has three exits.
      1. Live success: QueryAsync returns a page (:44-45), the snapshot records it only when IsProgrammePage(request) holds (:47-50), through RememberSnapshotAsync (:49) rather than on the grid's own token, the page's cached-data banner is switched off through the onCacheStateChanged(false) callback (:52), and the live result is returned (:53).
      2. Live failure with a snapshot: a transport fault arrives as a failed Result, not an exception, so the fallback is tried on the same terms (:56-59), but only when IsProgrammePage(request) holds (:60-62), so a My Schedule or narrowed request is never answered with the programme snapshot; when the snapshot answers, the banner is raised and the cached page is returned as a success (:63); with nothing cached the original failure travels on to the grid's own handling.
      3. Thrown fault with a snapshot: the catch (Exception) when (IsProgrammePage(request) && _snapshot.CanServe(request.Page)) filter (:65) still guards the paths that can throw rather than fail, the local cache store itself and a cancelled fetch (:67). If the snapshot has nothing, the exception is rethrown (:69-72) so cancellation and genuine faults are not swallowed.
    • TryReadOfflineSnapshotAsync(request, onCacheStateChanged, ct) (PublicSessionScheduleService.cs:78-89): the second public method. It reads the snapshot without a live fetch, for a page that cannot run its query at all (its event scope or bookmarks could not be read). It answers only on the fallback's terms: IsProgrammePage(request) && _snapshot.CanServe(request.Page) (:86), else a completed task holding null (:88). It guards a null request with ArgumentNullException.ThrowIfNull (:84).
    • RememberSnapshotAsync(fetched, page) (PublicSessionScheduleService.cs:100-111): writes the snapshot under its own CancellationTokenSource bounded by SnapshotWriteTimeout, a five-second static readonly TimeSpan (:31), not the grid's token. A superseding reload (or the pre-render timeout) that cancels the grid's token after the 200 arrived therefore cannot turn that success into an empty page; an OperationCanceledException is swallowed because the snapshot is best-effort (:107-110, doc comment :91-97).
    • IsProgrammePage(request) (PublicSessionScheduleService.cs:121-122): a private static predicate, request.MyScheduleSessionIds is null && request.Filters.Keys.All(key => key == "EventId"), the gate for every snapshot write and read described above.
    • QueryAsync(request, ct) (PublicSessionScheduleService.cs:131-146): builds the effective filters described above and calls ISessionUIService.GetPagedAsync with includeChildren: true, since the public schedule renders each session's child data (:142-145).
    • ReadSnapshotAsync(page, onCacheStateChanged, ct) (PublicSessionScheduleService.cs:152-164): reads the snapshot and raises the "showing cached data" banner only when one actually answered (:157-161).
  • Why it's built this way: the offline behavior is a property of this list surface, so it lives in a service the page injects rather than in the page or in the generic session service. Both the live service and the snapshot stay independently testable, and no other consumer of ISessionUIService inherits the caching.
  • Where it's used: registered explicitly as IPublicSessionScheduleService at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:55, under the comment naming ADR-042 Wave 3 (:54), and injected into PublicSessionList (Pages/Public/Sessions/PublicSessionList.razor.cs:33), whose grid data delegate calls FetchPageAsync at :321.
  • Caveats / not-in-source: the snapshot is best-effort. Nothing here expires it, so on a device that has been offline for a long time the cached first page can be arbitrarily old; the only signal to the user is the banner the onCacheStateChanged callback raises. What the store does on a write failure is the store's concern, not visible in this class.

SpeakerInfo

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerLookupService.cs:9 · Level 0 · record

  • What it is: the speaker-side lookup projection, three fields wide: Id, FullName, and a nullable ProfilePicture (ISpeakerLookupService.cs:9-12). It is what pages index when they hold a speaker id and must render a name or an avatar.
  • Depends on: no first-party types. SpeakerIdentifierType is System.Guid in this module (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:23), unlike the other Conference aliases, which are int, because speakers carry Sessionize-assigned GUIDs (BR-61, noted at MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3).
  • Concept: the UI lookup projection introduced by CategoryItemInfo; see there for the mechanism and the reason a page pre-loads a dictionary. [Rubric §18, UI Architecture]. ProfilePicture is nullable because not every speaker has an image, so the render path must have a fallback branch.
  • Walkthrough: a positional record with three members and no body (ISpeakerLookupService.cs:9-12); it is filled member for member from the speaker DTO in SpeakerLookupService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerLookupService.cs:125-126).
  • Where it's used: returned as the dictionary value of ISpeakerLookupService.GetAllAsync (ISpeakerLookupService.cs:19-20), which the session and speaker surfaces inject to enrich speaker ids.
  • Caveats / not-in-source: the name is reused. A separate, unrelated SpeakerInfo record exists in the Conference application layer for AI session scoring, documented as SpeakerInfo. They share only a name; the namespaces keep them apart.

SpeakerDetailLookups

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerDetailLookupService.cs:14 · Level 1 · record

  • What it is: the three display lookups the speaker detail page needs before it can render a speaker, gathered into one record: the category items assigned to the speaker, the titles of the categories those items belong to, and the text of the questions the speaker answered (ISpeakerDetailLookupService.cs:14-17).
  • Depends on: CategoryItemInfo as the value type of its first dictionary, plus the CategoryItemIdentifierType, ConferenceCategoryIdentifierType, and QuestionIdentifierType aliases. BCL IReadOnlyDictionary.
  • Concept introduced, the composite lookup bundle. [Rubric §19, State Management] (assesses how many independent pieces of loaded state a component has to track and invalidate). The interface's doc comment states the problem it removes: the page used to issue the three loads itself and carry three nullable caches with a failure branch each, so this gathers them into one call answering with one Result<T>, leaving the page a single point of failure to report and a single cache to check (ISpeakerDetailLookupService.cs:19-24). [Rubric §1, SOLID]: the bundle is what makes the single-method composite interface possible at all, since a method can only return one value.
  • Walkthrough: a positional record with three dictionary members and no body.
    • CategoryItems (ISpeakerDetailLookupService.cs:15): IReadOnlyDictionary<CategoryItemIdentifierType, CategoryItemInfo>, for the assigned-items panel.
    • CategoryTitles (line 15): IReadOnlyDictionary<ConferenceCategoryIdentifierType, string>, for grouping those items.
    • QuestionTexts (line 16): IReadOnlyDictionary<QuestionIdentifierType, string>, for the answers section.
  • Why it's built this way: the three sources keep their own services because other pages share them; SpeakerDetailLookupService only composes them, short-circuiting on the first failure (SpeakerDetailLookupService.cs:22-39) so the caller reports one error instead of branching three times. Note the composition is sequential, not parallel: each await completes before the next begins.
  • Where it's used: SpeakerDetail holds exactly one nullable field of this type (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDetail.razor.cs:74), loads it once when it is still null (SpeakerDetail.razor.cs:128-136), and reads QuestionTexts when rendering an answer, falling back to the raw id when a question is missing (SpeakerDetail.razor.cs:198). CategoryTitles is handed down to SpeakerCategoryItemsPanel as a parameter (SpeakerCategoryItemsPanel.razor.cs:30, read at :36-37).

ActivityFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityFormModel.cs:26 · Level 2 · class (abstract)

  • What it is: the editable activity fields, declared once, for both the create page and the detail page's inline editor. It carries the DataAnnotations rules for the text fields, the four half-picked schedule values, and the computed properties that recombine them.
  • Depends on: System.ComponentModel.DataAnnotations (:1) and ActivityDTO (:2), which owns the length constants the rules quote. Its rules are executed through ModelValidation and DataAnnotationsModelValidator. VenueUrl also carries AbsoluteUrlAttribute (:80).
  • Concept introduced, one form model behind two forms. This is the shape every Conference create/edit pair in this unit now uses, and it is worth reading once here.
    1. Rules declared once, not per control. Each property carries its own [Required] / [MaxLength] with the length taken from the DTO constant (:35-53), and the pages bridge MudBlazor's per-field validation to those attributes with a single delegate built by ModelValidation.For(...) rather than repeating Required="true" and RequiredError="..." on every MudTextField (class doc, :7-13). Because both pages bind the same ActivityFormFields component to an instance of this type, a name the create page accepts is a name the detail page accepts. [Rubric §24, Forms, Validation & UX Safety] (assesses whether validation is expressed once and consistently) and [Rubric §15, Best Practices & Code Quality]: adding a field is one property here plus one control in the shared field block.
    2. Error messages are resource keys, not sentences. ErrorMessage = "Error.NameMaxLength" (:36) is a key that the page's localizing validator resolves at render time, which is what lets DataAnnotations participate in the localization strategy of ADR-027. [Rubric §27, Internationalization]. The NameRequiredKey constant (:32) exists so the model rule and the field's own RequiredError affordance quote one key instead of two copies of a literal.
    3. The rule DataAnnotations cannot express stays out. The start/end window is four separate properties (:56-65) because two DateTime values are picked as four controls; no attribute can state "end after start", so the model exposes the raw parts plus the derived answers and leaves the check to the page, which owns the wording of the three schedule messages (class doc, :18-23). [Rubric §1, SOLID]: the model states what it can state and does not pretend to own the rest.
  • Walkthrough
    • Text fields (:35-53): Name (required, ActivityDTO.NameMaxLength), Description, VenueName, VenueAddress, VenueUrl, each capped from the DTO constant; VenueUrl also runs the shared [AbsoluteUrl(ErrorMessage = "Error.AbsoluteUrl")] rule (:80), rejecting a relative or scheme-less link before it is stored.
    • Schedule parts (:56-65): StartDate / StartTime and EndDate / EndTime, nullable so "not picked yet" is representable.
    • SortOrder (:68): the tie-break between activities starting at the same instant.
    • Derived (:71-86): HasStart and HasEnd report whether both halves are present; StartsAt and EndsAt collapse a date plus a TimeSpan into one DateTime and are documented as valid only once the matching Has* is true (:76-79, :82-85).
  • Why it's built this way: the create form and the edit form of the same aggregate diverging is a classic and invisible defect (a field that is required on one and optional on the other). Making the shared base the only declaration site removes the possibility rather than testing for it.
  • Where it's used: subclassed by ActivityCreateModel and ActivityEditModel; bound by ActivityCreate (ActivityCreate.razor.cs:90) and ActivityDetail (ActivityDetail.razor.cs:71) through the shared ActivityFormFields component.

ISpeakerDetailLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerDetailLookupService.cs:25 · Level 3 · interface

  • What it is: a one-method composite lookup contract for the speaker detail page. One call gathers the three display lookups that page needs before it can render a speaker: the category items assigned to the speaker, the titles of the categories those items belong to, and the text of the questions the speaker answered (ISpeakerDetailLookupService.cs:25-30). The file also declares the payload record SpeakerDetailLookups (ISpeakerDetailLookupService.cs:14-17).
  • Depends on: SpeakerDetailLookups (the gathered shape), CategoryItemInfo (carried inside it), the CategoryItemIdentifierType / ConferenceCategoryIdentifierType / QuestionIdentifierType aliases, and Result from MMCA.Common.Shared.Abstractions (ISpeakerDetailLookupService.cs:2).
  • Concept introduced, the composite lookup that collapses a page's failure branches. The three underlying lookups already exist as separate services, each shared with other pages. What this contract adds is aggregation of the outcome: the doc comment records the shape it replaced, a page that issued the three loads itself, carried three nullable caches, and repeated a failure branch for each (ISpeakerDetailLookupService.cs:19-24). Gathering them behind one Task<Result<SpeakerDetailLookups>> gives the page a single point of failure to report and a single cache field to check. [Rubric §18, UI Architecture] assesses whether page code-behind stays a rendering concern rather than an orchestration one; here the orchestration moved down into a service and the page keeps one if. [Rubric §15, Best Practices & Code Quality] assesses the cost of adding a fourth lookup later: it becomes one more member on the record plus one more step in the implementation, and no new branch on the page. The railway convention itself is taught at Result, and the UI-side branching helpers live on ResultUiExtensions.
  • Walkthrough: one member.
    • GetAllAsync(CancellationToken cancellationToken = default) (ISpeakerDetailLookupService.cs:30): returns Task<Result<SpeakerDetailLookups>>. The contract states the failure rule explicitly, a failure in any one of the three loads fails the whole call (ISpeakerDetailLookupService.cs:27), and names the token's meaning, cancelled when the calling page is disposed (ISpeakerDetailLookupService.cs:28).
  • Why it's built this way: the three sources stay independent services because other pages consume them individually; only the composition is speaker-detail-specific, so only the composition gets a new type. The implementation short-circuits on the first failure, which is what makes "one error to report" true rather than aspirational (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerDetailLookupService.cs:22-35).
  • Where it's used: implemented by SpeakerDetailLookupService, registered scoped in the Conference UI composition root under a comment that repeats the rationale (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:62-64), and injected into exactly one page, SpeakerDetail (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDetail.razor.cs:30).

ISpeakerLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerLookupService.cs:17 · Level 3 · interface

  • What it is: the speaker display-enrichment contract. One call returns a speaker-keyed dictionary of SpeakerInfo so a grid or detail page can turn the speaker ids on a session into names and avatars without a request per row (ISpeakerLookupService.cs:17-27). The same file declares the SpeakerInfo record it emits (ISpeakerLookupService.cs:9-12).
  • Depends on: SpeakerInfo, the SpeakerIdentifierType alias, and Result (ISpeakerLookupService.cs:1).
  • Concept: the bulk-then-index lookup, the same shape as ICategoryItemLookupService and IEventLookupService: fetch one flat collection once, project it into a dictionary keyed by id, and let every consuming row do an O(1) TryGetValue instead of a fetch. [Rubric §23, Front-End Performance] assesses whether the front end avoids per-row network work; this contract is the shape that makes that possible. [Rubric §18, UI Architecture] assesses whether pages talk to typed services rather than raw HTTP; the page sees one method and no URL.
  • Walkthrough: two members.
    • GetAllAsync(CancellationToken cancellationToken = default) returning Task<Result<IReadOnlyDictionary<SpeakerIdentifierType, SpeakerInfo>>> (ISpeakerLookupService.cs:19-20). The Result wrapper is what distinguishes this from a naive dictionary-returning helper: a transport fault or a failed response arrives as a failure the page can render, never as an empty dictionary that would look like "this event has no speakers".
    • void Invalidate() (ISpeakerLookupService.cs:26): drops the implementation's memoized lookup so the next GetAllAsync refetches. Its doc comment names the callers: the write services, after a successful create, update, delete or Sessionize refresh (ISpeakerLookupService.cs:22-25). That makes the contract stateful: a consumer may now see a cached answer, and a writer has a way to evict it.
  • Why it's built this way: the enrichment payload is deliberately narrower than SpeakerDTO, just id, full name and profile picture (ISpeakerLookupService.cs:9-12), so a change to the transport DTO that does not touch those three fields never reaches the consuming pages.
  • Where it's used: implemented by SpeakerLookupService and registered scoped (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:58). Injected into SessionDetail (Pages/Session/SessionDetail.razor.cs:25), SessionList (Pages/Session/SessionList.razor.cs:26), PublicSessionList (Pages/Public/PublicSessionList.razor.cs:33), PublicSessionDetail (Pages/Public/PublicSessionDetail.razor.cs:25), and taken as a constructor argument by the SessionLookups helper that the session pages share (Pages/Session/SessionLookups.cs:31).

ISpeakerDashboardUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerDashboardUIService.cs:10 · Level 3 · interface

  • What it is: a bespoke, non-CRUD UI-service contract for a speaker's personalized dashboard: the sessions the speaker presents, per-session bookmark counts (single and batched), and per-session feedback (ISpeakerDashboardUIService.cs:10-45). It does not extend IEntityService<TEntityDTO, TIdentifierType>; it is its own read-only interface over the Conference contracts and Result<T> (ISpeakerDashboardUIService.cs:1-3).
  • Depends on: SessionDTO and SessionFeedbackDTO, the SpeakerIdentifierType alias (a Guid in this module, MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:23) and SessionIdentifierType.
  • Concept introduced, a cache-bypassing personalized read. [Rubric §23, Front-End Performance] and [Rubric §19, State Management] (assess how the front end balances shared caching against read-your-writes freshness for a personalized view). The doc comment on GetSpeakerSessionsAsync (ISpeakerDashboardUIService.cs:12-17) is explicit and load-bearing: this read is fetched fresh, bypassing the shared sessions output cache, so a just-made speaker assignment is reflected immediately. Without the bypass, a read-populate-after-evict race on the output cache could leave a freshly assigned speaker seeing "no sessions". The contract, not just the implementation, is where that decision is written down; the implementation honors it with a unique _= cache-bust query parameter that makes this one read a guaranteed miss while public list reads keep their cache (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerDashboardService.cs:31-38).
  • Walkthrough: four read methods, all SpeakerIdentifierType-scoped.
    • GetSpeakerSessionsAsync(speakerId, ct) (ISpeakerDashboardUIService.cs:18-20): returns Task<Result<IReadOnlyList<SessionDTO>>>, the speaker's sessions, uncached. The implementation filters server-side on the virtual SpeakerId key with a page size capped at 100 and no child collections (SpeakerDashboardService.cs:18,38).
    • GetSessionBookmarkCountAsync(speakerId, sessionId, ct) (ISpeakerDashboardUIService.cs:22-25): returns Task<Result<int>>, the bookmark count for one of the speaker's sessions.
    • GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct) (ISpeakerDashboardUIService.cs:32-35): returns Task<Result<IReadOnlyDictionary<SessionIdentifierType, int>>>, every requested session's active bookmark count in a single request. The doc comment (lines 27-31) records that it replaces the dashboard's per-session fan-out, that only sessions assigned to the speaker come back, and that sessions with no bookmarks map to 0. That is the [Rubric §12, Performance & Scalability] point in one signature: an N+1 of HTTP calls collapsed into one.
    • GetSessionFeedbackAsync(speakerId, sessionId, ct) (ISpeakerDashboardUIService.cs:41-44): returns Task<Result<SessionFeedbackDTO>>. Its doc comment (lines 37-40) states the modelling decision: "no feedback captured yet" arrives as an ErrorType.NotFound failure, not as a success carrying null, so the page tells that legitimate domain state apart from a real load failure (SpeakerDashboardService.cs:68-71).
  • Why it's built this way: keeping these on a dedicated interface, rather than folding them into ISessionUIService, isolates the cache-bypass semantics to the personalized surface and keeps the generic session CRUD cache-friendly. It also keeps the speaker-scoped authorization story simple: every method takes the speaker id explicitly, so the server has the subject it needs to check ownership on every call [Rubric §11, Security].
  • Where it's used: implemented by SpeakerDashboardService (SpeakerDashboardService.cs:14-16) and registered explicitly at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:40 (an explicit AddScoped because it is not an IEntityService<,> and the assembly scan would not find it); injected into SpeakerDashboard (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDashboard.razor.cs:26), which calls three of the four methods (SpeakerDashboard.razor.cs:109, :136, :269).
  • Caveats / not-in-source: GetSessionBookmarkCountAsync, the single-session count, has no page consumer today; the dashboard uses the batched form. It remains on the contract, is implemented (SpeakerDashboardService.cs:45), and is covered by unit tests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Services/SpeakerDashboardServiceTests.cs:121,135).

ActivityCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityCreateModel.cs:11 · Level 3 · class (sealed)

  • What it is: the create page's binding target. It adds nothing to ActivityFormModel except the mapping onto the DTO that gets posted.
  • Depends on: ActivityFormModel (:11), ActivityDTO and the EventIdentifierType alias (:1,19).
  • Concept introduced: none new. It is the create half of the shared-form-model pattern ActivityFormModel teaches.
  • Walkthrough: ToNew(EventIdentifierType eventId) (:19-32) is the only member. It sends Id = default (:22) and lets the server mint the key, sets StartTime / EndTime from the base's combined StartsAt / EndsAt (:25-26), and stamps the owning event from the argument (:31). [Rubric §8, Data Architecture] (assesses a deliberate identity strategy): compare SpeakerCreateModel, which mints its own Guid client-side because a speaker's key is a GUID rather than a server-assigned int.
  • Why it's built this way: the owning event is a create-time-only decision (the class doc says so at :5-9), so it is a parameter of the mapping rather than a property on the shared base that the edit form would also have to bind.
  • Where it's used: ActivityCreate holds one instance (ActivityCreate.razor.cs:90) and calls ToNew on the save path (ActivityCreate.razor.cs:200).

ActivityEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityEditModel.cs:12 · Level 3 · class (sealed)

  • What it is: the detail page's inline-editor binding target: the same shared fields plus the two mappings an edit needs, load-from-record and build-the-update.
  • Depends on: ActivityFormModel (:12) and ActivityDTO (:1).
  • Concept introduced, the edit buffer as a typed object. The older shape of these pages kept a row of loose _edit* fields on the code-behind; folding them into a model class means the buffer has a name, the copy-in and copy-out are two methods rather than twenty assignments, and the same DataAnnotations that guard the create form guard the editor. The loaded ActivityDTO is never mutated: cancelling an edit simply abandons this object. [Rubric §19, State Management & Data Flow] (assesses where mutable state lives and how long it lives). ToUpdated also carries the concurrency token forward: RowVersion = activity.RowVersion (:45) is the client half of the optimistic-concurrency contract in ADR-035, so a stale editor loses the write instead of silently overwriting a newer one. [Rubric §8, Data Architecture].
  • Walkthrough
    • LoadFrom(ActivityDTO) (:16-34): null-guards, then copies the ten editable values off the loaded record, splitting the two instants back into the four picker halves (:22-25).
    • ToUpdated(ActivityDTO) (:38-56): rebuilds the DTO from the edited values over three preserved fields, Id (:44), RowVersion (:45) and EventId (:54). Preserving the event id here, rather than binding it, is what enforces the class doc's rule (:6-10) that moving an activity between events is a create plus a delete.
  • Why it's built this way: an unmapped field is the failure mode of a hand-rolled edit form; keeping copy-in and copy-out adjacent in one small file makes the two halves reviewable against each other.
  • Where it's used: ActivityDetail holds one instance (ActivityDetail.razor.cs:71), seeds it in StartEditing (ActivityDetail.razor.cs:151) and posts ToUpdated from SaveChangesAsync (ActivityDetail.razor.cs:222).

ISpeakerUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerUIService.cs:10 · Level 4 · interface

  • What it is: the UI-service contract for the speakers resource, extending generic CRUD with two user-linking operations (ISpeakerUIService.cs:10-15).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> bound to SpeakerDTO, Result (ISpeakerUIService.cs:2), and the SpeakerIdentifierType / UserIdentifierType aliases (the second crosses into Identity's vocabulary as a scalar, never as a project reference).
  • Concept: [Rubric §9, API & Contract Design], state-transition verbs over generic update, the same rationale as IEventUIService. Linking a speaker to a user account is a distinct operation, not a field edit, so it gets LinkUserAsync / UnlinkUserAsync. [Rubric §7, Microservices Readiness] assesses whether cross-module consistency travels over decoupled edges: the UI issues one call against Conference, and the Identity side of the association is reconciled asynchronously by the SpeakerLinkedToUser / SpeakerUnlinkedFromUser integration events, so this contract deliberately says nothing about Identity. [Rubric §18, UI Architecture].
  • Walkthrough: two added members, both returning Result.
    • LinkUserAsync(SpeakerIdentifierType speakerId, UserIdentifierType userId, CancellationToken) (ISpeakerUIService.cs:12).
    • UnlinkUserAsync(SpeakerIdentifierType speakerId, CancellationToken) (ISpeakerUIService.cs:14); unlink needs only the speaker id, because the association is single-valued from the speaker's side.
  • Why it's built this way: a valueless Result is the right return shape for a confirm-only call. There is no payload worth deserializing, but the failure still has to carry the server's ErrorType so the page can tell "already linked" from "not permitted".
  • Where it's used: implemented by SpeakerService; injected into SpeakerList (Pages/Speaker/SpeakerList.razor.cs:23), SpeakerDetail's link-to-user control (Pages/Speaker/SpeakerDetail.razor.cs:27), SpeakerCreate (Pages/Speaker/SpeakerCreate.razor.cs:17), SpeakerDashboard (Pages/Speaker/SpeakerDashboard.razor.cs:23) and the public speaker pages (PublicSpeakerList :42, PublicSpeakerDetail :22).

SpeakerDashboardService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerDashboardService.cs:14 · Level 4 · class (sealed)

  • What it is: the bespoke authenticated HTTP service behind the speaker's own dashboard. It exposes four speaker-scoped reads: the sessions this speaker presents, how many attendees bookmarked one of those sessions (single and batched variants), and the aggregated feedback for a session. It extends AuthenticatedServiceBase directly rather than the CRUD base, and implements ISpeakerDashboardUIService (SpeakerDashboardService.cs:14-16).
  • Depends on: AuthenticatedServiceBase for CreateAuthenticatedClientAsync() and the shared static Polly RetryPolicy (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:27,59); HttpResultExecutor and ProblemDetailsResultReader; PagedCollectionResult<T>; SessionDTO and SessionFeedbackDTO; ITokenStorageService; the SpeakerIdentifierType / SessionIdentifierType aliases; BCL IHttpClientFactory, CultureInfo.InvariantCulture, Guid.
  • Concept introduced, the server-filtered, cache-bypassing dashboard read. These are computed, actor-scoped reads, not an entity surface, so the class drops to the thinner authenticated base and hand-builds each request. Two mechanisms inside GetSpeakerSessionsAsync are load-bearing, and the inline comment block (SpeakerDashboardService.cs:25-34) explains both.
    • Filter on the server, not in memory. The URL sends filters[SpeakerId].operator=equals and filters[SpeakerId].value={speakerId} (SpeakerDashboardService.cs:38), a virtual filter key. Session has no SpeakerId column, so SessionsController removes the key from the filter dictionary, parses it with TryParse under the invariant culture, and resolves it through the SessionSpeaker join before ANDing the result with the public-session specification (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:119-128, documented at :90-94). The request also asks for includeFKs=false&includeChildren=false and caps the page at MaxSpeakerSessions = 100 (SpeakerDashboardService.cs:19,38). The comment records what this replaced: fetching the whole catalog with every child collection and filtering client-side. [Rubric §12, Performance & Scalability] (assesses whether work happens where the data is; the predicate and the paging both run in the database) and [Rubric §23, Front-End Performance] (assesses payload size on the wire; the dashboard renders no child collections, so it asks for none).
    • A deliberate cache bust. A unique _={Guid:N} query parameter (SpeakerDashboardService.cs:35,38) makes this one read a guaranteed miss against the shared output cache. The reason is a read-populate-after-evict race: a read that began before a session assignment can populate the cache after the assignment's eviction fired, so the cached list lags a change the speaker just made and must see. Public and anonymous list reads keep their caching (SpeakerDashboardService.cs:31-34). [Rubric §19, State Management] (assesses how the UI keeps what it shows consistent with the server, and at what cost). The bookmark counts are produced server-side from the Engagement service across the gRPC boundary (ADR-007); the UI sees only this one typed contract, which is what lets Engagement move without touching this file. [Rubric §7, Microservices Readiness].
  • Walkthrough
    • MaxSpeakerSessions = 100 (SpeakerDashboardService.cs:19): an upper bound, on the stated reasoning that a conference speaker presents a handful of sessions (:18).
    • GetSpeakerSessionsAsync(speakerId, ct) (SpeakerDashboardService.cs:21-43) mints the cache bust (:35), builds the paged URL with string.Create(CultureInfo.InvariantCulture, ...) sorted StartsAt asc (:36-38), dispatches through the private SendGetRequestAsync<T> into a PagedCollectionResult<SessionDTO> because the paged endpoint answers with an envelope rather than a bare array (:40-41), then Maps the envelope to the item list, substituting an empty list for a null Items (:42). Map runs only on success and a failure passes through untouched, which is what makes the one-line projection safe.
    • GetSessionBookmarkCountAsync(speakerId, sessionId, ct) (SpeakerDashboardService.cs:45-52) GETs speakers/{speakerId}/sessions/{sessionId}/bookmarks/count (:50) and returns the deserialized count as a Result<int>.
    • GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct) (SpeakerDashboardService.cs:54-66) is the batched variant. It short-circuits an empty id list into a successful empty dictionary with no round trip (:59-60), joins one repeated sessionIds= parameter per id (:62-63), and maps the concrete Dictionary up to the read-only interface the caller declares (:64-65). One request for a whole grid instead of one per row.
    • GetSessionFeedbackAsync(speakerId, sessionId, ct) (SpeakerDashboardService.cs:73-80) GETs speakers/{speakerId}/sessions/{sessionId}/feedback (:78). "No feedback captured yet" is a legitimate domain state and arrives as an ErrorType.NotFound failure, which the caller tells apart from a real load failure with IsNotFound(), as the doc comment states (:68-72). The typed error is the branch; there is no sentinel value to interpret.
    • SendGetRequestAsync<T>(url, ct) (SpeakerDashboardService.cs:92-104) is the central GET dispatch. HttpResultExecutor.ExecuteAsync wraps the body (:95), the client carries the bearer token (:98), the GET runs through the Polly RetryPolicy (:99-101), and ProblemDetailsResultReader turns the response into a Result<T> (:102).
    • Every URL is built with string.Create(CultureInfo.InvariantCulture, ...) and every call leads with speakerId, so the server scopes the read to the calling speaker rather than trusting a filter. [Rubric §11, Security] (assesses whether authorization scope is enforced by the resource shape rather than by client-side filtering).
  • Why it's built this way: aggregate dashboard reads fit neither the entity-CRUD nor the lookup shape, so they get their own narrow service. The cache bust trades one extra origin fetch for correctness on the one surface where staleness is unacceptable, while the anonymous public list keeps its output cache.
  • Where it's used: registered explicitly as ISpeakerDashboardUIService at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:40, and injected into SpeakerDashboard, the speaker's "My Sessions" page (Pages/Speaker/SpeakerDashboard.razor.cs:24).
  • Caveats / not-in-source: the retry-plus-reader dispatch in SendGetRequestAsync is a deliberate private copy of the EntityServiceBase.SendRequestAsync semantics rather than inherited behavior, as the doc comment says (SpeakerDashboardService.cs:82-88), so the two must be kept in step by hand. The 100-session page cap (:19,38) is an upper bound, not a paging mechanism: a speaker with more sessions would silently see only the first 100.

SpeakerLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerLookupService.cs:29 · Level 4 · class (sealed)

  • What it is: a read service that fetches every speaker and builds a speaker-keyed lookup dictionary (SpeakerIdentifierType to SpeakerInfo), so pages holding raw speaker ids can render display names and profile pictures (SpeakerLookupService.cs:29-131). It memoizes that lookup per DI scope for five minutes (SpeakerLookupService.cs:39). It implements ISpeakerLookupService.
  • Depends on: SpeakerInfo, the three-field projection it emits (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerLookupService.cs:9-12); SpeakerDTO as the wire shape; PagedCollectionResult<T>; PagedReadAll for the page loop and the page URL; IdempotentReadRetry for the GET; HttpResultExecutor and ProblemDetailsResultReader; BCL IHttpClientFactory. BCL TimeProvider supplies the clock for the memo's expiry (SpeakerLookupService.cs:29, :73, :113). Note what is absent: the primary constructor takes only IHttpClientFactory and TimeProvider (SpeakerLookupService.cs:29), with no ITokenStorageService and no AuthenticatedServiceBase base. This is an unauthenticated public read. It still gets that base's Polly retry, but by composition: IdempotentReadRetry.Policy is the same AuthenticatedServiceBase.SharedRetryPolicy object (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/IdempotentReadRetry.cs:24).
  • Concept introduced, the client-side denormalizing lookup. Session and event pages hold speaker ids but must show speaker names. Rather than issue one fetch per referenced speaker, this service pulls the whole speaker set in a short run of 500-row pages and hands back an in-memory dictionary the page indexes locally; the doc comment states exactly that use (SpeakerLookupService.cs:8-11). The second idea is the per-scope single-flight memo, the same shape as EventLookupService (remarks, SpeakerLookupService.cs:12-28). The cached unit is the Task, not the result (_cached, :42), so concurrent callers in one scope share one in-flight fetch, and a successful fetch is reused until _cachedUntil (:49). The public session list and detail pages both read the full speaker list, so without the memo an attendee moving between them on conference day paid one identical read per navigation, each counted against the gateway's per-client-IP window that a whole venue Wi-Fi shares (remarks, :14-19). It is per scope rather than per process because the payload is role-dependent (BR-239: a privileged caller sees speakers the public projection hides), so it must never be shared across users (:22-26). [Rubric §23, Front-End Performance] (assesses whether a view avoids N per-item round trips; one paged sweep serves a whole grid). [Rubric §9, API & Contract Design] shows up in the URL PagedReadAll.LookupPageUrl builds: speakers/paged?...&pageSize=500&sortColumn=Id&sortDirection=asc&includeFKs=false&includeChildren=false (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:43-46). The stable id order keeps consecutive pages from skipping or repeating a row, and the two include flags ask for the flat rows only, so the bulk read stays cheap on both ends. The dictionary is keyed by the identifier alias rather than a bare primitive, which is what makes a wrong-id lookup a compile error (ADR-048).
  • Walkthrough
    • GetAllAsync(ct) (SpeakerLookupService.cs:66-83) is now a memo front, not the fetch itself. It reads _cached (:69); when that is null, or the task is completed and the clock has reached _cachedUntil, it zeroes _cachedUntil, starts FetchAsync() and stores the new task (:73-78). Anything in flight is reused (single-flight), and a faulted, canceled or failed-Result task expires at once because _cachedUntil is only stamped on success. The shared fetch runs WITHOUT the caller's token; a cancelable caller token is applied with entry.WaitAsync(token) instead (:80-82), so a page disposing mid-load abandons only its own await and never cancels the fetch other callers share.
    • Invalidate() (:58-64) bumps _generation, nulls _cached and resets _cachedUntil. An in-flight fetch completes unobserved. FetchAsync captures the generation on entry (:87) and stamps _cachedUntil = now + CacheTtl only when the Result succeeded and the generation is still current (:111-114), so a fetch a write superseded cannot stamp the entry that replaced it. CacheTtl is five minutes (:39), matched to the server's "SpeakersCache" output-cache policy (:32-38); a privileged reader bypasses that server cache, which is why the write services call Invalidate.
    • FetchAsync() (:85-117) is the former GetAllAsync body. The inline comment (:89-90) names why it pages: the GET-all endpoint ignores pageSize and stops silently at the API's maximum page size. It hands PagedReadAll.ReadAllAsync a per-page delegate (:91-105), every token argument now CancellationToken.None. Each page runs inside HttpResultExecutor.ExecuteAsync (:92), so a dead network becomes a transport failure rather than a thrown exception. The delegate resolves the named "APIClient" client from the factory (:95), GETs LookupPageUrl("speakers", pageNumber) through IdempotentReadRetry.GetAsync (:96-99), which re-sends on a transient failure up to three times with exponential backoff (IdempotentReadRetry.cs:10-12), and reads the response into a Result<PagedCollectionResult<SpeakerDTO>> through ProblemDetailsResultReader (:101-102). ReadAllAsync loops pages from 1, stopping on an empty page or once the accumulated count reaches the reported TotalItemCount, and returns the first failing page's errors unchanged (PagedReadAll.cs:65-89). The method then Maps the accumulated List<SpeakerDTO> through BuildLookup (SpeakerLookupService.cs:106). Mapping rather than unwrapping is the point: the projection runs only on success, and an API-described failure travels out with its ErrorType intact.
    • BuildLookup(speakers) (SpeakerLookupService.cs:119-130) is a private static taking the flattened List<SpeakerDTO>; the null-Items guard now lives in ReadAllAsync (PagedReadAll.cs:75). It fills a Dictionary<SpeakerIdentifierType, SpeakerInfo> with Id, FullName and ProfilePicture per speaker (SpeakerLookupService.cs:122-127) and returns it as an IReadOnlyDictionary (:129). Being static is not decoration: it proves the projection touches no instance state, so it reads in isolation.
  • Why it's built this way: one paged sweep plus a local index is far cheaper than per-id lookups when a page renders many speaker references, and the projection to SpeakerInfo keeps only the three display fields the UI needs rather than the full SpeakerDTO, so what a page holds in memory stays small. Paging to the reported total, instead of asking for one oversized page, is the only route that does not truncate silently at the API's page-size clamp (PagedReadAll.cs:12-17).
  • Where it's used: registered as ISpeakerLookupService among the "cross-module lookup services" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:58), and injected into SessionList (Pages/Sessions/SessionList.razor.cs:29), SessionDetail (Pages/Sessions/SessionDetail.razor.cs:30, passed on to SessionLookups at :58, whose constructor takes it at Pages/Sessions/SessionLookups.cs:34), PublicSessionList (Pages/Public/Sessions/PublicSessionList.razor.cs:35) and PublicSessionDetail (Pages/Public/Sessions/PublicSessionDetail.razor.cs:25). The Conference service's SelfHttpOutputCacheWarmupTask replays this service's first page URL as a literal (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:55), so the output cache is warm for it.
  • Caveats / not-in-source: ReadAllAsync stops after 40 pages of 500 (20,000 rows) whatever the server reports (PagedReadAll.cs:22-28), and returns success with what it has, so a speaker set past that would still be cut short without a signal. The warm-up literal must match LookupPageUrl byte for byte (PagedReadAll.cs:33-34); a change to the URL format silently turns the warm-up into a miss. The memo is unsynchronized by design (remarks, SpeakerLookupService.cs:24-26): it relies on Blazor dispatching a scope's callbacks on one logical thread, so the class must stay Scoped. A write made outside this scope (another organizer, or a Sessionize refresh the caller did not trigger) is not seen until the five-minute TTL lapses, because Invalidate is only called by this scope's own write services. This class sends no token itself, so what the payload contains depends on the named "APIClient" configuration and the server's role-dependent projection (BR-239), not on anything here.

SpeakerDetailLookupService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerDetailLookupService.cs:13 · Level 5 · class (sealed)

  • What it is: a composite read service that gathers the three display lookups the speaker detail page needs (the category items a speaker can be tagged with, the titles of the categories those items belong to, and the text of the questions a speaker answered) behind one call, answering with a single Result that carries either all three or the first failure (SpeakerDetailLookupService.cs:13-40). It implements ISpeakerDetailLookupService and its success value is the SpeakerDetailLookups record.
  • Depends on: three services injected through the primary constructor (SpeakerDetailLookupService.cs:13-17): ICategoryItemLookupService (implemented by CategoryItemLookupService), IConferenceCategoryUIService and IQuestionUIService, the last two being plain IEntityService<TEntityDTO, TIdentifierType> CRUD contracts (IConferenceCategoryUIService.cs:9-11, IQuestionUIService.cs:9-11). Also Result and its Map (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Result.cs:276-280), the TryGetValue extension from ResultUiExtensions (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:103), CategoryItemInfo, ConferenceCategoryDTO and QuestionDTO.
  • Concept introduced, the composite lookup service. Most Conference pages load their reference data one service at a time, and every extra load costs the page a nullable cache field, a call site and a failure branch. A composite lookup collapses that into one interface, one call, one value object and one failure branch. The load-bearing detail is that it composes, it does not replace: each underlying lookup keeps its own service because other pages use them independently, and this class holds no state and no cache of its own (SpeakerDetailLookupService.cs:13-18 declares nothing but the three injected dependencies). [Rubric §1, SOLID] (assesses whether a type has one reason to change): the page's job is rendering, and the gathering of its reference data now lives in one substitutable class, so a fourth lookup is a change here rather than a fourth branch in the page. [Rubric §18, UI Architecture] (assesses how presentation is layered above data access): the page depends on one narrow interface instead of three. [Rubric §2, Design Patterns] (assesses whether a recognized pattern is applied where it earns its keep): this is a facade over three collaborators, deliberately thin enough that it adds no behavior beyond ordering and failure short-circuiting.
  • Walkthrough (SpeakerDetailLookupService.cs:20-40, one method)
    • GetAllAsync(cancellationToken) starts with the category items: categoryItemLookupService.GetAllAsync(cancellationToken) (:20), then unwraps with the TryGetValue UI extension (:21). On failure it re-wraps the errors into a failure of the composite type, Result.Failure<SpeakerDetailLookups>(itemsResult.Errors) (:23), so the error list travels intact and nothing is invented at this layer.
    • The category load repeats the shape: categoryService.GetAllAsync(cancellationToken: cancellationToken) (:26), unwrap (:27), propagate errors (:29). The named argument is required because the contract's first two parameters are the includeFKs and includeChildren flags (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:25-28), so this call takes both defaults and asks only for the flat rows.
    • The question load is last, so it needs no early return: questionsResult.Map(...) (:32, :34) either projects the success value or passes the errors through unchanged (Result.cs:276-280).
    • The projection builds the SpeakerDetailLookups record (:34-37). The category-item dictionary arrives already keyed from ICategoryItemLookupService (ICategoryItemLookupService.cs:20-21), while the other two are lists indexed here: categories.ToDictionary(c => c.Id, c => c.Title) (:36) and questions.ToDictionary(q => q.Id, q => q.QuestionText) (:37). Both projections keep only the single display string the page renders, so the DTOs do not travel further into the UI.
  • Why it's built this way: the three loads are a set, not three independent concerns, because the page cannot render its category panel or its answers section until all three are present. Making them one call means one place decides the order and one place decides what a partial failure means, and here that decision is strict: a failure in any one of them fails the whole call (ISpeakerDetailLookupService.cs:27-30). Returning Result rather than throwing follows the framework-wide error contract (ADR-013).
  • Where it's used: registered explicitly rather than by the entity-service scan, because it implements no IEntityService<,>: services.AddScoped<ISpeakerDetailLookupService, SpeakerDetailLookupService>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:64). Its one consumer is SpeakerDetail, which injects it (Pages/Speaker/SpeakerDetail.razor.cs:29), holds the value in a single nullable field for the life of the page (SpeakerDetail.razor.cs:81), calls it once inside the guarded load (SpeakerDetail.razor.cs:129-141), renders QuestionTexts through GetQuestionText (SpeakerDetail.razor.cs:198-199) and hands CategoryItems plus CategoryTitles to SpeakerCategoryItemsPanel (Pages/Speaker/SpeakerDetail.razor:169-172). The interface is also what SpeakerDetailTests mocks (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Speakers/SpeakerDetailTests.cs:31), which is the practical payoff of the facade: one Mock<ISpeakerDetailLookupService> stands in for three collaborators. [Rubric §14, Testability] (assesses whether collaborators can be substituted at a narrow boundary).
  • Caveats / not-in-source: the three loads are sequential awaits (:20, :26, :32), not a Task.WhenAll, so the page pays the sum of the three round trips rather than the longest of them. [Rubric §23, Front-End Performance] (assesses how much latency the first render waits on). The short-circuit is ordered, so a category-item failure means the other two calls are never made and the page reports one cause even when several are broken. ToDictionary (:36-37) throws on duplicate keys and nothing here guards that; uniqueness comes from the server, since both keys are entity primary keys. Nothing in this class caches: the "cached for the life of the page" behavior is the page's own null check on its field (SpeakerDetail.razor.cs:129), not something this service does.

SpeakerService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerService.cs:19 · Level 5 · class (sealed)

  • What it is: the concrete HTTP service for the speakers WebAPI resource. It inherits the whole CRUD surface from EntityServiceBase<TEntityDTO, TIdentifierType>, overrides the three write verbs (AddAsync, UpdateAsync, DeleteAsync) to evict the scope's memoized speaker lookup after a success, and adds the two operations specific to speakers: linking a user account to a speaker record and unlinking it again (SpeakerService.cs:19-74). It implements ISpeakerUIService.
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> and, through it, AuthenticatedServiceBase; the base's Endpoint property (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:51) and its valueless SendRequestAsync overload (EntityServiceBase.cs:381-397); ITokenStorageService; SpeakerDTO; LinkUserRequest; ISpeakerLookupService, whose Invalidate() it calls (SpeakerService.cs:22, :69); ISpeakerUIService; Result; the SpeakerIdentifierType and UserIdentifierType aliases; BCL IHttpClientFactory and System.Net.Http.Json (SpeakerService.cs:1-5).
  • Concept: the thin-leaf CRUD service is taught at ActivityService; this is that shape plus a two-method extension, and the extension is the standard recipe for adding a non-CRUD verb to an entity service. Build the relative URI from the inherited Endpoint so the resource name still lives in exactly one place, and dispatch through the base's protected SendRequestAsync instead of an HttpClient of your own, which is what buys the call the authenticated client, the Polly retry pipeline and the Result conversion through ProblemDetailsResultReader (EntityServiceBase.cs:381-397). [Rubric §9, API & Contract Design] (assesses whether the client speaks the server's contract exactly rather than an assumed one): link is a PUT to {id}/link carrying a LinkUserRequest body and unlink is a DELETE to the same path, which is precisely what the controller declares (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerLinksController.cs:42, :59). [Rubric §2, Design Patterns] (assesses use of template-method style bases): the subclass supplies only what varies, the resource name and the two extra verbs.
  • Walkthrough
    • The primary constructor takes IHttpClientFactory, ITokenStorageService and ISpeakerLookupService; the first two are forwarded with the resource name to the base: EntityServiceBase<SpeakerDTO, SpeakerIdentifierType>("speakers", httpClientFactory, tokenStorageService) (SpeakerService.cs:19-24). The lookup service is held only for eviction. The base's fourth parameter, the optional IUiReadCache, is left at its null default (EntityServiceBase.cs:43-47), so every read here goes to the API.
    • AddAsync, UpdateAsync and DeleteAsync (SpeakerService.cs:27-30, :33-36, :39-42) are expression-bodied overrides that await the base call and pass its result through the private generic InvalidateOnSuccess<TResult> (:64-73), constrained to Result. On IsSuccess it calls speakerLookup.Invalidate() (:67-70); a failure leaves the memo alone. The class remarks say why it matters: organizers who write bypass the server's output cache, so the client memo was the only thing that would keep a new speaker out of the pickers and render a deleted one as present. Link and unlink do not change the lookup's rows, so they do not evict.
    • LinkUserAsync(speakerId, userId, cancellationToken) (SpeakerService.cs:44-53): calls the valueless SendRequestAsync with a lambda that PutAsJsonAsynces a new LinkUserRequest { UserId = userId } to new Uri($"{Endpoint}/{speakerId}/link", UriKind.Relative) (:49-52). The URI is relative because the base resolves the named API client, and its configured base address, inside CreateAuthenticatedClientAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:59-61), which is also where the bearer token is attached (AuthenticatedServiceBase.cs:65-70). The interpolated speakerId is culture-safe because SpeakerIdentifierType aliases System.Guid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:23, ADR-048).
    • UnlinkUserAsync(speakerId, cancellationToken) (SpeakerService.cs:55-62): the same dispatch with DeleteAsync against the identical path and no body (:59-61). Both methods return the Result the base produced, success for any 2xx and otherwise the errors the ProblemDetails response described (EntityServiceBase.cs:381-397); the server answers NoContent on both success paths (SpeakerLinksController.cs:57, :73).
    • Neither call passes an idempotencyKey or an ifMatch, both optional parameters of that overload (EntityServiceBase.cs:381-385). That matches the contract the base documents: a key is for non-idempotent writes (creates), and link and unlink are a PUT and a DELETE whose repetition lands on the same state (EntityServiceBase.cs:337-339, ADR-017).
  • Why it's built this way: linking a user to a speaker is a relationship operation, not a field edit, so it is its own endpoint rather than a PUT of the whole SpeakerDTO, and the client mirrors that. Keeping the two methods on the subclass, and on ISpeakerUIService which extends the generic contract (ISpeakerUIService.cs:10-15), means pages doing plain CRUD never see them while the organizer page that needs them gets them without a cast.
  • Where it's used: registered by the Scrutor scan inside AddUIModule<ConferenceUIModule>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31, which scans the module assembly for IEntityService<,> implementations at MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:323-327), so no explicit AddScoped line exists for it. Injected as ISpeakerUIService into SpeakerList (Pages/Speaker/SpeakerList.razor.cs:23), SpeakerDetail (Pages/Speaker/SpeakerDetail.razor.cs:27), SpeakerCreate (Pages/Speaker/SpeakerCreate.razor.cs:17), SpeakerDashboard (Pages/Speaker/SpeakerDashboard.razor.cs:23), PublicSpeakerList (Pages/Public/PublicSpeakerList.razor.cs:42) and PublicSpeakerDetail (Pages/Public/PublicSpeakerDetail.razor.cs:22). The two link verbs have exactly one caller each, both on SpeakerDetail (SpeakerDetail.razor.cs:302 and :327).
  • Caveats / not-in-source: the base invalidates its read cache only from the CRUD verbs, through its own private InvalidateOnSuccess (EntityServiceBase.cs:165, :187, :213, :281-288; not the same method as the subclass's private generic one at SpeakerService.cs:64), so the two link verbs here evict nothing. That is inert as written, because this service is constructed without an IUiReadCache; the server-side output cache is evicted by the controller itself on both paths (SpeakerLinksController.cs:56, :72, ADR-040). Authorization for both endpoints is enforced server side with [HasPermission(ConferencePermissions.SpeakersManage)] (SpeakerLinksController.cs:41, :60, ADR-020): the client sends the bearer token and reports whatever the server decides.

ActivityCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityCreate.razor.cs:20 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer form that schedules an activity, meaning the non-session items on a programme (registration, breaks, receptions, after-parties). It collects the name, the owning event, the start and end of the window as separate date and time pickers, the display order, and an optional off-site venue (class doc, :12-17).
  • Depends on: IActivityUIService (:22), IEventLookupService returning EventInfo (:23,87), IToastService (:25), ActivityCreateModel (:90), CurrentEventSelector (:70), ModelValidation with DataAnnotationsModelValidator (:50), ConferenceRoutePaths (:45,218,230), and ErrorMessages (:193,199). Externals: NavigationManager (:24), TimeProvider (:26), the ComponentLifetimeExtensions.LifetimeToken extension on the page's CancellationTokenSource (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26), MudBlazor's MudForm and BreadcrumbItem, and the IStringLocalizer<ActivityCreate> from the template (ActivityCreate.razor:6).
  • Concept introduced, the cross-field rule that lives outside the form. Every other rule on this page is declared on ActivityFormModel and executed by MudBlazor through the shared _validate delegate. The schedule window cannot be: no DataAnnotation states "end after start" across four controls. ValidateSchedule (:157-179) therefore checks the model's own HasStart / HasEnd / StartsAt / EndsAt and puts its message in _scheduleError (:161,167,173) instead of the form's error list, and the markup renders that string in its own MudAlert above the shared error summary (ActivityCreate.razor:30-33, summary at :41). A cross-field failure therefore reads like every other validation error to the organizer even though MudForm knows nothing about it. [Rubric §24, Forms, Validation & UX Safety] (assesses whether a form can express only legal input and explains a rejection in place): the check runs before anything is posted (:197) and the snackbar quotes the specific message rather than a generic one (:199). The second idea is the two-stage smart default, which removes the two most common clicks without hiding either field.
    1. The event picker is seeded with CurrentEventSelector.SelectCurrentOrNext evaluated over each event's start date, end date and IANA time zone against TimeProvider.GetUtcNow().UtcDateTime (:68-75), rather than the weaker "auto-select when exactly one exists" rule. The clock is the injected TimeProvider (:26), not the static DateTime.UtcNow, so "current or next" is a dependency a test can control. The event stage uses ??= (:68), so an event already set is kept.
    2. ApplyEventDateDefaults (:131-150) then seeds both date pickers with the selected event's first day (:138-147), and re-runs on every event change through OnEventSelected (:115-119). It remembers the day it last seeded in _seededDay (:125, set at :149), and overwrites a picker only when it is empty or still holds that seeded day (:139, :144). So an untouched date follows the next event the organizer picks, while a date the organizer chose is kept (doc, :127-130). [Rubric §24, Forms, Validation & UX Safety]. The event lookup is explicitly non-critical (comment, :59-60): a failed load leaves the picker empty and the required-event check at :191 guides the user, rather than blocking the page. [Rubric §29, Resilience & Business Continuity]. The third idea is the same inline error result alongside the toast SpeakerCreate now carries. _saveResult (:99-103) records the last create attempt's Result, reset to null before each attempt (:188) and set from the service call (:207). On failure CreateActivityAsync calls created.NotifyOnFailure(Toast, L) (:212) instead of a bare Toast.Error, so the message reaches the page's ErrorSummary component as well as the snackbar. The fourth idea is the disposal-safe lifetime token. The page no longer reads _cts.Token directly; it goes through a private LifetimeToken property that calls _cts.LifetimeToken() (:33). Reading Token off a disposed source throws ObjectDisposedException; the extension instead returns an already-cancelled token once the source is cancelled, disposed or null (ComponentLifetimeExtensions.cs:20-26, :28-30). Dispose cancels before it disposes (:243-244), so a load or save that resumes after the user navigated away sees a cancelled token and leaves through its OperationCanceledException path rather than crashing (comment, :30-32). Both awaited service calls use it: the event lookup (:61) and the create post (:206). [Rubric §29, Resilience & Business Continuity].
  • Walkthrough
    • OnInitialized (:39-51): three breadcrumbs, then the validation delegate over _model.
    • OnInitializedAsync (:53-83): loads the events through LifetimeToken (:61), resolves the default event, applies the date defaults (:77), and swallows OperationCanceledException as the expected disposal or InteractiveAuto transition outcome (:79-82, ADR-056).
    • CreateActivityAsync (:181-228): null-guard the form (:183-186), clear _saveResult (:188), validate and re-check _eventId is null (:190-195), run ValidateSchedule (:197-201), post _model.ToNew(_eventId.Value) through LifetimeToken (:206) and record the result to _saveResult (:207), report a failed create through NotifyOnFailure (:212), clear _isDirty before navigating (:216), and route to ConferenceRoutePaths.ActivityDetails(createdActivity.Id) (:218) using the key the server assigned. The finally always clears IsSaving (:224-227).
    • NavigateToList (:230) and the standard Dispose(bool) / Dispose pair (:234-254), guarded by _disposed so the _cts is cancelled and disposed exactly once.
  • Why it's built this way: an activity belongs to exactly one event and its schedule is a window rather than a point, so the form's job is to make the window easy to enter and impossible to invert. Defaulting the event and both dates from the selected conference removes the routine clicks, and keeping the window check in the code-behind lets one message name the actual problem (a missing part versus an inverted window) instead of a generic "invalid form".
  • Where it's used: the /activities/create route with [Authorize(Roles = "Organizer")] (ActivityCreate.razor:1-2), reached from ActivityList's create button; on success it hands off to ActivityDetail.
  • Caveats / not-in-source: StartsAt and EndsAt compose local DateTime values from the pickers with no time-zone conversion, even though the event's TimeZone is available on EventInfo and is used for the default-event choice. How the posted StartTime and EndTime are interpreted downstream is decided by the command handler, not by this page.

ActivityDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityDetail.razor.cs:20 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer's activity record page. It loads one activity by route id, inline-edits every field except the owning event, and deletes with confirmation (class doc, :14-18).
  • Depends on: DetailPageBase (ActivityDetail.razor:7), which supplies PageToken, IsEditing/IsDirty and the BeginEdit/EndEdit transitions plus disposal of its own CancellationTokenSource. Also IActivityUIService (:24), IEventLookupService returning EventInfo (:25,65), IToastService (:27), ActivityEditModel (:71), ActivityDTO (:66), ModelValidation with DataAnnotationsModelValidator (:61), ErrorMessages (:109,201,207), and ConferenceRoutePaths (:56,282), plus the shared DeleteConfirmation component (:75). Through the base it also uses LatestLoadGuard, exposed as the protected LoadGuard property (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43). Externals: System.Globalization, MudBlazor's MudForm, and the IStringLocalizer<ActivityDetail> from the template.
  • Concept introduced, culture-formatted display and the field that is deliberately not editable. The detail-page mechanics are the ones SpeakerDetail teaches (load-once guard on _loadedId, :81-90; edit buffer; confirm-then-delete). This page now extends DetailPageBase (ActivityDetail.razor:7) for the edit-lifecycle plumbing and the CancellationTokenSource, rather than owning its own _cts and _isEditing field as it once did; StartEditing and CancelEditing/SaveChangesAsync call the base's BeginEdit() / EndEdit() (:153,159,239) instead of setting the flags directly. Three things are specific here.
    1. Two computed display properties. EventName (:35-39) resolves the activity's EventId against the lookup and falls back to the invariant-culture id, so the read-only event line never renders blank. TimeRange (:41-48) formats the window as one localized sentence by passing the start ("f", full date and time) and the end ("t", short time) rendered in CultureInfo.CurrentCulture into the Text.TimeRange resource; the joining wording lives in the .resx, not in the code, so a locale can reorder or reword the range. [Rubric §27, Internationalization] (assesses whether user-visible text and formats follow the request culture, ADR-027).
    2. The immutable field on an editable record. ActivityEditModel has no event property and re-sends EventId from the loaded record, so this form cannot even express a move between events, which is the rule the class doc states (:15-17). [Rubric §24, Forms, Validation & UX Safety].
    3. A stale-response guard on the load. LoadAsync opens with var (token, generation) = LoadGuard.Begin() (:95), which supersedes any load already in flight and hands back that load's own token and generation number. Every await result is then checked with LoadGuard.IsCurrent(generation) before it is used (:101-104), so a slow response for a previous route id cannot overwrite the record a newer navigation loaded. The same check wraps the finally (:137-140): only the current generation may clear IsLoading, because an unconditional clear would let a superseded response switch off the spinner the newer load had just turned on (comment, :134-136). [Rubric §29, Resilience & Business Continuity]. The window check reappears here as ValidateSchedule (:167-189) with the same three localized keys as ActivityCreate, so the create and edit paths validate a window identically. They are two copies of the same small method rather than one shared helper, which is the maintenance cost of keeping each page self-contained. [Rubric §15, Best Practices & Code Quality].
  • Walkthrough
    • OnInitialized (:50-62): breadcrumbs plus the validation delegate.
    • OnParametersSetAsync (:81-90) then LoadAsync (:92-142): begin the guarded load (:95), fetch with children through the guard's token (:100) and return if the generation is no longer current (:101-104), treat IsNotFound() as the page's not-found state with a localized message (:106-111), report any other failure through NotifyOnFailure (:115), then hydrate the event lookup once, again through token (:123-127), with the comment noting that a failed lookup is reported and stops the load exactly as it did when the same failure arrived as an exception (:121-122). The finally clears IsLoading only for the current generation (:137-140).
    • StartEditing / CancelEditing (:144-160): seed the edit model from the record, clear _scheduleError, then call the base's BeginEdit() / EndEdit() (:153,159) so neither a stale cross-field error nor the unsaved-changes guard can fire after a cancel.
    • SaveChangesAsync (:191-249): validate the form, run ValidateSchedule, post _model.ToUpdated(Activity) (:214), then re-fetch (:221). Save and re-fetch still run on the base's PageToken, not on the load guard. The reload has three outcomes, and all three are handled: a value refreshes the page (:222-225), a IsNotFound() drops the page into its not-found state (:226-231, with the comment recording that this matches the previous null answer), and anything else reports and returns (:232-236). Success calls EndEdit() (:239).
    • DeleteActivityAsync (:251-280): confirm through _deleteConfirm.ShowAsync(Activity.Name) (:258), return unless the answer is exactly true (:259), delete, toast, and navigate back to the list.
    • No disposal code remains on the page itself: DetailPageBase owns the CancellationTokenSource and its guarded Dispose(bool) pair, so ActivityDetail no longer declares IDisposable.
  • Why it's built this way: an activity is a per-event programme item, so its name, window, order and venue are freely editable while its owning event is not. The re-fetch after save keeps the concurrency token current for the next edit, and handling the reload's not-found branch means a record deleted by someone else mid-edit resolves to the same empty state the page shows on a bad id.
  • Where it's used: the /activities/{Id:int} route with [Authorize(Roles = "Organizer")] (ActivityDetail.razor:1-2), reached from ActivityList rows and from ActivityCreate's success redirect. It edits the aggregate the Activity entity models.

ActivityList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityList.razor.cs:20 · Level 10 · class (Blazor code-behind)

  • What it is: the organizer browse page for activities: a server-paged MudDataGrid with a name search, start-time, venue and display-order columns, an event filter, a mobile card layout, and delete-with-confirmation.
  • Depends on: extends EventFilteredListPageBase<TDto> closed over ActivityDTO (:19), which itself extends DataGridListPageBase<TDto> and supplies Toast, IsMobile, LoadServerDataAsync, the event lookup and the persisted event filter. It injects IActivityUIService (:24) and uses ListPageActions (:46,92), ErrorMessages (:98), ConferenceRoutePaths (:101-102), the MobileInfiniteScrollList<TItem> and DeleteConfirmation components (:32-33), and Result<T> as the mobile fetch's return type (:77).
  • Concept introduced, the event-filtered list page as a thin override set. Once the base class owns the event lookup, the persisted eventId filter and the current-or-next default, a list page is reduced to five small overrides, and this page is the clearest place to read them.
    • SavePageFilters / RestorePageFilters (:39-43) persist only the page's own filter, the search string; the event choice is the base's business.
    • ReloadForEventFilterAsync (:48) tells the base how to refresh this page, which here means "reload whichever layout is on screen".
    • ApplyFilters (:69-74) adds the search term as a contains filter on Name and then calls the base's ApplyEventFilter (:73), which adds EventId equals when a scope is selected (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Common/EventFilteredListPageBase.cs:194-199). For activities that key needs no special handling: EventId is a real Activity column, so it travels straight through the generic filter pipeline (class doc, :14-16). Contrast SpeakerList.
    • GridRef (:28) hands the base the grid instance it needs to reload. [Rubric §15, Best Practices & Code Quality] (assesses whether shared behavior is factored rather than repeated) and [Rubric §1, SOLID]: the base defines the algorithm, the page supplies the varying steps. The startup race the base solves is still visible in the page: both fetch paths await WaitForEventsAsync() first (:60, :79), because the grid's first ServerData call can run ahead of initialization and ApplyFilters executes inside the base's LoadServerDataAsync (in-code comment, :58-59). Miss that await and the first page loads unscoped. What is specific to activities is the mobile sort: FetchMobilePage pins "StartTime", "asc" (:85) because, as the comment says, the programme reads chronologically (:84), while the desktop grid lets the organizer re-sort any sortable column. The two layouts share filters and a data contract but not ordering. [Rubric §22, Responsive & Cross-Browser]: one DTO feeds the desktop grid and the mobile infinite-scroll list, switched on the base's IsMobile. [Rubric §12, Performance & Scalability]: paging, searching, sorting and filtering all happen server-side.
  • Walkthrough
    • FormatStartTime (:37): the grid's start-time cell rendered in the viewer's culture ("g", short date plus short time). [Rubric §27, Internationalization].
    • RetryLoadAsync (:31): the inline error state's retry, a null-safe ReloadServerData().
    • OnSearchChanged (:50-54): store the term, reload the active layout.
    • LoadServerData (:56-67): wait for events, then hand the base the fetch delegate and ApplyFilters.
    • FetchMobilePage (:77-86): the same filters against the mobile page size, with the chronological sort pinned.
    • DeleteActivityAsync (:91-99): delegates the whole confirm, delete, toast and reload sequence to ListPageActions, passing the shared dialog, the activity name, the delete call, the success message and the reload.
    • NavigateToCreate / NavigateToDetails (:101-102): route constants, never literals. [Rubric §25, Navigation & Information Architecture].
  • Why it's built this way: an organizer works one conference at a time, so scoping to an event is the default rather than an option, and the base class makes that scoping identical on every list page in the module.
  • Where it's used: the /activities route with [Authorize(Roles = "Organizer")] (ActivityList.razor:1-2), linking on to ActivityCreate and ActivityDetail.

ConferenceCategoryItemEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryItemEditModel.cs:19 · Level 2 · class (sealed form model)

  • What it is: the one-field form model behind the inline "add category item" form that ConferenceCategoryItemsPanel hosts. It carries a single Name property and the rules that property must satisfy.
  • Depends on: CategoryItemDTO for the length cap (.../Pages/ConferenceCategory/ConferenceCategoryItemEditModel.cs:2,30); System.ComponentModel.DataAnnotations. It is consumed through ModelValidation and DataAnnotationsModelValidator.
  • Concept introduced, the page form model as the single declaration of a form's rules. Every editable surface in this unit follows the same three-part arrangement, and this is the smallest instance of it, so it is the clearest place to read the mechanism.
    1. The rules live on a plain C# model, as DataAnnotations. [Required(ErrorMessage = NameRequiredKey)] and [MaxLength(CategoryItemDTO.NameMaxLength, ErrorMessage = "Validation.NameMaxLength")] sit on the Name property (lines 29-31). The cap is not a literal: it reads CategoryItemDTO.NameMaxLength, which is 500 and is declared once on the DTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:16), so the client cap and the contract cap cannot drift.
    2. The markup bridges to those rules instead of restating them. The panel builds one delegate with ModelValidation.For(_model, new DataAnnotationsModelValidator(L)) (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor.cs:57) and hands it to the field's Validation parameter (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor:30). MudBlazor invokes that delegate with (model, member path) and the model's own annotations decide the outcome (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:43-49), so one delegate serves every field on the form and no rule is written twice.
    3. Every ErrorMessage is a resource key, not a sentence. NameRequiredKey is the constant "Validation.NameRequired" (line 26), and the localizing validator resolves the key at render time (ADR-027). The constant exists so the model's [Required] rule and the field's RequiredError affordance quote the same key rather than two copies of one string literal (lines 21-25). [Rubric §24, Forms, Validation & UX Safety] (assesses whether a form's rules are declared once and enforced before submit): the annotation is the only declaration, and the field renders its asterisk and message from it. [Rubric §15, Best Practices & Code Quality] (assesses whether a change has one landing site): raising the item-name cap is a one-line DTO edit that the model, the input's MaxLength, and the character counter all inherit. [Rubric §27, Internationalization]: the message is a key, so the wording is a resource concern rather than a code concern.
  • Walkthrough
    • NameRequiredKey (line 26): the shared resource key described above.
    • Name (line 31): string initialized to string.Empty, so a fresh model binds cleanly to a text field, carrying the required and max-length rules (lines 29-30).
  • Why it's built this way: there is no standalone item-creation page to mirror, so unlike its siblings this model has no abstract base and no ToNew (the doc comment says so explicitly, lines 6-18). The panel builds the CategoryItemDTO itself. The sort order is a plain numeric field with nothing to validate, so it stays on the panel rather than joining the model.
  • Where it's used: held as the private _model of ConferenceCategoryItemsPanel (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor.cs:36), reset on every "add item" click (line 62) and read when the new item is posted (line 87).

PartnerFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Partners/PartnerFormModel.cs:18 · Level 2 · class (abstract base form model)

  • What it is: the editable partner fields shared by the create page and the detail page's inline editor: name, description, logo URL, website URL, partner type, and display order.
  • Depends on: PartnerDTO for the four length caps (lines 35,39,44,49) and AbsoluteUrlAttribute for the two URL fields (lines 43,48). PartnerType (its default of PartnerType.Community, line 56). Its subclasses are PartnerCreateModel and PartnerEditModel.
  • Concept introduced: none new. This is the partner-side instance of the shared-base form model taught on ConferenceCategoryFormModel and QuestionFormModel: the create and edit pages bind one type so neither form can add a rule the other lacks. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §15, Best Practices & Code Quality] apply for the same reasons.
  • Walkthrough
    • NameRequiredKey (line 31): the resource key "Error.NameRequired", quoted by Name's [Required] (line 34) and, per the doc comment (lines 27-30), by the field's RequiredError.
    • Name (line 36): required, capped at PartnerDTO.NameMaxLength.
    • Description (line 40): optional, capped at PartnerDTO.DescriptionMaxLength.
    • LogoUrl (line 45) and WebsiteUrl (line 50): optional, each validated by [AbsoluteUrl(ErrorMessage = "Error.AbsoluteUrl")] plus its own PartnerDTO length cap. Unlike Name and Description, these two fields carry a format rule as well as a length rule.
    • PartnerType (line 56): defaults to PartnerType.Community, the doc comment explaining that this is deliberately harmless because, unlike a sponsorship tier, a partner type carries no commercial weight (lines 52-55).
    • Sort (line 59): plain int, display order within the partner type.
  • Why it's built this way: the same anti-drift argument as ConferenceCategoryFormModel: one field block, bound by both consuming pages, cannot fall out of sync with itself.
  • Where it's used: base of PartnerCreateModel and PartnerEditModel; bound by PartnerCreate and PartnerDetail through their respective model fields.

ConferenceCategoryFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryFormModel.cs:24 · Level 3 · class (abstract base form model)

  • What it is: the editable conference-category fields shared by the create page and the detail page's inline editor: the title with its rules, the display order, and the optional type discriminator.
  • Depends on: ConferenceCategoryDTO for the title cap (lines 2,35). Its subclasses are ConferenceCategoryCreateModel and ConferenceCategoryEditModel.
  • Concept introduced: none new. This is the category-side twin of QuestionFormModel; read that section for the shared-base rationale, and ConferenceCategoryItemEditModel for the annotations-to-MudBlazor bridge. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §15, Best Practices & Code Quality] apply for the same reasons.
  • Walkthrough
    • CategoryTitleRequiredKey (line 31): the resource key "Validation.TitleRequired", quoted by the model's [Required] (line 34) and by the field's RequiredError (.../Pages/ConferenceCategory/ConferenceCategoryFormFields.razor:18).
    • CategoryTitle (line 36): required and capped at ConferenceCategoryDTO.TitleMaxLength, which is 255 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:17). The name is deliberate and the doc comment says why (line 33): a property named Title on a page class would read as the page title, and the pages already expose a localized Title.
    • Sort (line 39) and Type (line 42): plain properties with no annotation. Type is nullable and optional (for example "session" or "speaker"); its input caps itself at ConferenceCategoryDTO.TypeMaxLength in markup (.../Pages/ConferenceCategory/ConferenceCategoryFormFields.razor:23) rather than through an annotation.
  • Why it's built this way: the same anti-drift argument as QuestionFormModel. The shared ConferenceCategoryFormFields component takes this type as its Model (.../Pages/ConferenceCategory/ConferenceCategoryFormFields.razor:28), so both consuming pages render the identical field block and neither can add a rule the other lacks.
  • Where it's used: base of ConferenceCategoryCreateModel and ConferenceCategoryEditModel; bound by ConferenceCategoryCreate (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:23) and ConferenceCategoryDetail (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:34).

PartnerCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Partners/PartnerCreateModel.cs:10 · Level 3 · class (sealed form model)

  • What it is: the form model for the partner-creation page. It adds nothing to PartnerFormModel except the shape of the post.
  • Depends on: PartnerFormModel (base) and PartnerDTO; EventIdentifierType (the module's identifier-type alias).
  • Concept introduced: none new. ToNew is the mapping-on-the-model idea introduced on QuestionCreateModel.
  • Walkthrough
    • ToNew(EventIdentifierType eventId) (lines 18-29) builds a PartnerDTO with Id = default (line 21) plus the five entered values, stamped with the caller-supplied eventId. Unlike ConferenceCategoryCreateModel.ToNew, the owning event is not on the model itself: it is chosen on the create page and passed in, because the page holds the event picker's selected value, not the form.
  • Why it's built this way: keeping the create model empty apart from ToNew mirrors ConferenceCategoryCreateModel: every field a reader could look for is on the shared base.
  • Where it's used: the _model field of PartnerCreate, read at the post.

PartnerEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Partners/PartnerEditModel.cs:10 · Level 3 · class (sealed form model)

  • What it is: the edit buffer for the inline partner editor on the detail page: it loads from the fetched partner and projects back onto an updated DTO.
  • Depends on: PartnerFormModel (base) and PartnerDTO.
  • Concept introduced: none new. The load-into-buffer and RowVersion round-trip mechanics are taught on QuestionEditModel. [Rubric §8, Data Architecture]: ToUpdated carries RowVersion = partner.RowVersion (line 37 of the source), so the partner edit participates in optimistic concurrency (ADR-035), the same as the category edit and unlike the category's item edits.
  • Walkthrough
    • LoadFrom(PartnerDTO partner) (lines 14-24): null-guards, then copies Name, PartnerType, Sort, Description, LogoUrl, and WebsiteUrl onto the form fields.
    • ToUpdated(PartnerDTO partner) (lines 32-48): null-guards, then rebuilds the DTO with the loaded Id, RowVersion, and EventId alongside the edited name, type, logo, description, website, and sort.
  • Why it's built this way: the shared-base split lets the edit form reuse every validation rule the create form declares, and ToUpdated is the one place identity, concurrency token, and the owning event are carried forward from the loaded record rather than re-entered.
  • Where it's used: the _model field of PartnerDetail, loaded at StartEditing and read at the save.

ConferenceCategoryCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryCreateModel.cs:10 · Level 4 · class (sealed form model)

  • What it is: the form model for the category-creation page. It adds nothing to ConferenceCategoryFormModel except the shape of the post.
  • Depends on: ConferenceCategoryFormModel (base) and ConferenceCategoryDTO.
  • Concept introduced: none new. ToNew is the mapping-on-the-model idea introduced on QuestionCreateModel.
  • Walkthrough
    • ToNew() (lines 17-24) builds a ConferenceCategoryDTO with Id = default (line 20) plus the three entered values. Note the contrast with QuestionCreateModel.ToNew, which takes an id: a category leaves identity assignment entirely to the server, and the page navigates on the id read back from the response. [Rubric §8, Data Architecture] (assesses a deliberate identity strategy): the two entities in this unit make opposite choices, and each is visible right here in the mapping method.
  • Why it's built this way: keeping the create model empty apart from ToNew is the point. Every field a reader could look for is on the shared base, which is the same base the detail page's editor binds, so the two forms cannot drift apart (doc comment, lines 5-9).
  • Where it's used: the _model field of ConferenceCategoryCreate (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:39), read at the post (line 67).

ConferenceCategoryEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryEditModel.cs:11 · Level 4 · class (sealed form model)

  • What it is: the edit buffer for the inline category editor on the detail page: it loads from the fetched category and projects back onto an updated DTO.
  • Depends on: ConferenceCategoryFormModel (base) and ConferenceCategoryDTO.
  • Concept introduced: none new. The load-into-buffer and RowVersion round-trip mechanics are taught on QuestionEditModel. [Rubric §8, Data Architecture]: ToUpdated carries RowVersion = category.RowVersion (line 37), so the category edit participates in optimistic concurrency (ADR-035). Worth contrasting with the item edits in ConferenceCategoryItemsPanel, which carry no token.
  • Walkthrough
    • LoadFrom(ConferenceCategoryDTO category) (lines 15-22): null-guards, then copies Title into CategoryTitle, plus Sort and Type.
    • ToUpdated(ConferenceCategoryDTO category) (lines 30-42): null-guards, then rebuilds the DTO with the loaded Id and RowVersion and the edited title, sort, and type.
  • Why it's built this way: the class doc records the deliberate omission (lines 5-10): the category items are absent from this model because they are edited in their own panel, not in this form. That is what lets the page's save path be a single UpdateAsync with no child reconciliation.
  • Where it's used: the _model field of ConferenceCategoryDetail (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:53), loaded at StartEditing (line 108) and read at the save (line 129).

ConferenceCategoryCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryCreate.razor.cs:12 · Level 5 · class (Blazor code-behind)

  • What it is: the organizer's category-creation form. It renders the shared field block over a ConferenceCategoryCreateModel, posts one ConferenceCategoryDTO through the UI service, and redirects to the detail page for the record it just made.
  • Depends on: IConferenceCategoryUIService (line 14), IToastService (line 16), ConferenceRoutePaths (lines 29,89,101), ErrorMessages (line 70), ResultUiExtensions for NotifyOnFailure (line 83), ModelValidation and DataAnnotationsModelValidator (line 34). Externals: Blazor ([Inject], NavigationManager, OnInitialized), MudBlazor (MudForm, BreadcrumbItem, Icons.Material.Filled.Home), the shared UnsavedChangesGuard and ErrorSummary components from MMCA.Common.UI, and the IStringLocalizer<ConferenceCategoryCreate> the template injects as L (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:6).
  • Concept introduced, the create-page shape and its three safety rails. This is the smallest create form in the group, which makes it the clearest place to read the shape the others repeat.
    1. Validate before you mutate. CreateCategoryAsync calls await _form.ValidateAsync() and returns with a warning toast when !_form.IsValid (lines 68-72), before any service call. The server validates again; this pass exists to keep a round trip off the wire and to put the message next to the field. Errors are then collapsed and localized once by the shared ErrorSummary (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:34), whose comment (lines 28-33) records why de-duplication is needed: MudBlazor runs a field's annotations through For as well as through the shared delegate, so one empty field can report the same rule twice, once localized and once as the bare key. [Rubric §24, Forms, Validation & UX Safety]. A failed save reports through the same summary: _saveResult (line 52) captures the last AddAsync result, cleared at the top of every attempt (line 65) and set again once the call returns (line 78), and a failure calls created.NotifyOnFailure(Toast, L) (line 83) instead of a bare Toast.Error, so the wording survives a snackbar timing out while the user is still reading the form.
    2. Dirty tracking that cannot block its own redirect. Every field change raises MarkDirty() (line 56, wired through the field block's OnFieldChanged, .../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:23-25) and the markup mounts <UnsavedChangesGuard IsDirty="_isDirty" IsDirtyAccessor="() => _isDirty" /> (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:10). The accessor is the load-bearing half: the guard prefers the live accessor over the parameter snapshot, because clearing the flag and calling NavigateTo without an intervening render would otherwise still prompt. The page clears _isDirty on the success path before navigating, with the reason written on the line (line 87).
    3. Cancel on disposal. A private CancellationTokenSource (line 18) is read through a LifetimeToken property (_cts.LifetimeToken(), line 24) rather than _cts.Token, because reading Token off a disposed source throws ObjectDisposedException; a handler that resumes after disposal gets a cancelled token instead. That token is passed into the service call (AddAsync(_model.ToNew(), LifetimeToken)) and the source is cancelled through a full Dispose(bool) pattern (lines 105-125), with OperationCanceledException caught and ignored as the expected teardown or InteractiveAuto render-mode transition outcome (lines 91-94, ADR-056). [Rubric §23, Front-End Performance & Rendering]: an abandoned form does not keep a response alive for a component that no longer exists. [Rubric §11, Security] (assesses authorization at the boundary the user actually reaches): the route carries @attribute [Authorize(Roles = "Organizer")] (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:2). [Rubric §27, Internationalization]: every label, breadcrumb, and toast reads through L.
  • Walkthrough
    • OnInitialized (lines 23-35) builds the Home / Categories / Create breadcrumb trail (lines 26-31) and then builds the validation delegate once (line 34), so the bridge is created per page instance rather than per render.
    • The form state is four fields: the _model (line 40), the _validate delegate (line 44), the captured MudForm (line 46), and _saveResult (line 52). Compare the pre-extraction shape, where each editable value was its own private field on the page.
    • CreateCategoryAsync (lines 58-99): null-guard the form (lines 60-63), clear _saveResult (line 65), validate, set IsSaving (line 74), post _model.ToNew() (line 77), record the result (line 78), and notify on failure through created.NotifyOnFailure(Toast, L) (lines 81-84), clear the dirty flag (line 87), toast success (line 88), and redirect to ConferenceRoutePaths.ConferenceCategoryDetails(newCategory.Id) using the id the server returned (line 89). The finally always clears IsSaving (lines 95-98), so a failed save leaves an enabled button rather than a stuck spinner.
    • NavigateToList (line 101) is the cancel action, routed through ConferenceRoutePaths rather than a literal. [Rubric §25, Navigation & Information Architecture]: every route in the module is a named constant in one file.
  • Why it's built this way: one create shape repeated per entity keeps the organizer's mental model constant (fill, validate, save, land on the new record) while each page varies only in the fields it collects, and since the extraction of ConferenceCategoryFormModel it varies in even less than that.
  • Where it's used: the /conferencecategories/create route (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:1-2), reached from ConferenceCategoryList's create button; on success it hands off to ConferenceCategoryDetail.

ConferenceCategoryDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryDetail.razor.cs:19 · Level 5 · class (Blazor code-behind)

  • What it is: the container half of the organizer's category console. It loads one ConferenceCategoryDTO with its children by route id, runs the inline category editor and the delete flow, and hosts the item panel that owns the child-entity CRUD.
  • Depends on: DetailPageBase (via @inherits, .../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:3), IConferenceCategoryUIService (line 23), IToastService (line 25), ConferenceCategoryEditModel (line 53), ConferenceCategoryItemsPanel (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:73), ResultUiExtensions for IsNotFound() and NotifyOnFailure (lines 80,88,132,139,175), DomainHelper's Parse<T> extension (line 74), ConferenceRoutePaths (lines 39,192), and ErrorMessages (lines 84,122). Externals: MudBlazor MudForm, plus the PageLoadingState, PageErrorState, ErrorSummary, UnsavedChangesGuard, and DeleteConfirmation components from MMCA.Common.UI (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:11,18,22,44,82).
  • Concept introduced, the container/presentational split of a parent-with-children editor. The class doc states the division (lines 14-18): this page owns the loaded aggregate, the inline category editor, and the delete flow; the item CRUD lives in the presentational ConferenceCategoryItemsPanel, which reloads the aggregate itself and hands the refreshed copy back through a callback that this page adopts in one line (OnCategoryReloaded, line 190). The page therefore has exactly one mutation entry point for child data and no child-collection logic at all. [Rubric §18, UI Architecture & Component Design] (assesses decomposition) and [Rubric §19, State Management & Data Flow] (assesses single ownership of state): the category has one owner (this page) and one place it can be replaced. Three further mechanisms are worth reading.
    • Load once on parameters. OnParametersSetAsync compares the route Id against _loadedId (lines 64-69) so a re-render does not refetch.
    • A 404 is a distinct outcome. result.IsNotFound() (line 80) clears Category and reports a not-found message; anything else routes through NotifyOnFailure (line 88). The comment at line 82 records the defect this replaced, where a 404 arrived as a null success. [Rubric §29, Resilience & Business Continuity]: not-found, failed, and empty are three different rendered states.
    • Refetch, do not patch. A successful update is followed by a fresh GetByIdAsync (line 136) and the page renders the server's answer. That costs one extra read and removes an entire class of drift between what was saved and what is shown. [Rubric §8, Data Architecture]: the save posts _model.ToUpdated(Category), which round-trips the RowVersion (ADR-035). [Rubric §11, Security]: organizer-only route (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:2).
  • Walkthrough
    • OnInitialized (lines 33-45): breadcrumbs (lines 36-41) and the validation delegate (line 44).
    • OnParametersSetAsync (lines 62-99): the _loadedId guard, Id.Parse<ConferenceCategoryIdentifierType>() (line 74), LoadGuard.Begin() to supersede any load already in flight, GetByIdAsync(id, true, token) with children using the guard's token, a LoadGuard.IsCurrent(generation) check that drops a superseded response, the three-way result handling above, OperationCanceledException swallowed, and a finally that clears IsLoading only when the generation is still current, so a stale response cannot switch off the spinner a newer load just turned on. LoadGuard comes from DetailPageBase, so the page itself never declares a CancellationTokenSource.
    • StartEditing (lines 101-110): copies the loaded category into the edit model (line 108) and calls the base's BeginEdit() (line 109), which sets IsEditing and clears IsDirty in one place.
    • SaveChangesAsync (lines 112-155): validate the form (lines 119-124), UpdateAsync(_model.ToUpdated(Category)) (line 129), report a failure through NotifyOnFailure (line 132), refetch (line 136), adopt the refreshed record (line 143), toast, and call EndEdit() (line 145) so the dirty flag can never be left set behind a closed editor.
    • DeleteCategoryAsync (lines 157-186): confirm through the shared DeleteConfirmation dialog seeded with the category title (line 164), delete (line 172), then navigate back to the list (line 180).
    • OnCategoryReloaded (line 190) and NavigateToList (line 192) are one-liners.
  • Why it's built this way: a category is only meaningful together with its items (a topic list, a session-level list, a locality list), so editing them on two routes would be worse than one composite page. Splitting the panel out keeps that composite page readable: the container is now roughly the size of any other detail page, and the child-entity concerns are in a component that can be read on its own.
  • Where it's used: the /conferencecategories/{Id} route (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:1-2), reached from ConferenceCategoryList rows and ConferenceCategoryCreate's redirect. The items authored here are what ICategoryItemLookupService resolves for the session and speaker pages.

ConferenceCategoryList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryList.razor.cs:12 · Level 5 · class (Blazor code-behind)

  • What it is: the organizer's category browse page: a server-paged grid with a single title search box, delete-with-confirmation, and a mobile card layout. It is a thin binding over the shared list-page base.
  • Depends on: extends DataGridListPageBase<TDto> closed over ConferenceCategoryDTO (line 11, declared in markup at .../Pages/ConferenceCategory/ConferenceCategoryList.razor:4); IConferenceCategoryUIService (line 16), MobileInfiniteScrollList<TItem> (line 24), ListPageActions (lines 35,68), ErrorMessages (line 74), ConferenceRoutePaths (lines 64,77), Result (line 55), and the shared DeleteConfirmation component (line 25, mounted at .../Pages/ConferenceCategory/ConferenceCategoryList.razor:115).
  • Concept introduced, the list page as a set of overrides. The base owns the machinery: the abstract Title, the grid reference plumbing, the IsMobile switch, the mobile paging fields, the filter save/restore contract, the inline LoadFailed state, and LoadServerDataAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs). This page supplies five things and nothing else.
    • The captured grid reference through the GridRef override (lines 19-20), which is how the base restores rows-per-page and the current page after a back-navigation.
    • SaveFilters / RestoreFilters for the one search term (lines 28-32). [Rubric §25, Navigation & Information Architecture]: a reader who opens a record and comes back finds the same view, not a reset grid.
    • LoadServerData (lines 43-52), which hands the base a fetch delegate plus a filter builder that turns the search string into a server-side Title contains filter (line 51). [Rubric §12, Performance & Scalability] and [Rubric §23, Front-End Performance & Rendering]: search, sort, and paging all execute where the data is, so the client never materializes a whole table.
    • FetchMobilePage (lines 55-61), the parallel path for the infinite-scroll card list, hard-sorted by Title ascending (line 60). [Rubric §22, Responsive & Cross-Browser] (assesses a genuine mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's IsMobile.
    • RetryLoadAsync (line 23), which re-runs the fetch from the inline error state. [Rubric §29, Resilience & Business Continuity]: a failed load offers a retry instead of a dead grid. Two details separate this page from its siblings. Both fetch paths pass includeChildren: true (lines 47,60), because both layouts render the child item count (.../Pages/ConferenceCategory/ConferenceCategoryList.razor:37,92); this is the one list page in the module that pays for children. And deletion is not reimplemented: ListPageActions.DeleteWithConfirmationAsync (lines 67-75) takes the dialog, the label to show, the delete call, the toast service, the localized success text, an error formatter, and the reload callback. [Rubric §1, SOLID] and [Rubric §15, Best Practices & Code Quality]: confirm, delete, toast, reload has one implementation for every list page in the app.
  • Walkthrough: ReloadActiveLayoutAsync (lines 34-35) asks ListPageActions to reload whichever of the two layouts is live and is the single reload entry point shared by search changes and post-delete refreshes; OnSearchChanged (lines 37-41) stores the term and calls it; OnMobileCardClick (lines 63-64) and NavigateToCreate (line 77) route through ConferenceRoutePaths; the grid's sortable columns are PropertyColumns over Title, Sort, and Type (.../Pages/ConferenceCategory/ConferenceCategoryList.razor:59,67,77) with the item count as a non-sortable TemplateColumn (line 86), which is the server-sortability rule the grid enforces.
  • Why it's built this way: several near-identical organizer browse surfaces are exactly the case a base class is for. Because each page is only its overrides, a change to paging, scroll restoration, or the mobile switch lands in one place and every list inherits it.
  • Where it's used: the /conferencecategories organizer route (.../Pages/ConferenceCategory/ConferenceCategoryList.razor:1-2). Rows and cards navigate to ConferenceCategoryDetail; the create button opens ConferenceCategoryCreate.

ConferenceCategoryItemsPanel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Categories/ConferenceCategoryItemsPanel.razor.cs:22 · Level 6 · class (Blazor component code-behind)

  • What it is: the category-item half of the category detail page: the item-count header, the inline add form, and the item table with its inline edit row and delete action. It performs the child-entity mutations itself and hands the reloaded parent category back to the page.
  • Depends on: ICategoryItemUIService (line 24) for the mutations and IConferenceCategoryUIService (line 23) for the reload; ConferenceCategoryDTO and CategoryItemDTO; the CategoryItemIdentifierType alias (line 47); ConferenceCategoryItemEditModel (line 36) with ModelValidation and DataAnnotationsModelValidator (line 57); IToastService (line 25); and ErrorMessages (line 80). Externals: MudBlazor MudForm, the shared ErrorSummary and DeleteConfirmation components (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor:53,106).
  • Concept introduced, the presentational child that owns its own writes. This is a different split from the filter-bar style of presentational component. The panel takes the category as a [Parameter] (line 28) and never mutates it; instead, every mutation re-reads the aggregate through ReloadCategoryAsync (lines 183-193) and raises the refreshed copy on the CategoryChanged callback (line 191), which the page adopts. State therefore still has exactly one owner (ConferenceCategoryDetail) while the write logic lives next to the markup that triggers it. [Rubric §18, UI Architecture & Component Design] and [Rubric §19, State Management & Data Flow]. Two details reward a close read.
    • The panel injects the page's localizer. Its template declares @inject IStringLocalizer<ConferenceCategoryDetail> L (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor:4), so every string keeps the exact wording it had before the split and no second resource file appears. [Rubric §27, Internationalization].
    • The two editors are mutually exclusive by construction. StartAddingItem closes any open row edit (line 65) and StartEditingItem closes the add form (line 113), so the panel can never show two open editors at once. [Rubric §24, Forms, Validation & UX Safety]. [Rubric §21, Accessibility]: every icon-only action carries an aria-label read from the localizer (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor:78,80,91,93). [Rubric §8, Data Architecture]: neither the item add nor the item update carries a RowVersion (lines 87,134), so a category item is a last-writer-wins edit while the category itself is not. That is a real asymmetry, not an oversight in the reading: CategoryItemDTO declares no concurrency token.
  • Walkthrough
    • OnInitialized (lines 52-58) builds the validation delegate over the panel's own ConferenceCategoryItemEditModel (line 57).
    • Add: StartAddingItem (lines 60-66) clears the model and the sort field and opens the form; AddItemAsync (lines 70-106) validates its own MudForm (lines 77-82), builds a CategoryItemDTO stamped with the parent CategoryId (line 87), posts (line 88), and treats "add failed" and "reload failed" as one outcome (line 89) before toasting success (line 95).
    • Edit: StartEditingItem (lines 108-114) seeds the row-edit fields from the clicked item, and UpdateItemAsync (lines 118-153) is the one path that does not use a MudForm: it hand-checks string.IsNullOrWhiteSpace(_editItemName) and warns with the same resource key the model declares (lines 125-129), because the row editor is inline in the table rather than a form.
    • Delete: DeleteItemAsync (lines 155-178) confirms through the DeleteConfirmation dialog seeded with the item name (line 157), deletes (line 165), and reloads.
    • ReloadCategoryAsync (lines 183-193): re-reads the category with children (line 185) and returns false when the read comes back empty, leaving the caller to report its own failure message; the comment at lines 180-182 states that contract.
    • Disposal (lines 195-220): the panel owns its own CancellationTokenSource (line 33) and implements the standard Dispose(bool) pattern, because it is a component rather than a page and so does not inherit DetailPageBase's LoadGuard. Every service call (add, update, delete, and the category reload) takes the LifetimeToken property (_cts.LifetimeToken()) instead of _cts.Token, so a call resuming after disposal sees a cancelled token rather than an ObjectDisposedException.
  • Why it's built this way: the item CRUD is the bulk of what a category console does, and keeping it in the page made that page the largest in the module. Extracting it as a presentational panel that reloads the aggregate itself preserves the previous behavior exactly (the doc comment says so, lines 13-20) while leaving the page with one aggregate, one editor, and one callback.
  • Where it's used: rendered once by ConferenceCategoryDetail as <ConferenceCategoryItemsPanel Category="Category" CategoryChanged="OnCategoryReloaded" /> (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:73).

PartnerCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Partners/PartnerCreate.razor.cs:19 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer's partner-creation form. It picks the owning event, renders the shared field block over a PartnerCreateModel, posts one PartnerDTO through the UI service, and redirects to the detail page for the record it just made.
  • Depends on: IPartnerUIService (line 21) for the post, IEventLookupService (line 22) for the event picker, IToastService (line 24), the injected TimeProvider (line 25) as the clock for the event default, CurrentEventSelector for the default event (lines 61-68), ConferenceRoutePaths (lines 38,128,140), ResultUiExtensions for NotifyOnFailure (line 122), ModelValidation and DataAnnotationsModelValidator (line 43). Externals: Blazor ([Inject], NavigationManager, OnInitialized/OnInitializedAsync), MudBlazor (MudForm, BreadcrumbItem, Icons.Material.Filled.Home), .NET TimeProvider.
  • Concept introduced: none new. This is the same create-page shape taught on ConferenceCategoryCreate: validate before mutate (lines 107-111, warns and returns before the service call), a failed save recorded on _saveResult (line 87, cleared at line 104, set again at line 117) and reported through createResult.NotifyOnFailure(Toast, L) (line 122) instead of a bare Toast.Error, dirty tracking cleared before the post-save navigation (_isDirty = false at line 126, with the reason on the line), and cancel-on-disposal through a private CancellationTokenSource read through a LifetimeToken property (the event lookup and AddAsync both take it, never _cts.Token) and the standard Dispose(bool) pair (lines 27,144-158, OperationCanceledException caught as the expected teardown or InteractiveAuto transition, ADR-056). One addition over that shape: an event picker.
    • The event defaults, it does not require a click. OnInitializedAsync loads the event lookup and then, if no event id is already chosen, calls CurrentEventSelector.SelectCurrentOrNext(_events.Values, e => e.StartDate, e => e.EndDate, e => e.TimeZone, TimeProvider.GetUtcNow().UtcDateTime)?.Id (lines 61-68). "Now" comes from the injected TimeProvider (line 25) rather than the static clock, so a test can pin which event counts as current or next. A failed lookup is treated as non-critical: it leaves the picker empty and the required-field error on _eventId guides the user (comment, lines 52-53).
    • The save guards on the event, not only the form. CreatePartnerAsync returns with a warning toast when !_form.IsValid || _eventId is null (lines 107-111), so a partner cannot be created without an owning event even though the event is not one of PartnerFormModel's own fields. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §29, Resilience & Business Continuity]: an event-lookup failure degrades to a guided empty picker rather than an unhandled error. [Rubric §27, Internationalization]: every label, breadcrumb, and toast reads through L.
  • Walkthrough
    • OnInitialized (lines 32-44) builds the Home / Partners / Create breadcrumb trail (lines 35-40) and builds the validation delegate (line 43).
    • OnInitializedAsync (lines 46-74): loads _events from IEventLookupService (line 54), then defaults _eventId through CurrentEventSelector against TimeProvider.GetUtcNow().UtcDateTime (lines 61-68), with OperationCanceledException swallowed (lines 70-73).
    • Form state: _events (line 78), the _model (line 79), _eventId (line 80), the captured MudForm (line 81), _saveResult (line 87), and _isDirty (line 89).
    • CreatePartnerAsync (lines 97-138): null-guard the form, clear _saveResult (line 104), validate and check _eventId, set IsSaving (line 113), post _model.ToNew(_eventId.Value) (line 116), record the result (line 117), and notify on failure through createResult.NotifyOnFailure(Toast, L) (lines 120-123), clear the dirty flag, toast success, and redirect to ConferenceRoutePaths.PartnerDetails(created.Id) (line 128). The finally always clears IsSaving (lines 134-137).
    • NavigateToList (line 140) routes through ConferenceRoutePaths.
    • Disposal (lines 142-164): standard Dispose(bool) pattern over the private CancellationTokenSource.
  • Why it's built this way: one create shape repeated per entity, with the event picker as the one genuinely new piece a partner (and a sponsor) needs that a category or a question does not, because both are scoped to a single event rather than global to the conference.
  • Where it's used: the create route reached from PartnerList's create button (NavigateToCreate); on success it hands off to PartnerDetail.

PartnerDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Partners/PartnerDetail.razor.cs:19 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer's partner console: it loads one PartnerDTO by route id, runs the inline editor and the delete flow, and resolves the owning event's display name for the read view.
  • Depends on: IPartnerUIService (line 20) for load/save/delete, IEventLookupService (line 21) for the event name, IToastService (line 23), PartnerEditModel (line 60), and ErrorMessages (line 89). Externals: MudBlazor MudForm, plus the shared DeleteConfirmation component.
  • Concept introduced: none new. This is the same load/edit/save/delete container shape taught on ConferenceCategoryDetail: load-once on parameters (OnParametersSetAsync comparing Id to _loadedId, lines 384-393), a 404 as a distinct outcome (partnerResult.IsNotFound(), lines 400-406), refetch rather than patch after a save (a fresh GetByIdAsync at line 471, adopted at line 478), and validate-before-mutate on SaveChangesAsync (lines 454-459). One addition: the event name is resolved for display rather than edited.
    • The event name is a computed property over the loaded lookup, not a field. EventName (lines 351-354) reads the partner's EventId out of _events when the lookup succeeded, and falls back to the raw id formatted with InvariantCulture otherwise, so the page degrades gracefully rather than blanking the row. [Rubric §29, Resilience & Business Continuity].
    • A failed event lookup stops the load, not the render. Inside LoadAsync, once the partner is fetched, a failed event lookup is reported through NotifyOnFailure and returns (lines 418-422), the same failure-handling contract the doc comment on the equivalent ConferenceCategoryDetail codepath uses, but here it runs only once per page instance (_events is null guard, line 418) since the lookup does not change per partner. [Rubric §8, Data Architecture]: SaveChangesAsync posts _model.ToUpdated(Partner), round-tripping the RowVersion (ADR-035).
  • Walkthrough
    • OnInitialized (lines 356-368): breadcrumbs and the validation delegate.
    • OnParametersSetAsync (lines 384-393) and LoadAsync (lines 395-432): fetch by Id, the three-way result handling (not-found at lines 401-406, failure at lines 408-412, success at line 414), the event-lookup guard (lines 418-422), OperationCanceledException swallowed (lines 424-427), and a finally that clears IsLoading (lines 428-431). LoadAsync opens with LoadGuard.Begin() to supersede any load in flight, passes the guard's token to both fetches, returns early when LoadGuard.IsCurrent(generation) is false, and clears IsLoading only for the current generation.
    • StartEditing (lines 434-443): loads the edit model from Partner and calls BeginEdit().
    • SaveChangesAsync (lines 447-490): validate, UpdateAsync(_model.ToUpdated(Partner)) (line 464), report a failure, refetch (line 471), adopt the refreshed record, toast, and EndEdit().
    • DeletePartnerAsync (lines 492-521): confirm through DeleteConfirmation seeded with the partner name (line 499), delete (line 507), toast, and navigate back to the list.
    • TypeLabel(PartnerType type) (line 348) and NavigateToList (line 523) are one-liners.
  • Why it's built this way: the same container shape as every other detail page in the module, with the one variation the entity requires: a read-only lookup of the owning event's name rather than a second editable field, because the owning event is fixed at creation.
  • Where it's used: reached from PartnerList rows and PartnerCreate's redirect.

PartnerList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Partners/PartnerList.razor.cs:19 · Level 10 · class (Blazor code-behind)

  • What it is: the organizer's partner browse page: a server-paged grid and mobile card list, scoped to one event, with a name search box and delete-with-confirmation.
  • Depends on: extends EventFilteredListPageBase<TDto> closed over PartnerDTO (line 19); IPartnerUIService (line 25), ListPageActions (lines 571,615), ErrorMessages (line 622), ConferenceRoutePaths (lines 625,626), and the shared DeleteConfirmation and MobileInfiniteScrollList<TItem> components.
  • Concept introduced: none new. This is the event-scoped variant of the list-page-as-overrides shape taught on ConferenceCategoryList, built over EventFilteredListPageBase<TDto> rather than the plain data-grid base: the extra base contributes WaitForEventsAsync and ApplyEventFilter, which every fetch path here calls before it runs.
    • Every fetch waits for the event filter first. LoadServerData (lines 582-593) and FetchMobilePage (lines 603-610) both await WaitForEventsAsync() before calling the service, and the comment on LoadServerData explains why: ApplyFilters runs before the fetch inside the base's LoadServerDataAsync, so the default event filter has to already be resolved by the time that runs (lines 584-585).
    • The reload path also answers to the event filter, not only the search box. ReloadForEventFilterAsync (line 574) overrides the base's hook by delegating to the same ReloadActiveLayoutAsync that OnSearchChanged uses (lines 571-580), so changing the selected event reloads whichever layout (grid or mobile list) is on screen exactly the way a changed search term does. [Rubric §12, Performance & Scalability] and [Rubric §23, Front-End Performance & Rendering]: search, event filter, sort, and paging all execute server-side.
  • Walkthrough
    • EntityName (line 21) and Title (line 22): localized labels.
    • GridRef override (lines 553-554) and RetryLoadAsync (line 557): the same grid-reference and inline-error-retry plumbing as every other list page.
    • SavePageFilters / RestorePageFilters (lines 565-569): the one search term.
    • OnSearchChanged (lines 576-580) stores the term and reloads.
    • LoadServerData (lines 582-593) and FetchMobilePage (lines 603-610): the paired server and mobile fetch paths, both waiting on the event filter, both delegating field-level filtering to ApplyFilters (lines 595-600), which adds a Name contains filter when the search box is non-empty and then calls ApplyEventFilter.
    • OnMobileCardClick (line 612), DeletePartnerAsync(PartnerDTO) (lines 615-623, routed through ListPageActions.DeleteWithConfirmationAsync), NavigateToCreate (line 625), and NavigateToDetails (line 626).
  • Why it's built this way: a partner belongs to exactly one event, so browsing it without an event filter would either show every event's partners at once or force a manual filter on every visit; the shared base makes the filter the default instead.
  • Where it's used: rows and cards navigate to PartnerDetail; the create button opens PartnerCreate.

QuestionFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Questions/QuestionFormModel.cs:24 · Level 2 · class (abstract base form model)

  • What it is: the editable question fields that the create page and the detail page's inline editor have in common: the question text with its rules, the display order, and the required flag. It is abstract, and the two concrete models derive from it.
  • Depends on: QuestionDTO for the text cap (lines 2,35); System.ComponentModel.DataAnnotations. Its subclasses are QuestionCreateModel and QuestionEditModel.
  • Concept introduced, one shared model as the anti-drift device between a create form and an edit form. The form-model mechanism itself is introduced on ConferenceCategoryItemEditModel; what this type adds is the abstract base. Two pages edit a question: /questions/create and the inline editor on /questions/{Id}. Both bind the same QuestionFormFields component (.../Pages/Question/QuestionCreate.razor:23, .../Pages/Question/QuestionDetail.razor:31), and that component's Model parameter is typed as QuestionFormModel (.../Pages/Question/QuestionFormFields.razor:42). The consequence is structural rather than conventional: a question text the create page accepts is a question text the detail page accepts, because there is exactly one [MaxLength] and one [Required] in the codebase for that field, and neither page can add a rule the other lacks. [Rubric §24, Forms, Validation & UX Safety]: a create form and an edit form that disagree on a rule is a classic source of "it saved yesterday and will not save today"; the shared base removes the possibility. [Rubric §1, SOLID] (assesses whether shared behavior is factored to one owner): the base holds what is common, the subclasses hold only what differs (what a post looks like). [Rubric §15, Best Practices & Code Quality]: QuestionDTO.QuestionTextMaxLength is 1000 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:17) and is referenced, never copied.
  • Walkthrough
    • QuestionTextRequiredKey (line 31): the resource key "Validation.QuestionTextRequired", quoted both by the model's [Required] (line 34) and by the field's RequiredError (.../Pages/Question/QuestionFormFields.razor:17).
    • QuestionText (line 36): required, capped at QuestionDTO.QuestionTextMaxLength (line 35), rendered as a two-line text field whose Counter reads the same constant (.../Pages/Question/QuestionFormFields.razor:18-19).
    • Sort (line 39) and IsRequired (line 42): plain properties. The doc comment records why they live here at all (lines 18-22): DataAnnotations cannot express either rule, but keeping them on the model lets one object carry the whole form instead of a parallel set of loose page fields.
  • Why it's built this way: the alternative (each page owning its own fields plus its own copy of the rules) is how the Conference pages were written before the shared field block existed, and it makes every rule change a two-file edit with no compiler help. Abstract rather than concrete because there is no scenario that binds "just the shared part": every use is a create or an edit.
  • Where it's used: as the base of QuestionCreateModel and QuestionEditModel, and as the declared parameter type of the shared QuestionFormFields component (.../Pages/Question/QuestionFormFields.razor:42).

RoomFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomFormModel.cs:24 · Level 2 · abstract class

  • What it is: the editable half of a room, expressed once as a plain C# class with DataAnnotations on it. It is abstract (line 24) because nobody binds it directly: the create page binds RoomCreateModel and the detail page's inline editor binds RoomEditModel, and both inherit exactly these six properties. One declaration of the rules means a name the create form accepts is a name the edit form accepts (class doc, lines 6-13).
  • Depends on: RoomDTO for the length caps (lines 35, 45, 49, 53) and nothing else first-party. Externals: System.ComponentModel.DataAnnotations (RequiredAttribute, MaxLengthAttribute, line 1). It is consumed by ModelValidation and DataAnnotationsModelValidator, named in the class doc at lines 9-10 and 16.
  • Concept introduced, the shared form model. A Blazor page could hold a loose field per input and a RequiredError string per control. This codebase does the opposite: the fields live on one model, the rules live on that model as attributes, and the MudBlazor controls carry only For plus a single shared Validation delegate (see MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomFormFields.razor:14-16). The delegate is built once per page by ModelValidation.For over a DataAnnotationsModelValidator (.../Pages/Room/RoomCreate.razor.cs:46, .../Pages/Room/RoomDetail.razor.cs:42), so a rule is declared exactly once and evaluated by both forms. Even the required affordance is derived rather than restated: the field asks ModelValidation.IsRequired(Model, nameof(RoomFormModel.Name)) (.../Pages/Room/RoomFormFields.razor:16) instead of hard-coding Required="true". [Rubric §24, Forms, Validation & UX Safety] assesses whether a form can express only legal input and explains a rejection in place; here the model is the only rule declaration, so create and edit cannot diverge on what "legal" means. [Rubric §27, Internationalization] assesses whether user-facing text is externalized: every ErrorMessage on this model is a resource key, not a sentence (lines 31, 35, 45, 49, 53), and the page's localizing validator resolves it (class doc, lines 15-17; see ADR-027). [Rubric §15, Best Practices & Code Quality] assesses whether a number lives in one place: the caps are not literals here, they are RoomDTO.NameMaxLength (255), RoomDTO.FloorMaxLength (100), RoomDTO.LocationMaxLength (255) and RoomDTO.AccessibilityInfoMaxLength (500), declared at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:16,19,22,25, the lowest layer the domain invariants, the EF configuration and this UI can all reach (RoomDTO.cs:7-11).
  • Walkthrough
    • NameRequiredKey = "Validation.NameRequired" (line 31) is a const rather than a literal so that the model's [Required] rule and any field-level RequiredError affordance quote the same key instead of two copies drifting apart (doc, lines 26-30).
    • Name (line 36) is the only mandatory field: [Required(ErrorMessage = NameRequiredKey)] plus [MaxLength(RoomDTO.NameMaxLength, ...)] (lines 34-35), defaulted to string.Empty so an unbound model still validates rather than null-references.
    • Sort (line 39) and Capacity (line 42) are plain properties with no attributes. The class doc is explicit about why (lines 18-22): DataAnnotations cannot express what they need, but keeping them on the model lets one object carry the whole form instead of a parallel set of loose page fields. Capacity is int?, so "unknown capacity" and "zero seats" stay distinguishable.
    • Floor (line 46), Location (line 50) and AccessibilityInfo (line 54) are nullable strings carrying only a [MaxLength] each, each with its own resource key.
  • Why it's built this way: a room has one hard rule (it needs a name) and five soft ones (nothing may exceed the column width). Encoding that as attributes on a shared base is the cheapest way to make the create page and the inline editor provably agree, and it keeps the caps traceable to the single constant the persistence layer also uses, so widening a column is one edit, not four.
  • Where it's used: subclassed by RoomCreateModel and RoomEditModel; bound as the Model parameter of the shared RoomFormFields component (.../Pages/Room/RoomFormFields.razor:50), which both RoomCreate and RoomDetail render.
  • Caveats / not-in-source: nothing on this model constrains Sort or Capacity to be non-negative. Whether the server rejects a negative capacity is decided by the domain invariants and the command validator, not here.

QuestionCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Questions/QuestionCreateModel.cs:11 · Level 3 · class (sealed form model)

  • What it is: the form model for the organizer question-creation page. It adds the two fields that are chosen once at creation and never edited afterwards (the target entity and the input type) and knows how to turn itself into the QuestionDTO the create posts.
  • Depends on: QuestionFormModel (base), QuestionDTO, and the QuestionIdentifierType alias (ADR-048).
  • Concept introduced, the model owns the shape of the post. The page validates and calls the service; it does not build the DTO field by field. ToNew (lines 24-33) is the one place that maps form state onto the wire contract, which keeps the page's save handler down to "validate, map, post, navigate". [Rubric §18, UI Architecture & Component Design] (assesses whether logic sits outside the component where it can be reasoned about alone): mapping is a pure function of the model, testable without a renderer. [Rubric §9, API & Contract Design] (assesses how the client fills the contract): the create post is a full QuestionDTO, not a partial patch.
  • Walkthrough
    • QuestionEntity (line 14) defaults to "Session" and QuestionType (line 17) defaults to "Rating", the common combination, so the organizer's most frequent case needs no interaction. Both render as MudSelect pickers only on the create leg, gated by the field block's ShowTargetPickers switch (.../Pages/Question/QuestionFormFields.razor:21-35).
    • ToNew(QuestionIdentifierType id) (lines 24-33) projects the model plus a caller-supplied id onto a QuestionDTO. Taking the id as a parameter rather than minting one keeps the model free of the identifier policy that QuestionCreate applies.
  • Why it's built this way: an entity/type pair chosen at creation determines how every answer to the question will be captured, so it belongs to the create form only; putting it on the create model rather than the shared base is what makes QuestionEditModel structurally unable to change it.
  • Where it's used: the _model field of QuestionCreate (.../Pages/Question/QuestionCreate.razor.cs:39), read at the post (.../Pages/Question/QuestionCreate.razor.cs:68).

QuestionEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Questions/QuestionEditModel.cs:12 · Level 3 · class (sealed form model)

  • What it is: the form model for the inline editor on the question detail page. It loads itself from the question that was fetched and projects itself back onto an updated QuestionDTO.
  • Depends on: QuestionFormModel (base) and QuestionDTO.
  • Concept introduced, the edit buffer plus the concurrency round trip. Two mechanisms are worth reading closely.
    1. The loaded record is never mutated. LoadFrom (lines 16-23) copies the three editable values off the fetched DTO into the model, and the page renders from the DTO throughout. Cancelling an edit therefore needs no undo: the model is simply abandoned, and what is on screen is still exactly what the server last returned. [Rubric §19, State Management & Data Flow] (assesses where mutable state lives and how long it lives): the edit buffer is a separate object with a scope of one editing session.
    2. ToUpdated round-trips what it must not change. The new DTO (lines 34-44) carries the edited QuestionText, Sort, and IsRequired over the loaded record's Id, RowVersion, QuestionEntity, and QuestionType. RowVersion is the client half of the optimistic-concurrency contract in ADR-035: a stale editor loses the write instead of silently overwriting a newer one. QuestionEntity and QuestionType are re-sent unchanged because the update contract is a full replacement, not a patch. [Rubric §8, Data Architecture] (assesses how concurrent writes are reconciled and how immutable fields are protected). Both methods open with ArgumentNullException.ThrowIfNull (lines 18,33), so a null argument fails at the call rather than as a NullReferenceException several lines later. [Rubric §15, Best Practices & Code Quality].
  • Walkthrough
    • LoadFrom(QuestionDTO question) (lines 16-23): copies QuestionText, Sort, IsRequired.
    • ToUpdated(QuestionDTO question) (lines 31-45): builds the replacement DTO as described above.
  • Why it's built this way: the class doc records the reasoning for the omission (lines 5-11). Retargeting a question or changing how it is answered would invalidate the answers already collected, so both fields are displayed but never edited; leaving them off the model makes that a compile-time fact rather than a markup discipline.
  • Where it's used: the _model field of QuestionDetail (.../Pages/Question/QuestionDetail.razor.cs:49), loaded at StartEditing (line 105) and read at the save (line 133).

RoomCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomCreateModel.cs:11 · Level 3 · sealed class

  • What it is: the create-page binding target. It adds no fields to RoomFormModel (line 12), only the one method that turns the entered values into the RoomDTO the page posts.
  • Depends on: RoomFormModel (base), RoomDTO from MMCA.ADC.Conference.Shared.Rooms (line 1), and the solution-wide EventIdentifierType alias. No externals.
  • Concept introduced, the server-allocated key for an app-assigned identity. Room identity is app-assigned, not database-generated, because the int primary key is the Sessionize room id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:11, class at :13), which is how an imported room keeps a stable identity across refreshes. An organizer-created room has no Sessionize id, so someone has to pick one, and that someone is the server, not this page. ToNew posts Id = default (line 22, with the in-code note at line 21), exactly like SponsorCreateModel (.../Pages/Sponsors/SponsorCreateModel.cs:22). RoomService.AddAsync then sends a default id as null in the request's RoomId field, because an explicit 0 would otherwise read as a client-chosen id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Rooms/RoomService.cs:27-29). The controller passes request.RoomId into AddRoomCommand (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:221), and AddRoomHandler, on command.RoomId is null, takes the highest id already in the reserved range EventInvariants.RoomManualIdRangeStart to RoomManualIdRangeEnd (999_999_000 to 999_999_999, EventInvariants at .../MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:66,69) plus one, or the range start when it is empty, failing with Room.ManualIdRangeExhausted past the end (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:125-144). RoomSyncStrategy skips any Sessionize room whose id falls in that range (.../Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:95-97), so organizer rooms and imported rooms cannot collide. [Rubric §8, Data Architecture] assesses how identity is allocated and whether it stays stable: this is a deliberate trade of database-generated keys for import-stable ones, with allocation of the non-imported keys kept in one place on the server.
  • Walkthrough
    • ToNew(EventIdentifierType eventId) (lines 18-30) is an expression-bodied factory returning a fresh RoomDTO. Id is left at default (line 22) and every editable property is copied straight off the base (lines 23, 25-29).
    • EventId = eventId (line 24) comes in as a parameter rather than living on the model: the owning event is picked on the page and never changed afterwards, so it is not a form field (class doc, lines 5-10).
  • Why it's built this way: keeping the create-only concern (build the posted DTO, stamp the owning event) on the create model and every shared field on the base is what lets RoomEditModel reuse the rules without inheriting a ToNew it must not call. Leaving the key to the handler means the reserved-range rule lives only in the application layer.
  • Where it's used: instantiated once as _model on RoomCreate (.../Pages/Rooms/RoomCreate.razor.cs:26) and consumed in CreateRoomAsync (.../Pages/Rooms/RoomCreate.razor.cs:95). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Rooms/RoomCreateModelTests.cs.
  • Caveats / not-in-source: the next id is computed as max-plus-one from a read of the reserved range (AddRoomHandler.cs:128-138), so two organizer creates racing each other could compute the same id; how that surfaces (a key violation on save, a retry) is not determinable from this section's source.

RoomEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomEditModel.cs:11 · Level 3 · sealed class

  • What it is: the binding target for the inline editor on RoomDetail. Like its create sibling it adds no fields to RoomFormModel (line 11), only the two methods that move values between the loaded room and the form.
  • Depends on: RoomFormModel (base) and RoomDTO (line 1). No externals beyond ArgumentNullException.
  • Concept introduced, load-then-project as the edit contract. A create model needs one direction (form to DTO); an edit model needs both, and the pair is deliberately asymmetric about which fields survive. LoadFrom copies only editable values in; ToUpdated puts the edited values back over the identity and the owning event taken from the originally loaded DTO, never from the form. That is what makes "the event is displayed but never edited here" (class doc, lines 8-9) structurally true rather than a UI convention: there is no path by which a form field could change EventId. [Rubric §24, Forms, Validation & UX Safety] assesses whether a form can express only legal input: moving a room between events is defined as a create plus a delete (doc, lines 8-9), so the edit model simply cannot express the illegal operation.
  • Walkthrough
    • LoadFrom(RoomDTO room) (lines 15-25) null-guards with ArgumentNullException.ThrowIfNull (line 17), then assigns the six editable properties (lines 19-24). It does not touch Id or EventId, because the model has no such properties.
    • ToUpdated(RoomDTO room) (lines 33-48) null-guards again (line 35) and returns a new RoomDTO whose Id (line 39) and EventId (line 41) come from the passed-in loaded room while everything else comes from the form (lines 40, 42-46).
    • Note what is absent: no RowVersion. RoomDTO does not implement IConcurrencyAware and declares no such property (.../MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:13-49), unlike SponsorDTO (.../MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:15,42). SponsorEditModel carries the token; this one has nothing to carry.
  • Why it's built this way: the DTO is a record class with init-only members (RoomDTO.cs:13,28), so "edit" cannot mean mutating the loaded object. Projecting a new DTO from the form over the loaded identity is the shape immutability forces, and it makes the set of fields an edit is allowed to change readable at a glance.
  • Where it's used: instantiated once as _model on RoomDetail (.../Pages/Room/RoomDetail.razor.cs:51); LoadFrom runs in StartEditing (.../Pages/Room/RoomDetail.razor.cs:119) and ToUpdated in SaveChangesAsync (.../Pages/Room/RoomDetail.razor.cs:147).

QuestionCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Questions/QuestionCreate.razor.cs:12 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer form that defines a feedback question: its text, the entity it attaches to (Event or Session), its input type (Rating, Text, or Email), its sort order, and whether an answer is required.
  • Depends on: IQuestionUIService (line 14), QuestionCreateModel (line 46), IToastService (line 16), ConferenceRoutePaths (lines 35,96,108), ErrorMessages (line 76), ModelValidation (line 40), Result (line 58). Externals: MudBlazor (MudForm, BreadcrumbItem), System.Security.Cryptography.RandomNumberGenerator (line 83), the UnsavedChangesGuard and ErrorSummary components, and the IStringLocalizer<QuestionCreate> injected as L (.../Pages/Question/QuestionCreate.razor:6).
  • Concept introduced, the client-minted identifier in a reserved range. QuestionDTO.Id is a non-nullable alias over int, so the form has to put something there. This page fabricates a value with RandomNumberGenerator.GetInt32(999_999_000, 999_999_999) (line 83), which is the user-created question band recorded on the domain side as QuestionInvariants.ManualIdRangeStart and ManualIdRangeEnd (QuestionInvariants, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:48,51), the range that sits above every Sessionize-assigned id. The server does not trust the value either way: CreateQuestionHandler allocates the next free id in that range and overwrites the request, with "Caller-provided IDs are ignored" written on the line (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:71-86). The page is written to tolerate that: it navigates using newQuestion.Id read back from the response (line 96), not the value it sent. Compare ConferenceCategoryCreate, which posts Id = default instead. [Rubric §8, Data Architecture] (assesses who owns identifier assignment): identity is server-owned inside a reserved range, because the same table also holds rows imported from Sessionize under externally assigned ids. [Rubric §24, Forms, Validation & UX Safety]: the validate-then-submit, dirty-guard, and cancel-on-disposal mechanics are the three rails ConferenceCategoryCreate introduces, repeated here verbatim (validate lines 73-76, _isDirty cleared before the redirect line 94). A failed save is now surfaced two ways at once: _saveResult (lines 54-58) holds the last create attempt's outcome and is bound to the page's ErrorSummary alongside the form's own validation messages (.../Pages/Question/QuestionCreate.razor:37), while created.NotifyOnFailure(Toast, L) (line 90) still raises the snackbar, so the wording survives a snackbar that times out while the organizer is still reading the form. The page also owns a lifetime-safe token: LifetimeToken (line 23) wraps _cts.LifetimeToken(), with the in-code comment that reading Token off a disposed source throws ObjectDisposedException, whereas Dispose cancels before it disposes, so a handler resuming after the user navigated away gets a cancelled token and exits through its OperationCanceledException path. The AddAsync call passes LifetimeToken (line 84), not _cts.Token.
  • Walkthrough
    • OnInitialized (lines 29-40): the Home / Questions / Create breadcrumb trail, then the validation delegate (line 40).
    • The form state is the _model (line 46), the _validate delegate (line 50), the captured MudForm (line 52), and _saveResult (line 58), the last create attempt's failed Result; the defaults for target entity and type live on the model, not the page (see QuestionCreateModel).
    • CreateQuestionAsync (lines 64-106): validate, reset _saveResult to null before revalidating (line 71), set IsSaving (line 80), mint the id (line 83), post _model.ToNew(id) with LifetimeToken (line 84), record the outcome on _saveResult (line 85), fail into NotifyOnFailure when the result carries no value (lines 86-91), clear _isDirty (line 94), toast success, and redirect to ConferenceRoutePaths.QuestionDetails(newQuestion.Id) (line 96).
    • NavigateToList (line 108) is the cancel path back to /questions.
  • Why it's built this way: questions are the schema behind every feedback screen in both the Conference and Engagement modules, so they are organizer-authored data rather than configuration. The reserved id band is what lets hand-authored questions and imported ones share a table without collisions.
  • Where it's used: the /questions/create organizer route (.../Pages/Question/QuestionCreate.razor:1-2), reached from QuestionList's create button; on success it hands off to QuestionDetail. It is also the one page in this unit with a bUnit component test class, QuestionCreateTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Questions/QuestionCreateTests.cs:19), which asserts that a blank submit shows the model's own required message without MudBlazor's duplicate, that the required field keeps its aria-required affordance, that an overlong text shows the model's max-length message and does not post, and that a valid submit calls AddAsync with the defaults and navigates to the detail page. [Rubric §28, Front-End Testing].

QuestionList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Questions/QuestionList.razor.cs:12 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer browse page for feedback questions. Structurally the twin of ConferenceCategoryList: same base class, same two layouts, same delete flow, with the search bound to the question text.
  • Depends on: extends DataGridListPageBase<TDto> over QuestionDTO (line 11, declared in markup at .../Pages/Question/QuestionList.razor:4); IQuestionUIService (line 32); ListPageActions (lines 51,84), ErrorMessages (line 90), ConferenceRoutePaths (lines 80,93), MobileInfiniteScrollList<TItem> (line 40), Result (line 71), and the shared DeleteConfirmation component (line 41, mounted at .../Pages/Question/QuestionList.razor:145).
  • Concept introduced: none new. See ConferenceCategoryList for the base-class contract (the grid reference override, filter persistence, the two fetch delegates, the inline retry, and the shared delete action).
  • Walkthrough (only the differences from ConferenceCategoryList):
    • The search filter targets QuestionText contains <search> on both the desktop (lines 62-68) and mobile (lines 73-76) paths, and the mobile fetch sorts by QuestionText ascending (line 76).
    • Neither fetch path passes includeChildren: a question has no children to count, so the list stays a plain paged read (lines 62-66,76). [Rubric §12, Performance & Scalability].
    • OptionLabel (lines 20-30) is the one addition over the category twin: it maps a stored question entity, type or source string to a localized label through the Option.{raw} resource keys the question form also uses (ADR-027), returning an empty string for null or empty input and falling back to the raw value when ResourceNotFound is set, so a missing resource never surfaces the key.
    • DeleteQuestionAsync (lines 83-91) passes question.QuestionText as the confirmation label, so the dialog names the question being removed rather than showing a bare id. [Rubric §24, Forms, Validation & UX Safety].
  • Why it's built this way: questions are low-volume reference data, so the page needs browse, search, and delete but no filters or enrichment; keeping it on the same base means it inherits URL-persisted paging, sorting, and the inline retry state for free.
  • Where it's used: the /questions organizer route (.../Pages/Question/QuestionList.razor:1-2); the create button opens QuestionCreate (line 93) and rows open QuestionDetail (line 80).

RoomCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomCreate.razor.cs:13 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer form that adds a room to an event's venue. It collects the name, the owning event, the display order, an optional capacity, floor, location and accessibility note, then posts one RoomDTO and routes to the new room's detail page. The route is /rooms/create and it is Authorize(Roles = "Organizer") (.../Pages/Room/RoomCreate.razor:1-2).
  • Depends on: IRoomUIService (line 15), IEventLookupService returning EventInfo (lines 16, 42), IToastService (line 18), ConferenceRoutePaths (lines 56, 113, 125), ErrorMessages (line 94), ModelValidation and DataAnnotationsModelValidator (line 61), RoomCreateModel (line 32), and Result (line 40). Externals: Blazor ([Inject], NavigationManager), MudBlazor (MudForm, BreadcrumbItem, Icons), and the IStringLocalizer<RoomCreate> injected by the template (.../Pages/Room/RoomCreate.razor:6). The markup also uses UnsavedChangesGuard and ErrorSummary from MMCA.Common.UI.Components (.../Pages/Room/RoomCreate.razor:10, :33-34) and the ADC-local RoomFormFields (.../Pages/Room/RoomCreate.razor:22-25).
  • Concept introduced, the shared field block plus the shared validation delegate. The form model taught the "declare the rules once" half; this page shows the other half at work. The page owns no markup for the fields at all: it renders RoomFormFields inside a MudForm (.../Pages/Room/RoomCreate.razor:21-26), passes the model, the delegate and the localizer, and that same component is rendered by RoomDetail's editor (.../Pages/Room/RoomDetail.razor:31-32). The delegate itself is one field on the page, Func<object, string, IEnumerable<string>> _validate (line 47), built at line 61 from the model and a localizing validator. MudBlazor calls it with (model, memberPath) and the model's own DataAnnotations decide the outcome, which the in-code comment states directly (lines 45-46). [Rubric §18, UI Architecture] assesses whether presentation is decomposed so that a change lands in one place: the create page and the edit page share the fields, the rules and the localizer wiring, and differ only in whether the event picker is offered. [Rubric §24, Forms, Validation & UX Safety] also applies twice over here. The submit path validates before it posts (lines 91-96) and reports with a localized toast rather than a silent no-op, and _isDirty (lines 43, 49) drives UnsavedChangesGuard (.../Pages/Room/RoomCreate.razor:10) so navigating away mid-edit prompts. The flag is cleared before the navigation on success (line 111, with a comment saying so), which is what stops the guard from firing on the page's own redirect. A failed save is now also surfaced inline: _saveResult (lines 36-40) holds the last create attempt's outcome, bound to the page's ErrorSummary alongside the form's own validation messages, and created.NotifyOnFailure(Toast, L) (line 107) still raises the snackbar so the wording survives a snackbar that times out while the organizer is still reading the form.
  • Walkthrough
    • OnInitializedAsync (lines 51-80) builds the three breadcrumbs (Home, Rooms, Create, with the last disabled: true so it renders as the current page, lines 53-58), then builds the validation delegate (line 61, with the ADR-027 comment at line 60).
    • It then loads the event lookup through the page's LifetimeToken (line 65), a property wrapping _cts.LifetimeToken() (line 25) that hands back a cancelled token, instead of throwing ObjectDisposedException, when a handler resumes after Dispose (the in-code comment at lines 19-22 says so). A failed lookup is fatal to initialization here: it toasts Snackbar.LoadEventsFailed and returns (lines 67-68). Contrast SponsorCreate, which treats the same failure as non-critical.
    • The default-event rule is the simple one: if (_eventLookup.Count == 1) pick that event (lines 71-74). There is no current-or-next resolution on this page, unlike SponsorCreate (.../Pages/Sponsor/SponsorCreate.razor.cs:58-65).
    • The picker is only rendered when there is a real choice: ShowEventPicker is bound to _eventLookup is not null && _eventLookup.Count > 1 (.../Pages/Room/RoomCreate.razor:24), so a single-event deployment never shows a one-option dropdown.
    • CreateRoomAsync (lines 82-123) returns early if the form reference is null (lines 84-87), resets _saveResult to null before revalidating (line 89), awaits _form.ValidateAsync() and toasts ErrorMessages.ValidationError on failure (lines 91-96), then sets IsSaving (line 98) and posts _model.ToNew(_eventId) through IRoomUIService.AddAsync with LifetimeToken (line 101), recording the outcome on _saveResult (line 102). A failed Result calls created.NotifyOnFailure(Toast, L) (lines 103-108); success clears the dirty flag, toasts, and navigates to ConferenceRoutePaths.RoomDetails(createdRoom.Id) (lines 111-113).
    • OperationCanceledException is swallowed in both async methods as expected during disposal or an InteractiveAuto render-mode transition (lines 76, 115-118; see ADR-056). IsSaving is cleared in the finally (lines 119-122), which re-enables the submit button after a failure.
    • NavigateToList (line 125) and the _disposed-guarded Dispose(bool) / Dispose pair close the page out: the _cts is cancelled and disposed exactly once.
  • Why it's built this way: a room is a small entity with one required field, so the page carries no cross-field rules and no staged workflow. What it does carry is the two pieces of discipline every create page in this group carries: a cancellation token tied to component lifetime, and a dirty flag that is cleared only on a successful save.
  • Where it's used: reached from RoomList's create button (.../Pages/Room/RoomList.razor.cs:90); on success it hands off to RoomDetail.
  • Caveats / not-in-source: the client-minted room id discussed on RoomCreateModel is produced inside ToNew, so this page never sees or validates it.

QuestionDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Questions/QuestionDetail.razor.cs:14 · Level 8 · class (Blazor code-behind)

  • What it is: the organizer's view, edit, and delete page for a single feedback question. It is the smallest detail page in the group: three editable fields, no lookups, and no child entities.
  • Depends on: DetailPageBase (base class, .../Pages/Questions/QuestionDetail.razor:7), IQuestionUIService (line 18), QuestionEditModel (line 62), IToastService (line 20), ResultUiExtensions for IsNotFound() and NotifyOnFailure (lines 96,104,155,162,198), DomainHelper's Parse<T> extension (line 85), ConferenceRoutePaths (lines 49,211), ErrorMessages (lines 100,145), and ModelValidation (line 54). Externals: MudBlazor MudForm, plus the PageLoadingState, PageErrorState, ErrorSummary, UnsavedChangesGuard, and DeleteConfirmation components (.../Pages/Questions/QuestionDetail.razor:19,23,43,12,82).
  • Concept introduced: none new. Route-id parsing, load-once-on-parameters, the edit buffer, the RowVersion round trip, and refetch-after-save are all covered on ConferenceCategoryDetail and QuestionEditModel. The page-scoped CancellationTokenSource (PageToken), the IsEditing / IsDirty pair, and BeginEdit / EndEdit are inherited from DetailPageBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:36,46,49,68,75) rather than declared here, so the code-behind carries none of that plumbing itself. [Rubric Section 8, Data Architecture]: the immutable-after-create fields (QuestionEntity, QuestionType) are round-tripped by QuestionEditModel.ToUpdated rather than omitted, so the update contract stays a full replacement. [Rubric Section 11, Security]: organizer-only route (.../Pages/Questions/QuestionDetail.razor:2).
  • Walkthrough
    • OptionLabel (lines 31-40) localizes a stored question entity, type or source through the Option.{raw} resource keys shared with the question form (ADR-027): empty input yields an empty string, and a value with no resource falls back to the raw value rather than the missing key.
    • OnInitialized (lines 43-55): the Home / Questions / Details breadcrumb trail, then the validation delegate (line 54).
    • OnParametersSetAsync (lines 70-120): the _loadedId guard (lines 72-77), then LoadGuard.Begin() (line 80), which supersedes any load already in flight and returns a (token, generation) pair. Id.Parse<QuestionIdentifierType>() (line 85), GetByIdAsync with that token instead of PageToken (line 86), and an early return when LoadGuard.IsCurrent(generation) is false (lines 87-90), so a superseded response never overwrites the newer one. Then the not-found branch that clears Question and reports through ErrorMessages (lines 96-101), NotifyOnFailure for anything else (line 104), and a finally that clears IsLoading only when the generation is still current (lines 111-119), so a stale response cannot switch off the spinner the newer load just turned on.
    • StartEditing (lines 122-131) loads the edit model from the fetched question (line 129) and opens the editor with the inherited BeginEdit (line 130); CancelEditing (line 133) is a one-line forward to the inherited EndEdit.
    • SaveChangesAsync (lines 135-178): validate (lines 142-147), UpdateAsync(_model.ToUpdated(Question)) (line 152), refetch (line 159), adopt the refreshed record (line 166), toast, and close the editor with EndEdit (line 168).
    • DeleteQuestionAsync (lines 180-209): confirm with the question text as the label (line 187), delete (line 195), and navigate back to the list (line 203).
  • Why it's built this way: a question's entity and type determine how every existing answer was captured, so changing them after answers exist would invalidate stored data; restricting the edit surface to text, order, and required-ness is the simplest way to keep answers interpretable.
  • Where it's used: the /questions/{Id} organizer route (.../Pages/Questions/QuestionDetail.razor:1-2), reached from QuestionList rows and from QuestionCreate's success redirect.

RoomDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomDetail.razor.cs:16 · Level 8 · class (Blazor code-behind)

  • What it is: the organizer's single-room page at /rooms/{Id} (.../Pages/Rooms/RoomDetail.razor:1, Authorize(Roles = RoleNames.Organizer) at :2). It loads one room, renders it read-only, flips into an inline editor bound to RoomEditModel, deletes behind a confirmation dialog, and exposes the room's door check-in QR code.
  • Depends on: DetailPageBase (base class, .../Pages/Rooms/RoomDetail.razor:7), IRoomUIService (line 20), IEventLookupService (line 21), IToastService (line 23), RoomEditModel (line 50), RoomDTO (line 46), ConferenceRoutePaths (lines 37, 211 and .../Pages/Rooms/RoomDetail.razor:61), ErrorMessages (lines 84, 145), ResultUiExtensions for IsNotFound and NotifyOnFailure (lines 81, 90, 99, 155, 162, 198), and ModelValidation (line 42). Externals: Blazor ([Parameter], NavigationManager), MudBlazor (MudForm, MudSimpleTable, BreadcrumbItem), System.Globalization.CultureInfo (line 120). The markup composes PageLoadingState, PageErrorState, ErrorSummary, UnsavedChangesGuard, QrCodeButton and DeleteConfirmation from MMCA.Common.UI.Components (.../Pages/Rooms/RoomDetail.razor:19,23,43,12,61,93).
  • Concept introduced, the three-state detail page and the string route parameter. The template is a single if / else if / else chain over three page states: loading, not-found, and loaded, with the loaded state further split by the inherited IsEditing (.../Pages/Rooms/RoomDetail.razor:17,21,25). That chain is why the page never renders a half-populated card. The genuinely new mechanic on this page is how the route id is handled. Id is declared [Parameter] public string Id (line 25), not an int, and the route is the untyped /rooms/{Id} (.../Pages/Rooms/RoomDetail.razor:1). The code-behind converts it itself with Id.Parse<RoomIdentifierType>() (line 74), an extension from MMCA.Common.Shared.Extensions (line 6). SponsorDetail takes the opposite approach with a constrained {Id:int} route and an int parameter (.../Pages/Sponsor/SponsorDetail.razor:1, .../Pages/Sponsor/SponsorDetail.razor.cs:27), so both idioms are live in this group. The reload guard is worth internalizing: OnParametersSetAsync returns immediately when Id == _loadedId (lines 61-64). Blazor calls OnParametersSetAsync on every parameter set, not only on navigation, so without this guard an unrelated re-render would refetch the room. [Rubric Section 19, State Management] assesses whether component state is fetched once and invalidated deliberately: _loadedId is the invalidation key, and _events is fetched only when still null (line 98), so switching rooms does not refetch the event lookup. [Rubric Section 24, Forms, Validation & UX Safety] shows up in the edit lifecycle: StartEditing seeds the model and opens the editor with the inherited BeginEdit (lines 122-131), CancelEditing is a one-line forward to the inherited EndEdit (line 133), and a successful save re-reads the room from the server before leaving edit mode (lines 159-168) rather than trusting the local projection.
  • Walkthrough
    • OnInitialized (lines 31-43) builds the breadcrumbs synchronously and constructs the shared validation delegate (line 42), the same one-line wiring RoomCreate uses.
    • OnParametersSetAsync (lines 59-117) sets _loadedId, then calls LoadGuard.Begin() (line 69) to supersede any load already in flight, receiving a (token, generation) pair. It flips IsLoading, parses the id (line 74) and fetches through IRoomUIService.GetByIdAsync with that token rather than PageToken (line 75). If LoadGuard.IsCurrent(generation) is false the method returns (lines 76-79), so a superseded response never overwrites a newer one. A Result carrying NotFound is handled distinctly from any other failure: it nulls Room and toasts ErrorMessages.NotFound(EntityName, Id) (lines 81-86), which is what makes the template's PageErrorState branch (.../Pages/Rooms/RoomDetail.razor:21-24) reachable. Every other failure goes through NotifyOnFailure (line 90).
    • The event lookup is loaded once, lazily, only after the room is in hand (lines 98-102), and it also uses the guarded token (line 98). The in-code comment (lines 96-97) notes that a failed lookup is reported and stops the load, which is the same behavior an exception used to produce. IsLoading is cleared in the finally only when the generation is still current (lines 108-116), so a superseded response cannot switch off the spinner the newer load just turned on.
    • GetEventName (lines 119-120) resolves an event's display name from the lookup, falling back to the raw id under CultureInfo.InvariantCulture. The template only renders the event row when there is more than one event to distinguish (.../Pages/Rooms/RoomDetail.razor:71-74).
    • StartEditing (lines 122-131) loads the edit model from the fetched room (line 129) and opens the editor with BeginEdit (line 130); CancelEditing (line 133) forwards to EndEdit.
    • SaveChangesAsync (lines 135-178) validates the form (lines 142-147), posts _model.ToUpdated(Room) through UpdateAsync (line 152), then re-fetches the room (line 159) and swaps Room for the refreshed instance before toasting and closing the editor with EndEdit (lines 166-168). IsSaving is cleared in the finally (lines 174-177).
    • DeleteRoomAsync (lines 180-209) awaits _deleteConfirm.ShowAsync(Room.Name) and treats anything other than true as a cancel (lines 187-191), then calls IRoomUIService.DeleteAsync(Room.Id, Room.EventId, PageToken) (line 195), toasts and navigates back to the list.
  • Why it's built this way: an inline editor on the same page as the read-only record keeps the organizer's context (they are looking at the room they are changing) and lets one component, RoomFormFields, serve both create and edit. Re-fetching after a successful update is the honest choice: the server may have normalized values, and the page then shows what was actually stored.
  • Where it's used: it is the navigation target of RoomCreate on success (.../Pages/Rooms/RoomCreate.razor.cs:113) and of every row link and mobile card on RoomList (.../Pages/Rooms/RoomList.razor.cs:78, .../Pages/Rooms/RoomList.razor:51,89,102). Its QrCodeButton points at ConferenceRoutePaths.RoomCheckInLink(Room.Id) (.../Pages/Rooms/RoomDetail.razor:61), which resolves to the Engagement-owned /engage/rooms/{id} landing page (.../MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:65), so the printed code takes an attendee to self check-in rather than to this management screen.
  • Caveats / not-in-source: the delete call binds the ADC-specific overload DeleteAsync(RoomIdentifierType roomId, EventIdentifierType eventId, CancellationToken) (.../Services/Rooms/IRoomUIService.cs:13), not the base IEntityService.DeleteAsync(id, ...). That overload issues DELETE rooms/{roomId}?eventId={eventId} (.../Services/Rooms/RoomService.cs:42-46), which the API's delete route needs: it declares [FromQuery] EventIdentifierType eventId and builds new RemoveRoomCommand(eventId, id) from it (.../MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:267-274). RoomList calls the same overload (.../Pages/Rooms/RoomList.razor.cs:85), so both delete paths carry the owning event. The base DeleteAsync(id, cancellationToken) is still reachable on the same service, and a call shaped DeleteAsync(Room.Id, PageToken) would bind it and send no eventId; nothing but review and the page tests keeps the two-argument form out.

RoomList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomList.razor.cs:13 · Level 10 · class (Blazor code-behind)

  • What it is: the organizer's room index at /rooms (.../Pages/Room/RoomList.razor:1-2, Organizer-only): a server-paged, sortable, filterable grid on desktop and an infinite-scroll card list on mobile, with a debounced name search and an event filter.
  • Depends on: EventFilteredListPageBase<TDto> as its base (line 12), IRoomUIService (line 17), ListPageActions (lines 36, 81), MobileInfiniteScrollList<TItem> (line 25), ConferenceRoutePaths (lines 77, 90), ErrorMessages (line 87), Result (line 67) and RoomDTO. Externals: MudBlazor (MudDataGrid, GridState, GridData), Blazor NavigationManager.
  • Concept introduced: none new at the page level. The event-scoped list machinery lives in EventFilteredListPageBase<TDto> (.../Pages/Common/EventFilteredListPageBase.cs:25), which sits on DataGridListPageBase<TDto>. This page is worth reading as the minimal derivation: it overrides only the three hooks the base declares abstract or virtual for page-specific state, and inherits everything else. [Rubric §1, SOLID] assesses whether a base class exposes the right extension points: the three overrides here (SavePageFilters, RestorePageFilters, ReloadForEventFilterAsync, lines 29, 32,
    1. plus GridRef (line 21) and Title (line 15) are the entire contract, and everything else the page needs (the event lookup, the persisted eventId filter, the current-or-next default) comes for free. [Rubric §22, Responsive and Cross-Browser] assesses whether the layout adapts rather than shrinks: the template branches on IsMobile between a card list and a grid (.../Pages/Room/RoomList.razor:39,70), and within the grid three columns carry HeaderClass="hide-below-desktop" CellClass="hide-below-desktop" (:104,114,124), so capacity, floor and location drop out on narrow viewports instead of forcing a horizontal scroll. [Rubric §23, Front-End Performance] assesses payload and round-trip discipline: paging, sorting and filtering are all server-side (ServerData="LoadServerData", .../Pages/Room/RoomList.razor:75), and the search box debounces at 300 ms (:23), so typing does not issue a request per keystroke.
  • Walkthrough
    • SavePageFilters / RestorePageFilters (lines 29-33) persist exactly one page-specific value, the search string, under the key search. The event filter is persisted by the base.
    • ReloadActiveLayoutAsync (lines 35-36) delegates to ListPageActions.ReloadActiveLayoutAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ListPageActions.cs:26), which reloads whichever of the mobile list or the grid is actually mounted. ReloadForEventFilterAsync (line 38) is the abstract hook the base calls when the event picker changes (.../Pages/Common/EventFilteredListPageBase.cs:202-206), and it simply forwards.
    • LoadServerData (lines 46-57) is the grid's ServerData delegate. The first thing it does is await WaitForEventsAsync() (line 50); the in-code comment (lines 48-49) explains why that cannot be skipped: ApplyFilters runs inside LoadServerDataAsync, so the default event filter has to be resolved before entering it. WaitForEventsAsync awaits the load the base started before its first await (.../Pages/Common/EventFilteredListPageBase.cs:192).
    • ApplyFilters (lines 59-64) contributes the page-specific Name contains filter when a search string is present (lines 61-62), then calls the base's ApplyEventFilter (line 63), which adds EventId equals when an event is selected (.../Pages/Common/EventFilteredListPageBase.cs:195-199).
    • FetchMobilePage (lines 67-74) is the mobile equivalent: it waits for the events, builds the same filter dictionary through the same ApplyFilters, and requests a page sorted by Name ascending (line 73). Both layouts therefore query with identical semantics.
    • DeleteRoomAsync (lines 80-88) hands the whole confirm-delete-toast-reload sequence to ListPageActions.DeleteWithConfirmationAsync (.../MMCA.Common.UI/Pages/Common/ListPageActions.cs:56), passing the dialog, the row's name, the delete call, the toast service, the success message, a failure-message factory and the reload callback. Note the delete call here is the ADC-specific overload RoomService.DeleteAsync(room.Id, room.EventId) (line 84, .../Services/IRoomUIService.cs:13), which appends ?eventId= to the request (.../Services/RoomService.cs:40-44).
    • RetryLoadAsync (line 24) is bound to the empty-state's retry affordance so a failed fetch is recoverable in place (.../Pages/Room/RoomList.razor:142, LoadFailed supplied by the base).
    • NavigateToCreate (line 90) and OnMobileCardClick (lines 76-77) are the two navigation edges.
    • The event column and the mobile card's event line are both conditional on Events is not null && Events.Count > 1 (.../Pages/Room/RoomList.razor:50,91), so a single-event deployment shows no redundant column.
  • Why it's built this way: rooms are a small, flat list that organizers scan rather than search deeply, so the page spends its budget on making the same data legible on a phone at the venue (the card layout) and on a laptop (the grid), from one set of filters.
  • Where it's used: it is the /rooms route reached from the Rooms breadcrumb on both RoomCreate and RoomDetail, and it navigates on to both of them.
  • Caveats / not-in-source: the mobile card shows the capacity line only when item.Capacity > 0 (.../Pages/Room/RoomList.razor:54), so a room recorded with a capacity of zero is displayed identically to one with no capacity recorded at all, even though RoomDTO.Capacity is nullable and can distinguish them (.../MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:37).

SponsorFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorFormModel.cs:25 · Level 2 · abstract class

  • What it is: the sponsor equivalent of RoomFormModel: one abstract class (line 25) holding the ten editable sponsor fields and the DataAnnotations that govern them, shared by SponsorCreateModel and SponsorEditModel.
  • Depends on: SponsorDTO for every length cap and SponsorTier for the package enum (line 2), plus AbsoluteUrlAttribute from MMCA.Common.UI.Validation (line 3). Externals: System.ComponentModel.DataAnnotations (line 1).
  • Concept introduced, the nullable enum as "not chosen yet". The shared-form-model pattern itself was taught on RoomFormModel; what is new here is Tier. It is declared SponsorTier? and [Required] (lines 74-75), and the doc comment states the reason plainly (lines 69-73): SponsorTier.Platinum is the enum's zero value, so a non-nullable SponsorTier Tier would make "the organizer has not picked a package yet" indistinguishable from "the top package", and an unmade choice would be silently sold as Platinum. Making the property nullable moves that ambiguity into the type system, and [Required] turns it into a validation error the organizer sees. [Rubric §24, Forms, Validation & UX Safety] assesses whether a form can express only legal input: a default-valued enum is exactly the class of bug this rules out, and it is a commercial field, so the failure mode is a wrong invoice rather than a cosmetic one. The second new idea is URL validation. Three fields (LogoUrl line 46, WebsiteUrl line 51, LinkedInUrl line 56) each carry [AbsoluteUrl(ErrorMessage = "Error.AbsoluteUrl")] on top of their [MaxLength], from AbsoluteUrlAttribute (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/AbsoluteUrlAttribute.cs:26). [Rubric §26, Front-End Security] assesses whether untrusted input reaches a rendering or navigation surface unchecked: LogoUrl is rendered as an image source and WebsiteUrl and LinkedInUrl as outbound links on SponsorDetail (.../Pages/Sponsor/SponsorDetail.razor:59, :119, :123), so requiring an absolute URL at entry is the first line of defence, and the links themselves carry rel="noopener noreferrer" (.../Pages/Sponsor/SponsorDetail.razor:119,123). [Rubric §27, Internationalization] applies exactly as on the room model: every ErrorMessage is a resource key resolved by the page's localizer (doc, lines 16-17, ADR-027). Note the key prefix differs from the room model's: sponsors use Error.* (lines 32, 36, 40) where rooms use Validation.*.
  • Walkthrough
    • NameRequiredKey = "Error.NameRequired" (line 32) and TierRequiredKey = "Error.TierRequired" (line 67) are the two required-message keys, named as constants for the same reason as on the room model (doc, lines 27-31).
    • Name (line 37) is [Required] plus [MaxLength(SponsorDTO.NameMaxLength)], 200 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:18).
    • Description (line 41) is capped at SponsorDTO.DescriptionMaxLength, 2000 (SponsorDTO.cs:24).
    • LogoUrl (line 46), WebsiteUrl (line 51) and LinkedInUrl (line 56) each pair [AbsoluteUrl] with a 2000-character cap (SponsorDTO.cs:21,27,30).
    • TwitterHandle (line 60) is a handle, not a URL, so it gets a [MaxLength] of 100 (SponsorDTO.cs:33) and deliberately no [AbsoluteUrl].
    • BoothNumber (line 64) is capped at 50 (SponsorDTO.cs:36).
    • Tier (line 75), Sort (line 78) and IsExhibitor (line 81) close the model out. Sort and IsExhibitor are the attribute-free properties the class doc calls out (lines 19-23).
  • Why it's built this way: sponsors are the one Conference entity where a wrong field costs money, so the model spends its complexity budget on the two fields that can be wrong silently (the tier's zero value and a malformed branding URL) and stays plain everywhere else.
  • Where it's used: subclassed by SponsorCreateModel and SponsorEditModel; bound as the Model parameter of SponsorFormFields (.../Pages/Sponsor/SponsorFormFields.razor:103), rendered by SponsorCreate and SponsorDetail.
  • Caveats / not-in-source: TwitterHandle has no format rule at all, so a full URL pasted into it passes validation. What the detail page does with it is render it as text through a localized template (.../Pages/Sponsor/SponsorDetail.razor:127), not as a link.

SessionFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionFormModel.cs:24 · Level 3 · abstract class (form model)

  • What it is: the session twin of EventFormModel. It declares the editable session fields once for both the create page and the detail page's inline editor, and it owns the two pieces of logic a session form needs that an event form does not: rejoining a date picker and a time picker into a single timestamp, and checking that the rejoined end falls after the rejoined start.
  • Depends on: SessionDTO for every length cap (MMCA.ADC.Conference.Shared.Sessions, line 2) and the Room identifier alias. Externals: System.ComponentModel.DataAnnotations.
  • Concept introduced: the shared-form-model idea is EventFormModel's. Three details are specific to sessions:
    1. The split date/time pair. MudBlazor has no single date-and-time control, so a session's StartsAt and EndsAt are each bound as two properties: StartsAtDate / StartsAtTime and EndsAtDate / EndsAtTime (lines 64-73). Combine (line 98) is the protected static rejoin, and it returns null unless both halves are set, so a half-entered time leaves the schedule unset rather than inventing a midnight. Both derived models call it, so the create page and the editor split and rejoin a timestamp identically.
    2. A client mirror of a server invariant. HasEndAfterStart (lines 87-92) rejoins both timestamps through Combine and returns false only when both are present and the end is not strictly after the start, so a zero-duration session is refused too. The doc comment (lines 81-86) names the server rule it mirrors, BR-122 (SessionInvariants.EnsureEndsAtIsAfterStartsAt), and states the same scope: a missing half is not this rule's concern. EndsAtAfterStartsAtKey (line 37) is the resource key the hosting pages resolve for the refusal message, carried with the server's own wording (Session.Duration.Invalid, per the doc comment at lines 33-36).
    3. SessionTitle rather than Title. The doc comment (lines 39-42) records the reason: every page in this group already has its own localized Title property, and SonarAnalyzer S4275 fires on the collision. The rename is an analyzer-driven naming choice, not a domain one. [Rubric §24, Forms, Validation & UX Safety]: the caps are SessionDTO.TitleMaxLength and its siblings (lines 44, 48, 52, 56, 60), so the browser rejects what the column would reject; the Combine rule makes an incomplete timestamp impossible to post by accident; and HasEndAfterStart lets both pages refuse an inverted schedule before the round trip the server would refuse anyway. [Rubric §15, Best Practices & Code Quality] assesses whether the analyzers-as-errors baseline is honored rather than suppressed: this is a case where the code changed a name instead of adding a #pragma.
  • Walkthrough
    • SessionTitleRequiredKey (line 31) is the hoisted resource key, the same technique EventFormModel uses; EndsAtAfterStartsAtKey (line 37) is its BR-122 sibling.
    • Five annotated text properties (lines 43-61): SessionTitle is required, Description, Status, AccessibilityInfo, and ResourceLinks are optional.
    • Six unannotated properties (lines 64-79): the four date/time halves, IsServiceSession (the lunch-or-break flag, line 76), and the nullable RoomId (line 79).
    • HasEndAfterStart() (lines 87-92) returns startsAt is null || endsAt is null || endsAt.Value > startsAt.Value over the two rejoined timestamps (line 91).
    • Combine(DateTime? date, TimeSpan? time) (lines 98-99) returns date.Value.Date + time.Value when both are present, otherwise null.
  • Why it's built this way: sessions carry a schedule that no single control captures, and both edit surfaces must split, rejoin, and check it the same way or a session saved from one screen and re-opened on the other would move (or be accepted on one screen and refused on the other). Putting Combine and HasEndAfterStart on the base makes that impossible to get wrong per page.
  • Where it's used: SessionCreateModel and SessionEditModel derive from it; SessionCreate and SessionDetail bind an instance to the shared SessionFormFields component (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionFormFields.razor), call HasEndAfterStart before posting, and resolve EndsAtAfterStartsAtKey for the inline refusal (.../Pages/Sessions/SessionCreate.razor.cs:162-167, .../Pages/Sessions/SessionDetail.razor.cs:186-191).

SponsorCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorCreateModel.cs:11 · Level 3 · sealed class

  • What it is: the create-page binding target for sponsors. It adds one method to SponsorFormModel (line 11) and no fields.
  • Depends on: SponsorFormModel (base), SponsorDTO and the EventIdentifierType alias (line 1).
  • Concept introduced: none new. This is the create half of the load-then-project pair taught on RoomCreateModel, with the server-minted-key variant rather than the client-minted one.
  • Walkthrough
    • ToNew(EventIdentifierType eventId) (lines 19-36) returns a fresh SponsorDTO.
    • Id = default (line 22): unlike rooms, sponsor ids are server-allocated, so the page posts a placeholder and reads the real key back off the response (.../Pages/Sponsor/SponsorCreate.razor.cs:113 navigates to created.Id).
    • Tier = Tier!.Value (line 26) is the one null-forgiving operator on the model, and the in-code comment above it states the justification: the form's [Required] rule gates the submit, so a validated model always carries a tier (line 25, rule at .../Pages/Sponsor/SponsorFormModel.cs:74).
    • EventId = eventId (line 33) arrives as a parameter for the same reason as on rooms: the owning event is chosen on the page and fixed thereafter (doc, lines 14-15).
  • Why it's built this way: the tier opens unset on purpose (class doc, lines 8-9), because it is a paid package and inheriting the enum's zero value would sell Platinum by accident. The nullable property is the mechanism; this factory is where the nullability is discharged, once, at the point the form has already been validated.
  • Where it's used: instantiated as _model on SponsorCreate (.../Pages/Sponsor/SponsorCreate.razor.cs:76), consumed in CreateSponsorAsync (.../Pages/Sponsor/SponsorCreate.razor.cs:104).
  • Caveats / not-in-source: the ! on line 26 is only as safe as the caller's validation. CreateSponsorAsync does check both _form.IsValid and _eventId is null before calling (.../Pages/Sponsor/SponsorCreate.razor.cs:95), but nothing on this type enforces that contract.

SponsorEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorEditModel.cs:12 · Level 3 · sealed class

  • What it is: the binding target for the inline editor on SponsorDetail, adding LoadFrom and ToUpdated to SponsorFormModel (line 12).
  • Depends on: SponsorFormModel (base) and SponsorDTO (line 1).
  • Concept introduced, carrying the concurrency token through the round trip. The load-then-project shape itself was taught on RoomEditModel. The difference here is line 45: RowVersion = sponsor.RowVersion. SponsorDTO implements IConcurrencyAware and carries a byte[] RowVersion (.../MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:15,42), so the token the page loaded must survive back to the server for optimistic-concurrency checking to mean anything. Notice that RowVersion is not a property on SponsorFormModel: it is never bound to a control and never editable, it is simply threaded from the loaded DTO through the projection. [Rubric §8, Data Architecture] assesses concurrency control: an edit form that dropped the token would turn every save into a last-writer-wins overwrite, silently. Keeping the token off the form model and on the projection is what makes dropping it impossible by construction.
  • Walkthrough
    • LoadFrom(SponsorDTO sponsor) (lines 16-30) null-guards (line 18) and copies the ten editable values, including Tier = sponsor.Tier (line 21), which widens the non-nullable DTO enum into the model's nullable one.
    • ToUpdated(SponsorDTO sponsor) (lines 38-60) null-guards (line 40) and projects a new DTO: Id (line 44), RowVersion (line 45) and EventId (line 56) from the loaded sponsor, everything else from the form (lines 46-55, 57-58).
    • Tier = Tier!.Value (line 49) discharges the nullability exactly as the create model does, with the same comment naming the [Required] rule that guarantees it (line 48).
  • Why it's built this way: the owning event is absent from the editable set for the same reason as on rooms (moving a sponsorship between events is a create plus a delete, doc lines 8-10), and the three carried-through values (identity, token, event) are exactly the three a UI must not be able to change.
  • Where it's used: instantiated as _model on SponsorDetail (.../Pages/Sponsor/SponsorDetail.razor.cs:64); LoadFrom in StartEditing (.../Pages/Sponsor/SponsorDetail.razor.cs:132), ToUpdated in SaveChangesAsync (.../Pages/Sponsor/SponsorDetail.razor.cs:160).

SessionCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionCreateModel.cs:11 · Level 4 · sealed class (form model)

  • What it is: the create-side concrete of SessionFormModel. One method, ToNew, turns the entered fields plus the event the organizer filed the session against into the DTO the create posts.
  • Depends on: SessionFormModel and SessionDTO (line 1), plus the Event identifier alias. No externals.
  • Concept introduced, server-allocated identifiers in an app-assigned key space. Like EventCreateModel, this model posts Id = default (line 25). The reason it is worth naming is that a session's int primary key is its Sessionize id, so the column is app-assigned rather than database-generated, and something has to pick the number. The in-code comment (lines 23-24, tagged M162) records that the server does: when command.Id == default, CreateSessionHandler allocates the next id inside the reserved manual range (SessionInvariants.ManualIdRangeStart to ManualIdRangeEnd) and fails with Session.ManualIdRangeExhausted past its end (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:79-92), retrying on a unique-constraint collision up to MaxManualIdAttempts (3) times (.../CreateSessionHandler.cs:33, :60). The page reads createdSession.Id back off the response before navigating (.../Pages/Sessions/SessionCreate.razor.cs:184). [Rubric §8, Data Architecture]: identifier assignment is an explicit, documented policy per aggregate rather than an implicit database default or a client guess, which is what lets Sessionize-imported and organizer-created sessions share one key space without colliding.
  • Walkthrough
    • ToNew(EventIdentifierType eventId) (lines 20-33): Id = default (line 25), Title from the base's SessionTitle (line 26), Description (line 27), the owning EventId from the parameter (line 28), the optional RoomId (line 29), StartsAt and EndsAt each through the base's Combine (lines 30-31), and IsServiceSession (line 32).
    • The Status, AccessibilityInfo, and ResourceLinks properties the base declares are not written here: they are edit-time fields, so a newly created session carries none of them.
  • Why it's built this way: sessions live in a key space shared with Sessionize, so the id is chosen where the existing ids are visible (the handler's range query) rather than guessed by the client; and a schedule half is only sent when it is complete, so an organizer who picks a date but no time gets an unscheduled session rather than one silently pinned to midnight.
  • Where it's used: the _model field of SessionCreate (.../Pages/Sessions/SessionCreate.razor.cs:36), consumed by its CreateSessionAsync (.../Pages/Sessions/SessionCreate.razor.cs:172).

SessionEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionEditModel.cs:16 · Level 4 · sealed class (form model)

  • What it is: the edit-side concrete of SessionFormModel, used by SessionDetail's inline editor. LoadFrom opens the editor and ToUpdated closes it, replacing what the class doc calls thirteen assignments in the page each way (lines 11-14).
  • Depends on: SessionFormModel and SessionDTO (line 1).
  • Concept introduced: none new. The shadow-editing-through-a-model idea is EventEditModel's, and the split-timestamp handling is SessionFormModel's. What is worth naming is the symmetry: LoadFrom splits each timestamp with ?.Date and ?.TimeOfDay (lines 29-32) and ToUpdated rejoins it with Combine (lines 58-59), so an untouched session round-trips to the same value. ToUpdated builds its result as session with { ... } (line 53), so every field the form does not edit is carried over from the loaded record rather than re-listed. [Rubric §8, Data Architecture]: because of that with expression the loaded RowVersion, the optimistic-concurrency token of ADR-035, is re-sent unchanged, along with the identity and owning event.
  • Walkthrough
    • LoadFrom(SessionDTO session) (lines 23-38): null guard, then eleven copies, including the four schedule halves (lines 29-32) and the RoomId (line 37).
    • ToUpdated(SessionDTO session) (lines 49-65): null guard, then session with overriding nine members off the model (lines 55-63): Title, Description, RoomId, the two rejoined timestamps, Status, IsServiceSession, AccessibilityInfo, and ResourceLinks. The doc comment (lines 40-45) names what round-trips instead of resetting: the identity, concurrency token, owning event, and the fields this form does not show (informed, confirmed, plenum, live and recording links). A schedule half left blank sends null, which clears the stored timestamp rather than preserving it, and the doc comment says so (lines 44-45).
    • The class doc (lines 8-10) records the deliberate omission: the owning event is displayed but never edited, because moving a session between events is a create plus a delete.
  • Why it's built this way: the session form has thirteen editable values and two of them are composites, so a hand-written transcription in the page is the most likely place for a dropped field. One method each way makes the round trip verifiable by reading two adjacent blocks, and copying from the loaded record with with means a DTO field the form never shows cannot be reset by omission.
  • Where it's used: the _model field of SessionDetail (.../Pages/Sessions/SessionDetail.razor.cs:71), driven by its StartEditing (.../Pages/Sessions/SessionDetail.razor.cs:166) and SaveChangesAsync (.../Pages/Sessions/SessionDetail.razor.cs:196).

SessionLookups

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionLookups.cs:32 · Level 5 · sealed class (page collaborator)

  • What it is: the display-enrichment layer behind SessionDetail. It holds the three global lookup dictionaries (events, speakers, category items) plus the room list of the loaded session's event, and exposes the naming and "not yet assigned" queries the page renders from.
  • Depends on: four service clients taken as primary-constructor parameters (lines 33-36), IEventLookupService, ISpeakerLookupService, ICategoryItemLookupService, and IRoomUIService; the EventInfo, SpeakerInfo, and CategoryItemInfo lookup records; RoomDTO; and Result (line 4). Externals: System.Globalization.CultureInfo.
  • Concept introduced, the page-owned collaborator with an input-keyed cache. Two ideas make this class worth reading:
    1. Not a registered service, deliberately. The <remarks> (lines 15-19) says it caches per page instance, because the underlying lookup services already do their own scope-wide caching; holding it here keeps the page's own state down to the session being edited. So it is constructed in the page's OnInitialized (.../Pages/Sessions/SessionDetail.razor.cs:58-59) rather than injected. [Rubric §19, State Management & Data Flow] assesses where view state lives and how long it lives: this puts derived display state at exactly the lifetime that needs it.
    2. The room cache is keyed by the event it was fetched for. The three global dictionaries are hydrated once each with a null check (lines 67, 76, 85), but rooms are per event, so _roomNames is paired with _roomsForEventId (line 46) and refetched whenever the loaded session belongs to a different event. The in-code comment (lines 43-45) records the exact bug the key prevents: navigating to a session in another event would otherwise render the previous event's room names and offer its rooms in the edit picker. A third, quieter rule runs through every accessor: a name falls back to the identifier when its lookup is unloaded (lines 134, 147-148, 160-163, 171), so a failed lookup load degrades to raw ids instead of an empty cell (<remarks>, lines 20-23). One accessor distinguishes the two cases: SpeakerName (lines 145-153) takes a localized deletedSpeakerLabel, because a session keeps its link to a speaker deleted later (BR-70/71) while the lookup serves live speakers only, so an id missing from a loaded lookup is a deleted speaker and shows the label rather than a raw GUID (OBS-1, doc comment lines 136-141). Only an unloaded lookup still yields the identifier. [Rubric §29, Resilience & Business Continuity] assesses graceful degradation: a non-critical enrichment failure costs readability, not the page.
  • Walkthrough
    • State: three nullable IReadOnlyDictionary fields (lines 38-40), the room-name dictionary (line 41), and the event key (line 46).
    • EditableRooms (line 52) is the room list the edit picker binds, initialized to an empty array so the picker renders no options rather than needing a null check per render. HasMultipleEvents (line 57) is true only when more than one event exists, which is when naming a session's event earns a row in the detail table (.../Pages/Sessions/SessionDetail.razor:71-73).
    • LoadGlobalAsync(CancellationToken) (lines 65-95) loads each of the three dictionaries at most once and returns the first failure unchanged, so the page reports the real error rather than a generic one.
    • LoadRoomsAsync(EventIdentifierType, CancellationToken) (lines 104-128) short-circuits when the cached list is already for that event (lines 107-110), otherwise fetches up to 500 rooms filtered by EventId equals <id> (lines 112-118) and assigns _roomNames, EditableRooms, and _roomsForEventId together (lines 124-126).
    • Naming: EventName (line 133), SpeakerName(id, deletedSpeakerLabel) (line 145), CategoryItemName (line 158), and RoomName (line 171). Describe (line 177) is the shared qualifier that renders a category item as "Category: Item" when the category has a title and just the item name otherwise (line 181).
    • Picker sources: SpeakersExcept (lines 187-196) and CategoryItemsExcept (lines 201-210) subtract the already-assigned ids through a HashSet so the add pickers never offer a duplicate, and return an empty sequence while the lookup is unloaded.
  • Why it's built this way: SessionDetail is the join-heaviest page in the module, and without this class its code-behind would carry four dictionaries, a cache key, six naming methods, and two set-difference queries on top of load, edit, save, delete, and two join editors. Extracting the enrichment leaves the page with orchestration only. The class stays ADC-local rather than moving to MMCA.Common because it is shaped entirely by Conference's own lookup services.
  • Where it's used: SessionDetail only (.../Pages/Sessions/SessionDetail.razor.cs:46, constructed at lines 58-59), and read throughout its template (.../Pages/Sessions/SessionDetail.razor:37, 71-75, 101-106, 136).

SessionCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionCreate.razor.cs:20 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer form that creates a session. It collects the shared SessionCreateModel fields plus the owning event, posts the new record, and redirects to that session's detail page. It is the clearest place to see the one thing that makes session editing awkward: a dependent lookup, because rooms belong to the chosen event.
  • Depends on: ISessionUIService (line 22), IEventLookupService returning EventInfo (line 23), IRoomUIService for the room dropdown (line 24), IToastService (line 26), SessionCreateModel (line 47) and its base SessionFormModel for the BR-122 key (line 188), RoomDTO, ModelValidation plus DataAnnotationsModelValidator (line 81), ConferenceRoutePaths, ErrorMessages (line 182), ResultUiExtensions.NotifyOnFailure, which posts the last save's Result (_saveResult, line 56) for the page's inline ErrorSummary, and the LatestLoadGuard (_roomsLoad, line 33) plus the _cts.LifetimeToken() extension (LifetimeToken, line 38). It uses the Event, Room, and Session identifier aliases. Externals: Blazor ([Inject], NavigationManager), MudBlazor (MudForm, BreadcrumbItem, MudAlert), System.Globalization.CultureInfo, and the IStringLocalizer<SessionCreate> injected by the template (.../Pages/Sessions/SessionCreate.razor:7).
  • Concept introduced, the dependent lookup. The create-form shape itself is EventCreate's; what is new here is that one field's options depend on another field's value. LoadRoomsAsync (lines 111-147) fetches rooms filtered by the selected event, and OnEventChangedAsync (lines 151-167) reloads them and clears the previous choice whenever the event changes. The doc comment (lines 106-110) records why this is not cosmetic: BR-130 rejects a room from another event server-side, so the dropdown must only ever offer rooms of the chosen event. [Rubric §24, Forms, Validation & UX Safety]: the client is shaped so it cannot compose a request the server will refuse, and the same applies to the schedule: CreateSessionAsync checks SessionFormModel.HasEndAfterStart (BR-122) before posting (lines 186-191). [Rubric §19, State Management & Data Flow]: _rooms is derived state, explicitly invalidated when its input changes rather than left to go stale, and _model.RoomId is nulled alongside it (line 157) with an in-code note that keeping it would have the server reject the save. The fetch is also last-writer-wins: LoadRoomsAsync calls _roomsLoad.Begin() (line 119) for a token and a generation, before the unset-event branch so clearing the event cancels a fetch in flight, and drops a superseded answer after the await (IsCurrent, lines 133-136), so a slow earlier event's rooms can never overwrite those of the event now selected (in-code comment, lines 30-32). A second idea belongs to the model rather than the page: SessionCreateModel posts Id = default and leaves allocation in the reserved manual-id range to the server, the same choice as EventCreate's.
  • Walkthrough
    • OnInitializedAsync (lines 71-104) builds the breadcrumb trail (lines 73-78), wires the validation delegate (line 81), loads the event lookup through LifetimeToken and toasts Snackbar.LoadLookupsFailed on failure (lines 85-91), auto-selects the only event when the lookup has exactly one entry (lines 93-96, the same single-conference convenience as RoomCreate), then calls LoadRoomsAsync (line 98).
    • LoadRoomsAsync (lines 111-147): returns at once when the page is disposed (lines 113-116), begins the room-load generation (line 119), then with no event chosen clears _rooms and returns (lines 121-125); otherwise it fetches up to 500 rooms filtered by EventId equals <selected> (lines 127-132) with the guard's token. A stale generation returns silently (lines 133-136). On failure it toasts and leaves the previously offered rooms in place (lines 138-144).
    • CreateSessionAsync (lines 169-218) clears both _saveResult and _scheduleError before validating (lines 176-177) and validates the form (lines 179-184). It then applies the client-side BR-122 check: when _model.HasEndAfterStart() is false it stores the localized SessionFormModel.EndsAtAfterStartsAtKey text in _scheduleError (declared at line 59), toasts it as a warning, and returns without posting (lines 186-191); the template renders _scheduleError as an inline MudAlert until the next attempt (.../Pages/Sessions/SessionCreate.razor:43-45). Otherwise it sets IsSaving, posts _model.ToNew(_eventId) with LifetimeToken and stores the outcome in _saveResult (lines 196-197), and on failure calls created.NotifyOnFailure(Toast, L) (line 202) rather than a fixed Toast.Error, so the wording also renders inline through the page's ErrorSummary, which survives the snackbar timing out while the organizer is still reading the form (in-code comment, lines 200-201). On success it clears _isDirty, toasts success, and navigates to ConferenceRoutePaths.SessionDetails(createdSession.Id) (line 208). The finally always clears IsSaving.
    • Dispose(bool) (lines 224-239) cancels and disposes _cts and also disposes _roomsLoad (line 235). LifetimeToken (line 38) exists because reading Token off a disposed source throws ObjectDisposedException; the extension hands a late-resuming handler a cancelled token instead, so it stops through its OperationCanceledException path.
    • The template shows the event picker only when it is worth showing, ShowEventPicker="@(_eventLookup is not null && _eventLookup.Count > 1)" (.../Pages/Sessions/SessionCreate.razor:29), and passes _rooms plus RoomPickerBeforeSchedule="true" so the dependent field is chosen before the schedule (.../Pages/Sessions/SessionCreate.razor:31).
  • Why it's built this way: one create-form shape (validate, post, redirect to detail) is reused across the Conference entities so behavior stays uniform; the split date/time editing exists because MudBlazor has no single date-time picker, so SessionFormModel composes two controls, recombines them defensively, and checks their order; and the event-scoped room reload keeps the client from ever offering a value the server will reject.
  • Where it's used: the /sessions/create route (.../Pages/Sessions/SessionCreate.razor:1), reached from SessionList's create button; on success it hands off to SessionDetail.

SessionDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionDetail.razor.cs:24 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer's full session editor: load one session by route id, inline-edit it, delete it, and manage its two child collections (speakers and category items), with event, room, speaker, and category names resolved through SessionLookups. It is the join-heaviest detail page in the Conference UI.
  • Depends on: DetailPageBase (@inherits, .../Pages/Sessions/SessionDetail.razor:9); seven service clients, ISessionUIService, IEventLookupService, ISpeakerLookupService, ICategoryItemLookupService, ISessionSpeakerUIService, ISessionCategoryItemUIService, and IRoomUIService (lines 29-35), plus IToastService (line 37); SessionLookups (line 46) and SessionEditModel (line 72), with SessionFormModel for the BR-122 key (line 192); a shared LoadGuard helper for the load-generation protocol (line 100); the SessionDTO shape and the SpeakerInfo / CategoryItemInfo lookup records; Result, ResultUiExtensions.NotifyOnFailure, ConferenceRoutePaths, ErrorMessages (line 114), the DeleteConfirmation component, and DomainHelper's Parse<T> extension (MMCA.Common.Shared.Extensions, line 10; called at line 105). Uses the Event/Room/Speaker/Session/SessionSpeaker/SessionCategoryItem/CategoryItem aliases.
  • Concept introduced, the load generation and the join-collection editor. The route-id parsing, load-once-on-parameters, and model-based shadow editing are EventDetail's. Two things are new:
    1. A monotonic load generation, factored into a shared LoadGuard helper. LoadAsync calls LoadGuard.Begin() (line 100) for a cancellation token and a generation number, then re-checks LoadGuard.IsCurrent(generation) after each of its three awaits (lines 106, 130, 141) and drops its results if a newer load has started; the token from Begin(), not PageToken, is what each of those three calls passes down. _loadedId is stamped synchronously before the await (line 93), so two rapid route changes would otherwise let the later-completing fetch paint the wrong session; the generation, not _loadedId, is what is authoritative here. A failed load also nulls Session (line 122, comment lines 120-121 on L156), because the edit, delete and child-mutation actions are bound to Session and must not act on the previous session under the new id. The finally is guarded the same way (LoadGuard.IsCurrent(generation), line 156), because an unconditional clear would let a superseded response switch off the spinner the newer load just turned on. Keeping the counter in LoadGuard rather than a page-local field makes the same protocol available to any page that needs it. [Rubric §19, State Management & Data Flow] assesses whether concurrent updates to view state are ordered: this is an explicit last-writer-wins protocol rather than an implicit one. [Rubric §12, Performance & Scalability]: a superseded load's results are discarded rather than painted, so nothing stale is rendered.
    2. Join management as an add/remove/available triple, factored once. The same three-method pattern applies twice, to speakers and to category items, and both funnel through MutateChildAsync (lines 302-324), which takes the operation, its own failure and success resource keys, and an optional picker-clearing callback. Each mutation ends with a full LoadAsync (line 318) rather than a local patch. Its failure toast depends on the error kind (lines 310-312, doc comment lines 295-301): an ErrorType.Unexpected fault (a 500, transport failure, or timeout) shows that operation's fixed failure message rather than raw diagnostic text, while any refusal the API stated (O-46, for example "already associated") shows the API's own localized message via LocalizedErrorMessage(L), falling back to the fixed key (ADR-027 Decision 9 carve-out). [Rubric §8, Data Architecture]: the update DTO re-sends the loaded RowVersion through SessionEditModel.ToUpdated, the client half of the optimistic-concurrency token (ADR-035). [Rubric §24, Forms, Validation & UX Safety]: the save refuses an end that is not after the start (BR-122) on the client, through the shared SessionFormModel.HasEndAfterStart, before it posts. [Rubric §18, UI Architecture & Component Design]: the page's remaining size comes from breadth (two join collections, four lookups, seven clients), not from bespoke mechanics; enrichment lives in SessionLookups and form shape in SessionEditModel.
  • Walkthrough
    • OnInitialized (lines 48-63) builds breadcrumbs, constructs SessionLookups from the four injected lookup clients (lines 58-59), and wires the validation delegate (line 62).
    • OnParametersSetAsync (lines 86-95) loads only when the route Id differs from _loadedId.
    • LoadAsync (lines 97-161): LoadGuard.Begin() for the token and generation (line 100), GetByIdAsync(id, true, token) so the join collections arrive with the record (line 105), toast ErrorMessages.NotFound and bail when the session is missing (lines 111-116), null Session and report any other failure through NotifyOnFailure (lines 118-125), then hydrate the global lookups (line 129) and the event's rooms (line 140), each followed by a LoadGuard.IsCurrent re-check.
    • Edit and save (lines 163-224): StartEditing calls _model.LoadFrom(Session) then BeginEdit (lines 170-171). SaveChangesAsync clears _scheduleError (line 181, the field is declared at line 75), validates the form (lines 183-188), then refuses a schedule whose end is not after its start: when _model.HasEndAfterStart() is false it stores the localized SessionFormModel.EndsAtAfterStartsAtKey text in _scheduleError, toasts it as a warning, and returns without posting (lines 190-195); the template renders it as an inline MudAlert (.../Pages/Sessions/SessionDetail.razor:52-54). Otherwise it posts _model.ToUpdated(Session) and refetches, both written as a single expression chaining NotifyOnFailure into IsFailure / TryGetValue (lines 200-210), then toasts and EndEdit (lines 213-214).
    • Delete (lines 226-252): confirm through _deleteConfirm.ShowAsync(Session.Title) (line 233), delete, navigate back to the list.
    • Joins: GetAvailableSpeakers / GetAvailableCategoryItems (lines 254-258) delegate the set difference to SessionLookups so a picker never offers an already-assigned entry; AddSessionSpeakerAsync (lines 260-272) and AddSessionCategoryItemAsync (lines 278-289) each capture their ids into locals before building the delegate, with an in-code note that nullable flow analysis does not reach inside a lambda (line 267); the two remove methods (lines 274-276, 291-293) are one-liners over the same MutateChildAsync.
  • Why it's built this way: a session is the join-heavy center of the program (speakers, category items, room, event, timing), so the organizer edits all of it from one console. Reloading after each join mutation keeps the page a single source of truth instead of hand-patching local collections, and the load generation is what makes that reload safe when the reader is navigating quickly between sessions.
  • Where it's used: the /sessions/{Id} organizer route (.../Pages/Sessions/SessionDetail.razor:1), reached from SessionList rows and from SessionCreate's success redirect; its "view feedback" button opens OrganizerSessionFeedback through ConferenceRoutePaths.SessionFeedbackOrganizer (.../Pages/Sessions/SessionDetail.razor:182-186).
  • Caveats / not-in-source: reads pass includeChildren: true so the join collections populate; how the server populates children is outside this page.

SponsorCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorCreate.razor.cs:19 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer form that records a sold sponsorship at /sponsors/create (.../Pages/Sponsors/SponsorCreate.razor:1-2, Authorize(Roles = RoleNames.Organizer)). It collects the sponsor name, tier, owning event, branding links and the optional expo booth details, and the class doc is explicit that the event picker is required because sponsorships are sold per event and the owning event cannot be changed afterwards (lines 12-16).
  • Depends on: ISponsorUIService (line 21), IEventLookupService with EventInfo (lines 22, 84), IToastService (line 24), an injected BCL TimeProvider (line 25), SponsorCreateModel (line 85), CurrentEventSelector (line 69), ConferenceRoutePaths (lines 44, 134, 146), ErrorMessages (line 115), ModelValidation with DataAnnotationsModelValidator (line 49), ResultUiExtensions.NotifyOnFailure over the create result (line 128), and the _cts.LifetimeToken() extension (LifetimeToken, line 32). Externals: Blazor, MudBlazor (MudForm, BreadcrumbItem), and the IStringLocalizer<SponsorCreate> from the template (.../Pages/Sponsors/SponsorCreate.razor:7).
  • Concept introduced, the split initialization and the smart event default. This page uses both lifecycle hooks deliberately. OnInitialized (lines 38-50) does the synchronous work (breadcrumbs, validation delegate) so the first render already has them, and OnInitializedAsync (lines 52-80) does the network work. The pattern matters because the async hook runs after the first render and the page must not be blank or unvalidatable in between. The default-event rule is the richer of the two idioms in this group. Rather than auto-selecting only when exactly one event exists (which is what RoomCreate does at .../Pages/Rooms/RoomCreate.razor.cs:65-67), it calls CurrentEventSelector.SelectCurrentOrNext over each event's start date, end date and IANA time zone against the injected TimeProvider.GetUtcNow().UtcDateTime (lines 67-74), so the picker opens on the conference the organizer is most likely selling against, and a test can pin "now" instead of depending on the wall clock. The ??= (line 67) means a value the organizer already picked is never overwritten, and the picker stays open for the rest (ShowEventPicker="true", .../Pages/Sponsors/SponsorCreate.razor:29). The failure policy also differs from RoomCreate: a failed lookup here is non-critical and silently leaves the picker empty, because the required-field error then guides the user (in-code comment, lines 58-59). Only a genuinely unusable form is escalated to a toast. [Rubric §24, Forms, Validation & UX Safety] assesses whether a form makes the common case cheap without hiding a field: the default is a pre-selection, not a lock, and the submit still re-checks _eventId is null alongside _form.IsValid (line 113) so a cleared picker cannot post. [Rubric §19, State Management] assesses ownership of transient state: _isDirty (line 95) is the page's only cross-cutting flag, set by MarkDirty (line 101) which SponsorFormFields invokes through its OnFieldChanged callback (.../Pages/Sponsors/SponsorCreate.razor:26). A second field, _saveResult (line 93), holds the last create attempt's failed Result so the page's ErrorSummary can render it inline alongside the form's own validation messages, because a snackbar can time out while the organizer is still reading the form.
  • Walkthrough
    • OnInitialized (lines 38-50): breadcrumbs Home, Sponsors, Create with the last disabled: true (lines 41-46), then the validation delegate (line 49, ADR-027 comment at line 48).
    • OnInitializedAsync (lines 52-80): awaits base.OnInitializedAsync() (line 54), loads the event lookup through LifetimeToken (line 60, so a handler resuming after disposal gets a cancelled token rather than an ObjectDisposedException from _cts.Token), then resolves the default (lines 67-74). OperationCanceledException is swallowed (lines 76-79).
    • CreateSponsorAsync (lines 103-144): null-guards _form (lines 105-108), clears _saveResult (line 110), validates and re-checks the event (lines 112-117), sets IsSaving (line 119), posts _model.ToNew(_eventId.Value) through AddAsync with LifetimeToken and stores the result in _saveResult (lines 122-123), and on a failed Result calls createResult.NotifyOnFailure(Toast, L) (line 128) rather than a fixed toast, so the same wording also surfaces inline. On success it clears _isDirty before navigating (line 132, with the comment), toasts, and routes to ConferenceRoutePaths.SponsorDetails(created.Id) (line 134). IsSaving is cleared in the finally (lines 140-143).
    • NavigateToList (line 146) and the _disposed-guarded dispose pair (lines 148-171) close the page out.
    • The template passes two extra localized strings into the shared field block, LinksHeading and TwitterPlaceholder (.../Pages/Sponsors/SponsorCreate.razor:27-28), which is how one SponsorFormFields component (.../Pages/Sponsors/SponsorFormFields.razor:120,123) serves both this page and the detail editor without either owning the copy.
  • Why it's built this way: a sponsorship is sold against one event and the tier is a paid package, so the page's whole design goal is to make the event obvious and the tier explicit. Defaulting the event removes the most common click; leaving the tier unset (see SponsorFormModel) forces the one choice that must not be inherited.
  • Where it's used: reached from SponsorList's create button (.../Pages/Sponsors/SponsorList.razor.cs:99); on success it hands off to SponsorDetail.

SponsorDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorDetail.razor.cs:19 · Level 9 · class (Blazor code-behind)

  • What it is: the organizer's sponsor record page at /sponsors/{Id:int} (.../Pages/Sponsor/SponsorDetail.razor:1-2, Organizer-only). It loads one sponsor, renders it as a branded card, inline-edits every field except the owning event, deletes behind a confirmation, and exposes the booth-visit QR code. The class doc states the event exclusion and its reason (lines 13-17).
  • Depends on: DetailPageBase (base class, line 19: supplies PageToken, IsEditing/IsDirty and the BeginEdit/EndEdit transitions), ISponsorUIService (line 23), IEventLookupService (line 24), IToastService (line 26), SponsorEditModel (line 63), SponsorDTO (line 59), SponsorTier (line 35), ConferenceRoutePaths (lines 49, 223 and .../Pages/Sponsor/SponsorDetail.razor:70), ErrorMessages (lines 99, 157), ResultUiExtensions (lines 105, 114, 167, 174), ModelValidation (line 54), and the shared LoadGuard (lines 85, 91, 127). Externals: Blazor, MudBlazor (MudForm, MudAvatar, MudImage, MudSimpleTable, MudDivider, MudLink), System.Globalization.CultureInfo (lines 41, 99).
  • Concept introduced, localized enum rendering by convention. TierLabel(SponsorTier tier) (line
    1. is a one-liner: L[$"Tier.{tier}"].Value. The enum member's name is interpolated into a resource key, so adding a tier means adding a resource entry, not a switch. The same helper appears on SponsorList (.../Pages/Sponsor/SponsorList.razor.cs:36). [Rubric §27, Internationalization] assesses whether every user-visible string is externalized: this is the pattern that keeps enums from leaking English identifiers into the UI. The second idea, contrasted against RoomDetail, is the typed route parameter. [Parameter] public int Id (line 27) plus the {Id:int} route constraint (.../Pages/Sponsor/SponsorDetail.razor:1) pushes the conversion into the router, so the page has no parse step and a non-numeric URL never reaches this component. The same _loadedId reload guard applies (lines 71-80), just typed int?. The page no longer declares its own IDisposable, CancellationTokenSource, or edit-mode fields: it now extends DetailPageBase, which owns the page-scoped PageToken the save, reload and delete calls take (lines 164, 171, 207), and the IsEditing/IsDirty pair the unsaved-changes guard reads. StartEditing opens the editor through BeginEdit() (line 142) instead of setting two fields by hand, and both CancelEditing (line 145) and the end of a successful SaveChangesAsync (line 180) close it through EndEdit(), so the dirty flag can never be left set behind a closed editor. LoadAsync, though, does not take PageToken: it begins a monotonic load generation with LoadGuard.Begin() (line 85), passes that token to both fetches (lines 90, 113), re-checks LoadGuard.IsCurrent(generation) after the sponsor fetch (line 91), and clears IsLoading only for the current generation (lines 127-130, in-code comment lines 125-126), so a superseded response cannot switch off the spinner a newer load just turned on. It is the same protocol SessionDetail uses. [Rubric section 3, Clean Architecture] (assesses reuse over per-page duplication of a cross-cutting concern): the edit-mode and token plumbing lives once in the shared base, and the load-generation counter once in LoadGuard, rather than being repeated per detail page. [Rubric §26, Front-End Security] assesses outbound-link handling: the two sponsor URLs render as MudLink with Target="_blank" and rel="noopener noreferrer" (.../Pages/Sponsor/SponsorDetail.razor:119,123), and the whole links block is only rendered when at least one of the three link fields has content (:110-112). [Rubric §21, Accessibility] assesses whether structure and labelling carry meaning to assistive technology: the detail table uses <th scope="row"> for its labels rather than bold <td> (.../Pages/Sponsor/SponsorDetail.razor:85,89,93), the logo image gets Alt="@Sponsor.Name" (:59), and both header icon buttons carry aria-label (:72-73).
  • Walkthrough
    • EntityName (line 21) and Title (line 30) are localizer lookups; EventName (lines 38-41) resolves the owning event's display name from the lookup and falls back to the invariant-formatted id.
    • OnInitialized (lines 43-55) builds the breadcrumbs and the validation delegate (line 54).
    • OnParametersSetAsync (lines 71-80) is a thin reload guard delegating to LoadAsync.
    • LoadAsync (lines 82-132) begins a load generation (line 85) and fetches with GetByIdAsync(Id, true, token) (line 90), using the guard's token rather than a page-owned CancellationTokenSource. Note the second argument: includeChildren: true, which RoomDetail does not request (.../Pages/Room/RoomDetail.razor.cs:75 passes the token by name and leaves the flag defaulted). A NotFound Result nulls Sponsor and toasts (lines 96-101); any other failure goes through NotifyOnFailure (line 105). The event lookup is then loaded once, lazily, with the guard's token (lines 113-117), and IsLoading is cleared in the finally only for the current generation (lines 123-131).
    • StartEditing (lines 134-143) seeds SponsorEditModel from the loaded sponsor and opens the editor through DetailPageBase's BeginEdit() (line 142); CancelEditing (line 145) closes it again through EndEdit() without touching Sponsor.
    • SaveChangesAsync (lines 147-190) validates (lines 154-159), posts _model.ToUpdated(Sponsor) (line 164), re-fetches with includeChildren: true (line 171), swaps Sponsor for the refreshed instance and leaves edit mode through EndEdit() (line 180). Because SponsorEditModel carries RowVersion through the projection, this is the round trip that keeps optimistic concurrency meaningful.
    • DeleteSponsorAsync (lines 192-221) confirms through _deleteConfirm.ShowAsync(Sponsor.Name) (line 199), deletes (line 207), toasts and returns to the list.
    • The read-only card renders the logo as a MudAvatar when present (:57-60), the name and localized tier (:62-63), then a compact table of event, sort and exhibitor status, where the exhibitor row picks between three localized strings depending on whether the sponsor exhibits and whether a booth number is known (:95-99).
  • Why it's built this way: the sponsor record is the one Conference screen an organizer shows to a paying customer, so it renders branding (logo, name, tier) before administrative fields, and it hides empty sections entirely rather than showing blank rows.
  • Where it's used: the navigation target of SponsorCreate (.../Pages/Sponsor/SponsorCreate.razor.cs:113) and of SponsorList (.../Pages/Sponsor/SponsorList.razor.cs:99). Its QrCodeButton points at ConferenceRoutePaths.SponsorVisitLink(Sponsor.Id) (.../Pages/Sponsor/SponsorDetail.razor:70), which resolves to the Engagement-owned /engage/sponsors/{id} landing page (.../MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:60), so the printed booth code takes an attendee to the visit flow, not to this management screen.
  • Caveats / not-in-source: what includeChildren: true actually pulls back for a sponsor is decided by the API's read model, not by this page.

SessionStatusDisplay

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionStatusDisplay.cs:11 · Level 10 · class

  • What it is: a small static helper that maps a Sessionize status string to a MudBlazor Color and to a localized label for chip rendering. GetStatusColor (lines 13-22) switches on SessionStatuses.Accepted, Waitlisted, AcceptQueue, Nominated, DeclineQueue, and Declined, defaulting to Color.Default for anything else. GetStatusLabelKey (lines 31-40) maps the same six statuses to Status.* resource keys and returns null for anything else, and GetStatusLabel (lines 50-54) resolves that key through an IStringLocalizer<SessionList>, returning the status as stored when no key matches.
  • Depends on: SessionStatuses for the status constants, and SessionList as the type argument of the localizer in GetStatusLabel (line 50), which is why the helper sits at the same level as the page. Externals: MudBlazor's Color enum, IStringLocalizer<T>.
  • Concept introduced, one label source for filter and chip. The labels come from the SessionList resources so a status chip shows exactly the wording of the page's status filter (doc comments, lines 25-28 and 43-46); a free-text status outside SessionStatuses (Sessionize imports any string) is shown as stored rather than hidden. GetStatusLabel null-guards the localizer (line 52).
  • Why it's built this way: keeping the color switch, the label-key switch, and the label lookup in one internal static class lets SessionList and SessionSelectionDisplay share them instead of each page keeping its own copy of the same six-case switch.
  • Where it's used: SessionList (StatusLabel, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionList.razor.cs:98) and SessionSelectionDisplay (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionDisplay.cs).

SessionList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionList.razor.cs:22 · Level 10 · class (Blazor code-behind)

  • What it is: the organizer browse page for sessions and the richest list in the Conference UI. It carries three filters (free-text title search, session status, and event), enriches each row with room and speaker names, and color-codes the Sessionize status. It sits at the top of the group's dependency order because it transitively pulls in the most lookups and defaults.
  • Depends on: extends DataGridListPageBase<TDto> over SessionDTO (line 22) and injects ISessionUIService, IEventUIService, and ISpeakerLookupService (lines 27-29), plus TimeProvider (line 31) as the clock for the default-event computation. It uses EventDTO, SpeakerInfo, SessionStatusDisplay for the status-to-color mapping and the localized status label (StatusLabel, line 98), CurrentEventDefaults (the EventDTO-typed wrapper over CurrentEventSelector), ConferenceRoutePaths, ErrorMessages (line 221), ListPageActions (lines 155, 215), and the MobileInfiniteScrollList<TItem>, DeleteConfirmation, and ListNoRecordsContent components. Uses the Event/Room/Session/Speaker aliases.
  • Concept introduced, the multi-filter enriched list. SessionList layers three refinements on the event-filtered shape RoomList, SponsorList, and SpeakerList share:
    1. A third filter with a sentinel. _searchString, _selectedStatus, and _selectedEventId persist together (SaveFilters lines 49-58, RestoreFilters lines 60-78) and are emitted as Title contains, Status equals, and EventId equals server filters (ApplyFilters, lines 201-209). The event filter needs three states, not two, so a saved "all" string distinguishes an explicit clear from no saved state at all, which is what lets the computed default apply only on a first visit (in-code comment, line 55).
    2. Enrichment from two bulk loads instead of per-row fetches. LoadEventsAndResolveDefaultAsync fetches events with includeChildren: true (line 104) and folds every event's rooms into one _roomNames dictionary (PopulateRoomNames, lines 128-142), while the speaker lookup loaded in OnInitializedAsync (line 88) backs GetSpeakerList (lines 144-152), which maps a row's SessionSpeakers to display names and skips ids the lookup does not know. Both loads are explicitly non-critical: a failed Result is simply not unwrapped, and the comments (lines 87, 102-103) say the fallback is dash display and an unset default filter, not a broken page. The paged fetch itself also passes includeChildren: true (lines 186, 198) so each row arrives with its speaker joins.
    3. Status color and label, delegated rather than local. The mapping is SessionStatusDisplay.GetStatusColor, called from both the mobile card and the grid's status chip (.../Pages/Sessions/SessionList.razor:87, 190), so this page and SessionSelectionDisplay share one copy of the switch. The chip text goes through StatusLabel (line 98, comment lines 96-97, X-01), which calls SessionStatusDisplay.GetStatusLabel(status, L), so a chip shows the same localized label as the status filter and an unrecognized free-text status is shown as stored. The startup race guard is the same one RoomList uses, with the clearest explanation in this file: _eventsLoadTask is started before the first await (lines 82-85) and awaited inside both LoadServerData (lines 180-181) and FetchMobilePage (lines 193-194), because ApplyFilters runs inside LoadServerDataAsync, so the default event must be resolved before entering it, "not merely before the fetch delegate runs" (in-code comment, lines 178-179). [Rubric §18, UI Architecture & Component Design]: the status filter surfaces the program-committee workflow inline instead of hiding it behind a separate screen. [Rubric §23, Front-End Performance & Rendering]: one children-loaded events fetch plus one speaker lookup replace what would otherwise be per-row enrichment calls. [Rubric §25, Navigation & Information Architecture]: all three filters survive navigation through the base class's persistence contract, with the "all" sentinel and the computed default. [Rubric §29, Resilience & Business Continuity]: both enrichment loads degrade rather than fail.
  • Walkthrough
    • OnInitializedAsync (lines 80-94): start the events task, load the speaker lookup (tolerating failure), then await the events task.
    • ResolveDefaultEventFilter (lines 114-126): keep a restored id that still exists in _events, otherwise fall back to CurrentEventDefaults.SelectCurrentOrNext(_events, TimeProvider.GetUtcNow().UtcDateTime)?.Id (line 124). Reading "now" from the injected TimeProvider rather than DateTime.UtcNow is what lets a test pin the clock and assert which event becomes the default. The comment (lines 116-117) records the case this covers: a dangling saved id would otherwise silently show an empty grid.
    • OnSearchChanged, OnStatusChanged, and OnEventFilterChanged (lines 157-174) each update one filter and reload whichever layout is active via ListPageActions.ReloadActiveLayoutAsync (lines 154-155).
    • LoadServerData (lines 176-188) and FetchMobilePage (lines 191-199) are the desktop and mobile fetch paths over the same ApplyFilters.
    • DeleteSessionAsync (lines 214-222) delegates the confirm, delete, toast, and reload sequence to ListPageActions.DeleteWithConfirmationAsync; NavigateToCreate and NavigateToDetails (lines 224-225) route to SessionCreate and SessionDetail.
    • The grid uses PropertyColumns for the sortable fields (Title, StartsAt, EndsAt, RoomName, Status, .../Pages/Sessions/SessionList.razor:114, 148, 158, 171, 183) and TemplateColumns only for the speakers and row actions (lines 121, 199). The status column keeps its chip inside a CellTemplate of a PropertyColumn because server-side sorting keys off the property expression (in-template comment, .../Pages/Sessions/SessionList.razor:181-182).
  • Why it's built this way: sessions are the central editable entity of the program, so the list has to answer "what is in this conference, in what state, presented by whom" at a glance; defaulting to the active event and enriching from two bulk loads keeps that view both relevant and cheap.
  • Where it's used: the /sessions organizer route (.../Pages/Sessions/SessionList.razor:1-2, Authorize(Roles = RoleNames.Organizer)); rows open SessionDetail and the create button opens SessionCreate.
  • Caveats / not-in-source: the page builds speaker names from its own lookup rather than trusting the paged payload alone, so it degrades to a dash rather than a wrong name when a speaker id is unknown; how the paged endpoint populates SessionSpeakers is a server-side concern outside this file.

SponsorList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorList.razor.cs:19 · Level 10 · class (Blazor code-behind)

  • What it is: the organizer's sponsor index at /sponsors (.../Pages/Sponsor/SponsorList.razor:1-2, Organizer-only). Structurally the twin of RoomList: server-side paging with search, an event filter, a mobile card layout, and a tier column. The class doc (lines 12-17) makes the interesting point explicitly: unlike the speaker list, EventId is a real Sponsor column, so the event filter goes straight through the generic filter pipeline with no special handling.
  • Depends on: EventFilteredListPageBase<TDto> (line 18), ISponsorUIService (line 23), ListPageActions (lines 45, 89), MobileInfiniteScrollList<TItem> (line 31), SponsorTier (line 36), ConferenceRoutePaths (lines 98-99), ErrorMessages (line 95), Result (line 76) and SponsorDTO. Externals: MudBlazor (MudDataGrid, MudChip, GridState, GridData).
  • Concept introduced: none new. Read this page against RoomList: the two share the same override set (SavePageFilters line 38, RestorePageFilters line 41, ReloadForEventFilterAsync line 47, GridRef line 27, Title line 21), the same WaitForEventsAsync-before-ApplyFilters ordering (lines 59, 78, comment at 57-58), the same Name contains search filter (line 71), and the same ListPageActions delegation for reload and delete (lines 44-45, 88-96). That repetition across a dozen list pages is precisely what EventFilteredListPageBase<TDto> exists to keep down. [Rubric §18, UI Architecture] assesses whether the same capability is expressed the same way everywhere: an engineer who has read one of these list pages can read all of them, and the differences that remain (a tier column, an id-typed navigation helper) are exactly the entity-specific parts. [Rubric §20, Design System and Theming] assesses consistent use of the component vocabulary: the tier renders as a MudChip in the grid (.../Pages/Sponsor/SponsorList.razor:82) and as secondary body text on the mobile card (:50), both fed by the same TierLabel helper, so the same value reads consistently in two layouts.
  • Walkthrough
    • TierLabel(SponsorTier tier) (line 36) is the same convention-keyed localizer lookup SponsorDetail uses (.../Pages/Sponsor/SponsorDetail.razor.cs:35).
    • SavePageFilters / RestorePageFilters (lines 38-42) persist the search key only.
    • ReloadActiveLayoutAsync (lines 44-45) and ReloadForEventFilterAsync (line 47) mirror RoomList exactly.
    • LoadServerData (lines 55-66) awaits WaitForEventsAsync() (line 59) before delegating to the base's LoadServerDataAsync with the paged fetch and ApplyFilters.
    • ApplyFilters (lines 68-73) adds the Name contains filter then the inherited ApplyEventFilter (line 72). This is where the class doc's point lands: because EventId is a real column on the sponsor read model, the base's generic EventId equals filter is sufficient and the page adds nothing.
    • FetchMobilePage (lines 76-83) builds the identical filter set and requests Name ascending (line 82).
    • DeleteSponsorAsync (lines 88-96) uses ListPageActions.DeleteWithConfirmationAsync with the base DeleteAsync(sponsor.Id) (line 92): ISponsorUIService declares no extra members (.../Services/ISponsorUIService.cs:9-11) and SponsorService is an empty subclass of EntityServiceBase (.../Services/SponsorService.cs:10-14), so the generic CRUD surface is the whole surface.
    • NavigateToCreate (line 98) and NavigateToDetails (line 99) are the navigation edges; OnMobileCardClick (line 85) routes through the latter rather than duplicating the URL build.
    • The grid columns are Name (with an initial ascending sort and a link cell), Tier (sortable, rendered as a chip), Sort and BoothNumber, plus a delete action column (.../Pages/Sponsor/SponsorList.razor:72-107). Empty and failed states share the same ListNoRecordsContent with a retry bound to RetryLoadAsync (line 30, .../Pages/Sponsor/SponsorList.razor:109).
  • Why it's built this way: an organizer reading the sponsor list is checking who is confirmed and at what level, so tier is the one non-name column that is both sortable and visually distinct, and it is the only field promoted onto the mobile card alongside the name.
  • Where it's used: the /sponsors route reached from the Sponsors breadcrumb on SponsorCreate and SponsorDetail, and it navigates on to both.
  • Caveats / not-in-source: the tier column is Sortable="true" (.../Pages/Sponsor/SponsorList.razor:80), so the server sorts by the underlying enum's numeric value. Whether that ordering matches the commercial ranking an organizer expects depends on the SponsorTier member order, which is declared in the Conference Shared project, not here.

IOrganizerEventFeedbackUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/IOrganizerFeedbackUIService.cs:11 · Level 3 · interface

  • What it is: the organizer-facing contract for event feedback moderation: read every answer for an event, and delete one (IOrganizerFeedbackUIService.cs:11-21). Its doc comment (lines 7-10) ties it to business rule BR-53 and states the distinguishing property: unlike the attendee-facing service, this returns answers from all users.
  • Depends on: EventQuestionAnswerDTO, the EventIdentifierType and EventQuestionAnswerIdentifierType aliases, and Result / Result<T>.
  • Concept introduced, the audience-scoped read contract. [Rubric §11, Security] (assesses whether authorization decisions are made by the server rather than assumed by the client: this interface is named for the organizer audience, but it carries no role logic of its own; the server widens the result set for organizer users, and the client simply asks for the full list). [Rubric §9, API & Contract Design]: a separate contract for the same underlying resource, keyed by audience, keeps the attendee path from accidentally acquiring an "all users" read. The delete is a moderation action, which is why an otherwise read-only organizer surface has one write.
  • Walkthrough: two members, both EventIdentifierType-scoped.
    • GetAllAnswersAsync(eventId, ct) (IOrganizerFeedbackUIService.cs:13-15): returns Task<Result<IReadOnlyList<EventQuestionAnswerDTO>>>, a flat list rather than a paged envelope, so paging is the implementation's problem.
    • DeleteAnswerAsync(eventId, answerId, ct) (lines 17-20): returns Task<Result>, with the event id carried alongside the answer id because the endpoint scopes the delete to its parent, the same parent-scoped rule the join services follow.
  • Why it's built this way: exposing IReadOnlyList<T> keeps the Blazor grid simple, and the implementation absorbs the transport shape; see OrganizerEventFeedbackService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:15-68) for the paged request it actually issues and the ceiling that comes with it.
  • Where it's used: implemented by OrganizerEventFeedbackService, registered scoped at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:42-43 (whose comment names BR-53 moderation), and injected into OrganizerEventFeedback (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:20).

IOrganizerSessionFeedbackUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/IOrganizerFeedbackUIService.cs:27 · Level 3 · interface

  • What it is: the session twin of IOrganizerEventFeedbackUIService, the same two operations keyed on a session instead of an event (IOrganizerFeedbackUIService.cs:27-37), with the same BR-53 all-users note in its doc comment (lines 23-26).

  • Depends on: SessionQuestionAnswerDTO, the SessionIdentifierType and SessionQuestionAnswerIdentifierType aliases, and Result / Result<T>.

  • Concept: see IOrganizerEventFeedbackUIService for the audience-scoped read contract. [Rubric §9, API & Contract Design].

    Member File:Line Differs from the event twin
    GetAllAnswersAsync(sessionId, ct) IOrganizerFeedbackUIService.cs:29-31 scoped by SessionIdentifierType; returns SessionQuestionAnswerDTO
    DeleteAnswerAsync(sessionId, answerId, ct) IOrganizerFeedbackUIService.cs:33-36 parent id is the session
  • Where it's used: implemented by OrganizerSessionFeedbackService (OrganizerFeedbackService.cs:70-118), registered scoped at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:44, and injected into OrganizerSessionFeedback (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:20).

SpeakerFormModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerFormModel.cs:25 · Level 3 · class (abstract)

  • What it is: the speaker equivalent of ActivityFormModel: the eleven editable speaker fields with their rules, shared by the organizer create page and the detail page's inline editor.
  • Depends on: System.ComponentModel.DataAnnotations (:1), SpeakerDTO for every length constant (:2), and AbsoluteUrlAttribute plus OptionalEmailAttribute from MMCA.Common.UI.Validation (:3,62,75,80,85).
  • Concept introduced, the framework validation attributes alongside the BCL ones. The shared-model pattern is the one ActivityFormModel already taught; what is new here is the mix of rule sources on one model. [OptionalEmail] (:62) is the framework's email rule (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/OptionalEmailAttribute.cs:25): blank values pass, and a non-blank value must match the API's own FluentValidation shape (one @, neither first nor last), so the form and the server give the same verdict and a cleared field is not an error. [MaxLength] (:43, and eight more) quotes a DTO constant, and [AbsoluteUrl] (:75,80,85) is the framework's own attribute applied to the three social URLs, so "http://..." style input is rejected in the browser with the same mechanism and the same resource-key error style as everything else. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §26, Front-End Security] (assesses that user-supplied URLs are constrained before they are rendered as links). The Bio property is the instructive exception (:52-55): it carries no rule at all, and the doc explains why. The biography is stored unbounded, so the character cap the markup applies is a UI affordance rather than an invariant, and inventing a rule here would make the form stricter than the aggregate. It still lives on the model because both forms bind it. [Rubric §4, Domain-Driven Design]: a UI rule is not promoted to an invariant just because it is convenient.
  • Walkthrough
    • Resource-key constants (:32,39): FirstNameRequiredKey and LastNameRequiredKey, quoted by the model rule and by the field's RequiredError affordance alike.
    • Required identity fields (:42-49): FirstName and LastName, each required and capped.
    • Optional profile fields (:52-72): Bio (unruled), TagLine, Email (format plus cap), ProfilePicture, TwitterHandle.
    • Social URLs (:75-87): LinkedInUrl, GitHubUrl, WebsiteUrl, each [AbsoluteUrl] plus a cap.
  • Why it's built this way: the same argument as the activity model, with one addition. A speaker record is edited from three different places in this application (the organizer create page, the organizer detail page, and the speaker's own dashboard), so having a single declaration of the rules is what keeps the first two identical; see the caveat on SpeakerDashboard for the third.
  • Where it's used: subclassed by SpeakerCreateModel and SpeakerEditModel; bound through the shared SpeakerFormFields component by SpeakerCreate (SpeakerCreate.razor:24) and SpeakerDetail (SpeakerDetail.razor:35).

OrganizerEventFeedbackService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:15 · Level 4 · class (sealed)

  • What it is: an authenticated HTTP service that reads and deletes event feedback answers on behalf of an organizer, who sees all answers (OrganizerFeedbackService.cs:15-65). It implements IOrganizerEventFeedbackUIService.
  • Depends on: AuthenticatedServiceBase (its base, supplying CreateAuthenticatedClientAsync() at MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:59 and the shared static Polly RetryPolicy at :25), ITokenStorageService (the bearer-token source), PagedReadAll (the read-every-page loop, MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:19), HttpResultExecutor and ProblemDetailsResultReader (the railway conversion), PagedCollectionResult<T> (the paged envelope) and EventQuestionAnswerDTO. BCL IHttpClientFactory, System.Globalization (OrganizerFeedbackService.cs:1-7).
  • Concept introduced, the authenticated organizer read-service over a token-carrying HttpClient. [Rubric §18, UI Architecture] and [Rubric §11, Security] assess how UI calls attach auth and handle failures. The class derives from AuthenticatedServiceBase through a primary constructor that forwards IHttpClientFactory and ITokenStorageService to the base (OrganizerFeedbackService.cs:15-17); every request goes through CreateAuthenticatedClientAsync() (:34,57), so the JWT is attached centrally rather than per call. The doc comment (OrganizerFeedbackService.cs:11-14) records the authorization intent: organizers see all answers because the server-side specification is null for organizer users, so this client simply requests the full answer set and does no filtering of its own. Note the direction of trust: the client is not the thing granting the wide view, the server is. The filters[...] query grammar it uses (:32) is the same dynamic-filter contract the Conference REST controllers expose (ADR-034), so the client needs no bespoke endpoint for a one-off filter.
  • Walkthrough
    • Endpoint (OrganizerFeedbackService.cs:19): the private const string "eventquestionanswers" resource root.
    • GetAllAnswersAsync(eventId, ct) (:21-45): hands a per-page delegate to PagedReadAll.ReadAllAsync (:27), which calls it for pages 1, 2, ... until a page comes back empty or the accumulated count reaches the server's reported total, stopping after 40 pages regardless (PagedReadAll.cs:28,48-79). The in-code comment (:25-26) names the reason: a single request would stop silently at the API's page-size clamp and the organizer report would aggregate a truncated answer set. Each page builds {Endpoint}/paged?filters[EventId].operator=equals&filters[EventId].value={eventId}&pageNumber={pageNumber}&pageSize={PagedReadAll.PageSize}&sortColumn=Id&sortDirection=asc&includeChildren=false with string.Create(CultureInfo.InvariantCulture, ...) (:31-32, culture-invariant so the id renders stably; PageSize is 500 at PagedReadAll.cs:22, and the stable Id ascending sort keeps consecutive pages from skipping or repeating a row); runs the GET inside HttpResultExecutor.ExecuteAsync (:28) with the request itself wrapped in RetryPolicy.ExecuteAsync (:35-37); and reads the response through ProblemDetailsResultReader.ReadAsync<PagedCollectionResult<EventQuestionAnswerDTO>> (:38-39). The accumulated list is then Mapped to IReadOnlyList<EventQuestionAnswerDTO> (:44); the first failed page short-circuits the loop and its failure comes back unchanged (PagedReadAll.cs:69-73). The nesting order is the point: the reader turns any response into a Result, the executor turns the absence of a response into one, the retry policy sits inside both, and the paging loop sits outside all three.
    • DeleteAnswerAsync(eventId, answerId, ct) (:47-64): builds {Endpoint}/{answerId}?eventId={eventId} (:52, the event id is a required query argument, mirroring the parent-scoped delete shape of IRoomUIService), issues the DELETE through the same executor plus retry pair (:54-60), and reads the valueless response through ProblemDetailsResultReader.ReadAsync (:61). It returns a bare Result: success is "the server accepted the delete", and a domain refusal comes back as a typed failure, not an exception.
  • Why it's built this way: inheriting the authenticated base means token attachment and the Polly retry live in one shared place, and the service owns only the URL shapes and the organizer-sees-all read. Delegating the page loop to the shared PagedReadAll keeps the organizer feedback grid simple (it still receives one complete list) without inheriting the silent truncation a single oversized request would suffer: per its remarks, the API clamps any requested page size to its own maximum (PagedReadAll.cs:12-18).
  • Where it's used: registered explicitly as IOrganizerEventFeedbackUIService under a comment naming BR-53 moderation (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:42-43) and injected into OrganizerEventFeedback (Pages/Feedback/OrganizerEventFeedback.razor.cs:20). Its structural twin OrganizerSessionFeedbackService shares the same file (OrganizerFeedbackService.cs:70) and differs only in resource root, filter key and delete scope. It is covered by OrganizerEventFeedbackServiceTests in the Conference UI test project.
  • Caveats / not-in-source: the read is bounded by PagedReadAll's runaway guard of 40 pages of 500 rows, 20,000 answers (PagedReadAll.cs:22-28); past that the loop stops. An event with more answers than that would still be truncated, and nothing in this class reports it. The server-side maximum page size is configured elsewhere (ApplicationSettings.MaxPageSize, per the remarks at PagedReadAll.cs:14), so whether a deployment lowers it below 500 is not determinable from this file.

OrganizerSessionFeedbackService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:70 · Level 4 · class (sealed)

  • What it is: the organizer-side read-and-moderate service for session feedback. It fetches every answer captured against one session and deletes an individual answer. It is the structural twin of OrganizerEventFeedbackService in the same file, keyed on SessionId and the sessionquestionanswers resource (OrganizerFeedbackService.cs:70-120), and it implements IOrganizerSessionFeedbackUIService.

  • Depends on: AuthenticatedServiceBase as its base (OrganizerFeedbackService.cs:72), for CreateAuthenticatedClientAsync() and the shared static Polly RetryPolicy; PagedReadAll for the read-every-page loop; HttpResultExecutor and ProblemDetailsResultReader for the two halves of the Result conversion; PagedCollectionResult<T> as the paged envelope; SessionQuestionAnswerDTO as the row shape; ITokenStorageService through the base; the SessionIdentifierType / SessionQuestionAnswerIdentifierType aliases; BCL IHttpClientFactory and CultureInfo.InvariantCulture.

  • Concept introduced, the two-half Result conversion in a hand-rolled HTTP service. This class does not extend the CRUD base, so it must do for itself what EntityServiceBase<TEntityDTO, TIdentifierType> does for its leaves: turn every possible outcome into a Result instead of an exception. Two collaborators split that job, and both are required for the signature to be honest. ProblemDetailsResultReader converts a response: it reads the API's ProblemDetails body and preserves the server's own ErrorType, so a refusal from the moderation policy arrives as a typed failure the page can render. HttpResultExecutor converts the absence of one: a refused connection, a DNS failure, a dropped socket, a client-side timeout, each mapped to a transport or timeout failure (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:38,41,127-136). Caller cancellation is deliberately excluded and rethrows (HttpResultExecutor.cs:69-72), because a page owns its own cancellation (a disposed component, a superseded fetch) and must not have it reported back as an error to render. [Rubric §29, Resilience, Reliability & Business Continuity] (assesses whether error handling is a factored concern rather than per-call-site improvisation; here it is two reusable helpers wrapped around each call). [Rubric §24, Forms/Validation/UX Safety] (assesses whether the user is shown a meaningful, safe message; the reader's typed error is what the feedback page renders instead of a raw exception).

  • Sibling family: the file holds two services with the same shape at different resource roots, which is why they share a file.

    Type File:Line Notes (what differs)
    OrganizerEventFeedbackService MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:15 eventquestionanswers root (:19); filters on EventId (:32); delete scoped ?eventId= (:52)
    OrganizerSessionFeedbackService MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:70 sessionquestionanswers root (:74); filters on SessionId (:87); delete scoped ?sessionId= (:107)
  • Walkthrough

    • private const string Endpoint = "sessionquestionanswers" (OrganizerFeedbackService.cs:74): the resource root, held as a constant rather than passed to a base constructor, because there is no CRUD base to pass it to.
    • GetAllAnswersAsync(sessionId, ct) (OrganizerFeedbackService.cs:76-100) hands a per-page delegate to PagedReadAll.ReadAllAsync (:82), which keeps requesting pages until one is empty or the server's reported total is reached (the comment at :80-81 names the truncation it prevents). Each page builds {Endpoint}/paged?filters[SessionId].operator=equals&filters[SessionId].value={sessionId}&pageNumber={pageNumber}&pageSize={PagedReadAll.PageSize}&sortColumn=Id&sortDirection=asc&includeChildren=false with string.Create(CultureInfo.InvariantCulture, ...), so the id never formats under the user's locale (:86-87). It runs the call inside HttpResultExecutor.ExecuteAsync (:83), creates the bearer-carrying client (:89), issues the GET through the inherited Polly RetryPolicy (:90-92), and reads the response into a Result<PagedCollectionResult<SessionQuestionAnswerDTO>> (:93-94). The loop accumulates each page's items, and the method maps the accumulated list to the IReadOnlyList<SessionQuestionAnswerDTO> the page wants (:99). The filter goes to the server, not to memory: the organizer's grid never pulls the whole answer table.
    • DeleteAnswerAsync(sessionId, answerId, ct) (OrganizerFeedbackService.cs:102-119) builds {Endpoint}/{answerId}?sessionId={sessionId} (:107) and DELETEs it through the same executor, client and retry policy (:109-115), returning the valueless Result the reader produces (:116). The parent sessionId is not decoration: the API removes a child row by loading the owning aggregate, so a delete sent without it addresses nothing.
  • Why it's built this way: two small parallel classes are cheaper to read than one generic service parameterized over "the parent key", and each one's URL shape stays literal and greppable. The class extends the authenticated base rather than the CRUD base because moderation is a two-verb surface, not an entity surface: there is no add, no update, and no lookup projection.

  • Where it's used: registered explicitly (it is not an IEntityService<,> implementation, so the assembly scan does not see it) as IOrganizerSessionFeedbackUIService at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:44, under the comment naming BR-53 moderation (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:42), and injected into OrganizerSessionFeedback (Pages/Feedback/OrganizerSessionFeedback.razor.cs:20).

  • Caveats / not-in-source: the read is no longer a single capped call, but it is still bounded: PagedReadAll stops after 40 pages of 500 rows (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:22-28), so a session with more than 20,000 captured answers would be truncated in the organizer grid, and nothing in this class detects that.

SpeakerUserSearch

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerUserSearch.cs:12 · Level 4 · class (internal static)

  • What it is: the candidate lookup behind the speaker-to-user link picker on SpeakerDetail. Given typed text, it returns up to ten distinct users whose email, first name or last name matches.
  • Depends on: IUserUIService and UserListDTO from the Identity module (:1-2), and Result<T> (:3,59).
  • Concept introduced, fanning out because the server ANDs. The remark on FindAsync (:20-25) states the constraint plainly: the paged users endpoint combines its filter parameters with AND, so one call carrying email, first name and last name would return the empty intersection for any real search term. The method therefore issues three independent single-filter calls, awaits them together, and unions the answers. [Rubric §9, API & Contract Design] (assesses whether a client can express its intent in the contract it is given): this is a client-side workaround for a server contract with no OR, and writing it down beside the code is what keeps the next reader from "simplifying" it back into one broken call. The failure policy is the second idea. ItemsOf (:59-60) turns a failed Result into an empty list, so a lookup that fails contributes nothing and the picker offers whatever the other two returned; a cancellation returns an empty list outright (:54-57). No snackbar is raised, because an error surfaced inside an autocomplete popover while a user is still typing is noise, not information. [Rubric §29, Resilience & Business Continuity] (assesses partial-failure behavior) and [Rubric §24, Forms, Validation & UX Safety]. Also worth noting is what this file's existence buys: the class doc (:8-11) says it is kept beside the page rather than inside the code-behind so the page holds only its own edit and link state. It has no component base, no injected service of its own, and takes its collaborator as a parameter, which makes it directly unit-testable without a renderer. [Rubric §14, Testability].
  • Walkthrough
    • MaxSuggestions = 10 (:15), applied twice over: as the pageSize of each of the three calls and as the final Take (:52), so neither one lookup nor the union can flood the popover.
    • FindAsync (:30-61): returns empty for blank input (:35-38); starts the three tasks without awaiting each in turn (:42-44) and joins them with Task.WhenAll (:46), so the three round trips overlap; concatenates, then DistinctBy(u => u.UserId) (:51) because a person matching on two fields must appear once.
    • ItemsOf (:59-60) is a static local function, which is the pattern the analyzer baseline pushes toward: no closure over the enclosing method's state.
  • Why it's built this way: linking a speaker record to a login is a rare organizer action against a large user set, so the search has to be forgiving about which field the organizer remembers while staying one interaction, not three.
  • Where it's used: called only by SpeakerDetail's SearchUsersAsync (SpeakerDetail.razor.cs:289-290), which is bound to the MudAutocomplete's SearchFunc (SpeakerDetail.razor:146-148).

IActivityUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Activities/IActivityUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the activities REST resource (the conference's social and networking programme). It is an empty marker interface, public interface IActivityUIService : IEntityService<ActivityDTO, ActivityIdentifierType> (IActivityUIService.cs:9-11), that adds no members of its own.
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> (the shared CRUD contract, imported from MMCA.Common.UI.Common.Interfaces at IActivityUIService.cs:2) and ActivityDTO (the transported shape, from MMCA.ADC.Conference.Shared.Activities at IActivityUIService.cs:1). ActivityIdentifierType is the module id alias.
  • Concept introduced, the per-entity marker UI-service interface. [Rubric §18, UI Architecture] assesses whether the front end talks to a typed service abstraction rather than a raw HttpClient; here every Blazor page injects an interface, never the concrete HTTP class. [Rubric §1, SOLID] assesses interface segregation at the injection point: the marker gives each aggregate its own name to depend on even though the shape is entirely inherited. The generic CRUD surface all comes from IEntityService<TEntityDTO, TIdentifierType>, whose seven members (GetAllAsync, GetPagedAsync, GetAllForLookupAsync, GetByIdAsync, AddAsync, UpdateAsync, DeleteAsync) every one return a Result: the same railway value the server produced, read back from its Problem Details response with the original ErrorType intact, so a page branches on an outcome instead of catching an exception (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:10-16,25-68). There is a second, load-bearing reason for the body-less specialization: registration is done by a Scrutor assembly scan, not by hand. AddUIModule<ConferenceUIModule>() scans the Conference UI assembly for every IEntityService<,> implementation and registers it with a scoped lifetime (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:29-31), so the named marker is exactly what a page gets to inject. Every plain-CRUD sibling in this group repeats this shape.
  • Walkthrough: no members. The whole contract is "be an IEntityService bound to ActivityDTO plus ActivityIdentifierType, under a name pages can inject". The doc comment (IActivityUIService.cs:6-8) states plainly that it "uses generic CRUD".
  • Why it's built this way: a named per-entity interface (rather than injecting the open generic directly) keeps the scan's registration unambiguous, and it lets one entity later grow an extra method without disturbing the others, which is exactly what IEventUIService, IRoomUIService and ISpeakerUIService did.
  • Where it's used: implemented by ActivityService; injected into the organizer activity list, detail and create pages (Pages/Activity/ActivityList.razor.cs:24, Pages/Activity/ActivityDetail.razor.cs:23, Pages/Activity/ActivityCreate.razor.cs:20) and into the anonymous PublicActivityList (Pages/Public/PublicActivityList.razor.cs:27). Note that the same contract serves both audiences: the client does not scope the data, the server does.

SpeakerCreateModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerCreateModel.cs:10 · Level 5 · class (sealed)

  • What it is: the organizer create page's binding target, adding only the mapping onto the SpeakerDTO it posts.
  • Depends on: SpeakerFormModel (:10) and SpeakerDTO (:1).
  • Concept introduced: none new beyond SpeakerFormModel, but one detail is worth contrasting. ToNew mints the identity client-side with Id = Guid.NewGuid() (:20), where ActivityCreateModel sends Id = default and lets the server assign an int. That is the identifier-alias strategy of ADR-048 showing through: a GUID-keyed aggregate can be named before it is stored, an int-keyed one cannot. [Rubric §8, Data Architecture].
  • Walkthrough: ToNew() (:17-32) is the only member. It composes FullName from the two entered parts (:23), which keeps the denormalized display name in step with the fields the organizer actually typed, and copies the remaining nine optional fields straight across.
  • Why it's built this way: the create page collects exactly the shared field set and nothing else, so the subclass is a mapping and not a second declaration of the form.
  • Where it's used: SpeakerCreate holds one instance (SpeakerCreate.razor.cs:50) and posts ToNew() (SpeakerCreate.razor.cs:81).

SpeakerEditModel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerEditModel.cs:11 · Level 5 · class (sealed)

  • What it is: the edit buffer for the inline speaker editor on the organizer detail page: the shared fields plus load-from-record and build-the-update.
  • Depends on: SpeakerFormModel (:11) and SpeakerDTO (:1).
  • Concept introduced: the typed edit buffer, as ActivityEditModel describes it. The speaker variant builds its update as a with expression over the loaded record, so it preserves every field it does not edit rather than a hand-picked list.
  • Walkthrough
    • LoadFrom(SpeakerDTO) (:18-32): null-guards, then copies the ten editable values off the displayed speaker. The doc frames the value plainly (:13-16): opening the editor becomes one call rather than ten assignments on the page.
    • ToUpdated(SpeakerDTO) (:41-58): returns speaker with { ... } (:45), overriding only the ten edited values plus the recomposed FullName (:49). Everything else on the loaded record round-trips untouched, and the doc names what that covers (:34-38): the identity, the RowVersion concurrency token (ADR-035), the linked user, and fields this form does not show, such as the Top Speaker flag. Linking and unlinking is its own action on the detail page (see SpeakerDetail), so LinkedUserId cannot be bound here and cannot be lost. [Rubric §24, Forms, Validation & UX Safety].
  • Why it's built this way: an edit form that silently drops a field it does not display is the failure this shape removes. A fresh DTO that copies named fields resets any field added later and not listed; the with expression inverts the default, so a new field on SpeakerDTO is preserved unless this model chooses to edit it.
  • Where it's used: SpeakerDetail holds one instance (SpeakerDetail.razor.cs:65), seeds it in StartEditing (SpeakerDetail.razor.cs:207) and posts ToUpdated from SaveChangesAsync (SpeakerDetail.razor.cs:230).

ActivityService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Activities/ActivityService.cs:10 · Level 5 · class (sealed)

  • What it is: the concrete HTTP service for the activities resource, a body-less class that inherits every CRUD method from the shared base and supplies only the endpoint name (ActivityService.cs:10-14). It implements IActivityUIService.
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> as its base (from MMCA.Common.UI.Services, ActivityService.cs:2); ITokenStorageService (from MMCA.Common.UI.Services.Auth, ActivityService.cs:3); ActivityDTO; BCL IHttpClientFactory.
  • Concept introduced, the four-line concrete UI service (Template Method with a supplied endpoint). The primary constructor forwards IHttpClientFactory and ITokenStorageService plus the literal resource name "activities" to EntityServiceBase<TEntityDTO, TIdentifierType> closed over ActivityDTO and ActivityIdentifierType (ActivityService.cs:10-12); the class body is empty (:13-14). Everything a page calls (GetAllAsync, GetPagedAsync, GetByIdAsync, GetAllForLookupAsync, AddAsync, UpdateAsync, DeleteAsync) lives on the base (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:68,86,132,143,162,188,215), along with the auth, the Polly retry, the read cache, the conditional-write header and the Result conversion. Two base behaviors are worth knowing here, because the leaf inherits them for free: a create attaches a fresh Idempotency-Key held constant across retries (EntityServiceBase.cs:171-176), and an update sends the DTO's concurrency token as If-Match (EntityServiceBase.cs:196, ADR-035). [Rubric §2, Design Patterns] (assesses whether a shared algorithm is factored once and specialized by leaves; the base owns the CRUD algorithm and the leaf supplies the resource name, a textbook Template Method) and [Rubric §15, Best Practices & Code Quality] (assesses the cost of one more like-for-like feature; a new plain-CRUD resource costs one tiny class).
  • Walkthrough: no members. The whole class is the base call carrying the resource root "activities" and the declaration that it satisfies IActivityUIService (ActivityService.cs:11-12). The doc comment (:7-9) says only that it provides standard CRUD.
  • Why it's built this way: the endpoint name is the only thing that varies for a plain CRUD aggregate, so the concrete class carries exactly that and nothing else. sealed (ActivityService.cs:10) closes the leaf: specialization belongs on the interface or in the base, not in a subclass of a subclass.
  • Where it's used: never named in DI by hand. Because it is an IEntityService<,> implementation in the Conference UI assembly, the Scrutor scan inside AddUIModule<ConferenceUIModule>() registers it AsImplementedInterfaces() with a scoped lifetime (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31 calling MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:320-326), which is what makes IActivityUIService resolvable in ActivityList (Pages/Activity/ActivityList.razor.cs:24), ActivityDetail (Pages/Activity/ActivityDetail.razor.cs:23), ActivityCreate (Pages/Activity/ActivityCreate.razor.cs:20) and PublicActivityList (Pages/Public/PublicActivityList.razor.cs:27).

SpeakerQr

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerQr.razor.cs:24 · Level 6 · class (Blazor code-behind)

  • What it is: the speaker-facing side of their own QR code. It renders one full-screen code that points at the speaker's PUBLIC profile page, for holding up at the podium or parking on a booth screen (class doc, SpeakerQr.razor.cs:10-13).
  • Depends on: IPublicLinkBuilder (:26), ISpeakerUIService (:27), ConferenceRoutePaths's PublicSpeakerDetails(id) route factory (:62, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:52). Externals: the cascading Task<AuthenticationState> (:29-30), MudBlazor's BreadcrumbItem and Icons (:32,40), and the IStringLocalizer<SpeakerQr> injected by the template (SpeakerQr.razor:4). The code image itself is the shared QrCodeImage component from MMCA.Common.UI, configured in markup (SpeakerQr.razor:26-30).
  • Concept introduced, the payload that needs no fetch and the absolute-URL payload rule. Two ideas keep this one of the smallest complete pages in the group.
    1. The payload needs no backend call; only the label does. The speaker's identity comes from the speaker_id JWT claim (:56) and the payload string is composed locally (:62), so the code itself depends on nothing but the cascading auth state. The class doc states the consequence (:15-17): the code renders on the SSR prerender pass as readily as on the interactive pass, which avoids the loading-state and double-fetch problem that pages such as SpeakerDashboard have to solve. The single read is the name beside the code: after the payload is set, SpeakerService.GetByIdAsync(speakerId, false, CancellationToken.None) (:66) fetches the linked speaker, and a non-blank FullName replaces the account name (:67-70). The code comment (:64-65) gives the reason: the code opens the speaker profile, so the name beside it is that profile's, and the two differ whenever an organizer linked an account to a speaker record. The class doc adds why the read can succeed on a non-public profile: the speaker service's self-read carve-out (:17-19). A failed read is not an error path; the account name set earlier (:54) simply stays. [Rubric §23, Front-End Performance & Rendering] (assesses network work per view): the payload is still the fetch that does not exist, and only the human-readable label costs a call.
    2. The payload must be an absolute public URL. LinkBuilder.BuildAbsolute(...) (:62) is not decoration. A relative path, or the origin the MAUI head's WebView serves from, would encode into a code that resolves for nobody outside that device (class doc, :20-21). Building the link through the shared builder is what keeps one page correct on the web head and on the native head at once. [Rubric §26, Front-End Security] (assesses that client-composed links point where they claim to) and [Rubric §22, Responsive & Cross-Browser] (the same component ships to two very different hosts). The name rendered beside the code (:54,70, markup at SpeakerQr.razor:32) is deliberate too: the in-code comment says a person scanning should be able to see whose profile they are about to open before they open it (:51-53). [Rubric §21, Accessibility]: the image also carries a localized AltText (SpeakerQr.razor:27), because a QR code is opaque to a screen reader by construction.
  • Walkthrough
    • State (:32-34): three fields only, the breadcrumb list, the nullable _payload, and _displayName.
    • OnInitializedAsync (:36-71): builds the two-crumb trail (:38-42), returns early when no auth state is cascaded (:44-47), seeds the display name from state.User.Identity?.Name (:54), then looks for the speaker_id claim and returns unless it parses as a Guid (:56-60). Only on that path is _payload assigned (:62), followed by the profile read that upgrades _displayName to the speaker's full name (:66-70).
    • The null-payload branch is the page's entire error handling: the markup renders an informational alert instead of a card (SpeakerQr.razor:11-18), with an in-markup comment noting that the nav item is claim-gated but a bookmarked or typed URL still lands here without the claim. [Rubric §24, Forms, Validation & UX Safety]: an unreachable state gets a sentence, not an empty screen.
    • The code's own rendering parameters are argued in markup (SpeakerQr.razor:24-25): PixelsPerModule="14" so the code stays readable from a few steps away, and ErrorCorrection="QrErrorCorrectionLevel.Medium" so glare on a phone camera does not kill the scan.
  • Why it's built this way: the public speaker page already carries the same code for whoever is reading it (class doc, :10-11); this page exists so the speaker can present that code rather than have to be found first. Deriving everything from the claim means the page cannot show one speaker's code to another.
  • Where it's used: the /speaker/qr route, gated by a bare [Authorize] (SpeakerQr.razor:1-2), so any signed-in user reaches the page and only the claim decides whether a code appears. It links to the same public route PublicSpeakerDetail serves.

SpeakerCreate

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerCreate.razor.cs:16 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer's speaker-creation form. It binds one SpeakerCreateModel, validates it, posts one SpeakerDTO, and redirects to the detail page for the record it just made.
  • Depends on: ISpeakerUIService (:18), IToastService (:20), SpeakerCreateModel (:50), ModelValidation and DataAnnotationsModelValidator (:44), ConferenceRoutePaths (:39,99,111), ErrorMessages (:80), and the LifetimeToken extension from ComponentLifetimeExtensions (:27). Externals: NavigationManager (:19), MudBlazor's MudForm, BreadcrumbItem and Icons, and the IStringLocalizer<SpeakerCreate> from the template (SpeakerCreate.razor:6). The markup mounts the shared UnsavedChangesGuard, SpeakerFormFields and ErrorSummary components (SpeakerCreate.razor:10,24,35).
  • Concept introduced, the model-validated create page. The create shape itself (validate, post, snackbar, redirect) is taught by the smaller create pages in this group. What this page adds is the bridge between DataAnnotations and MudBlazor.
    1. One validation delegate for the whole form. OnInitialized builds _validate = ModelValidation.For(_model, new DataAnnotationsModelValidator(L)) (:44, field at :64) and the shared field block hands that single Func<object, string, IEnumerable<string>> to every control (SpeakerCreate.razor:24-25). MudBlazor calls it with the model instance and the member path, and the model's own attributes decide the outcome, so no rule is written twice (in-code comment, :62-63). The localizer passed into the validator is what turns the resource keys on SpeakerFormModel into sentences, per ADR-027. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §27, Internationalization].
    2. Validate before you mutate. CreateSpeakerAsync awaits _form.ValidateAsync() and returns with a warning toast on !_form.IsValid (:77-82) before any service call. The server validates again; this pass keeps a round trip off the wire and puts the message beside the field.
    3. An inline error result alongside the toast. _saveResult (:54-58) records the last create attempt's Result, reset to null before each attempt (:75) and set from the service call (:88). On failure CreateSpeakerAsync calls createResult.NotifyOnFailure(Toast, L) (:93) instead of a bare Toast.Error, so the message reaches both the snackbar and the page's ErrorSummary component and survives a snackbar timing out while the organizer is still reading the form.
    4. A dirty flag that cannot block its own redirect. Every field change calls MarkDirty() (:66, wired at SpeakerCreate.razor:25), the guard reads it through an accessor (SpeakerCreate.razor:10), and the success path clears _isDirty before navigating, with the reason on the line (:97). [Rubric §25, Navigation & Information Architecture].
    5. Cancel on disposal, read through a safe token. A page-scoped CancellationTokenSource (:22) is cancelled and then disposed in the full Dispose(bool) pattern (:115-129). The post does not read _cts.Token directly: it reads the page's LifetimeToken property (:27, passed at :87), which forwards to the LifetimeToken() extension (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26). Reading Token off a disposed source throws ObjectDisposedException, so the extension returns an already-cancelled token once the source is cancelled, disposed or null (:28-41), and a handler that resumes after the user navigated away stops through the ordinary OperationCanceledException path instead of crashing the circuit. That exception is caught and ignored as the expected teardown or InteractiveAuto transition outcome (:101-104, see ADR-056).
  • Walkthrough
    • OnInitialized (:33-45): the three-crumb trail (Home, Speakers, Create) with the last crumb disabled, then the validation delegate.
    • CreateSpeakerAsync (:68-109): null-guard the form (:70-73), clear _saveResult (:75), validate, set IsSaving (:84), post _model.ToNew() with the LifetimeToken (:87) and record the result to _saveResult (:88), report a failed create through NotifyOnFailure (:93), then clear the dirty flag, snackbar success, and route to ConferenceRoutePaths.SpeakerDetails(created.Id) (:99) using the id read back from the response. The finally always clears IsSaving (:105-108), so a failed save leaves an enabled button rather than a stuck spinner.
    • NavigateToList (:111) is the cancel action, and it goes through the route constants rather than a literal.
  • Why it's built this way: pushing the rules onto the shared model leaves the page holding only the things that are genuinely page-level, the breadcrumb trail, the save orchestration, and the navigation.
  • Where it's used: the /speakers/create route with [Authorize(Roles = "Organizer")] (SpeakerCreate.razor:1-2), reached from SpeakerList's create button and redirecting to SpeakerDetail.

SpeakerCategoryItemsPanel

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerCategoryItemsPanel.razor.cs:18 · Level 6 · class (Blazor code-behind)

  • What it is: the "Additional Info" panel of the organizer speaker detail page. It renders a speaker's category items grouped by category as removable chips, and offers the not-yet-assigned items in an add picker.
  • Depends on: ISpeakerCategoryItemUIService (:20), IToastService (:21), SpeakerDTO and SpeakerCategoryItemDTO (:3,24,49), CategoryItemInfo (:27,59), the LifetimeToken() extension from ComponentLifetimeExtensions (:40), and the CategoryItemIdentifierType, ConferenceCategoryIdentifierType and SpeakerCategoryItemIdentifierType aliases.
  • Concept introduced, the container/presentational split with a partial ownership boundary. The panel is presentational about data and self-sufficient about actions, and the class doc names the division exactly (:9-15).
    • The page owns and reloads the speaker, and owns both lookups; all three arrive as [Parameter]s (:24,27,30), and the panel never fetches them.
    • The panel calls the child-entity service itself (:77, :98) and then raises the Changed callback (:33,86,106), whose handler on the page is LoadAsync (SpeakerDetail.razor:172). So the mutation is local, but the refresh is the page's, and the page stays the single source of truth for what is on screen. [Rubric §18, UI Architecture & Component Design] (assesses decomposition and where responsibility sits) and [Rubric §19, State Management & Data Flow].
    • It owns its own CancellationTokenSource (:35) and its own IDisposable implementation (:114-136), because it makes its own calls. A child component that awaits must cancel on its own disposal; inheriting the parent's token would tie its lifetime to the wrong component. Both service calls read the token through a private LifetimeToken property (:40) rather than _cts.Token: the source is cancelled and then disposed, and reading Token off a disposed source throws, whereas the LifetimeToken() extension (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26) hands back an already-cancelled token, so a late handler stops through its OperationCanceledException path. The lookups are passed as IReadOnlyDictionary (:27,30) rather than lists, so both label helpers are O(1) and both fall back to the raw id when a lookup is missing (:43-47), which is the same never-render-blank rule the other detail pages follow. [Rubric §24, Forms, Validation & UX Safety].
  • Walkthrough
    • GetCategoryTitle / GetCategoryItemName (:43-47): dictionary lookups with an invariant-culture id fallback.
    • GetCategoryItemsGroupedByCategory (:49-57): returns empty until the lookup arrives, then filters the speaker's items to those the lookup knows and groups them by their category id, which is what produces the per-category chip rows (SpeakerCategoryItemsPanel.razor:8-20).
    • GetAvailableCategoryItems (:59-66): builds a HashSet of the already-assigned item ids and offers the complement, so the picker cannot propose a duplicate.
    • AddCategoryItemAsync (:68-92): no-ops without a selection (:70-73), calls AddAsync(Speaker.Id, id, LifetimeToken) (:77), reports failure through a localized error toast and returns (:78-82), otherwise clears the selection, toasts success, and awaits Changed (:84-86).
    • RemoveCategoryItemAsync (:94-112): the same shape over DeleteAsync with the same LifetimeToken (:98), bound to each chip's OnClose (SpeakerCategoryItemsPanel.razor:15).
    • Both handlers swallow OperationCanceledException as expected disposal (:88-91, :108-111).
  • Why it's built this way: the chip panel was extracted from the detail page so that page holds only its own edit and link state. Keeping the add/remove calls inside the panel while leaving the reload to the parent preserves the pre-split behavior exactly (class doc, :13-15) without giving the child a second copy of the speaker.
  • Where it's used: rendered once by SpeakerDetail (SpeakerDetail.razor:169-172), fed Speaker, _lookups?.CategoryItems and _lookups?.CategoryTitles, with Changed bound straight to the page's LoadAsync. It is not routable and carries no @page directive.

SpeakerDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDetail.razor.cs:24 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer's speaker console. It loads one speaker with children by route id, inline-edits the profile, shows the answered questions and the speaker's sessions, hosts the category-item panel, links or unlinks a login, and deletes with confirmation.
  • Depends on: ISpeakerUIService, ISessionUIService, ISpeakerDetailLookupService returning SpeakerDetailLookups, and IUserUIService (:28-31); IToastService (:33); SpeakerEditModel (:65), SpeakerUserSearch (:289-290), ModelValidation and DataAnnotationsModelValidator (:55), ErrorMessages (:111,223), and ConferenceRoutePaths (:50). It extends DetailPageBase (SpeakerDetail.razor:8), which supplies PageToken, IsEditing, IsDirty, MarkDirty, BeginEdit, EndEdit, the shared LoadGuard and the disposal pattern. Externals: MudBlazor's MudForm, MudAutocomplete and BreadcrumbItem, plus the IStringLocalizer<SpeakerDetail> from the template.
  • Concept introduced, the load-generation guard, now carried by the base page. Every detail page in this group guards against a re-render refetching (OnParametersSetAsync compares Id against _loadedId, :77-86). This page used to own a second, page-local generation counter; it now delegates that guard to DetailPageBase's LoadGuard property, a LatestLoadGuard instance (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43). LoadAsync calls var (token, generation) = LoadGuard.Begin() at its top (:91), which cancels whatever load is already in flight and hands back the token to pass into every fetch, and each awaited step re-checks LoadGuard.IsCurrent(generation) before touching page state (:103, :131, :183). The reasoning is unchanged: _loadedId is stamped synchronously before the await, so two rapid route changes would let a later-completing fetch of the older speaker paint over the newer one, and the generation, not the route id, is authoritative. The finally applies the same rule to the spinner (:148-156): only the current generation may clear IsLoading, because an unconditional clear would switch off a spinner that a newer load just turned on. A second, newer guard concerns stale view state across route ids: LoadAsync empties _speakerSessions right after Begin() (:93-94), because the list belongs to the previous speaker until this load replaces it, and a failed speaker fetch sets Speaker = null before reporting (:117-120), because the edit and delete actions are bound to Speaker and must not stay live against the previous record under the new id. [Rubric §19, State Management & Data Flow]. [Rubric §19, State Management & Data Flow] (assesses reconciliation of concurrent updates to view state) and [Rubric §12, Performance & Scalability] (the cheap fix is to serialize the loads, which would be slower and still wrong). Moving the guard onto the base means SessionDetail and the module's public detail pages share the identical mechanism instead of each carrying its own counter. Two further mechanisms sit alongside it.
    1. Server-side filtering instead of client-side filtering. LoadSpeakerSessionsAsync (:167-195) sends a SpeakerId equals filter (:170-173) with includeChildren: false (:180) and sortColumn: "StartsAt" (:178), capped at MaxSpeakerSessions = 100 (:38), threading the same guard token through as cancellationToken (:181). The remark records what it replaced (:159-166): the page used to pull the entire session catalog with all child collections and filter it in memory on SessionSpeakers, purely so it could match. [Rubric §12, Performance & Scalability] (assesses whether work happens where the data lives).
    2. One composite lookup, cached for the page. The three display lookups arrive in a single LookupService.GetAllAsync(token) call, and only when _lookups is null (:128-140), so re-entering LoadAsync after a link or unlink does not refetch them. A failed lookup is reported and stops the load (:136-139). [Rubric §23, Front-End Performance & Rendering]. Result handling is uniform: IsNotFound() gets a dedicated not-found message and a null record (:108-113), and every other failure goes through NotifyOnFailure(Toast, L) (:120,233,240,275), which is the framework's one-line "report whatever went wrong, localized" path.
  • Walkthrough
    • OnInitialized (:39-51): breadcrumbs plus the shared validation delegate over _model (:50).
    • OnParametersSetAsync (:77-86) then LoadAsync (:88-157): begin the guard (:91), clear the previous speaker's sessions (:94), parse the route id defensively (:101; the comment at :99-100 notes an unconstrained string route parameter degrades to Guid.Empty and resolves to not-found rather than throwing out of the render), fetch with children (:102), null Speaker on a failed load (:119), fetch the lookups once (:128-140), then the sessions (:142).
    • Edit cycle: StartEditing (:200-209) seeds SpeakerEditModel from the loaded record and calls the base's BeginEdit(); CancelEditing (:211) is one call to EndEdit(), which clears the dirty flag the unsaved-changes guard reads (SpeakerDetail.razor:13). SaveChangesAsync (:213-256) validates the form, posts _model.ToUpdated(Speaker) (:230), then re-fetches the speaker (:237) so the page shows the server's version, including the new RowVersion for the next edit.
    • DeleteSpeakerAsync (:258-286): confirms through the shared DeleteConfirmation dialog seeded with the speaker's full name (:265), and treats anything other than exactly true as a cancel, so a dismissed dialog is not a delete.
    • Link and unlink (:288-340): SearchUsersAsync delegates to SpeakerUserSearch (:289-290); OnUserPickedAsync (:292-315) posts LinkUserAsync and reloads; UnlinkUserAsync (:317-340) is the mirror. Both reload through LoadAsync rather than patching local state, which is exactly why the guard matters: a link followed quickly by an unlink starts two loads.
    • GetQuestionText (:197-198) resolves an answered question's text from the cached lookups with an id fallback.
  • Why it's built this way: this page is the widest read surface in the organizer area, so it composes four services rather than growing one. Delegating the chip panel to SpeakerCategoryItemsPanel, the candidate search to SpeakerUserSearch, the field rules to SpeakerFormModel, and the edit lifecycle and load guard to DetailPageBase is what keeps the code-behind at load, save, delete and link. [Rubric §15, Best Practices & Code Quality].
  • Where it's used: the /speakers/{Id} route with a bare [Authorize] (SpeakerDetail.razor:1-2), reached from SpeakerList rows and from SpeakerCreate's success redirect. Session rows link straight to SessionDetail through a MudLink built in the markup from ConferenceRoutePaths.SessionDetails(session.Id); there is no code-behind navigation method for it. It edits the aggregate the Speaker entity models.

SpeakerDashboard

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDashboard.razor.cs:23 · Level 9 · class (Blazor code-behind)

  • What it is: the signed-in speaker's own console. It shows their profile with an inline editor, the sessions they present at the current or next event, the bookmark count per session, and their per-session feedback on demand (class doc, :13-19).
  • Depends on: ISpeakerUIService (:24), ISpeakerDashboardUIService (:25), IEventLookupService (:26), AuthenticationStateProvider (:27), IToastService (:28), TimeProvider (:29), SpeakerDTO (:42), SessionDTO, SessionFeedbackDTO (:46-50), CurrentEventSelector with EventInfo (:169,177-182), the LifetimeToken() extension from ComponentLifetimeExtensions (:37), and the IStringLocalizer<SpeakerDashboard> from the template.
  • Concept introduced, the claim-scoped self-service page. Four mechanisms distinguish this from the organizer pages in this unit.
    1. Identity comes from the token, not the route. There is no [Parameter] id: the speaker is read from the speaker_id claim (:84-94), and a missing or unparseable claim sets _hasSpeakerId = false (:89) and renders one informational alert (SpeakerDashboard.razor:19-21). The server enforces the same scope; the claim just decides what this page asks for. [Rubric §11, Security] (assesses that a self-service view derives its subject from the authenticated principal, ADR-004) and [Rubric §26, Front-End Security].
    2. Skip the loads on the prerender pass. OnInitializedAsync returns immediately when !RendererInfo.IsInteractive (:76-79), with the comment recording exactly what that saves: the profile, the sessions and the per-session bookmark counts used to run twice per visit, once for SSR prerender and once for the interactive instance (:73-75). The prerender pass shows the loading skeleton instead. [Rubric §23, Front-End Performance & Rendering] and ADR-056.
    3. One batched count call. GetSessionBookmarkCountsAsync(_speakerId, sessionIds, ...) (:144) returns every count from one grouped query; the comment records that each count was previously its own cross-service hop, HTTP to Conference to gRPC to Engagement (:137-139). The result is best effort: a failure is ignored with no snackbar so the dashboard still renders (:142-143). [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience & Business Continuity].
    4. Every await reads a safe lifetime token. The page's _cts (:32) is cancelled and then disposed, and reading Token off a disposed source throws ObjectDisposedException. A private LifetimeToken property (:37) forwards to the LifetimeToken() extension (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26), which returns an already-cancelled token once the source is cancelled, disposed or null, so a load that resumes after the user navigated away stops through the OperationCanceledException path (:153-156) instead of throwing. Every service call passes it: the profile read (:97), sessions (:115), counts (:144), event lookup (:172), update (:226), reload (:238) and feedback (:277). [Rubric §19, State Management & Data Flow]. The read scope is worth stating explicitly, because the class doc does (:16-19): a speaker is not a privileged reader, so the server returns only publicly visible sessions (accepted-or-unset), which means a submission still under review does not appear here. The empty-state copy names that. [Rubric §11, Security]. Reads go through ISpeakerDashboardUIService rather than the shared sessions service on purpose: the comment at :112-114 records that the dashboard path bypasses the shared sessions output cache, so a just-made speaker assignment shows immediately instead of lagging behind a cached public list.
  • Walkthrough
    • State (:41-63): the claim flag and id, the full session list and the event-narrowed one, the current event name, and four collections keyed by session id, _bookmarkCounts, _sessionFeedback, _expandedSessions and _feedbackLoading, plus the profile edit fields.
    • OnInitializedAsync (:65-167): breadcrumbs, the interactivity gate, the claim read, the profile load (a not-found renders the empty dashboard without a toast, :98-107), the session load ordered by StartsAt (:122), the current-event narrowing (:124-135), and the batched counts. The outer catch (Exception) (:157-162) is explained in place: the authentication state provider is the one collaborator that still reports failure by throwing, while everything else returns a Result.
    • ResolveCurrentEventAsync (:169-183): non-critical by design (comment, :171), it returns null on a failed lookup and the page then shows all of the speaker's sessions rather than none. "Now" is the injected TimeProvider.GetUtcNow().UtcDateTime (:182), not the static clock.
    • Profile edit (:185-257): StartEditingProfile copies six fields into _edit* shadow fields (:192-197); SaveProfileAsync builds the update as Speaker with { ... } over the loaded record (:216-224), overriding only the six edited fields, so the identity, RowVersion and every field the speaker may not edit round-trip unchanged. The comment (:213-215) names the case this protects: the Top Speaker flag is preserved, so an organizer who is also the linked speaker does not reset it by saving a bio. It then posts (:226). A failed update no longer always shows the fixed message: the comment (:229-231) says a refusal the API stated (an invalid social URL, say) shows the API's own localized message, and only an unexpected fault (a 500, a transport failure, a timeout) falls back to Snackbar.ProfileUpdateFailed (:232-234), by testing HasErrorType(ErrorType.Unexpected) and otherwise LocalizedErrorMessage(L) (ADR-027 Decision 9 carve-out). It then re-fetches (:238) and leaves edit mode (:247). This is the same with shape SpeakerEditModel uses for the organizer editor.
    • ToggleFeedbackAsync (:259-297): the HashSet.Add return value doubles as the toggle (:261-265), an already-cached session returns immediately (:267-270), and the fetch sets a per-session loading flag with an explicit StateHasChanged() (:272-273) because the spinner has to appear before the await. A not-found is treated as "no feedback captured yet" and left to the panel's empty state rather than reported (:282-287). [Rubric §23, Front-End Performance & Rendering]: feedback is fetched per session on first expand, never for the whole list.
  • Why it's built this way: the dashboard is the one page a speaker sees on conference day, so it favors rendering something useful over rendering everything: a failed count load, a failed event lookup, or missing feedback each degrade to a smaller view rather than an error page.
  • Where it's used: the /speaker/dashboard route with a bare [Authorize] (SpeakerDashboard.razor:1-2); the claim, not the role, decides what it can show. It reads the same aggregate SpeakerDetail edits.
  • Caveats / not-in-source: the profile editor here does not use SpeakerFormModel. It binds six loose _edit* fields (:57-62) and its caps come from SpeakerDTO constants applied as MudBlazor MaxLength / Counter affordances in markup (SpeakerDashboard.razor:42-55), so it has no DataAnnotations pass and its MudForm (:63) is never validated in the code-behind. The organizer create and edit forms share their rules; this third editing surface does not.

SpeakerList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerList.razor.cs:19 · Level 10 · class (Blazor code-behind)

  • What it is: the organizer browse page for speakers: server-side paging with a full-name search and avatars, an event filter, a mobile card layout, and delete-with-confirmation.
  • Depends on: extends EventFilteredListPageBase<TDto> closed over SpeakerDTO (:18), and injects ISpeakerUIService (:23). It uses ListPageActions (:42,86), ErrorMessages (:92), ConferenceRoutePaths (:95-96), the MobileInfiniteScrollList<TItem> and DeleteConfirmation components (:31-32), and Result<T> (:73).
  • Concept introduced, the virtual filter key. Structurally this is ActivityList: the same five overrides over the same base, the same WaitForEventsAsync guard before both fetches (:56, :75), the same delegation of delete to ListPageActions. One thing genuinely differs, and it is the interesting part. ApplyEventFilter (:69) adds filters["EventId"] exactly as it does for activities, but a Speaker has no EventId column: a speaker relates to an event through the EventSpeaker and SessionSpeaker joins. So EventId here is a virtual filter key. The paged speakers endpoint intercepts it, removes it from the filter dictionary before the generic filter pipeline ever sees it, and resolves the scope through those joins instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:132-134,164-171). The client-side contract is therefore identical for both entities while the server-side resolution is not. [Rubric §9, API & Contract Design] (assesses whether a query contract can express a client's intent without leaking the storage shape) and [Rubric §8, Data Architecture]: the join stays server-side, where the indexes are, instead of becoming a two-step client fetch. The class doc records this in one sentence at the top of the page (:13-16), which matters: a reader who assumes EventId is a column would look for it on the DTO and find nothing.
  • Walkthrough
    • SavePageFilters / RestorePageFilters (:35-39): persist and restore the search term only.
    • ReloadActiveLayoutAsync (:41-42) and ReloadForEventFilterAsync (:44): one reload path for both layouts, via ListPageActions.
    • LoadServerData (:52-63) and ApplyFilters (:65-70): the search term as a contains filter on FullName (:68), the denormalized display name, then the event filter.
    • FetchMobilePage (:73-80): the same filters sorted "FullName", "asc" (:79), because a speaker roster reads alphabetically where a programme reads chronologically (compare ActivityList).
    • DeleteSpeakerAsync (:85-93): confirm, delete, toast, reload, all through the shared helper, seeded with the speaker's full name.
    • RetryLoadAsync (:30) backs the inline error state; NavigateToCreate / NavigateToDetails (:95-96) use the route constants.
  • Why it's built this way: the organizer wants "the speakers at this conference", not "the speakers whose row carries this id". Expressing that as an ordinary filter key keeps the page identical to every other event-filtered list, and the one place that knows it is not an ordinary column is the endpoint that can resolve it cheaply.
  • Where it's used: the /speakers route with [Authorize(Roles = "Organizer")] (SpeakerList.razor:1-2), linking on to SpeakerCreate and SpeakerDetail. Its public counterpart is PublicSpeakerList, which applies the same virtual key with a locked scope.

IPartnerUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Partners/IPartnerUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the partners REST resource (a conference's sponsoring and exhibiting organizations). It is an empty marker interface, public interface IPartnerUIService : IEntityService<PartnerDTO, PartnerIdentifierType> (IPartnerUIService.cs:9-11), that adds no members of its own.
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> (the shared CRUD contract) and PartnerDTO (the transported shape, from MMCA.ADC.Conference.Shared.Partners). PartnerIdentifierType is the module id alias.
  • Concept: the same per-entity marker UI-service interface as IActivityUIService; see that section for the rubric analysis of interface segregation and the Scrutor assembly-scan registration [Rubric §18, UI Architecture], [Rubric §1, SOLID].
  • Walkthrough: no members. The whole contract is "be an IEntityService bound to PartnerDTO plus PartnerIdentifierType, under a name pages can inject."
  • Why it's built this way: a named per-entity interface, rather than injecting the open generic directly, keeps the assembly scan's registration unambiguous and gives Partners its own name to depend on even though every member is inherited.
  • Where it's used: implemented by PartnerService; injected into PartnerCreate (Pages/Partners/PartnerCreate.razor.cs:20), PartnerDetail (Pages/Partners/PartnerDetail.razor.cs:23), and PartnerList (Pages/Partners/PartnerList.razor.cs:24). Cited by ADR-094 as an example of the generic client-side entity data-access pattern.

IQuestionUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Questions/IQuestionUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the questions resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to QuestionDTO (IQuestionUIService.cs:9-11).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> and QuestionDTO.
  • Concept: the same marker shape as IActivityUIService. [Rubric §18, UI Architecture].
  • Walkthrough: no members (doc comment IQuestionUIService.cs:6-8).
  • Where it's used: implemented by QuestionService; injected into the question list, detail and create pages (Pages/Question/QuestionList.razor.cs:16, Pages/Question/QuestionDetail.razor.cs:18, Pages/Question/QuestionCreate.razor.cs:13), into both organizer feedback pages, which need the question text to label the answers (Pages/Feedback/OrganizerEventFeedback.razor.cs:19, Pages/Feedback/OrganizerSessionFeedback.razor.cs:19), taken as an argument by the shared FeedbackQuestionLoader (Pages/Feedback/FeedbackQuestionLoader.cs:36), and composed as the third load inside SpeakerDetailLookupService (Services/SpeakerDetailLookupService.cs:14,32).

IRoomUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Rooms/IRoomUIService.cs:10 · Level 4 · interface

  • What it is: the UI-service contract for the rooms resource. It extends the generic CRUD surface with a single specialized delete that also carries the owning event id (IRoomUIService.cs:10-14).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> bound to RoomDTO (note that RoomDTO lives in the MMCA.ADC.Conference.Shared.Events namespace, IRoomUIService.cs:1, because a room belongs to an event), Result (IRoomUIService.cs:2), and the RoomIdentifierType / EventIdentifierType aliases.
  • Concept: [Rubric §9, API & Contract Design] assesses whether client contracts carry the parameters the server actually requires. A room is scoped to an event, so its delete needs the EventIdentifierType the WebAPI endpoint expects; the generic DeleteAsync(id) would omit it. The doc comment (IRoomUIService.cs:12) states the added overload "passes the required event ID to the API". This is the UI-side counterpart of the parent-scoped delete the join services and the organizer feedback services use.
  • Walkthrough: one added member, DeleteAsync(RoomIdentifierType roomId, EventIdentifierType eventId, CancellationToken) (IRoomUIService.cs:13), returning Task<Result>. It supplements, rather than replaces, the inherited single-argument delete: both overloads are visible on the interface, so an accidental call to the id-only one still compiles, and the server is what rejects it.
  • Where it's used: implemented by RoomService; injected into RoomList (Pages/Room/RoomList.razor.cs:17), RoomDetail (Pages/Room/RoomDetail.razor.cs:19), RoomCreate (Pages/Room/RoomCreate.razor.cs:13) and into the session pages that render the room a session is scheduled in (SessionCreate :21, SessionDetail :29, PublicSessionDetail :26), plus the shared SessionLookups helper (Pages/Session/SessionLookups.cs:33).

ISessionSelectionUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/Selection/ISessionSelectionUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the organizer's session-selection decision-support dashboard: read the dashboard, and start a scoring pass over one event's sessions (ISessionSelectionUIService.cs:9-24). It is a bespoke (non-CRUD) interface: it does not extend IEntityService<TEntityDTO, TIdentifierType>, because decision support is a pair of computed operations, not an entity surface.
  • Depends on: SessionSelectionDashboardDTO (the dashboard payload) and ScoreEventSessionsResultDTO (the scoring outcome), both from MMCA.ADC.Conference.Shared.Sessions.DecisionSupport (ISessionSelectionUIService.cs:1); Result and ErrorType (ISessionSelectionUIService.cs:2); the EventIdentifierType alias.
  • Concept introduced, the accepted-for-background-processing outcome expressed in one contract. [Rubric §18, UI Architecture] assesses whether pages depend on narrow typed contracts instead of raw HTTP; the page sees two methods and never a URL. [Rubric §9, API & Contract Design] assesses interface segregation on the client side; decision support lives on its own contract rather than swelling ISessionUIService. The distinctive part is the documented semantics of ScoreSessionsAsync (ISessionSelectionUIService.cs:15-20): a background start answers HTTP 202 and comes back as a success carrying the SessionsScored == -1 sentinel, while a refusal to start (409, because a run is already going or the queue is full) arrives as an ErrorType.Conflict failure. Two very different outcomes, one method, and the distinction is written on the contract rather than left in the implementation. [Rubric §6, CQRS & Event-Driven] assesses read/write separation: GetDashboardAsync is a pure query and ScoreSessionsAsync is a command with an asynchronous completion.
  • Walkthrough: two members, both scoped by EventIdentifierType.
    • GetDashboardAsync(eventId, ct) (ISessionSelectionUIService.cs:11-13): returns Task<Result<SessionSelectionDashboardDTO>>.
    • ScoreSessionsAsync(eventId, ct) (ISessionSelectionUIService.cs:21-23): returns Task<Result<ScoreEventSessionsResultDTO>> with the 202/409 semantics above. The two map one to one onto two of the endpoints on SessionSelectionController: the GET dashboard/{eventId} read (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:47) and the POST score/{eventId} command (SessionSelectionController.cs:133). The controller exposes three further analytical GETs (category distribution, speaker overlap, content similarity, :54,68,82) that this UI contract deliberately does not surface.
  • Why it's built this way: keeping the analytical surface on its own interface matches its lifecycle, an organizer-only screen backed by dedicated controller endpoints, and lets the implementation extend AuthenticatedServiceBase rather than the CRUD base. Modelling the 202 as a success value rather than a separate method means the page needs no second endpoint and no polling contract on the service: polling is a rendering decision, and it lives beside the page.
  • Where it's used: implemented by SessionSelectionService, registered explicitly (DependencyInjection.cs:47, explicit because it is not an IEntityService<,> and the assembly scan would not find it), injected into SessionSelectionDashboard (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:17) and taken as a constructor argument by ScorePollSession (Pages/SessionSelection/ScorePollSession.cs:37), the fire-and-forget polling session whose own doc comment explains that it stays beside the page rather than moving onto this contract because every step it takes is a rendering decision (ScorePollSession.cs:26-31).

ISessionUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/ISessionUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the sessions resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to SessionDTO (ISessionUIService.cs:9-11).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> and SessionDTO.
  • Concept: the same marker shape as IActivityUIService. [Rubric §18, UI Architecture]. Worth pausing on what this contract does not carry: the personalized speaker-facing session reads live on a separate contract, ISpeakerDashboardUIService, because they must bypass the shared output cache. Keeping them apart is what lets this contract stay cache-friendly.
  • Walkthrough: no members (doc comment ISessionUIService.cs:6-8).
  • Where it's used: implemented by SessionService; injected into the session list, detail and create pages (Pages/Session/SessionList.razor.cs:24, Pages/Session/SessionDetail.razor.cs:23, Pages/Session/SessionCreate.razor.cs:19), the public session and speaker pages (PublicSessionDetail :24, PublicSpeakerDetail :23), the organizer session-feedback page (Pages/Feedback/OrganizerSessionFeedback.razor.cs:20) and SpeakerDetail (Pages/Speaker/SpeakerDetail.razor.cs:28).

ISponsorUIService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sponsors/ISponsorUIService.cs:9 · Level 4 · interface

  • What it is: the UI-service contract for the sponsors REST resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to SponsorDTO and SponsorIdentifierType (ISponsorUIService.cs:9-11).
  • Depends on: IEntityService<TEntityDTO, TIdentifierType> and SponsorDTO (from MMCA.ADC.Conference.Shared.Sponsors, ISponsorUIService.cs:1).
  • Concept: the marker shape taught under IActivityUIService; the doc comment (ISponsorUIService.cs:6-8) repeats the "uses generic CRUD" formula verbatim. [Rubric §15, Best Practices & Code Quality] is the point worth pausing on: the whole sponsor admin surface plus a public sponsor page costs exactly one empty interface and one tiny class (SponsorService), because the CRUD algorithm, the auth, the retry and the result translation are all inherited. [Rubric §18, UI Architecture].
  • Walkthrough: no members.
  • Why it's built this way: sponsor management is plain CRUD from the client's point of view, so the contract adds nothing; the named marker exists so the assembly scan inside AddUIModule<ConferenceUIModule>() can bind a concrete implementation to a name the pages inject (DependencyInjection.cs:31).
  • Where it's used: implemented by SponsorService; injected into SponsorList (Pages/Sponsor/SponsorList.razor.cs:23), SponsorDetail (Pages/Sponsor/SponsorDetail.razor.cs:22), SponsorCreate (Pages/Sponsor/SponsorCreate.razor.cs:19) and the anonymous PublicSponsorList (Pages/Public/PublicSponsorList.razor.cs:26).

PartnerService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Partners · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Partners/PartnerService.cs:10 · Level 5 · class (sealed)

  • What it is: the concrete HTTP service for the partners resource, a body-less class that inherits every CRUD method from the shared base and supplies only the endpoint name (PartnerService.cs:10-14). It implements IPartnerUIService.
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> as its base, ITokenStorageService, PartnerDTO, and BCL IHttpClientFactory.
  • Concept: the same four-line concrete UI service as ActivityService: a primary constructor forwards IHttpClientFactory and ITokenStorageService plus the literal resource name "partners" to EntityServiceBase<TEntityDTO, TIdentifierType> closed over PartnerDTO and PartnerIdentifierType (PartnerService.cs:10-14); the class body is empty. See ActivityService for the Template Method rubric analysis and the two base behaviors (the constant Idempotency-Key on create, the If-Match concurrency header on update) it inherits for free. [Rubric §2, Design Patterns], [Rubric §15, Best Practices and Code Quality].
  • Walkthrough: no members. The whole class is the base call carrying the resource root "partners" and the declaration that it satisfies IPartnerUIService (PartnerService.cs:11-13).
  • Why it's built this way: the endpoint name is the only thing that varies for a plain CRUD aggregate, so the concrete class carries exactly that and nothing else. sealed (PartnerService.cs:10) closes the leaf.
  • Where it's used: never named in DI by hand. Because it is an IEntityService<,> implementation in the Conference UI assembly, the same Scrutor scan inside AddUIModule<ConferenceUIModule>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:29-31) registers it AsImplementedInterfaces() with a scoped lifetime, which is what makes IPartnerUIService resolvable in PartnerCreate (Pages/Partners/PartnerCreate.razor.cs:20), PartnerDetail (Pages/Partners/PartnerDetail.razor.cs:23), and PartnerList (Pages/Partners/PartnerList.razor.cs:24).

QuestionService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Questions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Questions/QuestionService.cs:10 · Level 5 · class (sealed)

  • What it is: a body-less concrete CRUD service for the questions WebAPI resource. It extends EntityServiceBase<TEntityDTO, TIdentifierType> over QuestionDTO and QuestionIdentifierType, passes the resource name to the base constructor, and implements the equally empty IQuestionUIService, inheriting the entire CRUD implementation with no added code (QuestionService.cs:10-14).
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>; ITokenStorageService; QuestionDTO; IQuestionUIService; BCL IHttpClientFactory.
  • Concept: the thin-leaf CRUD service taught at ActivityService, over a different resource. [Rubric §2, Design Patterns], [Rubric §15, Best Practices & Code Quality].
  • Walkthrough: a primary-constructor class whose base call is the only content (QuestionService.cs:10-12): EntityServiceBase<QuestionDTO, QuestionIdentifierType>("questions", httpClientFactory, tokenStorageService), with an empty body (:13-14).
  • Why it's built this way: questions need nothing beyond CRUD in the UI, so an empty subclass is the smallest concrete type that still gives DI a binding for IQuestionUIService and keeps the resource name in exactly one place.
  • Where it's used: picked up automatically by the Scrutor scan inside AddUIModule<ConferenceUIModule>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31), so no explicit AddScoped line exists for it. Injected as IQuestionUIService into QuestionList (Pages/Question/QuestionList.razor.cs:16), QuestionDetail (Pages/Question/QuestionDetail.razor.cs:18), QuestionCreate (Pages/Question/QuestionCreate.razor.cs:13), OrganizerEventFeedback (Pages/Feedback/OrganizerEventFeedback.razor.cs:19) and OrganizerSessionFeedback (Pages/Feedback/OrganizerSessionFeedback.razor.cs:19), where the question text labels each captured answer.

RoomService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Rooms · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Rooms/RoomService.cs:14 · Level 5 · class (sealed)

  • What it is: the concrete Room CRUD service. It extends EntityServiceBase<TEntityDTO, TIdentifierType> over "rooms" but overrides AddAsync to reshape the create payload, and adds the parent-scoped DeleteAsync(roomId, eventId) declared by IRoomUIService (RoomService.cs:14-47).
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>, its Endpoint property (EntityServiceBase.cs:51) and both SendRequestAsync overloads (EntityServiceBase.cs:351,381); ITokenStorageService; RoomDTO; IRoomUIService; Result; the RoomIdentifierType / EventIdentifierType aliases; BCL System.Net.Http.Json and CultureInfo.InvariantCulture (RoomService.cs:1-2).
  • Concept: cross-reference the thin-leaf CRUD pattern at ActivityService for the inherited half. Two things make RoomService more than a four-liner.
    1. It overrides the base's virtual AddAsync (EntityServiceBase.cs:162) because the create endpoint's contract record AddRoomRequest binds a nullable RoomIdentifierType? RoomId property, not the DTO's Id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:36, bound at :215 and read at :221). The override therefore posts an anonymous body that remaps the DTO id into RoomId alongside the remaining room fields (RoomService.cs:25-37), sending a default id as null so the handler allocates one instead of treating an explicit 0 as a client-chosen id (RoomService.cs:27-29), while still routing through the base SendRequestAsync so it keeps the Polly retry and the Result conversion. [Rubric §9, API & Contract Design] (assesses whether the client honors the server's request contract rather than assuming DTO/request symmetry).
    2. It adds a parent-scoped delete, because a room is addressed under its event.
  • Walkthrough
    • AddAsync(dto, ct) override (RoomService.cs:18-40): guards a null DTO (:20), then SendRequestAsync<RoomDTO> posting the anonymous object { RoomId = dto.Id == default ? (RoomIdentifierType?)null : dto.Id, dto.EventId, dto.Name, dto.Sort, dto.Capacity, dto.Floor, dto.Location, dto.AccessibilityInfo } to Endpoint (:22-37).
    • DeleteAsync(roomId, eventId, ct) (RoomService.cs:42-46): builds {Endpoint}/{roomId}?eventId={eventId} with string.Create(CultureInfo.InvariantCulture, ...) so the ids format culture-stably (:45), and dispatches it through the valueless SendRequestAsync (:43-46), returning the Result the base produces.
  • Why it's built this way: the create-payload remap keeps the UI honest about the server's AddRoomRequest shape, and the parent-scoped delete exists because rooms belong to an event and the endpoint binds the parent id as a query argument.
  • Where it's used: registered by the AddUIModule<ConferenceUIModule>() entity-service scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31) and injected as IRoomUIService into RoomList (Pages/Rooms/RoomList.razor.cs:18), RoomDetail (Pages/Rooms/RoomDetail.razor.cs:20), RoomCreate (Pages/Rooms/RoomCreate.razor.cs:15), SessionCreate (Pages/Sessions/SessionCreate.razor.cs:24), SessionDetail (Pages/Sessions/SessionDetail.razor.cs:34) and PublicSessionDetail (Pages/Public/Sessions/PublicSessionDetail.razor.cs:26) for room wayfinding, and taken as a parameter by SessionLookups (Pages/Sessions/SessionLookups.cs:36).
  • Caveats / not-in-source: two consequences of the override are worth knowing, and both are visible by comparing it to the base. The base AddAsync attaches a fresh Idempotency-Key held constant across retries (EntityServiceBase.cs:171-176), and this override calls SendRequestAsync without one (RoomService.cs:22-39), so a retried room create is not deduplicated by the server-side idempotency filter the way other creates are. The base AddAsync and DeleteAsync also call the base's private InvalidateOnSuccess to drop stale read-cache entries (EntityServiceBase.cs:165,213,285), which neither method here does, and being private it is not callable from this subclass.

SessionSelectionService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Sessions.Selection · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/Selection/SessionSelectionService.cs:14 · Level 5 · class (sealed)

  • What it is: the HTTP service behind the organizer's session-selection decision-support dashboard. It reads the dashboard projection for an event and starts an AI scoring run over that event's sessions (SessionSelectionService.cs:14-63). It extends AuthenticatedServiceBase and implements ISessionSelectionUIService.
  • Depends on: AuthenticatedServiceBase; HttpResultExecutor and ProblemDetailsResultReader; SessionSelectionDashboardDTO and ScoreEventSessionsResultDTO; ITokenStorageService; the EventIdentifierType alias; BCL System.Net.HttpStatusCode, IHttpClientFactory and CultureInfo.InvariantCulture.
  • Concept introduced, retry is a per-verb decision, and 202 is an answer. This class is the clearest place in the group to see that the retry policy is not applied blanket-fashion.
    • GetDashboardAsync is a read, so it runs inside the inherited Polly RetryPolicy (SessionSelectionService.cs:27-29): a repeated GET costs nothing but latency.
    • ScoreSessionsAsync deliberately does not: the inline comment states why (SessionSelectionService.cs:46-47), starting a scoring run is not idempotent, so a retried POST could queue a second run behind the first. The call goes straight to the HttpClient (:48). [Rubric §29, Resilience & Business Continuity] (assesses whether retry is applied only where the operation can safely be repeated). The second idea is the 202 Accepted path. Scoring runs in the background, so the endpoint can answer 202 with no body. A body-less 2xx would look like a failure to ProblemDetailsResultReader, so the status is checked before the reader is reached and answered with a sentinel ScoreEventSessionsResultDTO carrying SessionsScored = -1 (SessionSelectionService.cs:50-57). That sentinel is not a magic number left for the reader to guess: the page turns it into a polling session rather than a finished-run report. [Rubric §9, API & Contract Design] (assesses whether an asynchronous operation is represented honestly at the boundary rather than being made to look synchronous).
  • Walkthrough
    • GetDashboardAsync(eventId, ct) (SessionSelectionService.cs:18-34): inside HttpResultExecutor.ExecuteAsync (:21) it creates the authenticated client (:24), builds sessionselection/dashboard/{eventId} with string.Create(CultureInfo.InvariantCulture, ...) (:26), GETs it through the retry policy (:27-29), and reads the body into a Result<SessionSelectionDashboardDTO> (:31-32).
    • ScoreSessionsAsync(eventId, ct) (SessionSelectionService.cs:36-62): same executor and client (:39,42), URL sessionselection/score/{eventId} (:44), a bare POST with no retry (:48), the 202 short-circuit described above (:54-57), and otherwise the normal reader path (:59-60).
  • Why it's built this way: decision support is a projection plus a long-running job, neither of which is CRUD, so the service sits on the authenticated base and spells out both calls. Keeping the 202 branch inside the service means the page never sees an HTTP status code, only a typed result it can branch on.
  • Where it's used: registered explicitly as ISessionSelectionUIService at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:47, and injected into SessionSelectionDashboard (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:17), which loads the projection at :123, starts a run at :172 and branches on the SessionsScored == -1 sentinel at :180. ScorePollSession then re-reads the dashboard while the run proceeds (Pages/SessionSelection/ScorePollSession.cs:67).
  • Caveats / not-in-source: what the server does with a second concurrent scoring request is not visible here; the client simply declines to create one by not retrying. The -1 sentinel is a convention shared between this service and its page, not a value the DTO documents.

SessionService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Sessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/SessionService.cs:10 · Level 5 · class (sealed)

  • What it is: a body-less concrete CRUD service for the sessions WebAPI resource, extending EntityServiceBase<TEntityDTO, TIdentifierType> over SessionDTO and SessionIdentifierType, and implementing ISessionUIService (SessionService.cs:10-14).
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>; ITokenStorageService; SessionDTO; ISessionUIService.
  • Concept: the thin-leaf CRUD service taught at ActivityService, over the busiest resource in the module. [Rubric §2, Design Patterns], [Rubric §15, Best Practices & Code Quality]. It is worth noticing what this one class carries without knowing it: the base's GetPagedAsync builds the filter, sort and paging query string (EntityServiceBase.cs:86-112), which is exactly the surface PublicSessionScheduleService composes over for the "My Schedule" Id IN (...) scope.
  • Walkthrough: a primary-constructor class, base call only (SessionService.cs:10-12): EntityServiceBase<SessionDTO, SessionIdentifierType>("sessions", httpClientFactory, tokenStorageService), with an empty body (:13-14).
  • Why it's built this way: sessions need nothing beyond generic CRUD from the UI's point of view, so the empty subclass is enough to give DI a concrete type behind a named interface. The two session reads that are special, the speaker's own sessions and the offline-first public schedule, live in their own services (SpeakerDashboardService and PublicSessionScheduleService) rather than as overrides here.
  • Where it's used: registered by the AddUIModule<ConferenceUIModule>() scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31), and injected as ISessionUIService into SessionList (Pages/Session/SessionList.razor.cs:24), SessionDetail (Pages/Session/SessionDetail.razor.cs:23), SessionCreate (Pages/Session/SessionCreate.razor.cs:19), PublicSessionDetail (Pages/Public/PublicSessionDetail.razor.cs:24), PublicSpeakerDetail (Pages/Public/PublicSpeakerDetail.razor.cs:23), SpeakerDetail (Pages/Speaker/SpeakerDetail.razor.cs:28) and OrganizerSessionFeedback (Pages/Feedback/OrganizerSessionFeedback.razor.cs:20). It is also consumed service-to-service by PublicSessionScheduleService (Services/PublicSessionScheduleService.cs:18), which is how the public schedule reaches the API.

SponsorService

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sponsors/SponsorService.cs:10 · Level 5 · class (sealed)

  • What it is: a body-less concrete CRUD service for the sponsors WebAPI resource. It extends EntityServiceBase<TEntityDTO, TIdentifierType> over SponsorDTO and SponsorIdentifierType, passes the resource name to the base constructor, and implements the equally empty ISponsorUIService (ISponsorUIService.cs:9-11), inheriting the entire CRUD implementation with no added code (SponsorService.cs:10-13).
  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>; ITokenStorageService; SponsorDTO; ISponsorUIService; the SponsorIdentifierType alias, an int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:25); BCL IHttpClientFactory.
  • Concept: the thin-leaf CRUD service taught at ActivityService, over a different resource. [Rubric §2, Design Patterns], [Rubric §15, Best Practices & Code Quality] (assesses what a new resource costs: here four lines plus an empty interface, with the verbs, retry, auth and error translation all inherited).
  • Walkthrough: a primary-constructor class whose base call is the only content (SponsorService.cs:10-12): EntityServiceBase<SponsorDTO, SponsorIdentifierType>("sponsors", httpClientFactory, tokenStorageService), with an empty body (:13). As with its siblings, the base's optional IUiReadCache parameter is left at null (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43-47), so reads go to the API and GetCachedAsync takes its no-cache path (EntityServiceBase.cs:260-263).
  • Why it's built this way: sponsors need nothing beyond CRUD in the UI, so an empty subclass is the smallest concrete type that still gives DI a binding for ISponsorUIService and keeps the resource name in exactly one place.
  • Where it's used: picked up automatically by the Scrutor scan inside AddUIModule<ConferenceUIModule>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31), so no explicit AddScoped line exists for it. Injected as ISponsorUIService into SponsorList (Pages/Sponsor/SponsorList.razor.cs:23), SponsorDetail (Pages/Sponsor/SponsorDetail.razor.cs:22), SponsorCreate (Pages/Sponsor/SponsorCreate.razor.cs:19) and PublicSponsorList (Pages/Public/PublicSponsorList.razor.cs:26).

FeedbackQuestionLoader

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/FeedbackQuestionLoader.cs:17 · Level 5 · internal static class (helper)

  • What it is: a one-method helper that reads every feedback question of one entity type ("Event" or "Session"), paging until the server's reported total is reached, so an organizer feedback report cannot silently truncate.
  • Depends on: IQuestionUIService (line 2), QuestionDTO (line 1), and Result (line 3). No Blazor.
  • Concept introduced, paging to exhaustion behind a bounded loop. A single page read is the obvious thing to write and the wrong thing here, and the <remarks> (lines 11-16) explains why in operational terms: the razor iterates only what it was handed, so questions past the page size, and every answer under them, vanish from the report with nothing on screen to say so. Raising the requested size does not fix it either, because the API base clamps any requested size to its own maximum. So the loop reads PageSize = 100 at a time (line 20) and stops on one of three conditions: an empty page (lines 59-62), the accumulated count reaching the server's TotalItems (lines 66-69), or the MaxPages = 20 ceiling (line 49). That ceiling is a runaway guard, not a business limit: the comment (lines 22-25) notes 2000 questions is far past any real feedback form, and its job is to stop a server that keeps reporting a larger total than it returns. [Rubric §12, Performance & Scalability] assesses whether an unbounded read is actually bounded: this one is, twice over, and the cost is capped at twenty round trips. [Rubric §19, State Management & Data Flow]: the first page failure short-circuits the whole load with Result.Failure<List<QuestionDTO>>(result.Errors) (line 56), so a partial read never reaches the page disguised as a complete one. [Rubric §15, Best Practices & Code Quality]: making this a shared internal static rather than a copy in each feedback page means the two organizer reports cannot diverge in their notion of "all questions".
  • Walkthrough
    • LoadAllAsync(IQuestionUIService questions, string questionEntity, CancellationToken) (lines 35-73) guards the service argument (line 40), then builds the single server filter QuestionEntity equals <questionEntity> with an ordinal comparer (lines 42-45).
    • The loop (lines 49-70) calls GetPagedAsync(filters, pageNumber, 100, "Sort", "asc", ...) (lines 51-52), so questions come back in their display order rather than in insertion order.
    • Accumulation is AddRange into a single list (line 64); the method returns Result.Success(accumulated) (line 72).
  • Why it's built this way: a feedback report is one of the few screens where a missing row is worse than a slow load. The paged-to-exhaustion read trades a handful of extra requests for the guarantee that what an organizer sees is what attendees submitted. The filter-as-operator-plus-value shape is the client data-access contract of Website/docs-src/adr/094-client-entity-data-access.md: the caller never touches HttpClient, only the typed I*UIService.
  • Where it's used: OrganizerEventFeedback (.../Pages/Feedback/OrganizerEventFeedback.razor.cs:69-70, with "Event") and OrganizerSessionFeedback (.../Pages/Feedback/OrganizerSessionFeedback.razor.cs:66-67, with "Session").
  • Caveats / not-in-source: the API's own maximum page size is enforced server-side and is not visible in this file; the 100 here is what the client asks for, not necessarily what it receives.

OrganizerEventFeedback

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:18 · Level 6 · class (Blazor code-behind)

  • What it is: the organizer's read-and-moderate view of event feedback. It loads every question and every answer submitted for one event, groups the answers under their question, averages the rating questions, and lets the organizer delete an individual free-text answer.
  • Depends on: IOrganizerEventFeedbackUIService (line 20), IQuestionUIService (line 21), IEventLookupService (line 22) returning EventInfo, IToastService (line 23), and FeedbackQuestionLoader (line 76); the QuestionDTO and EventQuestionAnswerDTO shapes; ConferenceRoutePaths; and DomainHelper's Parse<T> string extension (MMCA.Common.Shared.Extensions, line 7; MMCA.Common/Source/Core/MMCA.Common.Shared/Extensions/DomainHelper.cs:30). Externals: Blazor [Parameter], MudBlazor (MudRating, MudCard, BreadcrumbItem), and the PageLoadingState / PageErrorState components from MMCA.Common.UI.
  • Concept introduced, the inline page-level error state. Unlike the create and detail pages, which toast their failures, this page keeps a _loadError string (line 38) and renders PageErrorState instead of the body when the load failed (.../Pages/Feedback/OrganizerEventFeedback.razor:17-20). The distinction is deliberate: a toast expires, and a feedback page that silently shows zero responses after a failed fetch reads as "nobody answered". A missing event sets the same field with L["Error.EventNotFound"] (line 70) rather than the generic message. [Rubric §19, State Management & Data Flow] assesses where view state lives and how failure is represented: loading, error, empty, and populated are four distinct rendered states driven by IsLoading (line 36), _loadError, and the two collections. [Rubric §30, Compliance, Privacy & Data Governance] assesses control over user-submitted content: answer deletion is the organizer's moderation lever over free-text feedback (BR-53, cited in the type's own doc comment, lines 12-15). [Rubric §23, Front-End Performance & Rendering]: aggregation happens client-side over one bulk answer fetch (line 87) rather than per-question round trips.
  • Walkthrough
    • The route id arrives as [Parameter] public string EventId (line 25) and is converted to the typed alias with EventId.Parse<EventIdentifierType>() (line 55), so the page compiles unchanged whichever primitive the alias maps to (ADR-048, revisited in ADR-085).
    • Every awaited call takes LifetimeToken (line 32) instead of reading _cts.Token directly: reading Token off a disposed source throws ObjectDisposedException, and Dispose cancels before it disposes (lines 289-303), so a load or handler resuming after the user navigated away gets a cancelled token and stops through its OperationCanceledException path.
    • OnInitializedAsync (lines 44-104) builds breadcrumbs (lines 46-51), resolves the event name from the lookup dictionary and bails with Error.EventNotFound when the id is unknown (lines 58-72), loads every event question through FeedbackQuestionLoader.LoadAllAsync(QuestionService, "Event", ...) (lines 76-77), then loads all answers for the event (line 87). The finally always clears IsLoading (lines 100-103).
    • Rendering (.../Pages/Feedback/OrganizerEventFeedback.razor:37-88) pairs each question with _answers.Where(a => a.QuestionId == question.Id) (line 39). A question whose QuestionType is "Rating" (case-insensitive, line 55) parses the answer values to integers, drops the unparseable ones, and renders a read-only MudRating at the rounded average plus the average to one decimal and the ratings count (lines 57-69). Anything else renders each answer as pre-wrapped text with a delete icon button carrying an aria-label (lines 74-83). [Rubric §21, Accessibility].
    • DeleteAnswerAsync (lines 106-137) deletes one answer and, on failure, toasts Snackbar.AnswerDeleteFailed and stops (lines 110-115). On success it drops the answer from _answers locally and toasts Snackbar.AnswerDeleted immediately (lines 120-121), then refetches the whole answer set to refresh the tallies (line 123). A failed refetch toasts Error.LoadFailed (line 130), a load failure, never a failed delete.
  • Why it's built this way: the organizer needs one screen that answers "what did attendees say about this event", and the aggregate-versus-free-text split follows from the question model itself: ratings are only meaningful in aggregate, free text is only meaningful individually (and is the only thing that can need moderating).
  • Where it's used: the /events/{EventId}/feedback route (.../Pages/Feedback/OrganizerEventFeedback.razor:1-2, organizer-only), reached from the "view feedback" button on EventDetail (.../Pages/Event/EventDetail.razor:135-136). The attendee-facing counterpart lives in the Engagement module (EventFeedback).
  • Caveats / not-in-source: the delete affordance renders only in the non-rating branch (.../Pages/Feedback/OrganizerEventFeedback.razor:74-83), so a rating answer has no moderation button on this page.

OrganizerSessionFeedback

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:18 · Level 6 · class (Blazor code-behind)

  • What it is: the session-scoped twin of OrganizerEventFeedback. Same load-group-aggregate-moderate flow, one level down the hierarchy: answers for a single session instead of a whole event.
  • Depends on: IOrganizerSessionFeedbackUIService (line 20), IQuestionUIService (line 21), and ISessionUIService (line 22) in place of the event lookup, plus IToastService (line 23) and FeedbackQuestionLoader (line 73); the SessionQuestionAnswerDTO and QuestionDTO shapes; ConferenceRoutePaths; and the same DomainHelper Parse<T> extension (line 7).
  • Concept introduced: none new. The page-level error state, the rating-versus-text rendering split, the LifetimeToken guard (line 32), and the remove-then-refetch-after-delete rule are the ones taught in OrganizerEventFeedback.
  • Walkthrough (only the differences from its twin):
    • The route parameter is [Parameter] public string SessionId (line 25), parsed to SessionIdentifierType (line 55).
    • The title comes from the session itself rather than a lookup dictionary: SessionService.GetByIdAsync(_parsedSessionId, false, ...) with includeChildren: false (line 58), since only session.Title is needed (line 69).
    • Failure is classified, not flattened. The load distinguishes a genuine miss from any other error: sessionResult.IsNotFound() chooses L["Error.SessionNotFound"], anything else keeps the generic L["Error.LoadFailed"] (lines 63-65), and the in-code comment (lines 61-62) records that this is the Result-based replacement for what used to arrive as a null success. The event twin cannot do this, because its miss is a dictionary lookup rather than a fetch.
    • The question filter is "Session" (line 74), the other half of the same question table that the event page filters on "Event".
    • DeleteAnswerAsync (lines 103-134) takes a SessionQuestionAnswerIdentifierType and passes the parsed session id alongside it (line 107); the local removal and success toast (lines 117-118) precede the refetch (line 120), whose failure toasts Error.LoadFailed (line 127).
    • The template renders the session title as a MudLink back to its detail page (.../Pages/Feedback/OrganizerSessionFeedback.razor:23) where the event page renders plain text, and ends with a back button to the same route (line 92).
  • Why it's built this way: session and event feedback are two instances of one questionnaire model, so the two pages stay structurally identical rather than sharing a parameterized component. The cost is duplication; the benefit is that each page's queries and route contract read literally. The genuinely shared part, the paged question read, was extracted into FeedbackQuestionLoader rather than duplicated.
  • Where it's used: the /sessions/{SessionId}/feedback route (.../Pages/Feedback/OrganizerSessionFeedback.razor:1-2), reached from the "view feedback" button on SessionDetail (.../Pages/Session/SessionDetail.razor:161-162). The attendee-facing counterpart is SessionFeedback.

EventFilteredListPageBase<TDto>

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Common · MMCA.ADC.Conference.UI/Pages/Common/EventFilteredListPageBase.cs:25 · Level 9 · class (abstract, generic)

  • What it is: the base class for every Conference list page whose rows are scoped to a single conference event. It adds three things on top of the framework's grid base: the event lookup, a persisted eventId filter, and the default (current or next) event resolution. A derived page supplies only what differs: how it saves and restores its own filters, and what it reloads when the scope changes.
  • Depends on: DataGridListPageBase<TDto> (its base, :25), IEventLookupService and its EventInfo record (injected at :27, :31), CurrentEventSelector (:3, :183), and the EventIdentifierType alias. Externals: Microsoft.AspNetCore.Components for [Inject] and RendererInfo, System.Globalization.CultureInfo (:1), and an injected TimeProvider (:28) that is the clock for the default-event computation (:188).
  • Concept introduced: layering an application-vocabulary base on a framework base. [Rubric §3, Clean Architecture] assesses whether abstractions sit at the layer that owns their vocabulary. The class remark makes the call in one sentence (:16-18): "conference event" is domain vocabulary, not framework vocabulary, so this does not belong in MMCA.Common alongside the grid plumbing it builds on. The result is a two-layer inheritance chain where each layer owns exactly the concepts of its own repository, which is the same reasoning that keeps DetailPageBase ADC-local for a different reason (nothing to share) and this one ADC-local for this reason (nothing shareable should be shared). The second idea is prerender-truthful interactivity, and it is worth reading in full because it is a non-obvious correctness rule rather than a preference. CanRenderEventPicker gates the picker on RendererInfo.IsInteractive && Events is { Count: > 1 } (:73), and the remark above it explains (:64-72): the SSR prerender pass resolves the events server-side and would happily paint a fully formed picker, dropdown and clear button included, into static HTML. Nothing is wired to it until the interactive runtime attaches, which under InteractiveAuto's WebAssembly leg is a whole runtime boot later, and every click landing in that window is swallowed with no feedback, leaving the list silently on the default event the prerender chose. Rendering the picker only once the component is interactive turns "the picker is on screen" into a truthful signal that a choice will be honored, for a reader and for an E2E wait alike. [Rubric §22, Responsive and Cross-Browser/Device] and [Rubric §24, Forms, Validation and UX Safety] both land on that one line, and ADR-056 is the render-mode policy it implements.
  • Walkthrough:
    • Injected state (:27-51): the IEventLookupService and the TimeProvider, then Events (the id-to-EventInfo dictionary, null when the lookup failed), SelectedEventId (null meaning "all events"), EventFilterResolved (true once the scope is decided, restored or computed), EventsLoadFailed (so a page that must fail closed can branch rather than silently querying unscoped), and EventsLoadTask, the in-flight load. Every setter is private, so the base owns all transitions.
    • EventFilterIsUserControlled (:58): virtual, default true. It answers "does the reader pick the event themselves", which is what makes the choice worth persisting and worth restoring. PublicSpeakerList overrides it with a role check rather than a constant (MMCA.ADC.Conference.UI/Pages/Public/Speakers/PublicSpeakerList.razor.cs:52), so the same page is user-controlled for a privileged reader and locked to the computed event for everyone else.
    • CanRenderEventPicker and EventPickerOptions (:73, :80-81): the gate described above, and the ordered option list derived from it. Iterating EventPickerOptions instead of Events keeps every page's picker on one ordering (OrderBy(e => e.StartDate)) and keeps the markup free of the null handling the gate already did.
    • The three extension points (:84-96): ReloadForEventFilterAsync() is abstract, so a page cannot forget to reload; SavePageFilters / RestorePageFilters are virtual no-ops for page-specific values (search text, status dropdowns); OnEventsLoadingAsync() is a virtual hook for work that must run before the events are fetched, such as resolving whether the reader is privileged.
    • SaveFilters / RestoreFilters (:99-130): both are sealed override, which is the load-bearing detail. The base takes over the framework's two filter hooks, calls the page's hook first, then owns the eventId entry itself, so no derived page can accidentally drop the event scope from persisted state. The saved value is either the invariant-formatted id or the literal "all", and the comment states why the literal exists (:105): it distinguishes an explicit clear from no saved state, which would apply the default instead. Restore handles the three cases (absent, "all", parseable id) and sets EventFilterResolved only in the latter two.
    • OnInitializedAsync and BeginEventsLoad (:133-145): initialization starts the load before any await and then awaits it. BeginEventsLoad() is exposed separately so a derived page can start the event load, run its own non-event work concurrently, and only then await EventsLoadTask.
    • LoadEventsAndResolveDefaultAsync (:151-168): the hook, then the lookup with Result unwrapped by TryGetValue (see the primer on the Result pattern), then ResolveDefaultEventFilter(). A failure is non-fatal by design (:155-156): the picker stays hidden and the default filter stays unset, and the failure is remembered in EventsLoadFailed. The method is documented as callable again (:147-150), so a retry button heals a transient lookup failure.
    • ResolveDefaultEventFilter (:170-190): the precedence rule, stated in its comment (:172-173) and encoded in one condition. A restored id that still exists wins; a dangling one falls back to the computed default; and a page with a locked scope is always pinned to the computed current or next event, which is what stops a stale saved id from surviving an override of EventFilterIsUserControlled. The computation delegates to CurrentEventSelector.SelectCurrentOrNext over Events.Values with TimeProvider.GetUtcNow().UtcDateTime as "now" (:183-188), the same selector the landing page uses, so "which event is current" has one implementation across the module and a test can pin the clock through the injected TimeProvider.
    • WaitForEventsAsync (:193): returns the load task or Task.CompletedTask. The doc on EventsLoadTask (:45-50) says exactly when to await it: before any fetch that applies the event filter, because the grid's first ServerData call can race ahead of initialization completing. That race is why the task is a field rather than a local.
    • ApplyEventFilter (:196-200): adds EventId as an ("equals", value) pair to the outgoing filter dictionary, and only when an event is selected, so "all events" is the absence of a filter rather than a sentinel.
    • OnEventFilterChanged (:203-208): bound to the picker's ValueChanged. It sets the id, marks the scope resolved, and awaits the page's ReloadForEventFilterAsync(). A null value clears the scope.
    • GetEventName / SelectedEventName (:211-220): display helpers that degrade rather than throw, falling back to the raw id and to an empty string respectively when the lookup has nothing.
  • Why it's built this way: [Rubric §19, State Management and Data Flow] is the through-line. There is one scope variable, one place that decides its default, one place that persists it, and one place that reloads on change, and the sealed override on the two persistence hooks is what keeps it that way as pages are added. [Rubric §11, Security] shows up in the EventsLoadFailed flag: a public reader whose lookup failed must not fall through to an unscoped query, so the flag exists specifically to let such a page fail closed, which PublicSpeakerList does through its ScopeUnresolvedForPublicReader() check (PublicSpeakerList.razor.cs:126). [Rubric §15, Best Practices & Code Quality]: six list pages share this, and each one's event handling is now three overrides.
  • Where it's used: six Conference list pages derive from it, each pairing an @inherits directive with a partial class: SponsorList (MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorList.razor.cs:19), PartnerList (Pages/Partners/PartnerList.razor.cs:19), SpeakerList (Pages/Speakers/SpeakerList.razor.cs:19), RoomList (Pages/Rooms/RoomList.razor.cs:13), ActivityList (Pages/Activities/ActivityList.razor.cs:20), and PublicSpeakerList (Pages/Public/Speakers/PublicSpeakerList.razor.cs:32). The five organizer pages, PartnerList included, follow one shape verbatim (SavePageFilters/RestorePageFilters, ReloadForEventFilterAsync => ReloadActiveLayoutAsync(), await WaitForEventsAsync() before each fetch, ApplyEventFilter(filters) inside it); the public page is the variant that exercises every extension point, overriding EventFilterIsUserControlled (:52), OnEventsLoadingAsync (:90), and re-calling LoadEventsAndResolveDefaultAsync() on retry (:114).

PublicActivityList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Activities/PublicActivityList.razor.cs:22 · Level 9 · class (Blazor code-behind)

  • What it is: the public social and networking programme. It lists the current (or next) event's activities (pre-conference party, coffee connect, after-party, closing ceremony) ordered by start time then display order, read-only and anonymous, BR-43 (class doc, PublicActivityList.razor.cs:13-21).
  • Depends on: IActivityUIService and IEventLookupService (:29-30), ActivityDTO, EventInfo (through the lookup), CurrentEventSelector (:80-85), IMapNavigationService (:31), IToastService (:32), and the injected TimeProvider (:33) that supplies "now" to the selector (:85). It derives from ComponentBase directly (:22), not from the list-page base.
  • Concept introduced, the bounded, deterministically ordered, single-shot read. This page is not a data grid, and reading it next to PublicEventList is the clearest way to see when the base class is the wrong tool. An activity programme is a handful of items with a fixed narrative order (chronological), so there is nothing to page, sort or search.
    • Bounded read: MaxActivities = 200 with the reasoning written on the constant, a conference schedules a handful, not thousands (:24-25). [Rubric §12, Performance & Scalability] (assesses that unbounded reads are avoided by design, not by luck).
    • Deterministic order: the server is asked for StartTime ascending (:98-99), and the result is re-ordered in memory by StartTime, then SortOrder, then Name (:109-115). The comment (:106-108) explains the layering: start time is the programme order, sort order breaks ties between activities that start together, and name is the final tiebreak so the list is deterministic rather than dependent on insertion order.
    • The event lookup fails closed; the activity fetch fails soft. A failed event lookup sets _loadFailed and returns before any fetch is issued (:72-76), and the markup hands that flag to ListNoRecordsContent with OnRetry="LoadAsync" (PublicActivityList.razor:26-27), so the reader gets an error with a retry. The doc on LoadAsync states the reason (:60-65): falling through to a fetch with no event filter would list every edition's activities; a successful lookup that genuinely finds no current or next event still keeps the unscoped fetch, matching PublicSpeakerList. The activity page itself (:104) stays non-critical: a failed fetch leaves the page on its empty state (:102-103). Only OperationCanceledException is caught (:118-121) as expected teardown or an InteractiveAuto transition, and the finally always clears _isLoading (:122-125). [Rubric §29, Resilience & Business Continuity] and [Rubric §11, Security] (an audience-scoped read never silently widens).
    • Only a published event can be featured. The selector is fed events.Values.Where(e => e.IsPublished) (:81): the event lookup is unscoped for staff callers, so it can hold a draft that is live today, and the comment at :78-79 (OBS-7) records why the page filters it out. The server already scopes non-privileged callers; this closes the same gap for staff.
    • Disposal-safe token. Every call passes LifetimeToken (:40) instead of reading _cts.Token directly, because reading Token off a disposed source throws ObjectDisposedException; Dispose cancels before it disposes, so a load or handler that resumes after the user navigated away gets a cancelled token and exits through the OperationCanceledException path (comment :37-39). It is used at :72, :100 and :139.
    • Culture-aware time rendering: FormatTimeRange (:52-56) formats start and end with CultureInfo.CurrentCulture and composes them through a localized Text.TimeRange resource, so both the times and the separator follow the viewer's culture. [Rubric §27, Internationalization] (assesses that formatting and phrasing are both localized, not just the strings). The class doc also draws the domain line that shapes the whole page (:18-20): activities are not sessions. They carry no room and no speakers, and an activity with its own venue offers the same directions affordance the public event page uses for the conference venue.
  • Walkthrough
    • OnInitializedAsync (:58) delegates to LoadAsync (:66-126), which is also the retry target: reset _isLoading and _loadFailed (:68-69), load the event lookup and stop on the error state when it fails (:72-76), resolve the current or next event, among published events only (:81), through CurrentEventSelector.SelectCurrentOrNext with the four accessors passed explicitly because the lookup returns EventInfo rather than EventDTO, and with TimeProvider.GetUtcNow().UtcDateTime as the clock (:80-85), remember its id and name (:87-88), build an EventId equals filter when one resolved (:90-92), fetch one bounded page (:94-100), and materialize the ordered list (:109-115).
    • OpenDirectionsAsync (:128-145): does nothing for an activity with no venue address (:130-133), otherwise launches the platform maps app on native heads or a maps site in a browser (:135-139), labelling the pin with the venue name and falling back to the activity name when the venue is unnamed (:138); a false return raises one warning toast (:141-144).
    • Disposal (:147-169) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :35.
  • Why it's built this way: a fixed-order programme wants a readable timeline, not sortable columns, and the read is small enough that one bounded call beats the machinery of server paging.
  • Where it's used: the /conference/activities route (PublicActivityList.razor:1, the same string as ConferenceRoutePaths.PublicActivities at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:54), reached from PublicEventDetail's ViewActivities action (PublicEventDetail.razor.cs:176).
  • Caveats / not-in-source: the page relies on the server scoping non-privileged callers to published events (class doc, :15-17); that scoping is enforced in the Conference API, not here. For staff callers the page adds its own published-only filter (:81).

PublicSponsorList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Sponsors · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sponsors/PublicSponsorList.razor.cs:21 · Level 9 · class (Blazor code-behind)

  • What it is: the public sponsor and exhibitor page. It groups the current (or next) event's sponsors by tier, orders them within each tier, and renders them as logo cards. Read-only and anonymous, BR-43 (class doc, PublicSponsorList.razor.cs:12-20).
  • Depends on: ISponsorUIService and IEventLookupService (:28-29), SponsorDTO and SponsorTier (:52), EventInfo (through the lookup), CurrentEventSelector (:79-84), and the injected TimeProvider (:30) that supplies "now" to the selector (:84); MudBlazor and the page's IStringLocalizer (PublicSponsorList.razor:3). Like PublicActivityList it derives from ComponentBase directly (:21).
  • Concept introduced, the deterministic grouped read and the graceful empty state. Like PublicActivityList, this page is not a data grid: the roster is small and needs a fixed visual hierarchy, so the page fetches one bounded page and shapes it in memory.
    • Bounded read: MaxSponsors = 200 with the reasoning stated on the constant, a conference sells dozens, not thousands (:23-24). [Rubric §12, Performance & Scalability].
    • Deterministic order: sponsors are grouped by tier, tiers ordered ascending because that is package order (Platinum first), and each group ordered by Sort then Name (:109-117), so the strip does not depend on insertion order. The comment states the rule (:106-108).
    • Empty state with no dead link: when the event has no sponsors the page falls back to the sponsorship-packet call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (class doc :17-19, field doc :45-49, assigned at :88). [Rubric §24, Forms, Validation & UX Safety] and [Rubric §25, Navigation & Information Architecture]: a missing value removes an affordance instead of producing a broken one.
    • The event lookup fails closed; the sponsor fetch fails soft. A failed event lookup sets _loadFailed and returns before any fetch is issued (:71-75), and the markup hands that flag to ListNoRecordsContent with OnRetry="LoadAsync" (PublicSponsorList.razor:26-27), so the reader gets an error with a retry. The doc on LoadAsync states the reason (:59-64): falling through to a fetch with no event filter would show every edition's sponsors; a successful lookup that genuinely finds no current or next event still keeps the unscoped fetch, matching PublicSpeakerList. A failed sponsor fetch (:104) stays non-fatal and falls back to the sponsorship call to action (:102-103). Only OperationCanceledException is caught (:120-123) as expected teardown or an InteractiveAuto transition, and the finally always clears _isLoading (:124-127). [Rubric §29, Resilience & Business Continuity] and [Rubric §11, Security] (an audience-scoped read never silently widens).
    • Only a published event can be featured. The selector is fed events.Values.Where(e => e.IsPublished) (:80): the event lookup is unscoped for staff callers, so it can hold a draft that is live today, and the comment at :77-78 (OBS-7) records why. The class doc now says the same (:15-16): the server scopes non-privileged callers and the page features only a published event for staff too.
    • Disposal-safe token. Every call passes LifetimeToken (:37) rather than _cts.Token, because reading Token off a disposed source throws ObjectDisposedException; Dispose cancels first, so a late-resuming load gets a cancelled token and stops through the OperationCanceledException path (comment :34-36; used at :71 and :100).
  • Walkthrough: OnInitializedAsync (:57) delegates to LoadAsync (:65-128), which is also the retry target. It resets _isLoading and _loadFailed (:67-68), loads the event lookup and stops on the error state when it fails (:71-75), resolves the current or next event among published events with CurrentEventSelector passing the four accessors explicitly and TimeProvider.GetUtcNow().UtcDateTime as the clock (:79-84), remembers its name and sponsorship packet URL (:87-88), builds an EventId equals filter when an event resolved (:90-92), fetches one page sorted by Sort ascending (:94-100), and materializes _tiers as an ordered list of tier-to-sponsors pairs (:109-117). HasSocialLinks (:130-133, SP-01) is a static guard that is true when a sponsor has a LinkedIn URL or a Twitter handle, so the card's social row renders only when there is something to link. TierLabel (:55) localizes each tier name through the page's IStringLocalizer with a Tier.{tier} key, so the tier enum never reaches the screen untranslated ([Rubric §27, Internationalization]). Disposal (:135-157) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :32.
  • Why it's built this way: the sponsor page is a marketing surface with a fixed hierarchy, so it wants deterministic grouping rather than sortable columns, and it must look intentional on an event that has not sold a sponsorship yet.
  • Where it's used: the /conference/sponsors route (PublicSponsorList.razor:1, matching ConferenceRoutePaths.PublicSponsors at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:53). The roster it renders is authored by the organizer through the sponsor admin pages in this module.
  • Caveats / not-in-source: the page relies on the server scoping non-privileged callers to published events (class doc, :15-16); that scoping is enforced in the Conference API, not here. For staff callers the page adds its own published-only filter (:80).

PublicSpeakerDetail

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Speakers/PublicSpeakerDetail.razor.cs:22 · Level 10 · class (Blazor code-behind)

  • What it is: the public speaker profile: photo, bio, social links, and the sessions that speaker presents at the conference the reader is allowed to see. Email is deliberately not rendered, BR-66 (class doc, PublicSpeakerDetail.razor.cs:18-21).
  • Depends on: ISpeakerUIService, ISessionUIService and IEventUIService (:24-26), AuthenticationStateProvider read directly rather than as a cascading task (:27), IToastService (:29), the injected TimeProvider (:30), LatestLoadGuard (:60), SpeakerDTO and SessionDTO, ConferenceReadAudience (:211), CurrentEventDefaults (:218), ConferenceRoutePaths (:47, :222), and DomainHelper's Id.Parse<T> (:103).
  • Concept introduced, the prerender skip, the server-side filter that replaced an in-memory one, fail-closed audience scoping, and clearing stale child data before a reload.
    1. Prerender skip. OnParametersSetAsync returns immediately when !RendererInfo.IsInteractive (:77-80): under InteractiveAuto the interactive instance re-runs the method, so without this guard every visit fetched the speaker and the session catalog twice. The prerender pass renders the loading skeleton, and the comment names the sibling pages that use the same guard (:74-76). [Rubric §23, Front-End Performance & Rendering] (assesses avoidable duplicate work per view).
    2. Push the filter to the server. LoadSpeakerSessionsAsync (:162-199) sends a SpeakerId equals filter with includeChildren: false, sorted by StartsAt ascending, capped at MaxSpeakerSessions = 100 (:34-35, request at :183-190). The remarks block (:152-157) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on SessionSpeakers, so viewing one speaker cost a full-catalog read. Since the page never renders those children, they are gone from the request too. [Rubric §12, Performance & Scalability] and [Rubric §8, Data Architecture] (a bounded page size instead of an unbounded read).
    3. Fail closed on the audience scope. ResolveSessionEventScopeAsync (:207-220) returns an unscoped result for a privileged reader (:210-214) and otherwise resolves the current or next event through CurrentEventDefaults, with TimeProvider.GetUtcNow().UtcDateTime as "now" (:218); a failed events lookup returns (false, null), an unresolved scope rather than a wide one, and the caller turns that into a load failure (:172-176). The comment states the leak it prevents (:169-171): without this, a speaker who also presented at past conferences leaked those sessions to attendees here. [Rubric §11, Security] and [Rubric §26, Front-End Security].
    4. Clear the previous speaker's sessions up front. LoadSpeakerAsync resets _speakerSessions to empty right after _load.Begin() and before any await (:96-98, the L156 comment): the session list belongs to the previous speaker until this load replaces it, so a failed scope or fetch for the new speaker cannot leave the old speaker's sessions on screen. [Rubric §24, Forms, Validation & UX Safety] (stale data is never shown under the wrong heading). [Rubric §30, Compliance, Privacy & Data Governance] (assesses deliberate handling of personal data): the speaker email exists on the DTO but is never rendered on the public page, and the class doc names the rule.
  • Walkthrough
    • OnInitialized (:41-50) builds the Home / Speakers / Profile breadcrumbs; unlike PublicEventDetail it is synchronous, because the speaker list is reachable by every audience and the trail does not vary.
    • HasSocialLinks (:66-70) collapses the four optional link fields into a single render guard, so the social row is absent rather than empty when a speaker supplied none.
    • LoadSpeakerAsync (:91-146): begin a new load through LatestLoadGuard.Begin(), which returns the cancellation token and the generation together (:94, field doc at :54-59), empty _speakerSessions (:96-98), parse the id (:103), GetByIdAsync(speakerId, true, token) (:104), clear Speaker and toast not-found-versus-load-failed on failure (:110-120), then load the sessions and toast one load failure if that resolver returned false (:124-127); OperationCanceledException is swallowed (:129-132), any other exception raises the load-failure toast (:133-136), and the _load.IsCurrent(generation)-guarded finally clears IsLoading (:137-145).
    • Session links are plain MudLink markup to ConferenceRoutePaths.PublicSessionDetails(session.Id) (PublicSpeakerDetail.razor:117), not a code-behind navigation method; the page keeps only NavigateToList (:222) for the back action. Disposal (:224-245) delegates to _load.Dispose() (:235), so the same LatestLoadGuard that owns the load generation also owns cancellation of the in-flight fetch.
  • Why it's built this way: a public profile is a read-only, cache-friendly page; keeping its fetches narrow (one speaker, that speaker's sessions for one event, no children) is what makes it cheap enough to serve to an anonymous crowd, and scoping those sessions to the reader's event keeps the profile consistent with the schedule they can actually browse.
  • Where it's used: the /conference/speakers/{Id} route (PublicSpeakerDetail.razor:1), reached from PublicSpeakerList cards and from session pages. Its markup renders the QrCodeButton for its own link (PublicSpeakerDetail.razor:45).

PublicSpeakerList

MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Speakers/PublicSpeakerList.razor.cs:32 · Level 10 · class (Blazor code-behind)

  • What it is: the public speaker directory, rendered as a photo-forward responsive card grid with infinite scroll, the same layout on desktop and mobile. Read-only for everyone (BR-43), no emails (BR-66), and the server returns only speakers with a visible session in the listed event, or in any published event when no event filter is applied, BR-239 (class doc, PublicSpeakerList.razor.cs:9-31).
  • Depends on: extends EventFilteredListPageBase<TDto> closed over SpeakerDTO (:32), which itself extends DataGridListPageBase<TDto> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Common/EventFilteredListPageBase.cs:25); ISpeakerUIService (:41), PublicReadAudience (:97), which applies ConferenceReadAudience's privileged roles (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicReadAudience.cs:28), and InfiniteScrollSentinel (PublicSpeakerList.razor:144). Note what is absent: no MudDataGrid, no GridRef override, no MobileInfiniteScrollList<TItem>.
  • Concept introduced, borrowing a base class's plumbing for a layout it was not written for. A speaker is a face and a tagline, not a row of columns, so this page throws away the grid and keeps everything else. The class doc explains the trade (:20-27): paging keeps the page-based model of the base's mobile path (MobileItems, MobileCurrentPage, MobileTotalItems, MobilePageSize, declared at MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:60-63), which already owns the cancellation token, the loading and failure flags, and the saved-state plumbing, and layers infinite scroll on top by appending each fetched page to _loadedSpeakers (:54, appended at :152 and :188) rather than replacing, which is what the base's mobile path does on its own. The event-filter half is not hand-rolled either: EventFilteredListPageBase<TDto> owns the events load, the "all" sentinel, the picker, and the EventId filter, and this page supplies four overrides. EventFilterIsUserControlled => _isPrivileged (:52) is the audience gate: only privileged readers pick their own event, so only their choice is persisted and restored, and everyone else is recomputed to the current or next event (doc at :48-51, base contract at EventFilteredListPageBase.cs:58). OnEventsLoadingAsync (:90-98) resolves privileged status before the events are fetched, because that decides whether a restored choice survives. It returns early, leaving the reader non-privileged, when no authentication state cascades (:92-95), and otherwise delegates to PublicReadAudience.IsPrivilegedReaderAsync (:97), whose catch degrades any non-cancellation failure to the least-privileged audience (PublicReadAudience.cs:30-34). ReloadForEventFilterAsync (:213) and SavePageFilters / RestorePageFilters (:72-76) fill in the rest. [Rubric §15, Best Practices & Code Quality] and [Rubric §1, SOLID] (assess reuse of one tested mechanism rather than a parallel implementation); [Rubric §11, Security] and [Rubric §26, Front-End Security] for the persistence gate. Four correctness details make that borrowing safe, and they are the real lesson of this page.
    1. A generation counter supersedes in-flight fetches. _generation (:64) is bumped by every reset (search, event filter, breakpoint change, retry) inside LoadSpeakersAsync (:134), and both LoadSpeakersAsync (:145-148) and LoadMoreSpeakersAsync (:177-180, :193-197) discard their rows when a newer generation has taken over. Without it a slow page-1 fetch could append to the list a later query had already cleared. [Rubric §19, State Management & Data Flow].
    2. The list is cleared before the await, not after. The comment (:138-139) states why: the grid, and with it the sentinel, is gone while page 1 is in flight, so a stray intersection-observer callback cannot ask for page 2 of the query being replaced. [Rubric §18, UI Architecture & Component Design].
    3. The page number is committed only on success. LoadMoreSpeakersAsync saves previousPage, optimistically advances, and rolls back when LoadFailed is set (:169-189), so the retry button re-requests the same page instead of silently skipping it. [Rubric §29, Resilience & Business Continuity].
    4. An unscoped query is refused rather than issued. ScopeUnresolvedForPublicReader() (:126) is true exactly when a non-privileged reader has no event scope because the events fetch failed. FetchCurrentPageAsync (:104-123) re-resolves the events first in that state (:112-115), which heals a transient failure on the retry path, and the fetch delegate then throws rather than falling through to an unscoped query that would show other conferences' speakers (:117-122). The comment records the invariant and its one deliberate exception (:106-111): a successful fetch that genuinely found no published events keeps the unscoped default, matching PublicSessionList. [Rubric §11, Security] and [Rubric §26, Front-End Security].
  • Walkthrough
    • CardsPerPage = 12 (:34-35) is assigned to the base's MobilePageSize in the constructor (:37), with the reasoning on the constant: a multiple of 2, 3 and 4 so a full chunk fills whole rows at every breakpoint. [Rubric §22, Responsive & Cross-Browser].
    • HasMoreSpeakers (:70) is true exactly while pages remain unfetched, and that is exactly when the sentinel renders (PublicSpeakerList.razor:133-147, comment at :131-132), so the trigger and the condition cannot disagree; the markup comment notes that the sentinel's absence is the "everything is loaded" signal, for the reader and for the tests.
    • OnInitializedAsync (:78-84) awaits the base first, because the base starts the events load before its own first await so the default event filter is resolved before the first speaker fetch, then runs LoadSpeakersAsync.
    • FetchCurrentPageAsync (:104-123) delegates to the base's LoadMobileDataAsync (DataGridListPageBase.cs:830) with a fixed FullName ascending sort; ApplyFilters (:215-220) emits FullName contains plus the base's ApplyEventFilter (:219, EventFilteredListPageBase.cs:196), which travels as the virtual EventId filter key that the speakers endpoint resolves through the EventSpeaker and SessionSpeaker joins, since a Speaker row has no EventId column (class doc, :18-19).
    • OnMobileDataRequestedAsync (:205) is the base's breakpoint-change hook (DataGridListPageBase.cs:1047, invoked at :313), overridden here to restart the accumulation rather than fetch one replacement page.
    • RetryLoadAsync (:202), OnSearchChanged (:207-211) and ReloadForEventFilterAsync (:213) all funnel through LoadSpeakersAsync, which is the single reset entry point.
    • Initials (:222-230) builds the no-photo avatar text with spans, tolerating a blank first or last name; HasSocialLinks (:232-236) hides the social row when a speaker supplied none.
  • Why it's built this way: attendees browse "the speakers at this conference", not a lifetime roster, so the default filter is the primary behavior and the picker is the privileged exception; and a directory of faces reads better as an endless wall of cards than as a pager. The class doc adds one more deliberate omission (:28-30): category chips are absent because the paged endpoint is called with includeChildren=false, so asking for them would both enlarge the payload and change the URL the output-cache warmup pins (ADR-040).
  • Where it's used: the /conference/speakers route (PublicSpeakerList.razor:1, matching ConferenceRoutePaths.PublicSpeakers at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:51); cards navigate to PublicSpeakerDetail.
  • Caveats / not-in-source: the join-based resolution of the virtual EventId filter is asserted by the class doc comment; the resolution itself lives in the Conference API. A restored mobile page number is deliberately ignored (class doc, :26-27), so a reader returning to this page starts at page 1 rather than at the scroll depth they left.

⬅ ADC Conference - API, gRPC Contracts & Service Host • Index • ADC Engagement Module (Session Bookmarks) ➡