Previously, in Part 2, three hosts rendered one Settings page and the first Android screenshot taught us who owns the edges of a screen. The app looks like an app now — and knows nothing. Season one's clinic keeps its data in PostgreSQL behind a Blazor Server app, and a phone can't talk to Postgres. This part gives the clinic a door: four small endpoints, a contracts project the phone shares by shape, the emulator's odd name for "my PC", and the first real My-visit screen.

The prompt: a door, not a second house

Verbatim from the commit (tag pocket-03):

"A phone can't talk to Postgres. Give ClinicLive a small public API
for the Pocket app under /api/pocket: clinic info, look up a visit by
confirmation code (first name only — the code is the credential), check in,
and the public queue. It must be a THIN layer over BookingService,
QueueService and ClinicTime — a second front door must not grow a second set
of rules; check-in from the phone IS the kiosk's method. Put the DTOs in the
Contracts project so the phone shares the shape, not the code. Then the
shared side: a PocketApi client over HttpClient where each host supplies the
base address (Android emulator → 10.0.2.2), the real My-visit screen with
loading / unreachable / not-found / booked / checked-in states, the clinic
card on Find us, and integration tests for the service against the real
Postgres. Prove it from the emulator: look up DEMO00 and check Maria in."

The sentence doing the most work is "a second front door must not grow a second set of rules." Season one spent real effort on who may check in, in what order patients are called, and what "today" means in the clinic's time zone — and then debugged those rules when they were wrong. An API that re-implemented any of that would be a second place for the same bug to live. So the prompt forbids it, and the code's own summary repeats the ban.

Model pick: Sonnet, medium effort, for nearly all of it — this is plumbing over rules that are already settled and tested, exactly the shape of work the cheaper model does well. Opus only for the one architectural call, which is the "no second rulebook" paragraph you just read.

A thin layer, and the sentence that keeps it thin

/// Everything the Pocket app may ask the clinic. It is a THIN translation layer:
/// the rules (who can check in, queue order, "today" in the clinic's zone) stay in
/// BookingService / QueueService / ClinicTime, exactly where the web app finds them.
/// A second front door must not grow a second set of rules.
public class PocketService(
    IDbContextFactory<ApplicationDbContext> dbFactory,
    QueueService queue,
    ClinicTime clinic,
    IConfiguration config)
{
    /* … GetClinicInfo, GetVisitAsync, GetQueueAsync … */

    /// Same rule as the kiosk — literally the same method.
    public async Task<CheckInResponse> CheckInAsync(string code)
    {
        var result = await queue.CheckInAsync(code);
        return new CheckInResponse(result.Success, result.Error, result.Position);
    }
}

Check-in from a phone is QueueService.CheckInAsync — the kiosk's method, not a copy of it. When a patient taps the button twice, the "already checked in" message that comes back is the kiosk's text, word for word, because it's the kiosk's code. The endpoints on top are a few lines each:

var api = app.MapGroup("/api/pocket")
    .WithTags("Pocket")
    .DisableAntiforgery();   // JSON API, not a form post

api.MapGet("/clinic", (PocketService pocket) => pocket.GetClinicInfo());

api.MapGet("/visits/{code}", async (string code, PocketService pocket) =>
    await pocket.GetVisitAsync(code) is { } visit
        ? Results.Ok(visit)
        : Results.NotFound(new { error = "No visit found for that code." }));

api.MapPost("/visits/{code}/check-in", async (string code, PocketService pocket) =>
{
    var result = await pocket.CheckInAsync(code);
    return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});

api.MapGet("/queue", (PocketService pocket) => pocket.GetQueueAsync());

No login. The confirmation code is the patient's credential, exactly as it is at the kiosk, and the visit that comes back carries a first name and nothing else identifying — a leaked screen should reveal as little as possible. DisableAntiforgery is there because the clinic is a Blazor app with antiforgery on, and a JSON POST from a phone isn't a form. The queue endpoint returns what the wall TV shows: masked names, "Maria G.", nothing more.

Share the shape, not the code

The DTOs live in ClinicLive.Contracts, a project with no dependencies at all, referenced by both the server and the shared UI. The status enum is the interesting one:

/// Lifecycle of one appointment as the patient sees it. Mirrors the server enum by NAME,
/// and travels as a name ("CheckedIn", not 1) so the JSON reads like English in a curl.
[JsonConverter(typeof(JsonStringEnumConverter<VisitStatus>))]
public enum VisitStatus { Booked, CheckedIn, InProgress, Done, Cancelled, NoShow }

public sealed record VisitDto(
    string Code,
    string FirstName,
    DateTime StartsAtUtc,
    string StartsAtLocal,
    string DayLocal,
    VisitStatus Status,
    bool IsToday,
    bool CanCheckIn,
    int? Position,
    int WaitingCount,
    string? NowServing);

public sealed record CheckInResponse(bool Success, string? Error, int Position);

Notice who does the thinking. DayLocal ("Today", "Tomorrow") and StartsAtLocal ("09:00") are strings computed by the server in the clinic's zone, because the phone in someone's pocket has its own idea of what time it is, and season one's Part 10 already paid for that lesson once. CanCheckIn is a decision, made where the rules are. The phone renders; it doesn't reason. With the server running, the door answers curl in plain English (trimmed):

$ curl http://localhost:5159/api/pocket/clinic
{"name":"ClinicLive Demo Clinic","addressLine1":"1 Example Parade",
 "addressLine2":"Demo Town","phone":"+00 0000 0000",
 "latitude":37.43,"longitude":-122.073,"timeZone":"UTC",
 "openingHours":"Mon–Fri 09:00–17:00"}

$ curl http://localhost:5159/api/pocket/visits/DEMO00
{"code":"DEMO00","firstName":"Maria","startsAtLocal":"09:00","dayLocal":"Today",
 "status":"Booked","isToday":true,"canCheckIn":true,"position":null, /* … */ }

No CORS — and why that's a lesson, not an omission

There is no CORS configuration anywhere in this season, deliberately. CORS is a browser rule: it governs a web page fetching from a different origin. Neither of our clients is that. The MAUI app is a native process making HTTP calls; the browser host is a Blazor Server app, so its components run on the web server and call the clinic server-to-server — the visitor's browser never talks to /api/pocket at all. The web host's Program.cs says so, right where a future maintainer will look for the missing AddCors:

// This host runs on a server, so it calls the clinic server-to-server; the
// browser never talks to the clinic's API directly (which is why no CORS).
var apiBase = builder.Configuration["ClinicLive:ApiBase"] ?? "http://localhost:5159/";
builder.Services.AddHttpClient<PocketApi>(http =>
{
    http.BaseAddress = new Uri(apiBase);
    http.Timeout = TimeSpan.FromSeconds(10);
});

Where is the server, from here?

The shared client, PocketApi, wraps an HttpClient and doesn't know where the clinic is; every host hands it a base address, because the emulator, a desktop and a browser all get there differently. The emulator's answer is the one every Android beginner trips on: inside the emulator, localhost is the emulator. The machine running it is 10.0.2.2.

/// Where the clinic's server is, from THIS device's point of view.
///
/// "localhost" inside the Android emulator is the emulator itself; the machine running
/// it is 10.0.2.2. A physical phone on your Wi-Fi needs the PC's LAN address instead
/// (and the server listening on 0.0.0.0, and a firewall rule) — Part 9 makes this a setting.
public static class ApiEndpoint
{
    public const int Port = 5159;   // ClinicLive's http launch profile

    public static Uri Base =>
        DeviceInfo.Platform == DevicePlatform.Android
            ? new Uri($"http://10.0.2.2:{Port}/")
            : new Uri($"http://localhost:{Port}/");
}

And then Android refuses to talk to it, because since Android 9 plain http is off by default. The dev server is plain http. The fix is a network security config that permits cleartext for those two hosts only, wired in via android:networkSecurityConfig on the manifest's <application>:

<!-- Android 9+ refuses plain http by default. The dev server is plain http, and the
     emulator reaches it at 10.0.2.2 — so allow cleartext for THAT host only. A real
     deployment talks https to a real hostname and never needs this file. -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
        <domain includeSubdomains="false">localhost</domain>
    </domain-config>
</network-security-config>

The visit screen: five states, one honest exception

The client turns two failure modes into things a screen can render. A 404 becomes null — "no such code" is an answer, not an error — and anything that means the clinic can't be reached at all becomes one exception, so the page has a single thing to catch:

/// Thrown when the clinic can't be reached at all — the UI turns it into "check your connection".
public sealed class ClinicUnreachableException(Exception inner) : Exception("The clinic's server can't be reached.", inner);

/* … every call in PocketApi runs inside Guard(), whose two catches are: … */
catch (HttpRequestException ex)
{
    throw new ClinicUnreachableException(ex);
}
catch (TaskCanceledException ex) when (!ex.CancellationToken.IsCancellationRequested)
{
    throw new ClinicUnreachableException(ex);   // timeout, not a user cancel
}

The page itself is a chain of states — asking the clinic, can't reach it, no such code, and then the visit — and the visit branches on status. Booked-and-today gets the button; checked-in gets the one number a waiting patient cares about, set at 4.5rem in petrol, followed by "You're the only one waiting." or "N people waiting." Here are the two states the prompt asked to be proven:

My visit on the Android emulator before check-in: a card headed Hi Maria with a grey Booked pill, 'Today at 09:00', a line inviting her to check in, and a full-width petrol button reading I'm here — check me in; below it a second card saying 'This screen asks the clinic each time you open it. Live updates arrive in Part 4.' with an outlined Refresh button. DEMO00, booked
The same screen after tapping the button: the pill now reads Checked in, the label YOUR PLACE IN THE QUEUE sits above a very large petrol number 1, and the line 'You're the only one waiting.' — the Refresh card is still underneath. After one tap
The prompt's proof, as photographed: DEMO00 is Maria, today at 09:00, booked; one tap and she's number 1 with nobody ahead of her. Look at the second card. "This screen asks the clinic each time you open it" and a Refresh button is an honest description of a screen that isn't live yet — it's a placeholder, it says so, and Part 4 deletes it.
Season two's waiting-room board on its dark signage palette: NOW SERVING above an empty placeholder bar, then UP NEXT with a single entry, '1 Maria G.', and a small clock reading 06:38 in the top-right corner.
The other side of the same tap: the wall TV lists Maria G. as up next, and nobody is being served. Nothing on the board changed for this season — a check-in that came from a phone is indistinguishable from one that came from the kiosk, which is the "one method, two doors" rule made visible. The clock is honest too: the board runs on the clinic's configured zone (UTC in the demo config) while the emulator's status bar keeps the host PC's, so the two frames disagree about the time of day.

The web host shows the same state through the same component — "Checked in", the big 1, the same placeholder card — which by now is what you'd expect, and expecting it is the payoff of Part 2. The Find us tab got its clinic card from /clinic the same way: name, address, a tappable phone number, opening hours, and a line promising distance and directions in Part 7.

Tests against the real Postgres, and a shared calendar

Four integration tests joined season one's suite, running against the same Testcontainers PostgreSQL that season one's Part 9 set up. The one that matters most asserts the "no second rulebook" rule directly:

[Fact]
public async Task Checking_in_through_the_api_lands_in_the_same_queue_as_the_kiosk()
{
    /* … book "Test Patient K" for later today … */
    var checkIn = await service.CheckInAsync(booking.Appointment!.ConfirmationCode);
    var visit = await service.GetVisitAsync(booking.Appointment.ConfirmationCode);

    Assert.True(checkIn.Success);
    Assert.Equal(VisitStatus.CheckedIn, visit!.Status);
    Assert.False(visit.CanCheckIn);                    // once is enough

    // The kiosk's own rule fires for a second attempt — one implementation, two doors.
    var again = await service.CheckInAsync(booking.Appointment.ConfirmationCode);
    Assert.False(again.Success);
    Assert.Contains("already checked in", again.Error);
}

The fourth test — tomorrow's booking is visible but can't check in yet — went red on its first run, and so did a test from season one that nobody had touched. The AI had booked its "tomorrow" appointment at 09:00, which is the slot season one's Booking_creates_patient_and_code already owns in the fixture database that every test in the collection shares. The partial unique index from season one's Part 4 did its job on whichever test ran second. The fix is one time, and a comment:

// 13:15, not 09:00: the fixture is ONE database shared by every test in the
// collection, and Season 1's booking test already owns tomorrow's 09:00 slot.
// The partial unique index doesn't care which test asked first.
var booking = await new BookingService(fx.DbFactory, fx.ClinicTime)
    .BookAsync("Test Patient L", "+00-1111-0012", null, DateTime.UtcNow.Date.AddDays(1).AddHours(13).AddMinutes(15));

What the AI got wrong: the calendar collision is the instructive one — in a shared fixture, tests share the calendar, and a test that's correct in isolation can break a neighbor it never read. Three more were environment, not code, and all three were season-one lessons coming back: docker compose up stopped working because postgres:18 images now refuse a volume mounted at /var/lib/postgresql/data (data lives in a major-version subdirectory; the mount moves one level up, and a fresh database re-seeds the demo codes for today — demo data has a shelf life); the build failed on DLL locks because the web host and the Windows app were still running (stop before build; still bites); and --no-launch-profile, Part 2's Production surprise, on the server also means no demo seed — DEMO00 didn't exist until the environment was set explicitly.

The meter: ≈ $1.90 on season three's running meter. Sixty cents for a service, four endpoints, a client, a five-state page and four tests is what "plumbing on settled rules, on the cheaper model" looks like; the compose-file archaeology cost more than the API.

Checkpoint: git checkout pocket-03 in the repo. docker compose up -d, run the clinic with ASPNETCORE_ENVIRONMENT=Development so it seeds DEMO00–DEMO44 for today, and curl the four endpoints. On the emulator, type DEMO00, meet Maria, check her in, and watch /board on the clinic list her as up next. 13 tests green — season one's nine plus these four.

Maria is number 1 on a screen that will say so until she presses Refresh. A patient in a car park doesn't press Refresh. The phone needs to hear the queue move — the same hub the wall TV listens to, one connection for the whole app, a second capability for the moments a phone is asleep, and a reconnect policy the screenshots proved wrong — Part 4: Live Queue in Your Pocket: SignalR, App Lifecycle and a Reconnect That Never Gives Up.