/** * API client for Maps V2 * Wraps all API endpoints with consistent error handling */ export class ApiClient { constructor(apiKey) { this.apiKey = apiKey this.baseURL = '/api/v1' } /** * Fetch points for date range (paginated) * @param {Object} options - { start_at, end_at, page, per_page } * @returns {Promise} { points, currentPage, totalPages } */ async fetchPoints({ start_at, end_at, page = 1, per_page = 1000 }) { const params = new URLSearchParams({ start_at, end_at, page: page.toString(), per_page: per_page.toString(), slim: 'true' }) const response = await fetch(`${this.baseURL}/points?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch points: ${response.statusText}`) } const points = await response.json() return { points, currentPage: parseInt(response.headers.get('X-Current-Page') || '1'), totalPages: parseInt(response.headers.get('X-Total-Pages') || '1') } } /** * Fetch all points for date range (handles pagination) * @param {Object} options - { start_at, end_at, onProgress } * @returns {Promise} All points */ async fetchAllPoints({ start_at, end_at, onProgress = null }) { const allPoints = [] let page = 1 let totalPages = 1 do { const { points, currentPage, totalPages: total } = await this.fetchPoints({ start_at, end_at, page, per_page: 1000 }) allPoints.push(...points) totalPages = total page++ if (onProgress) { // Avoid division by zero - if no pages, progress is 100% const progress = totalPages > 0 ? currentPage / totalPages : 1.0 onProgress({ loaded: allPoints.length, currentPage, totalPages, progress }) } } while (page <= totalPages) return allPoints } /** * Fetch visits for date range */ async fetchVisits({ start_at, end_at }) { const params = new URLSearchParams({ start_at, end_at }) const response = await fetch(`${this.baseURL}/visits?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch visits: ${response.statusText}`) } return response.json() } /** * Fetch places optionally filtered by tags */ async fetchPlaces({ tag_ids = [] } = {}) { const params = new URLSearchParams() if (tag_ids && tag_ids.length > 0) { tag_ids.forEach(id => params.append('tag_ids[]', id)) } const url = `${this.baseURL}/places${params.toString() ? '?' + params.toString() : ''}` const response = await fetch(url, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch places: ${response.statusText}`) } return response.json() } /** * Fetch photos for date range */ async fetchPhotos({ start_at, end_at }) { // Photos API uses start_date/end_date parameters // Pass dates as-is (matching V1 behavior) const params = new URLSearchParams({ start_date: start_at, end_date: end_at }) const url = `${this.baseURL}/photos?${params}` const response = await fetch(url, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch photos: ${response.statusText}`) } return response.json() } /** * Fetch areas */ async fetchAreas() { const response = await fetch(`${this.baseURL}/areas`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch areas: ${response.statusText}`) } return response.json() } /** * Fetch single area by ID * @param {number} areaId - Area ID */ async fetchArea(areaId) { const response = await fetch(`${this.baseURL}/areas/${areaId}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch area: ${response.statusText}`) } return response.json() } /** * Fetch tracks */ async fetchTracks() { const response = await fetch(`${this.baseURL}/tracks`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch tracks: ${response.statusText}`) } return response.json() } /** * Create area * @param {Object} area - Area data */ async createArea(area) { const response = await fetch(`${this.baseURL}/areas`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ area }) }) if (!response.ok) { throw new Error(`Failed to create area: ${response.statusText}`) } return response.json() } /** * Delete area by ID * @param {number} areaId - Area ID */ async deleteArea(areaId) { const response = await fetch(`${this.baseURL}/areas/${areaId}`, { method: 'DELETE', headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to delete area: ${response.statusText}`) } return response.json() } /** * Fetch points within a geographic area * @param {Object} options - { start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude } * @returns {Promise} Points within the area */ async fetchPointsInArea({ start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }) { const params = new URLSearchParams({ start_at, end_at, min_longitude: min_longitude.toString(), max_longitude: max_longitude.toString(), min_latitude: min_latitude.toString(), max_latitude: max_latitude.toString(), per_page: '10000' // Get all points in area (up to 10k) }) const response = await fetch(`${this.baseURL}/points?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch points in area: ${response.statusText}`) } return response.json() } /** * Fetch visits within a geographic area * @param {Object} options - { start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng } * @returns {Promise} Visits within the area */ async fetchVisitsInArea({ start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }) { const params = new URLSearchParams({ start_at, end_at, selection: 'true', sw_lat: sw_lat.toString(), sw_lng: sw_lng.toString(), ne_lat: ne_lat.toString(), ne_lng: ne_lng.toString() }) const response = await fetch(`${this.baseURL}/visits?${params}`, { headers: this.getHeaders() }) if (!response.ok) { throw new Error(`Failed to fetch visits in area: ${response.statusText}`) } return response.json() } /** * Bulk delete points * @param {Array} pointIds - Array of point IDs to delete * @returns {Promise} { message, count } */ async bulkDeletePoints(pointIds) { const response = await fetch(`${this.baseURL}/points/bulk_destroy`, { method: 'DELETE', headers: this.getHeaders(), body: JSON.stringify({ point_ids: pointIds }) }) if (!response.ok) { throw new Error(`Failed to delete points: ${response.statusText}`) } return response.json() } /** * Update visit status (confirm/decline) * @param {number} visitId - Visit ID * @param {string} status - 'confirmed' or 'declined' * @returns {Promise} Updated visit */ async updateVisitStatus(visitId, status) { const response = await fetch(`${this.baseURL}/visits/${visitId}`, { method: 'PATCH', headers: this.getHeaders(), body: JSON.stringify({ visit: { status } }) }) if (!response.ok) { throw new Error(`Failed to update visit status: ${response.statusText}`) } return response.json() } /** * Merge multiple visits * @param {Array} visitIds - Array of visit IDs to merge * @returns {Promise} Merged visit */ async mergeVisits(visitIds) { const response = await fetch(`${this.baseURL}/visits/merge`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ visit_ids: visitIds }) }) if (!response.ok) { throw new Error(`Failed to merge visits: ${response.statusText}`) } return response.json() } /** * Bulk update visit status * @param {Array} visitIds - Array of visit IDs to update * @param {string} status - 'confirmed' or 'declined' * @returns {Promise} Update result */ async bulkUpdateVisits(visitIds, status) { const response = await fetch(`${this.baseURL}/visits/bulk_update`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ visit_ids: visitIds, status }) }) if (!response.ok) { throw new Error(`Failed to bulk update visits: ${response.statusText}`) } return response.json() } getHeaders() { return { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } } }