Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Analytics: Updates to display locations by groups #2311

Merged
merged 3 commits into from
Dec 10, 2024
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
60 changes: 43 additions & 17 deletions platform/src/common/components/AQNumberCard/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,35 @@ const AQI_CATEGORY_MAP = {

const MAX_CARDS = 4;

// ====================== Utility Functions ====================== //

/**
* Generates trend data based on percentage difference
* @param {Object} averages - The averages object containing percentageDifference
* @returns {Object|null} - The trend data or null if not available
* Generates trend data based on percentage difference.
* @param {Object} averages - The averages object containing percentageDifference.
* @returns {Object|null} - The trend data or null if not available.
*/
const getTrendData = (averages) => {
const generateTrendData = (averages) => {
if (!averages?.percentageDifference) return null;

const percentageDifference = Math.abs(averages.percentageDifference);
const isIncreasing = averages.percentageDifference > 0;

let trendTooltip =
'No significant change in air quality compared to the previous week.';

if (isIncreasing) {
trendTooltip = `AQI has increased by ${percentageDifference}% compared to the previous week, indicating deteriorating air quality.`;
} else if (averages.percentageDifference < 0) {
trendTooltip = `AQI has decreased by ${percentageDifference}% compared to the previous week, indicating improving air quality.`;
}

return {
trendIcon: isIncreasing ? IconMap.trend2 : IconMap.trend1,
trendIcon: isIncreasing ? IconMap.trend2 : IconMap.trend1, // Reverted to existing icons
trendColor: isIncreasing
? 'text-red-700 bg-red-100'
: 'text-green-700 bg-green-100',
trendText: `${percentageDifference.toFixed(0)}%`,
trendTooltip: isIncreasing
? `Air quality has worsened by ${percentageDifference.toFixed(0)}%`
: `Air quality has improved by ${percentageDifference.toFixed(0)}%`,
trendText: `${percentageDifference}%`,
trendTooltip,
isIncreasing,
};
};
Expand All @@ -56,12 +65,20 @@ const getTrendData = (averages) => {
const TrendIndicator = React.memo(({ trendData }) => (
<Tooltip
content={trendData?.trendTooltip || 'No trend data available'}
className="w-52"
placement="top"
className="w-64"
>
<div
className={`shrink-0 px-2 py-1 rounded-xl text-xs flex items-center gap-1.5 ${
trendData ? trendData.trendColor : 'bg-gray-100 text-gray-500'
}`}
aria-label={
trendData
? trendData.isIncreasing
? 'Air quality has deteriorated compared to last week.'
: 'Air quality has improved compared to last week.'
: 'No trend data available.'
}
>
{trendData ? (
<>
Expand All @@ -86,6 +103,7 @@ TrendIndicator.propTypes = {
trendColor: PropTypes.string,
trendText: PropTypes.string,
trendTooltip: PropTypes.string,
isIncreasing: PropTypes.bool,
}),
};

Expand Down Expand Up @@ -123,7 +141,7 @@ const SiteCard = React.memo(

const AirQualityIcon = IconMap[statusKey];
const trendData = useMemo(
() => getTrendData(measurement?.averages),
() => generateTrendData(measurement?.averages),
[measurement],
);

Expand All @@ -134,7 +152,11 @@ const SiteCard = React.memo(

if (isTruncated) {
return (
<Tooltip content={site.name || 'No Location Data'} className="w-52">
<Tooltip
content={site.name || 'No Location Data'}
placement="top"
className="w-52"
>
<span className={`${baseClasses} inline-block`} ref={nameRef}>
{site.name || '---'}
</span>
Expand All @@ -153,6 +175,7 @@ const SiteCard = React.memo(
<button
className="w-full h-auto"
onClick={() => onOpenModal('inSights', [], site)}
aria-label={`View detailed insights for ${site.name || 'this location'}`}
>
<div className="w-full flex flex-col justify-between bg-white border border-gray-200 rounded-xl px-6 py-5 h-[220px] shadow-sm hover:shadow-md transition-shadow duration-200 ease-in-out cursor-pointer">
{/* Header Section */}
Expand All @@ -172,7 +195,7 @@ const SiteCard = React.memo(
<div>
<div className="flex items-center gap-2 mb-3">
<div className="p-1.5 bg-gray-100 rounded-full flex items-center justify-center">
<IconMap.wind className="w-3.5 h-3.5" />
<IconMap.wind className="text-gray-500" />
</div>
<div className="text-slate-400 text-sm font-medium">
{pollutantType === 'pm2_5' ? 'PM2.5' : 'PM10'}
Expand All @@ -194,7 +217,10 @@ const SiteCard = React.memo(
>
<div className="w-16 h-16 flex items-center justify-center">
{AirQualityIcon && (
<AirQualityIcon className="w-full h-full" />
<AirQualityIcon
className="w-full h-full"
aria-hidden="true"
/>
)}
</div>
</Tooltip>
Expand Down Expand Up @@ -224,10 +250,10 @@ SiteCard.propTypes = {
const AddLocationCard = React.memo(({ onOpenModal }) => (
<button
onClick={() => onOpenModal('addLocation')}
className="border-dashed border-2 border-blue-400 bg-blue-50 rounded-xl px-4 py-6 h-[220px] flex justify-center items-center text-blue-500 transition-transform transform hover:scale-95"
aria-label="Add Location"
className="border-dashed border-2 border-blue-400 bg-blue-50 rounded-xl px-4 py-6 h-[220px] flex justify-center items-center text-blue-500 transition-transform transform hover:scale-95 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Add a new location to monitor air quality"
>
+ Add location
+ Add Location
</button>
));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import LocationCard from '../components/LocationCard';
import { replaceUserPreferences } from '@/lib/store/services/account/UserDefaultsSlice';
import { setRefreshChart } from '@/lib/store/services/charts/ChartSlice';
import { getIndividualUserPreferences } from '@/lib/store/services/account/UserDefaultsSlice';
import { fetchSitesSummary } from '@/lib/store/services/sitesSummarySlice';

/**
* Header component for the Add Location modal.
Expand Down Expand Up @@ -34,6 +35,7 @@ const AddLocations = ({ onClose }) => {
const preferencesData = useSelector(
(state) => state.defaults.individual_preferences,
);
const chartData = useSelector((state) => state.chart);

// Local state management
const [selectedSites, setSelectedSites] = useState([]);
Expand All @@ -60,6 +62,15 @@ const AddLocations = ({ onClose }) => {
return user ? JSON.parse(user)?._id : null;
}, []);

/**
* Fetch sites summary whenever the selected organization changes.
*/
useEffect(() => {
if (chartData.organizationName) {
dispatch(fetchSitesSummary({ group: chartData.organizationName }));
}
}, [dispatch, chartData.organizationName]);
Comment on lines +65 to +72
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider enhancing the fetch implementation.

While the basic functionality is correct, consider these improvements:

  1. Add loading state management to prevent multiple concurrent requests
  2. Implement error handling for failed fetches
  3. Consider debouncing the fetch operation if organization changes are frequent
 useEffect(() => {
+  const controller = new AbortController();
+  
   if (chartData.organizationName) {
-    dispatch(fetchSitesSummary({ group: chartData.organizationName }));
+    dispatch(fetchSitesSummary({ 
+      group: chartData.organizationName,
+      signal: controller.signal 
+    })).catch(error => {
+      if (!error.name === 'AbortError') {
+        console.error('Failed to fetch sites:', error);
+      }
+    });
   }
+  
+  return () => controller.abort();
 }, [dispatch, chartData.organizationName]);

Committable suggestion skipped: line range outside the PR's diff.


// Extract selected site IDs from user preferences
const selectedSiteIds = useMemo(() => {
const firstPreference = preferencesData?.[0];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import WorldIcon from '@/icons/SideBar/world_Icon';
import CalibrateIcon from '@/icons/Analytics/calibrateIcon';
import FileTypeIcon from '@/icons/Analytics/fileTypeIcon';
Expand All @@ -21,6 +21,7 @@ import 'jspdf-autotable';
import { saveAs } from 'file-saver';
import CustomToast from '../../../Toast/CustomToast';
import { format } from 'date-fns';
import { fetchSitesSummary } from '@/lib/store/services/sitesSummarySlice';

/**
* Header component for the Download Data modal.
Expand Down Expand Up @@ -51,6 +52,7 @@ const getMimeType = (fileType) => {
* Allows users to select parameters and download air quality data accordingly.
*/
const DataDownload = ({ onClose }) => {
const dispatch = useDispatch();
const userInfo = useSelector((state) => state.login.userInfo);
const preferencesData = useSelector(
(state) => state.defaults.individual_preferences,
Expand All @@ -74,17 +76,20 @@ const DataDownload = ({ onClose }) => {
return preferencesData?.[0]?.selected_sites?.map((site) => site._id) || [];
}, [preferencesData]);

// Network options based on user groups
const NETWORK_OPTIONS =
userInfo?.groups?.map((network) => ({
id: network._id,
name: network.grp_title,
})) || [];
// Organization options based on user groups
const ORGANIZATION_OPTIONS = useMemo(
() =>
userInfo?.groups?.map((group) => ({
id: group._id,
name: group.grp_title,
})) || [],
[userInfo],
);

// Form data state
const [formData, setFormData] = useState({
title: { name: 'Untitled Report' },
network: NETWORK_OPTIONS[0] || { id: '', name: 'Default Network' },
organization: null,
dataType: DATA_TYPE_OPTIONS[0],
pollutant: POLLUTANT_OPTIONS[0],
duration: null,
Expand All @@ -95,15 +100,44 @@ const DataDownload = ({ onClose }) => {
const [edit, setEdit] = useState(false);

/**
* Clears all selected sites.
* Initialize default organization once ORGANIZATION_OPTIONS are available.
* Defaults to "airqo" if available; otherwise, selects the first organization.
*/
useEffect(() => {
if (ORGANIZATION_OPTIONS.length > 0 && !formData.organization) {
const airqoNetwork = ORGANIZATION_OPTIONS.find(
(group) => group.name.toLowerCase() === 'airqo',
);
setFormData((prevData) => ({
...prevData,
organization: airqoNetwork || ORGANIZATION_OPTIONS[0],
}));
}
}, [ORGANIZATION_OPTIONS, formData.organization]);

/**
* Fetch sites summary whenever the selected organization changes.
*/
useEffect(() => {
if (formData.organization) {
dispatch(fetchSitesSummary({ group: formData.organization.name }));
}
}, [dispatch, formData.organization]);

/**
* Clears all selected sites and resets form data.
*/
const handleClearSelection = useCallback(() => {
setClearSelected(true);
setSelectedSites([]);
// Reset form data after submission
const airqoNetwork = ORGANIZATION_OPTIONS.find(
(group) => group.name.toLowerCase() === 'airqo',
);
setFormData({
title: { name: 'Untitled Report' },
network: NETWORK_OPTIONS[0] || { id: '', name: 'Default Network' },
organization: airqoNetwork ||
ORGANIZATION_OPTIONS[0] || { id: '', name: 'Default Network' },
dataType: DATA_TYPE_OPTIONS[0],
pollutant: POLLUTANT_OPTIONS[0],
duration: null,
Expand All @@ -112,7 +146,7 @@ const DataDownload = ({ onClose }) => {
});
// Reset clearSelected flag in the next tick
setTimeout(() => setClearSelected(false), 0);
}, []);
}, [ORGANIZATION_OPTIONS]);

/**
* Handles the selection of form options.
Expand All @@ -123,6 +157,19 @@ const DataDownload = ({ onClose }) => {
setFormData((prevData) => ({ ...prevData, [id]: option }));
}, []);

/**
* Toggles the selection of a site.
* @param {object} site - The site to toggle.
*/
const handleToggleSite = useCallback((site) => {
setSelectedSites((prev) => {
const isSelected = prev.some((s) => s._id === site._id);
return isSelected
? prev.filter((s) => s._id !== site._id)
: [...prev, site];
});
}, []);

/**
* Handles the submission of the form.
* Prepares data and calls the exportDataApi to download the data.
Expand Down Expand Up @@ -165,8 +212,9 @@ const DataDownload = ({ onClose }) => {
return null;
};

const frequencyLower = formData.frequency.name.toLowerCase();
const durationError = validateDuration(
formData.frequency.name.toLowerCase(),
frequencyLower,
startDate,
endDate,
);
Expand All @@ -184,13 +232,13 @@ const DataDownload = ({ onClose }) => {
startDateTime: format(startDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
endDateTime: format(endDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"),
sites: selectedSites.map((site) => site._id),
network: formData.network.name,
network: formData.organization.name,
datatype:
formData.dataType.name.toLowerCase() === 'calibrated data'
? 'calibrated'
: 'raw',
pollutants: [formData.pollutant.name.toLowerCase().replace('.', '_')],
frequency: formData.frequency.name.toLowerCase(),
frequency: frequencyLower,
downloadType: formData.fileType.name.toLowerCase(),
outputFormat: 'airqo-standard',
minimum: true,
Expand Down Expand Up @@ -248,27 +296,17 @@ const DataDownload = ({ onClose }) => {
onClose();
} catch (error) {
console.error('Error downloading data:', error);
setFormError('An error occurred while downloading. Please try again.');
setFormError(
error.message ||
'An error occurred while downloading. Please try again.',
);
} finally {
Comment on lines +299 to +302
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid Displaying Technical Error Messages to Users

In setFormError, using error.message directly can expose technical details to the user. It's better to display a generic error message to enhance user experience and security.

Apply this diff to implement the change:

-setFormError(
-  error.message ||
-    'An error occurred while downloading. Please try again.',
-);
+setFormError('An error occurred while downloading. Please try again.');
📝 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
setFormError(
error.message ||
'An error occurred while downloading. Please try again.',
);
setFormError('An error occurred while downloading. Please try again.');

setDownloadLoading(false);
}
},
[formData, selectedSites, handleClearSelection, onClose],
[formData, selectedSites, handleClearSelection, fetchData, onClose],
);

/**
* Toggles the selection of a site.
* @param {object} site - The site to toggle.
*/
const handleToggleSite = useCallback((site) => {
setSelectedSites((prev) => {
const isSelected = prev.some((s) => s._id === site._id);
return isSelected
? prev.filter((s) => s._id !== site._id)
: [...prev, site];
});
}, []);

return (
<>
{/* Section 1: Form */}
Expand All @@ -295,11 +333,11 @@ const DataDownload = ({ onClose }) => {
handleOptionSelect={handleOptionSelect}
/>
<CustomFields
title="Network"
options={NETWORK_OPTIONS}
id="network"
title="Organization"
options={ORGANIZATION_OPTIONS}
id="organization"
icon={<WorldIcon width={16} height={16} fill="#000" />}
defaultOption={formData.network}
defaultOption={formData.organization}
handleOptionSelect={handleOptionSelect}
textFormat="uppercase"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ const MoreInsights = () => {
network: chartData.organizationName,
pollutants: [chartData.pollutionType],
frequency: frequency,
datatype: 'raw',
datatype: 'calibrated',
downloadType: 'csv',
outputFormat: 'airqo-standard',
minimum: true,
Expand Down Expand Up @@ -361,7 +361,7 @@ const MoreInsights = () => {
{/* Actions: Download Data */}
<div>
<Tooltip
content={'Download data in CSV format'}
content={'Download calibrated data in CSV format'}
className="w-auto text-center"
>
<TabButtons
Expand Down
Loading
Loading