Skip to content
Open
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
2 changes: 2 additions & 0 deletions septa-fare-calculator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.node_modules
.vscode
10 changes: 10 additions & 0 deletions septa-fare-calculator/.stylelintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": [
"stylelint-config-standard-scss"
],
"rules": {
"selector-max-id": 0,
"max-nesting-depth": 3,
"string-quotes": "double"
}
}
19 changes: 19 additions & 0 deletions septa-fare-calculator/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('keyboard flow and total updates', async ({ page }) => {
await page.goto('/');
await page.getByRole('combobox', { name: 'Where are you going?' }).selectOption({ index: 0 });
await page.getByRole('combobox', { name: 'When are you riding?' }).selectOption('weekday');
await page.getByLabel('Station Kiosk').check();
await page.getByLabel('How many rides will you need?').fill('3');
const total = await page.getByRole('status');
await expect(total).toContainText('$');
});

test('axe scan passes (no serious violations)', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
const serious = accessibilityScanResults.violations.filter(v => v.impact === 'serious' || v.impact === 'critical');
expect(serious).toEqual([]);
});
Binary file removed septa-fare-calculator/img/widget.png
Binary file not shown.
Binary file removed septa-fare-calculator/img/zone-map.jpg
Binary file not shown.
71 changes: 62 additions & 9 deletions septa-fare-calculator/index.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,63 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SEPTA Regional Rail Fare Calculator</title>
</head>
<body>

</body>
</html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Regional Rail Fares</title>
<link rel="stylesheet" href="/src/styles/main.scss" />
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header class="app-header">
<h1>Regional Rail Fares</h1>
</header>

<main id="main" tabindex="-1">
<form id="fare-form" class="card" novalidate>

<div class="form-group">
<label for="zone">Where are you going?</label>
<select id="zone" name="zone">
<!-- options injected -->
</select>
</div>

<div class="form-group">
<label for="rideType">When are you riding?</label>
<select id="rideType" name="rideType">
<option value="weekday">Weekdays</option>
<option value="evening_weekend">Evenings & Weekends</option>
<option value="anytime">Anytime (10‑trip pack)</option>
</select>
<p id="type-help" class="help" aria-live="polite"></p>
</div>

<fieldset class="form-group">
<legend>Where will you purchase the fare?</legend>
<div class="choice">
<input type="radio" id="purchase-advance" name="purchase" value="advance_purchase" checked />
<label for="purchase-advance">Station Kiosk</label>
</div>
<div class="choice">
<input type="radio" id="purchase-onboard" name="purchase" value="onboard_purchase" />
<label for="purchase-onboard">Onboard</label>
</div>
<p id="purchase-help" class="help" aria-live="polite"></p>
</fieldset>

<div class="form-group">
<label for="rides">How many rides will you need?</label>
<input type="number" id="rides" name="rides" inputmode="numeric" min="1" step="1" value="1" />
</div>

<div class="total">
<p class="total-label">Your fare will cost</p>
<output id="total" role="status" aria-live="polite">$0.00</output>
<p id="detail" class="detail" aria-live="polite"></p>
</div>

</form>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
28 changes: 28 additions & 0 deletions septa-fare-calculator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "regional-rail-fares-widget",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:ui": "vitest",
"test:e2e": "playwright test"
},
"devDependencies": {
"typescript": "^5.4.0",
"vite": "^5.0.0",
"sass": "^1.77.0",
"vitest": "^1.6.0",
"@types/node": "^20.11.0",
"@playwright/test": "^1.47.0",
"@axe-core/playwright": "^4.8.3",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38"
},
"resolutions": {
"@playwright/test": "^1.47.0"
}
}
20 changes: 20 additions & 0 deletions septa-fare-calculator/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } }
]
});
5 changes: 5 additions & 0 deletions septa-fare-calculator/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {}
}
};
39 changes: 39 additions & 0 deletions septa-fare-calculator/src/compute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { computeTotal } from './compute';
import type { Zone } from './types';

const zone: Zone = {
name: 'Zone 4',
zone: 4,
fares: [
{ type: 'weekday', purchase: 'advance_purchase', trips: 1, price: 7 },
{ type: 'weekday', purchase: 'onboard_purchase', trips: 1, price: 9 },
{ type: 'evening_weekend', purchase: 'advance_purchase', trips: 1, price: 6 },
{ type: 'anytime', purchase: 'advance_purchase', trips: 10, price: 35 }
]
};

describe('computeTotal', () => {
it('calculates single weekday rides (advance)', () => {
const res = computeTotal(zone, 'weekday', 'advance_purchase', 3);
expect(res.total).toBe(21);
expect(res.detail).toContain('3 × single-ride');
});

it('calculates onboard weekday rides', () => {
const res = computeTotal(zone, 'weekday', 'onboard_purchase', 2);
expect(res.total).toBe(18);
});

it('calculates anytime packs', () => {
const res = computeTotal(zone, 'anytime', 'advance_purchase', 12);
expect(res.total).toBe(70); // needs 2 packs
expect(res.detail).toContain('2 × 10-trip');
});

it('handles missing combos', () => {
const res = computeTotal(zone, 'anytime', 'onboard_purchase', 10);
expect(res.total).toBe(0);
expect(res.detail).toContain('No fare');
});
});
31 changes: 31 additions & 0 deletions septa-fare-calculator/src/compute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Zone, FareType, PurchaseType } from './types';

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export interface TotalResult {
total: number;
detail: string;
}

export function computeTotal(z: Zone, type: FareType, purchase: PurchaseType, rides: number): TotalResult {
const faresOfType = z.fares.filter(f => f.type === type && f.purchase === purchase);
if (faresOfType.length === 0) {
return { total: 0, detail: 'No fare available for that combination.' };
}

if (type === 'anytime') {
const pack = faresOfType[0]; // assume single 10-pack
const packsNeeded = Math.ceil(rides / pack.trips);
const total = packsNeeded * pack.price;
const ridesCovered = packsNeeded * pack.trips;
const leftover = ridesCovered - rides;
const note = `${packsNeeded} × 10-trip pack${packsNeeded>1?'s':''} (${ridesCovered} rides, ${leftover} spare)`;
return { total, detail: note };
} else {
const single = faresOfType.find(f => f.trips === 1);
if (!single) return { total: 0, detail: 'Single-ride fare missing in data.' };
const total = rides * single.price;
const note = `${rides} × single-ride @ ${currency.format(single.price)}`;
return { total, detail: note };
}
}
108 changes: 108 additions & 0 deletions septa-fare-calculator/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { FareData, Zone, FareType, PurchaseType } from './types';
import { computeTotal } from './compute';
import './styles/main.scss';

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

const zoneEl = document.querySelector<HTMLSelectElement>('#zone')!;
const typeEl = document.querySelector<HTMLSelectElement>('#rideType')!;
const purchaseRadios = Array.from(document.querySelectorAll<HTMLInputElement>('input[name="purchase"]'));
const ridesEl = document.querySelector<HTMLInputElement>('#rides')!;
const totalEl = document.querySelector<HTMLOutputElement>('#total')!;
const detailEl = document.querySelector<HTMLParagraphElement>('#detail')!;
const typeHelpEl = document.querySelector<HTMLParagraphElement>('#type-help')!;
const purchaseHelpEl = document.querySelector<HTMLParagraphElement>('#purchase-help')!;

let data: FareData;
let zones: Zone[] = [];

async function init() {
const res = await fetch('fares.json', { cache: 'no-store' });
data = await res.json();
zones = data.zones;

// populate zones
for (const z of zones) {
const opt = document.createElement('option');
opt.value = String(z.zone);
opt.textContent = z.name;
zoneEl.appendChild(opt);
}

updateTypeHelp();

zoneEl.addEventListener('change', update);
typeEl.addEventListener('change', () => {
updateTypeHelp();
updatePurchaseAvailability();
update();
});
for (const r of purchaseRadios) r.addEventListener('change', update);
ridesEl.addEventListener('input', update);

updatePurchaseAvailability();
update();
}

function selectedZone(): Zone | undefined {
const val = Number(zoneEl.value);
return zones.find(z => z.zone === val);
}

function selectedPurchase(): PurchaseType {
const checked = purchaseRadios.find(r => r.checked);
return (checked?.value as PurchaseType) ?? 'advance_purchase';
}

function updateTypeHelp() {
const t = typeEl.value as FareType;
const map: Record<FareType, string> = {
weekday: data.info.weekday,
evening_weekend: data.info.evening_weekend,
anytime: data.info.anytime
};
typeHelpEl.textContent = map[t] ?? '';
}

function updatePurchaseAvailability() {
const z = selectedZone();
const t = typeEl.value as FareType;
if (!z) return;

const available = new Set(z.fares.filter(f => f.type === t).map(f => f.purchase));
for (const r of purchaseRadios) {
const allowed = available.has(r.value as PurchaseType);
r.disabled = !allowed;
r.setAttribute('aria-disabled', String(!allowed));
if (!allowed && r.checked) {
const first = purchaseRadios.find(x => !x.disabled);
if (first) first.checked = true;
}
}

if (!available.has('onboard_purchase') && t === 'anytime') {
purchaseHelpEl.textContent = 'Anytime 10-trip packs are only available at stations (not onboard).';
} else {
purchaseHelpEl.textContent = '';
}
}

function update() {
const z = selectedZone();
const t = typeEl.value as FareType;
const p = selectedPurchase();
const rides = Math.max(1, Math.floor(Number(ridesEl.value) || 1));

if (!z) return;

const { total, detail } = computeTotal(z, t, p, rides);

totalEl.value = currency.format(total);
detailEl.textContent = detail;
}

init().catch(err => {
totalEl.value = '$0.00';
detailEl.textContent = 'Failed to load fares.json';
console.error(err);
});
10 changes: 10 additions & 0 deletions septa-fare-calculator/src/styles/_components.card.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@use "settings.tokens" as *;

// Components — card container
.card {
background: $color-card;
border: 1px solid $color-border;
border-radius: $radius-md;
padding: $space-4 $space-4 $space-2;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
30 changes: 30 additions & 0 deletions septa-fare-calculator/src/styles/_components.form.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@use "settings.tokens" as *;

// Components — form controls
.form-group { margin-bottom: $space-4; }

label, legend {
display:block; font-weight: 600; margin-bottom: $space-2;
}

select,
input[type="number"] {
width: 100%;
font-size: 1rem;
padding: $space-3 $space-3;
border-radius: 8px;
border: 1px solid $color-border;
background: #fff;
}

fieldset { border: none; padding: 0; margin: 0 0 $space-4 0; }

.choice {
display:flex; align-items:center; gap: $space-2; margin: $space-1 0;
}

.help {
color: $color-ink-muted;
font-size: .875rem;
margin: $space-2 0 0;
}
Loading