Commit fa021fc8 authored by prurite's avatar prurite
Browse files

bus: add changed schedule display

parent ab702922
Loading
Loading
Loading
Loading
+61 −0
Changes for docs/.vuepress/cache.mjs: 61 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Cache API rejects 206 responses; store each PMTiles byte range separately as 200.
export const pmtilesRangePlugin = {
  cacheKeyWillBeUsed: ({ request }) => {
    const url = new URL(request.url)
    url.searchParams.set('__pmtiles_range', request.headers.get('Range'))
    return new Request(url, request)
  },
  cacheWillUpdate: ({ request, response }) => {
    if (request.cache === 'no-store' || response.status !== 206) return null
    return new Response(response.body, { status: 200, headers: response.headers })
  },
  cachedResponseWillBeUsed: ({ request, cachedResponse }) => {
    if (!cachedResponse || ['reload', 'no-store', 'no-cache'].includes(request.cache)) return null
    return new Response(cachedResponse.body, { status: 206, headers: cachedResponse.headers })
  },
}

// Workbox serializes these callbacks into the worker; keep them self-contained.
export const runtimeCaching = [
  {
    urlPattern: ({ url }) => /^(\/api\/public)?\/(routes|stops)(\/|$)/.test(url.pathname),
    handler: 'NetworkOnly', // The API's HTTP cache headers control reference data freshness.
  },
  {
    urlPattern: ({ url }) => url.pathname.startsWith('/api/') || /^\/(vehicles|arrivals|notices|schedules)(\/|$)/.test(url.pathname),
    handler: 'NetworkOnly',
    options: { fetchOptions: { cache: 'no-store' } },
  },
  {
    urlPattern: ({ url, request }) => url.pathname.endsWith('.pmtiles') && request.headers.has('Range'),
    handler: 'CacheFirst',
    options: {
      cacheName: 'pmtiles-ranges-v1',
      plugins: [pmtilesRangePlugin],
      expiration: { maxEntries: 512, maxAgeSeconds: 86400, purgeOnQuotaError: true },
    },
  },
  {
    urlPattern: ({ url, request }) =>
      (url.origin === 'https://bus.sustcra.com' && /^\/(static|geojson)\//.test(url.pathname)) ||
      (url.origin === 'https://mirrors.sustech.edu.cn' && /^\/(osm-tile|site\/pmtiles-data)\//.test(url.pathname)) ||
      (url.origin === 'https://google-proxy.zuzuche.com' && url.pathname.startsWith('/vt/')) ||
      ['image', 'font', 'style', 'script'].includes(request.destination),
    handler: 'CacheFirst',
    options: {
      cacheName: 'static-resources-v1',
      expiration: { maxEntries: 512, maxAgeSeconds: 86400, purgeOnQuotaError: true },
      cacheableResponse: { statuses: [0, 200] },
    },
  },
  {
    urlPattern: ({ url }) => url.origin === self.location.origin,
    handler: 'NetworkFirst',
    options: {
      cacheName: 'web-cache',
      networkTimeoutSeconds: 5,
      expiration: { maxEntries: 50, maxAgeSeconds: 86400, purgeOnQuotaError: true },
      cacheableResponse: { statuses: [200] },
    },
  },
]
+72 −0
Changes for docs/.vuepress/cache.test.mjs: 72 added lines, 0 removed lines.
Original line number Diff line number Diff line
import assert from 'node:assert/strict'
import test from 'node:test'
import { pmtilesRangePlugin, runtimeCaching } from './cache.mjs'

test('live APIs bypass caches before static/site rules; reference data uses HTTP caching', () => {
  globalThis.self = { location: { origin: 'https://sustech.online' } }
  try {
    const ruleFor = (url, destination = '') => runtimeCaching.find(({ urlPattern }) => urlPattern({
      url: new URL(url), request: { destination, headers: new Headers() },
    }))
    for (const path of ['/vehicles/all', '/arrivals/s1', '/notices', '/schedules', '/api/public/vehicles', '/api/routes', '/api/v2/monitor_osm/', '/api/v3/r/d/s']) {
      for (const origin of ['https://sustech.online', 'https://buseta.sustcra.com', 'https://bus.sustcra.com']) {
        const rule = ruleFor(origin + path, 'image')
        assert.equal(rule.handler, 'NetworkOnly')
        assert.equal(rule.options.fetchOptions.cache, 'no-store')
      }
    }
    for (const path of ['/routes', '/stops/id', '/api/public/routes/id']) {
      const rule = ruleFor('https://buseta.sustcra.com' + path)
      assert.equal(rule.handler, 'NetworkOnly')
      assert.equal(rule.options, undefined)
    }
    for (const url of [
      'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-light.json',
      'https://bus.sustcra.com/static/protomaps/fonts/a.pbf',
      'https://bus.sustcra.com/geojson/sustech_bldg.json',
      'https://mirrors.sustech.edu.cn/osm-tile/tiles/14/1/2.pbf',
      'https://mirrors.sustech.edu.cn/site/pmtiles-data/fonts/Noto%20Sans/0-255.pbf',
      'https://google-proxy.zuzuche.com/vt/?lyrs=s&x=1&y=2&z=14',
    ]) {
      const rule = ruleFor(url)
      assert.equal(rule.handler, 'CacheFirst')
      assert.ok(rule.options.expiration.maxEntries > 0)
      assert.ok(rule.options.expiration.maxAgeSeconds > 0)
    }
    assert.equal(ruleFor('https://sustech.online/transport/bustimer.html').handler, 'NetworkFirst')
  } finally {
    delete globalThis.self
  }
})

test('PMTiles caches distinct ranges as 200 and returns unchanged bytes and ETag as 206', async () => {
  const request = new Request('https://bus.sustcra.com/static/map.pmtiles?v=2', { headers: { Range: 'bytes=10-13' } })
  assert.equal(runtimeCaching.find(({ urlPattern }) => urlPattern({ url: new URL(request.url), request })).options.cacheName, 'pmtiles-ranges-v1')
  const key = pmtilesRangePlugin.cacheKeyWillBeUsed({ request })
  const otherKey = pmtilesRangePlugin.cacheKeyWillBeUsed({ request: new Request(request, { headers: { Range: 'bytes=14-17' } }) })
  assert.notEqual(key.url, otherKey.url)
  assert.equal(new URL(key.url).searchParams.get('v'), '2')
  const headers = { ETag: '"archive-v2"', 'Content-Range': 'bytes 10-13/100', 'Content-Length': '4' }
  const stored = pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response(new Uint8Array([0, 1, 254, 255]), { status: 206, headers }) })
  assert.equal(stored.status, 200)
  const replayed = pmtilesRangePlugin.cachedResponseWillBeUsed({ request: key, cachedResponse: stored })
  assert.equal(replayed.status, 206)
  assert.equal(replayed.headers.get('ETag'), headers.ETag)
  assert.equal(replayed.headers.get('Content-Range'), headers['Content-Range'])
  assert.deepEqual([...new Uint8Array(await replayed.arrayBuffer())], [0, 1, 254, 255])
})

test('PMTiles reload bypasses stale ranges and no-store/error responses are not persisted', () => {
  for (const cache of ['reload', 'no-cache', 'no-store']) {
    const request = new Request('https://bus.sustcra.com/map.pmtiles', { cache, headers: { Range: 'bytes=0-3' } })
    const key = pmtilesRangePlugin.cacheKeyWillBeUsed({ request })
    assert.equal(key.cache, cache)
    assert.equal(pmtilesRangePlugin.cachedResponseWillBeUsed({ request: key, cachedResponse: new Response('old') }), null)
    const stored = pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response('new', { status: 206 }) })
    assert.equal(stored?.status ?? null, cache === 'no-store' ? null : 200)
  }
  const request = new Request('https://bus.sustcra.com/map.pmtiles')
  for (const status of [200, 404, 416, 500]) {
    assert.equal(pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response('error', { status }) }), null)
  }
})
+6 −1
Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -11,7 +11,7 @@
        <strong>{{ group.routeName }}</strong><span>{{ group.directionName }}</span><small>{{ serviceLabel(group) }}</small>
      </div>
      <div class="times">
        <span v-for="(item, index) in displayedTimes(group)" :key="index" :class="['time', item.status, item.vehicleType?.toLowerCase()]">{{ item.time }}</span>
        <span v-for="(item, index) in displayedTimes(group)" :key="index" :class="['time', item.status, item.vehicleType?.toLowerCase(), { changed: item.change === 'CANCELLED' || item.change === 'EXTRA' }]" :title="item.note || undefined"><del v-if="item.change === 'CANCELLED'">{{ item.time }}</del><template v-else>{{ item.time }}</template><small v-if="changeLabel(item)"> {{ changeLabel(item) }}</small></span>
        <span v-if="!displayedTimes(group).length" class="muted">{{ text('无运行中或待发车班次', 'No running or upcoming trips') }}</span>
        <button v-if="isCollapsed(group.key) && visibleTimes(group.times).length > 1" class="ellipsis" type="button" :aria-label="text('展开线路时刻表', 'Expand route schedule')" @click="toggleRoute(group.key)">······</button>
      </div>
@@ -28,6 +28,7 @@ const collapsedRoutes = ref(new Set())
const portrait = ref(false)
let orientationQuery
const text = (zh, en) => props.language === 'zh' ? zh : en
const changeLabel = (item) => ({ EXTRA: text('加班', 'Extra'), CANCELLED: text('停运', 'Cancelled') })[item.change] || ''
const serviceLabel = (group) => [group.serviceType && group.serviceType !== 'NORMAL' ? group.serviceType : '', group.vehicleTypes?.join(' / ')].filter(Boolean).join(' · ') || text('常规服务', 'Normal service')
const visibleTimes = (times) => activeOnly.value ? times.filter((item) => item.status !== 'past') : times
const isCollapsed = (groupKey) => portrait.value && collapsedRoutes.value.has(groupKey)
@@ -47,4 +48,8 @@ onBeforeUnmount(() => { orientationQuery?.removeEventListener?.('change', update
.route-info.collapsible { cursor: pointer; }
.ellipsis { border: 0; padding: .13rem .38rem; background: transparent; color: var(--bus-v2-muted); font: inherit; letter-spacing: .15em; cursor: pointer; }
.route-info small, .muted { color: var(--bus-v2-muted); }
.time.changed { border: 1px dashed currentColor; }
.time.cancelled { color: var(--bus-v2-muted); }
.time small { font-family: inherit; font-size: .8em; }
.time del { opacity: .7; }
</style>
+17 −11
Changes for docs/.vuepress/components/bus-v2/BusSchedulesV2.vue: 17 added lines, 11 removed lines.
Original line number Diff line number Diff line
<template>
  <section class="bus-schedules" :lang="language === 'zh' ? 'zh-CN' : 'en'">
    <header class="section-title"><div class="schedule-title"><h3>{{ text('全部时刻表', 'All schedules') }}</h3><p class="schedule-note">{{ text('表中时间均为首站发车时间。', 'Times are departures from the terminus.') }}</p></div></header>
    <div class="day-select" role="group" :aria-label="text('计划类型', 'Schedule type')"><button :class="{ active: dayType === 'WORKDAY' }" type="button" @click="load('WORKDAY')">{{ text('工作日', 'Workday') }}</button><button :class="{ active: dayType === 'HOLIDAY' }" type="button" @click="load('HOLIDAY')">{{ text('节假日', 'Holiday') }}</button></div>
    <label class="day-select">{{ text('运营日期', 'Service date (Shanghai)') }}<input v-model="serviceDate" type="date" required @change="load" /></label>
    <section v-if="loading && !groups.length" class="state">{{ busText('loading') }}</section>
    <section v-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load(retryDayType)">{{ busText('retry') }}</button></section>
    <section v-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button></section>
    <section v-if="!loading && !error && !groups.length" class="state">{{ busText('empty') }}</section>
    <BusScheduleRowsV2 v-if="groups.length" :groups="groups" :language="language" />
    <p class="note">{{ text('“运行中”按线路预计运行时间加 20% 估算。', 'Running status uses the estimated route duration plus 20%.') }}</p>
@@ -19,23 +19,26 @@ import BusScheduleRowsV2 from './BusScheduleRowsV2.vue'

let routesCache = null, routesRequest, scheduleRequest = 0
const loadRoutes = () => routesCache ? Promise.resolve(routesCache) : (routesRequest ||= busApi.routes().then((data) => routesCache = Array.isArray(data) ? data : []).finally(() => { routesRequest = undefined }))
const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const retryDayType = ref(''); const now = ref(new Date())
const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const serviceDate = ref(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())); const now = ref(new Date())
const text = (zh, en) => language.value === 'zh' ? zh : en
const nowMinutes = computed(() => now.value.getHours() * 60 + now.value.getMinutes())
const nowMinutes = computed(() => {
  const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(now.value)
  const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now.value).split(':')
  return (Date.parse(today) - Date.parse(serviceDate.value)) / 60000 + Number(parts[0]) * 60 + Number(parts[1])
})
const groups = computed(() => groupSchedules(schedules.value, nowMinutes.value, routes.value).map((item) => ({ ...item, color: item.route_color, routeName: item[language.value === 'zh' ? 'route_name_zh' : 'route_name_en'] || item.route_name_zh || item.route_name_en || item.route_id, directionName: item[language.value === 'zh' ? 'direction_name_zh' : 'direction_name_en'] || item.direction_name_zh || item.direction_name_en || item.route_direction_id })))
async function load(requestedDayType) {
async function load() {
  now.value = new Date()
  if (!serviceDate.value) { error.value = text('请选择运营日期。', 'Select a service date.'); return }
  const request = ++scheduleRequest
  retryDayType.value = requestedDayType || ''
  if (requestedDayType) dayType.value = requestedDayType
  loading.value = true
  error.value = ''
  schedules.value = []
  try {
    const [data, routeData] = await Promise.all([busApi.schedules(requestedDayType ? `day_type=${requestedDayType}` : ''), loadRoutes()])
    const [data, routeData] = await Promise.all([busApi.schedules(`date=${encodeURIComponent(serviceDate.value)}`), loadRoutes()])
    if (request !== scheduleRequest) return
    schedules.value = Array.isArray(data) ? data : []
    routes.value = routeData
    dayType.value = schedules.value[0]?.day_type || requestedDayType || (new Date().getDay() % 6 ? 'WORKDAY' : 'HOLIDAY')
  } catch (reason) {
    if (request === scheduleRequest) error.value = reason.message || String(reason)
  } finally {
@@ -46,10 +49,13 @@ onMounted(load); defineExpose({ load })
</script>

<style scoped lang="scss">
.bus-schedules { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.section-title { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }.schedule-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }.section-title h3 { margin: 0; padding-top: 0; font-size: 1rem; line-height: 1.3; }.bus-schedules p { margin-top: 0; }.schedule-note, .muted, .note { color: #667085; }.schedule-note { margin-top: 0 !important; margin-bottom: 0; font-size: .875rem; font-weight: 400; white-space: nowrap; }.bus-schedules button { padding: .42rem .75rem; border: 1px solid #a9c2dc; background: transparent; color: inherit; font: inherit; cursor: pointer; }.day-select { display: flex; margin: 1rem 0; }.day-select button { flex: 1; }.day-select button:first-child { border-radius: .4rem 0 0 .4rem; }.day-select button:last-child { border-radius: 0 .4rem .4rem 0; }.day-select .active { border-color: #1765ac; background: #1765ac; color: #fff; }.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; font-size: .86rem; }.toolbar time { font-family: ui-monospace, monospace; }.state { display: flex; align-items: center; gap: .5rem; padding: 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; }.error { color: #b42318; }.note { margin-top: 1rem; font-size: .85rem; } @media (max-width: 600px) { .section-title { align-items: flex-start; flex-direction: column; } .toolbar { justify-content: flex-start; } }
.bus-schedules { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.section-title { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }.schedule-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }.section-title h3 { margin: 0; padding-top: 0; font-size: 1rem; line-height: 1.3; }.bus-schedules p { margin-top: 0; }.schedule-note, .muted, .note { color: #667085; }.schedule-note { margin-top: 0 !important; margin-bottom: 0; font-size: .875rem; font-weight: 400; white-space: nowrap; }.bus-schedules button { padding: .42rem .75rem; border: 1px solid #a9c2dc; background: transparent; color: inherit; font: inherit; cursor: pointer; }.day-select { display: flex; margin: 1rem 0; }.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; font-size: .86rem; }.toolbar time { font-family: ui-monospace, monospace; }.state { display: flex; align-items: center; gap: .5rem; padding: 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; }.error { color: #b42318; }.note { margin-top: 1rem; font-size: .85rem; } @media (max-width: 600px) { .section-title { align-items: flex-start; flex-direction: column; } .toolbar { justify-content: flex-start; } }
.bus-schedules { color: var(--bus-v2-text); }
.bus-schedules .schedule-note, .bus-schedules .muted, .bus-schedules .note { color: var(--bus-v2-muted); }
.bus-schedules button, .bus-schedules .state { border-color: var(--bus-v2-border); }
.bus-schedules .day-select .active { border-color: var(--bus-v2-link); background: var(--bus-v2-link); color: var(--vp-c-accent-text); }

.bus-schedules .state { background: var(--bus-v2-bg); }

.day-select { align-items: center; gap: .75rem; flex-wrap: wrap; }
.day-select input { min-height: 44px; max-width: 100%; padding: .4rem .6rem; border: 1px solid var(--bus-v2-border); border-radius: .4rem; background: var(--bus-v2-bg); color: inherit; font: inherit; }
</style>
+4 −4
Changes for docs/.vuepress/components/bus-v2/api.mjs: 4 added lines, 4 removed lines.
Original line number Diff line number Diff line
import { resolveBusApiBase } from './core.mjs'

export const busApiBase = () => resolveBusApiBase(import.meta.env.PROD)
export const busApiBase = () => resolveBusApiBase(import.meta.env?.PROD)

export async function publicApi(path, options = {}) {
  const base = busApiBase()
  const separator = path.includes('?') ? '&' : '?'
  const url = `${base}${path.startsWith('/') ? path : `/${path}`}${separator}_=${Date.now()}`
  const url = `${base}${path.startsWith('/') ? path : `/${path}`}`
  const reference = /^\/(routes|stops)(\/|$)/.test(new URL(url).pathname)
  const response = await fetch(url, {
    ...options,
    cache: 'no-store',
    cache: reference ? 'default' : 'no-store',
    credentials: 'omit',
    headers: { Accept: 'application/json', ...options.headers },
  })
Loading