Skip to content
Open
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
4 changes: 4 additions & 0 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class StudySpot(db.Model):
study_spot_name = db.Column(db.String(200), nullable=False)
building_name = db.Column(db.String(200), nullable=True)
address = db.Column(db.String(500), nullable=False)
latitude = db.Column(db.Float, nullable=True)
longitude = db.Column(db.Float, nullable=True)
Comment on lines +20 to +21

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding latitude/longitude columns requires a DB schema change. Since the app currently uses db.create_all() (which won’t alter existing tables), any existing SQLite DB will be incompatible and queries can fail. Consider adding a migration step / schema update script (or ensure the dev DB is recreated) as part of this change.

Copilot uses AI. Check for mistakes.
floor = db.Column(db.Integer, nullable=True)
tags = db.Column(db.JSON, nullable=False, default=lambda: [])
pictures = db.Column(db.JSON, nullable=False, default=lambda: [])
Expand All @@ -37,6 +39,8 @@ def to_dict(self):
'study_spot_name': self.study_spot_name,
'building_name': self.building_name,
'address': self.address,
'latitude': self.latitude,
'longitude': self.longitude,
'floor': self.floor,
'tags': self.tags if self.tags is not None else [],
'pictures': self.pictures if self.pictures is not None else [],
Expand Down
64 changes: 64 additions & 0 deletions backend/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,67 @@ def delete_study_spot(spot_id):
db.session.rollback()
logger.error(f"Error deleting study spot {spot_id}: {str(e)}")
return jsonify({'error': 'Failed to delete study spot'}), 500


@api_bp.route('/study_spots/sort_by_distance', methods=['POST'])
def sort_by_distance():
"""Sort study spots by distance from user location."""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400

user_lat = data.get('user_lat')
user_lng = data.get('user_lng')

if user_lat is None or user_lng is None:
return jsonify({'error': 'user_lat and user_lng are required'}), 400

# Convert to float
try:
user_lat = float(user_lat)
user_lng = float(user_lng)
except (ValueError, TypeError):
return jsonify({'error': 'Invalid coordinates'}), 400

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinate validation here only checks “is present” and “can be cast to float”. It should also validate bounds (lat ∈ [-90, 90], lng ∈ [-180, 180]) to avoid nonsensical values and surprising distance calculations.

Suggested change
return jsonify({'error': 'Invalid coordinates'}), 400
return jsonify({'error': 'Invalid coordinates'}), 400
# Validate coordinate bounds
if not (-90 <= user_lat <= 90) or not (-180 <= user_lng <= 180):
return jsonify({'error': 'Coordinates out of range'}), 400

Copilot uses AI. Check for mistakes.

# Get all study spots
spots = StudySpot.query.all()

# Calculate distances and sort
spots_with_distance = []
for spot in spots:
# Skip spots without coordinates
if not hasattr(spot, 'latitude') or not hasattr(spot, 'longitude'):
continue
if spot.latitude is None or spot.longitude is None:
continue

distance = _haversine_distance(user_lat, user_lng, spot.latitude, spot.longitude)
spot_dict = spot.to_dict()
spot_dict['distance_from_user'] = distance
spots_with_distance.append(spot_dict)

# Sort by distance (closest first)
spots_with_distance.sort(key=lambda x: x['distance_from_user'])
Comment on lines +267 to +285

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StudySpot.query.all() loads every row into memory and then sorts in Python. This won’t scale and can become a latency/memory issue as the table grows. Consider filtering out null coordinates at the DB level, selecting only needed columns, and optionally supporting paging/limits.

Copilot uses AI. Check for mistakes.
Comment on lines +273 to +285

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint relies on StudySpot.latitude/longitude, but the create/update JSON handler (_study_spot_from_json) doesn’t accept/populate those fields. As a result, newly created/updated spots will have null coordinates and will be filtered out here, often returning an empty list. Add latitude/longitude handling (with validation) to the create/update flow, or adjust the endpoint’s behavior.

Suggested change
# Skip spots without coordinates
if not hasattr(spot, 'latitude') or not hasattr(spot, 'longitude'):
continue
if spot.latitude is None or spot.longitude is None:
continue
distance = _haversine_distance(user_lat, user_lng, spot.latitude, spot.longitude)
spot_dict = spot.to_dict()
spot_dict['distance_from_user'] = distance
spots_with_distance.append(spot_dict)
# Sort by distance (closest first)
spots_with_distance.sort(key=lambda x: x['distance_from_user'])
spot_dict = spot.to_dict()
# If the spot doesn't have usable coordinates, include it with no distance
if (not hasattr(spot, 'latitude') or not hasattr(spot, 'longitude') or
spot.latitude is None or spot.longitude is None):
spot_dict['distance_from_user'] = None
spots_with_distance.append(spot_dict)
continue
# Compute distance for spots with valid coordinates
distance = _haversine_distance(user_lat, user_lng, spot.latitude, spot.longitude)
spot_dict['distance_from_user'] = distance
spots_with_distance.append(spot_dict)
# Sort by distance (closest first); spots without distance go last
spots_with_distance.sort(
key=lambda x: (
x['distance_from_user'] is None,
x['distance_from_user'] if x['distance_from_user'] is not None else float('inf'),
)
)

Copilot uses AI. Check for mistakes.
Comment on lines +281 to +285

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response adds distance_from_user, but _haversine_distance uses r = 3956, which implies miles. Since the field name doesn’t include units, API consumers won’t know how to display/interpret it. Consider returning an explicit unit (e.g., distance_miles or distance_meters) and documenting it.

Suggested change
spot_dict['distance_from_user'] = distance
spots_with_distance.append(spot_dict)
# Sort by distance (closest first)
spots_with_distance.sort(key=lambda x: x['distance_from_user'])
# distance is in miles (Earth radius r = 3956 in _haversine_distance)
spot_dict['distance_miles'] = distance
spots_with_distance.append(spot_dict)
# Sort by distance (closest first)
spots_with_distance.sort(key=lambda x: x['distance_miles'])

Copilot uses AI. Check for mistakes.

return jsonify(spots_with_distance), 200

except Exception as e:
logger.error(f"Error sorting by distance: {str(e)}")
return jsonify({'error': 'Failed to sort by distance'}), 500


def _haversine_distance(lat1, lon1, lat2, lon2):
import math

# Convert decimal degrees to radians
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])

# Haversine formula
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))

r = 3956
return c * r
50 changes: 50 additions & 0 deletions frontend/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,56 @@ import ParallaxScrollView from '@/components/parallax-scroll-view';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Link } from 'expo-router';
import * as Location from 'expo-location';
import { useEffect, useState } from 'react';

export default function HomeScreen() {

const [userLat, setUserLat] = useState<number | null>(null);
const [userLng, setUserLng] = useState<number | null>(null);
const [spots, setSpots] = useState<any[]>([]);

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spots state is set but never read/rendered in this component, which is likely to trigger no-unused-vars lint noise and makes it harder to see intended behavior. Either render/use spots (even temporarily) or remove the state until it’s needed.

Copilot uses AI. Check for mistakes.

async function getUserLocation() {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
return;
}

const location = await Location.getCurrentPositionAsync({});
setUserLat(location.coords.latitude);
setUserLng(location.coords.longitude);
Comment on lines +19 to +26

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Location.getCurrentPositionAsync can throw (e.g., location services disabled, timeout). Wrap this call in try/catch and handle failures (at least log and avoid leaving state in an indeterminate null/partial state).

Suggested change
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
return;
}
const location = await Location.getCurrentPositionAsync({});
setUserLat(location.coords.latitude);
setUserLng(location.coords.longitude);
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
console.warn('Location permission not granted');
setUserLat(null);
setUserLng(null);
return;
}
const location = await Location.getCurrentPositionAsync({});
if (!location || !location.coords) {
console.error('Failed to obtain location coordinates');
setUserLat(null);
setUserLng(null);
return;
}
setUserLat(location.coords.latitude);
setUserLng(location.coords.longitude);
} catch (error) {
console.error('Error getting user location:', error);
setUserLat(null);
setUserLng(null);
}

Copilot uses AI. Check for mistakes.
}

async function fetchSortedByDistance() {
if (!userLat || !userLng) return;

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!userLat || !userLng) return; treats 0 as “missing”, which is incorrect for valid coordinates. Use explicit null/undefined checks (e.g., userLat == null || userLng == null) to decide whether coordinates are available.

Suggested change
if (!userLat || !userLng) return;
if (userLat == null || userLng == null) return;

Copilot uses AI. Check for mistakes.

try {
const response = await fetch('http://localhost:8000/api/study_spots/sort_by_distance', {
method: 'POST',
Comment on lines +33 to +34

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coding http://localhost:8000 will fail on physical devices and many emulator/simulator setups because localhost resolves to the device itself. Consider centralizing the API base URL (env/config) and using platform-appropriate host resolution (e.g., dev machine LAN IP / Android emulator 10.0.2.2).

Copilot uses AI. Check for mistakes.
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_lat: userLat,
user_lng: userLng
})
});

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchSortedByDistance doesn’t check response.ok before calling response.json(), so non-2xx responses (or non-JSON error bodies) can throw or silently produce confusing UI state. Handle non-OK responses explicitly (and consider surfacing the API error message).

Suggested change
});
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(
`Request failed with status ${response.status} ${response.statusText}${
errorText ? `: ${errorText}` : ''
}`,
);
}

Copilot uses AI. Check for mistakes.

const sortedSpots = await response.json();
setSpots(sortedSpots);
} catch (error) {
console.error('Error fetching sorted spots:', error);
}
}

useEffect(() => {
getUserLocation();
}, []);

useEffect(() => {
if (userLat && userLng) {
fetchSortedByDistance();
}
Comment on lines +53 to +56

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same falsy-check issue here: if (userLat && userLng) will skip when either coordinate is 0. Prefer explicit null/undefined checks before calling fetchSortedByDistance().

Copilot uses AI. Check for mistakes.
}, [userLat, userLng]);

return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
Expand Down Expand Up @@ -83,6 +131,8 @@ export default function HomeScreen() {
);
}



const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-location": "^55.1.2",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
Expand Down
1 change: 1 addition & 0 deletions frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
Expand Down
Binary file added instance/longhorn_studies.db
Binary file not shown.
Loading