Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e295294
chore: add scaffolding for langauge support
brentonmdunn Sep 14, 2026
a979912
refactor: update message key names
brentonmdunn Sep 14, 2026
c2f7449
feat: occupancy formatter function
brentonmdunn Sep 14, 2026
039107f
test: formatOccupancy
brentonmdunn Sep 14, 2026
2fa235a
feat: edit defaults
brentonmdunn Sep 15, 2026
d561d3d
feat: add admin toggle
brentonmdunn Sep 15, 2026
696a864
refactor: light/medium/full vals to uppercase
brentonmdunn Sep 15, 2026
63582b5
feat: aadd occupancy to formatBoardDeparture
brentonmdunn Sep 15, 2026
cc36b33
feat: show change when occupancy changes
brentonmdunn Sep 15, 2026
b76755a
docs: update JSDocs for uppercase
brentonmdunn Sep 15, 2026
dc25af1
feat: occupancy colors
brentonmdunn Sep 15, 2026
5ab996d
feat: occupancy component
brentonmdunn Sep 15, 2026
0370c72
feat: show occupancy on board
brentonmdunn Sep 15, 2026
4c20159
fix: adjust colors for light occupancy
brentonmdunn Sep 15, 2026
2b1aac4
chore: update languages
brentonmdunn Sep 15, 2026
48245c0
test: occupancy pip
brentonmdunn Sep 15, 2026
95c1c2a
fix: only show occupancy when trip isn't cancelled
brentonmdunn Sep 15, 2026
33f15d1
test: add check to ensure occupancy is shown and hidden when intended
brentonmdunn Sep 15, 2026
bf82c16
test: no occupancy status when null
brentonmdunn Sep 15, 2026
bd98a5f
refactor: showOccupancyStatus into showCrowding for feature name
brentonmdunn Sep 15, 2026
56e840d
feat: occupancy status on multi stop boards
brentonmdunn Sep 15, 2026
9f7641e
chore: update languages
brentonmdunn Sep 15, 2026
bf66cf8
chore: trigger CI
brentonmdunn Sep 16, 2026
5899d23
chore: trigger CI
brentonmdunn Sep 16, 2026
f761532
Merge main into feat/crowding-indicator
brentonmdunn Sep 16, 2026
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
5 changes: 4 additions & 1 deletion messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@
"board_dir_SE": "باتجاه الجنوب الشرقي",
"board_dir_SW": "باتجاه الجنوب الغربي",
"board_stop_code": "#{stopId}",
"board_no_departures_inline": "لا توجد رحلات مغادرة قادمة"
"board_no_departures_inline": "لا توجد رحلات مغادرة قادمة",
"board_occupancy_light": "قليل الازدحام",
"board_occupancy_medium": "مزدحم",
"board_occupancy_full": "ممتلئ"
}
5 changes: 4 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@
"board_dir_SE": "Richtung Südosten",
"board_dir_SW": "Richtung Südwesten",
"board_stop_code": "#{stopId}",
"board_no_departures_inline": "Keine bevorstehenden Abfahrten"
"board_no_departures_inline": "Keine bevorstehenden Abfahrten",
"board_occupancy_light": "Geringe Auslastung",
"board_occupancy_medium": "Mittlere Auslastung",
"board_occupancy_full": "Sehr voll"
}
5 changes: 4 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@
"board_dir_SE": "Southeast",
"board_dir_SW": "Southwest",
"board_stop_code": "#{stopId}",
"board_no_departures_inline": "No upcoming departures"
"board_no_departures_inline": "No upcoming departures",
"board_occupancy_light": "Not Crowded",
"board_occupancy_medium": "Crowded",
"board_occupancy_full": "Full"
}
5 changes: 4 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@
"board_dir_SE": "Hacia el sureste",
"board_dir_SW": "Hacia el suroeste",
"board_stop_code": "#{stopId}",
"board_no_departures_inline": "No hay próximas salidas"
"board_no_departures_inline": "No hay próximas salidas",
"board_occupancy_light": "Casi vacío",
"board_occupancy_medium": "Concurrido",
"board_occupancy_full": "Lleno"
}
5 changes: 4 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@
"board_dir_SE": "Direction sud-est",
"board_dir_SW": "Direction sud-ouest",
"board_stop_code": "#{stopId}",
"board_no_departures_inline": "Aucun départ à venir"
"board_no_departures_inline": "Aucun départ à venir",
"board_occupancy_light": "Presque vide",
"board_occupancy_medium": "Chargé",
"board_occupancy_full": "Complet"
}
10 changes: 10 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ body::before {
--status-tone: var(--sched);
}

.theme-departure .occupancy-LIGHT {
--occupancy-tone: var(--ink-dim);
}
.theme-departure .occupancy-MEDIUM {
--occupancy-tone: var(--early);
}
.theme-departure .occupancy-FULL {
--occupancy-tone: var(--late);
}
Comment on lines +153 to +161

@brentonmdunn brentonmdunn Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using colors defined for a different area feels like a beige flag for me since for example "late" has nothing to do with "full" but I believe that having a shared color palette would be beneficial. There is also slight precedence with cancel not getting its own dedicated color:

--cancel: var(--brand-late, #ff8b6a);

In an ideal world, I would like INFO, WARNING, CRITICAL (or similar wording) colors defined that both status and occupancy inherit from but that feels like too big of a breaking change for agencies that customized the colors. I also don't know if there is a migration process for things like this.

Alternatives considered:

  1. Define own colors for occupancy status
  • Pros: Not connected to status so variable naming isn't confusing
  • Cons: Color palette drift between status and occupancy (although if don't want shared palette, this is a feature)
  1. Create new abstraction level but leave brand-* status colors are legacy code
  • Pros: Gets the higher level of abstraction that both status and occupancy can inherit from
  • Cons: Legacy code has to be maintained

This is all under the assumption that a shared color palette is beneficial. I would be open to implementing 1 or 2, but they were bigger changes so I just biased towards the simplest implementation for now.


.theme-departure .tnum {
font-variant-numeric: tabular-nums;
}
Expand Down
5 changes: 3 additions & 2 deletions src/components/board/board.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
showStopName = false,
rowCount = 5,
showFooter = true,
showAlerts = true
showAlerts = true,
showCrowding = false
} = $props();

const visible = $derived(arrivals.slice(0, rowCount));
Expand Down Expand Up @@ -198,7 +199,7 @@
style:min-height="0"
>
{#each visible as arrival (arrival.tripId ?? `${arrival.route}-${arrival.departureAt}`)}
<DepartureRow {arrival} {showStopName} />
<DepartureRow {arrival} {showStopName} {showCrowding} />
{/each}
{#each Array.from({ length: emptyCount }, (_, i) => i) as i (i)}
<div style:border-bottom="1px dashed var(--rule)"></div>
Expand Down
8 changes: 7 additions & 1 deletion src/components/board/departure-row.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import ArrivalHero from '$components/board/arrival-hero.svelte';
import RouteBadge from '$components/board/route-badge.svelte';
import StatusPip from '$components/board/status-pip.svelte';
import OccupancyPip from '$components/board/occupancy-pip.svelte';

let { arrival, showStopName = false } = $props();
let { arrival, showStopName = false, showCrowding = false } = $props();

const isCancel = $derived(arrival.status === 'CANCEL');
const isSched = $derived(arrival.status === 'SCHED');
Expand Down Expand Up @@ -108,5 +109,10 @@
{t.board_sched()} {clock}
{/if}
</div>
{#if showCrowding && arrival.occupancy && !isCancel}
<div style:margin-top="6px">
<OccupancyPip level={arrival.occupancy} />
</div>
{/if}
</div>
</div>
48 changes: 48 additions & 0 deletions src/components/board/departure-row.svelte.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render, cleanup } from '@testing-library/svelte';
import { describe, test, expect, afterEach } from 'vitest';
import DepartureRow from './departure-row.svelte';

function arrival(overrides = {}) {
return {
route: '249',
name: 'Route 249',
dest: 'South Bellevue Station',
min: 16,
delta: 0,
status: 'ONTIME',
departureAt: Date.now() + 16 * 60000,
tripId: 't-1',
occupancy: 'FULL',
...overrides
};
}

describe('DepartureRow occupancy', () => {
afterEach(() => cleanup());

test('shows occupancy when enabled', () => {
const { container } = render(DepartureRow, {
props: { arrival: arrival(), showCrowding: true }
});
expect(container.querySelector('.occupancy-FULL')).not.toBeNull();
});

test('hides occupancy when disabled', () => {
const { container } = render(DepartureRow, { props: { arrival: arrival() } });
expect(container.querySelector('.occupancy-FULL')).toBeNull();
});

test('hides occupancy when there is no occupancy data', () => {
const { container } = render(DepartureRow, {
props: { arrival: arrival({ occupancy: null }), showCrowding: true }
});
expect(container.querySelector('[class*="occupancy-"]')).toBeNull();
});

test('hides occupancy for a canceled trip', () => {
const { container } = render(DepartureRow, {
props: { arrival: arrival({ status: 'CANCEL' }), showCrowding: true }
});
expect(container.querySelector('.occupancy-FULL')).toBeNull();
});
});
4 changes: 3 additions & 1 deletion src/components/board/multi-stop-board.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
isStale = false,
maxDepartures = 6,
showFooter = true,
showAlerts = true
showAlerts = true,
showCrowding = false
} = $props();

// Stops with departures come first; an empty or failed stop collapses to one line at the
Expand Down Expand Up @@ -172,6 +173,7 @@
limit={layout.perCard}
rowHeight={layout.rowHeight}
numeralSize={layout.numeralSize}
{showCrowding}
/>
{/each}
</div>
Expand Down
39 changes: 39 additions & 0 deletions src/components/board/occupancy-pip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script>
import * as t from '$lib/paraglide/messages.js';

const LEVELS = {
LIGHT: { filled: 1 },
MEDIUM: { filled: 2 },
FULL: { filled: 3 }
};

let { level, size = 22 } = $props();

const s = $derived(LEVELS[level]);
const label = $derived.by(() => {
if (!s) return '';
if (level === 'LIGHT') return t.board_occupancy_light();
if (level === 'MEDIUM') return t.board_occupancy_medium();
if (level === 'FULL') return t.board_occupancy_full();
return '';
});
</script>

{#if s}
<span
class="occupancy-{level} sc"
dir="ltr"
style:display="inline-flex"
style:align-items="center"
style:gap="{Math.round(size * 0.45)}px"
style:font-weight="500"
style:font-size="{size}px"
style:color="var(--occupancy-tone)"
style:letter-spacing="0.1em"
>
<span aria-hidden="true" style:font-size="{size * 0.95}px">
{#each [0, 1, 2] as i (i)}{i < s.filled ? '●' : '○'}{/each}
</span>
<span>{label}</span>
</span>
{/if}
46 changes: 46 additions & 0 deletions src/components/board/occupancy-pip.svelte.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, cleanup } from '@testing-library/svelte';
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import OccupancyPip from './occupancy-pip.svelte';

let mockLocale = 'en';

vi.mock('$lib/paraglide/runtime.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getLocale: () => mockLocale
};
});

beforeEach(() => {
mockLocale = 'en';
});

describe('OccupancyPip', () => {
afterEach(() => cleanup());

test.each([
['LIGHT', '●○○', 'Not Crowded'],
['MEDIUM', '●●○', 'Crowded'],
['FULL', '●●●', 'Full']
])('renders %s as %s with its label', (level, glyphs, label) => {
const { container } = render(OccupancyPip, { props: { level } });
const pip = container.querySelector(`.occupancy-${level}`);
expect(pip).not.toBeNull();
expect(pip.querySelector('[aria-hidden="true"]').textContent.trim()).toBe(glyphs);
expect(pip.textContent).toContain(label);
});

test.each([null, undefined, 'UNKNOWN'])('renders nothing for %s', (level) => {
const { container } = render(OccupancyPip, { props: { level } });
expect(container.textContent.trim()).toBe('');
});

test('keeps glyph-then-label order and translates the label in Arabic', () => {
mockLocale = 'ar';
const { container } = render(OccupancyPip, { props: { level: 'FULL' } });
const pip = container.querySelector('.occupancy-FULL');
expect(pip.getAttribute('dir')).toBe('ltr');
expect(pip.textContent).toContain('ممتلئ');
});
});
4 changes: 2 additions & 2 deletions src/components/board/stop-card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { COLLAPSED_CARD_HEIGHT } from '$lib/board-layout.js';
import StopRow from '$components/board/stop-row.svelte';

let { stop, limit = 4, rowHeight = 64, numeralSize = 48 } = $props();
let { stop, limit = 4, rowHeight = 64, numeralSize = 48, showCrowding = false } = $props();

const rows = $derived(stop.failed ? [] : stop.arrivals.slice(0, limit));
// A failed stop can still hold last-good arrivals, but the card has always given the
Expand Down Expand Up @@ -97,7 +97,7 @@

<div style:display="grid" style:min-height="0">
{#each rows as arrival, i (arrival.tripId ?? `${arrival.route}-${arrival.departureAt}`)}
<StopRow {arrival} {rowHeight} {numeralSize} last={i === rows.length - 1} />
<StopRow {arrival} {rowHeight} {numeralSize} {showCrowding} last={i === rows.length - 1} />
{/each}
</div>
</section>
Expand Down
30 changes: 22 additions & 8 deletions src/components/board/stop-row.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import * as t from '$lib/paraglide/messages.js';
import OccupancyPip from '$components/board/occupancy-pip.svelte';

const STATUS = {
ONTIME: { glyph: '●', weight: 500 },
Expand All @@ -9,9 +10,10 @@
SCHED: { glyph: '○', weight: 500 }
};

let { arrival, last = false, rowHeight = 64, numeralSize = 48 } = $props();
let { arrival, last = false, rowHeight = 64, numeralSize = 48, showCrowding = false } = $props();

const isCancel = $derived(arrival.status === 'CANCEL');
const showOccupancy = $derived(showCrowding && !!arrival.occupancy && !isCancel);
const isNow = $derived(!isCancel && arrival.min <= 0);
const s = $derived(STATUS[arrival.status] ?? STATUS.SCHED);

Expand Down Expand Up @@ -102,16 +104,28 @@
>
{arrival.dest || arrival.name}
</div>
{#if phrase}
{#if phrase || showOccupancy}
<div
class="sc tnum"
style:font-size="12px"
style:letter-spacing="0.10em"
style:display="flex"
style:align-items="center"
style:gap="14px"
style:margin-top="2px"
style:color="var(--status-tone)"
style:font-weight={s.weight}
style:white-space="nowrap"
>
{phrase}
{#if phrase}
<span
class="sc tnum"
style:font-size="12px"
style:letter-spacing="0.10em"
style:color="var(--status-tone)"
style:font-weight={s.weight}
>
{phrase}
</span>
{/if}
{#if showOccupancy}
<OccupancyPip level={arrival.occupancy} size={12} />
{/if}
</div>
{/if}
</div>
Expand Down
41 changes: 41 additions & 0 deletions src/components/board/stop-row.svelte.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,44 @@ describe('StopRow sizing', () => {
expect(container.firstElementChild.style.height).toBe('96px');
});
});

describe('StopRow occupancy', () => {
afterEach(() => cleanup());

test('shows occupancy when enabled', () => {
const { container } = render(StopRow, {
props: { arrival: arrival({ occupancy: 'FULL' }), showCrowding: true }
});
expect(container.querySelector('.occupancy-FULL')).not.toBeNull();
});

test('hides occupancy when disabled', () => {
const { container } = render(StopRow, { props: { arrival: arrival({ occupancy: 'FULL' }) } });
expect(container.querySelector('.occupancy-FULL')).toBeNull();
});

test('hides occupancy when there is no occupancy data', () => {
const { container } = render(StopRow, {
props: { arrival: arrival({ occupancy: null }), showCrowding: true }
});
expect(container.querySelector('[class*="occupancy-"]')).toBeNull();
});

test('hides occupancy for a canceled trip', () => {
const { container } = render(StopRow, {
props: { arrival: arrival({ status: 'CANCEL', occupancy: 'FULL' }), showCrowding: true }
});
expect(container.querySelector('.occupancy-FULL')).toBeNull();
});

test('sits on the same line as the late phrase', () => {
const { container } = render(StopRow, {
props: {
arrival: arrival({ status: 'LATE', delta: 1, occupancy: 'MEDIUM' }),
showCrowding: true
}
});
const pip = container.querySelector('.occupancy-MEDIUM');
expect(pip.parentElement.textContent).toContain('1 MIN LATE');
});
});
7 changes: 5 additions & 2 deletions src/lib/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export const DEFAULT_CONFIG = {
updateInterval: 30,
theme: THEMES[0],
colorMode: COLOR_MODES[0],
branding: BRANDING_DEFAULTS
branding: BRANDING_DEFAULTS,
showCrowding: false
};

function positiveInt(value, fallback) {
Expand All @@ -25,6 +26,8 @@ export function normalizeConfig(raw) {
updateInterval: positiveInt(source.updateInterval, DEFAULT_CONFIG.updateInterval),
theme: THEMES.includes(source.theme) ? source.theme : DEFAULT_CONFIG.theme,
colorMode: COLOR_MODES.includes(source.colorMode) ? source.colorMode : DEFAULT_CONFIG.colorMode,
branding: normalizeBranding(source.branding)
branding: normalizeBranding(source.branding),
showCrowding:
typeof source.showCrowding === 'boolean' ? source.showCrowding : DEFAULT_CONFIG.showCrowding
};
}
Loading
Loading