Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dependencies": {
"@capacitor/app": "^6.0.0",
"@capacitor/core": "^6.1.2",
"@vercel/analytics": "^2.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
Expand Down
11 changes: 10 additions & 1 deletion src/components/app/RoomCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ export interface RoomCardProps {
meta: string;
/** free (teal) / soon (amber) / occupied (red). */
status?: RoomStatus;
/** Override the small status tag (defaults to a word derived from `status`),
* e.g. "closed" when the campus is shut. */
statusLabel?: string;
/** The duration hero, e.g. "2h 10m", "all day", "25m", "until 11:30". */
duration?: string;
/** Optional soft caveat appended to the meta line, e.g. "may be locked". Shown
* in amber after another "·" separator, so it reads as a gentle warning. */
hint?: string;
href?: string;
onClick?: MouseEventHandler;
style?: CSSProperties;
Expand All @@ -25,7 +31,9 @@ export function RoomCard({
name,
meta,
status = 'free',
statusLabel,
duration = '',
hint,
href = '#',
onClick,
style = {},
Expand Down Expand Up @@ -86,6 +94,7 @@ export function RoomCard({
}}
>
{meta}
{hint && <span style={{ color: 'var(--status-soon)' }}> · {hint}</span>}
</div>
</div>
<div
Expand All @@ -107,7 +116,7 @@ export function RoomCard({
color: TONE.fg,
}}
>
{TONE.label}
{statusLabel ?? TONE.label}
</div>
<div
style={{
Expand Down
21 changes: 21 additions & 0 deletions src/domain/campusAvailability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildingAvailability,
CAMPUS_KEY_TO_BUILDINGS,
campusKeyForRoom,
isOnCampusMap,
} from './campusAvailability';
import type { FreeRoom, Room } from './models';

Expand Down Expand Up @@ -97,3 +98,23 @@ describe('buildingAvailability', () => {
expect(avail.A).toEqual({ free: 1, total: 1 });
});
});

describe('isOnCampusMap', () => {
it('accepts a mapped Deggendorf room', () => {
expect(isOnCampusMap(room({ ident: 'a1', building: 'A', name: 'A 0.13 Hörsaal' }))).toBe(true);
});

it('rejects a remote Cham/Badstraße room that reuses a Deggendorf letter', () => {
const cham = room({ ident: 'c1', building: 'A', name: 'A 0.13 Hörsaal Zollner (Badstraße)' });
expect(campusKeyForRoom(cham)).toBe('A'); // collides with the core A footprint…
expect(isOnCampusMap(cham)).toBe(false); // …but the marker keeps it off the map
});

it('rejects a Pfarrkirchen/ECRI room', () => {
expect(isOnCampusMap(room({ ident: 'p1', building: 'B', name: 'B1 (Pfarrkirchen)' }))).toBe(false);
});

it('rejects rooms whose building has no footprint', () => {
expect(isOnCampusMap(room({ ident: 'x1', building: 'DMS', name: 'DMS 1.01' }))).toBe(false);
});
});
25 changes: 24 additions & 1 deletion src/domain/campusAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* `Availability` shape the `CampusMap` component consumes, without importing it.
*/
import type { FreeRoom, Room } from './models';
import { isExcludedVenue } from './priority';
import { isExcludedVenue, normalizeForMatching } from './priority';

/** One building's headline availability. */
export interface BuildingCount {
Expand Down Expand Up @@ -44,6 +44,29 @@ export const CAMPUS_KEY_TO_BUILDINGS: Record<string, string[]> = {
V2: ['V2'],
};

/**
* Room-name markers for THD's non-Deggendorf campuses (Cham/Badstraße and
* Pfarrkirchen/ECRI). These remote sites reuse Deggendorf building letters —
* Cham has its own "A"/"B" — so they collide with mapped footprints and must be
* rejected explicitly. Matched against the umlaut-folded room text, so
* "Badstraße" → "badstrasse" matches "badstra".
*/
const OFF_CAMPUS_MARKERS = ['badstra', 'cham', 'pfarrkirchen', 'ecri', 'rottal'];

/**
* Whether a room belongs on the Deggendorf campus map. The 2.5D map only draws
* the Deggendorf riverside core, so a room qualifies when it (a) resolves to a
* mapped footprint and (b) carries no remote-campus marker. Rooms at other sites
* (Cham, Pfarrkirchen, Land-Au, …) stay in the full Rooms list but off the map.
*/
export function isOnCampusMap(room: Room): boolean {
if (campusKeyForRoom(room) === null) return false;
const text = normalizeForMatching(
[room.name, room.displayName, room.untisLongname ?? ''].join(' '),
);
return !OFF_CAMPUS_MARKERS.some((m) => text.includes(m));
}

/** Resolve one parsed THabella room onto its campus-map footprint. */
export function campusKeyForRoom(room: Room): string | null {
// The general room mapper intentionally keeps the legacy `ITC` building code,
Expand Down
43 changes: 42 additions & 1 deletion src/domain/openingHours.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getCampusHours, getLibraryHours, periodFor } from './openingHours';
import { getCafeteriaHours, getCampusHours, getLibraryHours, isCafeteria, periodFor } from './openingHours';

// Local Date constructor (year, monthIndex, day, hours, minutes).
const dt = (y: number, m: number, d: number, hh = 0, mm = 0) => new Date(y, m - 1, d, hh, mm);
Expand Down Expand Up @@ -94,3 +94,44 @@ describe('getLibraryHours', () => {
expect(h.todayClose).toEqual(dt(2026, 7, 4, 23, 45));
});
});

describe('getCafeteriaHours', () => {
it('recognises the cafeteria map keys', () => {
expect(isCafeteria('GH')).toBe(true);
expect(isCafeteria('F')).toBe(true);
expect(isCafeteria('K')).toBe(true);
expect(isCafeteria('A')).toBe(false);
expect(getCafeteriaHours(dt(2026, 5, 13, 10, 0), 'A')).toBeNull();
});

it('opens the Glashaus Mon–Fri 07:30–14:00 in term/exam', () => {
const h = getCafeteriaHours(dt(2026, 5, 13, 10, 0), 'GH')!; // Wed regular
expect(h.open).toBe(true);
expect(h.todayOpen).toEqual(dt(2026, 5, 13, 7, 30));
expect(h.todayClose).toEqual(dt(2026, 5, 13, 14, 0));
expect(getCafeteriaHours(dt(2026, 5, 13, 14, 30), 'GH')!.open).toBe(false);
});

it('shortens the Glashaus to 07:30–12:00 during the break', () => {
const h = getCafeteriaHours(dt(2026, 8, 12, 11, 0), 'GH')!; // Wed break
expect(h.period).toBe('break');
expect(h.open).toBe(true);
expect(h.todayClose).toEqual(dt(2026, 8, 12, 12, 0));
});

it('closes the Mensa cafeteria earlier on Fridays (15:30)', () => {
expect(getCafeteriaHours(dt(2026, 5, 13, 16, 30), 'F')!.open).toBe(true); // Wed 17:00
const fri = getCafeteriaHours(dt(2026, 5, 15, 16, 0), 'F')!; // Fri after 15:30
expect(fri.open).toBe(false);
expect(getCafeteriaHours(dt(2026, 5, 15, 15, 0), 'F')!.todayClose).toEqual(
dt(2026, 5, 15, 15, 30),
);
});

it('opens the Kaffeebar 09:30–13:30 in term but is closed over the break', () => {
const term = getCafeteriaHours(dt(2026, 5, 13, 10, 0), 'K')!; // Wed regular
expect(term.open).toBe(true);
expect(term.todayClose).toEqual(dt(2026, 5, 13, 13, 30));
expect(getCafeteriaHours(dt(2026, 8, 12, 11, 0), 'K')!.open).toBe(false); // Aug break
});
});
79 changes: 79 additions & 0 deletions src/domain/openingHours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,82 @@ export function getCampusHours(now: Date): CampusHours {
export function getLibraryHours(now: Date): CampusHours {
return resolveHours(now, LIBRARY);
}

// ── Cafeterias (STWNO gastronomy) ─────────────────────────────────────────────
// The café/canteen buildings keep their own STWNO hours, unrelated to teaching.
// STWNO publishes two sets per venue: one for lecture + exam periods, one for the
// lecture-free ("break") time. Weekends closed. Source: stwno.de/…/cafeterien-deggendorf.

// Glashaus (map key GH): lecture/exam Mon–Fri 07:30–14:00; break Mon–Fri 07:30–12:00.
const GLASHAUS_TERM: DayHours[] = [
null, // Sun
{ open: H(7, 30), close: H(14) }, // Mon
{ open: H(7, 30), close: H(14) }, // Tue
{ open: H(7, 30), close: H(14) }, // Wed
{ open: H(7, 30), close: H(14) }, // Thu
{ open: H(7, 30), close: H(14) }, // Fri
null, // Sat
];
const GLASHAUS_BREAK: DayHours[] = [
null,
{ open: H(7, 30), close: H(12) },
{ open: H(7, 30), close: H(12) },
{ open: H(7, 30), close: H(12) },
{ open: H(7, 30), close: H(12) },
{ open: H(7, 30), close: H(12) },
null,
];
const GLASHAUS: Schedule = { regular: GLASHAUS_TERM, exam: GLASHAUS_TERM, break: GLASHAUS_BREAK };

// Mensa-building cafeteria (map key F): lecture/exam Mon–Thu 07:30–17:00, Fri
// 07:30–15:30; break Mon–Thu 07:30–16:00, Fri 07:30–15:30.
const MENSA_TERM: DayHours[] = [
null,
{ open: H(7, 30), close: H(17) }, // Mon
{ open: H(7, 30), close: H(17) }, // Tue
{ open: H(7, 30), close: H(17) }, // Wed
{ open: H(7, 30), close: H(17) }, // Thu
{ open: H(7, 30), close: H(15, 30) }, // Fri
null,
];
const MENSA_BREAK: DayHours[] = [
null,
{ open: H(7, 30), close: H(16) }, // Mon
{ open: H(7, 30), close: H(16) }, // Tue
{ open: H(7, 30), close: H(16) }, // Wed
{ open: H(7, 30), close: H(16) }, // Thu
{ open: H(7, 30), close: H(15, 30) }, // Fri
null,
];
const MENSA: Schedule = { regular: MENSA_TERM, exam: MENSA_TERM, break: MENSA_BREAK };

// Kaffeebar (K-Gebäude, map key K): lecture/exam Mon–Fri 09:30–13:30. STWNO lists
// lecture-free hours of 10:00–14:00 for bridge days, but the café is closed for
// the whole summer break (27 Jul–30 Sep), which is the dominant break-time state,
// so we model `break` as closed rather than showing hours it won't keep.
const KAFFEEBAR_TERM: DayHours[] = [
null,
{ open: H(9, 30), close: H(13, 30) }, // Mon
{ open: H(9, 30), close: H(13, 30) }, // Tue
{ open: H(9, 30), close: H(13, 30) }, // Wed
{ open: H(9, 30), close: H(13, 30) }, // Thu
{ open: H(9, 30), close: H(13, 30) }, // Fri
null,
];
const KAFFEEBAR_BREAK: DayHours[] = [null, null, null, null, null, null, null];
const KAFFEEBAR: Schedule = { regular: KAFFEEBAR_TERM, exam: KAFFEEBAR_TERM, break: KAFFEEBAR_BREAK };

/** Cafeteria schedules keyed by campus-map building key. */
const CAFETERIAS: Record<string, Schedule> = { GH: GLASHAUS, F: MENSA, K: KAFFEEBAR };

/** True when `key` is a cafeteria with published STWNO hours. */
export function isCafeteria(key: string): boolean {
return key in CAFETERIAS;
}

/** Opening state of a cafeteria (map key GH / F / K) at `now`, or null if `key`
* is not a known cafeteria. */
export function getCafeteriaHours(now: Date, key: string): CampusHours | null {
const schedule = CAFETERIAS[key];
return schedule ? resolveHours(now, schedule) : null;
}
22 changes: 21 additions & 1 deletion src/domain/roomFilters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { favoritesFirst, matchesRoomFilters, roomKind } from './roomFilters';
import { favoritesFirst, matchesRoomFilters, mayBeLocked, roomKind } from './roomFilters';
import type { FreeRoom, Room } from './models';

function room(partial: Partial<Room> & Pick<Room, 'ident' | 'name'>): Room {
Expand Down Expand Up @@ -41,6 +41,26 @@ describe('roomKind', () => {
);
});

it('flags labs and computer/EDV rooms as may-be-locked at any size', () => {
expect(mayBeLocked(room({ ident: 'l1', name: 'A008 - Labor', seatsRegular: 60 }))).toBe(true);
expect(mayBeLocked(room({ ident: 'l2', name: 'K209 - Rechnerraum', seatsRegular: 24 }))).toBe(true);
expect(mayBeLocked(room({ ident: 'l3', name: 'Deggs 0.02 (EDV)', seatsRegular: 62 }))).toBe(true);
});

it('flags small seminar/meeting rooms, even with a "Sendehörsaal" facility', () => {
// E203: 16-seat meeting room that merely has broadcast kit fitted.
const e203 = room({ ident: 's1', name: 'E203', seatsRegular: 16, facilities: ['Besprechungsraum', 'Sendehörsaal'] });
expect(mayBeLocked(e203)).toBe(true);
expect(mayBeLocked(room({ ident: 's2', name: 'A210', seatsRegular: 30 }))).toBe(true);
});

it('reads big lecture rooms as open: 40+ seats, named Hörsaal/Kino, or building I', () => {
// B004: 220-seat hall whose only "hörsaal" marker is the Sendehörsaal facility.
expect(mayBeLocked(room({ ident: 'h0', name: 'B004', seatsRegular: 220, facilities: ['Sendehörsaal'] }))).toBe(false);
expect(mayBeLocked(room({ ident: 'h1', name: 'B 0.13 Hörsaal 1', seatsRegular: 20 }))).toBe(false);
expect(mayBeLocked(room({ ident: 'h2', name: 'I107', building: 'I', seatsRegular: 100 }))).toBe(false);
});

it('falls back to seminar for a plain classroom', () => {
expect(roomKind(room({ ident: '7', name: 'A110' }))).toBe('seminar');
});
Expand Down
28 changes: 28 additions & 0 deletions src/domain/roomFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,34 @@ export function roomKind(room: Room): RoomKind {
return 'seminar';
}

// Markers matched against the room NAME only — deliberately NOT the facilities
// list, where "Sendehörsaal" (a broadcast feature fitted to rooms of every size,
// from the 16-seat E203 to the 220-seat B004) would otherwise misclassify them
// as lecture halls via the substring "hörsaal".
const LAB_NAME = /labor|\blab\b|praktikum|rechner|\bedv\b|(^|\W)pcs?(\W|$)/;
const LECTURE_HALL_NAME = /hoersaal|kino|(^|\W)hs(\W|$)/;
/** Rooms this size or larger read as general lecture rooms (open); see below. */
const OPEN_SEAT_THRESHOLD = 40;

/**
* Whether a room is likely locked when it has no class. THabella reports a room
* as "free" the moment no event occupies it, but many rooms are physically
* locked outside their courses, so a student shouldn't cross campus on a maybe.
* We never hide these — just add a soft "may be locked" caveat. The heuristic
* mirrors how the rooms actually behave:
* • specialised labs (incl. computer/EDV rooms) stay locked at any size;
* • named lecture halls (Hörsaal/Kino) and building "I" read as open;
* • otherwise small rooms (< 40 seats) may be locked, big rooms read as open —
* the "prioritise the 40+ lecture rooms" rule, since seat count is the only
* signal separating a 16-seat seminar room from a 220-seat hall.
*/
export function mayBeLocked(room: Room): boolean {
const name = normalizeForMatching([room.name, room.displayName].join(' '));
if (LAB_NAME.test(name)) return true;
if (LECTURE_HALL_NAME.test(name) || room.building === 'I') return false;
return room.seatsRegular < OPEN_SEAT_THRESHOLD;
}

/** User-adjustable filters, shared by the room list and the campus map. */
export interface RoomFilters {
/** Only rooms that report a seat count (hides store rooms, foyers, etc.). */
Expand Down
Loading
Loading