Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ PRIVATE_OBACO_API_BASE_URL=https://onebusaway.co/api/v1
PRIVATE_REGION_ID=
PRIVATE_OBACO_SHOW_TEST_ALERTS=false



PUBLIC_NAV_BAR_LINKS={"Home": "/","About": "/about","Contact": "/contact","Fares & Tolls": "/fares-and-tolls"}
PUBLIC_OBA_GOOGLE_MAPS_API_KEY=""
PUBLIC_OBA_LOGO_URL="https://onebusaway.org/wp-content/uploads/oba_logo-1.png"
Expand All @@ -19,3 +21,7 @@ PUBLIC_OBA_REGION_NAME="Puget Sound"
PUBLIC_OBA_SERVER_URL="https://api.pugetsound.onebusaway.org/"

PUBLIC_OTP_SERVER_URL=""

# Analytics
PUBLIC_ANALYTICS_DOMAIN=""
PUBLIC_ANALYTICS_ENABLED=true
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ See `.env.example` for an example of the required keys and values.
- `PUBLIC_NAV_BAR_LINKS` - JSON string: (required) A dictionary of the links displayed across the navigation bar.
- `PUBLIC_APP_PRIMARY_COLOR` - string: (required) The hex color code for the application's primary brand color. Must be wrapped in quotes (e.g., "#214666").
- `PUBLIC_APP_SECONDARY_COLOR` - string: (required) The hex color code for the application's secondary brand color. Must be wrapped in quotes (e.g., "#486621").
- `PUBLIC_ANALYTICS_DOMAIN` - string: (optional).
- `PUBLIC_ANALYTICS_ENABLED` - boolean: (optional).

### OBA Server

Expand Down
2 changes: 2 additions & 0 deletions src/components/map/MapView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { faBus } from '@fortawesome/free-solid-svg-icons';
import { RouteType, routePriorities, prioritizedRouteTypeForDisplay } from '$config/routeConfig';
import { isMapLoaded } from '$src/stores/mapStore';
import { userLocation } from '$src/stores/userLocationStore';
/**
* @typedef {Object} Props
* @property {any} [selectedTrip]
Expand Down Expand Up @@ -198,6 +199,7 @@
function handleLocationObtained(latitude, longitude) {
mapInstance.setCenter({ lat: latitude, lng: longitude });
mapInstance.addUserLocationMarker({ lat: latitude, lng: longitude });
userLocation.set({ lat: latitude, lng: longitude });
}

onMount(async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/search/SearchField.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import { t } from 'svelte-i18n';
import analytics from '$lib/Analytics/PlausibleAnalytics';

/**
* @typedef {Object} Props
Expand All @@ -14,6 +15,7 @@
const response = await fetch(`/api/oba/search?query=${encodeURIComponent(value)}`);
const results = await response.json();
handleSearchResults(results);
analytics.reportSearchQuery(value);
} catch (error) {
console.error('Error fetching search results:', error);
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/stops/StopPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { surveyStore, showSurveyModal } from '$stores/surveyStore';
import { getUserId } from '$lib/utils/user';
import HeroQuestion from '$components/surveys/HeroQuestion.svelte';
import analytics from '$lib/Analytics/PlausibleAnalytics';

/**
* @typedef {Object} Props
Expand Down Expand Up @@ -88,6 +89,7 @@
if (handleUpdateRouteMap) {
handleUpdateRouteMap({ detail: { show } });
}
analytics.reportArrivalClicked('Clicked on arrival/departure');
}

let heroAnswer = '';
Expand Down
67 changes: 67 additions & 0 deletions src/lib/Analytics/PlausibleAnalytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { PUBLIC_ANALYTICS_DOMAIN, PUBLIC_ANALYTICS_ENABLED } from '$env/static/public';

class PlausibleAnalytics {
constructor(domain) {
this.domain = domain;
this.defaultProperties = {};
this.enabled = PUBLIC_ANALYTICS_ENABLED === 'true' && PUBLIC_ANALYTICS_DOMAIN !== '';
}

async postEvent(pageURL, eventName, props = {}) {
if (!this.enabled) {
console.debug('Analytics disabled: skipping event');
return;
}

const payload = {
domain: this.domain,
name: eventName,
url: pageURL,
props: this.buildProps(props)
};

try {
console.debug('Sending event:', payload);
const response = await fetch(`/api/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error sending event: ${response.statusText}. ${errorText}`);
}
return response.json();
} catch (error) {
console.error('Error tracking event:', error);
throw error;
}
}

async reportPageView(pageURL, props = {}) {
return this.postEvent(pageURL, 'pageview', props);
}

async reportSearchQuery(query) {
return this.postEvent('/search', 'search', { query: query });
}

async reportStopViewed(id, stopDistance) {
return this.postEvent('/stop', 'pageview', { id: id, distance: stopDistance });
}

async reportRouteClicked(routeId) {
return this.postEvent('/route', 'click', { id: routeId });
}

async reportArrivalClicked(action) {
return this.postEvent('/arrivals', 'click', { item_id: action });
}

buildProps(otherProps = {}) {
return { ...this.defaultProperties, ...otherProps };
}
}

const analytics = new PlausibleAnalytics(PUBLIC_ANALYTICS_DOMAIN);
export default analytics;
42 changes: 42 additions & 0 deletions src/lib/Analytics/analyticsUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { calcDistanceBetweenTwoPoints } from '$lib/mathUtils';

/**
* Converts a distance (in km) to a category string.
* @param {number} distanceKm - The distance in kilometers.
* @returns {string} - The distance category string.
*/
export function getDistanceCategory(distanceKm) {
const distanceM = distanceKm * 1000;
if (distanceM < 50) {
return 'User Distance: 00000-00050m';
} else if (distanceM < 100) {
return 'User Distance: 00050-00100m';
} else if (distanceM < 200) {
return 'User Distance: 00100-00200m';
} else if (distanceM < 400) {
return 'User Distance: 00200-00400m';
} else if (distanceM < 800) {
return 'User Distance: 00400-00800m';
} else if (distanceM < 1600) {
return 'User Distance: 00800-01600m';
} else if (distanceM < 3200) {
return 'User Distance: 01600-03200m';
} else {
return 'User Distance: 03200-INFINITY';
}
}

/**
* Calculates the distance between the user location and the stop,
* then returns the corresponding distance category for analytics.
*
* @param {number} userLat - User latitude.
* @param {number} userLng - User longitude.
* @param {number} stopLat - Stop latitude.
* @param {number} stopLng - Stop longitude.
* @returns {string} - The analytics distance category.
*/
export function analyticsDistanceToStop(userLat, userLng, stopLat, stopLng) {
const distanceKm = calcDistanceBetweenTwoPoints(userLat, userLng, stopLat, stopLng);
return getDistanceCategory(distanceKm);
}
34 changes: 34 additions & 0 deletions src/lib/mathUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,37 @@ export function calculateMidpoint(stops) {

return { lat: midpointLat, lng: midpointLon };
}

/**
* Calculates the distance between two geographical points using the Haversine formula.
*
* @param {number} lat1 - Latitude of the first location in degrees.
* @param {number} lon1 - Longitude of the first location in degrees.
* @param {number} lat2 - Latitude of the second location in degrees.
* @param {number} lon2 - Longitude of the second location in degrees.
* @returns {number} The distance between the two points in kilometers.
*/
export function calcDistanceBetweenTwoPoints(lat1, lon1, lat2, lon2) {
const earthRadiusKm = 6371; // Earth's radius in kilometers
const deltaLat = toRadians(lat2 - lat1);
const deltaLon = toRadians(lon2 - lon1);
const radLat1 = toRadians(lat1);
const radLat2 = toRadians(lat2);

const a =
Math.sin(deltaLat / 2) ** 2 +
Math.sin(deltaLon / 2) ** 2 * Math.cos(radLat1) * Math.cos(radLat2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distanceKm = earthRadiusKm * c;
return distanceKm;
}

/**
* Converts degrees to radians.
*
* @param {number} degrees - The degrees to convert.
* @returns {number} The angle in radians.
*/
function toRadians(degrees) {
return (degrees * Math.PI) / 180;
}
4 changes: 4 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import '$lib/i18n';
import { locale } from 'svelte-i18n';
import { onMount } from 'svelte';
import analytics from '$lib/Analytics/PlausibleAnalytics.js';

/**
* @typedef {Object} Props
* @property {import('svelte').Snippet} [children]
Expand All @@ -23,6 +25,8 @@
document.documentElement.classList.remove('rtl');
}
});

analytics.reportPageView('/');
});
</script>

Expand Down
17 changes: 17 additions & 0 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
import { loadSurveys } from '$lib/Surveys/surveyUtils';
import { showSurveyModal } from '$stores/surveyStore';
import { getUserId } from '$lib/utils/user';
import analytics from '$lib/Analytics/PlausibleAnalytics';
import { userLocation } from '$src/stores/userLocationStore';
import { analyticsDistanceToStop } from '$lib/Analytics/analyticsUtils';
let stop = $state();
let selectedTrip = $state(null);
Expand All @@ -37,6 +40,8 @@
let toMarker = $state(null);
let currentHighlightedStopId = null;
let currentUserLocation = $state($userLocation);
$effect(() => {
if (showRouteModal && showAllRoutesModal) {
showAllRoutesModal = false;
Expand All @@ -52,11 +57,20 @@
pushState(`/stops/${stop.id}`);
showAllRoutesModal = false;
loadSurveys(stop, getUserId());
if (currentHighlightedStopId !== null) {
mapProvider.unHighlightMarker(currentHighlightedStopId);
}
mapProvider.highlightMarker(stop.id);
currentHighlightedStopId = stop.id;
const distanceCategory = analyticsDistanceToStop(
currentUserLocation.lat,
currentUserLocation.lng,
stop.lat,
stop.lon
);
analytics.reportStopViewed(stop.id, distanceCategory);
}
function handleViewAllRoutes() {
Expand Down Expand Up @@ -125,6 +139,7 @@
stops = routeData.stops;
currentIntervalId = routeData.currentIntervalId;
showRouteModal = true;
analytics.reportRouteClicked(selectedRoute.id);
}
function clearPolylines() {
Expand Down Expand Up @@ -190,6 +205,8 @@
});
</script>
<!-- <PlausibleAnalytics domain={"api.pugetsound.onebusaway.org/stop"} enabled={true} /> -->
<svelte:head>
<title>{PUBLIC_OBA_REGION_NAME}</title>
</svelte:head>
Expand Down
47 changes: 47 additions & 0 deletions src/routes/api/events/+server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export async function POST({ request }) {
try {
const {
domain,
name,
url,
referrer,
props,
apiHost = 'https://plausible.io'
} = await request.json();
const res = await fetch(`${apiHost}/api/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain,
name,
url,
referrer,
props
})
});

if (!res.ok) {
return new Response(JSON.stringify({ error: `Error sending event: ${res.statusText}` }), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}

const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { status: text };
}
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message || 'Unknown error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
12 changes: 12 additions & 0 deletions src/routes/stops/[stopID]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@
import { onMount } from 'svelte';
import { loadSurveys } from '$lib/Surveys/surveyUtils.js';
import { getUserId } from '$lib/utils/user.js';
import analytics from '$lib/Analytics/PlausibleAnalytics.js';
import { analyticsDistanceToStop } from '$lib/Analytics/analyticsUtils.js';
import { userLocation } from '$src/stores/userLocationStore.js';

let { data } = $props();
const stop = data.stopData.entry;
const arrivalsAndDeparturesResponse = data.arrivalsAndDeparturesResponse;

const currentUserLocation = $state($userLocation);

onMount(() => {
const distanceCategory = analyticsDistanceToStop(
currentUserLocation.lat,
currentUserLocation.lng,
stop.lat,
stop.lon
);
analytics.reportStopViewed(stop.id, distanceCategory);
loadSurveys(stop, getUserId());
});
</script>
Expand Down
3 changes: 3 additions & 0 deletions src/stores/userLocationStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';

export const userLocation = writable({ lat: null, lng: null });