Many more changes for btter experience.

This commit is contained in:
Brian McGonagill 2026-03-24 18:48:29 -05:00
parent 87059a62e2
commit e2a95252bb
16 changed files with 385 additions and 72 deletions

View file

@ -8,21 +8,27 @@ datasource db {
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
name String
screenName String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
games Game[] @relation("GameMaster")
teams TeamMember[]
captainOf Team? @relation("TeamCaptain")
chatMessages ChatMessage[]
id String @id @default(uuid())
email String @unique
passwordHash String
name String
screenName String?
avatarUrl String?
unitPreference UnitPreference @default(METRIC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
games Game[] @relation("GameMaster")
teams TeamMember[]
captainOf Team? @relation("TeamCaptain")
chatMessages ChatMessage[]
locationHistory LocationHistory[]
}
enum UnitPreference {
METRIC
IMPERIAL
}
model Game {
id String @id @default(uuid())
name String

View file

@ -14,6 +14,7 @@ router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
name: true,
screenName: true,
avatarUrl: true,
unitPreference: true,
createdAt: true
}
});
@ -31,14 +32,15 @@ router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
router.put('/me', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { name, screenName, avatarUrl } = req.body;
const { name, screenName, avatarUrl, unitPreference } = req.body;
const updated = await prisma.user.update({
where: { id: req.user!.id },
data: {
name: name || undefined,
screenName: screenName !== undefined ? screenName || null : undefined,
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined,
unitPreference: unitPreference || undefined
},
select: {
id: true,
@ -46,6 +48,7 @@ router.put('/me', authenticate, async (req: AuthRequest, res: Response) => {
name: true,
screenName: true,
avatarUrl: true,
unitPreference: true,
createdAt: true
}
});

View file

@ -1,9 +1,24 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import NavBar from './components/NavBar.vue'
import Modal from './components/Modal.vue'
import { state, key, handleConfirm, handleCancel } from './composables/useModal'
</script>
<template>
<NavBar />
<RouterView />
<Modal
:key="key"
:open="state.open"
:title="state.title"
:message="state.message"
:type="state.type"
:confirm-text="state.confirmText"
:cancel-text="state.cancelText"
:danger="state.danger"
@update:open="state.open = $event"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</template>

View file

@ -0,0 +1,112 @@
<script setup lang="ts">
interface Props {
open: boolean
title?: string
message?: string
type?: 'alert' | 'confirm'
confirmText?: string
cancelText?: string
danger?: boolean
}
interface Emits {
(e: 'update:open', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}
withDefaults(defineProps<Props>(), {
type: 'alert',
confirmText: 'OK',
cancelText: 'Cancel',
danger: false
})
const emit = defineEmits<Emits>()
function close() {
emit('update:open', false)
}
function handleConfirm() {
emit('confirm')
emit('update:open', false)
}
function handleCancel() {
emit('cancel')
emit('update:open', false)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
close()
}
}
</script>
<template>
<dialog :open="open" @keydown="handleKeydown" @click.self="type === 'confirm' ? handleCancel() : close()">
<article>
<header v-if="title">
<h3>{{ title }}</h3>
</header>
<p v-if="message">{{ message }}</p>
<slot></slot>
<footer>
<button
v-if="type === 'confirm'"
@click="handleCancel"
class="secondary"
>
{{ cancelText }}
</button>
<button
@click="handleConfirm"
:class="danger ? 'secondary' : ''"
>
{{ confirmText }}
</button>
</footer>
</article>
</dialog>
</template>
<style scoped>
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
max-width: 400px;
width: 90vw;
border: none;
background: transparent;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
dialog article {
margin: 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
dialog article p {
margin-bottom: 1rem;
}
footer {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
footer button {
margin: 0;
}
</style>

View file

@ -0,0 +1,66 @@
import { ref, reactive } from 'vue'
interface ModalState {
open: boolean
title: string
message: string
type: 'alert' | 'confirm'
confirmText: string
cancelText: string
danger: boolean
resolve: ((value: boolean) => void) | null
}
const state = reactive<ModalState>({
open: false,
title: '',
message: '',
type: 'alert',
confirmText: 'OK',
cancelText: 'Cancel',
danger: false,
resolve: null
})
const key = ref(0)
function show(options: {
message: string
title?: string
type?: 'alert' | 'confirm'
confirmText?: string
cancelText?: string
danger?: boolean
}): Promise<boolean> {
return new Promise((resolve) => {
state.open = true
state.title = options.title || ''
state.message = options.message
state.type = options.type || 'alert'
state.confirmText = options.confirmText || 'OK'
state.cancelText = options.cancelText || 'Cancel'
state.danger = options.danger || false
state.resolve = resolve
key.value++
})
}
export function alert(message: string, title?: string): Promise<void> {
return show({ message, title, type: 'alert' }).then(() => {})
}
export function confirm(message: string, title?: string, danger = false): Promise<boolean> {
return show({ message, title, type: 'confirm', danger })
}
export function handleConfirm() {
if (state.resolve) state.resolve(true)
state.open = false
}
export function handleCancel() {
if (state.resolve) state.resolve(false)
state.open = false
}
export { state, key }

View file

@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { Game } from '../types';
import { gameService } from '../services/api';
import { alert, confirm } from '../composables/useModal';
const games = ref<Game[]>([]);
const loading = ref(false);
@ -31,24 +32,24 @@ async function loadGames() {
}
async function archiveGame(gameId: string) {
if (!confirm('Are you sure you want to archive this game?')) return;
if (!await confirm('Are you sure you want to archive this game?')) return;
try {
await gameService.archive(gameId);
await loadGames();
} catch (err) {
alert('Failed to archive game');
await alert('Failed to archive game');
}
}
async function unarchiveGame(gameId: string) {
if (!confirm('Are you sure you want to unarchive this game?')) return;
if (!await confirm('Are you sure you want to unarchive this game?')) return;
try {
await gameService.unarchive(gameId);
await loadGames();
} catch (err) {
alert('Failed to unarchive game');
await alert('Failed to unarchive game');
}
}

View file

@ -5,8 +5,12 @@ import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { Game, Route } from '../types';
import { gameService, routeService } from '../services/api';
import { alert, confirm } from '../composables/useModal';
import { useAuthStore } from '../stores/auth';
import { formatDistance } from '../utils/units';
const route = useRoute();
const authStore = useAuthStore();
const game = ref<Game | null>(null);
const routes = ref<Route[]>([]);
@ -20,6 +24,8 @@ const selectedRoute = computed(() =>
routes.value.find(r => r.id === selectedRouteId.value) || null
);
const unitPreference = computed(() => authStore.user?.unitPreference || 'METRIC');
const mapContainer = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let routeMarkers: { [routeId: string]: L.Marker[] } = {};
@ -76,7 +82,7 @@ async function saveRoute() {
updateMapMarkers();
} catch (err) {
alert('Failed to update route');
await alert('Failed to update route');
} finally {
saving.value = false;
}
@ -162,7 +168,7 @@ function updateMapMarkers() {
async function createRoute() {
if (!newRoute.value.name.trim()) {
alert('Please enter a route name');
await alert('Please enter a route name');
return;
}
@ -181,7 +187,7 @@ async function createRoute() {
newRoute.value = { name: '', description: '', color: '#3498db' };
updateMapMarkers();
} catch (err) {
alert('Failed to create route');
await alert('Failed to create route');
} finally {
saving.value = false;
}
@ -194,12 +200,12 @@ async function copyRoute(routeId: string) {
selectedRouteId.value = response.data.id;
updateMapMarkers();
} catch (err) {
alert('Failed to copy route');
await alert('Failed to copy route');
}
}
async function deleteRoute(routeId: string) {
if (!confirm('Are you sure you want to delete this route and all its legs?')) return;
if (!await confirm('Are you sure you want to delete this route and all its legs?')) return;
try {
await routeService.delete(routeId);
@ -211,13 +217,13 @@ async function deleteRoute(routeId: string) {
updateMapMarkers();
} catch (err) {
alert('Failed to delete route');
await alert('Failed to delete route');
}
}
async function addLeg() {
if (!selectedRouteId.value || !newLeg.value.description) {
alert('Please enter a description');
await alert('Please enter a description');
return;
}
@ -249,14 +255,14 @@ async function addLeg() {
updateMapMarkers();
} catch (err) {
alert('Failed to add leg');
await alert('Failed to add leg');
} finally {
saving.value = false;
}
}
async function deleteLeg(legId: string) {
if (!confirm('Are you sure you want to delete this leg?')) return;
if (!await confirm('Are you sure you want to delete this leg?')) return;
try {
await routeService.deleteLeg(selectedRouteId.value!, legId);
@ -268,7 +274,7 @@ async function deleteLeg(legId: string) {
updateMapMarkers();
} catch (err) {
alert('Failed to delete leg');
await alert('Failed to delete leg');
}
}
@ -374,7 +380,7 @@ onUnmounted(() => {
<footer>
<small>
{{ routeItem.routeLegs?.length || 0 }} legs
· {{ getRouteDistance(routeItem).toFixed(2) }} km
· {{ formatDistance(getRouteDistance(routeItem), unitPreference) }}
</small>
</footer>
</div>

View file

@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css';
import { useAuthStore } from '../stores/auth';
import type { Game, Team, ChatMessage, Route } from '../types';
import { gameService, teamService, routeService } from '../services/api';
import { alert, confirm } from '../composables/useModal';
const route = useRoute();
const authStore = useAuthStore();
@ -109,10 +110,22 @@ function connectSocket() {
team.lat = data.lat;
team.lng = data.lng;
const teamRoute = team.teamRoutes?.[0];
const teamRouteData = routes.value.find(r => r.id === teamRoute?.routeId);
const color = teamRouteData?.color || '#666';
if (teamMarkers[data.teamId]) {
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
} else if (map) {
const marker = L.marker([data.lat, data.lng])
teamMarkers[data.teamId].remove();
}
if (map) {
const teamIcon = L.divIcon({
className: 'team-marker',
html: `<div style="background:${color}; width:24px; height:24px; border-radius:50%; border:2px solid white; box-shadow:0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const marker = L.marker([data.lat, data.lng], { icon: teamIcon })
.addTo(map)
.bindPopup(team.name);
teamMarkers[data.teamId] = marker;
@ -148,30 +161,30 @@ async function advanceTeam(teamId: string) {
await loadGame();
socket?.emit('team-advanced', { gameId: gameId.value, teamId });
} catch (err) {
alert('Failed to advance team');
await alert('Failed to advance team');
}
}
async function deductTime(teamId: string) {
const seconds = prompt('Enter deduction in seconds:', '60');
if (!seconds) return;
const secondsStr = window.prompt('Enter deduction in seconds:', '60');
if (!secondsStr) return;
try {
await teamService.deduct(teamId, parseInt(seconds));
await teamService.deduct(teamId, parseInt(secondsStr));
await loadGame();
} catch (err) {
alert('Failed to deduct time');
await alert('Failed to deduct time');
}
}
async function disqualifyTeam(teamId: string) {
if (!confirm('Are you sure you want to disqualify this team?')) return;
if (!await confirm('Are you sure you want to disqualify this team?')) return;
try {
await teamService.disqualify(teamId);
await loadGame();
} catch (err) {
alert('Failed to disqualify team');
await alert('Failed to disqualify team');
}
}

View file

@ -4,6 +4,8 @@ import { useRoute, RouterLink } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import type { Game } from '../types';
import { gameService } from '../services/api';
import { alert, confirm } from '../composables/useModal';
import { formatRadius } from '../utils/units';
const route = useRoute();
const authStore = useAuthStore();
@ -14,6 +16,7 @@ const error = ref('');
const gameId = computed(() => route.params.id as string);
const isGameMaster = computed(() => game.value?.gameMasterId === authStore.user?.id);
const unitPreference = computed(() => authStore.user?.unitPreference || 'METRIC');
async function loadGame() {
loading.value = true;
@ -29,46 +32,46 @@ async function loadGame() {
}
async function publishGame() {
if (!confirm('Are you sure you want to publish this game?')) return;
if (!await confirm('Are you sure you want to publish this game?')) return;
try {
await gameService.publish(gameId.value);
await loadGame();
} catch (err) {
alert('Failed to publish game');
await alert('Failed to publish game');
}
}
async function archiveGame() {
if (!confirm('Are you sure you want to archive this game?')) return;
if (!await confirm('Are you sure you want to archive this game?')) return;
try {
await gameService.archive(gameId.value);
await loadGame();
} catch (err) {
alert('Failed to archive game');
await alert('Failed to archive game');
}
}
async function unarchiveGame() {
if (!confirm('Are you sure you want to unarchive this game?')) return;
if (!await confirm('Are you sure you want to unarchive this game?')) return;
try {
await gameService.unarchive(gameId.value);
await loadGame();
} catch (err) {
alert('Failed to unarchive game');
await alert('Failed to unarchive game');
}
}
async function endGame() {
if (!confirm('Are you sure you want to end this game?')) return;
if (!await confirm('Are you sure you want to end this game?')) return;
try {
await gameService.end(gameId.value);
await loadGame();
} catch (err) {
alert('Failed to end game');
await alert('Failed to end game');
}
}
@ -77,9 +80,9 @@ async function copyInviteLink() {
const response = await gameService.getInvite(gameId.value);
const link = `${window.location.origin}/invite/${response.data.inviteCode}`;
await navigator.clipboard.writeText(link);
alert('Invite link copied to clipboard!');
await alert('Invite link copied to clipboard!');
} catch (err) {
alert('Failed to get invite link');
await alert('Failed to get invite link');
}
}
@ -144,7 +147,7 @@ onMounted(() => {
<dd v-else>Not set</dd>
<dt>Search Radius</dt>
<dd>{{ game.searchRadius || 500 }} meters</dd>
<dd>{{ formatRadius(game.searchRadius || 500, unitPreference) }}</dd>
</dl>
</section>

View file

@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import type { Game, Team } from '../types';
import { gameService, teamService } from '../services/api';
import { alert } from '../composables/useModal';
const route = useRoute();
const router = useRouter();
@ -44,7 +45,7 @@ async function loadGame() {
async function createTeam() {
if (!newTeamName.value.trim()) {
alert('Please enter a team name');
await alert('Please enter a team name');
return;
}
@ -55,7 +56,7 @@ async function createTeam() {
await loadGame();
router.push(`/games/${gameId.value}/play`);
} catch (err) {
alert('Failed to create team');
await alert('Failed to create team');
} finally {
creating.value = false;
}
@ -68,7 +69,7 @@ async function joinTeam(teamId: string) {
await loadGame();
router.push(`/games/${gameId.value}/play`);
} catch (err) {
alert('Failed to join team');
await alert('Failed to join team');
} finally {
joining.value = false;
}

View file

@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css';
import { useAuthStore } from '../stores/auth';
import type { Game, Team, ChatMessage, Route, RouteLeg } from '../types';
import { teamService, uploadService } from '../services/api';
import { alert } from '../composables/useModal';
const route = useRoute();
const authStore = useAuthStore();
@ -164,7 +165,7 @@ function handlePhotoSelect(event: Event) {
async function submitPhoto() {
if (!photoFile.value || !currentLeg.value || !team.value) {
alert('Please select a photo');
await alert('Please select a photo');
return;
}
@ -183,11 +184,11 @@ async function submitPhoto() {
})
});
alert('Photo submitted! Wait for Game Master approval.');
await alert('Photo submitted! Wait for Game Master approval.');
showPhotoUpload.value = false;
photoFile.value = null;
} catch (err) {
alert('Failed to submit photo');
await alert('Failed to submit photo');
} finally {
uploading.value = false;
}

View file

@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { userService, uploadService } from '../services/api';
import type { User, UserGameHistory, LocationHistory } from '../types';
import { alert, confirm } from '../composables/useModal';
import { formatDistance } from '../utils/units';
const router = useRouter();
const authStore = useAuthStore();
@ -22,6 +24,10 @@ const uploadingAvatar = ref(false);
const locationHistory = ref<{ totalLocations: number; byGame: { game: { id: string; name: string }; locations: LocationHistory[]; locationCount: number }[] }>({ totalLocations: 0, byGame: [] });
const gamesHistory = ref<UserGameHistory[]>([]);
const unitPreference = ref<'METRIC' | 'IMPERIAL'>('METRIC');
const displayUnitPreference = computed(() => unitPreference.value);
async function loadProfile() {
loading.value = true;
try {
@ -31,6 +37,7 @@ async function loadProfile() {
screenName.value = response.data.screenName || '';
avatarUrl.value = response.data.avatarUrl || '';
avatarPreview.value = response.data.avatarUrl || '';
unitPreference.value = response.data.unitPreference || 'METRIC';
} catch (err) {
console.error('Failed to load profile:', err);
} finally {
@ -65,7 +72,7 @@ async function handleAvatarSelect(event: Event) {
avatarUrl.value = uploadRes.data.url;
avatarPreview.value = uploadRes.data.url;
} catch (err) {
alert('Failed to upload avatar');
await alert('Failed to upload avatar');
} finally {
uploadingAvatar.value = false;
}
@ -78,40 +85,41 @@ async function saveProfile() {
const response = await userService.updateProfile({
name: name.value,
screenName: screenName.value || undefined,
avatarUrl: avatarUrl.value || undefined
avatarUrl: avatarUrl.value || undefined,
unitPreference: unitPreference.value
});
user.value = response.data;
authStore.user = response.data;
alert('Profile updated successfully!');
await alert('Profile updated successfully!');
} catch (err) {
alert('Failed to update profile');
await alert('Failed to update profile');
} finally {
saving.value = false;
}
}
async function deleteLocationData() {
if (!confirm('Are you sure you want to delete all your location data? This cannot be undone.')) return;
if (!await confirm('Are you sure you want to delete all your location data? This cannot be undone.')) return;
try {
await userService.deleteLocationData();
alert('Location data deleted.');
await alert('Location data deleted.');
loadLocationHistory();
} catch (err) {
alert('Failed to delete location data');
await alert('Failed to delete location data');
}
}
async function deleteAccount() {
if (!confirm('Are you sure you want to delete your account? This will remove all your data and cannot be undone.')) return;
if (!confirm('This is your final warning. Type "DELETE" to confirm.')) return;
if (!await confirm('Are you sure you want to delete your account? This will remove all your data and cannot be undone.')) return;
if (!await confirm('This is your final warning. This action cannot be undone.')) return;
try {
await userService.deleteAccount();
authStore.logout();
router.push('/');
} catch (err) {
alert('Failed to delete account');
await alert('Failed to delete account');
}
}
@ -178,6 +186,14 @@ onMounted(() => {
<input v-model="screenName" type="text" placeholder="Optional alias" />
</label>
<label>
Distance Units
<select v-model="unitPreference">
<option value="METRIC">Metric (km, m)</option>
<option value="IMPERIAL">Imperial (miles, feet)</option>
</select>
</label>
<button @click="saveProfile" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Profile' }}
</button>
@ -274,7 +290,7 @@ onMounted(() => {
<small>Legs</small>
</div>
<div class="stat">
<strong>{{ game.totalDistance }} km</strong>
<strong>{{ formatDistance(game.totalDistance, displayUnitPreference) }}</strong>
<small>Distance</small>
</div>
<div class="stat">

View file

@ -114,10 +114,22 @@ function connectSocket() {
team.lat = data.lat;
team.lng = data.lng;
const teamRoute = team.teamRoutes?.[0];
const teamRouteData = routes.value.find(r => r.id === teamRoute?.routeId);
const color = teamRouteData?.color || '#666';
if (teamMarkers[data.teamId]) {
teamMarkers[data.teamId].setLatLng([data.lat, data.lng]);
} else if (map) {
const marker = L.marker([data.lat, data.lng])
teamMarkers[data.teamId].remove();
}
if (map) {
const teamIcon = L.divIcon({
className: 'team-marker',
html: `<div style="background:${color}; width:16px; height:16px; border-radius:50%; border:2px solid white; box-shadow:0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
const marker = L.marker([data.lat, data.lng], { icon: teamIcon })
.addTo(map)
.bindPopup(team.name);
teamMarkers[data.teamId] = marker;

View file

@ -81,7 +81,7 @@ export const uploadService = {
export const userService = {
getProfile: () => api.get<User>('/users/me'),
updateProfile: (data: { name?: string; screenName?: string; avatarUrl?: string }) =>
updateProfile: (data: { name?: string; screenName?: string; avatarUrl?: string; unitPreference?: 'METRIC' | 'IMPERIAL' }) =>
api.put<User>('/users/me', data),
getLocationHistory: () => api.get<{ totalLocations: number; byGame: { game: { id: string; name: string }; locations: LocationHistory[]; locationCount: number }[] }>('/users/me/location-history'),
getGamesHistory: () => api.get<UserGameHistory[]>('/users/me/games'),

View file

@ -4,6 +4,7 @@ export interface User {
name: string;
screenName?: string;
avatarUrl?: string;
unitPreference?: 'METRIC' | 'IMPERIAL';
createdAt?: string;
}

View file

@ -0,0 +1,57 @@
export type UnitPreference = 'METRIC' | 'IMPERIAL'
const KM_TO_MILES = 0.621371
const METERS_TO_FEET = 3.28084
export function formatDistance(km: number, unit: UnitPreference): string {
if (unit === 'IMPERIAL') {
if (km < 0.1) {
const feet = km * 1000 * METERS_TO_FEET
return `${Math.round(feet)} ft`
}
const miles = km * KM_TO_MILES
return `${miles.toFixed(2)} mi`
}
if (km < 1) {
const meters = km * 1000
return `${Math.round(meters)} m`
}
return `${km.toFixed(2)} km`
}
export function formatDistanceShort(km: number, unit: UnitPreference): string {
if (unit === 'IMPERIAL') {
const miles = km * KM_TO_MILES
if (miles < 0.1) {
return `${Math.round(miles * 5280)} ft`
}
return `${miles.toFixed(1)} mi`
}
if (km < 1) {
return `${Math.round(km * 1000)} m`
}
return `${km.toFixed(1)} km`
}
export function formatRadius(meters: number, unit: UnitPreference): string {
if (unit === 'IMPERIAL') {
const feet = meters * METERS_TO_FEET
if (feet < 5280) {
return `${Math.round(feet)} ft`
}
const miles = feet / 5280
return `${miles.toFixed(1)} mi`
}
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
export function kmToMiles(km: number): number {
return km * KM_TO_MILES
}
export function metersToFeet(meters: number): number {
return meters * METERS_TO_FEET
}