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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Dashboard Backend API URL
# Change this to match your backend server address
VITE_API_BASE_URL=http://localhost:5000
127 changes: 127 additions & 0 deletions src/hooks/useCampaigns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchCampaigns, fetchDatasetsByCampaign } from '@/services/api';
import type { Campaign, Dataset, ApiError } from '@/types/api';

/**
* State shape for the useCampaigns hook
*/
interface CampaignsState {
/** List of fetched campaigns */
campaigns: Campaign[];
/** Whether the initial fetch is in progress */
loading: boolean;
/** Error message if the fetch failed */
error: string | null;
/** Refetch campaigns from the API */
refetch: () => void;
}

/**
* Custom hook to fetch campaigns from the backend API.
*
* Gracefully handles the case where the backend is not yet available
* by catching errors and setting a user-friendly error message.
* This allows the rest of the app to continue functioning with
* local fixture data while the backend is being developed.
*
* @param {number} page - Page number for pagination (default: 1)
* @param {number} pageSize - Number of items per page (default: 50)
* @returns {CampaignsState} Campaigns data, loading state, error, and refetch function
*/
export const useCampaigns = (page = 1, pageSize = 50): CampaignsState => {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const loadCampaigns = useCallback(async () => {
setLoading(true);
setError(null);

try {
const response = await fetchCampaigns(page, pageSize);
setCampaigns(response.data);
} catch (err) {
const apiError = err as ApiError;
const message =
apiError.status === 0 || apiError.message?.includes('fetch')
? 'Backend not available — using local data. Start the dashboard_server to enable live data.'
: `Failed to fetch campaigns: ${apiError.message}`;
setError(message);
setCampaigns([]);
console.warn('[useCampaigns]', message);
} finally {
setLoading(false);
}
}, [page, pageSize]);

useEffect(() => {
loadCampaigns();
}, [loadCampaigns]);
Comment on lines +36 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against stale campaign responses.

Line 36-59 has no cancellation/version guard, so an older page/pageSize request can resolve after a newer one and overwrite the latest state. That makes pagination/refetch nondeterministic and can also update state after unmount.

Suggested fix
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
 import { fetchCampaigns, fetchDatasetsByCampaign } from '@/services/api';
 import type { Campaign, Dataset, ApiError } from '@/types/api';
@@
 export const useCampaigns = (page = 1, pageSize = 50): CampaignsState => {
   const [campaigns, setCampaigns] = useState<Campaign[]>([]);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
+  const requestIdRef = useRef(0);
 
   const loadCampaigns = useCallback(async () => {
+    const requestId = ++requestIdRef.current;
     setLoading(true);
     setError(null);
 
     try {
       const response = await fetchCampaigns(page, pageSize);
-      setCampaigns(response.data);
+      if (requestId === requestIdRef.current) {
+        setCampaigns(response.data);
+      }
     } catch (err) {
       const apiError = err as ApiError;
       const message =
         apiError.status === 0 || apiError.message?.includes('fetch')
           ? 'Backend not available — using local data. Start the dashboard_server to enable live data.'
           : `Failed to fetch campaigns: ${apiError.message}`;
-      setError(message);
-      setCampaigns([]);
-      console.warn('[useCampaigns]', message);
+      if (requestId === requestIdRef.current) {
+        setError(message);
+        setCampaigns([]);
+        console.warn('[useCampaigns]', message);
+      }
     } finally {
-      setLoading(false);
+      if (requestId === requestIdRef.current) {
+        setLoading(false);
+      }
     }
   }, [page, pageSize]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useCampaigns.ts` around lines 36 - 59, The loadCampaigns function
can suffer from race conditions where an older fetch resolves after a newer one
or after unmount; add a request-version guard using a ref (e.g., requestIdRef)
that you increment before each fetch and capture into a local currentId; after
awaiting fetchCampaigns(page, pageSize) and in the catch block only call
setCampaigns, setError, and setLoading if requestIdRef.current === currentId;
also increment/invalidate the ref in a cleanup effect to prevent state updates
after unmount; reference loadCampaigns, fetchCampaigns, page, pageSize,
setCampaigns, setError, setLoading and the useEffect that calls loadCampaigns
when applying this change.


return { campaigns, loading, error, refetch: loadCampaigns };
};

/**
* State shape for the useCampaignDatasets hook
*/
interface CampaignDatasetsState {
/** List of datasets for the selected campaign */
datasets: Dataset[];
/** Whether the fetch is in progress */
loading: boolean;
/** Error message if the fetch failed */
error: string | null;
}

/**
* Custom hook to fetch datasets for a specific campaign.
*
* @param {number | null} campaignId - Campaign ID to fetch datasets for. Pass null to skip.
* @returns {CampaignDatasetsState} Datasets data, loading state, and error
*/
export const useCampaignDatasets = (
campaignId: number | null,
): CampaignDatasetsState => {
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (campaignId === null) {
setDatasets([]);
return;
}

let cancelled = false;

const loadDatasets = async () => {
setLoading(true);
setError(null);

try {
const data = await fetchDatasetsByCampaign(campaignId);
if (!cancelled) {
setDatasets(data);
}
} catch (err) {
if (!cancelled) {
const apiError = err as ApiError;
setError(`Failed to fetch datasets: ${apiError.message}`);
setDatasets([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};

loadDatasets();

return () => {
cancelled = true;
};
}, [campaignId]);
Comment on lines +89 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear loading/error state when no campaign is selected.

When campaignId becomes null, the effect clears datasets but leaves loading and error untouched. If selection is cleared mid-fetch, the spinner and stale error can stick around even though there is no active request.

Suggested fix
     if (campaignId === null) {
       setDatasets([]);
+      setError(null);
+      setLoading(false);
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (campaignId === null) {
setDatasets([]);
return;
}
let cancelled = false;
const loadDatasets = async () => {
setLoading(true);
setError(null);
try {
const data = await fetchDatasetsByCampaign(campaignId);
if (!cancelled) {
setDatasets(data);
}
} catch (err) {
if (!cancelled) {
const apiError = err as ApiError;
setError(`Failed to fetch datasets: ${apiError.message}`);
setDatasets([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadDatasets();
return () => {
cancelled = true;
};
}, [campaignId]);
useEffect(() => {
if (campaignId === null) {
setDatasets([]);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
const loadDatasets = async () => {
setLoading(true);
setError(null);
try {
const data = await fetchDatasetsByCampaign(campaignId);
if (!cancelled) {
setDatasets(data);
}
} catch (err) {
if (!cancelled) {
const apiError = err as ApiError;
setError(`Failed to fetch datasets: ${apiError.message}`);
setDatasets([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadDatasets();
return () => {
cancelled = true;
};
}, [campaignId]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useCampaigns.ts` around lines 89 - 124, When campaignId is null the
effect currently clears datasets but leaves loading and error state set, so
update the early-return branch in the useEffect to also reset loading and error:
call setDatasets([]), setLoading(false), and setError(null) when campaignId ===
null. Also ensure the cancellation logic around loadDatasets (the cancelled flag
and finally block that calls setLoading(false)) remains intact so any in-flight
fetch still clears loading and avoids setting state after unmount or selection
change; reference useEffect, campaignId, loadDatasets, cancelled, setDatasets,
setLoading, and setError.


return { datasets, loading, error };
};
106 changes: 106 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { API_V1_URL } from './config';
import type { Campaign, Dataset, PaginatedResponse, ApiError } from '@/types/api';

/**
* API service for fetching campaigns and datasets from the backend.
*
* All methods return typed promises. When the backend endpoints
* are not yet available, errors are caught and logged with
* meaningful messages so the frontend degrades gracefully.
*
* Expected backend endpoints (to be implemented):
* - GET /api/v1/campaigns → list all campaigns
* - GET /api/v1/campaigns/:id → get a single campaign
* - GET /api/v1/campaigns/:id/datasets → list datasets for a campaign
* - GET /api/v1/datasets → list all datasets
* - GET /api/v1/datasets/:id → get a single dataset
*/

/**
* Generic fetch wrapper with error handling
*/
async function apiFetch<T>(endpoint: string): Promise<T> {
const url = `${API_V1_URL}${endpoint}`;

const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
Comment on lines +25 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/services/api.ts | head -50

Repository: OpenSourceFellows/map_dashboard_hackathon

Length of output: 1888


🏁 Script executed:

cat -n src/services/api.ts | tail -100

Repository: OpenSourceFellows/map_dashboard_hackathon

Length of output: 3453


🏁 Script executed:

wc -l src/services/api.ts

Repository: OpenSourceFellows/map_dashboard_hackathon

Length of output: 109


Remove Content-Type header from the GET wrapper—it triggers unnecessary CORS preflights.

GET requests have no body, so Content-Type: application/json is unnecessary. The application/json value for Content-Type is not a CORS-simple header and will cause preflights on cross-origin requests. Keep Accept for all reads; add Content-Type only when the request includes a body.

Suggested fix
   const response = await fetch(url, {
     headers: {
-      'Content-Type': 'application/json',
       'Accept': 'application/json',
     },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
},
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/api.ts` around lines 25 - 30, Remove the unnecessary
'Content-Type' header from the GET fetch wrapper so it doesn't trigger CORS
preflights: in the code that calls fetch(url, { headers: { 'Content-Type':
'application/json', 'Accept': 'application/json' } }) remove the 'Content-Type'
entry and keep only 'Accept'. If you have a shared request helper, update the
logic around the fetch call (the code creating response via fetch(...) and the
headers object) to add 'Content-Type: application/json' only when a request body
is present (e.g., POST/PUT paths) rather than for GETs.


if (!response.ok) {
const error: ApiError = {
status: response.status,
message: response.statusText,
};

try {
const body = await response.json();
error.message = body.message || error.message;
error.details = body.details;
} catch {
// Response body wasn't JSON — keep the status text
}

throw error;
}

return response.json() as Promise<T>;
}

// ─── Campaigns ───────────────────────────────────────────────────────────

/**
* Fetch all campaigns (paginated)
* GET /api/v1/campaigns?page=1&pageSize=50
*/
export async function fetchCampaigns(
page = 1,
pageSize = 50,
): Promise<PaginatedResponse<Campaign>> {
return apiFetch<PaginatedResponse<Campaign>>(
`/campaigns?page=${page}&pageSize=${pageSize}`,
);
}

/**
* Fetch a single campaign by ID
* GET /api/v1/campaigns/:id
*/
export async function fetchCampaignById(id: number): Promise<Campaign> {
return apiFetch<Campaign>(`/campaigns/${id}`);
}

// ─── Datasets ────────────────────────────────────────────────────────────

/**
* Fetch all datasets (paginated)
* GET /api/v1/datasets?page=1&pageSize=50
*/
export async function fetchDatasets(
page = 1,
pageSize = 50,
): Promise<PaginatedResponse<Dataset>> {
return apiFetch<PaginatedResponse<Dataset>>(
`/datasets?page=${page}&pageSize=${pageSize}`,
);
}

/**
* Fetch a single dataset by ID
* GET /api/v1/datasets/:id
*/
export async function fetchDatasetById(id: number): Promise<Dataset> {
return apiFetch<Dataset>(`/datasets/${id}`);
}

/**
* Fetch all datasets belonging to a specific campaign
* GET /api/v1/campaigns/:campaignId/datasets
*/
export async function fetchDatasetsByCampaign(
campaignId: number,
): Promise<Dataset[]> {
return apiFetch<Dataset[]>(`/campaigns/${campaignId}/datasets`);
}
11 changes: 11 additions & 0 deletions src/services/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* API configuration
* Centralizes the backend URL so it's easy to change per environment.
*/

/** Base URL for the dashboard backend API */
export const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';

/** Full prefix for v1 API endpoints */
export const API_V1_URL = `${API_BASE_URL}/api/v1`;
81 changes: 81 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Type definitions for Campaign and Dataset entities
* returned by the dashboard_server backend.
*
* These types are derived from the backend's Sequelize models
* and the expected API response shape. They will be refined
* once the backend endpoints are finalized.
*/

/** A geographic coordinate pair */
export interface LatLng {
lat: number;
lng: number;
}

/** GeoJSON-compatible geometry for map rendering */
export interface CampaignGeometry {
type: 'Point' | 'Polygon' | 'MultiPolygon' | 'LineString';
coordinates: number[] | number[][] | number[][][] | number[][][][];
}

/**
* A Campaign represents a conservation/land project.
* Campaigns are the top-level entity that users see on the map.
*/
export interface Campaign {
id: number;
name: string;
description: string;
status: 'active' | 'planning' | 'completed' | 'on_hold';
partner: string;
location: string;
acreage: number;
watershed: string;
projectType: string;
fundingSource: string;
startDate: string;
endDate?: string;
geometry?: CampaignGeometry;
center?: LatLng;
createdAt: string;
updatedAt: string;
}

/**
* A Dataset is a data layer associated with a Campaign.
* Datasets contain the actual geospatial features (GeoJSON)
* that are rendered on the map.
*/
export interface Dataset {
id: number;
campaignId: number;
name: string;
description: string;
type: 'species' | 'water' | 'soil' | 'events' | 'boundary';
/** GeoJSON FeatureCollection as a raw object */
geojson: GeoJSON.FeatureCollection;
visible: boolean;
createdAt: string;
updatedAt: string;
}

/**
* Standard paginated API response wrapper
*/
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}

/**
* Standard API error response
*/
export interface ApiError {
status: number;
message: string;
details?: string;
}