From 3904a5c0f2466461536e7febac198ac4124ab21d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sat, 11 Apr 2026 16:14:03 -0500 Subject: [PATCH 1/3] Add dev tools page and fix dev environment URL - New Dev Tools page (dev mode only) with storage stats, clear scan data, invalidate cache, nuke storage, dump to console - Dev Tools link at top of sidebar (only in dev config) - Fix dev environment baseUrl: localhost -> 127.0.0.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/app.component.html | 9 +- src/app/app.component.ts | 4 + .../dev-tools/dev-tools-routing.module.ts | 11 ++ src/app/pages/dev-tools/dev-tools.module.ts | 11 ++ src/app/pages/dev-tools/dev-tools.page.html | 100 ++++++++++++++++++ src/app/pages/dev-tools/dev-tools.page.scss | 1 + src/app/pages/dev-tools/dev-tools.page.ts | 82 ++++++++++++++ .../tabs-page/tabs-page-routing.module.ts | 9 ++ src/environments/environment.dev.ts | 2 +- 9 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/app/pages/dev-tools/dev-tools-routing.module.ts create mode 100644 src/app/pages/dev-tools/dev-tools.module.ts create mode 100644 src/app/pages/dev-tools/dev-tools.page.html create mode 100644 src/app/pages/dev-tools/dev-tools.page.scss create mode 100644 src/app/pages/dev-tools/dev-tools.page.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 90ef21f4..e7af6f86 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -8,6 +8,13 @@ PyCon US 2026 + + + + Dev Tools + + + Hello, {{nickname}} @@ -43,7 +50,7 @@ - + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 271b8958..d3cc5149 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -10,6 +10,7 @@ import { Storage } from '@ionic/storage-angular'; import { UserData } from './providers/user-data'; import { ConferenceData } from './providers/conference-data'; import { LiveUpdateService } from './providers/live-update.service'; +import { environment } from '../environments/environment'; @Component({ selector: 'app-root', @@ -67,6 +68,8 @@ export class AppComponent implements OnInit { hasLeadRetrieval = false; hasDoorCheck= false; hasMaskViolation = false; + isDev = !environment.production; + environmentUrl = environment.baseUrl; constructor( private menu: MenuController, @@ -215,4 +218,5 @@ export class AppComponent implements OnInit { openUrl(url: string) { window.open(url, '_system', 'location=yes'); } + } diff --git a/src/app/pages/dev-tools/dev-tools-routing.module.ts b/src/app/pages/dev-tools/dev-tools-routing.module.ts new file mode 100644 index 00000000..7f24a605 --- /dev/null +++ b/src/app/pages/dev-tools/dev-tools-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { DevToolsPage } from './dev-tools.page'; + +const routes: Routes = [{ path: '', component: DevToolsPage }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class DevToolsPageRoutingModule {} diff --git a/src/app/pages/dev-tools/dev-tools.module.ts b/src/app/pages/dev-tools/dev-tools.module.ts new file mode 100644 index 00000000..d7622f1b --- /dev/null +++ b/src/app/pages/dev-tools/dev-tools.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { DevToolsPageRoutingModule } from './dev-tools-routing.module'; +import { DevToolsPage } from './dev-tools.page'; + +@NgModule({ + imports: [CommonModule, IonicModule, DevToolsPageRoutingModule], + declarations: [DevToolsPage] +}) +export class DevToolsPageModule {} diff --git a/src/app/pages/dev-tools/dev-tools.page.html b/src/app/pages/dev-tools/dev-tools.page.html new file mode 100644 index 00000000..db321298 --- /dev/null +++ b/src/app/pages/dev-tools/dev-tools.page.html @@ -0,0 +1,100 @@ + + + + + + Dev Tools + + + + + + + Environment + + + + + +

Name

+

{{env.name}}

+
+
+ + +

API Base URL

+

{{env.baseUrl}}

+
+
+ + +

Timezone

+

{{env.timezone}}

+
+
+ + +

Production

+

{{env.production}}

+
+
+
+
+
+ + + + Storage + {{storageCount}} keys | {{scanCount}} scans + + + + + Dump Storage to Console + + + + + + + Actions + + + + + + +

Clear Scan Data

+

Remove all scans, notes, consent, sponsor selection

+
+
+ + + + +

Invalidate Schedule Cache

+

Force re-fetch of conference.json on next load

+
+
+ + + + +

Nuke All Storage

+

Wipe everything and reload — you will be logged out

+
+
+ + + + +

Refresh Stats

+

Update storage key counts

+
+
+
+
+
+ +
+
diff --git a/src/app/pages/dev-tools/dev-tools.page.scss b/src/app/pages/dev-tools/dev-tools.page.scss new file mode 100644 index 00000000..e6576d16 --- /dev/null +++ b/src/app/pages/dev-tools/dev-tools.page.scss @@ -0,0 +1 @@ +/* Minimal — uses Ionic defaults */ diff --git a/src/app/pages/dev-tools/dev-tools.page.ts b/src/app/pages/dev-tools/dev-tools.page.ts new file mode 100644 index 00000000..139879c2 --- /dev/null +++ b/src/app/pages/dev-tools/dev-tools.page.ts @@ -0,0 +1,82 @@ +import { Component } from '@angular/core'; +import { Storage } from '@ionic/storage-angular'; +import { ToastController } from '@ionic/angular'; +import { ConferenceData } from '../../providers/conference-data'; +import { environment } from '../../../environments/environment'; + +@Component({ + selector: 'app-dev-tools', + templateUrl: './dev-tools.page.html', + styleUrls: ['./dev-tools.page.scss'], +}) +export class DevToolsPage { + env = environment; + storageKeys: string[] = []; + storageCount = 0; + scanCount = 0; + + constructor( + private storage: Storage, + private toastCtrl: ToastController, + private confData: ConferenceData, + ) {} + + async ionViewWillEnter() { + await this.refreshStats(); + } + + async refreshStats() { + const keys = await this.storage.keys(); + this.storageKeys = keys; + this.storageCount = keys.length; + this.scanCount = keys.filter(k => + k.startsWith('pending-scan-') || k.startsWith('synced-scan-') || k.startsWith('failed-scan-') + ).length; + } + + async clearScanData() { + const keys = await this.storage.keys(); + let cleared = 0; + for (const key of keys) { + if (key.startsWith('pending-scan-') || key.startsWith('synced-scan-') || key.startsWith('failed-scan-') || + key.startsWith('pending-note-') || key.startsWith('note-') || + key === 'staff-sponsor-id' || key === 'staff-sponsor-name' || key === 'hasScannerConsent') { + await this.storage.remove(key); + cleared++; + } + } + await this.showToast(`Cleared ${cleared} scan entries`); + await this.refreshStats(); + } + + async invalidateCache() { + this.confData.invalidateCache(); + await this.showToast('Schedule cache invalidated — pull to refresh'); + await this.refreshStats(); + } + + async nukeStorage() { + await this.storage.clear(); + await this.showToast('All storage wiped — reloading...'); + setTimeout(() => window.location.reload(), 500); + } + + async dumpStorage() { + const dump: any = {}; + await this.storage.forEach((value, key) => { + dump[key] = typeof value === 'object' ? JSON.stringify(value).substring(0, 100) : String(value); + }); + console.table(dump); + await this.showToast('Storage dumped to console (open DevTools)'); + } + + private async showToast(message: string) { + const toast = await this.toastCtrl.create({ + message, + duration: 2000, + position: 'bottom', + color: 'dark', + }); + toast.present(); + } +} 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 c1f4b0ca..4c61df1e 100644 --- a/src/app/pages/tabs-page/tabs-page-routing.module.ts +++ b/src/app/pages/tabs-page/tabs-page-routing.module.ts @@ -226,6 +226,15 @@ const routes: Routes = [ path: 'login', loadChildren: () => import('../login/login.module').then(m => m.LoginModule) }, + { + path: 'dev-tools', + children: [ + { + path: '', + loadChildren: () => import('../dev-tools/dev-tools.module').then(m => m.DevToolsPageModule) + } + ] + }, { path: '', redirectTo: '/app/tabs/schedule', diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts index 66e5aa6d..9e4a8dc8 100644 --- a/src/environments/environment.dev.ts +++ b/src/environments/environment.dev.ts @@ -1,7 +1,7 @@ export const environment = { name: 'development', production: false, - baseUrl: 'http://localhost:8000', + baseUrl: 'http://127.0.0.1:8000', storageKey: '__pycon_us_mobile_development_2026', timezone: 'America/Los_Angeles', utcOffset: -7, From dfc6f021c4cca6abffbce0c289a17ad4e3e8fc17 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sat, 11 Apr 2026 16:14:22 -0500 Subject: [PATCH 2/3] Redesign lead scanner page - Branded header + sponsor banner with logo, filter funnel icon - Staff sponsor filter integrated into banner (tap funnel to toggle) - Clean scan list with status dots, sponsor badges, tappable rows - Empty state with icon when no scans - Full-width Start Scanner button, simulate above (dev only) - Last scan toast bar in footer - Sponsor logo stored and displayed - Scan rows open notes on tap Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/pages/map/map.html | 194 +++++++++++------------- src/app/pages/map/map.scss | 264 +++++++++++++++++++++++++++++++-- src/app/pages/map/map.ts | 68 ++++++++- src/app/providers/pycon-api.ts | 4 +- 4 files changed, 405 insertions(+), 125 deletions(-) diff --git a/src/app/pages/map/map.html b/src/app/pages/map/map.html index ffe2ba17..5f371f77 100644 --- a/src/app/pages/map/map.html +++ b/src/app/pages/map/map.html @@ -1,8 +1,7 @@ - + - - 1 + Lead Scanner @@ -13,120 +12,95 @@ - - - -

Scanning for: {{ selectedSponsor.name }}

-

Tap to change sponsor

-
- -
+ + - - - -

No sponsor selected

-

Select a sponsor before scanning

-
- Select -
+ - - - Recent Scans - - - - This list displays the recent scans on this device only.
- View all your scans and the captured data on your dashboard at us.pycon.org.
-
-
- - - - Uh oh.. - Camera Permissions Not Granted - - - Looks like you did not grant this application permission to access your camera. - - You can resolve this by opening

- Settings > Privacy & Security > Camera > PyCon US 2026

- Settings > Apps > PyCon US 2026 > Permissions > Camera

- And enabling camera permssions for this app. -
-
-
-
- + + + +

Camera Permission Required

+

Open Settings > Privacy > Camera > PyCon USSettings > Apps > PyCon US > Camera and enable access.

+
+
+ + + +
+ +

No scans yet

+

Tap "Start Scanner" to begin scanning badges

+
+ + + + +
- - - -

- - - {{(scan.status === 'captured')? scan.first_name : scan.access_code}} -

-

Scanned {{scan.scanned_at|dateAgo}}

-

Captured {{scan.scanned_at|dateAgo}}

-

Pending capture when online...

-

Invalid code.

-
- -
- - - Note - -
-
-
-
+

{{ (scan.status === 'captured') ? scan.first_name : scan.access_code }}

+

+ {{ scan.sponsor_name }} +

+

+ {{ scan.scanned_at | dateAgo }} + Pending + Failed +

+
- - - - - - -

- - - {{(last_scan.status === 'captured')? last_scan.first_name : last_scan.access_code}} - -

-
- -
- - - Note - -
-
-
-
-
-
-
- - - - Start Scanner! - - - Stop Scanner - - - + +
+
+ + + {{ (last_scan.status === 'captured') ? last_scan.first_name : last_scan.access_code }} + + +
+ + + +
+ + +
+ + + Simulate Scan + + + + Start Scanner + + + + Stop Scanner + +
diff --git a/src/app/pages/map/map.scss b/src/app/pages/map/map.scss index 95a9e486..f2bacdeb 100644 --- a/src/app/pages/map/map.scss +++ b/src/app/pages/map/map.scss @@ -1,20 +1,264 @@ -.footer-buttons { +ion-header { + background: linear-gradient(180deg, #3B3EA9 0%, #3B3EA9 100%); + &::after { display: none; } +} + +ion-toolbar { + --background: transparent; + --border-color: transparent; + --color: #ffffff; +} + +ion-toolbar ion-menu-button { + --color: #ffffff; +} + +/* Sponsor banner */ +.sponsor-banner { + display: flex; + align-items: center; + background: #3B3EA9; + color: #fff; + + &.sponsor-banner-warning { + background: linear-gradient(135deg, #e6a817, #c48800); + } +} + +.sponsor-banner-main { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 8px 12px 16px; + flex: 1; + min-width: 0; + cursor: pointer; +} + +.sponsor-banner-filter { display: flex; align-items: center; justify-content: center; + padding: 12px 14px; + border-left: 1px solid rgba(255,255,255,0.15); + cursor: pointer; + + ion-icon { + font-size: 20px; + opacity: 0.5; + transition: opacity 0.2s; + + &.active { + opacity: 1; + color: #FFD779; + } + } +} + +.sponsor-banner-logo { + width: 36px; + height: 36px; + object-fit: contain; + background: #fff; + border-radius: 8px; + padding: 4px; + flex-shrink: 0; +} + +.sponsor-banner-icon { + font-size: 28px; + flex-shrink: 0; + opacity: 0.8; + + &.warning { + color: #fff; + opacity: 1; + } +} + +.sponsor-banner-text { + flex: 1; + min-width: 0; + + strong { + display: block; + font-size: 0.95rem; + font-weight: 600; + } + + span { + font-size: 0.75rem; + opacity: 0.7; + } +} + +.sponsor-banner-chevron { + font-size: 20px; + opacity: 0.5; +} + +/* Permission card */ +.permission-card { + margin: 16px; + border-radius: 12px; + + h3 { + margin: 0 0 8px; + font-weight: 700; + } + + code { + background: rgba(255,255,255,0.2); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; + } +} + + +/* Empty state */ +.empty-state { + display: flex; flex-direction: column; - height: 100%; + align-items: center; + justify-content: center; + padding: 64px 32px; + text-align: center; + + .empty-icon { + font-size: 56px; + color: var(--ion-color-step-300, #ccc); + margin-bottom: 16px; + } + + h3 { + margin: 0 0 6px; + font-size: 1.1rem; + font-weight: 600; + color: var(--ion-text-color); + } + + p { + margin: 0; + font-size: 0.85rem; + color: var(--ion-color-medium); + } +} + +/* Scan list */ +.scan-list { + padding-top: 0; +} + +.scan-item { + --padding-start: 12px; + --inner-padding-end: 4px; +} + +.scan-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + margin-inline-end: 12px; + background: var(--ion-color-medium); + + &.captured { background: var(--ion-color-success); } + &.pending { background: var(--ion-color-warning); } + &.failed { background: var(--ion-color-danger); } +} + +.scan-name { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.scan-sponsor { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.7rem; + color: #5833E9; + font-weight: 500; + margin: 2px 0 0; + + ion-icon { font-size: 0.7rem; } +} + +.scan-time { + font-size: 0.75rem; + color: var(--ion-color-medium); + margin: 2px 0 0; + display: flex; + align-items: center; + gap: 6px; +} + +.scan-badge { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + padding: 1px 6px; + border-radius: 4px; + + &.pending { + background: rgba(var(--ion-color-warning-rgb), 0.15); + color: var(--ion-color-warning-shade); + } + + &.failed { + background: rgba(var(--ion-color-danger-rgb), 0.15); + color: var(--ion-color-danger); + } +} + +/* Last scan bar */ +.last-scan-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--ion-color-step-50, #f8f8f8); + border-top: 1px solid var(--ion-color-step-100, #eee); +} + +.last-scan-info { + display: flex; + align-items: center; + gap: 8px; + + ion-icon { font-size: 22px; } +} + +.last-scan-name { + font-size: 0.95rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; } -.footer-button { - position: absolute; +.last-scan-spinner { + width: 16px; + height: 16px; } -.footer-toolbar { - padding-top: 1em; - padding-bottom: 1em; + +/* Action buttons */ +.scanner-actions { + display: flex; + flex-direction: column; + gap: 0; + padding: 4px 16px 8px; + + .scan-btn-main { + --border-radius: 14px; + font-weight: 700; + font-size: 1rem; + letter-spacing: 0.02em; + } } -.header-info { - font-size: .6em; - padding-bottom: .5em; +.scan-note-icon { + font-size: 18px; } diff --git a/src/app/pages/map/map.ts b/src/app/pages/map/map.ts index ff12f861..3f597307 100644 --- a/src/app/pages/map/map.ts +++ b/src/app/pages/map/map.ts @@ -9,6 +9,7 @@ import { PyConAPI } from '../../providers/pycon-api'; import { UserData } from '../../providers/user-data'; import { LiveUpdateService } from '../../providers/live-update.service'; import { LeadNoteModalComponent } from '../../lead-note-modal/lead-note-modal.component'; +import { environment } from '../../../environments/environment'; @Component({ @@ -32,6 +33,8 @@ export class MapPage implements OnInit, OnDestroy { isStaffScanner: boolean = false; selectedSponsor: any = null; sponsorList: any[] = []; + isDev: boolean = !environment.production; + filterBySponsor: boolean = false; constructor( public confData: ConferenceData, @@ -47,7 +50,15 @@ export class MapPage implements OnInit, OnDestroy { ) {} sortScans() { - return this.scan_presentation.sort(function(a, b) { + let scans = [...this.scan_presentation]; + if (this.isStaffScanner && this.filterBySponsor && this.selectedSponsor) { + const filterName = this.selectedSponsor.name; + scans = scans.filter(s => { + const name = (s.sponsor_name || '').replace(/^"|"$/g, ''); + return name === filterName; + }); + } + return scans.sort(function(a, b) { var x = new Date(a.scanned_at); var y = new Date(b.scanned_at); return ((x > y) ? -1 : ((x < y) ? 1 : 0)); @@ -56,13 +67,19 @@ export class MapPage implements OnInit, OnDestroy { refresh_presentation = async () => { var allScans = []; - this.storage.forEach((value, key, index) => { + const selectedId = this.selectedSponsor ? String(this.selectedSponsor.id) : null; + const keys = await this.storage.keys(); + for (const key of keys) { + const value = await this.storage.get(key); + if (!value) continue; if (key.startsWith("pending-scan-")) { allScans.push({ "status": "pending", "scanned_at": value.scannedAt, "access_code": value.scanData.split(":")[0], "note": value.note, + "sponsor_name": value.sponsorName ? String(value.sponsorName).replace(/^"|"$/g, '') : null, + "sponsor_id": value.sponsorId || null, }) } else if (key.startsWith("synced-scan-")) { allScans.push({ @@ -71,6 +88,8 @@ export class MapPage implements OnInit, OnDestroy { "access_code": value.scanData.split(":")[0], "first_name": value.data.first_name, "note": value.note, + "sponsor_name": value.sponsorName ? String(value.sponsorName).replace(/^"|"$/g, '') : null, + "sponsor_id": value.sponsorId || null, }) } else if (key.startsWith("failed-scan-")) { allScans.push({ @@ -78,10 +97,17 @@ export class MapPage implements OnInit, OnDestroy { "scanned_at": value.scannedAt, "access_code": value.scanData.split(":")[0], "note": value.note, + "sponsor_name": value.sponsorName ? String(value.sponsorName).replace(/^"|"$/g, '') : null, + "sponsor_id": value.sponsorId || null, }) } - }); + } this.scan_presentation = allScans; + this.detectorRef.detectChanges(); + } + + toggleSponsorFilter() { + this.detectorRef.detectChanges(); } openNoteModal = async (accessCode, scan) => { @@ -233,6 +259,29 @@ export class MapPage implements OnInit, OnDestroy { this.content_visibility = ''; } + async simulateScan() { + const fakeNames = [ + 'Guido van Rossum', 'Carol Willing', 'Brett Cannon', 'Mariatta Wijaya', + 'Pablo Galindo', 'Dustin Ingram', 'Sumana Harihareswara', 'Ned Batchelder', + 'Lynn Root', 'Russell Keith-Magee', 'Hynek Schlawack', 'Łukasz Langa', + 'Deb Nicholson', 'Thomas Wouters', 'Barry Warsaw', 'Savannah Ostrowski', + ]; + const name = fakeNames[Math.floor(Math.random() * fakeNames.length)]; + const fakeCode = 'SIM' + Math.floor(Math.random() * 99999); + const scanDate = new Date(); + + const sponsorName = this.selectedSponsor?.name ? String(this.selectedSponsor.name).replace(/^"|"$/g, '') : null; + await this.storage.set('synced-scan-' + fakeCode, { + scanData: fakeCode + ':SIMULATED', + scannedAt: scanDate.toISOString(), + sponsorName: sponsorName, + sponsorId: this.selectedSponsor ? String(this.selectedSponsor.id) : null, + data: { first_name: name, captured: true, captured_date: scanDate.toISOString() }, + }); + this.refresh_presentation(); + this.detectorRef.detectChanges(); + } + ionViewWillLeave() { this.stopScan(); } @@ -251,7 +300,15 @@ export class MapPage implements OnInit, OnDestroy { const hasDoorCheck = await this.userData.checkHasDoorCheck(); if (!hasLeadRetrieval && hasDoorCheck) { this.isStaffScanner = true; - this.showSponsorSelector(); + // Restore previously selected sponsor from storage + const savedSponsorId = await this.storage.get('staff-sponsor-id'); + const savedSponsorName = await this.storage.get('staff-sponsor-name'); + const savedSponsorLogo = await this.storage.get('staff-sponsor-logo'); + if (savedSponsorId && savedSponsorName) { + this.selectedSponsor = { id: savedSponsorId, name: String(savedSponsorName).replace(/^"|"$/g, ''), logo_url: savedSponsorLogo || null }; + } else { + this.showSponsorSelector(); + } } } @@ -328,6 +385,9 @@ export class MapPage implements OnInit, OnDestroy { if (sponsorId) { this.selectedSponsor = this.sponsorList.find(s => String(s.id) === sponsorId); this.pycon.setStaffSponsorId(sponsorId); + this.storage.set('staff-sponsor-name', this.selectedSponsor?.name || null); + this.storage.set('staff-sponsor-logo', this.selectedSponsor?.logo_url || null); + this.refresh_presentation(); } } } diff --git a/src/app/providers/pycon-api.ts b/src/app/providers/pycon-api.ts index c05e56bc..d6579027 100644 --- a/src/app/providers/pycon-api.ts +++ b/src/app/providers/pycon-api.ts @@ -386,9 +386,11 @@ export class PyConAPI { return; } else { const scanDate = new Date(); + const sponsorId = await this.storage.get('staff-sponsor-id'); + const sponsorName = await this.storage.get('staff-sponsor-name'); return this.storage.set( 'pending-scan-' + accessCode, - {scanData: scanData, scannedAt: scanDate.toISOString()} + {scanData: scanData, scannedAt: scanDate.toISOString(), sponsorId: sponsorId, sponsorName: sponsorName} ).then(() => { console.log('Scanned ' + accessCode); this.syncScan(accessCode); From 9bd0c9524b28efae20847fe1e61a29cbf5a7d1e6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sat, 11 Apr 2026 16:14:39 -0500 Subject: [PATCH 3/3] Redesign schedule error state with Python logo and retry button Replaces plain text "Schedule not loaded" with a full-screen error state featuring a Python logo watermark, cloud-offline icon, friendly copy, and a "Try Again" button. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/pages/schedule/schedule.html | 28 ++++++++++++-- src/app/pages/schedule/schedule.scss | 55 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/app/pages/schedule/schedule.html b/src/app/pages/schedule/schedule.html index 0b5dd195..d54063ed 100644 --- a/src/app/pages/schedule/schedule.html +++ b/src/app/pages/schedule/schedule.html @@ -94,9 +94,31 @@

- - Schedule not loaded, if this persists check your network. - +
+
+ + + + + + + + + + + + + + + +
+

Couldn't load schedule

+

Check your network connection and try again. The schedule needs an internet connection to load for the first time.

+ + + Try Again + +