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 stringconstants 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@pagedirective orNavigateTocall 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 asglobal using(see the primer on identifier-type aliases, and ADR-048), plusSystem.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}withstring.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) andPublicSessionDetails(:50), so an attendee-facing deep link never borrows an organizer route. Then the speaker surfacesSpeakerDashboardandSpeakerQr(: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, andPartnerDetails(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).SponsorVisitLinkandRoomCheckInLinkbuild/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 aQrCodeButtonon 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:
EventFeedbackOrganizergives/events/{id}/feedbackandSessionFeedbackOrganizergives/sessions/{id}/feedback(:68-69), so the URL itself expresses the ownership hierarchy.SessionSelectionDashboardcloses the file (:72). - Two factories differ from the rest:
SpeakerDetails(:20) andPublicSpeakerDetails(:52) use plain interpolation rather thanstring.Create(CultureInfo.InvariantCulture, ...), becauseSpeakerIdentifierTypeis aGuidwhoseToString()is already culture-invariant.
- The partners routes follow the same shape as every other organizer entity (
- Why it's built this way: if the admin prefix ever moves (say
/eventsbecomes/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
@pagedirective andNavigationManager.NavigateTocall, the "see all sponsors" link on the landing page (ADCHome.razor:308), theNavItemscollection in ConferenceUIModule (ConferenceUIModule.cs:21-40), and even one Engagement page, which links back toPublicSponsors(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) andSystem.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 callsTimeZoneInfo.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 nullValidationContextwithArgumentNullException.ThrowIfNull(:41), returns success on anything that is not a non-empty string (:43-46), returns success whenTryFindSystemTimeZoneByIdfinds the zone (:48-51), and otherwise builds aValidationResultcarryingErrorMessageagainst the failing member name (:53-54). - Why it's built this way: EventFormModel's
TimeZoneproperty 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'sIStringLocalizerper 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-partySessionizeCodeFormathelper (: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 aValidationResult. 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 whenSessionizeCodeFormat.IsValidaccepts the code (:41-44), and otherwise returns aValidationResultagainst 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 (theOrganizerrole string), ConferenceRoutePaths (the URLs), plus MudBlazorIconsandSystem.Reflection.Assembly(externals,:1,:5) and the co-locatedConferenceUIModule.resx/ConferenceUIModule.es.resxpair. - 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 theTitleandGroupstrings are resource keys, not literals:typeof(ConferenceUIModule)in theTitleResourceposition on every item tells the shared NavMenu to resolve them against the co-located.resxat 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 anIReadOnlyList<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 noRequiredRoleand noSection, so they default toNavSection.General(MMCA.Common.UI/Common/NavItem.cs:16). - Speaker (
:28-29): the Dashboard and the QR page, both carryingRequiredClaim: "speaker_id"andSection: 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 carryingRoleNames.Organizer,Section: NavSection.Admin, andGroup: "Nav.Group.Conference"so they fold into one labelled admin group. The newNav.Partnersentry (:38) sits betweenNav.SponsorsandNav.Activities, pointing atConferenceRoutePaths.PartnerswithIcons.Material.Filled.Diversity3, matching the position of the routes themselves in ConferenceRoutePaths; the tier ends with the Session Selection entry (:41). Assembly(:44) returnstypeof(ConferenceUIModule).Assemblyso 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.
- Public (
- 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
AppBarComponentTypesandLayoutComponentTypesat 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
IUIModuleby this module's DependencyInjection throughAddUIModule<ConferenceUIModule>()(DependencyInjection.cs:23, implemented atMMCA.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:
EventDTOfor every length cap andQuestionModerationDefaultfor the moderation field (MMCA.ADC.Conference.Shared.Events, line 2), plusAbsoluteUrlAttributefromMMCA.Common.UI.Validation(line 4),OptionalEmailAttributefrom the same namespace, and the co-locatedIanaTimeZoneAttributeandSessionizeCodeAttribute. 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'sRequired/RequiredError/MaxLengthattributes, 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:IanaTimeZoneAttributeonTimeZone(line 54) andSessionizeCodeAttributeonSessionizeCode(line 59). The pages bridge MudBlazor to them throughModelValidation.Forrather than repeating a rule per field. The twoErrorMessageresource keys that name a missing value are hoisted to consts,NameRequiredKey(line 33) andTimeZoneRequiredKey(line 40), so the model's rule and the field's ownRequiredErroraffordance quote the same key instead of two string literals that can drift. Every otherErrorMessageis a bare resource key too, resolved at render time by the page'sDataAnnotationsModelValidatorwrapped around that page'sIStringLocalizer(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 areEventDTO.NameMaxLengthand 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 sharedEventFormFieldscomponent, 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):
NameandTimeZoneare required and non-nullable withstring.Emptydefaults; the eight optional ones are nullable.TimeZonealso 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 optionalSessionizeCodecarries[SessionizeCode](line 59) for the same reason.OrganizerContactEmailadds[OptionalEmail](line 77), the framework attribute that validates the address only when one is entered, andVenueMapUrl,SponsorshipPacketUrl, andTicketingUrlall 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, andQuestionModerationDefault. 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
EventCreateModelandEventEditModel) makes that guarantee structural instead of a convention someone has to remember. - Where it's used:
EventCreateModelandEventEditModelderive from it;EventCreateandEventDetailbind an instance to the sharedEventFormFieldscomponent (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:
EventFormModelandEventDTO(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
ToNewmeans the "what a create posts" decision is one readable object initializer instead of a paragraph inside anasync 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 anEventDTOwithId = default(line 28), converts the twoDateTime?pickers withDateOnly.FromDateTime(lines 31-32), copies the ten text fields across, and hard-codesIsPublished = false(line 41). The null-forgivingStartDate!.Valueis safe because the page runs its date guards first; the<remarks>(lines 21-24) says exactly that.- Note what
ToNewdoes not carry:QuestionModerationDefaultis on the base but is not written into the created DTO, so a new event takes whatever default the server applies. OnlyEventEditModelposts it.
- The parameterless constructor (line 14) seeds
- Why it's built this way:
Id = defaulthands 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 onEventDetail. - Where it's used: instantiated as the
_modelfield ofEventCreate(.../Pages/Event/EventCreate.razor.cs:44) and consumed by itsCreateEventAsync(.../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 byEventDetail's inline editor. It is a round trip in two methods:LoadFromfills the editor from the loaded event,ToUpdatedproduces the DTO the update posts. - Depends on:
EventFormModelandEventDTO(line 1). - Concept introduced, shadow editing through a model instead of shadow fields. The live loaded
EventDTOis 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:ToUpdatedre-sends the loadedRowVersion(line 55), the client half of the optimistic-concurrency token inWebsite/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:IsPublishedis 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 twoDateOnlyvalues back toDateTimeatTimeOnly.MinValuefor the pickers (lines 24-25), and copiesQuestionModerationDefault(line 34).ToUpdated(EventDTO source)(lines 48-71): builds a newEventDTOcarrying identity and concurrency from the source (Idline 54,RowVersionline 55,IsPublishedline 68), the edited text and dates from the model, and the editedQuestionModerationDefault(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
_modelfield ofEventDetail(.../Pages/Event/EventDetail.razor.cs:53), driven by itsStartEditing(.../Pages/Event/EventDetail.razor.cs:119) andSaveChangesAsync(.../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
EventCreateModelto the sharedEventFormFieldsblock, 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),ModelValidationplusDataAnnotationsModelValidator(line 44),ConferenceRoutePaths,ResultUiExtensions.NotifyOnFailure(line 105), andErrorMessages(line 78). Externals: Blazor ([Inject],NavigationManager,OnInitialized), MudBlazor (MudForm,BreadcrumbItem,Icons.Material.Filled.Home), theUnsavedChangesGuard,EventFormFields, andErrorSummarycomponents wired in the template (.../Pages/Event/EventCreate.razor:10, 26, 41), and theIStringLocalizer<EventCreate>the template injects asL(.../Pages/Event/EventCreate.razor:6). - Concept introduced, the partial-class code-behind create form. Every Conference page is a
.razortemplate plus a.razor.cspartial 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:- Cancel-on-disposal: a
CancellationTokenSource _cts(line 22) is read through aLifetimeTokenproperty (line 27,_cts.LifetimeToken()) and passed to the service call (line 99), then cancelled plus disposed through the standardDispose(bool)pattern (lines 127-143), so an in-flight save cannot resolve against a torn-down component. The property exists because readingTokenoff a disposed source throwsObjectDisposedException;LifetimeToken()hands a handler that resumes after disposal an already-cancelled token instead, so it exits through itsOperationCanceledExceptionpath (comment, lines 24-26;MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26). - Model-declared validation:
OnInitializedbuilds one delegate,ModelValidation.For(_model, new DataAnnotationsModelValidator(L))(line 44), and the template hands that single delegate to every field onEventFormFields(.../Pages/Event/EventCreate.razor:26). NoRequiredorMaxLengthis written in markup; the rules live onEventFormModeland theErrorMessagekeys are resolved throughL(ADR-027). - Validate-then-submit:
await _form.ValidateAsync()followed by anIsValidguard (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. TheIsSavingproperty (line 47) disables the button for the round trip and is always cleared infinally(lines 117-120). - An unsaved-changes guard:
_isDirtyis set byMarkDirty()(line 66), passed toEventFormFieldsasOnFieldChanged(.../Pages/Event/EventCreate.razor:27), and read by theUnsavedChangesGuardcomponent (.../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. - Two-tier failure handling: a failed
Resultis captured in_saveResult(line 100) and notified throughcreated.NotifyOnFailure(Toast, L)(line 105), so the wording reaches both the snackbar and, inline, the template'sErrorSummary; the comment (lines 103-104) explains why the inline copy matters, the snackbar can time out while the reader is still reading the form.OperationCanceledExceptionis swallowed as expected during disposal or an InteractiveAuto render-mode transition (lines 113-116).Website/docs-src/adr/056-blazor-render-mode-strategy.mdis 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 throughL(for exampleL["Snackbar.Created"], line 110), per ADR-011 and ADR-027.
- Cancel-on-disposal: a
- 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 singleEventCreateModel, 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, setsIsSaving, posts_model.ToNew()withAddAsync(line 99), clears_isDirty, toasts success, and navigates toConferenceRoutePaths.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 aFor-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 sameEventFormFieldsblock 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/createroute (.../Pages/Event/EventCreate.razor:1), reached fromEventList's create button; on success it hands off toEventDetail.
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>overEventDTO(line 16) and injectsIEventUIService(line 21). It usesListPageActions(lines 40, 72),ErrorMessages(line 78),ConferenceRoutePaths, and theMobileInfiniteScrollList<TItem>,DeleteConfirmation, andListNoRecordsContentcomponents fromMMCA.Common.UI. The toast service and theIsMobileflag 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:- A grid reference:
_dataGridcaptured via@refand surfaced through the overriddenGridRef(lines 24-25), which the base needs to restore rows-per-page after first render. - 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]. - The fetch delegates:
LoadServerData(lines 48-57) hands the base a lambda that callsEventService.GetPagedAsyncand an additional-filters callback that appendsName contains <search>(lines 53-57).LoadServerDataAsyncin the base owns cancellation-token resetting, page-index conversion, sort extraction, theIsLoadingandLoadFailedflags, and uniform error reporting. - 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 byName asc(line 65).[Rubric §22, Responsive & Cross-Browser]: the page renders two genuinely different layouts off the base'sIsMobileflag rather than reflowing one grid (.../Pages/Event/EventList.razor:31). - Actions:
DeleteEventAsync(lines 71-79) delegates the confirm, delete, toast, and reload sequence toListPageActions.DeleteWithConfirmationAsync, andReloadActiveLayoutAsync(lines 39-40) dispatches a refresh to whichever layout is live. The failure path is worth noting: when a fetch fails the base setsLoadFailed, and the template feeds it toListNoRecordsContentwith anOnRetryhandler wired toRetryLoadAsync(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 followsWebsite/docs-src/adr/094-client-entity-data-access.md: the page never touchesHttpClient, only the typedI*UIServiceclient, and expresses filters as operator-plus-value pairs the server model binder understands.
- A grid reference:
- Walkthrough
TitleandEntityName(lines 18-19) come from the injected localizer;Titleis 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) andNavigateToDetails(lines 84-85) both route throughConferenceRoutePaths.EventDetails(id), andNavigateToCreate(lines 81-82) opensEventCreate.- The grid declares three sortable
PropertyColumns overName,StartDate, andEndDate(.../Pages/Event/EventList.razor:79, 87, 96), withNamecarryingInitialDirection="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
/eventsorganizer route (.../Pages/Event/EventList.razor:1-2,Authorize(Roles = "Organizer")); rows openEventDetailand the create button opensEventCreate.
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),LatestLoadGuardthrough the base'sLoadGuard(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43);EventDTO,RefreshFromSessionizeResultDTO, andQuestionModerationDefault;ConferenceRoutePaths,ErrorMessages(line 99),ResultUiExtensions.NotifyOnFailure(lines 108, 173, 180, 341),DomainHelper'sParse<T>extension (line 4), and theDeleteConfirmation,UnsavedChangesGuard,EventFormFields, andErrorSummarycomponents (.../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:
- A shared base owns the boring half.
DetailPageBasesuppliesPageToken(the page-scoped cancellation token with its dispose pattern) and the edit-mode lifecycle (IsEditing,IsDirty,BeginEdit,EndEdit), so this file contains noCancellationTokenSourceand noDisposeat all. It also suppliesLoadGuard, aLatestLoadGuardthe load below uses so only the newest load may write the page. - Route id as a string: the id arrives as
[Parameter] public string Id(line 28) and is converted withId.Parse<EventIdentifierType>()(line 90), so the page compiles unchanged whichever primitive the alias maps to (ADR-048, ADR-085). - Load once per id, newest load wins:
OnParametersSetAsynccompares against_loadedIdand returns early when the id is unchanged (lines 70-80), so a re-render does not refetch. When the id does change,LoadEventAsyncstarts withLoadGuard.Begin()(line 85), which supersedes any load still in flight, and a response that is no longer current is dropped (lines 91-94). - Shadow editing through a model:
StartEditingcopies the loaded record intoEventEditModeland callsBeginEdit(lines 126-135);CancelEditingis justEndEdit(line 137). The liveEventobject 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 loadedRowVersion, on save throughEventEditModel.ToUpdatedand on publish/unpublish explicitly (lines 220-221), which is the client half of the optimistic-concurrency token described inWebsite/docs-src/adr/035-optimistic-concurrency.md.[Rubric §6, CQRS & Event-Driven]: publish and unpublish are notIsPublishedflag 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 aRefreshFromSessionizeResultDTOheld in_refreshResult(line 64) so the organizer sees what the import actually did.
- A shared base owns the boring half.
- Walkthrough
OnInitialized(lines 34-46) builds breadcrumbs and wires the validation delegate against_model(line 45), exactly asEventCreatedoes.LoadEventAsync(lines 82-124):LoadGuard.Begin()yields a token and a generation (line 85), andGetByIdAsync(id, true, token)fetches with children (line 90); a response from a superseded generation returns without touching the page (lines 91-94). ANotFoundresult clearsEventand toastsErrorMessages.NotFound(lines 96-100); any other failure goes throughNotifyOnFailure(line 108) so the server's own message reaches the user; success seeds_sessionizeCode(line 104). ThefinallyclearsIsLoadingonly 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 guardsEventCreateruns), 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, andEndEdit.PublishAsync/UnpublishAsync(lines 199-201) both delegate toSetPublishedAsync(bool)(lines 208-249), which picks the failure message up front (line 215), calls the chosen named operation with the currentRowVersion(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 showsActionErrorMessage(toggled, failureMessage)(line 227): a refusal the API stated, such as a stale row version answering 412 withConcurrency.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: anErrorType.Unexpectedresult (a 500, a transport failure, a timeout) shows the genericfallbackrather than raw diagnostic text, anything else showsresult.LocalizedErrorMessage(L)and falls back only when that is null (ADR-027 Decision 9 carve-out, per its doc comment).ErrorTypeis 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 sameValidation.SessionizeCodeFormatmessage the Edit form shows, sinceSessionizeCodeAttributeapplies the sameSessionizeCodeFormatrule.RefreshFromSessionizeAsync(lines 271-322) first rejects a malformed code before any call, toastingSessionizeCodeError(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 throughActionErrorMessage(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 currentRowVersioninstead of hitting a 409 until a reload, while the typed_sessionizeCodeis 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 fromEventListrows and fromEventCreate's success redirect. Its "view feedback" button opensOrganizerEventFeedback(.../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 fallbackTimeProviderfor 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 syntaxextension(IServiceCollection services)(:21) to hangAddConferenceUI(:27) offIServiceCollection, the same idiom every module'sDependencyInjectionuses. The convention half is delegated toAddUIModule<ConferenceUIModule>()(:31), which does two things in one call (MMCA.Common.UI/DependencyInjection.cs:323-329): a Scrutor scan of this assembly registering everyIEntityService<,>implementationAsImplementedInterfaces().WithScopedLifetime(), and the singleton registration of the descriptor itself. RegisteringAsImplementedInterfacesis 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 implementIEntityService<,>:- 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 throughTimeProvider, no UI head registers one (the heads never callAddInfrastructure, where the framework's registration lives), and the Engagement UI's ownTryAddis skipped when that module is disabled.TryAddmeans 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 theTimeProvider, both stateless, so one instance per container is correct. The method returnsservicesfor chaining (:76).- four child-entity managers for the join relationships (
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:IPublicLinkBuildercomes from the framework, whereAddUISharedTryAdd-registers the browser-origin builder (MMCA.Common.UI/DependencyInjection.cs:194-196), and the MAUI head overrides it afterwards withAddCommonMauiPublicLinkBuilder()(MMCA.Common.UI.Maui/DependencyInjection.cs:140, called atMMCA.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 flatstring-to-stringmap thatDataGridListPageBase<TDto>persists per page. Two static methods,SaveandRestore, and no state of its own (PublicSessionListFilterState.cs:12). - Depends on: only
System.Globalization(:1) and theRoomIdentifierType/EventIdentifierTypealiases. No component base, no injected service, no fetch. The class doc records the placement rule (:5-10): it is kept beside the page, likePublicScheduleRoomOptions, 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.
- A sentinel that distinguishes "cleared" from "never set".
Savewrites"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.Restorereads 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). - Persistence itself is audience-scoped.
persistEventIdis 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 theroomIddoc 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]. - A restore never destroys a value it cannot read. Every branch in
Restoreis guarded by aTryGetValueplus aTryParse, 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].
- A sentinel that distinguishes "cleared" from "never set".
- Walkthrough
Save(:27-44): writessearchverbatim (:35), the My Schedule toggle as an invariantboolstring (:36), the room id as an invariant number or empty (:37), and the event id only whenpersistEventIdis true, with the"all"sentinel for an explicit clear (:39-43).Restore(:57-91): parseseventIdfirst, accepting"all"case-insensitively as an explicit clear and any invariant integer as a pick, settingeventFilterResolvedin both cases so the page knows not to recompute the default (:64-76); thenroomId(:78-82), thenmySchedulecompared case-insensitively against"true"(:84-87);searchfalls 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
Restoredeliberately 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/RestoreFilterspair 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, passingpersistEventId: _isPrivileged && _eventFilterResolved) andRestoreFilters(PublicSessionList.razor.cs:84-87).
VenueMapLinks
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.UriandSystem.StringComparer. - Concept introduced, an allow-list of embeddable hosts.
IsGoogleMapsEmbed(lines 159-177) only returns true for anhttpsURL whose host is in the fixedEmbedHostsarray (line 152:www.google.com,google.com,maps.google.com) and whose path is either/maps/embedor/mapswith anoutput=embedquery parameter. The in-code comment (lines 150-151) ties this directly to the page's CSP: theframe-srcdirective (BlazorCsp:FrameSourcesin the UI.Web host'sappsettings.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 nothttpsor not onEmbedHosts, then accepts an/maps/embedpath outright or an/mapspath whose query string carriesoutput=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 returnsnullwhen 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 callsIsGoogleMapsEmbedto decide whether to show the expand/collapse map toggle at all, andBuildSearchUrlto 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 ofConferenceReadAudience.PrivilegedRoles(class doc,:6-9). - Depends on:
Microsoft.AspNetCore.Components.AuthorizationforAuthenticationState(:1) andConferenceReadAudiencefromMMCA.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):- No cascaded state means the public audience. A
nulltask returnsfalsebefore any await (:20-23). - 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:28runs. The callers' own comments explain why that ordering matters for them (seePublicEventListandPublicEventDetail). - 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 returnsfalse(:34) and the public page still renders, while a cancellation propagates to the caller's own teardown handling. TheCA1031suppression carries its reason inline (:30).[Rubric §26, Front-End Security](fail closed to the narrower audience) and[Rubric §11, Security].
- No cascaded state means the public audience. A
- Walkthrough:
IsPrivilegedReaderAsync(Task<AuthenticationState>?)(:18-36): null guard (:20-23),awaitthe task (:27), thenPrivilegedRoles.Any(authState.User.IsInRole)(:28); the filteredcatch(:31-35) returnsfalse. - 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 statichelper 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) andPublicSpeakerList(PublicSpeakerList.razor.cs:97), each assigning_isPrivilegedfrom it.PublicReadAudienceTestspins 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
PublicEventListredirects them here. - Depends on:
IEventUIService(:20),EventDTO,IToastService(:22),PublicReadAudience(:67, which readsConferenceReadAudienceon the page's behalf),ConferenceRoutePaths(:77,:172,:174,:176),DomainHelper'sId.Parse<T>extension for the route string (:119, defined atMMCA.Common/Source/Core/MMCA.Common.Shared/Extensions/DomainHelper.cs:30),VenueMapLinks(called from the markup, not the code-behind, atPublicEventDetail.razor:73,86) to decide the venue-map rendering, and four device-capability abstractions:IClipboardService,IMapNavigationService,IGeolocationService,IGeocodingService(:23-26), whoseGeoPointresults supply theDistanceKmToused at:235. It also readsIConfigurationfor the host-wide support contacts (:27,:64-65), and theLifetimeToken()extension onCancellationTokenSourcefromMMCA.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.
- Load once per id, and let the newest load win. The route value arrives as
[Parameter] string Id(:29), andOnParametersSetAsynccompares it against_loadedId(:96-105) so a re-render does not refetch. On top of that, eachLoadEventAsynccall 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):_loadedIdis stamped synchronously before the await, so two rapid route changes would otherwise let the later-completing fetch paint the wrong event. Thefinallyis 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_distanceMilesto 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]. - The breadcrumb trail depends on the audience, and the audience is awaited first.
OnInitializedAsyncresolves privileged status throughPublicReadAudience.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). - 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-eventOrganizerContactEmailwins over the host-wideSupport: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 throughLifetimeToken()instead of_cts.Token, because readingTokenoff a disposed source throwsObjectDisposedException; disposal cancels before it disposes, so a handler that resumes after the reader navigated away receives an already-cancelled token and stops through itsOperationCanceledExceptionpath.
- Load once per id, and let the newest load win. The route value arrives as
- 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 clearEventso 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_showMapto 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).OperationCanceledExceptionis swallowed as expected teardown (:151-154) and a broadcatchtoasts the same load-failure key (:155-158)._showMap(:51-52) andToggleMap(:178) back a collapse/expand toggle for the venue map: the markup only offers it whenVenueMapLinks.IsGoogleMapsEmbedrecognizes the storedVenueMapUrl(PublicEventDetail.razor:73), and only actually renders the iframe when_showMapis true (PublicEventDetail.razor:90-99), so third-party map content does not load until the reader asks for it.VenueMapLinks.BuildSearchUrlalways renders the plain "open in Google Maps" link beside the toggle as a fallback (PublicEventDetail.razor:86-89).CopyWifiAsync(:182-193): copiesEvent.WiFiInfothrough the clipboard abstraction underLifetimeToken(: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 underLifetimeToken(:203); a false return raises a warning.TryComputeDistanceAsync(:215-237): takes the load generation and aCancellationTokenfrom the caller rather than reading_ctsitself, 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 callsStateHasChanged()(: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 theCancellationTokenSourceat:33, plus disposing theLatestLoadGuarditself (: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-79in-code comment). - Where it's used: the
/conference/events/{Id}route (PublicEventDetail.razor:1), reached fromPublicEventListeither as a grid row (privileged) or as areplace: trueredirect (everyone else); its markup also renders theQrCodeButtonfor 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/eventsroute, 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 overEventDTO(:30);IEventUIServiceandIEventLookupService(:34-35), an injectedTimeProvider(:37),PublicReadAudience(:72),CurrentEventSelector(:91),EventInfo(:81),ConferenceRoutePaths(:103,:147),MobileInfiniteScrollList<TItem>(:46, rendered atPublicEventList.razor:23),ListPageActions(:121), andResultin the mobile fetch signature (:137). Server-side the audience split is enforced byPublishedEventSpecification. - 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.
- Resolve the audience before deciding anything.
OnInitializedAsyncawaitsPublicReadAudience.IsPrivilegedReaderAsyncover the cascadingTask<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). - 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). replace: trueon 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 (NavigateToat:103).[Rubric §25, Navigation & Information Architecture](assesses that the Back button keeps working). The redirect target is computed withCurrentEventSelector.SelectCurrentOrNext, passing the four accessors explicitly because the lookup returnsEventInforather thanEventDTO, which the comment calls out (:87-96). "Now" comes from the injectedTimeProvider(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 leaveseventsnull (:80-85), which falls through to the empty state rather than to a broken redirect.
- Resolve the audience before deciding anything.
- 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 whenLoadFailedis 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 throughListPageActions.ReloadActiveLayoutAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ListPageActions.cs:26).LoadServerData(:124-134) passesshowCancelSnackbar: false(:134), so a superseded fetch (the reader typed another character) is silent rather than raising a toast, and turns the search string into aName containsserver filter (:131-132).FetchMobilePage(:137-144) is the parallel infinite-scroll path, hard-sorted byNameascending;OnMobileCardClick(:146-147) routes toPublicEventDetail.
- 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/eventsroute (PublicEventList.razor:1). Every non-privileged arrival leaves immediately forPublicEventDetail; 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 throughPublishedEventSpecification, 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, anIcon(a MudBlazor icon path constant), and aTopicsstring listing the track's subject areas (:81). - Depends on: no first-party types. The
Iconvalues are MudBlazorIcons.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.Tracksproperty, anIReadOnlyList<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. EveryIconis a MudBlazorIcons.Material.Filled.*constant picked per track (School,Psychology,AutoAwesome,Code,Devices,Security,SportsEsports,Groups). Storing the icon as astring(rather than aRenderFragmentor 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 inADCHome.razor(:233-250): oneMudItem/MudCardper entry, keyed bytrack.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
IsLiveboolean anywhere on the page:UpdateCountdown()recomputes_phasefromDateTime.UtcNowagainst 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 < _startUtcgivesUpcoming,now < _endUtcgivesLive, anything later givesEnded.ArmPhaseTimerForEventEnd()reads it as its guard, returning immediately unless the phase isLive(: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, aPageLoadingStatewhile_isLoading), thenUpcomingrenders theHomeCountdownchild (ADCHome.razor:36-41),Liverenders the "event live" chip plus a button to/happening-now(ADCHome.razor:42-56), andEndedrenders 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
_phasefield 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 optionalBadge(a recognition chip rendered above the title, e.g. "Microsoft MVP"), theTalkTitle, an optionalPhotoFileName, andBioParagraphsas anIReadOnlyList<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
EventDTOandSponsorDTOthroughIEventUIServiceandISponsorUIService,ADCHome.razor.cs:52-55and: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.Keynoteproperty, 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 ownBadgeproperty rather than folding it intoTitle(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, sinceBadgeis nullable.BioParagraphsis 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 usablesrcis composed from the head-specificImageBasePathparameter throughKeynoteImageSrc, which returns$"{ImageBasePath}/speakers/{fileName}"ornullwhen no file name is supplied (ADCHome.razor.cs:56-57). That split exists because a head could package its assets elsewhere, and thenullcase 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 throughImageBasePaththe 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 addsloading="lazy"with the reason inline (ADCHome.razor:182-184): the keynote sits below the hero, so it is never the largest-contentful-paint element, andMudImagesplats unmatched attributes onto the renderedimg, 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 byADCHome.KeynoteImageSrc(ADCHome.razor.cs:56-57) and rendered in the keynote section ofADCHome.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 workshopTitle, aSubtitle, thePresentername, anIcon, and aDetailsUrlfor the workshop's public detail page (:103-109). Two instances make up the whole workshops section. - Depends on: no first-party types. The
Iconvalues are MudBlazorIcons.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.resxpair. The bridge isKey, documented on the record itself: the markup composesWorkshops.{Key}.AudienceandWorkshops.{Key}.Descriptionat 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 inADCHome.es.resx). One consequence worth noticing: adding a workshop means adding four resource entries per locale, and a typo inKeyfails at runtime as a missing resource rather than at compile time. - Walkthrough: a six-property positional record. The catalogue is the get-only
ADCHomeContent.Workshopsproperty (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 theSmartToyicon, linking tohttps://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 thePrecisionManufacturingicon, linking tohttps://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 anS1075suppression 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 byworkshop.Key(:123), each card showing the icon in a circle (:128), the title (:130), the localizedWorkshops.PresenterLabelformatted 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 thePreConferenceTicketingUrlconstant (ADCHome.razor.cs:29-30) and always renders (ADCHome.razor:147-159), while the hero's conference-day button readsTicketingUrloff the featured event and hides itself when absent.DetailsUrlis 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 withTarget="_blank"together withrel="noopener noreferrer"(ADCHome.razor:155). - Where it's used:
ADCHomeContent.Workshops(:64) and the workshops section ofADCHome.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'sIcons.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 betweenOnInitializedAsyncandDispose. [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: theinternalaccessibility. The three records and the holder are allinternal 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): anIReadOnlyList<ConferenceTrackInfo>collection expression with the eight conference tracks.Workshops(:64-78): anIReadOnlyList<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 theS1075suppression pair for the twoDetailsUrladdresses (: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. ReturningIReadOnlyList<T>rather than an array keeps the markup's@foreachhonest about the collection being read-only. - Where it's used: ADCHome only:
KeynoteImageSrcreadsADCHomeContent.Keynote.PhotoFileName(ADCHome.razor.cs:56-57), and the markup reads all three (ADCHome.razor:121for the workshop grid,:186,:193,:195,:202,:203for the keynote card,:234for 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
PublicSessionListhas already loaded,IndexNamesbuilds the id-to-name map the list renders with, andScopereturns the ordered room options for the active event filter plus the room filter that survives that scoping (PublicScheduleRoomOptions.cs:20,:37). - Depends on:
RoomDTOandEventDTOfromMMCA.ADC.Conference.Shared.Events(:1), plus theRoomIdentifierTypeandEventIdentifierTypealiases. 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.
- 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). - A selection that cannot go stale. The last block of
Scope(:53-55) re-validatesselectedRoomIdagainst the freshly scoped list and returnsnullwhen 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 byPublicSessionListFilterState. 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].
- Derive, do not fetch. The class doc states the rule (
- Walkthrough
IndexNames(:18-27): a nested loop over every loaded event'sRooms, adding eachId-to-Namepair withTryAdd(: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_roomNamesdictionary, 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-nulleventIdtakes that event'sRooms(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, thenOrderBy(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:
IndexNamesis called once per events load (PublicSessionList.razor.cs:136);Scopeis called by that page'sRefreshRoomOptions(:151-152), from the initial load (:147) and from every event-filter change (:216). The options it returns are passed down toPublicSessionListFilterBar'sRoomsparameter and the surviving id to itsSelectedRoomId.
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
ImageBasePathtoday (:9-16).Depends on: IEventUIService (
:51-52), ISponsorUIService (:54-55), and IPartnerUIService (:57-58) for the three reads, an injectedTimeProvider(: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 fromMMCA.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 asL(ADCHome.razor:1),System.Threading.Timer,TimeZoneInfo, MudBlazor, the BlazorRendererInfoAPI, and theLifetimeToken()extension onCancellationTokenSource(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.razorfile 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.
OnInitializedAsyncchecksRendererInfo.IsInteractiveand, 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-loginNavigateTo("/"), 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
HomeCountdownchild, which owns its own timer, so this page arms only a single one-shotTimerfor 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 unlessRendererInfo.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
ImageBasePathparameter (:69). [Rubric §27, Internationalization]: user-facing chrome resolves throughL[...], while the strings that must not be translated carry explicit// i18n: allowmarkers 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 usingvar(--mmca-primary)(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Ui/BrandColorTokenTests.cs:12-17, embedded viaMMCA.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 injectedTimeProviderrather thanDateTime.UtcNow, so the event choice and the phase share one clock that a test host can replace.- Skip the fetch during prerender.
Walkthrough, in lifecycle order:
- Constants and state (
:24-97): thePreConferenceTicketingUrlconstant placed first because SA1203 requires constants before fields, with its doc block andS1075suppression (:24-36), thenMaxRoster(:38-42), a fixed upper bound of200on 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) andFallbackStartDate(:46-49) follow. Then the three injected UI services (:51-58), the injectedTimeProvider(:60-61), theImageBasePathparameter (:63-69) and the derivedKeynoteImageSrc(:75-76), aCancellationTokenSource(:78) with aLifetimeTokenaccessor over it (:80-83), the one-shot_phaseTimer, the computed_startUtc/_endUtc,_phase, the nullable_event(EventDTO?,:89), the grouped_sponsorTiersoverSponsorDTO(:91-92) and_partnerGroupsoverPartnerDTO(:94-95),_isLoading(startingtrue), and a_disposedguard the timer callback checks (:97).LifetimeTokenexists because readingTokenoff a disposed source throwsObjectDisposedException;Disposecancels before it disposes, so a load that resumes after the reader navigated away gets an already-cancelled token and stops through itsOperationCanceledExceptionpath (comment:80-82). - Derived display properties (
:106-113):EventName,EventDescription,VenueAddress, andMapSearchUrlare each_event?.X ?? <fallback>, so the page is fully renderable before and without a successful fetch.MapSearchUrlbuilds a Google Maps search URL withUri.EscapeDataStringover 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 usesIndexOf/LastIndexOfwithStringComparison.Ordinaland 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 awaitsLoadEventAsync(),LoadSponsorsAsync(), thenLoadPartnersAsync()in sequence (both the sponsor and partner calls need the featured event id) and arms the phase timer.LoadEventAsync(:199-227): callsEventService.GetAllAsync(cancellationToken: LifetimeToken)(:204) and, on a successful Result, picks the event withCurrentEventSelector.SelectCurrentOrNext(...)passing three accessor lambdas plusTimeProvider.GetUtcNow().UtcDateTime(:210-215). The candidates areevents.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 aFirstOrDefault: 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 unsuccessfulResulthandled by theif, which simply leaves the fallback defaults in place; the only exception caught isOperationCanceledException, meaning the component was disposed mid-load (:218-221). Thefinallyblock always clears_isLoadingand 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 callsSponsorService.GetPagedAsyncfiltered to the featured event,pageNumber: 1,pageSize: MaxRoster, sorted bySortascending, underLifetimeToken(:248-254), and on a successful page reducessponsors.Itemsto a tier-grouped list: tiers ascending in package order (Platinum first), each tier ordered bySortthenName(: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 wayPublicSponsorListdoes, so a second published edition's sponsors never bleed onto this page (:232-238). Its one catch arm isOperationCanceledException(:271-274) and, on any unsuccessful page,_sponsorTierssimply stays at its previous value, which is a supported render state rather than an error state.LoadPartnersAsync(:277-320): the same shape asLoadSponsorsAsyncone method up: returns immediately with no featured event (:287-290), otherwise callsPartnerService.GetPagedAsyncthe same way, again underLifetimeToken(:294-300), and reduces a successful page'spartners.Itemsto the type-grouped list, Community first (:302-314). Its one catch arm mirrors the sponsor load (:316-319) and an unsuccessful page leaves_partnerGroupsempty, 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 bothGetPagedAsynccalls pass, formatting the id withCultureInfo.InvariantCultureso the filter value is stable regardless of the host's culture.UpdateCountdown(:328-351): converts the event's local start and end into UTC usingTimeZoneInfo.FindSystemTimeZoneById(timeZoneId)with"America/New_York"as the default (:334,:340), callingCurrentEventSelector.ToUtcrather thanConvertTimeToUtcbecause 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).nowis read fromTimeProvider(:344) and_phaseis then assigned from the switch described under EventPhase (:345-350).- Phase timing:
OnCountdownElapsedAsync(:160-165) is theEventCallbacktheHomeCountdownchild raises at zero (HomeCountdown.razor:82), which recomputes the phase, re-arms, and callsInvokeAsync(StateHasChanged).ArmPhaseTimerForEventEnd(:171-186) returns unless the phase isLiveand the remaining time, measured againstTimeProvider.GetUtcNow()(:178), is positive, then disposes any prior timer and schedules one callback atuntilEndwithTimeout.InfiniteTimeSpanas the period, meaning fire once and never repeat (:185).OnEventEnded(:188-197) checks_disposedbefore re-rendering. FormatEventDate(:353-360): formats the date with a pattern read from a resource (L["Hero.DateFormat"]) againstCultureInfo.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.
- Constants and state (
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 pointsComponentTypestraight 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'sLocation.OpenInMapsresource 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.
UpdateCountdownstarts the event atEventStartTime = 08:00local (: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;:333here). 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 date2026-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
SessionAssetsPanelas 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:
SessionAssetsPanelas its only host (line 43,_fileInputId), and the Blazor primitivesEventCallback<T>andInputFileChangeEventArgs/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) checksCanSubmitLink(lines 50-51) and normalizes the address throughTryNormalizeWebUrl(lines 100-125) itself, and only ever raises one of two outcomes:OnAddLinkwith the trimmed title and the normalized absolute URL, orOnInvalidLinkAddresswith 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, andClearDraft(lines 54-58) is the only way the host reaches back in, called only once a write has actually succeeded (the host'sAddLinkAsynccalls_composer?.ClearDraft()afterMutateAsyncreports 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 withGuid.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-checksCanSubmitLink, normalizes throughTryNormalizeWebUrl, echoes the normalized URL back into_newLinkUrl(line 75, so the speaker sees the address that will actually be stored) before raisingOnAddLinkwith the trimmed title and normalized URL.OnFileSelectedAsync(lines 80-88) forwards the pickedIBrowserFilethroughOnFileSelected; it does no size or format check itself, that stays the host's job againstSessionAssetLimits.TryNormalizeWebUrl(lines 100-125) accepts a value that already parses as an absolutehttp/httpsURL as-is (Ordinalscheme 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 withhttps://and re-parses (lines 111-122), so a speaker pastinggithub.com/me/repois 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
MutateAsyncgateway focused on the write itself, and gives the input surface aClearDraftthe 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:
TryNormalizeWebUrlduplicates the method of the same name and shape that used to live onSessionAssetsPanelitself (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.
Trackrecords 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
ServerDatacall 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_newestis 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: callTrackbefore 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 returnsNewestResultAsync(load)(:27), so the assignment happens synchronously on the caller's turn.NewestResultAsync(:30-40): thewhile (_newest is { } newest && !ReferenceEquals(newest, load))loop (:33) advancesloadanddata(:35-36) until nothing newer exists, then returnsdata(: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
_gridLoadsand applied inPublicSessionList.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
Taskand delegates supplied by the caller (scopeUnresolved,reissue,onReissued,:36); state is_lookupand_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_lookupand callingonReissuedso 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_lookuphas not completed or anyEnsureAsynccall 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_resolutionsInFlightin afinally(:65-67), soPendingcannot 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
_eventScopebyPublicSessionList(PublicSessionList.razor.cs:43);Startis called inOnInitializedAsync(:128),PendingbacksEventsLookupPending(:163), andEnsureAsyncbacksEnsureEventScopeAsync(: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:
SessionAssetDTOandSessionAssetKind(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/SessionAssets/SessionAssetDTO.cs,.../SessionAssets/SessionAssetKind.cs),SessionAssetLimits.MaxFileBytes(line 28). Externals: MudBlazorIcons.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
Kindand 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 isMaxFileMegabytes(line 28), derived fromSessionAssetLimits.MaxFileBytesby 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:FormatSizereturns 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 forSessionAssetKind.Link(lines 43-46), and otherwise switches onExtensionOf(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 throughRound(lines 83-84), which formats with"0.#"underCultureInfo.CurrentCultureso 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'sUrlwhen 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:
IconForrenders the material icon onSessionAssetsPanelandSessionAssetsDownloadList;FormatSizebacksDescribeSizeon both of those components (.../SessionAssets/SessionAssetsPanel.razor.cs:486-490,.../SessionAssets/SessionAssetsDownloadList.razor.cs:157-161);MaxFileMegabytesis quoted inSessionAssetsPanel'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:
EventDTOandRoomDTO(:2);IScreenshotService,IShareService, andIToastService(:18-20). ItsRoomsoption set is produced byPublicScheduleRoomOptions. - Concept introduced, the container/presentational split. The bar owns no filter state. Every value arrives as a
[Parameter]and every change leaves through a matchingEventCallback:IsPrivileged(:26),Events(:29),SelectedEventId/SelectedEventIdChanged(:32,:35),SearchString/SearchStringChanged(:38,:41),Rooms(:47),SelectedRoomId/SelectedRoomIdChanged(:50,:53), andShowMyScheduleOnly/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 isIsPrivileged, not "is organizer", because the privileged read audience is a role set (ConferenceReadAudience) rather than one role.Roomsdocuments 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 isIStringLocalizer<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-inEventslist, returning empty when nothing is selected.ShareScheduleAsync()(:64-85): captures the current view to a file throughIScreenshotServiceand hands it toIShareServiceasimage/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 atrythat swallowsOperationCanceledExceptionas expected disposal or an InteractiveAuto transition (:75-78) and turns any other exception into the same warning toast (:79-84): it runs from anOnClickcallback, 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'sOnEventFilterChanged,OnSearchChanged,OnRoomFilterChangedandOnMyScheduleToggledhandlers (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 toPublicSessionListView, which patches it in place as stars toggle (docPublicSessionBookmarkState.cs:27-30). - Depends on: the cascaded
Task<AuthenticationState>?and the optionalISessionBookmarkUIServiceas primary-constructor parameters (:14-16);ClaimsPrincipalExtensions'sGetUserId(:79);Resultfor 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) runsLoadAsyncand stores the task, because the page must start it eagerly: the grid's firstServerDatacall 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 throughGetUserId(:79, accepting both thesubclaim and theNameIdentifierform the bearer handler maps it to), setsCurrentUserIdandIsAuthenticated(:81-82), and loads the bookmarked ids (:86). The failure is stored in_loadFailure(:87) and a success replacesBookmarkedSessions(:88-91).OperationCanceledExceptionis swallowed as expected disposal or an InteractiveAuto transition (:94-97).EnsureAsync(showMyScheduleOnly)(:51-65) awaits the load task and returnsnullunless 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 byFetchSessionsAsyncthroughEnsureAsync(:245), and read forIsAuthenticatedandBookmarkedSessionswhen 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 theLifetimeTokenextension on theCancellationTokenSource(line 30). Externals: Blazor[Inject]/[Parameter],System.Threading.CancellationTokenSource. - Concept introduced, none new. This component is the minimal half of the pattern
SessionAssetsPanelteaches in full: load-once-per-session, swallowOperationCanceledExceptionduring disposal, dispose the_ctsexactly 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 awaitsSessionAssetService.GetBySessionAsync(SessionId, cancellationToken: LifetimeToken)(line 47), whereLifetimeToken(line 30, comment lines 27-29) is_cts.LifetimeToken(): readingTokenoff a disposed source throwsObjectDisposedException, and becauseDisposecancels before it disposes, a load that resumes after the user navigated away gets an already-cancelled token and stops through itsOperationCanceledExceptioncatch (line 50) instead. It leavesbypassCacheat itsfalsedefault 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 theResultwithTryGetValue, falling back to an empty list on failure (line 48) rather than surfacing an error state.DescribeSize(long sizeBytes)(lines 56-60) forwards toSessionAssetDisplay.FormatSizeand resolves the returned unit key through the component's own localizer (L[unitKey, amount].Value, line 59).- The
_disposed-guardedDispose(bool)/Disposepair (lines 62-84) cancels and disposes_ctsexactly once, identical in shape to every other component in this group. - The per-instance
_headingId(line 31) exists for the same reason as onSessionAssetsPanel: 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
CanManageparameter toggles it into the same read-only renderingSessionAssetsDownloadListprovides. Its add-link and pick-a-file inputs live in a childSessionAssetComposer(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),SessionAssetDisplayforMaxFileMegabytesandFormatSize,SessionAssetLimits.MaxAssetsPerSession(line 69),SessionAssetLinkRequest(lines 115-121) andSessionAssetUpdateRequest(lines 186-190), theLifetimeTokenextension on_cts(line 49 property), andResult(line 214). Externals: Blazor ([Inject],[Parameter],IBrowserFile), MudBlazor (Severity), and aDeleteConfirmationcomponent (line 66) fromMMCA.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 theTask<Result<T>>-adapting overload at lines 244-245): set busy, run the operation, report the failure throughDescribeFailureor the success through a localized toast state, reload the list, clear busy in afinally. A caller supplies only the operation delegate, the success resource key, and an optional post-success callback (AddLinkAsyncclears the composer's draft,SaveTitleAsynccallsCancelEditing).[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.413is the one transport-level status in the table, because Kestrel refuses an over-cap request body before the friendlySessionAsset.InvalidUploaddomain 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- rather than surfacing the server's raw text.
[Rubric #27, Internationalization]assesses whether every user-facing string is externalized: every key inFailureResourceKeysand every success key passed intoMutateAsyncis a resource lookup throughL[...], never an inline sentence.
- rather than surfacing the server's raw text.
- Walkthrough
IsAtCap(line 69) is the one gating boolean the panel itself computes; the markup passes it into the composer asDisabledalongside the busy flag.LifetimeToken(line 49, comment lines 46-48) is_cts.LifetimeToken(): readingTokenoff a disposed source throwsObjectDisposedException, and becauseDisposecancels before it disposes, a load or handler that resumes after the user navigated away gets a cancelled token and stops through itsOperationCanceledExceptionpath. Every service call below (load, add link, upload, rename, delete) passes it instead of_cts.Token.OnParametersSetAsync/LoadAsync(lines 71-105) mirrorSessionAssetsDownloadList's reload guard, but differ in two ways. The fetch passesbypassCache: 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 onlyIsAtCap, builds aSessionAssetLinkRequestwithSortOrder = _assets.Count(line 120), and clears the composer's draft (_composer?.ClearDraft()) only onceMutateAsyncreports 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 inFailureResourceKeys.OnFileSelectedAsync(lines 139-162) takes the composer's forwardedIBrowserFiledirectly, rejects an over-cap file client-side againstSessionAssetLimits.MaxFileBytes(line 151) before ever posting, with a title defaulted byDefaultTitleFor(lines 312-324), which strips the extension and falls back to the raw file name only if the stripped result is blank, capped atSessionAssetDTO.TitleMaxLength.StartEditing/CancelEditing(lines 164-175) toggle the inline rename state;SaveTitleAsync(lines 177-196) echoes the asset's ownSortOrderback unchanged (in-code comment: omitting it would silently move the material to the top) and posts throughUpdateAsyncwith the asset'sRowVersionfor optimistic concurrency.DeleteAsync(lines 198-208) confirms through the injectedDeleteConfirmationcomponent rather than a native browserconfirm()(in-code comment: unstyled, untranslated and unreachable from the MAUI head), then deletes throughMutateAsync.- 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
FailureResourceKeysis what lets a new server error code be wired to a message in one place instead of four. Extracting the add-link fields intoSessionAssetComposerkeeps 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
CanManagedistinguishing an editable view from a read-only preview;SessionAssetDisplay.IconForandFormatSizesupply the icon and size text for every rendered asset. - Caveats / not-in-source:
CanManage="false"renders the same read-only shapeSessionAssetsDownloadListprovides 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 optionalISessionBookmarkUIService(:60),SpeakerInfo(:69),Resultin the mobile fetch delegate's signature (:78),ConferenceRoutePaths(:185),MobileInfiniteScrollList<TItem>(:80, rendered atPublicSessionListView.razor:6),ListPageActions(:94),IToastServiceandIHapticFeedbackService(:28-29), plus MudBlazor'sMudDataGrid<T>/GridState<T>/GridData<T>andNavigationManager. - 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 itsServerDataandFetchPageResultdelegates (:75,:78), its paging parameters (:42-48), the speaker and room lookups (:69,:72), and the sharedBookmarkedSessionsdictionary (:66). The subtlety is that the view mutates that dictionary in place when a star is toggled (AddBookmarkAsyncwritesBookmarkedSessions[sessionId] = bookmark.Idat:170,RemoveBookmarkAsyncremoves 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 capturedGridreference (:90) andReloadAsync()(:93-94) so the page'sDataGridListPageBase<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, andSessionStatuses.IsEligibleaccepts 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-sessionHashSetwhoseAdddoubles as the guard test (:112, field at:83), firesHaptics.Click()(:116, a no-op off native heads), then removes or adds, catchingOperationCanceledExceptionas expected teardown or an InteractiveAuto transition (:129-132) and clearing the guard entry in thefinally(:135). The per-session guard is a fixed defect worth reading: the comment at:81-82records 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 asremoved.IsFailure && !removed.IsNotFound()overResultUiExtensions.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'sSessionSpeakersto display names through the passed-in lookup, skipping ids the lookup does not know;OnMobileCardClick(:184-185) routes toPublicSessionDetailthroughConferenceRoutePaths.
- 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 atPublicSessionList.razor:31) and reads_view?.Gridfor itsGridRefoverride (: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
PublicSessionDetailso 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 throughIServiceProvider(:29);IToastServiceandIHapticFeedbackService(:30-31); theSessionIdparameter typed asSessionIdentifierType(:38);UserIdentifierTypeandUserSessionBookmarkIdentifierTypefor its local state (_currentUserIdand_bookmarkId,:48-50); andClaimsPrincipalExtensions'sGetUserId, read off the cascadingAuthenticationStateTask(: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), soBookmarkServiceis resolved withServiceProvider.GetService<T>()inOnInitialized(: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 letPublicSessionDetaildrop 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(): readingTokenoff a disposed source throwsObjectDisposedException, and becauseDisposecancels before it disposes, a read or toggle that resumes after the reader navigated away gets a cancelled token and ends through itsOperationCanceledExceptioncatch. 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 whenSessionIdactually changed (:64,:69-70).LoadBookmarkStateAsync(:73-105): awaits the cascading authentication state, reads the identifier throughGetUserId(:86, accepting both thesubclaim and theNameIdentifierform 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_isTogglingBookmarkre-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 catchOperationCanceledExceptionas expected component disposal or an InteractiveAuto transition, and clear the re-entry guard in thefinally(:153-156).- Disposal (
:159-184) is the standard cancel-on-disposal pattern over the component's ownCancellationTokenSource(: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
PublicSessionDetailfor its single session's star.PublicSessionListViewkeeps its own inline bookmark implementation (a per-sessionHashSetre-entry guard over a sharedBookmarkedSessionsdictionary, 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),IEventLookupServiceand an injected BCLTimeProviderfor the live-window check (:34-35); optionallyISessionLiveUIService(field:40, resolved at:58);ITextToSpeechService(:33);SessionDTO,RoomDTO,IToastService(:31),CurrentEventSelector.GetLiveWindowUtc(:270-273),ConferenceRoutePaths(:62,:278), andDomainHelper'sId.Parse<T>(:130). The bookmark toggle itself, includingISessionBookmarkUIServiceand theClaimsPrincipalExtensions-based user lookup this page used to hold, now lives entirely inSessionBookmarkButton, 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-ownedISessionLiveUIServiceis resolved withServiceProvider.GetService<T>()inOnInitializedand left null when that module is disabled (field:40, resolved at:58). The markup only renders the Live button whenSessionLiveresolved.[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-runsOnParametersSetAsync, 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 aLatestLoadGuard(:75), which pairs the generation check with a per-loadCancellationTokenSource:LoadSessionAsynccalls_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), andDispose(bool)disposes the guard alongside the page's own_cts(:323). The page's own token is read throughLifetimeToken(:49, comment:45-48), which is_cts.LifetimeToken(): readingTokenoff a disposed source throws, and sinceDisposecancels 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 asIsStatusIneligible(:88-91), with the comment pointing atSessionStatuses.IsEligibleas the same rule the server applies. - Walkthrough
LoadSessionAsync(:113-184): right afterBegin()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), clearsSessionand 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 finallyResolveLiveWindowAsync(:162). The remaining broadcatch (Exception)(:168) no longer guards a bookmark-state read (that moved intoSessionBookmarkButton); its comment records that every resolve step above answers with aResult, 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) andResolveCategoryNamesAsync(: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/_roomNameon a miss:LoadSessionAsyncalready reset them up front.ResolveLiveWindowAsync(:259-276): decides whether the session is inside its event's live window. It looks the event up throughIEventLookupService(:262), re-checks the generation, and returns with_isLiveNowfalse when the lookup failed or the event is unknown (:263-268). OtherwiseCurrentEventSelector.GetLiveWindowUtcturns 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 againstTimeProvider.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;SpeakAsynccompletes when playback finishes orStopAsynccancels it, and thefinallyclears_isSpeakingeither 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 theCancellationTokenSourcethis page still owns directly (:44; it has not been moved ontoDetailPageBasethe waySponsorDetailhas), now also disposing theLatestLoadGuard(: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 fromPublicSessionListViewrows and cards and fromPublicSpeakerDetail; its markup rendersSessionBookmarkButtonfor the star and aQrCodeButtonfor 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
PublicSessionListknows 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:
IEventUIServiceandISpeakerLookupServiceasLoadAsyncarguments (:54-59), a BCLTimeProvider(:57), the cascadedTask<AuthenticationState>(:58),PublicReadAudience(:60),PublicScheduleRoomOptions(:75,:127),CurrentEventDefaults(:142),SpeakerInfo(:34), andEventDTO/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 throughPublicReadAudience(:60), fetches events with children usingCancellationToken.None(:63), and on success indexes every event's rooms intoRoomNamesand 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) addsTitle contains,EventId equalsandRoomId equalsto the query's filter dictionary. The comment on the room branch (:117-118) notes thatSession.RoomIdis a real nullable column, so it rides the generic filter pipeline likeEventId.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 toCurrentEventDefaults.SelectCurrentOrNextevaluated againsttimeProvider.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
_catalogbyPublicSessionList(PublicSessionList.razor.cs:47), which loads it inLoadEventsAndResolveDefaultAsync(:150), saves and restores its selection throughPublicSessionListFilterState(: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:PublicSessionEventCatalogfor events, rooms, speakers and the event and room filters,PublicSessionBookmarkStatefor who the reader is and what they starred,PublicSessionEventScopefor the in-flight events lookup, andNewestLoadTracker<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 overSessionDTO(:24);IPublicSessionScheduleService(:28) with itsSessionSchedulePageRequest(:249),IEventUIServiceandISpeakerLookupService(:29-30), an injected BCLTimeProvider(:32),NavigationManagerfor the sign-in redirect (:33,:143), the optionalISessionBookmarkUIService(:37); the four helpers above (:43-50);PublicSessionListFilterState(:72,:80),ConferenceRoutePaths(:143), andResult(:232). Event, room and speaker types (EventDTO,RoomDTO,SpeakerInfo),PublicReadAudience,PublicScheduleRoomOptionsandCurrentEventDefaultsare now used insidePublicSessionEventCatalog, andClaimsPrincipalExtensions'sGetUserIdinsidePublicSessionBookmarkState. - 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.
- Two startup tasks, both awaited by the fetch path.
OnInitializedAsync(:96-137) creates_bookmarksand starts its load (:121-122), then starts the events load through_eventScope.Start(...)(:128), both before the firstawaitthat matters. The comments (:115-120,:124-127) name the failure each guards: theMudDataGrid's firstServerDatacall 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) awaitsEnsureEventScopeAsync(:219) before entering the base'sLoadServerDataAsync(:221), becauseApplyAdditionalFiltersruns inside it, andFetchSessionsAsync(:232-270) awaits the bookmark load through_bookmarks.EnsureAsync(:245).[Rubric §19, State Management & Data Flow]. - 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]. - Two fetch branches, both truly server-paged. In My Schedule mode with bookmarks present, the page passes the bookmarked ids as
MyScheduleSessionIdson theSessionSchedulePageRequest(:255) and the service turns them into a server-sideId 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]. - Audience-scoped filter persistence. Only privileged readers persist an event choice, expressed as
persistEventId: _catalog.IsPrivileged && _catalog.EventFilterResolvedin the call toPublicSessionListFilterState.Save(:71-74). The default-event rule itself (everyone else locked to the current or next event) lives inPublicSessionEventCatalog.ResolveDefaultEventFilter.[Rubric §11, Security]and[Rubric §26, Front-End Security]. - Fail closed, with a retry that heals. When the public scope is unresolved, or the bookmark read failed in My Schedule mode,
FetchSessionsAsyncroutes toAnswerUnqueryablePageAsync(: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 failedResultcarrying the same errors (:285-288), and an unresolved scope throws anInvalidOperationExceptionrather 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 toPublicSessionEventScope.EnsureAsync, which re-issues a failed events lookup at most once and lets overlapping loads share that one re-issue.EventsLookupPending(:163) andShowLoadFailed(:169) hold the load-failed state back while a lookup is still in flight (doc:165-168, tag U-35), andScopeFailureShown(: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]. - 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, andOnInitializedAsyncapplies 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_redirectingToLoginand navigates to/loginwith areturnUrlback to?mine=true, andFetchSessionsAsyncreturns an empty page while that flag is set (:240-243).[Rubric §25, Navigation & Information Architecture]and ADR-042 Wave 2. - A restored My Schedule is not an intent (L155).
ClearMyScheduleForAnonymousReader(:88-94) drops_showMyScheduleOnlyonce the reader is known to be anonymous (_readerKnownAnonymous,:54, set at:135). It runs after the initial load (:136) and again at the end ofRestoreFilters(:78-84, remark:77), because the base page's later sessionStorage restore can re-apply a stale flag.[Rubric §25, Navigation & Information Architecture]. - The offline snapshot lives in the service, not the page. The section comment (
:295-297) states the division:IPublicSessionScheduleServicekeeps 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 passesOnCacheStateChangedinto the fetch (:269) and that handler flipsShowingCachedData(:307), callingStateHasChanged()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.
- Two startup tasks, both awaited by the fetch path.
- Walkthrough
SaveFilters/RestoreFilters(:71-84) delegate toPublicSessionListFilterState, reading and writing the catalog'sSelectedEventId,EventFilterResolvedandSelectedRoomIdrather 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 callReloadViewAsync(:206), which forwards to the view child'sReloadAsync()and no-ops when the child is not yet rendered. ApplyAdditionalFilters(:314-315) forwards to_catalog.ApplyFilters(filters, _searchString); theTitle,EventIdandRoomIdfilters are described underPublicSessionEventCatalog. The room branch is worth reading againstPublicSpeakerList:Session.RoomIdis a real nullable column, so it rides the generic filter pipeline with no virtual-key interception in the controller, unlike the speaker page'sEventId.FetchMobilePage(:318-327) awaits the sameEnsureEventScopeAsync(:320), builds the same filters, and reusesFetchSessionsAsync, 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 onPublicSessionDetail:[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/sessionsroute (PublicSessionList.razor:1, matchingConferenceRoutePaths.PublicSessionsatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:47), including the?mine=truedeep link; it rendersPublicSessionListFilterBar(PublicSessionList.razor:11-21) andPublicSessionListView(:31) and routes onward toPublicSessionDetail.
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 enumdeclared 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'sInternalsVisibleTo(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 singleswitchinside 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
switchin 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 byScorePollSession.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
Colorenum (external NuGet, imported atSessionSelectionDisplay.cs:2) andSessionStatusesfor 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.razormarkup so it can be unit-tested and reused;[Rubric §14, Testability]is the same point from the other side. Every method here isstaticand total (eachswitchhas 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, soIsUnknownTier(:21-22, an ordinal equality test) lets the page swap in the localizedChip.Unknownlabel instead of showing the raw string.IsLocalTier(:24-27) folds three locality substrings (Atlanta,Georgia,Surrounding, all matched withStringComparison.OrdinalIgnoreCase) into one "is this speaker local" test.GetScoreColor(:29-35) buckets adecimalscore 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.0is the only strict-less-than case.MatchesAcceptedFilter(:50-51) now compares againstSessionStatuses.Acceptedinstead of a literal, andSessionMatchesStatus(:53-56) delegates the null-status case toSessionStatuses.IsEligiblerather 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
SessionStatuseskeeps this file and SessionSelectionFilterOptions from drifting on what "Accepted" means. The tier tokens it understands are exactly the values SessionSelectionFilters.ScoreTierOptionsoffers in the picker. - Where it's used: by SessionSelectionSpeakerOverlap (
SessionMatchesStatusandScoreMatchesFilter,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionSpeakerOverlap.razor.cs:62and:69), by SessionSelectionAiScores (MatchesAcceptedFilterandScoreMatchesFilter,.../SessionSelectionAiScores.razor.cs:48and:63), and directly from markup for chip coloring:.../SessionSelectionAiScores.razor:72and: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 fourintfields. - 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 callingRegisterFetchwith 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) andMaxConsecutiveFailures = 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) andStablePollsForCompletion = 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 returnstrueonce the budget is exhausted.CountScoredSince(scores, baseline)(:75) is astatichelper added alongside the widened limits: it counts a session as scored-this-run when it is missing from the baseline dictionary or itsScoredOnmoved, 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_zeroProgressPollsand returnsGaveUpNoScoresat the limit, otherwiseContinue(:84-90); any nonzero count clears the zero-progress counter (:92); a count greater than the previous one advances_previousCount, resets_stablePolls, and returnsCompletedAllwhen the count has reachedtotalSessions(guarded bytotalSessions > 0) orProgressedotherwise (:94-101); an unchanged count increments_stablePollsand returnsCompletedStableat the (now six-poll) stability window (:103-110); anything else returnsContinue(: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
CountScoredSincewas 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), whoseforloop bounds itself withScorePollTracker.MaxPolls(:57);CountScoredSinceis called from the same file (:64). - Caveats / not-in-source: the latency claim in the
MaxPollscomment ("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 BCLFunc<>,Action<>,Task, andTimeSpan. It is a positionalrecordwith 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.CurrentGenerationandPollIntervalareFunc<>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_loadGenerationcounter, 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 anAction<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'sInvokeAsync(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, soStateHasChanged, the toasts, and the generation counter that supersedes a stale session all stay on the component. Making the port arecordof 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]andEventCallbackinfrastructure fromMicrosoft.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 anIsScoringflag the parent flips (:15) and aScoredThisRuncount 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 wholeDashboardDTO, 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, soDashboard.AiScoresstill holds every previous score while the run is in flight, and only this count measures the run's progress.ScoredShown(:31) clamps the count toDashboard.TotalSessions, andScoringPercent(: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), returnsDashboard.AiScoresuntouched when no filter is active (:48-49), and otherwise materializesApplyAiScoreFiltersinto an array with a collection expression (:51).ApplyAiScoreFilters(:55-77) is a straight pipeline ofWhereclauses over the flat score rows: status through a single call to SessionSelectionDisplay.SessionMatchesStatus(:59-62); locality against the row'sSpeakerLocalitiescollection (:64-65); category againstSessionCategories(:67-68); level as a case-insensitive equality onSessionLevel(:70-71); and score tier viaScoreMatchesFilteronOverallScore(:73-74). The markup colors each row's status and score chip through SessionStatusDisplay and SessionSelectionDisplay (.../SessionSelectionAiScores.razor:78and: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 bindsScoredThisRunto the page's_scoredThisRun(:243) and itsScoreRequestedcallback to the page'sScoreSessionsAsync(.../SessionSelectionDashboard.razor.cs:164); covered bySessionSelectionAiScoresTestsin the Conference UI bUnit tier (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Sessions/Selection/SessionSelectionAiScoresTests.cs, which setsScoredThisRunat: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 itsSpeakerOverlap.Speakers,AiScores,SpeakerLocality, andCategoryDistribution.Categoriescollections. Externally it is LINQ plusStringComparer.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 onestaticmethod 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 fourIReadOnlyList<string>members (:12-15) are positional record parameters, so instances are immutable by construction. - Walkthrough: a private
static readonly Emptyinstance (:17) is the null-board answer, so callers never getnulllists and no allocation happens on the not-yet-loaded path.From(SessionSelectionDashboardDTO?)(:20-54) returnsEmptyfor 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, withnullnormalized toSessionStatuses.Accepted(the shared constant, replacing a literal), then de-duplicated and ordered case-insensitively. Localities (:35-37) are theLocalityTiervalues from the board's locality breakdown, ordered the same way. The level group is picked out by aCategoryTitle.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,
IStringLocalizerfor 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; externallyTask.Delay,CancellationToken, and the ConferenceEventIdentifierType/SessionIdentifierTypealiases. - 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 throughhost.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-73and: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 failedResultrather 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 toMaxPollswithout ever reporting. - Walkthrough:
RunAsync(eventId, baseline, generation, cancellationToken)(:51-121) takes abaseline(each already-scored session'sScoredOnbefore 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,lastFreshandlastScoredThisRun(: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 toScorePollTracker.MaxPollstimes (:65). Each iteration awaitshost.PollInterval()under the page's disposal token (:69), checks the generation (:70-73), fetches the board throughselectionService.GetDashboardAsync(:75), and checks the generation again (:76-79). A failedResultfeedstracker.RegisterFailure(), and when the budget is exhausted the run callsRestoreLastBoard, reportsSnackbar.ScoringPollFailedas an error and returns (:86-91); a failure inside budget falls through tocontinuebecauseTryGetValueyields nothing (:93-96). A successful fetch resets the failure counter (:98), computesscoredThisRunviaScorePollTracker.CountScoredSince(fresh.AiScores, baseline)(:99), records it with the board in the two locals (:100-101), and passes it (not the raw count) to bothtracker.RegisterFetchandHandleSignalAsync; atruereturn means the run is over and the finish callback has already fired (:102-105).OperationCanceledExceptionexits 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 reportsSnackbar.ScoringTimedOutas a warning (:119-120).RestoreLastBoard(:126-132) callshost.ApplyFreshDashboard(lastFresh, scoredThisRun)only when some poll succeeded.HandleSignalAsync(fresh, scoredThisRun, signal)(:142-172) is the effects half of the split introduced under ScorePollSignal:GaveUpNoScoresnow 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);Progressedcallshost.ApplyFreshDashboard(fresh, scoredThisRun)and re-renders but keeps going (:152-155);CompletedAllapplies the board with the same count, re-renders, and finishes with a success message carryingscoredThisRun(:157-161);CompletedStableapplies the board with the count and defers toFinishStable(fresh, scoredThisRun)(:163-166); andContinuedoes nothing (:168-170). Every apply passesscoredThisRunthrough 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 againstTotalSessionsand reports eitherSnackbar.ScoringPartialwith the missed count as a warning orSnackbar.ScoringCompleteas 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 toRunAsync(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/Selection/SessionSelectionDashboard.razor.cs:270). Its supersede behavior is asserted end to end byWhenEventChangesMidScoring_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 inSessionSelectionDashboard.resxandSessionSelectionDashboard.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, andIStringLocalizer(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_filterXfields 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 inScoreTierOptions, 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) areIReadOnlyList<string>withprivate set, so only this class can replace them; they start empty.ScoreTierOptions(IStringLocalizer)(:47-58) isstaticand returns nine label/value tuples pairing a localized label with the token SessionSelectionDisplay.ScoreMatchesFilterunderstands: 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.Fromand 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
.LoadDashboardAsynccallsReset()thenComputeOptions(...)for a new board (.../SessionSelectionDashboard.razor.cs:133-134), whileApplyFreshDashboardcalls onlyComputeOptions(...)mid-poll (:240), so the organizer's active filters survive a polling update. - Where it's used: one instance is a
readonlyfield on SessionSelectionDashboard (.../SessionSelectionDashboard.razor.cs:43), bound by the fiveMudSelectpickers 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, theSessionIdentifierTypealias, 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): theSpeakerslist, anAiScoreLookupdictionary 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 returnsSpeakersunfiltered 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 comparingLocalityCategory ?? "Unknown"against the selection (:42-46); then, if any session-level filter is set, a recordwithexpression rebuilds each speaker'sSessionscollection 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'sCategoryItemNames, and the score-tier test.SessionMatchesScoreTier(:67-69) looks the session up inAiScoreLookupand 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
Sessionslist (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. Thewithcopy 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 bySessionSelectionSpeakerOverlapTestsin 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.razorhalf (@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: anyawaitin 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 ininternal 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 injectedIStringLocalizer<SessionSelectionDashboard>(.../SessionSelectionDashboard.razor:5) against the co-locatedSessionSelectionDashboard.resxand its.es.resxtranslation (ADR-027).[Rubric §11, Security]applies with the usual caveat that theOrganizerrole attribute is a UX gate; the services behind it enforce authorization independently. - Walkthrough: the component implements
IDisposableand owns aCancellationTokenSource(:23) that every service call threads through, but never reads.Tokenoff it directly: the privateLifetimeTokenproperty (:28) returns_cts.LifetimeToken(), because readingTokenfrom a disposed source throwsObjectDisposedException, whereasDisposecancels before it disposes, so a load or handler resuming after navigation gets a cancelled token and exits through itsOperationCanceledExceptionpath (comment at:24-27; the Common architecture ruleComponentsReadTheirTokenThroughLifetimeTokenenforces the habit,MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Ui/ArchitectureRules.LifetimeTokens.cs:28).Disposecancels and disposes it exactly once via the guarded_disposedpattern (: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), passingTimeProvider.GetUtcNow().UtcDateTimeas "now" (:80) from the injected clock rather than readingDateTime.UtcNowdirectly; then it loads the board.OperationCanceledExceptionis swallowed as disposal andIsLoadingis cleared in thefinally(:87-94).OnEventSelectedAsync(:97-113) bumps_loadGenerationbefore 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 withLifetimeToken(: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 thefinallyis 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, whichSessionSelectionDisplay.IsUnknownTierdetects so the page shows the localizedChip.Unknownlabel the speaker table already uses.RebuildAiScoreLookup(:169-172) projects the score rows into aSessionIdtoOverallScoredictionary for the speaker section.ScoreSessionsAsync(:174-240) sets_isScoring, captures the currentgenerationandeventIdbefore 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 abaselinedictionary of each visible score'sSessionIdto itsScoredOn, and resets_scoredThisRunto 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 withLifetimeToken(:197); if the generation moved while the request was in flight, the run belongs to the old event, so it clears_isScoringand returns without wiping the board or polling (:198-204). A failedResult(including the 409 already-running or queue-full conflict) toasts an error and clears the flag; aSessionsScored == -1result means the server accepted the work asynchronously, so it toasts "started", only now clears the replaced scores from view with awithexpression and rebuilds the lookup so the table and the overlap chips empty together, and launches the fire-and-forgetPollForScoresAsync(baseline, eventId, generation)(:213-223); and a synchronous result toasts the scored and failed counts and reloads. The closing comment documents why there is nofinallyhere: only the accepted asynchronous path leaves_isScoringset, 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_selectedEventIdand_loadGenerationwhen the task begins, and builds a ScorePollSession with the service, the localizer, and a ScorePollHost wired to_loadGeneration,PollInterval,ApplyFreshDashboard,InvokeAsync(StateHasChanged), andFinishScoring(:258-266; the host's generation callback still reads the live_loadGeneration), runssession.RunAsync(eventId, baseline, generation, LifetimeToken)(:270), and clears_isScoringin afinallyso 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_scoredThisRunfor 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 bySessionSelectionDashboardTestsandSessionSelectionStaleResponseTestsin the Conference UI bUnit tier (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Sessions/Selection/). - Caveats / not-in-source: the
-1sentinel onSessionsScoredis 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'sCategoryTitle(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
CategoryItemIdentifierTypeandConferenceCategoryIdentifierType, bothintin this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8-9). BCLstring. - 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 tostring.Emptywhen 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
CategoryTitleexists 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 statichelper 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 itsdefaultvalue, 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), constrainedwhere TChildId : notnullandwhere TParentId : notnull(lines 79-80). The body is a singlestring.Concat(lines 81-86) that formats the child id withConvert.ToString(..., CultureInfo.InvariantCulture)(invariant so anintid 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 inUri.EscapeDataString. Both conversions fall back tostring.Emptyon a null result. The generic parameters matter because Conference ids are not one type:SpeakerIdentifierTypeis aSystem.Guidwhile every other alias here isint(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:
EventSpeakerServicewith"eventId"(ChildEntityServices.cs:29),SessionSpeakerServicewith"sessionId"(line 42),SessionCategoryItemServicewith"sessionId"(line 55), andSpeakerCategoryItemServicewith"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
CategoryItemIdentifierTypealias, andResult<T>fromMMCA.Common.Shared.Abstractions(ICategoryItemLookupService.cs:1). BCLTask,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). ReturningIReadOnlyDictionary<,>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. TheResult<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)returningTask<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:60under 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
EventSpeakerjoin 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, andEventSpeakerIdentifierTypealiases, andResult/Result<T>. BCLTask,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 noUpdateand noGetById, onlyAddandDelete).[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 genericIJoinService<,>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.AddAsynctakes the two parent ids, whileDeleteAsynctakes 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): returnsTask<Result<EventSpeakerDTO>>, the created row read back from the response body.DeleteAsync(eventId, id, ct)(line 17): takes the event id and theEventSpeakerIdentifierTypeand returnsTask<Result>, a non-generic Result, so a 404 arrives as an ErrorType.NotFoundfailure rather than as a barefalse(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 IEventSpeakerUIServiceIChildEntityUIService.cs:14eventId,speakerIdEventSpeakerDTO / eventId+EventSpeakerIdentifierTypeISessionSpeakerUIServiceIChildEntityUIService.cs:26sessionId,speakerIdSessionSpeakerDTO / sessionId+SessionSpeakerIdentifierTypeISessionCategoryItemUIServiceIChildEntityUIService.cs:38sessionId,categoryItemIdSessionCategoryItemDTO / sessionId+SessionCategoryItemIdentifierTypeISpeakerCategoryItemUIServiceIChildEntityUIService.cs:50speakerId,categoryItemIdSpeakerCategoryItemDTO / speakerId+SpeakerCategoryItemIdentifierTypeWhy 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 atDependencyInjection.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
IEventSpeakerUIServicetoday, 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, andSessionCategoryItemIdentifierTypealiases, andResult/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)returningTask<Result<SessionCategoryItemDTO>>(line 40) andDeleteAsync(sessionId, id, ct)returningTask<Result>(line 41). - Where it's used: implemented by SessionCategoryItemService
(
ChildEntityServices.cs:48-49), registered atDependencyInjection.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, andSessionSpeakerIdentifierTypealiases, andResult/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 inAddAsyncare of different CLR types, anintsession and aGuidspeaker, which is why the family was written as four typed interfaces rather than one generic pair of ids. - Walkthrough:
AddAsync(sessionId, speakerId, ct)returningTask<Result<SessionSpeakerDTO>>(line 28) andDeleteAsync(sessionId, id, ct)returningTask<Result>(line 29). - Where it's used: implemented by SessionSpeakerService
(
ChildEntityServices.cs:35-36), registered atDependencyInjection.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, andSpeakerCategoryItemIdentifierTypealiases, andResult/Result<T>. - Concept: see IEventSpeakerUIService.
[Rubric §9, API & Contract Design]. Its delete is the one that carries aGuidparent id into the query string, which is why ChildEntityDeletePath is generic and escapes the parent value (ChildEntityServices.cs:68). - Walkthrough:
AddAsync(speakerId, categoryItemId, ct)returningTask<Result<SpeakerCategoryItemDTO>>(line 52) andDeleteAsync(speakerId, id, ct)returningTask<Result>(line 53). - Where it's used: implemented by SpeakerCategoryItemService
(
ChildEntityServices.cs:61-62), registered atDependencyInjection.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 aCategoryIdbut 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 aResult. - Walkthrough: one public method,
GetAllAsync(ct)(CategoryItemLookupService.cs:15-47), with the whole body wrapped inHttpResultExecutor.ExecuteAsync(:17).- Resolves the named
"APIClient"HttpClientfrom the factory in ausing(:20), so the per-call handler lifetime is the factory's concern. - GETs
conferencecategories?includeFKs=false&includeChildren=falsethrough the privateGetAsync<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". BuildCategoryTitlesfolds the categories into aDictionary<ConferenceCategoryIdentifierType, string>, null-guarding the collection (:33,:58-71).- Reads every item through
PagedReadAll.ReadAllAsync(:37-42), handing it a per-page fetch that GETsPagedReadAll.LookupPageUrl("categoryitems", pageNumber)into a PagedCollectionResult<CategoryItemDTO> (:38-41). That URL is the/pagedendpoint inIdascending 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 ignorespageSizeand stops silently at the API's maximum page size, so only paging to the reported total is unbounded.ReadAllAsyncstops on an empty page or once the accumulated count reachesTotalItemCount, and returns the first failing page's errors unchanged (PagedReadAll.cs:57-90). Mapprojects the accumulatedList<CategoryItemDTO>throughBuildLookup(:44-45), which loops the items, resolves each parent title withTryGetValuefalling back tostring.Empty, and stores one CategoryItemInfo per id (:73-85).- The private
GetAsync<T>(:49-56) is the shared read step for both calls: oneIdempotentReadRetry.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 oneProblemDetailsResultReader.ReadAsync<T>(:55).
- Resolves the named
- Why it's built this way:
includeFKs=false&includeChildren=falsekeeps both payloads flat and small, which is the whole point of a lookup fetch, no navigation graphs, only the display fields. The stableIdsort means consecutive pages neither skip nor repeat a row (PagedReadAll.cs:30-34). The retry exists because this service has no base class: withoutIdempotentReadRetryan anonymous lookup would get a single attempt, and a GET is safe to re-send. TheTryGetValuefallback (: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
AddConferenceUIagainst 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: eachGetAllAsynccall 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
categoryitemsREST resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to CategoryItemDTO andCategoryItemIdentifierType(ICategoryItemUIService.cs:9-11). - Depends on: IEntityService<TEntityDTO, TIdentifierType>
(imported at
ICategoryItemUIService.cs:2) and CategoryItemDTO (imported atICategoryItemUIService.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
- What it is: the UI-service contract for the
conferencecategoriesREST resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to ConferenceCategoryDTO andConferenceCategoryIdentifierType(IConferenceCategoryUIService.cs:9-11). - Depends on: IEntityService<TEntityDTO, TIdentifierType> and ConferenceCategoryDTO.
- Concept: identical shape to IActivityUIService.
[Rubric §18, UI Architecture]and[Rubric §15, Best Practices & Code Quality](a new aggregate resource costs one empty interface plus one thin class). - Walkthrough: no members (doc comment
IConferenceCategoryUIService.cs:6-8). - Where it's used: implemented by ConferenceCategoryService; injected
into the conference-category list, detail and create pages
(
Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16,Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:23,Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:13) and into ConferenceCategoryItemsPanel (:23). It is also one of the three services SpeakerDetailLookupService composes, supplying the category titles for SpeakerDetailLookups (Services/SpeakerDetailLookupService.cs:13,26).
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
categoryitemsresource, structurally identical toActivityServicebut bound toCategoryItemDTOandCategoryItemIdentifierType(CategoryItemService.cs:10-14). It implementsICategoryItemUIService. - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>,ITokenStorageService,CategoryItemDTO; BCLIHttpClientFactory. - 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 declaresICategoryItemUIService. - 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 throughICategoryItemUIServiceinConferenceCategoryItemsPanel, 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
conferencecategoriesresource, structurally identical toCategoryItemServicebut bound toConferenceCategoryDTOandConferenceCategoryIdentifierType(ConferenceCategoryService.cs:10-14). It implementsIConferenceCategoryUIService. - 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 throughIConferenceCategoryUIServiceinConferenceCategoryList(Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16),ConferenceCategoryDetail(Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:23),ConferenceCategoryCreate(Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:13) andConferenceCategoryItemsPanel(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 implementsIEventSpeakerUIService. - 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 atChildEntityServices.cs:89-90);ChildEntityDeletePathfor the delete suffix;ITokenStorageService;EventSpeakerDTO;Result; theEventIdentifierType/SpeakerIdentifierType/EventSpeakerIdentifierTypealiases. - 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 leanerChildEntityServiceBase, whose whole surface is three protected helpers:PostAsync<TResponse>, which posts and reads the created DTO back (ChildEntityServiceBase.cs:36-44), a valuelessPostAsyncfor an endpoint that answers 204 (:52-60), andDeleteByIdAsync(:70-79). All three create their client throughAuthenticatedServiceBase.CreateAuthenticatedClientAsync()(:40,56,74), so the join endpoints carry the same bearer token as their parent CRUD endpoints, and all three wrap the call inHttpResultExecutorand read the response throughProblemDetailsResultReader, so a missing join row arrives as a typedErrorType.NotFoundfailure 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 rawHttpClient; 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 itsdefaultvalue, that aggregate is never found, and the API answers 404 while the UI reports a generic failure. That is why everyDeleteAsyncin 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 withCultureInfo.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 payloadnew { EventId, SpeakerId }, returningResult<EventSpeakerDTO>.DeleteAsync(eventId, id, ct)(ChildEntityServices.cs:28-29):DeleteByIdAsyncoverChildEntityDeletePath.For(id, "eventId", eventId), returning a valuelessResult.
- The base call supplies the
- 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 notIEntityService<,>implementations, so the assembly scan does not see them). - Caveats / not-in-source: unlike the CRUD services, none of the
ChildEntityServiceBasehelpers runs inside the PollyRetryPolicy(the calls atChildEntityServiceBase.cs:41,57,76go straight to theHttpClient), so a transient failure on a link edit surfaces immediately rather than being retried. Note also that in the current tree no Conference page injectsIEventSpeakerUIService: 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 implementsISessionCategoryItemUIService. - Depends on:
ChildEntityServiceBase;ChildEntityDeletePath;ITokenStorageService;SessionCategoryItemDTO;Result; theSessionIdentifierType/CategoryItemIdentifierType/SessionCategoryItemIdentifierTypealiases. - 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 createdSessionCategoryItemDTOin aResult<T>.DeleteAsync(sessionId, id, ct)(ChildEntityServices.cs:54-55):DeleteByIdAsyncoverChildEntityDeletePath.For(id, "sessionId", sessionId), so the owning session id rides along in the query string.
- The base call supplies the
- 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 intoSessionDetail'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 ofEventSpeakerService,SessionCategoryItemServiceandSpeakerCategoryItemService, differing only in endpoint, payload keys, DTO and parent parameter name. It implementsISessionSpeakerUIService. - Depends on:
ChildEntityServiceBase;ChildEntityDeletePath;ITokenStorageService;SessionSpeakerDTO;Result; theSessionIdentifierType/SpeakerIdentifierType/SessionSpeakerIdentifierTypealiases. - 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):DeleteByIdAsyncoverChildEntityDeletePath.For(id, "sessionId", sessionId).
- The base call supplies the
- 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 intoSessionDetail's "speakers on this session" editor (Pages/Session/SessionDetail.razor.cs:27). The rows it creates are also what the server joins on whenSpeakerDashboardServicefilters sessions by the virtualSpeakerIdkey.
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 implementsISpeakerCategoryItemUIService. - Depends on:
ChildEntityServiceBase;ChildEntityDeletePath;ITokenStorageService;SpeakerCategoryItemDTO;Result; theSpeakerIdentifierType/CategoryItemIdentifierType/SpeakerCategoryItemIdentifierTypealiases. - 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):DeleteByIdAsyncoverChildEntityDeletePath.For(id, "speakerId", speakerId).
- The base call supplies the
- 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 intoSpeakerCategoryItemsPanel, 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.
EventIdentifierTypeisintin this module (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8); BCLDateOnly,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 feedStartDate,EndDate, andTimeZoneinto 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):DateOnlybounds 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 ofnull. 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
SponsorshipPacketUrloff it (PublicSponsorList.razor.cs:88); every page that injects IEventLookupService indexes the dictionary of these records to render event names, includingEventFilteredListPageBase<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.
SessionIdentifierTypeisintin this module (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15); BCLstring,int. - Concept: a plain positional-free
record classwithinit-only,requiredmembers 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]: therequiredmodifier 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.csand 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
Urlhere 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, ornullto 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
Urlbeing 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.csand 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 recorddescribing 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.
SessionIdentifierTypeisint(MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15). BCLDictionary<string, (string Operator, string Value)>andIReadOnlyCollection<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'sServerDatadelegate 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): bothstring?, 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 anId IN (...)filter and the server still pages; it isnullfor 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
MyScheduleSessionIdsinto an extra"Id"filter with theINoperator 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 emptyINlist 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
EventIdentifierTypealias, andResult<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 theTask, 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 explicitInvalidate()for this scope's own writes (EventLookupService.cs:62-68). - Walkthrough: two members.
GetAllAsync(CancellationToken cancellationToken = default)returningTask<Result<IReadOnlyDictionary<EventIdentifierType, EventInfo>>>(IEventLookupService.cs:28-29).void Invalidate()(IEventLookupService.cs:36): drops the memoized lookup so the nextGetAllAsyncrefetches. 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, includingEventFilteredListPageBase<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 whyDependencyInjection.cs:57labels 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>. BCLAction<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 composesOfflineFirstPageSnapshot<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)returningTask<(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, andnullmeans the snapshot cannot answer.onCacheStateChangedis raised withtruewhen it does.FetchPageAsync(SessionSchedulePageRequest request, Action<bool> onCacheStateChanged, CancellationToken cancellationToken = default)returningTask<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 withtruewhen the snapshot answered, so the page shows its "cached schedule" banner and re-renders, and withfalsewhen 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
Resultas 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 privateIsProgrammePagecheck (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:55under an explicit ADR-042 Wave 3 comment (line 54), and injected into PublicSessionList (PublicSessionList.razor.cs:28), which calls itsFetchPageAsyncfrom the page fetch (PublicSessionList.razor.cs:269) and itsTryReadOfflineSnapshotAsyncwhen 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 fromIEntityService<,>, 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>; BCLIBrowserFile(Microsoft.AspNetCore.Components.Forms),byte[],CancellationToken. - Concept: the conditional update carries the same
If-Matchshape as IEventUIService's transitions:UpdateAsynctakes a requiredbyte[] rowVersionand 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:GetBySessionAsyncis 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: thebypassCacheflag (: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 passestrue.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 onrowVersionas 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
AddLinkAsyncfromUploadFileAsyncinstead 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 withbypassCache: trueat 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, andEvent, 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 recordwith two members and no behavior (SessionizeRefreshOutcome.cs:13). Value equality andwithcome 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 aTimeProviderand, 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 viaBeginEventsLoad(Pages/Common/EventFilteredListPageBase.cs:137, defined at:145) and a derived page awaits it later. - A TTL stamped only on success (
:114-120)._cachedUntilstarts atDateTimeOffset.MinValue(:53), which is what makes a faulted task, a cancelled one and a failedResultall expire immediately rather than caching a non-answer; a transient outage heals on the next page instead of being remembered for five minutes.CacheTtlis 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_generationcounter, nulls_cachedand resets_cachedUntiltoMinValue. An in-flight fetch simply completes unobserved and the next read starts a fresh one.FetchAsynccaptures_generationon entry (:93) and stamps_cachedUntilonly whenresult.IsSuccess && generation == _generation(:117), so a fetch that anInvalidatesuperseded 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-83never 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.
- The cached unit is the
- Walkthrough
CacheTtl(:43):static readonly TimeSpanof five minutes._cached(:46): the shared fetch, in flight or completed and still fresh._cachedUntil(:53): when_cachedstops being reusable._generation(:59): bumped byInvalidate(), read byFetchAsyncto decide whether it may stamp.Invalidate()(:62-68): increments_generation, nulls_cached, resets_cachedUntil.GetAllAsync(ct)(:70-89): snapshots_cachedinto a local (:73); starts a newFetchAsync()when the entry is null, or completed and past its TTL as read from the injectedtimeProvider.GetUtcNow()(:78-83), resetting_cachedUntilfirst; 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 viaentry.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.ReadAllAsyncuntil the reported total (:95-111), because the GET-all endpoint ignorespageSizeand stops silently at the API's maximum page size (:95-96). Each page is oneHttpResultExecutor.ExecuteAsynccall that GETsPagedReadAll.LookupPageUrl("events", pageNumber)through IdempotentReadRetry.GetAsync(:102-105) and reads it through ProblemDetailsResultReader (:107-108), withCancellationToken.Nonethroughout. The combined list is mapped throughBuildLookup(:112), and the TTL is stamped fromtimeProvider.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 accumulatedList<EventDTO>and projects each DTO into an EventInfo carrying all seven members includingSponsorshipPacketUrl(:131), keyed byevt.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 itsValuesto CurrentEventSelector along with theStartDate/EndDate/TimeZoneaccessors. - Caveats / not-in-source: the read has no fixed row ceiling, since
PagedReadAllkeeps requesting pages until the reported total (EventLookupService.cs:95-111); the page size it uses isPagedReadAll'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'sEventServiceclears 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
eventsresource. 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). BCLTask,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 CRUDUpdate, 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 requiredbyte[] rowVersion(IEventUIService.cs:13,15), the optimistic-concurrency token the client echoes back from the EventDTO it acted on. The implementation turns it into anIf-Matchheader 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 theETagon the read and the conditionalIf-Matchon the write as one exchange. - Walkthrough: four declared members, all returning
Result values.
PublishAsync(EventIdentifierType id, byte[] rowVersion, CancellationToken)(IEventUIService.cs:13), returnsTask<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), returnsTask<Result<RefreshFromSessionizeResultDTO>>. It takes norowVersion: a Sessionize pull is not a lifecycle transition on the event row.RefreshFromSessionizeWithCodeAsync(EventDTO current, string? sessionizeCode, CancellationToken)(IEventUIService.cs:29-32), returnsTask<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, callingRefreshFromSessionizeAsync, 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. Thecurrentparameter 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 ofRefreshFromSessionizeWithCodeAsync, 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
SessionAssetsendpoint (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-Matchheader; SessionAssetLinkRequest, SessionAssetUpdateRequest, SessionAssetDTO; BCLIHttpClientFactory,IBrowserFile,MultipartFormDataContent,CultureInfo.InvariantCulture. - Concept: the upload path is the teaching point.
UploadFileAsynccarries no retry policy at all (SessionAssetService.cs:96-143), unlike every other method here, which runs its request through the sharedRetryPolicy. 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. TheIdempotency-Keyheader 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, inSessionAssetLimits.MaxFileBytes, and stated toOpenReadStreamso Blazor's own 512 KB default cannot silently truncate a real slide deck (:110-112); the suppressed Sonar rule S5693 onUploadFileAsync(: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): GETsSessionAssets?sessionId={id}throughHttpResultExecutor.ExecuteAsync, using the plain authenticated client (no idempotency key, noIf-Match, since it is a read), and reads the array through ProblemDetailsResultReader (:43-54). WhenbypassCacheistruethe 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 toSessionAssets/linkthrough a client built with a freshIdempotency-Keyand noIf-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 atSessionAssetLimits.MaxFileBytes(:112); builds aMultipartFormDataContentwith parts namedfile,sessionId,title,sortOrder(:130-133), matching the controller'sIFormFileparameter and its three[FromForm]arguments exactly, since a renamed part fails binding; posts directly withhttpClient.PostAsyncrather than throughRetryPolicy(:135-136), for the single-shot-stream reason above.UpdateAsync(id, rowVersion, request, ct)(:146-171): builds theIf-Matchheader only whenrowVersionis 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 toSessionAssets/{id}.DeleteAsync(id, ct)(:174-191): DELETEsSessionAssets/{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 everyRetryPolicyattempt 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/Updatepair. - 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 passingbypassCache: 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
eventsresource. 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 inheritedAddAsync,UpdateAsyncandDeleteAsync(:31-46) so that every successful write drops the scope's memoized event lookup (see the cache-invalidation concept below). It implementsIEventUIService, which itself extends the genericIEntityService<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, theEndpointproperty (EntityServiceBase.cs:51) and the protectedSendRequestAsyncdispatch (EntityServiceBase.cs:351,381);ConcurrencyETagto render the row version as a weak entity tag (EventService.cs:29,41);EventDTO,RefreshFromSessionizeResultDTOandSessionizeRefreshOutcome;Result;ITokenStorageService;IEventLookupServiceandISpeakerLookupService, the two memoized lookups it invalidates (primary constructor,EventService.cs:22-26); BCLCultureInfo.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
EventLookupServicewould be the only staleness they could see. Every successful write therefore callseventLookup.Invalidate()through the private generic helperInvalidateEventsOnSuccess<TResult>(TResult result) where TResult : Result(:125-134), which passes the result through untouched and invalidates only whenresult.IsSuccess. It wraps the overriddenAddAsync,UpdateAsyncandDeleteAsync(: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}/publishand{Endpoint}/{id}/unpublishwith no body at all (EventService.cs:53-57,65-69). What they do carry is anIf-Matchheader holding the event's current row version, rendered byConcurrencyETag.Formatas a weak tag such asW/"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 whatIf-Matchasks (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.
RefreshFromSessionizeWithCodeAsyncis 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 oneResultcarrying aSessionizeRefreshOutcome, so the page reads one value and reports one failure (EventService.cs:93-123, contract documented atIEventUIService.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, eachInvalidateEventsOnSuccess(await base.XAsync(...)).PublishAsync(id, rowVersion, ct)(EventService.cs:48-58): a body-less POST to{Endpoint}/{id}/publishbuilt withstring.Create(CultureInfo.InvariantCulture, ...)(:54), dispatched through the base's valuelessSendRequestAsyncwithifMatch: ConcurrencyETag.Format(rowVersion)(:58), the whole call wrapped inInvalidateEventsOnSuccess(:52). The base attaches the header and turns the response into aResult.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-returningSendRequestAsync<T>and yields the import summary as aResult<RefreshFromSessionizeResultDTO>. NoIf-Matchhere: 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 withStringComparison.Ordinaland, only when they differ, persists it first through the overriddenUpdateAsyncon awith-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 usingTryGetValue(:110-114); then reloads the event through the inheritedGetByIdAsync(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 aSessionizeRefreshOutcome(:120-122). Every branch returns the underlying call's ownErrors, so nothing is re-worded on the way out. Because the persist goes through the overriddenUpdateAsync, 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), sinceIEventUIServicederives fromIEntityService<,>. It is injected asIEventUIServiceintoEventList(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) andPublicSessionList(Pages/Public/PublicSessionList.razor.cs:32). The transition methods are called fromEventDetail: publish and unpublish atPages/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 implementsIPublicSessionScheduleService. - Depends on:
ISessionUIServiceas the live query (constructor,PublicSessionScheduleService.cs:20), so it composes overSessionServicerather than issuing HTTP itself;ILocalCacheStoreandIConnectivityStatusService(:21-22);OfflineFirstPageSnapshot<TItem>as the snapshot mechanism (:30-31);SessionSchedulePageRequestas 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:CanServeis 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), andRememberAsyncrecords 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.ILocalCacheStorepersists 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
IsProgrammePagecheck (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 keyPublicSessionListwrites). 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
QueryAsyncshows 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 anId 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): oneOfflineFirstPageSnapshot<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.- Live success:
QueryAsyncreturns a page (:44-45), the snapshot records it only whenIsProgrammePage(request)holds (:47-50), throughRememberSnapshotAsync(:49) rather than on the grid's own token, the page's cached-data banner is switched off through theonCacheStateChanged(false)callback (:52), and the live result is returned (:53). - 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 whenIsProgrammePage(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. - 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.
- Live success:
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 holdingnull(:88). It guards a null request withArgumentNullException.ThrowIfNull(:84).RememberSnapshotAsync(fetched, page)(PublicSessionScheduleService.cs:100-111): writes the snapshot under its ownCancellationTokenSourcebounded bySnapshotWriteTimeout, a five-secondstatic 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; anOperationCanceledExceptionis swallowed because the snapshot is best-effort (:107-110, doc comment:91-97).IsProgrammePage(request)(PublicSessionScheduleService.cs:121-122): aprivate staticpredicate,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 callsISessionUIService.GetPagedAsyncwithincludeChildren: 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
ISessionUIServiceinherits the caching. - Where it's used: registered explicitly as
IPublicSessionScheduleServiceatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:55, under the comment naming ADR-042 Wave 3 (:54), and injected intoPublicSessionList(Pages/Public/Sessions/PublicSessionList.razor.cs:33), whose grid data delegate callsFetchPageAsyncat: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
onCacheStateChangedcallback 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 nullableProfilePicture(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.
SpeakerIdentifierTypeisSystem.Guidin this module (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:23), unlike the other Conference aliases, which areint, because speakers carry Sessionize-assigned GUIDs (BR-61, noted atMMCA.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].ProfilePictureis 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
SpeakerInforecord 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, andQuestionIdentifierTypealiases. BCLIReadOnlyDictionary. - 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 oneResult<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: eachawaitcompletes 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 readsQuestionTextswhen rendering an answer, falling back to the raw id when a question is missing (SpeakerDetail.razor.cs:198).CategoryTitlesis 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) andActivityDTO(:2), which owns the length constants the rules quote. Its rules are executed throughModelValidationandDataAnnotationsModelValidator.VenueUrlalso carriesAbsoluteUrlAttribute(: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.
- 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 byModelValidation.For(...)rather than repeatingRequired="true"andRequiredError="..."on everyMudTextField(class doc,:7-13). Because both pages bind the sameActivityFormFieldscomponent 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. - 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]. TheNameRequiredKeyconstant (:32) exists so the model rule and the field's ownRequiredErroraffordance quote one key instead of two copies of a literal. - The rule DataAnnotations cannot express stays out. The start/end window is four separate properties (
:56-65) because twoDateTimevalues 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.
- Rules declared once, not per control. Each property carries its own
- Walkthrough
- Text fields (
:35-53):Name(required,ActivityDTO.NameMaxLength),Description,VenueName,VenueAddress,VenueUrl, each capped from the DTO constant;VenueUrlalso 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/StartTimeandEndDate/EndTime, nullable so "not picked yet" is representable. SortOrder(:68): the tie-break between activities starting at the same instant.- Derived (
:71-86):HasStartandHasEndreport whether both halves are present;StartsAtandEndsAtcollapse a date plus aTimeSpaninto oneDateTimeand are documented as valid only once the matchingHas*is true (:76-79,:82-85).
- Text fields (
- 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
ActivityCreateModelandActivityEditModel; bound byActivityCreate(ActivityCreate.razor.cs:90) andActivityDetail(ActivityDetail.razor.cs:71) through the sharedActivityFormFieldscomponent.
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/QuestionIdentifierTypealiases, and Result fromMMCA.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 oneTask<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 oneif.[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): returnsTask<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
SpeakerIdentifierTypealias, 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)
TryGetValueinstead 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)returningTask<Result<IReadOnlyDictionary<SpeakerIdentifierType, SpeakerInfo>>>(ISpeakerLookupService.cs:19-20). TheResultwrapper 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 nextGetAllAsyncrefetches. 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 extendIEntityService<TEntityDTO, TIdentifierType>; it is its own read-only interface over the Conference contracts andResult<T>(ISpeakerDashboardUIService.cs:1-3). - Depends on: SessionDTO and
SessionFeedbackDTO, the
SpeakerIdentifierTypealias (aGuidin this module,MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:23) andSessionIdentifierType. - 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 onGetSpeakerSessionsAsync(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): returnsTask<Result<IReadOnlyList<SessionDTO>>>, the speaker's sessions, uncached. The implementation filters server-side on the virtualSpeakerIdkey with a page size capped at 100 and no child collections (SpeakerDashboardService.cs:18,38).GetSessionBookmarkCountAsync(speakerId, sessionId, ct)(ISpeakerDashboardUIService.cs:22-25): returnsTask<Result<int>>, the bookmark count for one of the speaker's sessions.GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct)(ISpeakerDashboardUIService.cs:32-35): returnsTask<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): returnsTask<Result<SessionFeedbackDTO>>. Its doc comment (lines 37-40) states the modelling decision: "no feedback captured yet" arrives as an ErrorType.NotFoundfailure, 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 atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:40(an explicitAddScopedbecause it is not anIEntityService<,>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
ActivityFormModelexcept the mapping onto the DTO that gets posted. - Depends on:
ActivityFormModel(:11),ActivityDTOand theEventIdentifierTypealias (:1,19). - Concept introduced: none new. It is the create half of the shared-form-model pattern
ActivityFormModelteaches. - Walkthrough:
ToNew(EventIdentifierType eventId)(:19-32) is the only member. It sendsId = default(:22) and lets the server mint the key, setsStartTime/EndTimefrom the base's combinedStartsAt/EndsAt(:25-26), and stamps the owning event from the argument (:31).[Rubric §8, Data Architecture](assesses a deliberate identity strategy): compareSpeakerCreateModel, which mints its ownGuidclient-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:
ActivityCreateholds one instance (ActivityCreate.razor.cs:90) and callsToNewon 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) andActivityDTO(: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 loadedActivityDTOis 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).ToUpdatedalso 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) andEventId(: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:
ActivityDetailholds one instance (ActivityDetail.razor.cs:71), seeds it inStartEditing(ActivityDetail.razor.cs:151) and postsToUpdatedfromSaveChangesAsync(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
speakersresource, 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 theSpeakerIdentifierType/UserIdentifierTypealiases (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 getsLinkUserAsync/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 theSpeakerLinkedToUser/SpeakerUnlinkedFromUserintegration 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
Resultis 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
AuthenticatedServiceBasedirectly rather than the CRUD base, and implementsISpeakerDashboardUIService(SpeakerDashboardService.cs:14-16). - Depends on:
AuthenticatedServiceBaseforCreateAuthenticatedClientAsync()and the shared static PollyRetryPolicy(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:27,59);HttpResultExecutorandProblemDetailsResultReader;PagedCollectionResult<T>;SessionDTOandSessionFeedbackDTO;ITokenStorageService; theSpeakerIdentifierType/SessionIdentifierTypealiases; BCLIHttpClientFactory,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
GetSpeakerSessionsAsyncare 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=equalsandfilters[SpeakerId].value={speakerId}(SpeakerDashboardService.cs:38), a virtual filter key.Sessionhas noSpeakerIdcolumn, soSessionsControllerremoves the key from the filter dictionary, parses it withTryParseunder 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 forincludeFKs=false&includeChildren=falseand caps the page atMaxSpeakerSessions = 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].
- Filter on the server, not in memory. The URL sends
- 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 withstring.Create(CultureInfo.InvariantCulture, ...)sortedStartsAt asc(:36-38), dispatches through the privateSendGetRequestAsync<T>into aPagedCollectionResult<SessionDTO>because the paged endpoint answers with an envelope rather than a bare array (:40-41), thenMaps the envelope to the item list, substituting an empty list for a nullItems(:42).Mapruns 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) GETsspeakers/{speakerId}/sessions/{sessionId}/bookmarks/count(:50) and returns the deserialized count as aResult<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 repeatedsessionIds=parameter per id (:62-63), and maps the concreteDictionaryup 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) GETsspeakers/{speakerId}/sessions/{sessionId}/feedback(:78). "No feedback captured yet" is a legitimate domain state and arrives as anErrorType.NotFoundfailure, which the caller tells apart from a real load failure withIsNotFound(), 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.ExecuteAsyncwraps the body (:95), the client carries the bearer token (:98), the GET runs through the PollyRetryPolicy(:99-101), andProblemDetailsResultReaderturns the response into aResult<T>(:102).- Every URL is built with
string.Create(CultureInfo.InvariantCulture, ...)and every call leads withspeakerId, 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
ISpeakerDashboardUIServiceatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:40, and injected intoSpeakerDashboard, the speaker's "My Sessions" page (Pages/Speaker/SpeakerDashboard.razor.cs:24). - Caveats / not-in-source: the retry-plus-reader dispatch in
SendGetRequestAsyncis a deliberate private copy of theEntityServiceBase.SendRequestAsyncsemantics 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 (
SpeakerIdentifierTypetoSpeakerInfo), 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 implementsISpeakerLookupService. - Depends on:
SpeakerInfo, the three-field projection it emits (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/ISpeakerLookupService.cs:9-12);SpeakerDTOas the wire shape;PagedCollectionResult<T>;PagedReadAllfor the page loop and the page URL;IdempotentReadRetryfor the GET;HttpResultExecutorandProblemDetailsResultReader; BCLIHttpClientFactory. BCLTimeProvidersupplies the clock for the memo's expiry (SpeakerLookupService.cs:29,:73,:113). Note what is absent: the primary constructor takes onlyIHttpClientFactoryandTimeProvider(SpeakerLookupService.cs:29), with noITokenStorageServiceand noAuthenticatedServiceBasebase. This is an unauthenticated public read. It still gets that base's Polly retry, but by composition:IdempotentReadRetry.Policyis the sameAuthenticatedServiceBase.SharedRetryPolicyobject (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 asEventLookupService(remarks,SpeakerLookupService.cs:12-28). The cached unit is theTask, 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 URLPagedReadAll.LookupPageUrlbuilds: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 twoincludeflags 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, startsFetchAsync()and stores the new task (:73-78). Anything in flight is reused (single-flight), and a faulted, canceled or failed-Resulttask expires at once because_cachedUntilis only stamped on success. The shared fetch runs WITHOUT the caller's token; a cancelable caller token is applied withentry.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_cachedand resets_cachedUntil. An in-flight fetch completes unobserved.FetchAsynccaptures the generation on entry (:87) and stamps_cachedUntil = now + CacheTtlonly when theResultsucceeded and the generation is still current (:111-114), so a fetch a write superseded cannot stamp the entry that replaced it.CacheTtlis 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 callInvalidate.FetchAsync()(:85-117) is the formerGetAllAsyncbody. The inline comment (:89-90) names why it pages: the GET-all endpoint ignorespageSizeand stops silently at the API's maximum page size. It handsPagedReadAll.ReadAllAsynca per-page delegate (:91-105), every token argument nowCancellationToken.None. Each page runs insideHttpResultExecutor.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), GETsLookupPageUrl("speakers", pageNumber)throughIdempotentReadRetry.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 aResult<PagedCollectionResult<SpeakerDTO>>throughProblemDetailsResultReader(:101-102).ReadAllAsyncloops pages from 1, stopping on an empty page or once the accumulated count reaches the reportedTotalItemCount, and returns the first failing page's errors unchanged (PagedReadAll.cs:65-89). The method thenMaps the accumulatedList<SpeakerDTO>throughBuildLookup(SpeakerLookupService.cs:106). Mapping rather than unwrapping is the point: the projection runs only on success, and an API-described failure travels out with itsErrorTypeintact.BuildLookup(speakers)(SpeakerLookupService.cs:119-130) is a private static taking the flattenedList<SpeakerDTO>; the null-Itemsguard now lives inReadAllAsync(PagedReadAll.cs:75). It fills aDictionary<SpeakerIdentifierType, SpeakerInfo>withId,FullNameandProfilePictureper speaker (SpeakerLookupService.cs:122-127) and returns it as anIReadOnlyDictionary(:129). Beingstaticis 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
SpeakerInfokeeps only the three display fields the UI needs rather than the fullSpeakerDTO, 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
ISpeakerLookupServiceamong the "cross-module lookup services" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:58), and injected intoSessionList(Pages/Sessions/SessionList.razor.cs:29),SessionDetail(Pages/Sessions/SessionDetail.razor.cs:30, passed on toSessionLookupsat:58, whose constructor takes it atPages/Sessions/SessionLookups.cs:34),PublicSessionList(Pages/Public/Sessions/PublicSessionList.razor.cs:35) andPublicSessionDetail(Pages/Public/Sessions/PublicSessionDetail.razor.cs:25). The Conference service'sSelfHttpOutputCacheWarmupTaskreplays 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:
ReadAllAsyncstops 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 matchLookupPageUrlbyte 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, becauseInvalidateis 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
Resultthat carries either all three or the first failure (SpeakerDetailLookupService.cs:13-40). It implementsISpeakerDetailLookupServiceand its success value is theSpeakerDetailLookupsrecord. - Depends on: three services injected through the primary constructor
(
SpeakerDetailLookupService.cs:13-17):ICategoryItemLookupService(implemented byCategoryItemLookupService),IConferenceCategoryUIServiceandIQuestionUIService, the last two being plainIEntityService<TEntityDTO, TIdentifierType>CRUD contracts (IConferenceCategoryUIService.cs:9-11,IQuestionUIService.cs:9-11). AlsoResultand itsMap(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Result.cs:276-280), theTryGetValueextension fromResultUiExtensions(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:103),CategoryItemInfo,ConferenceCategoryDTOandQuestionDTO. - 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-18declares 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 theTryGetValueUI 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 theincludeFKsandincludeChildrenflags (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
SpeakerDetailLookupsrecord (:34-37). The category-item dictionary arrives already keyed fromICategoryItemLookupService(ICategoryItemLookupService.cs:20-21), while the other two are lists indexed here:categories.ToDictionary(c => c.Id, c => c.Title)(:36) andquestions.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). ReturningResultrather 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 isSpeakerDetail, 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), rendersQuestionTextsthroughGetQuestionText(SpeakerDetail.razor.cs:198-199) and handsCategoryItemsplusCategoryTitlestoSpeakerCategoryItemsPanel(Pages/Speaker/SpeakerDetail.razor:169-172). The interface is also whatSpeakerDetailTestsmocks (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/Pages/Speakers/SpeakerDetailTests.cs:31), which is the practical payoff of the facade: oneMock<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 aTask.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
speakersWebAPI resource. It inherits the whole CRUD surface fromEntityServiceBase<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 implementsISpeakerUIService. - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>and, through it,AuthenticatedServiceBase; the base'sEndpointproperty (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:51) and its valuelessSendRequestAsyncoverload (EntityServiceBase.cs:381-397);ITokenStorageService;SpeakerDTO;LinkUserRequest;ISpeakerLookupService, whoseInvalidate()it calls (SpeakerService.cs:22,:69);ISpeakerUIService;Result; theSpeakerIdentifierTypeandUserIdentifierTypealiases; BCLIHttpClientFactoryandSystem.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 inheritedEndpointso the resource name still lives in exactly one place, and dispatch through the base's protectedSendRequestAsyncinstead of anHttpClientof your own, which is what buys the call the authenticated client, the Polly retry pipeline and theResultconversion throughProblemDetailsResultReader(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 aPUTto{id}/linkcarrying aLinkUserRequestbody and unlink is aDELETEto 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,ITokenStorageServiceandISpeakerLookupService; 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 optionalIUiReadCache, is left at itsnulldefault (EntityServiceBase.cs:43-47), so every read here goes to the API. AddAsync,UpdateAsyncandDeleteAsync(SpeakerService.cs:27-30,:33-36,:39-42) are expression-bodied overrides that await the base call and pass its result through the private genericInvalidateOnSuccess<TResult>(:64-73), constrained toResult. OnIsSuccessit callsspeakerLookup.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 valuelessSendRequestAsyncwith a lambda thatPutAsJsonAsynces anew LinkUserRequest { UserId = userId }tonew 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, insideCreateAuthenticatedClientAsync(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 interpolatedspeakerIdis culture-safe becauseSpeakerIdentifierTypealiasesSystem.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 withDeleteAsyncagainst the identical path and no body (:59-61). Both methods return theResultthe base produced, success for any 2xx and otherwise the errors the ProblemDetails response described (EntityServiceBase.cs:381-397); the server answersNoContenton both success paths (SpeakerLinksController.cs:57,:73).- Neither call passes an
idempotencyKeyor anifMatch, 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 aPUTand aDELETEwhose repetition lands on the same state (EntityServiceBase.cs:337-339, ADR-017).
- The primary constructor takes
- 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
PUTof the wholeSpeakerDTO, and the client mirrors that. Keeping the two methods on the subclass, and onISpeakerUIServicewhich 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 forIEntityService<,>implementations atMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:323-327), so no explicitAddScopedline exists for it. Injected asISpeakerUIServiceintoSpeakerList(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) andPublicSpeakerDetail(Pages/Public/PublicSpeakerDetail.razor.cs:22). The two link verbs have exactly one caller each, both onSpeakerDetail(SpeakerDetail.razor.cs:302and: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 atSpeakerService.cs:64), so the two link verbs here evict nothing. That is inert as written, because this service is constructed without anIUiReadCache; 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),IEventLookupServicereturningEventInfo(:23,87),IToastService(:25),ActivityCreateModel(:90),CurrentEventSelector(:70),ModelValidationwithDataAnnotationsModelValidator(:50),ConferenceRoutePaths(:45,218,230), andErrorMessages(:193,199). Externals:NavigationManager(:24),TimeProvider(:26), theComponentLifetimeExtensions.LifetimeTokenextension on the page'sCancellationTokenSource(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26), MudBlazor'sMudFormandBreadcrumbItem, and theIStringLocalizer<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
ActivityFormModeland executed by MudBlazor through the shared_validatedelegate. The schedule window cannot be: no DataAnnotation states "end after start" across four controls.ValidateSchedule(:157-179) therefore checks the model's ownHasStart/HasEnd/StartsAt/EndsAtand puts its message in_scheduleError(:161,167,173) instead of the form's error list, and the markup renders that string in its ownMudAlertabove 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 thoughMudFormknows 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.- The event picker is seeded with
CurrentEventSelector.SelectCurrentOrNextevaluated over each event's start date, end date and IANA time zone againstTimeProvider.GetUtcNow().UtcDateTime(:68-75), rather than the weaker "auto-select when exactly one exists" rule. The clock is the injectedTimeProvider(:26), not the staticDateTime.UtcNow, so "current or next" is a dependency a test can control. The event stage uses??=(:68), so an event already set is kept. ApplyEventDateDefaults(:131-150) then seeds both date pickers with the selected event's first day (:138-147), and re-runs on every event change throughOnEventSelected(: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:191guides the user, rather than blocking the page.[Rubric §29, Resilience & Business Continuity]. The third idea is the same inline error result alongside the toastSpeakerCreatenow carries._saveResult(:99-103) records the last create attempt'sResult, reset tonullbefore each attempt (:188) and set from the service call (:207). On failureCreateActivityAsynccallscreated.NotifyOnFailure(Toast, L)(:212) instead of a bareToast.Error, so the message reaches the page'sErrorSummarycomponent as well as the snackbar. The fourth idea is the disposal-safe lifetime token. The page no longer reads_cts.Tokendirectly; it goes through a privateLifetimeTokenproperty that calls_cts.LifetimeToken()(:33). ReadingTokenoff a disposed source throwsObjectDisposedException; the extension instead returns an already-cancelled token once the source is cancelled, disposed or null (ComponentLifetimeExtensions.cs:20-26,:28-30).Disposecancels before it disposes (:243-244), so a load or save that resumes after the user navigated away sees a cancelled token and leaves through itsOperationCanceledExceptionpath 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].
- The event picker is seeded with
- Walkthrough
OnInitialized(:39-51): three breadcrumbs, then the validation delegate over_model.OnInitializedAsync(:53-83): loads the events throughLifetimeToken(:61), resolves the default event, applies the date defaults (:77), and swallowsOperationCanceledExceptionas 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), runValidateSchedule(:197-201), post_model.ToNew(_eventId.Value)throughLifetimeToken(:206) and record the result to_saveResult(:207), report a failed create throughNotifyOnFailure(:212), clear_isDirtybefore navigating (:216), and route toConferenceRoutePaths.ActivityDetails(createdActivity.Id)(:218) using the key the server assigned. Thefinallyalways clearsIsSaving(:224-227).NavigateToList(:230) and the standardDispose(bool)/Disposepair (:234-254), guarded by_disposedso the_ctsis 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/createroute with[Authorize(Roles = "Organizer")](ActivityCreate.razor:1-2), reached fromActivityList's create button; on success it hands off toActivityDetail. - Caveats / not-in-source:
StartsAtandEndsAtcompose localDateTimevalues from the pickers with no time-zone conversion, even though the event'sTimeZoneis available onEventInfoand is used for the default-event choice. How the postedStartTimeandEndTimeare 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 suppliesPageToken,IsEditing/IsDirtyand theBeginEdit/EndEdittransitions plus disposal of its ownCancellationTokenSource. AlsoIActivityUIService(:24),IEventLookupServicereturningEventInfo(:25,65),IToastService(:27),ActivityEditModel(:71),ActivityDTO(:66),ModelValidationwithDataAnnotationsModelValidator(:61),ErrorMessages(:109,201,207), andConferenceRoutePaths(:56,282), plus the sharedDeleteConfirmationcomponent (:75). Through the base it also usesLatestLoadGuard, exposed as the protectedLoadGuardproperty (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43). Externals:System.Globalization, MudBlazor'sMudForm, and theIStringLocalizer<ActivityDetail>from the template. - Concept introduced, culture-formatted display and the field that is deliberately not editable. The detail-page mechanics are the ones
SpeakerDetailteaches (load-once guard on_loadedId,:81-90; edit buffer; confirm-then-delete). This page now extendsDetailPageBase(ActivityDetail.razor:7) for the edit-lifecycle plumbing and theCancellationTokenSource, rather than owning its own_ctsand_isEditingfield as it once did;StartEditingandCancelEditing/SaveChangesAsynccall the base'sBeginEdit()/EndEdit()(:153,159,239) instead of setting the flags directly. Three things are specific here.- Two computed display properties.
EventName(:35-39) resolves the activity'sEventIdagainst 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 inCultureInfo.CurrentCultureinto theText.TimeRangeresource; 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). - The immutable field on an editable record.
ActivityEditModelhas no event property and re-sendsEventIdfrom 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]. - A stale-response guard on the load.
LoadAsyncopens withvar (token, generation) = LoadGuard.Begin()(:95), which supersedes any load already in flight and hands back that load's own token and generation number. Everyawaitresult is then checked withLoadGuard.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 thefinally(:137-140): only the current generation may clearIsLoading, 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 asValidateSchedule(:167-189) with the same three localized keys asActivityCreate, 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].
- Two computed display properties.
- Walkthrough
OnInitialized(:50-62): breadcrumbs plus the validation delegate.OnParametersSetAsync(:81-90) thenLoadAsync(:92-142): begin the guarded load (:95), fetch with children through the guard'stoken(:100) and return if the generation is no longer current (:101-104), treatIsNotFound()as the page's not-found state with a localized message (:106-111), report any other failure throughNotifyOnFailure(:115), then hydrate the event lookup once, again throughtoken(: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). ThefinallyclearsIsLoadingonly for the current generation (:137-140).StartEditing/CancelEditing(:144-160): seed the edit model from the record, clear_scheduleError, then call the base'sBeginEdit()/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, runValidateSchedule, post_model.ToUpdated(Activity)(:214), then re-fetch (:221). Save and re-fetch still run on the base'sPageToken, not on the load guard. The reload has three outcomes, and all three are handled: a value refreshes the page (:222-225), aIsNotFound()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 callsEndEdit()(:239).DeleteActivityAsync(:251-280): confirm through_deleteConfirm.ShowAsync(Activity.Name)(:258), return unless the answer is exactlytrue(:259), delete, toast, and navigate back to the list.- No disposal code remains on the page itself:
DetailPageBaseowns theCancellationTokenSourceand its guardedDispose(bool)pair, soActivityDetailno longer declaresIDisposable.
- 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 fromActivityListrows and fromActivityCreate's success redirect. It edits the aggregate theActivityentity 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
MudDataGridwith 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 overActivityDTO(:19), which itself extendsDataGridListPageBase<TDto>and suppliesToast,IsMobile,LoadServerDataAsync, the event lookup and the persisted event filter. It injectsIActivityUIService(:24) and usesListPageActions(:46,92),ErrorMessages(:98),ConferenceRoutePaths(:101-102), theMobileInfiniteScrollList<TItem>andDeleteConfirmationcomponents (:32-33), andResult<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
eventIdfilter 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 acontainsfilter onNameand then calls the base'sApplyEventFilter(:73), which addsEventIdequals 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:EventIdis a real Activity column, so it travels straight through the generic filter pipeline (class doc,:14-16). ContrastSpeakerList.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 awaitWaitForEventsAsync()first (:60,:79), because the grid's firstServerDatacall can run ahead of initialization andApplyFiltersexecutes inside the base'sLoadServerDataAsync(in-code comment,:58-59). Miss that await and the first page loads unscoped. What is specific to activities is the mobile sort:FetchMobilePagepins"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'sIsMobile.[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-safeReloadServerData().OnSearchChanged(:50-54): store the term, reload the active layout.LoadServerData(:56-67): wait for events, then hand the base the fetch delegate andApplyFilters.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 toListPageActions, 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
/activitiesroute with[Authorize(Roles = "Organizer")](ActivityList.razor:1-2), linking on toActivityCreateandActivityDetail.
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
ConferenceCategoryItemsPanelhosts. It carries a singleNameproperty and the rules that property must satisfy. - Depends on:
CategoryItemDTOfor the length cap (.../Pages/ConferenceCategory/ConferenceCategoryItemEditModel.cs:2,30);System.ComponentModel.DataAnnotations. It is consumed throughModelValidationandDataAnnotationsModelValidator. - 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.
- The rules live on a plain C# model, as DataAnnotations.
[Required(ErrorMessage = NameRequiredKey)]and[MaxLength(CategoryItemDTO.NameMaxLength, ErrorMessage = "Validation.NameMaxLength")]sit on theNameproperty (lines 29-31). The cap is not a literal: it readsCategoryItemDTO.NameMaxLength, which is500and 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. - 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'sValidationparameter (.../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. - Every
ErrorMessageis a resource key, not a sentence.NameRequiredKeyis 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'sRequiredErroraffordance 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'sMaxLength, 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.
- The rules live on a plain C# model, as DataAnnotations.
- Walkthrough
NameRequiredKey(line 26): the shared resource key described above.Name(line 31):stringinitialized tostring.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 theCategoryItemDTOitself. 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
_modelofConferenceCategoryItemsPanel(.../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:
PartnerDTOfor the four length caps (lines 35,39,44,49) andAbsoluteUrlAttributefor the two URL fields (lines 43,48).PartnerType(its default ofPartnerType.Community, line 56). Its subclasses arePartnerCreateModelandPartnerEditModel. - Concept introduced: none new. This is the partner-side instance of the shared-base form model
taught on
ConferenceCategoryFormModelandQuestionFormModel: 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 byName's[Required](line 34) and, per the doc comment (lines 27-30), by the field'sRequiredError.Name(line 36): required, capped atPartnerDTO.NameMaxLength.Description(line 40): optional, capped atPartnerDTO.DescriptionMaxLength.LogoUrl(line 45) andWebsiteUrl(line 50): optional, each validated by[AbsoluteUrl(ErrorMessage = "Error.AbsoluteUrl")]plus its ownPartnerDTOlength cap. UnlikeNameandDescription, these two fields carry a format rule as well as a length rule.PartnerType(line 56): defaults toPartnerType.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): plainint, 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
PartnerCreateModelandPartnerEditModel; bound byPartnerCreateandPartnerDetailthrough 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:
ConferenceCategoryDTOfor the title cap (lines 2,35). Its subclasses areConferenceCategoryCreateModelandConferenceCategoryEditModel. - Concept introduced: none new. This is the category-side twin of
QuestionFormModel; read that section for the shared-base rationale, andConferenceCategoryItemEditModelfor 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'sRequiredError(.../Pages/ConferenceCategory/ConferenceCategoryFormFields.razor:18).CategoryTitle(line 36): required and capped atConferenceCategoryDTO.TitleMaxLength, which is255(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 namedTitleon a page class would read as the page title, and the pages already expose a localizedTitle.Sort(line 39) andType(line 42): plain properties with no annotation.Typeis nullable and optional (for example"session"or"speaker"); its input caps itself atConferenceCategoryDTO.TypeMaxLengthin 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 sharedConferenceCategoryFormFieldscomponent takes this type as itsModel(.../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
ConferenceCategoryCreateModelandConferenceCategoryEditModel; bound byConferenceCategoryCreate(.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:23) andConferenceCategoryDetail(.../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
PartnerFormModelexcept the shape of the post. - Depends on:
PartnerFormModel(base) andPartnerDTO;EventIdentifierType(the module's identifier-type alias). - Concept introduced: none new.
ToNewis the mapping-on-the-model idea introduced onQuestionCreateModel. - Walkthrough
ToNew(EventIdentifierType eventId)(lines 18-29) builds aPartnerDTOwithId = default(line 21) plus the five entered values, stamped with the caller-suppliedeventId. UnlikeConferenceCategoryCreateModel.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
ToNewmirrorsConferenceCategoryCreateModel: every field a reader could look for is on the shared base. - Where it's used: the
_modelfield ofPartnerCreate, 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) andPartnerDTO. - Concept introduced: none new. The load-into-buffer and
RowVersionround-trip mechanics are taught onQuestionEditModel.[Rubric §8, Data Architecture]:ToUpdatedcarriesRowVersion = 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 copiesName,PartnerType,Sort,Description,LogoUrl, andWebsiteUrlonto the form fields.ToUpdated(PartnerDTO partner)(lines 32-48): null-guards, then rebuilds the DTO with the loadedId,RowVersion, andEventIdalongside 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
ToUpdatedis 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
_modelfield ofPartnerDetail, loaded atStartEditingand 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
ConferenceCategoryFormModelexcept the shape of the post. - Depends on:
ConferenceCategoryFormModel(base) andConferenceCategoryDTO. - Concept introduced: none new.
ToNewis the mapping-on-the-model idea introduced onQuestionCreateModel. - Walkthrough
ToNew()(lines 17-24) builds aConferenceCategoryDTOwithId = default(line 20) plus the three entered values. Note the contrast withQuestionCreateModel.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
ToNewis 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
_modelfield ofConferenceCategoryCreate(.../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) andConferenceCategoryDTO. - Concept introduced: none new. The load-into-buffer and
RowVersionround-trip mechanics are taught onQuestionEditModel.[Rubric §8, Data Architecture]:ToUpdatedcarriesRowVersion = category.RowVersion(line 37), so the category edit participates in optimistic concurrency (ADR-035). Worth contrasting with the item edits inConferenceCategoryItemsPanel, which carry no token. - Walkthrough
LoadFrom(ConferenceCategoryDTO category)(lines 15-22): null-guards, then copiesTitleintoCategoryTitle, plusSortandType.ToUpdated(ConferenceCategoryDTO category)(lines 30-42): null-guards, then rebuilds the DTO with the loadedIdandRowVersionand 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
UpdateAsyncwith no child reconciliation. - Where it's used: the
_modelfield ofConferenceCategoryDetail(.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:53), loaded atStartEditing(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 oneConferenceCategoryDTOthrough 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),ResultUiExtensionsforNotifyOnFailure(line 83),ModelValidationandDataAnnotationsModelValidator(line 34). Externals: Blazor ([Inject],NavigationManager,OnInitialized), MudBlazor (MudForm,BreadcrumbItem,Icons.Material.Filled.Home), the sharedUnsavedChangesGuardandErrorSummarycomponents fromMMCA.Common.UI, and theIStringLocalizer<ConferenceCategoryCreate>the template injects asL(.../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.
- Validate before you mutate.
CreateCategoryAsynccallsawait _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 sharedErrorSummary(.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:34), whose comment (lines 28-33) records why de-duplication is needed: MudBlazor runs a field's annotations throughForas 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 lastAddAsyncresult, cleared at the top of every attempt (line 65) and set again once the call returns (line 78), and a failure callscreated.NotifyOnFailure(Toast, L)(line 83) instead of a bareToast.Error, so the wording survives a snackbar timing out while the user is still reading the form. - Dirty tracking that cannot block its own redirect. Every field change raises
MarkDirty()(line 56, wired through the field block'sOnFieldChanged,.../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 callingNavigateTowithout an intervening render would otherwise still prompt. The page clears_isDirtyon the success path before navigating, with the reason written on the line (line 87). - Cancel on disposal. A private
CancellationTokenSource(line 18) is read through aLifetimeTokenproperty (_cts.LifetimeToken(), line 24) rather than_cts.Token, because readingTokenoff a disposed source throwsObjectDisposedException; 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 fullDispose(bool)pattern (lines 105-125), withOperationCanceledExceptioncaught 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 throughL.
- Validate before you mutate.
- 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_validatedelegate (line 44), the capturedMudForm(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, setIsSaving(line 74), post_model.ToNew()(line 77), record the result (line 78), and notify on failure throughcreated.NotifyOnFailure(Toast, L)(lines 81-84), clear the dirty flag (line 87), toast success (line 88), and redirect toConferenceRoutePaths.ConferenceCategoryDetails(newCategory.Id)using the id the server returned (line 89). Thefinallyalways clearsIsSaving(lines 95-98), so a failed save leaves an enabled button rather than a stuck spinner.NavigateToList(line 101) is the cancel action, routed throughConferenceRoutePathsrather 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
ConferenceCategoryFormModelit varies in even less than that. - Where it's used: the
/conferencecategories/createroute (.../Pages/ConferenceCategory/ConferenceCategoryCreate.razor:1-2), reached fromConferenceCategoryList's create button; on success it hands off toConferenceCategoryDetail.
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
ConferenceCategoryDTOwith 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),ResultUiExtensionsforIsNotFound()andNotifyOnFailure(lines 80,88,132,139,175),DomainHelper'sParse<T>extension (line 74),ConferenceRoutePaths(lines 39,192), andErrorMessages(lines 84,122). Externals: MudBlazorMudForm, plus thePageLoadingState,PageErrorState,ErrorSummary,UnsavedChangesGuard, andDeleteConfirmationcomponents fromMMCA.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.
OnParametersSetAsynccompares the routeIdagainst_loadedId(lines 64-69) so a re-render does not refetch. - A 404 is a distinct outcome.
result.IsNotFound()(line 80) clearsCategoryand reports a not-found message; anything else routes throughNotifyOnFailure(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 theRowVersion(ADR-035).[Rubric §11, Security]: organizer-only route (.../Pages/ConferenceCategory/ConferenceCategoryDetail.razor:2).
- Load once on parameters.
- Walkthrough
OnInitialized(lines 33-45): breadcrumbs (lines 36-41) and the validation delegate (line 44).OnParametersSetAsync(lines 62-99): the_loadedIdguard,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, aLoadGuard.IsCurrent(generation)check that drops a superseded response, the three-way result handling above,OperationCanceledExceptionswallowed, and afinallythat clearsIsLoadingonly when the generation is still current, so a stale response cannot switch off the spinner a newer load just turned on.LoadGuardcomes fromDetailPageBase, so the page itself never declares aCancellationTokenSource.StartEditing(lines 101-110): copies the loaded category into the edit model (line 108) and calls the base'sBeginEdit()(line 109), which setsIsEditingand clearsIsDirtyin one place.SaveChangesAsync(lines 112-155): validate the form (lines 119-124),UpdateAsync(_model.ToUpdated(Category))(line 129), report a failure throughNotifyOnFailure(line 132), refetch (line 136), adopt the refreshed record (line 143), toast, and callEndEdit()(line 145) so the dirty flag can never be left set behind a closed editor.DeleteCategoryAsync(lines 157-186): confirm through the sharedDeleteConfirmationdialog seeded with the category title (line 164), delete (line 172), then navigate back to the list (line 180).OnCategoryReloaded(line 190) andNavigateToList(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 fromConferenceCategoryListrows andConferenceCategoryCreate's redirect. The items authored here are whatICategoryItemLookupServiceresolves 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 overConferenceCategoryDTO(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 sharedDeleteConfirmationcomponent (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, theIsMobileswitch, the mobile paging fields, the filter save/restore contract, the inlineLoadFailedstate, andLoadServerDataAsync(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
GridRefoverride (lines 19-20), which is how the base restores rows-per-page and the current page after a back-navigation. SaveFilters/RestoreFiltersfor 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-sideTitle containsfilter (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 byTitleascending (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'sIsMobile.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 passincludeChildren: 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.
- The captured grid reference through the
- Walkthrough:
ReloadActiveLayoutAsync(lines 34-35) asksListPageActionsto 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) andNavigateToCreate(line 77) route throughConferenceRoutePaths; the grid's sortable columns arePropertyColumns overTitle,Sort, andType(.../Pages/ConferenceCategory/ConferenceCategoryList.razor:59,67,77) with the item count as a non-sortableTemplateColumn(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
/conferencecategoriesorganizer route (.../Pages/ConferenceCategory/ConferenceCategoryList.razor:1-2). Rows and cards navigate toConferenceCategoryDetail; the create button opensConferenceCategoryCreate.
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 andIConferenceCategoryUIService(line 23) for the reload;ConferenceCategoryDTOandCategoryItemDTO; theCategoryItemIdentifierTypealias (line 47);ConferenceCategoryItemEditModel(line 36) withModelValidationandDataAnnotationsModelValidator(line 57);IToastService(line 25); andErrorMessages(line 80). Externals: MudBlazorMudForm, the sharedErrorSummaryandDeleteConfirmationcomponents (.../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 throughReloadCategoryAsync(lines 183-193) and raises the refreshed copy on theCategoryChangedcallback (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.
StartAddingItemcloses any open row edit (line 65) andStartEditingItemcloses 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 anaria-labelread from the localizer (.../Pages/ConferenceCategory/ConferenceCategoryItemsPanel.razor:78,80,91,93).[Rubric §8, Data Architecture]: neither the item add nor the item update carries aRowVersion(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:CategoryItemDTOdeclares no concurrency token.
- The panel injects the page's localizer. Its template declares
- Walkthrough
OnInitialized(lines 52-58) builds the validation delegate over the panel's ownConferenceCategoryItemEditModel(line 57).- Add:
StartAddingItem(lines 60-66) clears the model and the sort field and opens the form;AddItemAsync(lines 70-106) validates its ownMudForm(lines 77-82), builds aCategoryItemDTOstamped with the parentCategoryId(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, andUpdateItemAsync(lines 118-153) is the one path that does not use aMudForm: it hand-checksstring.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 theDeleteConfirmationdialog 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 returnsfalsewhen 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 standardDispose(bool)pattern, because it is a component rather than a page and so does not inheritDetailPageBase'sLoadGuard. Every service call (add, update, delete, and the category reload) takes theLifetimeTokenproperty (_cts.LifetimeToken()) instead of_cts.Token, so a call resuming after disposal sees a cancelled token rather than anObjectDisposedException.
- 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
ConferenceCategoryDetailas<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 onePartnerDTOthrough 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 injectedTimeProvider(line 25) as the clock for the event default,CurrentEventSelectorfor the default event (lines 61-68),ConferenceRoutePaths(lines 38,128,140),ResultUiExtensionsforNotifyOnFailure(line 122),ModelValidationandDataAnnotationsModelValidator(line 43). Externals: Blazor ([Inject],NavigationManager,OnInitialized/OnInitializedAsync), MudBlazor (MudForm,BreadcrumbItem,Icons.Material.Filled.Home), .NETTimeProvider. - 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 throughcreateResult.NotifyOnFailure(Toast, L)(line 122) instead of a bareToast.Error, dirty tracking cleared before the post-save navigation (_isDirty = falseat line 126, with the reason on the line), and cancel-on-disposal through a privateCancellationTokenSourceread through aLifetimeTokenproperty (the event lookup andAddAsyncboth take it, never_cts.Token) and the standardDispose(bool)pair (lines 27,144-158,OperationCanceledExceptioncaught 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.
OnInitializedAsyncloads the event lookup and then, if no event id is already chosen, callsCurrentEventSelector.SelectCurrentOrNext(_events.Values, e => e.StartDate, e => e.EndDate, e => e.TimeZone, TimeProvider.GetUtcNow().UtcDateTime)?.Id(lines 61-68). "Now" comes from the injectedTimeProvider(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_eventIdguides the user (comment, lines 52-53). - The save guards on the event, not only the form.
CreatePartnerAsyncreturns 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 ofPartnerFormModel'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 throughL.
- The event defaults, it does not require a click.
- 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_eventsfromIEventLookupService(line 54), then defaults_eventIdthroughCurrentEventSelectoragainstTimeProvider.GetUtcNow().UtcDateTime(lines 61-68), withOperationCanceledExceptionswallowed (lines 70-73).- Form state:
_events(line 78), the_model(line 79),_eventId(line 80), the capturedMudForm(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, setIsSaving(line 113), post_model.ToNew(_eventId.Value)(line 116), record the result (line 117), and notify on failure throughcreateResult.NotifyOnFailure(Toast, L)(lines 120-123), clear the dirty flag, toast success, and redirect toConferenceRoutePaths.PartnerDetails(created.Id)(line 128). Thefinallyalways clearsIsSaving(lines 134-137).NavigateToList(line 140) routes throughConferenceRoutePaths.- Disposal (lines 142-164): standard
Dispose(bool)pattern over the privateCancellationTokenSource.
- 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 toPartnerDetail.
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
PartnerDTOby 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), andErrorMessages(line 89). Externals: MudBlazorMudForm, plus the sharedDeleteConfirmationcomponent. - Concept introduced: none new. This is the same load/edit/save/delete container shape taught on
ConferenceCategoryDetail: load-once on parameters (OnParametersSetAsynccomparingIdto_loadedId, lines 384-393), a 404 as a distinct outcome (partnerResult.IsNotFound(), lines 400-406), refetch rather than patch after a save (a freshGetByIdAsyncat line 471, adopted at line 478), and validate-before-mutate onSaveChangesAsync(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'sEventIdout of_eventswhen the lookup succeeded, and falls back to the raw id formatted withInvariantCultureotherwise, 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 throughNotifyOnFailureand returns (lines 418-422), the same failure-handling contract the doc comment on the equivalentConferenceCategoryDetailcodepath uses, but here it runs only once per page instance (_events is nullguard, line 418) since the lookup does not change per partner.[Rubric §8, Data Architecture]:SaveChangesAsyncposts_model.ToUpdated(Partner), round-tripping theRowVersion(ADR-035).
- The event name is a computed property over the loaded lookup, not a field.
- Walkthrough
OnInitialized(lines 356-368): breadcrumbs and the validation delegate.OnParametersSetAsync(lines 384-393) andLoadAsync(lines 395-432): fetch byId, 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),OperationCanceledExceptionswallowed (lines 424-427), and afinallythat clearsIsLoading(lines 428-431).LoadAsyncopens withLoadGuard.Begin()to supersede any load in flight, passes the guard's token to both fetches, returns early whenLoadGuard.IsCurrent(generation)is false, and clearsIsLoadingonly for the current generation.StartEditing(lines 434-443): loads the edit model fromPartnerand callsBeginEdit().SaveChangesAsync(lines 447-490): validate,UpdateAsync(_model.ToUpdated(Partner))(line 464), report a failure, refetch (line 471), adopt the refreshed record, toast, andEndEdit().DeletePartnerAsync(lines 492-521): confirm throughDeleteConfirmationseeded with the partner name (line 499), delete (line 507), toast, and navigate back to the list.TypeLabel(PartnerType type)(line 348) andNavigateToList(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
PartnerListrows andPartnerCreate'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 overPartnerDTO(line 19);IPartnerUIService(line 25),ListPageActions(lines 571,615),ErrorMessages(line 622),ConferenceRoutePaths(lines 625,626), and the sharedDeleteConfirmationandMobileInfiniteScrollList<TItem>components. - Concept introduced: none new. This is the event-scoped variant of the list-page-as-overrides shape
taught on
ConferenceCategoryList, built overEventFilteredListPageBase<TDto>rather than the plain data-grid base: the extra base contributesWaitForEventsAsyncandApplyEventFilter, which every fetch path here calls before it runs.- Every fetch waits for the event filter first.
LoadServerData(lines 582-593) andFetchMobilePage(lines 603-610) bothawait WaitForEventsAsync()before calling the service, and the comment onLoadServerDataexplains why:ApplyFiltersruns before the fetch inside the base'sLoadServerDataAsync, 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 sameReloadActiveLayoutAsyncthatOnSearchChangeduses (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.
- Every fetch waits for the event filter first.
- Walkthrough
EntityName(line 21) andTitle(line 22): localized labels.GridRefoverride (lines 553-554) andRetryLoadAsync(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) andFetchMobilePage(lines 603-610): the paired server and mobile fetch paths, both waiting on the event filter, both delegating field-level filtering toApplyFilters(lines 595-600), which adds aNamecontains filter when the search box is non-empty and then callsApplyEventFilter.OnMobileCardClick(line 612),DeletePartnerAsync(PartnerDTO)(lines 615-623, routed throughListPageActions.DeleteWithConfirmationAsync),NavigateToCreate(line 625), andNavigateToDetails(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 opensPartnerCreate.
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:
QuestionDTOfor the text cap (lines 2,35);System.ComponentModel.DataAnnotations. Its subclasses areQuestionCreateModelandQuestionEditModel. - 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/createand the inline editor on/questions/{Id}. Both bind the sameQuestionFormFieldscomponent (.../Pages/Question/QuestionCreate.razor:23,.../Pages/Question/QuestionDetail.razor:31), and that component'sModelparameter is typed asQuestionFormModel(.../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.QuestionTextMaxLengthis1000(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'sRequiredError(.../Pages/Question/QuestionFormFields.razor:17).QuestionText(line 36): required, capped atQuestionDTO.QuestionTextMaxLength(line 35), rendered as a two-line text field whoseCounterreads the same constant (.../Pages/Question/QuestionFormFields.razor:18-19).Sort(line 39) andIsRequired(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
QuestionCreateModelandQuestionEditModel, and as the declared parameter type of the sharedQuestionFormFieldscomponent (.../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 bindsRoomCreateModeland the detail page's inline editor bindsRoomEditModel, 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:
RoomDTOfor the length caps (lines 35, 45, 49, 53) and nothing else first-party. Externals:System.ComponentModel.DataAnnotations(RequiredAttribute,MaxLengthAttribute, line 1). It is consumed byModelValidationandDataAnnotationsModelValidator, 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
RequiredErrorstring 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 onlyForplus a single sharedValidationdelegate (seeMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomFormFields.razor:14-16). The delegate is built once per page byModelValidation.Forover aDataAnnotationsModelValidator(.../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 asksModelValidation.IsRequired(Model, nameof(RoomFormModel.Name))(.../Pages/Room/RoomFormFields.razor:16) instead of hard-codingRequired="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: everyErrorMessageon 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 areRoomDTO.NameMaxLength(255),RoomDTO.FloorMaxLength(100),RoomDTO.LocationMaxLength(255) andRoomDTO.AccessibilityInfoMaxLength(500), declared atMMCA.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 aconstrather than a literal so that the model's[Required]rule and any field-levelRequiredErroraffordance 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 tostring.Emptyso an unbound model still validates rather than null-references.Sort(line 39) andCapacity(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.Capacityisint?, so "unknown capacity" and "zero seats" stay distinguishable.Floor(line 46),Location(line 50) andAccessibilityInfo(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
RoomCreateModelandRoomEditModel; bound as theModelparameter of the sharedRoomFormFieldscomponent (.../Pages/Room/RoomFormFields.razor:50), which bothRoomCreateandRoomDetailrender. - Caveats / not-in-source: nothing on this model constrains
SortorCapacityto 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
QuestionDTOthe create posts. - Depends on:
QuestionFormModel(base),QuestionDTO, and theQuestionIdentifierTypealias (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 fullQuestionDTO, not a partial patch. - Walkthrough
QuestionEntity(line 14) defaults to"Session"andQuestionType(line 17) defaults to"Rating", the common combination, so the organizer's most frequent case needs no interaction. Both render asMudSelectpickers only on the create leg, gated by the field block'sShowTargetPickersswitch (.../Pages/Question/QuestionFormFields.razor:21-35).ToNew(QuestionIdentifierType id)(lines 24-33) projects the model plus a caller-supplied id onto aQuestionDTO. Taking the id as a parameter rather than minting one keeps the model free of the identifier policy thatQuestionCreateapplies.
- 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
QuestionEditModelstructurally unable to change it. - Where it's used: the
_modelfield ofQuestionCreate(.../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) andQuestionDTO. - Concept introduced, the edit buffer plus the concurrency round trip. Two mechanisms are worth
reading closely.
- 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. ToUpdatedround-trips what it must not change. The new DTO (lines 34-44) carries the editedQuestionText,Sort, andIsRequiredover the loaded record'sId,RowVersion,QuestionEntity, andQuestionType.RowVersionis the client half of the optimistic-concurrency contract in ADR-035: a stale editor loses the write instead of silently overwriting a newer one.QuestionEntityandQuestionTypeare 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 withArgumentNullException.ThrowIfNull(lines 18,33), so a null argument fails at the call rather than as aNullReferenceExceptionseveral lines later.[Rubric §15, Best Practices & Code Quality].
- The loaded record is never mutated.
- Walkthrough
LoadFrom(QuestionDTO question)(lines 16-23): copiesQuestionText,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
_modelfield ofQuestionDetail(.../Pages/Question/QuestionDetail.razor.cs:49), loaded atStartEditing(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 theRoomDTOthe page posts. - Depends on:
RoomFormModel(base),RoomDTOfromMMCA.ADC.Conference.Shared.Rooms(line 1), and the solution-wideEventIdentifierTypealias. No externals. - Concept introduced, the server-allocated key for an app-assigned identity. Room identity is
app-assigned, not database-generated, because the
intprimary 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.ToNewpostsId = default(line 22, with the in-code note at line 21), exactly likeSponsorCreateModel(.../Pages/Sponsors/SponsorCreateModel.cs:22).RoomService.AddAsyncthen sends a default id asnullin the request'sRoomIdfield, because an explicit0would otherwise read as a client-chosen id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Rooms/RoomService.cs:27-29). The controller passesrequest.RoomIdintoAddRoomCommand(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:221), andAddRoomHandler, oncommand.RoomId is null, takes the highest id already in the reserved rangeEventInvariants.RoomManualIdRangeStarttoRoomManualIdRangeEnd(999_999_000 to 999_999_999,EventInvariantsat.../MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:66,69) plus one, or the range start when it is empty, failing withRoom.ManualIdRangeExhaustedpast the end (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:125-144).RoomSyncStrategyskips 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 freshRoomDTO.Idis left atdefault(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
RoomEditModelreuse the rules without inheriting aToNewit 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
_modelonRoomCreate(.../Pages/Rooms/RoomCreate.razor.cs:26) and consumed inCreateRoomAsync(.../Pages/Rooms/RoomCreate.razor.cs:95). Covered byMMCA.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 toRoomFormModel(line 11), only the two methods that move values between the loaded room and the form. - Depends on:
RoomFormModel(base) andRoomDTO(line 1). No externals beyondArgumentNullException. - 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.
LoadFromcopies only editable values in;ToUpdatedputs 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 changeEventId.[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 withArgumentNullException.ThrowIfNull(line 17), then assigns the six editable properties (lines 19-24). It does not touchIdorEventId, because the model has no such properties.ToUpdated(RoomDTO room)(lines 33-48) null-guards again (line 35) and returns a newRoomDTOwhoseId(line 39) andEventId(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.RoomDTOdoes not implementIConcurrencyAwareand declares no such property (.../MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:13-49), unlikeSponsorDTO(.../MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:15,42).SponsorEditModelcarries the token; this one has nothing to carry.
- Why it's built this way: the DTO is a
record classwithinit-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
_modelonRoomDetail(.../Pages/Room/RoomDetail.razor.cs:51);LoadFromruns inStartEditing(.../Pages/Room/RoomDetail.razor.cs:119) andToUpdatedinSaveChangesAsync(.../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
(
EventorSession), its input type (Rating,Text, orEmail), 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), theUnsavedChangesGuardandErrorSummarycomponents, and theIStringLocalizer<QuestionCreate>injected asL(.../Pages/Question/QuestionCreate.razor:6). - Concept introduced, the client-minted identifier in a reserved range.
QuestionDTO.Idis a non-nullable alias overint, so the form has to put something there. This page fabricates a value withRandomNumberGenerator.GetInt32(999_999_000, 999_999_999)(line 83), which is the user-created question band recorded on the domain side asQuestionInvariants.ManualIdRangeStartandManualIdRangeEnd(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:CreateQuestionHandlerallocates 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 usingnewQuestion.Idread back from the response (line 96), not the value it sent. CompareConferenceCategoryCreate, which postsId = defaultinstead.[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 railsConferenceCategoryCreateintroduces, repeated here verbatim (validate lines 73-76,_isDirtycleared 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'sErrorSummaryalongside the form's own validation messages (.../Pages/Question/QuestionCreate.razor:37), whilecreated.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 readingTokenoff a disposed source throwsObjectDisposedException, whereasDisposecancels before it disposes, so a handler resuming after the user navigated away gets a cancelled token and exits through itsOperationCanceledExceptionpath. TheAddAsynccall passesLifetimeToken(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_validatedelegate (line 50), the capturedMudForm(line 52), and_saveResult(line 58), the last create attempt's failedResult; the defaults for target entity and type live on the model, not the page (seeQuestionCreateModel). CreateQuestionAsync(lines 64-106): validate, reset_saveResulttonullbefore revalidating (line 71), setIsSaving(line 80), mint the id (line 83), post_model.ToNew(id)withLifetimeToken(line 84), record the outcome on_saveResult(line 85), fail intoNotifyOnFailurewhen the result carries no value (lines 86-91), clear_isDirty(line 94), toast success, and redirect toConferenceRoutePaths.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/createorganizer route (.../Pages/Question/QuestionCreate.razor:1-2), reached fromQuestionList's create button; on success it hands off toQuestionDetail. 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 itsaria-requiredaffordance, that an overlong text shows the model's max-length message and does not post, and that a valid submit callsAddAsyncwith 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>overQuestionDTO(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 sharedDeleteConfirmationcomponent (line 41, mounted at.../Pages/Question/QuestionList.razor:145). - Concept introduced: none new. See
ConferenceCategoryListfor 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 byQuestionTextascending (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 theOption.{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 whenResourceNotFoundis set, so a missing resource never surfaces the key.DeleteQuestionAsync(lines 83-91) passesquestion.QuestionTextas the confirmation label, so the dialog names the question being removed rather than showing a bare id.[Rubric §24, Forms, Validation & UX Safety].
- The search filter targets
- 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
/questionsorganizer route (.../Pages/Question/QuestionList.razor:1-2); the create button opensQuestionCreate(line 93) and rows openQuestionDetail(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
RoomDTOand routes to the new room's detail page. The route is/rooms/createand it isAuthorize(Roles = "Organizer")(.../Pages/Room/RoomCreate.razor:1-2). - Depends on:
IRoomUIService(line 15),IEventLookupServicereturningEventInfo(lines 16, 42),IToastService(line 18),ConferenceRoutePaths(lines 56, 113, 125),ErrorMessages(line 94),ModelValidationandDataAnnotationsModelValidator(line 61),RoomCreateModel(line 32), andResult(line 40). Externals: Blazor ([Inject],NavigationManager), MudBlazor (MudForm,BreadcrumbItem,Icons), and theIStringLocalizer<RoomCreate>injected by the template (.../Pages/Room/RoomCreate.razor:6). The markup also usesUnsavedChangesGuardandErrorSummaryfromMMCA.Common.UI.Components(.../Pages/Room/RoomCreate.razor:10,:33-34) and the ADC-localRoomFormFields(.../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
RoomFormFieldsinside aMudForm(.../Pages/Room/RoomCreate.razor:21-26), passes the model, the delegate and the localizer, and that same component is rendered byRoomDetail'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) drivesUnsavedChangesGuard(.../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'sErrorSummaryalongside the form's own validation messages, andcreated.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 lastdisabled: trueso 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 throwingObjectDisposedException, when a handler resumes afterDispose(the in-code comment at lines 19-22 says so). A failed lookup is fatal to initialization here: it toastsSnackbar.LoadEventsFailedand returns (lines 67-68). ContrastSponsorCreate, 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, unlikeSponsorCreate(.../Pages/Sponsor/SponsorCreate.razor.cs:58-65). - The picker is only rendered when there is a real choice:
ShowEventPickeris 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_saveResulttonullbefore revalidating (line 89), awaits_form.ValidateAsync()and toastsErrorMessages.ValidationErroron failure (lines 91-96), then setsIsSaving(line 98) and posts_model.ToNew(_eventId)throughIRoomUIService.AddAsyncwithLifetimeToken(line 101), recording the outcome on_saveResult(line 102). A failedResultcallscreated.NotifyOnFailure(Toast, L)(lines 103-108); success clears the dirty flag, toasts, and navigates toConferenceRoutePaths.RoomDetails(createdRoom.Id)(lines 111-113).OperationCanceledExceptionis swallowed in both async methods as expected during disposal or anInteractiveAutorender-mode transition (lines 76, 115-118; see ADR-056).IsSavingis cleared in thefinally(lines 119-122), which re-enables the submit button after a failure.NavigateToList(line 125) and the_disposed-guardedDispose(bool)/Disposepair close the page out: the_ctsis 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 toRoomDetail. - Caveats / not-in-source: the client-minted room id discussed on
RoomCreateModelis produced insideToNew, 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),ResultUiExtensionsforIsNotFound()andNotifyOnFailure(lines 96,104,155,162,198),DomainHelper'sParse<T>extension (line 85),ConferenceRoutePaths(lines 49,211),ErrorMessages(lines 100,145), andModelValidation(line 54). Externals: MudBlazorMudForm, plus thePageLoadingState,PageErrorState,ErrorSummary,UnsavedChangesGuard, andDeleteConfirmationcomponents (.../Pages/Questions/QuestionDetail.razor:19,23,43,12,82). - Concept introduced: none new. Route-id parsing, load-once-on-parameters, the edit buffer, the
RowVersionround trip, and refetch-after-save are all covered onConferenceCategoryDetailandQuestionEditModel. The page-scopedCancellationTokenSource(PageToken), theIsEditing/IsDirtypair, andBeginEdit/EndEditare inherited fromDetailPageBase(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 byQuestionEditModel.ToUpdatedrather 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 theOption.{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_loadedIdguard (lines 72-77), thenLoadGuard.Begin()(line 80), which supersedes any load already in flight and returns a(token, generation)pair.Id.Parse<QuestionIdentifierType>()(line 85),GetByIdAsyncwith thattokeninstead ofPageToken(line 86), and an early return whenLoadGuard.IsCurrent(generation)is false (lines 87-90), so a superseded response never overwrites the newer one. Then the not-found branch that clearsQuestionand reports throughErrorMessages(lines 96-101),NotifyOnFailurefor anything else (line 104), and afinallythat clearsIsLoadingonly 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 inheritedBeginEdit(line 130);CancelEditing(line 133) is a one-line forward to the inheritedEndEdit.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 withEndEdit(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 fromQuestionListrows and fromQuestionCreate'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 toRoomEditModel, 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),ResultUiExtensionsforIsNotFoundandNotifyOnFailure(lines 81, 90, 99, 155, 162, 198), andModelValidation(line 42). Externals: Blazor ([Parameter],NavigationManager), MudBlazor (MudForm,MudSimpleTable,BreadcrumbItem),System.Globalization.CultureInfo(line 120). The markup composesPageLoadingState,PageErrorState,ErrorSummary,UnsavedChangesGuard,QrCodeButtonandDeleteConfirmationfromMMCA.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 / elsechain over three page states: loading, not-found, and loaded, with the loaded state further split by the inheritedIsEditing(.../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.Idis declared[Parameter] public string Id(line 25), not anint, and the route is the untyped/rooms/{Id}(.../Pages/Rooms/RoomDetail.razor:1). The code-behind converts it itself withId.Parse<RoomIdentifierType>()(line 74), an extension fromMMCA.Common.Shared.Extensions(line 6).SponsorDetailtakes the opposite approach with a constrained{Id:int}route and anintparameter (.../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:OnParametersSetAsyncreturns immediately whenId == _loadedId(lines 61-64). Blazor callsOnParametersSetAsyncon 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:_loadedIdis the invalidation key, and_eventsis 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:StartEditingseeds the model and opens the editor with the inheritedBeginEdit(lines 122-131),CancelEditingis a one-line forward to the inheritedEndEdit(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 wiringRoomCreateuses.OnParametersSetAsync(lines 59-117) sets_loadedId, then callsLoadGuard.Begin()(line 69) to supersede any load already in flight, receiving a(token, generation)pair. It flipsIsLoading, parses the id (line 74) and fetches throughIRoomUIService.GetByIdAsyncwith thattokenrather thanPageToken(line 75). IfLoadGuard.IsCurrent(generation)is false the method returns (lines 76-79), so a superseded response never overwrites a newer one. AResultcarryingNotFoundis handled distinctly from any other failure: it nullsRoomand toastsErrorMessages.NotFound(EntityName, Id)(lines 81-86), which is what makes the template'sPageErrorStatebranch (.../Pages/Rooms/RoomDetail.razor:21-24) reachable. Every other failure goes throughNotifyOnFailure(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.IsLoadingis cleared in thefinallyonly 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 underCultureInfo.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 withBeginEdit(line 130);CancelEditing(line 133) forwards toEndEdit.SaveChangesAsync(lines 135-178) validates the form (lines 142-147), posts_model.ToUpdated(Room)throughUpdateAsync(line 152), then re-fetches the room (line 159) and swapsRoomfor the refreshed instance before toasting and closing the editor withEndEdit(lines 166-168).IsSavingis cleared in thefinally(lines 174-177).DeleteRoomAsync(lines 180-209) awaits_deleteConfirm.ShowAsync(Room.Name)and treats anything other thantrueas a cancel (lines 187-191), then callsIRoomUIService.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
RoomCreateon success (.../Pages/Rooms/RoomCreate.razor.cs:113) and of every row link and mobile card onRoomList(.../Pages/Rooms/RoomList.razor.cs:78,.../Pages/Rooms/RoomList.razor:51,89,102). ItsQrCodeButtonpoints atConferenceRoutePaths.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 baseIEntityService.DeleteAsync(id, ...). That overload issuesDELETE rooms/{roomId}?eventId={eventId}(.../Services/Rooms/RoomService.cs:42-46), which the API's delete route needs: it declares[FromQuery] EventIdentifierType eventIdand buildsnew RemoveRoomCommand(eventId, id)from it (.../MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:267-274).RoomListcalls the same overload (.../Pages/Rooms/RoomList.razor.cs:85), so both delete paths carry the owning event. The baseDeleteAsync(id, cancellationToken)is still reachable on the same service, and a call shapedDeleteAsync(Room.Id, PageToken)would bind it and send noeventId; 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) andRoomDTO. Externals: MudBlazor (MudDataGrid,GridState,GridData), BlazorNavigationManager. - Concept introduced: none new at the page level. The event-scoped list machinery lives in
EventFilteredListPageBase<TDto>(.../Pages/Common/EventFilteredListPageBase.cs:25), which sits onDataGridListPageBase<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,- plus
GridRef(line 21) andTitle(line 15) are the entire contract, and everything else the page needs (the event lookup, the persistedeventIdfilter, 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 onIsMobilebetween a card list and a grid (.../Pages/Room/RoomList.razor:39,70), and within the grid three columns carryHeaderClass="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.
- plus
- Walkthrough
SavePageFilters/RestorePageFilters(lines 29-33) persist exactly one page-specific value, the search string, under the keysearch. The event filter is persisted by the base.ReloadActiveLayoutAsync(lines 35-36) delegates toListPageActions.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'sServerDatadelegate. The first thing it does isawait WaitForEventsAsync()(line 50); the in-code comment (lines 48-49) explains why that cannot be skipped:ApplyFiltersruns insideLoadServerDataAsync, so the default event filter has to be resolved before entering it.WaitForEventsAsyncawaits the load the base started before its firstawait(.../Pages/Common/EventFilteredListPageBase.cs:192).ApplyFilters(lines 59-64) contributes the page-specificNamecontainsfilter when a search string is present (lines 61-62), then calls the base'sApplyEventFilter(line 63), which addsEventIdequalswhen 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 sameApplyFilters, and requests a page sorted byNameascending (line 73). Both layouts therefore query with identical semantics.DeleteRoomAsync(lines 80-88) hands the whole confirm-delete-toast-reload sequence toListPageActions.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 overloadRoomService.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,LoadFailedsupplied by the base).NavigateToCreate(line 90) andOnMobileCardClick(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
/roomsroute reached from the Rooms breadcrumb on bothRoomCreateandRoomDetail, 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 thoughRoomDTO.Capacityis 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 bySponsorCreateModelandSponsorEditModel. - Depends on:
SponsorDTOfor every length cap andSponsorTierfor the package enum (line 2), plusAbsoluteUrlAttributefromMMCA.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 isTier. It is declaredSponsorTier?and[Required](lines 74-75), and the doc comment states the reason plainly (lines 69-73):SponsorTier.Platinumis the enum's zero value, so a non-nullableSponsorTier Tierwould 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 (LogoUrlline 46,WebsiteUrlline 51,LinkedInUrlline 56) each carry[AbsoluteUrl(ErrorMessage = "Error.AbsoluteUrl")]on top of their[MaxLength], fromAbsoluteUrlAttribute(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:LogoUrlis rendered as an image source andWebsiteUrlandLinkedInUrlas outbound links onSponsorDetail(.../Pages/Sponsor/SponsorDetail.razor:59,:119,:123), so requiring an absolute URL at entry is the first line of defence, and the links themselves carryrel="noopener noreferrer"(.../Pages/Sponsor/SponsorDetail.razor:119,123).[Rubric §27, Internationalization]applies exactly as on the room model: everyErrorMessageis 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 useError.*(lines 32, 36, 40) where rooms useValidation.*. - Walkthrough
NameRequiredKey = "Error.NameRequired"(line 32) andTierRequiredKey = "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 atSponsorDTO.DescriptionMaxLength, 2000 (SponsorDTO.cs:24).LogoUrl(line 46),WebsiteUrl(line 51) andLinkedInUrl(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) andIsExhibitor(line 81) close the model out.SortandIsExhibitorare 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
SponsorCreateModelandSponsorEditModel; bound as theModelparameter ofSponsorFormFields(.../Pages/Sponsor/SponsorFormFields.razor:103), rendered bySponsorCreateandSponsorDetail. - Caveats / not-in-source:
TwitterHandlehas 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:
SessionDTOfor every length cap (MMCA.ADC.Conference.Shared.Sessions, line 2) and theRoomidentifier alias. Externals:System.ComponentModel.DataAnnotations. - Concept introduced: the shared-form-model idea is
EventFormModel's. Three details are specific to sessions:- The split date/time pair. MudBlazor has no single date-and-time control, so a session's
StartsAtandEndsAtare each bound as two properties:StartsAtDate/StartsAtTimeandEndsAtDate/EndsAtTime(lines 64-73).Combine(line 98) is theprotected staticrejoin, and it returnsnullunless 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. - A client mirror of a server invariant.
HasEndAfterStart(lines 87-92) rejoins both timestamps throughCombineand returnsfalseonly 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). SessionTitlerather thanTitle. The doc comment (lines 39-42) records the reason: every page in this group already has its own localizedTitleproperty, 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 areSessionDTO.TitleMaxLengthand its siblings (lines 44, 48, 52, 56, 60), so the browser rejects what the column would reject; theCombinerule makes an incomplete timestamp impossible to post by accident; andHasEndAfterStartlets 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.
- The split date/time pair. MudBlazor has no single date-and-time control, so a session's
- Walkthrough
SessionTitleRequiredKey(line 31) is the hoisted resource key, the same techniqueEventFormModeluses;EndsAtAfterStartsAtKey(line 37) is its BR-122 sibling.- Five annotated text properties (lines 43-61):
SessionTitleis required,Description,Status,AccessibilityInfo, andResourceLinksare optional. - Six unannotated properties (lines 64-79): the four date/time halves,
IsServiceSession(the lunch-or-break flag, line 76), and the nullableRoomId(line 79). HasEndAfterStart()(lines 87-92) returnsstartsAt is null || endsAt is null || endsAt.Value > startsAt.Valueover the two rejoined timestamps (line 91).Combine(DateTime? date, TimeSpan? time)(lines 98-99) returnsdate.Value.Date + time.Valuewhen both are present, otherwisenull.
- 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
CombineandHasEndAfterStarton the base makes that impossible to get wrong per page. - Where it's used:
SessionCreateModelandSessionEditModelderive from it;SessionCreateandSessionDetailbind an instance to the sharedSessionFormFieldscomponent (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionFormFields.razor), callHasEndAfterStartbefore posting, and resolveEndsAtAfterStartsAtKeyfor 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),SponsorDTOand theEventIdentifierTypealias (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 freshSponsorDTO.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:113navigates tocreated.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
_modelonSponsorCreate(.../Pages/Sponsor/SponsorCreate.razor.cs:76), consumed inCreateSponsorAsync(.../Pages/Sponsor/SponsorCreate.razor.cs:104). - Caveats / not-in-source: the
!on line 26 is only as safe as the caller's validation.CreateSponsorAsyncdoes check both_form.IsValidand_eventId is nullbefore 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, addingLoadFromandToUpdatedtoSponsorFormModel(line 12). - Depends on:
SponsorFormModel(base) andSponsorDTO(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.SponsorDTOimplementsIConcurrencyAwareand carries abyte[] 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 thatRowVersionis not a property onSponsorFormModel: 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, includingTier = 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) andEventId(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
_modelonSponsorDetail(.../Pages/Sponsor/SponsorDetail.razor.cs:64);LoadFrominStartEditing(.../Pages/Sponsor/SponsorDetail.razor.cs:132),ToUpdatedinSaveChangesAsync(.../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:
SessionFormModelandSessionDTO(line 1), plus theEventidentifier alias. No externals. - Concept introduced, server-allocated identifiers in an app-assigned key space. Like
EventCreateModel, this model postsId = default(line 25). The reason it is worth naming is that a session'sintprimary 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: whencommand.Id == default,CreateSessionHandlerallocates the next id inside the reserved manual range (SessionInvariants.ManualIdRangeStarttoManualIdRangeEnd) and fails withSession.ManualIdRangeExhaustedpast 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 toMaxManualIdAttempts(3) times (.../CreateSessionHandler.cs:33,:60). The page readscreatedSession.Idback 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),Titlefrom the base'sSessionTitle(line 26),Description(line 27), the owningEventIdfrom the parameter (line 28), the optionalRoomId(line 29),StartsAtandEndsAteach through the base'sCombine(lines 30-31), andIsServiceSession(line 32).- The
Status,AccessibilityInfo, andResourceLinksproperties 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
_modelfield ofSessionCreate(.../Pages/Sessions/SessionCreate.razor.cs:36), consumed by itsCreateSessionAsync(.../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 bySessionDetail's inline editor.LoadFromopens the editor andToUpdatedcloses it, replacing what the class doc calls thirteen assignments in the page each way (lines 11-14). - Depends on:
SessionFormModelandSessionDTO(line 1). - Concept introduced: none new. The shadow-editing-through-a-model idea is
EventEditModel's, and the split-timestamp handling isSessionFormModel's. What is worth naming is the symmetry:LoadFromsplits each timestamp with?.Dateand?.TimeOfDay(lines 29-32) andToUpdatedrejoins it withCombine(lines 58-59), so an untouched session round-trips to the same value.ToUpdatedbuilds its result assession 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 thatwithexpression the loadedRowVersion, 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 theRoomId(line 37).ToUpdated(SessionDTO session)(lines 49-65): null guard, thensession withoverriding nine members off the model (lines 55-63):Title,Description,RoomId, the two rejoined timestamps,Status,IsServiceSession,AccessibilityInfo, andResourceLinks. 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 sendsnull, 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
withmeans a DTO field the form never shows cannot be reset by omission. - Where it's used: the
_modelfield ofSessionDetail(.../Pages/Sessions/SessionDetail.razor.cs:71), driven by itsStartEditing(.../Pages/Sessions/SessionDetail.razor.cs:166) andSaveChangesAsync(.../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, andIRoomUIService; theEventInfo,SpeakerInfo, andCategoryItemInfolookup records;RoomDTO; andResult(line 4). Externals:System.Globalization.CultureInfo. - Concept introduced, the page-owned collaborator with an input-keyed cache. Two ideas make this
class worth reading:
- 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'sOnInitialized(.../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. - 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
_roomNamesis 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 localizeddeletedSpeakerLabel, 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.
- Not a registered service, deliberately. The
- Walkthrough
- State: three nullable
IReadOnlyDictionaryfields (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 byEventId equals <id>(lines 112-118) and assigns_roomNames,EditableRooms, and_roomsForEventIdtogether (lines 124-126).- Naming:
EventName(line 133),SpeakerName(id, deletedSpeakerLabel)(line 145),CategoryItemName(line 158), andRoomName(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) andCategoryItemsExcept(lines 201-210) subtract the already-assigned ids through aHashSetso the add pickers never offer a duplicate, and return an empty sequence while the lookup is unloaded.
- State: three nullable
- Why it's built this way:
SessionDetailis 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 toMMCA.Commonbecause it is shaped entirely by Conference's own lookup services. - Where it's used:
SessionDetailonly (.../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
SessionCreateModelfields 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),IEventLookupServicereturningEventInfo(line 23),IRoomUIServicefor the room dropdown (line 24),IToastService(line 26),SessionCreateModel(line 47) and its baseSessionFormModelfor the BR-122 key (line 188),RoomDTO,ModelValidationplusDataAnnotationsModelValidator(line 81),ConferenceRoutePaths,ErrorMessages(line 182),ResultUiExtensions.NotifyOnFailure, which posts the last save'sResult(_saveResult, line 56) for the page's inlineErrorSummary, and theLatestLoadGuard(_roomsLoad, line 33) plus the_cts.LifetimeToken()extension (LifetimeToken, line 38). It uses theEvent,Room, andSessionidentifier aliases. Externals: Blazor ([Inject],NavigationManager), MudBlazor (MudForm,BreadcrumbItem,MudAlert),System.Globalization.CultureInfo, and theIStringLocalizer<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, andOnEventChangedAsync(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:CreateSessionAsyncchecksSessionFormModel.HasEndAfterStart(BR-122) before posting (lines 186-191).[Rubric §19, State Management & Data Flow]:_roomsis derived state, explicitly invalidated when its input changes rather than left to go stale, and_model.RoomIdis 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:LoadRoomsAsynccalls_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:SessionCreateModelpostsId = defaultand leaves allocation in the reserved manual-id range to the server, the same choice asEventCreate's. - Walkthrough
OnInitializedAsync(lines 71-104) builds the breadcrumb trail (lines 73-78), wires the validation delegate (line 81), loads the event lookup throughLifetimeTokenand toastsSnackbar.LoadLookupsFailedon failure (lines 85-91), auto-selects the only event when the lookup has exactly one entry (lines 93-96, the same single-conference convenience asRoomCreate), then callsLoadRoomsAsync(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_roomsand returns (lines 121-125); otherwise it fetches up to 500 rooms filtered byEventId 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_saveResultand_scheduleErrorbefore 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 localizedSessionFormModel.EndsAtAfterStartsAtKeytext in_scheduleError(declared at line 59), toasts it as a warning, and returns without posting (lines 186-191); the template renders_scheduleErroras an inlineMudAlertuntil the next attempt (.../Pages/Sessions/SessionCreate.razor:43-45). Otherwise it setsIsSaving, posts_model.ToNew(_eventId)withLifetimeTokenand stores the outcome in_saveResult(lines 196-197), and on failure callscreated.NotifyOnFailure(Toast, L)(line 202) rather than a fixedToast.Error, so the wording also renders inline through the page'sErrorSummary, 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 toConferenceRoutePaths.SessionDetails(createdSession.Id)(line 208). Thefinallyalways clearsIsSaving.Dispose(bool)(lines 224-239) cancels and disposes_ctsand also disposes_roomsLoad(line 235).LifetimeToken(line 38) exists because readingTokenoff a disposed source throwsObjectDisposedException; the extension hands a late-resuming handler a cancelled token instead, so it stops through itsOperationCanceledExceptionpath.- 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_roomsplusRoomPickerBeforeSchedule="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
SessionFormModelcomposes 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/createroute (.../Pages/Sessions/SessionCreate.razor:1), reached fromSessionList's create button; on success it hands off toSessionDetail.
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, andIRoomUIService(lines 29-35), plusIToastService(line 37);SessionLookups(line 46) andSessionEditModel(line 72), withSessionFormModelfor the BR-122 key (line 192); a sharedLoadGuardhelper for the load-generation protocol (line 100); theSessionDTOshape and theSpeakerInfo/CategoryItemInfolookup records;Result,ResultUiExtensions.NotifyOnFailure,ConferenceRoutePaths,ErrorMessages(line 114), theDeleteConfirmationcomponent, andDomainHelper'sParse<T>extension (MMCA.Common.Shared.Extensions, line 10; called at line 105). Uses theEvent/Room/Speaker/Session/SessionSpeaker/SessionCategoryItem/CategoryItemaliases. - 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:- A monotonic load generation, factored into a shared
LoadGuardhelper.LoadAsynccallsLoadGuard.Begin()(line 100) for a cancellation token and a generation number, then re-checksLoadGuard.IsCurrent(generation)after each of its three awaits (lines 106, 130, 141) and drops its results if a newer load has started; the token fromBegin(), notPageToken, is what each of those three calls passes down._loadedIdis 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 nullsSession(line 122, comment lines 120-121 onL156), because the edit, delete and child-mutation actions are bound toSessionand must not act on the previous session under the new id. Thefinallyis 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 inLoadGuardrather 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. - 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 fullLoadAsync(line 318) rather than a local patch. Its failure toast depends on the error kind (lines 310-312, doc comment lines 295-301): anErrorType.Unexpectedfault (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 viaLocalizedErrorMessage(L), falling back to the fixed key (ADR-027 Decision 9 carve-out).[Rubric §8, Data Architecture]: the update DTO re-sends the loadedRowVersionthroughSessionEditModel.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 sharedSessionFormModel.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 inSessionLookupsand form shape inSessionEditModel.
- A monotonic load generation, factored into a shared
- Walkthrough
OnInitialized(lines 48-63) builds breadcrumbs, constructsSessionLookupsfrom the four injected lookup clients (lines 58-59), and wires the validation delegate (line 62).OnParametersSetAsync(lines 86-95) loads only when the routeIddiffers 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), toastErrorMessages.NotFoundand bail when the session is missing (lines 111-116), nullSessionand report any other failure throughNotifyOnFailure(lines 118-125), then hydrate the global lookups (line 129) and the event's rooms (line 140), each followed by aLoadGuard.IsCurrentre-check.- Edit and save (lines 163-224):
StartEditingcalls_model.LoadFrom(Session)thenBeginEdit(lines 170-171).SaveChangesAsyncclears_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 localizedSessionFormModel.EndsAtAfterStartsAtKeytext in_scheduleError, toasts it as a warning, and returns without posting (lines 190-195); the template renders it as an inlineMudAlert(.../Pages/Sessions/SessionDetail.razor:52-54). Otherwise it posts_model.ToUpdated(Session)and refetches, both written as a single expression chainingNotifyOnFailureintoIsFailure/TryGetValue(lines 200-210), then toasts andEndEdit(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 toSessionLookupsso a picker never offers an already-assigned entry;AddSessionSpeakerAsync(lines 260-272) andAddSessionCategoryItemAsync(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 sameMutateChildAsync.
- 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 fromSessionListrows and fromSessionCreate's success redirect; its "view feedback" button opensOrganizerSessionFeedbackthroughConferenceRoutePaths.SessionFeedbackOrganizer(.../Pages/Sessions/SessionDetail.razor:182-186). - Caveats / not-in-source: reads pass
includeChildren: trueso 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),IEventLookupServicewithEventInfo(lines 22, 84),IToastService(line 24), an injected BCLTimeProvider(line 25),SponsorCreateModel(line 85),CurrentEventSelector(line 69),ConferenceRoutePaths(lines 44, 134, 146),ErrorMessages(line 115),ModelValidationwithDataAnnotationsModelValidator(line 49),ResultUiExtensions.NotifyOnFailureover the create result (line 128), and the_cts.LifetimeToken()extension (LifetimeToken, line 32). Externals: Blazor, MudBlazor (MudForm,BreadcrumbItem), and theIStringLocalizer<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, andOnInitializedAsync(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 whatRoomCreatedoes at.../Pages/Rooms/RoomCreate.razor.cs:65-67), it callsCurrentEventSelector.SelectCurrentOrNextover each event's start date, end date and IANA time zone against the injectedTimeProvider.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 fromRoomCreate: 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 nullalongside_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 byMarkDirty(line 101) whichSponsorFormFieldsinvokes through itsOnFieldChangedcallback (.../Pages/Sponsors/SponsorCreate.razor:26). A second field,_saveResult(line 93), holds the last create attempt's failedResultso the page'sErrorSummarycan 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 lastdisabled: true(lines 41-46), then the validation delegate (line 49, ADR-027 comment at line 48).OnInitializedAsync(lines 52-80): awaitsbase.OnInitializedAsync()(line 54), loads the event lookup throughLifetimeToken(line 60, so a handler resuming after disposal gets a cancelled token rather than anObjectDisposedExceptionfrom_cts.Token), then resolves the default (lines 67-74).OperationCanceledExceptionis 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), setsIsSaving(line 119), posts_model.ToNew(_eventId.Value)throughAddAsyncwithLifetimeTokenand stores the result in_saveResult(lines 122-123), and on a failedResultcallscreateResult.NotifyOnFailure(Toast, L)(line 128) rather than a fixed toast, so the same wording also surfaces inline. On success it clears_isDirtybefore navigating (line 132, with the comment), toasts, and routes toConferenceRoutePaths.SponsorDetails(created.Id)(line 134).IsSavingis cleared in thefinally(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,
LinksHeadingandTwitterPlaceholder(.../Pages/Sponsors/SponsorCreate.razor:27-28), which is how oneSponsorFormFieldscomponent (.../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 toSponsorDetail.
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: suppliesPageToken,IsEditing/IsDirtyand theBeginEdit/EndEdittransitions),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 sharedLoadGuard(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- 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 aswitch. The same helper appears onSponsorList(.../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 againstRoomDetail, 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_loadedIdreload guard applies (lines 71-80), just typedint?. The page no longer declares its ownIDisposable,CancellationTokenSource, or edit-mode fields: it now extendsDetailPageBase, which owns the page-scopedPageTokenthe save, reload and delete calls take (lines 164, 171, 207), and theIsEditing/IsDirtypair the unsaved-changes guard reads.StartEditingopens the editor throughBeginEdit()(line 142) instead of setting two fields by hand, and bothCancelEditing(line 145) and the end of a successfulSaveChangesAsync(line 180) close it throughEndEdit(), so the dirty flag can never be left set behind a closed editor.LoadAsync, though, does not takePageToken: it begins a monotonic load generation withLoadGuard.Begin()(line 85), passes that token to both fetches (lines 90, 113), re-checksLoadGuard.IsCurrent(generation)after the sponsor fetch (line 91), and clearsIsLoadingonly 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 protocolSessionDetailuses.[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 inLoadGuard, rather than being repeated per detail page.[Rubric §26, Front-End Security]assesses outbound-link handling: the two sponsor URLs render asMudLinkwithTarget="_blank"andrel="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 getsAlt="@Sponsor.Name"(:59), and both header icon buttons carryaria-label(:72-73).
- is a one-liner:
- Walkthrough
EntityName(line 21) andTitle(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 toLoadAsync.LoadAsync(lines 82-132) begins a load generation (line 85) and fetches withGetByIdAsync(Id, true, token)(line 90), using the guard's token rather than a page-ownedCancellationTokenSource. Note the second argument:includeChildren: true, whichRoomDetaildoes not request (.../Pages/Room/RoomDetail.razor.cs:75passes the token by name and leaves the flag defaulted). ANotFoundResultnullsSponsorand toasts (lines 96-101); any other failure goes throughNotifyOnFailure(line 105). The event lookup is then loaded once, lazily, with the guard's token (lines 113-117), andIsLoadingis cleared in thefinallyonly for the current generation (lines 123-131).StartEditing(lines 134-143) seedsSponsorEditModelfrom the loaded sponsor and opens the editor throughDetailPageBase'sBeginEdit()(line 142);CancelEditing(line 145) closes it again throughEndEdit()without touchingSponsor.SaveChangesAsync(lines 147-190) validates (lines 154-159), posts_model.ToUpdated(Sponsor)(line 164), re-fetches withincludeChildren: true(line 171), swapsSponsorfor the refreshed instance and leaves edit mode throughEndEdit()(line 180). BecauseSponsorEditModelcarriesRowVersionthrough 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
MudAvatarwhen 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 ofSponsorList(.../Pages/Sponsor/SponsorList.razor.cs:99). ItsQrCodeButtonpoints atConferenceRoutePaths.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: trueactually 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
Colorand to a localized label for chip rendering.GetStatusColor(lines 13-22) switches onSessionStatuses.Accepted,Waitlisted,AcceptQueue,Nominated,DeclineQueue, andDeclined, defaulting toColor.Defaultfor anything else.GetStatusLabelKey(lines 31-40) maps the same six statuses toStatus.*resource keys and returnsnullfor anything else, andGetStatusLabel(lines 50-54) resolves that key through anIStringLocalizer<SessionList>, returning the status as stored when no key matches. - Depends on:
SessionStatusesfor the status constants, andSessionListas the type argument of the localizer inGetStatusLabel(line 50), which is why the helper sits at the same level as the page. Externals: MudBlazor'sColorenum,IStringLocalizer<T>. - Concept introduced, one label source for filter and chip. The labels come from the
SessionListresources 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 outsideSessionStatuses(Sessionize imports any string) is shown as stored rather than hidden.GetStatusLabelnull-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 classletsSessionListandSessionSelectionDisplayshare 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) andSessionSelectionDisplay(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>overSessionDTO(line 22) and injectsISessionUIService,IEventUIService, andISpeakerLookupService(lines 27-29), plusTimeProvider(line 31) as the clock for the default-event computation. It usesEventDTO,SpeakerInfo,SessionStatusDisplayfor the status-to-color mapping and the localized status label (StatusLabel, line 98),CurrentEventDefaults(theEventDTO-typed wrapper overCurrentEventSelector),ConferenceRoutePaths,ErrorMessages(line 221),ListPageActions(lines 155, 215), and theMobileInfiniteScrollList<TItem>,DeleteConfirmation, andListNoRecordsContentcomponents. Uses theEvent/Room/Session/Speakeraliases. - Concept introduced, the multi-filter enriched list.
SessionListlayers three refinements on the event-filtered shapeRoomList,SponsorList, andSpeakerListshare:- A third filter with a sentinel.
_searchString,_selectedStatus, and_selectedEventIdpersist together (SaveFilterslines 49-58,RestoreFilterslines 60-78) and are emitted asTitle contains,Status equals, andEventId equalsserver 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). - Enrichment from two bulk loads instead of per-row fetches.
LoadEventsAndResolveDefaultAsyncfetches events withincludeChildren: true(line 104) and folds every event's rooms into one_roomNamesdictionary (PopulateRoomNames, lines 128-142), while the speaker lookup loaded inOnInitializedAsync(line 88) backsGetSpeakerList(lines 144-152), which maps a row'sSessionSpeakersto display names and skips ids the lookup does not know. Both loads are explicitly non-critical: a failedResultis 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 passesincludeChildren: true(lines 186, 198) so each row arrives with its speaker joins. - 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 andSessionSelectionDisplayshare one copy of the switch. The chip text goes throughStatusLabel(line 98, comment lines 96-97, X-01), which callsSessionStatusDisplay.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 oneRoomListuses, with the clearest explanation in this file:_eventsLoadTaskis started before the first await (lines 82-85) and awaited inside bothLoadServerData(lines 180-181) andFetchMobilePage(lines 193-194), becauseApplyFiltersruns insideLoadServerDataAsync, 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.
- A third filter with a sentinel.
- 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 toCurrentEventDefaults.SelectCurrentOrNext(_events, TimeProvider.GetUtcNow().UtcDateTime)?.Id(line 124). Reading "now" from the injectedTimeProviderrather thanDateTime.UtcNowis 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, andOnEventFilterChanged(lines 157-174) each update one filter and reload whichever layout is active viaListPageActions.ReloadActiveLayoutAsync(lines 154-155).LoadServerData(lines 176-188) andFetchMobilePage(lines 191-199) are the desktop and mobile fetch paths over the sameApplyFilters.DeleteSessionAsync(lines 214-222) delegates the confirm, delete, toast, and reload sequence toListPageActions.DeleteWithConfirmationAsync;NavigateToCreateandNavigateToDetails(lines 224-225) route toSessionCreateandSessionDetail.- The grid uses
PropertyColumns for the sortable fields (Title,StartsAt,EndsAt,RoomName,Status,.../Pages/Sessions/SessionList.razor:114, 148, 158, 171, 183) andTemplateColumns only for the speakers and row actions (lines 121, 199). The status column keeps its chip inside aCellTemplateof aPropertyColumnbecause 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
/sessionsorganizer route (.../Pages/Sessions/SessionList.razor:1-2,Authorize(Roles = RoleNames.Organizer)); rows openSessionDetailand the create button opensSessionCreate. - 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
SessionSpeakersis 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 ofRoomList: 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,EventIdis 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) andSponsorDTO. Externals: MudBlazor (MudDataGrid,MudChip,GridState,GridData). - Concept introduced: none new. Read this page against
RoomList: the two share the same override set (SavePageFiltersline 38,RestorePageFiltersline 41,ReloadForEventFilterAsyncline 47,GridRefline 27,Titleline 21), the sameWaitForEventsAsync-before-ApplyFiltersordering (lines 59, 78, comment at 57-58), the sameNamecontainssearch filter (line 71), and the sameListPageActionsdelegation for reload and delete (lines 44-45, 88-96). That repetition across a dozen list pages is precisely whatEventFilteredListPageBase<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 aMudChipin the grid (.../Pages/Sponsor/SponsorList.razor:82) and as secondary body text on the mobile card (:50), both fed by the sameTierLabelhelper, so the same value reads consistently in two layouts. - Walkthrough
TierLabel(SponsorTier tier)(line 36) is the same convention-keyed localizer lookupSponsorDetailuses (.../Pages/Sponsor/SponsorDetail.razor.cs:35).SavePageFilters/RestorePageFilters(lines 38-42) persist thesearchkey only.ReloadActiveLayoutAsync(lines 44-45) andReloadForEventFilterAsync(line 47) mirrorRoomListexactly.LoadServerData(lines 55-66) awaitsWaitForEventsAsync()(line 59) before delegating to the base'sLoadServerDataAsyncwith the paged fetch andApplyFilters.ApplyFilters(lines 68-73) adds theNamecontainsfilter then the inheritedApplyEventFilter(line 72). This is where the class doc's point lands: becauseEventIdis a real column on the sponsor read model, the base's genericEventIdequalsfilter is sufficient and the page adds nothing.FetchMobilePage(lines 76-83) builds the identical filter set and requestsNameascending (line 82).DeleteSponsorAsync(lines 88-96) usesListPageActions.DeleteWithConfirmationAsyncwith the baseDeleteAsync(sponsor.Id)(line 92):ISponsorUIServicedeclares no extra members (.../Services/ISponsorUIService.cs:9-11) andSponsorServiceis an empty subclass ofEntityServiceBase(.../Services/SponsorService.cs:10-14), so the generic CRUD surface is the whole surface.NavigateToCreate(line 98) andNavigateToDetails(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),SortandBoothNumber, plus a delete action column (.../Pages/Sponsor/SponsorList.razor:72-107). Empty and failed states share the sameListNoRecordsContentwith a retry bound toRetryLoadAsync(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
/sponsorsroute reached from the Sponsors breadcrumb onSponsorCreateandSponsorDetail, 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 theSponsorTiermember 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
EventIdentifierTypeandEventQuestionAnswerIdentifierTypealiases, andResult/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): returnsTask<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): returnsTask<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
SessionIdentifierTypeandSessionQuestionAnswerIdentifierTypealiases, andResult/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-31scoped by SessionIdentifierType; returnsSessionQuestionAnswerDTODeleteAnswerAsync(sessionId, answerId, ct)IOrganizerFeedbackUIService.cs:33-36parent id is the session Where it's used: implemented by OrganizerSessionFeedbackService (
OrganizerFeedbackService.cs:70-118), registered scoped atMMCA.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),SpeakerDTOfor every length constant (:2), andAbsoluteUrlAttributeplusOptionalEmailAttributefromMMCA.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
ActivityFormModelalready 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). TheBioproperty 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):FirstNameRequiredKeyandLastNameRequiredKey, quoted by the model rule and by the field'sRequiredErroraffordance alike. - Required identity fields (
:42-49):FirstNameandLastName, 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.
- Resource-key constants (
- 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
SpeakerDashboardfor the third. - Where it's used: subclassed by
SpeakerCreateModelandSpeakerEditModel; bound through the sharedSpeakerFormFieldscomponent bySpeakerCreate(SpeakerCreate.razor:24) andSpeakerDetail(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()atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:59and the shared static PollyRetryPolicyat: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. BCLIHttpClientFactory,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 forwardsIHttpClientFactoryand ITokenStorageService to the base (OrganizerFeedbackService.cs:15-17); every request goes throughCreateAuthenticatedClientAsync()(: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. Thefilters[...]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): theprivate 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=falsewithstring.Create(CultureInfo.InvariantCulture, ...)(:31-32, culture-invariant so the id renders stably;PageSizeis 500 atPagedReadAll.cs:22, and the stableIdascending sort keeps consecutive pages from skipping or repeating a row); runs the GET insideHttpResultExecutor.ExecuteAsync(:28) with the request itself wrapped inRetryPolicy.ExecuteAsync(:35-37); and reads the response throughProblemDetailsResultReader.ReadAsync<PagedCollectionResult<EventQuestionAnswerDTO>>(:38-39). The accumulated list is thenMapped toIReadOnlyList<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 throughProblemDetailsResultReader.ReadAsync(:61). It returns a bareResult: 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 byOrganizerEventFeedbackServiceTestsin 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 atPagedReadAll.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
OrganizerEventFeedbackServicein the same file, keyed onSessionIdand thesessionquestionanswersresource (OrganizerFeedbackService.cs:70-120), and it implementsIOrganizerSessionFeedbackUIService.Depends on:
AuthenticatedServiceBaseas its base (OrganizerFeedbackService.cs:72), forCreateAuthenticatedClientAsync()and the shared static PollyRetryPolicy;PagedReadAllfor the read-every-page loop;HttpResultExecutorandProblemDetailsResultReaderfor the two halves of the Result conversion;PagedCollectionResult<T>as the paged envelope;SessionQuestionAnswerDTOas the row shape;ITokenStorageServicethrough the base; theSessionIdentifierType/SessionQuestionAnswerIdentifierTypealiases; BCLIHttpClientFactoryandCultureInfo.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 aResultinstead of an exception. Two collaborators split that job, and both are required for the signature to be honest.ProblemDetailsResultReaderconverts a response: it reads the API's ProblemDetails body and preserves the server's ownErrorType, so a refusal from the moderation policy arrives as a typed failure the page can render.HttpResultExecutorconverts 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) OrganizerEventFeedbackServiceMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:15eventquestionanswersroot (:19); filters onEventId(:32); delete scoped?eventId=(:52)OrganizerSessionFeedbackServiceMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:70sessionquestionanswersroot (:74); filters onSessionId(: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 toPagedReadAll.ReadAllAsync(:82), which keeps requesting pages until one is empty or the server's reported total is reached (the comment at:80-81names 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=falsewithstring.Create(CultureInfo.InvariantCulture, ...), so the id never formats under the user's locale (:86-87). It runs the call insideHttpResultExecutor.ExecuteAsync(:83), creates the bearer-carrying client (:89), issues the GET through the inherited PollyRetryPolicy(:90-92), and reads the response into aResult<PagedCollectionResult<SessionQuestionAnswerDTO>>(:93-94). The loop accumulates each page's items, and the method maps the accumulated list to theIReadOnlyList<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 valuelessResultthe reader produces (:116). The parentsessionIdis 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) asIOrganizerSessionFeedbackUIServiceatMMCA.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 intoOrganizerSessionFeedback(Pages/Feedback/OrganizerSessionFeedback.razor.cs:20).Caveats / not-in-source: the read is no longer a single capped call, but it is still bounded:
PagedReadAllstops 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:
IUserUIServiceandUserListDTOfrom the Identity module (:1-2), andResult<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 failedResultinto 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 thepageSizeof each of the three calls and as the finalTake(: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 withTask.WhenAll(:46), so the three round trips overlap; concatenates, thenDistinctBy(u => u.UserId)(:51) because a person matching on two fields must appear once.ItemsOf(:59-60) is astaticlocal 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'sSearchUsersAsync(SpeakerDetail.razor.cs:289-290), which is bound to theMudAutocomplete'sSearchFunc(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
activitiesREST 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.InterfacesatIActivityUIService.cs:2) and ActivityDTO (the transported shape, fromMMCA.ADC.Conference.Shared.ActivitiesatIActivityUIService.cs:1).ActivityIdentifierTypeis 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 rawHttpClient; 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 everyIEntityService<,>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
IEntityServicebound toActivityDTOplusActivityIdentifierType, 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
SpeakerDTOit posts. - Depends on:
SpeakerFormModel(:10) andSpeakerDTO(:1). - Concept introduced: none new beyond
SpeakerFormModel, but one detail is worth contrasting.ToNewmints the identity client-side withId = Guid.NewGuid()(:20), whereActivityCreateModelsendsId = defaultand 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 composesFullNamefrom 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:
SpeakerCreateholds one instance (SpeakerCreate.razor.cs:50) and postsToNew()(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) andSpeakerDTO(:1). - Concept introduced: the typed edit buffer, as
ActivityEditModeldescribes it. The speaker variant builds its update as awithexpression 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): returnsspeaker with { ... }(:45), overriding only the ten edited values plus the recomposedFullName(:49). Everything else on the loaded record round-trips untouched, and the doc names what that covers (:34-38): the identity, theRowVersionconcurrency 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 (seeSpeakerDetail), soLinkedUserIdcannot 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
withexpression inverts the default, so a new field onSpeakerDTOis preserved unless this model chooses to edit it. - Where it's used:
SpeakerDetailholds one instance (SpeakerDetail.razor.cs:65), seeds it inStartEditing(SpeakerDetail.razor.cs:207) and postsToUpdatedfromSaveChangesAsync(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
activitiesresource, a body-less class that inherits every CRUD method from the shared base and supplies only the endpoint name (ActivityService.cs:10-14). It implementsIActivityUIService. - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>as its base (fromMMCA.Common.UI.Services,ActivityService.cs:2);ITokenStorageService(fromMMCA.Common.UI.Services.Auth,ActivityService.cs:3);ActivityDTO; BCLIHttpClientFactory. - Concept introduced, the four-line concrete UI service (Template Method with a supplied endpoint).
The primary constructor forwards
IHttpClientFactoryandITokenStorageServiceplus the literal resource name"activities"toEntityServiceBase<TEntityDTO, TIdentifierType>closed overActivityDTOandActivityIdentifierType(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 freshIdempotency-Keyheld constant across retries (EntityServiceBase.cs:171-176), and an update sends the DTO's concurrency token asIf-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 satisfiesIActivityUIService(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 insideAddUIModule<ConferenceUIModule>()registers itAsImplementedInterfaces()with a scoped lifetime (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:31callingMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:320-326), which is what makesIActivityUIServiceresolvable inActivityList(Pages/Activity/ActivityList.razor.cs:24),ActivityDetail(Pages/Activity/ActivityDetail.razor.cs:23),ActivityCreate(Pages/Activity/ActivityCreate.razor.cs:20) andPublicActivityList(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'sPublicSpeakerDetails(id)route factory (:62, defined atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:52). Externals: the cascadingTask<AuthenticationState>(:29-30), MudBlazor'sBreadcrumbItemandIcons(:32,40), and theIStringLocalizer<SpeakerQr>injected by the template (SpeakerQr.razor:4). The code image itself is the sharedQrCodeImagecomponent fromMMCA.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.
- The payload needs no backend call; only the label does. The speaker's identity comes from the
speaker_idJWT 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 asSpeakerDashboardhave 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-blankFullNamereplaces 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. - 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 atSpeakerQr.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 localizedAltText(SpeakerQr.razor:27), because a QR code is opaque to a screen reader by construction.
- The payload needs no backend call; only the label does. The speaker's identity comes from the
- 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 fromstate.User.Identity?.Name(:54), then looks for thespeaker_idclaim and returns unless it parses as aGuid(:56-60). Only on that path is_payloadassigned (:62), followed by the profile read that upgrades_displayNameto 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, andErrorCorrection="QrErrorCorrectionLevel.Medium"so glare on a phone camera does not kill the scan.
- State (
- 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/qrroute, 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 routePublicSpeakerDetailserves.
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 oneSpeakerDTO, and redirects to the detail page for the record it just made. - Depends on:
ISpeakerUIService(:18),IToastService(:20),SpeakerCreateModel(:50),ModelValidationandDataAnnotationsModelValidator(:44),ConferenceRoutePaths(:39,99,111),ErrorMessages(:80), and theLifetimeTokenextension fromComponentLifetimeExtensions(:27). Externals:NavigationManager(:19), MudBlazor'sMudForm,BreadcrumbItemandIcons, and theIStringLocalizer<SpeakerCreate>from the template (SpeakerCreate.razor:6). The markup mounts the sharedUnsavedChangesGuard,SpeakerFormFieldsandErrorSummarycomponents (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.
- One validation delegate for the whole form.
OnInitializedbuilds_validate = ModelValidation.For(_model, new DataAnnotationsModelValidator(L))(:44, field at:64) and the shared field block hands that singleFunc<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 onSpeakerFormModelinto sentences, per ADR-027.[Rubric §24, Forms, Validation & UX Safety]and[Rubric §27, Internationalization]. - Validate before you mutate.
CreateSpeakerAsyncawaits_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. - An inline error result alongside the toast.
_saveResult(:54-58) records the last create attempt'sResult, reset tonullbefore each attempt (:75) and set from the service call (:88). On failureCreateSpeakerAsynccallscreateResult.NotifyOnFailure(Toast, L)(:93) instead of a bareToast.Error, so the message reaches both the snackbar and the page'sErrorSummarycomponent and survives a snackbar timing out while the organizer is still reading the form. - A dirty flag that cannot block its own redirect. Every field change calls
MarkDirty()(:66, wired atSpeakerCreate.razor:25), the guard reads it through an accessor (SpeakerCreate.razor:10), and the success path clears_isDirtybefore navigating, with the reason on the line (:97).[Rubric §25, Navigation & Information Architecture]. - Cancel on disposal, read through a safe token. A page-scoped
CancellationTokenSource(:22) is cancelled and then disposed in the fullDispose(bool)pattern (:115-129). The post does not read_cts.Tokendirectly: it reads the page'sLifetimeTokenproperty (:27, passed at:87), which forwards to theLifetimeToken()extension (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26). ReadingTokenoff a disposed source throwsObjectDisposedException, 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 ordinaryOperationCanceledExceptionpath instead of crashing the circuit. That exception is caught and ignored as the expected teardown or InteractiveAuto transition outcome (:101-104, see ADR-056).
- One validation delegate for the whole form.
- 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, setIsSaving(:84), post_model.ToNew()with theLifetimeToken(:87) and record the result to_saveResult(:88), report a failed create throughNotifyOnFailure(:93), then clear the dirty flag, snackbar success, and route toConferenceRoutePaths.SpeakerDetails(created.Id)(:99) using the id read back from the response. Thefinallyalways clearsIsSaving(: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/createroute with[Authorize(Roles = "Organizer")](SpeakerCreate.razor:1-2), reached fromSpeakerList's create button and redirecting toSpeakerDetail.
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),SpeakerDTOandSpeakerCategoryItemDTO(:3,24,49),CategoryItemInfo(:27,59), theLifetimeToken()extension fromComponentLifetimeExtensions(:40), and theCategoryItemIdentifierType,ConferenceCategoryIdentifierTypeandSpeakerCategoryItemIdentifierTypealiases. - 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 theChangedcallback (:33,86,106), whose handler on the page isLoadAsync(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 ownIDisposableimplementation (: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 privateLifetimeTokenproperty (:40) rather than_cts.Token: the source is cancelled and then disposed, and readingTokenoff a disposed source throws, whereas theLifetimeToken()extension (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26) hands back an already-cancelled token, so a late handler stops through itsOperationCanceledExceptionpath. The lookups are passed asIReadOnlyDictionary(: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].
- The page owns and reloads the speaker, and owns both lookups; all three arrive as
- 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 aHashSetof 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), callsAddAsync(Speaker.Id, id, LifetimeToken)(:77), reports failure through a localized error toast and returns (:78-82), otherwise clears the selection, toasts success, and awaitsChanged(:84-86).RemoveCategoryItemAsync(:94-112): the same shape overDeleteAsyncwith the sameLifetimeToken(:98), bound to each chip'sOnClose(SpeakerCategoryItemsPanel.razor:15).- Both handlers swallow
OperationCanceledExceptionas 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), fedSpeaker,_lookups?.CategoryItemsand_lookups?.CategoryTitles, withChangedbound straight to the page'sLoadAsync. It is not routable and carries no@pagedirective.
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,ISpeakerDetailLookupServicereturningSpeakerDetailLookups, andIUserUIService(:28-31);IToastService(:33);SpeakerEditModel(:65),SpeakerUserSearch(:289-290),ModelValidationandDataAnnotationsModelValidator(:55),ErrorMessages(:111,223), andConferenceRoutePaths(:50). It extendsDetailPageBase(SpeakerDetail.razor:8), which suppliesPageToken,IsEditing,IsDirty,MarkDirty,BeginEdit,EndEdit, the sharedLoadGuardand the disposal pattern. Externals: MudBlazor'sMudForm,MudAutocompleteandBreadcrumbItem, plus theIStringLocalizer<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 (
OnParametersSetAsynccomparesIdagainst_loadedId,:77-86). This page used to own a second, page-local generation counter; it now delegates that guard toDetailPageBase'sLoadGuardproperty, aLatestLoadGuardinstance (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43).LoadAsynccallsvar (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-checksLoadGuard.IsCurrent(generation)before touching page state (:103,:131,:183). The reasoning is unchanged:_loadedIdis 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. Thefinallyapplies the same rule to the spinner (:148-156): only the current generation may clearIsLoading, 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:LoadAsyncempties_speakerSessionsright afterBegin()(:93-94), because the list belongs to the previous speaker until this load replaces it, and a failed speaker fetch setsSpeaker = nullbefore reporting (:117-120), because the edit and delete actions are bound toSpeakerand 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 meansSessionDetailand the module's public detail pages share the identical mechanism instead of each carrying its own counter. Two further mechanisms sit alongside it.- Server-side filtering instead of client-side filtering.
LoadSpeakerSessionsAsync(:167-195) sends aSpeakerIdequals filter (:170-173) withincludeChildren: false(:180) andsortColumn: "StartsAt"(:178), capped atMaxSpeakerSessions = 100(:38), threading the same guard token through ascancellationToken(: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 onSessionSpeakers, purely so it could match.[Rubric §12, Performance & Scalability](assesses whether work happens where the data lives). - 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-enteringLoadAsyncafter 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 throughNotifyOnFailure(Toast, L)(:120,233,240,275), which is the framework's one-line "report whatever went wrong, localized" path.
- Server-side filtering instead of client-side filtering.
- Walkthrough
OnInitialized(:39-51): breadcrumbs plus the shared validation delegate over_model(:50).OnParametersSetAsync(:77-86) thenLoadAsync(:88-157): begin the guard (:91), clear the previous speaker's sessions (:94), parse the route id defensively (:101; the comment at:99-100notes an unconstrainedstringroute parameter degrades toGuid.Emptyand resolves to not-found rather than throwing out of the render), fetch with children (:102), nullSpeakeron a failed load (:119), fetch the lookups once (:128-140), then the sessions (:142).- Edit cycle:
StartEditing(:200-209) seedsSpeakerEditModelfrom the loaded record and calls the base'sBeginEdit();CancelEditing(:211) is one call toEndEdit(), 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 newRowVersionfor the next edit. DeleteSpeakerAsync(:258-286): confirms through the sharedDeleteConfirmationdialog seeded with the speaker's full name (:265), and treats anything other than exactlytrueas a cancel, so a dismissed dialog is not a delete.- Link and unlink (
:288-340):SearchUsersAsyncdelegates toSpeakerUserSearch(:289-290);OnUserPickedAsync(:292-315) postsLinkUserAsyncand reloads;UnlinkUserAsync(:317-340) is the mirror. Both reload throughLoadAsyncrather 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 toSpeakerUserSearch, the field rules toSpeakerFormModel, and the edit lifecycle and load guard toDetailPageBaseis 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 fromSpeakerListrows and fromSpeakerCreate's success redirect. Session rows link straight toSessionDetailthrough aMudLinkbuilt in the markup fromConferenceRoutePaths.SessionDetails(session.Id); there is no code-behind navigation method for it. It edits the aggregate theSpeakerentity 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),CurrentEventSelectorwithEventInfo(:169,177-182), theLifetimeToken()extension fromComponentLifetimeExtensions(:37), and theIStringLocalizer<SpeakerDashboard>from the template. - Concept introduced, the claim-scoped self-service page. Four mechanisms distinguish this from the organizer pages in this unit.
- Identity comes from the token, not the route. There is no
[Parameter]id: the speaker is read from thespeaker_idclaim (: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]. - Skip the loads on the prerender pass.
OnInitializedAsyncreturns 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. - 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]. - Every await reads a safe lifetime token. The page's
_cts(:32) is cancelled and then disposed, and readingTokenoff a disposed source throwsObjectDisposedException. A privateLifetimeTokenproperty (:37) forwards to theLifetimeToken()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 theOperationCanceledExceptionpath (: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 throughISpeakerDashboardUIServicerather than the shared sessions service on purpose: the comment at:112-114records 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.
- Identity comes from the token, not the route. There is no
- 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,_expandedSessionsand_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 byStartsAt(:122), the current-event narrowing (:124-135), and the batched counts. The outercatch (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 aResult.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 injectedTimeProvider.GetUtcNow().UtcDateTime(:182), not the static clock.- Profile edit (
:185-257):StartEditingProfilecopies six fields into_edit*shadow fields (:192-197);SaveProfileAsyncbuilds the update asSpeaker with { ... }over the loaded record (:216-224), overriding only the six edited fields, so the identity,RowVersionand 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 toSnackbar.ProfileUpdateFailed(:232-234), by testingHasErrorType(ErrorType.Unexpected)and otherwiseLocalizedErrorMessage(L)(ADR-027 Decision 9 carve-out). It then re-fetches (:238) and leaves edit mode (:247). This is the samewithshapeSpeakerEditModeluses for the organizer editor. ToggleFeedbackAsync(:259-297): theHashSet.Addreturn 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 explicitStateHasChanged()(: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.
- State (
- 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/dashboardroute with a bare[Authorize](SpeakerDashboard.razor:1-2); the claim, not the role, decides what it can show. It reads the same aggregateSpeakerDetailedits. - Caveats / not-in-source: the profile editor here does not use
SpeakerFormModel. It binds six loose_edit*fields (:57-62) and its caps come fromSpeakerDTOconstants applied as MudBlazorMaxLength/Counteraffordances in markup (SpeakerDashboard.razor:42-55), so it has no DataAnnotations pass and itsMudForm(: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 overSpeakerDTO(:18), and injectsISpeakerUIService(:23). It usesListPageActions(:42,86),ErrorMessages(:92),ConferenceRoutePaths(:95-96), theMobileInfiniteScrollList<TItem>andDeleteConfirmationcomponents (:31-32), andResult<T>(:73). - Concept introduced, the virtual filter key. Structurally this is
ActivityList: the same five overrides over the same base, the sameWaitForEventsAsyncguard before both fetches (:56,:75), the same delegation of delete toListPageActions. One thing genuinely differs, and it is the interesting part.ApplyEventFilter(:69) addsfilters["EventId"]exactly as it does for activities, but aSpeakerhas noEventIdcolumn: a speaker relates to an event through the EventSpeaker and SessionSpeaker joins. SoEventIdhere 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 assumesEventIdis 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) andReloadForEventFilterAsync(:44): one reload path for both layouts, viaListPageActions.LoadServerData(:52-63) andApplyFilters(:65-70): the search term as acontainsfilter onFullName(: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 (compareActivityList).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
/speakersroute with[Authorize(Roles = "Organizer")](SpeakerList.razor:1-2), linking on toSpeakerCreateandSpeakerDetail. Its public counterpart isPublicSpeakerList, 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
partnersREST 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, fromMMCA.ADC.Conference.Shared.Partners).PartnerIdentifierTypeis 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
IEntityServicebound toPartnerDTOplusPartnerIdentifierType, 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
questionsresource, 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
roomsresource. 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
RoomDTOlives in theMMCA.ADC.Conference.Shared.Eventsnamespace,IRoomUIService.cs:1, because a room belongs to an event), Result (IRoomUIService.cs:2), and theRoomIdentifierType/EventIdentifierTypealiases. - 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 theEventIdentifierTypethe WebAPI endpoint expects; the genericDeleteAsync(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), returningTask<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); theEventIdentifierTypealias. - 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 ofScoreSessionsAsync(ISessionSelectionUIService.cs:15-20): a background start answers HTTP 202 and comes back as a success carrying theSessionsScored == -1sentinel, while a refusal to start (409, because a run is already going or the queue is full) arrives as an ErrorType.Conflictfailure. 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:GetDashboardAsyncis a pure query andScoreSessionsAsyncis a command with an asynchronous completion. - Walkthrough: two members, both scoped by
EventIdentifierType.GetDashboardAsync(eventId, ct)(ISessionSelectionUIService.cs:11-13): returnsTask<Result<SessionSelectionDashboardDTO>>.ScoreSessionsAsync(eventId, ct)(ISessionSelectionUIService.cs:21-23): returnsTask<Result<ScoreEventSessionsResultDTO>>with the 202/409 semantics above. The two map one to one onto two of the endpoints on SessionSelectionController: theGET dashboard/{eventId}read (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:47) and thePOST 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 anIEntityService<,>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
sessionsresource, 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
sponsorsREST resource, an empty marker over IEntityService<TEntityDTO, TIdentifierType> bound to SponsorDTO andSponsorIdentifierType(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
partnersresource, 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 BCLIHttpClientFactory. - Concept: the same four-line concrete UI service as ActivityService: a primary
constructor forwards
IHttpClientFactoryand ITokenStorageService plus the literal resource name"partners"to EntityServiceBase<TEntityDTO, TIdentifierType> closed overPartnerDTOandPartnerIdentifierType(PartnerService.cs:10-14); the class body is empty. See ActivityService for the Template Method rubric analysis and the two base behaviors (the constantIdempotency-Keyon create, theIf-Matchconcurrency 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 insideAddUIModule<ConferenceUIModule>()(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:29-31) registers itAsImplementedInterfaces()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
questionsWebAPI resource. It extendsEntityServiceBase<TEntityDTO, TIdentifierType>overQuestionDTOandQuestionIdentifierType, passes the resource name to the base constructor, and implements the equally emptyIQuestionUIService, inheriting the entire CRUD implementation with no added code (QuestionService.cs:10-14). - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>;ITokenStorageService;QuestionDTO;IQuestionUIService; BCLIHttpClientFactory. - 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
IQuestionUIServiceand 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 explicitAddScopedline exists for it. Injected asIQuestionUIServiceintoQuestionList(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) andOrganizerSessionFeedback(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 overridesAddAsyncto reshape the create payload, and adds the parent-scopedDeleteAsync(roomId, eventId)declared byIRoomUIService(RoomService.cs:14-47). - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>, itsEndpointproperty (EntityServiceBase.cs:51) and bothSendRequestAsyncoverloads (EntityServiceBase.cs:351,381);ITokenStorageService;RoomDTO;IRoomUIService;Result; theRoomIdentifierType/EventIdentifierTypealiases; BCLSystem.Net.Http.JsonandCultureInfo.InvariantCulture(RoomService.cs:1-2). - Concept: cross-reference the thin-leaf CRUD pattern at
ActivityServicefor the inherited half. Two things makeRoomServicemore than a four-liner.- It overrides the base's
virtual AddAsync(EntityServiceBase.cs:162) because the create endpoint's contract recordAddRoomRequestbinds a nullableRoomIdentifierType? RoomIdproperty, not the DTO'sId(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:36, bound at:215and read at:221). The override therefore posts an anonymous body that remaps the DTO id intoRoomIdalongside the remaining room fields (RoomService.cs:25-37), sending a default id asnullso the handler allocates one instead of treating an explicit0as a client-chosen id (RoomService.cs:27-29), while still routing through the baseSendRequestAsyncso 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). - It adds a parent-scoped delete, because a room is addressed under its event.
- It overrides the base's
- Walkthrough
AddAsync(dto, ct)override (RoomService.cs:18-40): guards a null DTO (:20), thenSendRequestAsync<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 }toEndpoint(:22-37).DeleteAsync(roomId, eventId, ct)(RoomService.cs:42-46): builds{Endpoint}/{roomId}?eventId={eventId}withstring.Create(CultureInfo.InvariantCulture, ...)so the ids format culture-stably (:45), and dispatches it through the valuelessSendRequestAsync(:43-46), returning theResultthe base produces.
- Why it's built this way: the create-payload remap keeps the UI honest about the server's
AddRoomRequestshape, 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 asIRoomUIServiceintoRoomList(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) andPublicSessionDetail(Pages/Public/Sessions/PublicSessionDetail.razor.cs:26) for room wayfinding, and taken as a parameter bySessionLookups(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
AddAsyncattaches a freshIdempotency-Keyheld constant across retries (EntityServiceBase.cs:171-176), and this override callsSendRequestAsyncwithout 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 baseAddAsyncandDeleteAsyncalso call the base's privateInvalidateOnSuccessto 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 extendsAuthenticatedServiceBaseand implementsISessionSelectionUIService. - Depends on:
AuthenticatedServiceBase;HttpResultExecutorandProblemDetailsResultReader;SessionSelectionDashboardDTOandScoreEventSessionsResultDTO;ITokenStorageService; theEventIdentifierTypealias; BCLSystem.Net.HttpStatusCode,IHttpClientFactoryandCultureInfo.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.
GetDashboardAsyncis a read, so it runs inside the inherited PollyRetryPolicy(SessionSelectionService.cs:27-29): a repeated GET costs nothing but latency.ScoreSessionsAsyncdeliberately 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 theHttpClient(: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 toProblemDetailsResultReader, so the status is checked before the reader is reached and answered with a sentinelScoreEventSessionsResultDTOcarryingSessionsScored = -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): insideHttpResultExecutor.ExecuteAsync(:21) it creates the authenticated client (:24), buildssessionselection/dashboard/{eventId}withstring.Create(CultureInfo.InvariantCulture, ...)(:26), GETs it through the retry policy (:27-29), and reads the body into aResult<SessionSelectionDashboardDTO>(:31-32).ScoreSessionsAsync(eventId, ct)(SessionSelectionService.cs:36-62): same executor and client (:39,42), URLsessionselection/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
ISessionSelectionUIServiceatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:47, and injected intoSessionSelectionDashboard(Pages/SessionSelection/SessionSelectionDashboard.razor.cs:17), which loads the projection at:123, starts a run at:172and branches on theSessionsScored == -1sentinel at:180.ScorePollSessionthen 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
-1sentinel 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
sessionsWebAPI resource, extendingEntityServiceBase<TEntityDTO, TIdentifierType>overSessionDTOandSessionIdentifierType, and implementingISessionUIService(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'sGetPagedAsyncbuilds the filter, sort and paging query string (EntityServiceBase.cs:86-112), which is exactly the surfacePublicSessionScheduleServicecomposes 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 (
SpeakerDashboardServiceandPublicSessionScheduleService) 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 asISessionUIServiceintoSessionList(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) andOrganizerSessionFeedback(Pages/Feedback/OrganizerSessionFeedback.razor.cs:20). It is also consumed service-to-service byPublicSessionScheduleService(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
sponsorsWebAPI resource. It extendsEntityServiceBase<TEntityDTO, TIdentifierType>overSponsorDTOandSponsorIdentifierType, passes the resource name to the base constructor, and implements the equally emptyISponsorUIService(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; theSponsorIdentifierTypealias, anint(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:25); BCLIHttpClientFactory. - 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 optionalIUiReadCacheparameter is left atnull(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43-47), so reads go to the API andGetCachedAsynctakes 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
ISponsorUIServiceand 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 explicitAddScopedline exists for it. Injected asISponsorUIServiceintoSponsorList(Pages/Sponsor/SponsorList.razor.cs:23),SponsorDetail(Pages/Sponsor/SponsorDetail.razor.cs:22),SponsorCreate(Pages/Sponsor/SponsorCreate.razor.cs:19) andPublicSponsorList(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), andResult(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 readsPageSize = 100at a time (line 20) and stops on one of three conditions: an empty page (lines 59-62), the accumulated count reaching the server'sTotalItems(lines 66-69), or theMaxPages = 20ceiling (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 withResult.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 filterQuestionEntity 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
AddRangeinto a single list (line 64); the method returnsResult.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 touchesHttpClient, only the typedI*UIService. - Where it's used:
OrganizerEventFeedback(.../Pages/Feedback/OrganizerEventFeedback.razor.cs:69-70, with"Event") andOrganizerSessionFeedback(.../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) returningEventInfo,IToastService(line 23), andFeedbackQuestionLoader(line 76); theQuestionDTOandEventQuestionAnswerDTOshapes;ConferenceRoutePaths; andDomainHelper'sParse<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 thePageLoadingState/PageErrorStatecomponents fromMMCA.Common.UI. - Concept introduced, the inline page-level error state. Unlike the create and detail pages, which
toast their failures, this page keeps a
_loadErrorstring (line 38) and rendersPageErrorStateinstead 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 withL["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 byIsLoading(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 withEventId.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.Tokendirectly: readingTokenoff a disposed source throwsObjectDisposedException, andDisposecancels before it disposes (lines 289-303), so a load or handler resuming after the user navigated away gets a cancelled token and stops through itsOperationCanceledExceptionpath. OnInitializedAsync(lines 44-104) builds breadcrumbs (lines 46-51), resolves the event name from the lookup dictionary and bails withError.EventNotFoundwhen the id is unknown (lines 58-72), loads every event question throughFeedbackQuestionLoader.LoadAllAsync(QuestionService, "Event", ...)(lines 76-77), then loads all answers for the event (line 87). Thefinallyalways clearsIsLoading(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 whoseQuestionTypeis"Rating"(case-insensitive, line 55) parses the answer values to integers, drops the unparseable ones, and renders a read-onlyMudRatingat 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 anaria-label(lines 74-83).[Rubric §21, Accessibility]. DeleteAnswerAsync(lines 106-137) deletes one answer and, on failure, toastsSnackbar.AnswerDeleteFailedand stops (lines 110-115). On success it drops the answer from_answerslocally and toastsSnackbar.AnswerDeletedimmediately (lines 120-121), then refetches the whole answer set to refresh the tallies (line 123). A failed refetch toastsError.LoadFailed(line 130), a load failure, never a failed delete.
- The route id arrives as
- 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}/feedbackroute (.../Pages/Feedback/OrganizerEventFeedback.razor:1-2, organizer-only), reached from the "view feedback" button onEventDetail(.../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), andISessionUIService(line 22) in place of the event lookup, plusIToastService(line 23) andFeedbackQuestionLoader(line 73); theSessionQuestionAnswerDTOandQuestionDTOshapes;ConferenceRoutePaths; and the sameDomainHelperParse<T>extension (line 7). - Concept introduced: none new. The page-level error state, the rating-versus-text rendering split,
the
LifetimeTokenguard (line 32), and the remove-then-refetch-after-delete rule are the ones taught inOrganizerEventFeedback. - Walkthrough (only the differences from its twin):
- The route parameter is
[Parameter] public string SessionId(line 25), parsed toSessionIdentifierType(line 55). - The title comes from the session itself rather than a lookup dictionary:
SessionService.GetByIdAsync(_parsedSessionId, false, ...)withincludeChildren: false(line 58), since onlysession.Titleis needed (line 69). - Failure is classified, not flattened. The load distinguishes a genuine miss from any other error:
sessionResult.IsNotFound()choosesL["Error.SessionNotFound"], anything else keeps the genericL["Error.LoadFailed"](lines 63-65), and the in-code comment (lines 61-62) records that this is theResult-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 aSessionQuestionAnswerIdentifierTypeand 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 toastsError.LoadFailed(line 127).- The template renders the session title as a
MudLinkback 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).
- The route parameter is
- 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
FeedbackQuestionLoaderrather than duplicated. - Where it's used: the
/sessions/{SessionId}/feedbackroute (.../Pages/Feedback/OrganizerSessionFeedback.razor:1-2), reached from the "view feedback" button onSessionDetail(.../Pages/Session/SessionDetail.razor:161-162). The attendee-facing counterpart isSessionFeedback.
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
eventIdfilter, 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 theEventIdentifierTypealias. Externals:Microsoft.AspNetCore.Componentsfor[Inject]andRendererInfo,System.Globalization.CultureInfo(:1), and an injectedTimeProvider(: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.CanRenderEventPickergates the picker onRendererInfo.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 underInteractiveAuto'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): theIEventLookupServiceand theTimeProvider, thenEvents(the id-to-EventInfodictionary,nullwhen the lookup failed),SelectedEventId(nullmeaning "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), andEventsLoadTask, the in-flight load. Every setter isprivate, so the base owns all transitions. EventFilterIsUserControlled(:58):virtual, defaulttrue. 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.CanRenderEventPickerandEventPickerOptions(:73,:80-81): the gate described above, and the ordered option list derived from it. IteratingEventPickerOptionsinstead ofEventskeeps 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()isabstract, so a page cannot forget to reload;SavePageFilters/RestorePageFiltersarevirtualno-ops for page-specific values (search text, status dropdowns);OnEventsLoadingAsync()is avirtualhook for work that must run before the events are fetched, such as resolving whether the reader is privileged. SaveFilters/RestoreFilters(:99-130): both aresealed 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 theeventIdentry 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 setsEventFilterResolvedonly in the latter two.OnInitializedAsyncandBeginEventsLoad(: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 awaitEventsLoadTask.LoadEventsAndResolveDefaultAsync(:151-168): the hook, then the lookup withResultunwrapped byTryGetValue(see the primer on the Result pattern), thenResolveDefaultEventFilter(). A failure is non-fatal by design (:155-156): the picker stays hidden and the default filter stays unset, and the failure is remembered inEventsLoadFailed. 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 ofEventFilterIsUserControlled. The computation delegates toCurrentEventSelector.SelectCurrentOrNextoverEvents.ValueswithTimeProvider.GetUtcNow().UtcDateTimeas "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 injectedTimeProvider.WaitForEventsAsync(:193): returns the load task orTask.CompletedTask. The doc onEventsLoadTask(:45-50) says exactly when to await it: before any fetch that applies the event filter, because the grid's firstServerDatacall can race ahead of initialization completing. That race is why the task is a field rather than a local.ApplyEventFilter(:196-200): addsEventIdas 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'sValueChanged. It sets the id, marks the scope resolved, and awaits the page'sReloadForEventFilterAsync(). Anullvalue 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.
- Injected state (
- 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 overrideon the two persistence hooks is what keeps it that way as pages are added. [Rubric §11, Security] shows up in theEventsLoadFailedflag: 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 itsScopeUnresolvedForPublicReader()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
@inheritsdirective 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, overridingEventFilterIsUserControlled(:52),OnEventsLoadingAsync(:90), and re-callingLoadEventsAndResolveDefaultAsync()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:
IActivityUIServiceandIEventLookupService(:29-30),ActivityDTO,EventInfo(through the lookup),CurrentEventSelector(:80-85),IMapNavigationService(:31),IToastService(:32), and the injectedTimeProvider(:33) that supplies "now" to the selector (:85). It derives fromComponentBasedirectly (: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
PublicEventListis 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 = 200with 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
StartTimeascending (:98-99), and the result is re-ordered in memory byStartTime, thenSortOrder, thenName(: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
_loadFailedand returns before any fetch is issued (:72-76), and the markup hands that flag toListNoRecordsContentwithOnRetry="LoadAsync"(PublicActivityList.razor:26-27), so the reader gets an error with a retry. The doc onLoadAsyncstates 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, matchingPublicSpeakerList. The activity page itself (:104) stays non-critical: a failed fetch leaves the page on its empty state (:102-103). OnlyOperationCanceledExceptionis caught (:118-121) as expected teardown or an InteractiveAuto transition, and thefinallyalways 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.Tokendirectly, because readingTokenoff a disposed source throwsObjectDisposedException;Disposecancels before it disposes, so a load or handler that resumes after the user navigated away gets a cancelled token and exits through theOperationCanceledExceptionpath (comment:37-39). It is used at:72,:100and:139. - Culture-aware time rendering:
FormatTimeRange(:52-56) formats start and end withCultureInfo.CurrentCultureand composes them through a localizedText.TimeRangeresource, 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.
- Bounded read:
- Walkthrough
OnInitializedAsync(:58) delegates toLoadAsync(:66-126), which is also the retry target: reset_isLoadingand_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), throughCurrentEventSelector.SelectCurrentOrNextwith the four accessors passed explicitly because the lookup returnsEventInforather thanEventDTO, and withTimeProvider.GetUtcNow().UtcDateTimeas the clock (:80-85), remember its id and name (:87-88), build anEventId equalsfilter 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 theCancellationTokenSourceat: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/activitiesroute (PublicActivityList.razor:1, the same string asConferenceRoutePaths.PublicActivitiesatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:54), reached fromPublicEventDetail'sViewActivitiesaction (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:
ISponsorUIServiceandIEventLookupService(:28-29),SponsorDTOandSponsorTier(:52),EventInfo(through the lookup),CurrentEventSelector(:79-84), and the injectedTimeProvider(:30) that supplies "now" to the selector (:84); MudBlazor and the page'sIStringLocalizer(PublicSponsorList.razor:3). LikePublicActivityListit derives fromComponentBasedirectly (: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 = 200with 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
SortthenName(: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
_loadFailedand returns before any fetch is issued (:71-75), and the markup hands that flag toListNoRecordsContentwithOnRetry="LoadAsync"(PublicSponsorList.razor:26-27), so the reader gets an error with a retry. The doc onLoadAsyncstates 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, matchingPublicSpeakerList. A failed sponsor fetch (:104) stays non-fatal and falls back to the sponsorship call to action (:102-103). OnlyOperationCanceledExceptionis caught (:120-123) as expected teardown or an InteractiveAuto transition, and thefinallyalways 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 readingTokenoff a disposed source throwsObjectDisposedException;Disposecancels first, so a late-resuming load gets a cancelled token and stops through theOperationCanceledExceptionpath (comment:34-36; used at:71and:100).
- Bounded read:
- Walkthrough:
OnInitializedAsync(:57) delegates toLoadAsync(:65-128), which is also the retry target. It resets_isLoadingand_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 withCurrentEventSelectorpassing the four accessors explicitly andTimeProvider.GetUtcNow().UtcDateTimeas the clock (:79-84), remembers its name and sponsorship packet URL (:87-88), builds anEventId equalsfilter when an event resolved (:90-92), fetches one page sorted bySortascending (:94-100), and materializes_tiersas 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'sIStringLocalizerwith aTier.{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 theCancellationTokenSourceat: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/sponsorsroute (PublicSponsorList.razor:1, matchingConferenceRoutePaths.PublicSponsorsatMMCA.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,ISessionUIServiceandIEventUIService(:24-26),AuthenticationStateProviderread directly rather than as a cascading task (:27),IToastService(:29), the injectedTimeProvider(:30),LatestLoadGuard(:60),SpeakerDTOandSessionDTO,ConferenceReadAudience(:211),CurrentEventDefaults(:218),ConferenceRoutePaths(:47,:222), andDomainHelper'sId.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.
- Prerender skip.
OnParametersSetAsyncreturns 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). - Push the filter to the server.
LoadSpeakerSessionsAsync(:162-199) sends aSpeakerId equalsfilter withincludeChildren: false, sorted byStartsAtascending, capped atMaxSpeakerSessions = 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 onSessionSpeakers, 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). - 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 throughCurrentEventDefaults, withTimeProvider.GetUtcNow().UtcDateTimeas "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]. - Clear the previous speaker's sessions up front.
LoadSpeakerAsyncresets_speakerSessionsto 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.
- Prerender skip.
- Walkthrough
OnInitialized(:41-50) builds the Home / Speakers / Profile breadcrumbs; unlikePublicEventDetailit 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 throughLatestLoadGuard.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), clearSpeakerand 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);OperationCanceledExceptionis swallowed (:129-132), any other exception raises the load-failure toast (:133-136), and the_load.IsCurrent(generation)-guardedfinallyclearsIsLoading(:137-145).- Session links are plain
MudLinkmarkup toConferenceRoutePaths.PublicSessionDetails(session.Id)(PublicSpeakerDetail.razor:117), not a code-behind navigation method; the page keeps onlyNavigateToList(:222) for the back action. Disposal (:224-245) delegates to_load.Dispose()(:235), so the sameLatestLoadGuardthat 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 fromPublicSpeakerListcards and from session pages. Its markup renders theQrCodeButtonfor 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 overSpeakerDTO(:32), which itself extendsDataGridListPageBase<TDto>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Common/EventFilteredListPageBase.cs:25);ISpeakerUIService(:41),PublicReadAudience(:97), which appliesConferenceReadAudience's privileged roles (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicReadAudience.cs:28), andInfiniteScrollSentinel(PublicSpeakerList.razor:144). Note what is absent: noMudDataGrid, noGridRefoverride, noMobileInfiniteScrollList<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 atMMCA.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:152and: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 theEventIdfilter, 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 atEventFilteredListPageBase.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 toPublicReadAudience.IsPrivilegedReaderAsync(:97), whose catch degrades any non-cancellation failure to the least-privileged audience (PublicReadAudience.cs:30-34).ReloadForEventFilterAsync(:213) andSavePageFilters/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.- A generation counter supersedes in-flight fetches.
_generation(:64) is bumped by every reset (search, event filter, breakpoint change, retry) insideLoadSpeakersAsync(:134), and bothLoadSpeakersAsync(:145-148) andLoadMoreSpeakersAsync(: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]. - 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]. - The page number is committed only on success.
LoadMoreSpeakersAsyncsavespreviousPage, optimistically advances, and rolls back whenLoadFailedis set (:169-189), so the retry button re-requests the same page instead of silently skipping it.[Rubric §29, Resilience & Business Continuity]. - 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, matchingPublicSessionList.[Rubric §11, Security]and[Rubric §26, Front-End Security].
- A generation counter supersedes in-flight fetches.
- Walkthrough
CardsPerPage = 12(:34-35) is assigned to the base'sMobilePageSizein 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 runsLoadSpeakersAsync.FetchCurrentPageAsync(:104-123) delegates to the base'sLoadMobileDataAsync(DataGridListPageBase.cs:830) with a fixedFullNameascending sort;ApplyFilters(:215-220) emitsFullName containsplus the base'sApplyEventFilter(:219,EventFilteredListPageBase.cs:196), which travels as the virtualEventIdfilter key that the speakers endpoint resolves through the EventSpeaker and SessionSpeaker joins, since a Speaker row has noEventIdcolumn (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) andReloadForEventFilterAsync(:213) all funnel throughLoadSpeakersAsync, 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 withincludeChildren=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/speakersroute (PublicSpeakerList.razor:1, matchingConferenceRoutePaths.PublicSpeakersatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:51); cards navigate toPublicSpeakerDetail. - Caveats / not-in-source: the join-based resolution of the virtual
EventIdfilter 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) ➡