Insights

StayEugene: when one house is five bookable listings

StayEugene: when one house is five bookable listings

StayEugene is my own direct-booking site for a set of short-term rentals near Hayward Field and the University of Oregon. Five listings, a calendar carrying real nightly rates, and a checkout that keeps the marketplace's cut in the house. The checkout is the ordinary part. What makes the site worth writing about is that four of those five listings are the same building.

What does booking direct actually buy?

About fifteen percent of the stay, and a phone number instead of a support queue.

The site puts it in one line, "Save 10-15% on Every Stay", and then shows the arithmetic underneath: a four-night stay at $1,420 through a platform against $1,210 booked direct. The discount arrives as a line item, not a slogan. Open any property page and the price breakdown itemizes the nights, the cleaning fee, the occupancy tax, a one percent management fee and then "Direct Booking Discount (15%)", each on its own line. Every number a guest is asked to pay has a label next to it, which is the only version of a price I am willing to ship.

What the marketing does not mention, and what this post is about, is the bill the host picks up in exchange. A marketplace does more than sell the room. It also runs the machine that stops two people buying the same bed on the same night. Take the booking yourself and you have taken that job too.

Why is one house five listings?

Because groups arrive in different sizes, and the building can be cut more than one way.

The StayEugene collection page showing five rental listings with photographs, each labeled with bedrooms, guest capacity and a nightly rate: a studio at $125, a two bedroom home at $175, a three bedroom family home at $250, a four bedroom home with guest suite at $350, and a garden cottage at $145.
Five listings, read off the live collection on 4 September 2026. Four of them share walls.

The largest listing says so on its own page. "Enjoy our studio plus house, all in one", and then, a paragraph later, "The main house has 2 bedrooms and full bath, while the home also offers a renovated studio for guests to enjoy a separate space complete with private deck, entrance and full bath." A couple can book the studio for two. A family arriving for a graduation can book the whole thing for nine. Both listings describe the same front door.

So selling the whole house has to close the studio inside it, and selling the studio has to close the whole house. Only the garden cottage stands on its own. That is the entire mechanism, and everything below is what it takes to make it true on a Friday night when a guest is holding a card.

How does a night get closed?

By any one of four writers, into one row per listing per night, and every row remembers which writer put it there.

Diagram of StayEugene's availability model. On the left, the conflict table as the Python port has it, drawn as a containment tree: the four bedroom listing contains a three bedroom listing, which contains a two bedroom listing, and also contains the studio, with a note that booking one listing closes the listings that share its walls. The garden cottage sits outside the tree with no conflicts. On the right, the four writers allowed to close a night: a direct booking, the marketplace iCal feed, the dynamic pricing feed and the host by hand, all writing one row per listing per night with a source tag. A footnote explains that DTEND on an iCal event is non-inclusive under RFC 5545.
The calendar is one row per listing per night. The source tag is what lets four writers share it.

One thing to say before the details: the code and the field names below come from the Python port of the same app on my machine, not from the PHP build that takes the bookings on stayeugene.today, and the two share the same conflict table apart from one edge I come back to below.

The row is the boring, load-bearing part: a listing, a date, a price, an availability flag, a minimum stay, and a tag naming the source. The key is the listing plus the date, so a night can never end up represented twice and disagreeing with itself.

Four things write those rows. A booking taken on the site. The iCal feed each marketplace publishes for each listing, fetched on a schedule. A dynamic pricing feed that sets nightly rates a year ahead and is allowed to close a night but never to reopen one. And the host, by hand, for repairs and family and the weekend the house is not for sale.

The rule that stops them eating each other is that a writer may only rewrite the rows it owns. Refetching a marketplace calendar begins by clearing that feed's own blocks, and nothing else:

await self.calendar_repo.clear_sync_blocks(prop.id, "airbnb_sync")

Cancellations disappear on their own that way, because the feed is rebuilt from scratch each run. More importantly, a feed that comes back empty because a URL rotated or a request timed out cannot open a house that is full. It can only fail to close nights that its own last run had closed, and the direct bookings underneath it never move.

The overlap is the second half, and it lives as a table of who blocks whom:

PROPERTY_CONFLICTS = {
    4: [1, 2, 3],  # 4-bed Guest House contains all others on the same address.
    3: [2, 4],     # 3-bed House contains 2-bed suite, also conflicts with full 4-bed retreat.
    2: [3, 4],     # 2-bed suite conflicts with 3-bed (parent) and 4-bed (grandparent).
    1: [4]         # Modern Studio conflicts with full 4-bed retreat.
}

Availability then stops being a lookup and becomes a question about a set. Take the listing, add everything it conflicts with, and if any member of that set is closed on a date, the date is closed for all of them. The same set is checked again at the moment a booking is created, not only when the calendar is drawn, because the two are seconds apart and a race there sells a bed twice.

A hand-written table like that has one failure mode, and it is worth naming: it has to be closed. If the studio sits inside the three bedroom listing as well as inside the four bedroom one, both edges have to be written down, or a studio booking will quietly leave the three bedroom listing on sale. On the live site those two do move together, on every one of the next 201 nights. The table above, taken from the port, carries only the four bedroom edge for the studio, and its two bedroom row does not match the live site either: it closes the two bedroom listing whenever the three bedroom one is closed, and the live calendar does not. Containment is transitive whether or not you remembered to type it.

Why does the checkout day have to stay on sale?

Because a calendar event ends on the morning the guest leaves, and a rental calendar sells nights, not days.

Every marketplace hands over its bookings as iCal, and RFC 5545 is precise about where an event stops:

The "DTSTART" property for a "VEVENT" specifies the inclusive start of the event. [...] The "DTEND" property for a "VEVENT" calendar component specifies the non-inclusive end of the event.

A Friday to Sunday stay therefore arrives as DTSTART Friday, DTEND Sunday, and it occupies two nights. Block every date from start to end and Sunday goes dark even though the room is clean by noon and somebody is trying to check in that afternoon. One subtraction is the whole fix:

# Checkout date is usually exclusive in iCal
# We pass base_price so new rows don't fail integrity check
end_date = dtend - timedelta(days=1)

Get it wrong and you lose a night on every reservation, back to back stays become impossible, and nothing anywhere reports an error. The calendar looks correct. It just quietly has fewer nights for sale than the house does. That failure shape, wrong but silent, is the reason I now write a check for the thing I expect rather than a log line for the thing I did.

What does the live calendar say today?

That the studio, the three bedroom listing and the four bedroom listing are closed on exactly the same fourteen nights.

The availability calendar on the four bedroom listing page, showing September and October 2026 with a nightly price under each open date and a strikethrough on the closed ones. Prices range from $263 to $1,600 a night, and eight October dates are struck out.
The four bedroom listing on 4 September 2026. The struck out dates are the nights the studio inside it is closed too.

The date picker on a property page calls /src/api/get_calendar.php?property_id=4, and it answers with 201 nights, each carrying a price and an availability flag. On 4 September 2026 I pulled that endpoint for all five listings and compared them.

The studio, the three bedroom and the four bedroom listings came back with identical closed sets: the same fourteen nights out of 201, to the date. The garden cottage came back with three. The two bedroom listing came back with seven, and the difference is the interesting part. On eight of the nights that the other three are closed, the two bedroom listing is still open. The port's table cannot produce that set. It has the three bedroom listing conflicting with the two bedroom one, so anything that closed those fourteen nights on the three bedroom listing would have closed the two bedroom listing on all fourteen as well. Whatever conflict table the live build is running differs from the port's on the two bedroom edge as well as on the studio edge, and I am not going to explain a live calendar with a table that cannot generate it. What the eight nights establish is narrower: on the live site the two bedroom listing moves independently of whatever closed the other three.

One night out of the 201 runs the other way, with the two bedroom listing closed while the listings that physically contain it stayed open. That is either a deliberate hold or a missing edge, and it is on my list for the day this publishes. It also makes the case for itself: the closure rule belongs in a test that reads the live endpoint and fails loudly, not in my memory of a table I typed once.

Pricing moves on the same rows. On that calendar the four bedroom listing asks $263 for a Monday in October and $1,600 for the Friday four days later, which is what a dynamic pricing feed looks like from the outside: nobody typed those numbers. The host sets the floor and the shape of the year. The feed fills in the rest.

What did this teach me that I still use?

Four things, and every one of them showed up again in client work later in the year.

The first is that a feed you do not own arrives on a schedule and sometimes arrives wrong, so the code that consumes it needs a blast radius. Owning your rows by source tag is the small version of the rule I ended up needing at a much larger scale on a real estate site running off an MLS feed, where a partial fetch that deletes is the difference between a slow morning and a site with no listings on it.

The second is that a scheduled job you cannot see is a scheduled job you do not have. A calendar sync that stops running looks exactly like a calendar with no bookings in it. That is the whole argument behind giving every unattended process a status file that says when it last succeeded.

The third is that properties sharing an address land their markers on top of each other, so anything that plots them has to spread them apart or nobody can tap the one they want. At four listings on one lot a handful of hard-coded offsets covers it. When the same problem came back with tens of thousands of markers on it, the answer was vector tiles and a real map engine.

The fourth is the one I use most. A booking form that lives inside the site, on the owner's own domain and in the owner's own database, is worth building rather than renting, and the reasons stack up quickly once compliance is involved: that is precisely the argument in what a licensed practice site needs, where the calendar carries appointments instead of nights.

The case study has the rest of the build in it: the guest portal, the galleries, the email list, the engine underneath. What I would tell anybody thinking about leaving the marketplaces is that the shopfront is the easy half. The calendar is the product.

Common questions

What is StayEugene?

A direct-booking site I built and run for a set of short-term rentals near Hayward Field and the University of Oregon in Eugene, Oregon. Guests browse five listings, pick dates against a live calendar and book with the host instead of through a marketplace. It is live at stayeugene.today, and the full build is written up as a case study at /work/stayeugene.

How much does booking direct actually save?

The site advertises 10 to 15 percent and shows the comparison it is based on: $1,420 for a four-night stay through a platform against $1,210 direct. The direct discount is a configured rate, set to 15 percent in production, and it appears as its own line on the quote next to the cleaning fee, the occupancy tax and a one percent management fee.

How does the site stop two guests booking the same room?

Availability is checked across a set of listings, not one listing. Four of the five listings are the same building sold at different sizes, so each one carries a list of the listings it conflicts with, and a night is only bookable when every listing in that set is free. The same check runs again at the moment the booking is created, because the calendar a guest is looking at is seconds old.

Why is the checkout date still available to book?

Because it is not a night. RFC 5545 defines DTEND on a calendar event as the non-inclusive end, so a Friday to Sunday reservation occupies Friday and Saturday nights, and Sunday is on sale again once the room is turned over. Blocking through DTEND instead costs a bookable night on every single reservation, silently.

Related

← All insights