Skip to content

Surface unfinished appointments so dropped items get caught#2186

Merged
jim merged 4 commits into
mainfrom
surface-unfinished-appointments
Jun 10, 2026
Merged

Surface unfinished appointments so dropped items get caught#2186
jim merged 4 commits into
mainfrom
surface-unfinished-appointments

Conversation

@phinze

@phinze phinze commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

📚 Stacked on #2185. This targets the appointment-hold-expiry-fixes branch, so review/merge #2185 first; GitHub will retarget this to main once that lands.

What it does

Adds an Unfinished Appointments report (under Reports) and a banner on the Appointments page that links to it. Both surface appointments where staff pulled the items, never marked the appointment complete, and at least one held item never got checked out. Each row can be resolved in place with a Mark complete button, and links to the appointment, member, and items for anything that needs a closer look.

"Unfinished" means: pulled, never completed, from the last 30 days, and still holding at least one item that's neither checked out nor explicitly ended (cancelled or retired). Today's and future appointments are never included, so the normal pickup flow doesn't show up here.

Why it is important

This is the third piece of the "items wandering off member accounts" story that staff have been reporting. The thread started from a real incident: an item showed as checked out to a member, but there was no loan behind it. Digging in, what actually happened was an appointment got pulled (items physically came off the shelf), some items went home and got checked out, one didn't, and the appointment was never closed. Nothing surfaces that half-finished state, so it just scrolls off that day's schedule and is forgotten. Weeks later, when someone goes looking for the item, the system looks like it's lying.

#2180 and #2181 (the stacked-under PR) stop the appointment page from mislabeling timed-out holds and stop holds from expiring mid-appointment. But neither makes the dangling appointments visible, which is what lets them rot. This adds the daily worklist: a short, recent list of appointments that need a human to close the loop, with the resolve action right there so it isn't a multi-click safari.

The 30-day window came out of checking prod before shipping. The naive "all pulled-but-never-completed appointments with an un-checked-out item" query returns ~330 rows stretching back over a year, and 70% of those items have been loaned to someone else in just the last 90 days (35% are checked out right now). In other words the old rows are stale records, not lost tools, and dumping 330 of them on staff would train everyone to ignore the list. Scoped to 30 days it's ~50 actionable rows. The report copy says so out loud, so the scoping isn't a silent surprise.

UI Change Screenshot

Banner on the Appointments page:
2026-06-05 at 14 41 08@2x

Unfinished Appointments report with the Mark complete action:
2026-06-05 at 14 40 50@2x

Implementation notes

A few decisions worth a look in review:

  • Scope excludes already-ended holds (loan_id nil and ended_at nil), so holds that were cancelled/retired (or hit the create_loan_from_hold silently ends a hold when the loan fails to save #2183 save-failure bug) don't show as unfinished. Expired-but-not-ended holds still surface, since that's exactly the signal we want.
  • The 30-day window is a constant (Appointment::UNFINISHED_WINDOW), and the report/banner copy is derived from it so they can't drift. The pre-existing backlog is intentionally out of scope here; it's a separate "bulk-complete old pulled appointments" decision we can make deliberately later.
  • Inline resolve posts to a complete action that sets completed_at and 303-redirects back to the report, so it plays nicely with Turbo and the row just drops off. Reversible from the appointment page.

Fixes #2182

phinze added 3 commits June 5, 2026 11:42
The appointment checkout panel rendered "Item checked-out" for any hold
that wasn't active?, but Hold#active? is false for both holds that were
genuinely picked up and holds that simply timed out. A hold that expired
without pickup looked identical to a completed checkout, which is what
made staff believe an item was on a member's account when it never was.

Split the label into three honest states (picked up, expired without
pickup, ended without checkout) and rename the loop variable from `loan`
to `hold`, since it was a Hold the whole time and the misleading name was
half the confusion.

Fixes #2180
A hold's expires_at ticks on its own clock regardless of whether its item
has been pulled for an appointment. If staff pull an item and the
"Check-out" click gets missed, the hold can expire that same night, the
item silently flips back to available, and the member drops out of line.

When an appointment is pulled, push its attached holds' expiry out to a
backstop (the default hold duration) so they can't die while the item
sits off the shelf waiting to be checked out. We only ever extend, never
shorten, and leave un-pulled appointments alone.

Fixes #2181
When staff pull the items for an appointment but never close it out, any
held item that didn't get checked out goes invisible: the item is off the
shelf, there's no loan behind it, and nothing flags the dangling state.
Paired with the appointment page mislabeling a timed-out hold as
"Item checked-out" (#2180), this is how items end up appearing to wander
off member accounts.

Add an "Unfinished Appointments" report (Reports menu) plus a banner on the
Appointments page that links to it whenever any exist. Unfinished means:
pulled, never marked complete, from the last 30 days, and still holding at
least one item that was neither checked out nor explicitly ended. Each row
resolves in place with a "Mark complete" button (reversible from the
appointment page) and links out to the appointment, member, and items for
the cases that need investigating.

The 30-day window is deliberate. Prod has ~330 pulled-but-never-completed
appointments going back over a year, but only ~50 in the last 30 days; the
old ones are stale records whose items have long since circulated to other
borrowers, so we don't nag about them. The pre-existing backlog is left as a
separate cleanup decision.

Fixes #2182
@phinze phinze requested a review from a team June 5, 2026 19:38
Base automatically changed from appointment-hold-expiry-fixes to main June 10, 2026 01:42
@jim jim merged commit 4f179a3 into main Jun 10, 2026
10 checks passed
@jim jim deleted the surface-unfinished-appointments branch June 10, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Surface pulled-but-never-completed appointments to staff

2 participants