diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d3cc5149..317bc081 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -23,6 +23,7 @@ export class AppComponent implements OnInit { { title: 'Schedule', url: '/app/tabs/schedule', icon: 'calendar-outline' }, { title: 'Speakers', url: '/app/tabs/speakers', icon: 'people-outline' }, { title: 'Keynote Speakers', url: '/app/tabs/keynote-speakers', icon: 'star-outline' }, + { title: 'Rooms', url: '/app/tabs/rooms', icon: 'pin-outline' }, ] presentationPages = [ { title: 'Talks', group: 'presentations', url: '/app/tabs/tracks/talks', icon: 'mic-outline'}, diff --git a/src/app/pages/room-detail/room-detail-routing.module.ts b/src/app/pages/room-detail/room-detail-routing.module.ts new file mode 100644 index 00000000..f4ac9c69 --- /dev/null +++ b/src/app/pages/room-detail/room-detail-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { RoomDetailPage } from './room-detail.page'; + +const routes: Routes = [ + { + path: '', + component: RoomDetailPage, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class RoomDetailPageRoutingModule {} diff --git a/src/app/pages/room-detail/room-detail.module.ts b/src/app/pages/room-detail/room-detail.module.ts new file mode 100644 index 00000000..6277fd92 --- /dev/null +++ b/src/app/pages/room-detail/room-detail.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { RoomDetailPageRoutingModule } from './room-detail-routing.module'; +import { RoomDetailPage } from './room-detail.page'; + +@NgModule({ + imports: [CommonModule, IonicModule, RoomDetailPageRoutingModule], + declarations: [RoomDetailPage], +}) +export class RoomDetailPageModule {} diff --git a/src/app/pages/room-detail/room-detail.page.html b/src/app/pages/room-detail/room-detail.page.html new file mode 100644 index 00000000..b72a50a4 --- /dev/null +++ b/src/app/pages/room-detail/room-detail.page.html @@ -0,0 +1,51 @@ + + + + + + {{ room?.name || 'Room' }} + + + + +
+ +

Room not found.

+
+ +
+ +

{{ room.name }}

+

{{ room.sessions.length }} {{ room.sessions.length === 1 ? 'session' : 'sessions' }} scheduled

+
+ +
+
{{ day.day }}
+ + + + +

{{ session.name }}

+

+ + {{ session.timeStart }} – {{ session.timeEnd }} +

+

+ {{ name }}, +

+ {{ session.track }} +
+
+
+
+ +
+
diff --git a/src/app/pages/room-detail/room-detail.page.scss b/src/app/pages/room-detail/room-detail.page.scss new file mode 100644 index 00000000..c64e9862 --- /dev/null +++ b/src/app/pages/room-detail/room-detail.page.scss @@ -0,0 +1,129 @@ +.room-hero { + text-align: center; + padding: 28px 16px 18px; + background: linear-gradient(135deg, var(--ion-color-primary, #2c4392) 0%, color-mix(in srgb, var(--ion-color-primary, #2c4392) 60%, #14224d) 100%); + color: #fff; + + .room-hero-icon { + font-size: 36px; + color: #fff; + margin-bottom: 6px; + opacity: 0.85; + } + + h1 { + font-size: 24px; + font-weight: 700; + margin: 0; + } + + p { + margin-top: 4px; + font-size: 13px; + opacity: 0.85; + } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + color: var(--ion-color-medium, #888); + + ion-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.5; + } +} + +.day-section { + margin-top: 24px; + + &:first-of-type { + margin-top: 16px; + } + + .day-header { + --pycon-accent: #680579; + + display: block; + margin: 0 16px 12px; + padding: 10px 16px; + border-radius: 999px; + + background: var(--pycon-accent); + color: #fff; + + font-size: 13px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + text-align: center; + + box-shadow: 0 2px 6px rgba(104, 5, 121, 0.25); + } +} + +:host-context(.dark-theme) .day-section .day-header { + --pycon-accent: #DD04D2; + box-shadow: 0 2px 6px rgba(221, 4, 210, 0.30); +} + +.session-item { + --padding-start: 16px; + --padding-end: 8px; + --inner-padding-end: 12px; + --min-height: 64px; + + &.session-item-highlight { + --background: rgba(104, 5, 121, 0.08); + box-shadow: inset 3px 0 0 #680579; + animation: room-session-pulse 1.6s ease-in-out 2; + } + + ion-label { + h3 { + font-size: 15px; + font-weight: 600; + margin: 0 0 4px; + } + + .session-time { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--ion-color-medium, #888); + margin: 0 0 4px; + + ion-icon { + font-size: 13px; + } + } + + .session-speakers { + font-size: 12px; + color: var(--ion-color-step-500, #666); + margin: 0 0 6px; + } + + .track-badge { + display: inline-block; + margin-top: 4px; + } + } +} + +@keyframes room-session-pulse { + 0% { background: rgba(104, 5, 121, 0.05); } + 50% { background: rgba(104, 5, 121, 0.18); } + 100% { background: rgba(104, 5, 121, 0.05); } +} + +:host-context(.dark-theme) .session-item.session-item-highlight { + --background: rgba(221, 4, 210, 0.10); + box-shadow: inset 3px 0 0 #DD04D2; +} diff --git a/src/app/pages/room-detail/room-detail.page.ts b/src/app/pages/room-detail/room-detail.page.ts new file mode 100644 index 00000000..8ef24ccb --- /dev/null +++ b/src/app/pages/room-detail/room-detail.page.ts @@ -0,0 +1,93 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { Subscription, combineLatest } from 'rxjs'; +import { ConferenceData } from '../../providers/conference-data'; + +interface RoomDay { + day: string; + sessions: any[]; +} + +@Component({ + selector: 'app-room-detail', + templateUrl: './room-detail.page.html', + styleUrls: ['./room-detail.page.scss'], +}) +export class RoomDetailPage implements OnInit, OnDestroy { + room: any = null; + days: RoomDay[] = []; + loaded = false; + highlightSessionId: any = null; + + private paramSub?: Subscription; + private dayOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + constructor( + private route: ActivatedRoute, + private router: Router, + public location: Location, + private confData: ConferenceData, + ) {} + + ngOnInit() { + this.paramSub = combineLatest([this.route.paramMap, this.route.queryParamMap]).subscribe( + ([params, query]) => { + const slug = params.get('roomSlug'); + this.highlightSessionId = query.get('session'); + this.loadRoom(slug); + }, + ); + } + + ngOnDestroy() { + this.paramSub?.unsubscribe(); + } + + private loadRoom(slug: string | null) { + if (!slug) return; + this.confData.getRoom(slug).subscribe((room: any) => { + this.loaded = true; + this.room = room || null; + this.days = room ? this.groupByDay(room.sessions) : []; + if (this.highlightSessionId) { + this.scrollToSession(this.highlightSessionId); + } + }); + } + + private scrollToSession(sessionId: any) { + setTimeout(() => { + const el = document.getElementById('room-session-' + sessionId); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 200); + } + + private groupByDay(sessions: any[]): RoomDay[] { + const map = new Map(); + sessions.forEach((s: any) => { + const day = s.day || 'TBD'; + if (!map.has(day)) map.set(day, []); + map.get(day).push(s); + }); + return Array.from(map.entries()) + .sort(([a], [b]) => { + const ai = this.dayOrder.indexOf(a); + const bi = this.dayOrder.indexOf(b); + if (ai === -1 && bi === -1) return a.localeCompare(b); + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }) + .map(([day, daySessions]) => ({ day, sessions: daySessions })); + } + + sessionRoute(session: any) { + if (typeof session.id === 'string' && session.id.startsWith('poster-detail-')) { + return ['/app/tabs/schedule/session', session.id]; + } + return ['/app/tabs/schedule/session', session.id]; + } +} diff --git a/src/app/pages/rooms/rooms-routing.module.ts b/src/app/pages/rooms/rooms-routing.module.ts new file mode 100644 index 00000000..712c7db3 --- /dev/null +++ b/src/app/pages/rooms/rooms-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { RoomsPage } from './rooms.page'; + +const routes: Routes = [ + { + path: '', + component: RoomsPage, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class RoomsPageRoutingModule {} diff --git a/src/app/pages/rooms/rooms.module.ts b/src/app/pages/rooms/rooms.module.ts new file mode 100644 index 00000000..ddcd3c1a --- /dev/null +++ b/src/app/pages/rooms/rooms.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { RoomsPageRoutingModule } from './rooms-routing.module'; +import { RoomsPage } from './rooms.page'; + +@NgModule({ + imports: [CommonModule, IonicModule, RoomsPageRoutingModule], + declarations: [RoomsPage], +}) +export class RoomsPageModule {} diff --git a/src/app/pages/rooms/rooms.page.html b/src/app/pages/rooms/rooms.page.html new file mode 100644 index 00000000..1ec3d6a5 --- /dev/null +++ b/src/app/pages/rooms/rooms.page.html @@ -0,0 +1,32 @@ + + + + + + Rooms + + + + +
+ +

Loading rooms…

+
+ + + + + +

{{ room.name }}

+

{{ room.sessions.length }} {{ room.sessions.length === 1 ? 'session' : 'sessions' }}

+
+
+
+ +
+
diff --git a/src/app/pages/rooms/rooms.page.scss b/src/app/pages/rooms/rooms.page.scss new file mode 100644 index 00000000..31cd90fa --- /dev/null +++ b/src/app/pages/rooms/rooms.page.scss @@ -0,0 +1,40 @@ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + color: var(--ion-color-medium, #888); + + ion-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.5; + } +} + +.room-item { + --padding-start: 18px; + --padding-end: 8px; + --inner-padding-end: 12px; + --min-height: 64px; + + ion-icon[slot="start"] { + margin-right: 16px; + font-size: 20px; + } + + ion-label { + h2 { + font-size: 16px; + font-weight: 600; + margin: 0; + } + + p { + margin-top: 2px; + font-size: 13px; + color: var(--ion-color-medium, #888); + } + } +} diff --git a/src/app/pages/rooms/rooms.page.ts b/src/app/pages/rooms/rooms.page.ts new file mode 100644 index 00000000..7f025861 --- /dev/null +++ b/src/app/pages/rooms/rooms.page.ts @@ -0,0 +1,19 @@ +import { Component, OnInit } from '@angular/core'; +import { ConferenceData } from '../../providers/conference-data'; + +@Component({ + selector: 'app-rooms', + templateUrl: './rooms.page.html', + styleUrls: ['./rooms.page.scss'], +}) +export class RoomsPage implements OnInit { + rooms: any[] = []; + + constructor(private confData: ConferenceData) {} + + ngOnInit() { + this.confData.getRooms().subscribe((rooms: any[]) => { + this.rooms = rooms; + }); + } +} diff --git a/src/app/pages/session-detail/session-detail.html b/src/app/pages/session-detail/session-detail.html index 4728be45..39931fd3 100644 --- a/src/app/pages/session-detail/session-detail.html +++ b/src/app/pages/session-detail/session-detail.html @@ -37,7 +37,17 @@

{{session.name}}

- {{session.location}} + + + {{ link.name }}, + + + + {{session.location}} +
diff --git a/src/app/pages/session-detail/session-detail.scss b/src/app/pages/session-detail/session-detail.scss index 03e919f7..6d945b79 100644 --- a/src/app/pages/session-detail/session-detail.scss +++ b/src/app/pages/session-detail/session-detail.scss @@ -150,6 +150,25 @@ ion-toolbar ion-button { color: #3B3EA9; flex-shrink: 0; } + + .room-links { + display: inline; + } + + .room-link { + color: #3B3EA9; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + + &:active { + opacity: 0.6; + } + } +} + +:host-context(.dark-theme) .meta-row .room-link { + color: #8b8fd4; } :host-context(.dark-theme) .meta-row ion-icon { diff --git a/src/app/pages/tabs-page/tabs-page-routing.module.ts b/src/app/pages/tabs-page/tabs-page-routing.module.ts index 4c61df1e..3a63beef 100644 --- a/src/app/pages/tabs-page/tabs-page-routing.module.ts +++ b/src/app/pages/tabs-page/tabs-page-routing.module.ts @@ -124,6 +124,23 @@ const routes: Routes = [ } ] }, + { + path: 'rooms', + children: [ + { + path: '', + loadChildren: () => import('../rooms/rooms.module').then(m => m.RoomsPageModule) + }, + { + path: 'room-detail/:roomSlug', + loadChildren: () => import('../room-detail/room-detail.module').then(m => m.RoomDetailPageModule) + }, + { + path: 'session/:sessionId', + loadChildren: () => import('../session-detail/session-detail.module').then(m => m.SessionDetailModule) + } + ] + }, { path: 'venues-hours', children: [ diff --git a/src/app/providers/conference-data.ts b/src/app/providers/conference-data.ts index adb0bca0..1ed9460e 100644 --- a/src/app/providers/conference-data.ts +++ b/src/app/providers/conference-data.ts @@ -47,6 +47,10 @@ export class ConferenceData { return /^https?:\/\//.test(photo) ? photo : `${environment.baseUrl}${photo}`; } + slugifyRoom(name: string): string { + return (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + } + constructor( public http: HttpClient, public user: UserData, @@ -218,15 +222,20 @@ export class ConferenceData { name = 'Lunch'; } } - // Extract room from parentheses in name (e.g., "Lunch (Hall C)" → room="Hall C") - // For breaks without parenthesized room, just use first room from comma-separated list + // Extract room from parentheses in name (e.g., "Lunch (Hall AB)" → room="Hall AB") + // and normalize bare "Hall AB"/"Hall C" to the canonical "Expo Hall …" + // venue used by posters, so a breakfast/lunch in the expo hall lines up + // with the same room bucket. Generic comma-joined breaks ("Break" with + // multiple rooms) are left untouched so the room-derivation pass below + // can fan them out across every listed room. let room = slot.room; const roomMatch = name.match(/\s*\(([^)]+)\)\s*$/); if (roomMatch) { room = roomMatch[1]; name = name.replace(roomMatch[0], '').trim(); - } else if (slot.kind === 'break' && room && room.includes(',')) { - room = ''; + } + if (room && /^Hall\s/i.test(room)) { + room = `Expo ${room}`; } collapsedGroups.set(key, { ...slot, name, room, endSlot: slot }); } else { @@ -242,7 +251,7 @@ export class ConferenceData { if (!group.room || group.room === '') { const laterRoom = markdownToTxt(slot.name).match(/\s*\(([^)]+)\)\s*$/); if (laterRoom) { - group.room = laterRoom[1]; + group.room = /^Hall\s/i.test(laterRoom[1]) ? `Expo ${laterRoom[1]}` : laterRoom[1]; } } } @@ -565,9 +574,53 @@ export class ConferenceData { }); }); + // Build a per-room index. Plenaries/breaks/lunch carry comma-joined room + // strings ("Grand Ballroom A, Grand Ballroom B") — we register the session + // under each one so it shows up wherever the audience actually is. Each + // session also gets a `roomLinks` array of {name, slug} so session-detail + // can render the location as tappable pills. + const roomMap = new Map(); + this.data.sessions.forEach((session: any) => { + const links: any[] = []; + String(session.location || '') + .split(',') + .map((r: string) => r.trim()) + .filter(Boolean) + .forEach((roomName: string) => { + const slug = this.slugifyRoom(roomName); + if (!roomMap.has(slug)) { + roomMap.set(slug, { name: roomName, slug, sessions: [] }); + } + roomMap.get(slug).sessions.push(session); + if (!links.find(l => l.slug === slug)) { + links.push({ name: roomName, slug }); + } + }); + session.roomLinks = links; + }); + roomMap.forEach((room: any) => { + room.sessions.sort( + (a: any, b: any) => + new Date(a.startUtc || 0).getTime() - new Date(b.startUtc || 0).getTime() + ); + }); + this.data.rooms = Array.from(roomMap.values()).sort((a: any, b: any) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }) + ); + return this.data; } + getRooms() { + return this.load().pipe(map((data: any) => data.rooms || [])); + } + + getRoom(slug: string) { + return this.load().pipe( + map((data: any) => (data.rooms || []).find((r: any) => r.slug === slug)) + ); + } + getDays( excludeTracks: any[] = [], segment = 'all' diff --git a/src/app/providers/live-update.service.ts b/src/app/providers/live-update.service.ts index 81fae028..3cd54b22 100644 --- a/src/app/providers/live-update.service.ts +++ b/src/app/providers/live-update.service.ts @@ -43,6 +43,7 @@ export class LiveUpdateService { this.channel = result.liveUpdate?.channel || ''; if (this.updateAvailable.activeApplicationPathChanged) { this.needsUpdate = true; + document.body.classList.add('has-pending-update'); } } diff --git a/src/global.scss b/src/global.scss index b058c6e1..0d13e9cb 100644 --- a/src/global.scss +++ b/src/global.scss @@ -103,3 +103,26 @@ ion-content [innerHTML] { border: 1px solid var(--ion-color-danger, #eb445a); } } + +/* + * Live-update indicator: small primary-color dot on the hamburger menu button + * whenever LiveUpdateService.needsUpdate is true. The service toggles the + * `has-pending-update` class on so this rule matches every page's + * ion-menu-button without per-page wiring. + */ +body.has-pending-update ion-menu-button { + position: relative; +} + +body.has-pending-update ion-menu-button::after { + content: ''; + position: absolute; + top: 8px; + right: 6px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--ion-color-primary, #3880ff); + box-shadow: 0 0 0 2px var(--ion-background-color, #fff); + pointer-events: none; +}