Surface unfinished appointments so dropped items get caught#2186
Merged
Conversation
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
jim
approved these changes
Jun 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:

Unfinished Appointments report with the Mark complete action:

Implementation notes
A few decisions worth a look in review:
loan_idnil andended_atnil), 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.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.completeaction that setscompleted_atand 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