"""Belegungsplan: a rooms × days occupancy timeline built from the guest register (the house's who-is-present record; the ledger is billing and stays out of this). Date semantics are half-open [arrival, departure): the departure day itself is free — it is the 'Von' of the follow-up row when a weekly renter is carried forward, so the bars tile without overlapping. An open departure means "still here" only while the arrival is recent; an open stay older than a year is a forgotten departure (same 365-day rule as the TVB Meldung) and is left to the Datenprüfung instead of blocking a room forever. """ import datetime from app import db, i18n FAR = "9999-12-31" STALE_AFTER_DAYS = 365 # keep in sync with tvb.STALE_AFTER_DAYS MONTHS = i18n.t("months") MONTHS_SHORT = i18n.t("months_short") WEEKDAYS = i18n.t("weekdays_short") def default_start(today=None): """Monday of last week — a little context behind, most of the window ahead.""" today = today or datetime.date.today() return today - datetime.timedelta(days=today.weekday() + 7) def _pack_lanes(stays): """Greedy interval packing: each stay goes into the first lane free at its arrival, so concurrent occupants of a room stack while back-to-back ones share a line.""" lane_ends, out = [], [] for st in stays: dep = st["departure"] or FAR for i, end in enumerate(lane_ends): if end <= st["arrival"]: lane_ends[i] = dep out.append((st, i)) break else: lane_ends.append(dep) out.append((st, len(lane_ends) - 1)) return out, max(len(lane_ends), 1) def build(conn, start, days, today=None): today = today or datetime.date.today() end = start + datetime.timedelta(days=days) s_iso, e_iso, t_iso = start.isoformat(), end.isoformat(), today.isoformat() fresh = (today - datetime.timedelta(days=STALE_AFTER_DAYS)).isoformat() stays = db.rows(conn, """ SELECT s.id, s.room_id, s.arrival, s.departure, s.sheet_number, COALESCE(TRIM(g.last_name || ' ' || COALESCE(g.first_name,'')), s.label) AS name, cl.name AS employer FROM stay s LEFT JOIN guest g ON g.id=s.guest_id LEFT JOIN client cl ON cl.id=s.employer_client_id WHERE s.source_sheet='guestsheet' AND s.arrival IS NOT NULL AND s.arrival < ? AND COALESCE(s.departure, ?) > ? AND (s.departure IS NOT NULL OR s.arrival >= ?) ORDER BY s.arrival NULLS FIRST, COALESCE(s.departure, ?), s.id""", (e_iso, FAR, s_iso, fresh, FAR)) per_room, unplaced = {}, [] for st in stays: if st["room_id"] is None: unplaced.append(dict(st)) else: per_room.setdefault(st["room_id"], []).append(st) # today's occupants per room, independent of the visible window counts = {r[0]: r[1] for r in db.rows(conn, """ SELECT room_id, COUNT(*) FROM stay WHERE source_sheet='guestsheet' AND room_id IS NOT NULL AND arrival IS NOT NULL AND arrival <= ? AND COALESCE(departure, ?) > ? AND (departure IS NOT NULL OR arrival >= ?) GROUP BY room_id""", (t_iso, FAR, t_iso, fresh))} rooms = [] for r in db.rows(conn, """SELECT r.id, r.number, r.category, r.capacity, r.long_term, u.code AS unit FROM room r LEFT JOIN unit u ON u.id=r.unit_id ORDER BY r.number"""): packed, lanes = _pack_lanes(per_room.get(r["id"], [])) bars = [] for st, lane in packed: dep = st["departure"] or FAR a, b = max(st["arrival"], s_iso), min(dep, e_iso) c1 = (datetime.date.fromisoformat(a) - start).days + 1 c2 = max((datetime.date.fromisoformat(b) - start).days + 1, c1 + 1) state = ("past" if st["departure"] and st["departure"] <= t_iso else "future" if st["arrival"] > t_iso else "current") bars.append({ "stay_id": st["id"], "name": st["name"] or "?", "employer": st["employer"], "sheet_number": st["sheet_number"], "arrival": st["arrival"], "departure": st["departure"], "col": c1, "col_end": c2, "lane": lane + 1, "state": state, "clip_start": st["arrival"] < s_iso, "clip_end": dep > e_iso, "open_end": st["departure"] is None, }) rooms.append({**dict(r), "bars": bars, "lanes": lanes, "today_count": counts.get(r["id"], 0), "floor": str(r["number"])[:1]}) day_list, months = [], [] for i in range(days): d = start + datetime.timedelta(days=i) day_list.append({"iso": d.isoformat(), "dom": d.day, "wd": WEEKDAYS[d.weekday()], "weekend": d.weekday() >= 5, "today": d == today, "monday": d.weekday() == 0, "month_start": d.day == 1, "label": f"{d.day:02d}.{d.month:02d}."}) m = f"{MONTHS[d.month - 1]} {d.year}" if months and months[-1]["label"] == m: months[-1]["span"] += 1 else: months.append({"label": m, "short": MONTHS_SHORT[d.month - 1], "span": 1}) for m in months: # pick a label that fits the span so it can't run into the next month's divider — # 810px is the grid width at 200% display scaling, the narrowest supported case est = m["span"] * 810 / days m["text"] = m["label"] if est >= 95 else (m["short"] if est >= 40 else "") live = [r for r in rooms if not r["long_term"]] return { "rooms": rooms, "days": day_list, "months": months, "n_days": days, "start": start, "end": end, "today": today, "last": (end - datetime.timedelta(days=1)).isoformat(), "prev_start": (start - datetime.timedelta(days=7)).isoformat(), "next_start": (start + datetime.timedelta(days=7)).isoformat(), "guests_today": sum(r["today_count"] for r in rooms), "free_beds": sum(max(0, (r["capacity"] or 0) - r["today_count"]) for r in live), "unplaced": unplaced, }