Skip to content
Merged
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
7 changes: 7 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,8 @@ 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
PUBLIC_ANALYTICS_API_HOS=""
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ 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).
- `PUBLIC_ANALYTICS_API_HOST` - string: (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
65 changes: 65 additions & 0 deletions src/lib/Analytics/PlausibleAnalytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { PUBLIC_ANALYTICS_DOMAIN, PUBLIC_ANALYTICS_ENABLED } from '$env/static/public';

class PlausibleAnalytics {
constructor() {
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 = {
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();
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
15 changes: 15 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
43 changes: 43 additions & 0 deletions src/routes/api/events/+server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { PUBLIC_ANALYTICS_DOMAIN, PUBLIC_ANALYTICS_API_HOST } from '$env/static/public';

export async function POST({ request }) {
try {
const { name, url, referrer, props } = await request.json();

const res = await fetch(`${PUBLIC_ANALYTICS_API_HOST}/api/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: PUBLIC_ANALYTICS_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 });
Loading