Commit 14672aa3 authored by prurite's avatar prurite
Browse files

BusTimerV2: add user analytics & announcement highlighting

parent 538ca863
Loading
Loading
Loading
Loading
+6 −1
Original line number Diff line number Diff line
@@ -17,9 +17,14 @@ import { BusHomeV2 } from './components/bus-v2/index.mjs'
import BusRouteV2 from './components/bus-v2/BusRouteV2.vue'
import BusStopV2 from './components/bus-v2/BusStopV2.vue'
import BusSchedulesV2 from './components/bus-v2/BusSchedulesV2.vue'
import { busPageForPath, reportBusVisit } from './components/bus-v2/analytics.mjs'

export default defineClientConfig({
  enhance({ app }) {
  enhance({ app, router }) {
    router.afterEach((to) => {
      const page = busPageForPath(to.path)
      if (page) reportBusVisit(page)
    })
    app.component("BusTable", BusTable)
    app.component("BusTable_img", BusTable_img)
    app.component("BusAnnouncement", BusAnnouncement)
+5 −1
Original line number Diff line number Diff line
@@ -30,7 +30,7 @@
      <section class="panel notices">
        <h3>{{ busText('announcements') }}</h3>
        <p v-if="!notices.length" class="muted">{{ busText('empty') }}</p>
        <details v-for="notice in notices" :key="notice.id" :open="noticeScope(notice) === 'global'">
        <details v-for="notice in notices" :key="notice.id" :class="{ 'bus-notice--important': isImportantNotice(notice) }" :open="isNoticeOpen(notice, noticeScope(notice) === 'global')" @toggle="rememberNoticeOpen(notice, $event)">
          <summary>
            <span>{{ noticeTitle(notice) }}</span>
            <small>{{ busText(noticeScope(notice)) }}<template v-if="noticeTime(notice)"> · <time :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></template></small>
@@ -102,6 +102,7 @@ import { displayName, displayStopName, formatDistance, formatLocalDateTime, have
import { favoriteRouteIds, favoriteStopIds, loadFavorites } from './favorites.mjs'
import { busLanguage, busText, setBusLanguage } from './i18n.mjs'
import { renderNoticeMarkdown } from './markdown.mjs'
import { isImportantNotice, loadNoticeOpenStates, noticeIsOpen, saveNoticeOpenState } from './notice-state.mjs'
import BusVehicleLegendV2 from './BusVehicleLegendV2.vue'
import BusVehiclesV2 from './BusVehiclesV2.vue'

@@ -117,6 +118,7 @@ const error = ref('')
const routes = ref([])
const stops = ref([])
const notices = ref([])
const noticeOpenStates = ref(loadNoticeOpenStates())
const vehicles = ref([])
const vehiclesLoading = ref(true)
const vehiclesError = ref(false)
@@ -167,6 +169,8 @@ const openFirstResult = () => { if (searchResults.value[0]) openResult(searchRes
const noticeScope = (notice) => notice.route_id ? 'route' : notice.stop_id ? 'stop' : 'global'
const noticeTitle = (notice) => notice[busLanguage.value === 'en' ? 'title_en' : 'title_zh'] || notice.title_zh || notice.title_en
const noticeTime = (notice) => formatLocalDateTime(notice.starts_at)
const isNoticeOpen = (notice, defaultOpen = false) => noticeIsOpen(noticeOpenStates.value, notice.id, defaultOpen)
const rememberNoticeOpen = (notice, event) => saveNoticeOpenState(noticeOpenStates.value, notice.id, event.target.open)
const arrivalName = (arrival) => `${arrival[busLanguage.value === 'en' ? 'route_name_en' : 'route_name_zh'] || arrival.route_name_zh || arrival.route_name_en || ''} · ${arrival[busLanguage.value === 'en' ? 'direction_name_en' : 'direction_name_zh'] || arrival.direction_name_zh || arrival.direction_name_en || ''}`
const vehicleIcon = (arrival) => String(arrival.vehicle_type).toUpperCase() === 'SHUTTLE' ? '/sev.png' : '/bus.png'
const vehicleLabel = (arrival) => String(arrival.vehicle_type).toUpperCase() === 'SHUTTLE' ? (busLanguage.value === 'zh' ? '电瓶车' : 'EV Shuttle') : (busLanguage.value === 'zh' ? '巴士' : 'Bus')
+5 −1
Original line number Diff line number Diff line
@@ -42,7 +42,7 @@
      <section class="panel notices">
        <h3>{{ busText('announcements') }}</h3>
        <p v-if="!routeNotices.length" class="muted">{{ busText('empty') }}</p>
        <details v-for="notice in routeNotices" :key="notice.id"><summary><span>{{ noticeTitle(notice) }}</span><time v-if="noticeTime(notice)" :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details>
        <details v-for="notice in routeNotices" :key="notice.id" :class="{ 'bus-notice--important': isImportantNotice(notice) }" :open="isNoticeOpen(notice)" @toggle="rememberNoticeOpen(notice, $event)"><summary><span>{{ noticeTitle(notice) }}</span><time v-if="noticeTime(notice)" :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details>
      </section>

      <section v-if="selectedStop" class="panel arrivals" aria-live="polite">
@@ -85,6 +85,7 @@ import { groupSchedules } from './bus-v2-helpers.mjs'
import { isFavorite, loadFavorites, toggleFavorite } from './favorites.mjs'
import { busLanguage, busText, setBusLanguage } from './i18n.mjs'
import { renderNoticeMarkdown } from './markdown.mjs'
import { isImportantNotice, loadNoticeOpenStates, noticeIsOpen, saveNoticeOpenState } from './notice-state.mjs'
import BusMapV2 from './BusMapV2.vue'
import BusScheduleRowsV2 from './BusScheduleRowsV2.vue'
import BusVehicleDetailV2 from './BusVehicleDetailV2.vue'
@@ -93,6 +94,7 @@ import BusVehicleLegendV2 from './BusVehicleLegendV2.vue'
const props = defineProps({ id: { type: String, default: '' }, direction: { type: String, default: '' }, stopHref: { type: String, default: '/transport/bustimer_v2_stop.html?id=' } })
const route = ref(null), loading = ref(true), error = ref(''), directionId = ref(''), selectedStopId = ref('')
const notices = ref([]), vehicles = ref([]), stops = ref([]), schedules = ref([]), showSchedules = ref(false), scheduleLoading = ref(false), scheduleError = ref(false)
const noticeOpenStates = ref(loadNoticeOpenStates())
const arrivalState = ref({ loading: false, error: false, items: [] }), allArrivals = ref(false), expandedVehicleKey = ref(''), now = ref(new Date())
const refreshRemaining = ref(30)
let refreshTimer, arrivalRequest = 0
@@ -114,6 +116,8 @@ const labels = { zh: { route: '线路', home: '返回首页', saved: '已收藏'
const label = (key, values = {}) => (labels[busLanguage.value][key] || key).replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '')
const noticeTitle = (notice) => notice[busLanguage.value === 'en' ? 'title_en' : 'title_zh'] || notice.title_zh || notice.title_en
const noticeTime = (notice) => formatLocalDateTime(notice.starts_at)
const isNoticeOpen = (notice) => noticeIsOpen(noticeOpenStates.value, notice.id)
const rememberNoticeOpen = (notice, event) => saveNoticeOpenState(noticeOpenStates.value, notice.id, event.target.open)
const statusText = (value) => value === 'SUSPENDED' ? label('suspended') : label('normal')
const arrivalKey = (item) => `${item.route_direction_id}-${item.trip_id || item.planned_arrival_at || item.updated_at || item.eta_minutes}`
function time(value) { return value ? new Date(value).toLocaleTimeString(busLanguage.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false }) : '' }
+6 −2
Original line number Diff line number Diff line
@@ -2,13 +2,13 @@
  <main class="bus-stop-detail" :lang="busLanguage === 'zh' ? 'zh-CN' : 'en'">
    <header class="detail-head">
      <div><p class="eyebrow">{{ label('stop') }}</p><h1 v-if="stop">{{ stopName }}</h1><h1 v-else>{{ label('stop') }}</h1><p v-if="stop?.group_id" class="muted">{{ label('platform', { name: displayName(stop, busLanguage) }) }}</p></div>
      <div class="head-actions"><a class="plain-button" href="/transport/bustimer_v2.html" :aria-label="label('home')" :title="label('home')">🏠</a><button type="button" class="plain-button" :aria-label="busLanguage === 'zh' ? '立即刷新' : 'Refresh now'" @click="refresh">🔄{{ refreshRemaining }}s</button><button type="button" class="plain-button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button><button v-if="stop" type="button" class="plain-button" :aria-pressed="favorite" @click="toggleFavorite('stop', stop.id)">{{ favorite ? label('saved') : label('save') }}</button></div>
      <div class="head-actions"><a class="plain-button" href="/transport/bustimer.html" :aria-label="label('home')" :title="label('home')">🏠</a><button type="button" class="plain-button" :aria-label="busLanguage === 'zh' ? '立即刷新' : 'Refresh now'" @click="refresh">🔄{{ refreshRemaining }}s</button><button type="button" class="plain-button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button><button v-if="stop" type="button" class="plain-button" :aria-pressed="favorite" @click="toggleFavorite('stop', stop.id)">{{ favorite ? label('saved') : label('save') }}</button></div>
    </header>
    <section v-if="loading" class="panel status"><span class="spinner" aria-hidden="true" /> {{ busText('loading') }}</section>
    <section v-else-if="error" class="panel status error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button></section>
    <template v-else-if="stop">
      <section v-if="platforms.length > 1" class="panel platforms"><h3>{{ label('platforms') }}</h3><div><button v-for="item in platforms" :key="item.id" type="button" :class="{ active: item.id === stop.id }" @click="openPlatform(item.id)">{{ displayName(item, busLanguage) || item.id }}</button></div></section>
      <section class="panel notices"><h3>{{ busText('announcements') }}</h3><p v-if="!stopNotices.length" class="muted">{{ busText('empty') }}</p><details v-for="notice in stopNotices" :key="notice.id"><summary><span>{{ noticeTitle(notice) }}</span><time v-if="noticeTime(notice)" :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details></section>
      <section class="panel notices"><h3>{{ busText('announcements') }}</h3><p v-if="!stopNotices.length" class="muted">{{ busText('empty') }}</p><details v-for="notice in stopNotices" :key="notice.id" :class="{ 'bus-notice--important': isImportantNotice(notice) }" :open="isNoticeOpen(notice)" @toggle="rememberNoticeOpen(notice, $event)"><summary><span>{{ noticeTitle(notice) }}</span><time v-if="noticeTime(notice)" :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details></section>
      <section class="panel arrivals" aria-live="polite"><div class="section-head"><h3>{{ label('arrivals') }}</h3><button v-if="hasMoreArrivals(arrivalState)" type="button" class="plain-button" :aria-expanded="allArrivals" @click="allArrivals = !allArrivals">{{ allArrivals ? label('collapse') : label('allArrivals') }}</button></div><BusVehicleLegendV2 :language="busLanguage" /><BusStopArrivalsV2 :state="arrivalState" :collapsed="!allArrivals" :route-href="routeHref" /></section>
      <section v-if="otherPlatforms.length" class="platform-services"><article v-for="item in otherPlatforms" :key="item.id" class="panel"><div class="section-head"><h3>{{ label('platform', { name: displayName(item, busLanguage) || item.id }) }}</h3><div class="platform-actions"><button v-if="hasMoreArrivals(platformArrivals[item.id])" type="button" class="plain-button" :aria-expanded="expandedPlatforms[item.id]" @click="togglePlatformArrivals(item.id)">{{ expandedPlatforms[item.id] ? label('collapse') : label('allArrivals') }}</button><button type="button" class="plain-button" @click="openPlatform(item.id)">{{ label('open') }}</button></div></div><BusStopArrivalsV2 :state="platformArrivals[item.id]" :collapsed="!expandedPlatforms[item.id]" :route-href="routeHref" /></article></section>
    </template>
@@ -22,11 +22,13 @@ import { closestArrivalsByRoute, displayName, displayStopName, formatLocalDateTi
import { isFavorite, loadFavorites, toggleFavorite } from './favorites.mjs'
import { busLanguage, busText, setBusLanguage } from './i18n.mjs'
import { renderNoticeMarkdown } from './markdown.mjs'
import { isImportantNotice, loadNoticeOpenStates, noticeIsOpen, saveNoticeOpenState } from './notice-state.mjs'
import BusStopArrivalsV2 from './BusStopArrivalsV2.vue'
import BusVehicleLegendV2 from './BusVehicleLegendV2.vue'

const props = defineProps({ id: { type: String, default: '' }, routeHref: { type: String, default: '/transport/bustimer_v2_route.html?id=' }, stopHref: { type: String, default: '/transport/bustimer_v2_stop.html?id=' } })
const stop = ref(null), platforms = ref([]), notices = ref([]), loading = ref(true), error = ref(''), allArrivals = ref(false), arrivalState = ref({ loading: false, error: false, items: [] }), platformArrivals = ref({}), expandedPlatforms = ref({}), refreshRemaining = ref(30)
const noticeOpenStates = ref(loadNoticeOpenStates())
let refreshTimer
const id = computed(() => props.id || (typeof window === 'undefined' ? '' : new URLSearchParams(window.location.search).get('id') || ''))
const favorite = computed(() => stop.value && isFavorite('stop', stop.value.id))
@@ -37,6 +39,8 @@ const labels = { zh: { stop: '站点', home: '返回首页', saved: '已收藏',
const label = (key, values = {}) => (labels[busLanguage.value][key] || key).replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '')
const noticeTitle = (notice) => notice[busLanguage.value === 'en' ? 'title_en' : 'title_zh'] || notice.title_zh || notice.title_en
const noticeTime = (notice) => formatLocalDateTime(notice.starts_at)
const isNoticeOpen = (notice) => noticeIsOpen(noticeOpenStates.value, notice.id)
const rememberNoticeOpen = (notice, event) => saveNoticeOpenState(noticeOpenStates.value, notice.id, event.target.open)
const hasMoreArrivals = (state) => (state?.items?.length || 0) > closestArrivalsByRoute(state?.items || []).length
function togglePlatformArrivals(platformId) { expandedPlatforms.value = { ...expandedPlatforms.value, [platformId]: !expandedPlatforms.value[platformId] } }
function openPlatform(value) { if (typeof window !== 'undefined') window.location.assign(`${props.stopHref}${encodeURIComponent(value)}`) }
+52 −0
Original line number Diff line number Diff line
import { busApiBase } from './api.mjs'

export const BUS_VISITOR_ID_KEY = 'bus_eta_visitor_id'

const PAGE_BY_PATH = {
  '/transport/bustimer.html': 'home',
  '/transport/bustimer-wx.html': 'home',
  '/transport/bustimer_v2_route.html': 'route_detail',
  '/transport/bustimer_v2_stop.html': 'stop_detail',
  '/transport/bustimer_v2_vehicles.html': 'vehicles',
  '/transport/bustimer_v2_schedules.html': 'schedules',
  '/transport/bustimer_v2_files.html': 'map_files',
}
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

export const busPageForPath = (path) => PAGE_BY_PATH[String(path || '').replace(/\/+$/, '')]

export function getBusVisitorId({ storage, randomUUID } = {}) {
  try {
    storage ??= globalThis.localStorage
    randomUUID ??= () => globalThis.crypto.randomUUID()
    const existing = storage.getItem(BUS_VISITOR_ID_KEY)
    if (UUID_V4.test(existing)) return existing
    const visitorId = randomUUID()
    if (!UUID_V4.test(visitorId)) return undefined
    storage.setItem(BUS_VISITOR_ID_KEY, visitorId)
    return visitorId
  } catch {
    return undefined
  }
}

export function reportBusVisit(page, { storage, randomUUID, fetchImpl, apiBase } = {}) {
  if (!Object.values(PAGE_BY_PATH).includes(page)) return Promise.resolve()
  const visitorId = getBusVisitorId({ storage, randomUUID })
  if (!visitorId) return Promise.resolve()

  try {
    const send = fetchImpl ?? globalThis.fetch
    const url = `${(apiBase ?? busApiBase()).replace(/\/+$/, '')}/api/public/analytics/visit`
    return send(url, {
      method: 'POST',
      mode: 'cors',
      credentials: 'omit',
      keepalive: true,
      headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
      body: JSON.stringify({ visitor_id: visitorId, page }),
    }).catch(() => {})
  } catch {
    return Promise.resolve()
  }
}
Loading