diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 69e960c..0b7820c 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,236 +1,737 @@
"use client"
-import type React from "react"
-import Link from "next/link"
+
+import { useEffect, useMemo, useState } from "react"
import Image from "next/image"
-import { ArrowRight, MapPin, Wind, BarChart3, BrainCircuit, Shield, Database, Cpu, LineChart } from "lucide-react"
+import Link from "next/link"
+import {
+ ArrowRight,
+ BarChart3,
+ BrainCircuit,
+ ChevronRight,
+ Locate,
+ LocateFixed,
+ Map as MapIcon,
+ MapPin,
+ Pause,
+ Play,
+ Radio,
+ ShieldCheck,
+ Sparkles,
+ Wind,
+ X,
+} from "lucide-react"
import Navigation from "@/components/navigation/navigation"
-import { FeatureCard } from "@/components/feature-card"
+import {
+ getDailyForecastCollection,
+ getMapNodes,
+ type DailyForecastEntry,
+ type DailyForecastResponse,
+ type MapNode,
+} from "@/services/apiService"
+import {
+ isBrowserApiCacheFresh,
+ readBrowserApiCache,
+ writeBrowserApiCache,
+ type BrowserApiCacheEntry,
+} from "@/lib/browserApiCache"
+
+type ForecastTickerItem = {
+ id: string
+ siteName: string
+ country: string
+ forecast: DailyForecastEntry
+}
+
+type LocationState = "idle" | "requesting" | "ready" | "denied" | "unavailable"
+
+const FORECAST_TICKER_COUNTRY_KEY = "daily-forecast-country"
+const FORECAST_TICKER_LOCATION_STATE_KEY = "daily-forecast-location-state"
+const FORECAST_TICKER_ACTIVE_KEY = "daily-forecast-active"
+const FORECAST_TICKER_VISIBLE_KEY = "daily-forecast-visible"
+const FORECAST_TICKER_COORDINATES_KEY = "daily-forecast-coordinates"
+const DAILY_FORECAST_CACHE_KEY = "map-daily-forecast"
+const DAILY_FORECAST_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000
+
+type MapboxCountryResponse = {
+ features?: Array<{
+ properties?: {
+ name?: string
+ context?: {
+ country?: {
+ name?: string
+ }
+ }
+ }
+ }>
+}
+
+const capabilityCards = [
+ {
+ title: "Explore live air quality",
+ description: "See monitoring sites, pollution levels, heatmaps, and forecasts in one interactive view.",
+ href: "/map",
+ image: "/images/homemap.webp",
+ icon: MapIcon,
+ accent: "bg-blue-600",
+ },
+ {
+ title: "Plan monitoring networks",
+ description: "Use spatial intelligence to identify high-value locations for new air quality sensors.",
+ href: "/locate",
+ image: "/images/model/locate.webp",
+ icon: LocateFixed,
+ accent: "bg-emerald-600",
+ },
+ {
+ title: "Understand pollution sources",
+ description: "Combine land use, satellite evidence, and nearby source context for clearer decisions.",
+ href: "/categorize",
+ image: "/images/model/categorisemap.webp",
+ icon: BrainCircuit,
+ accent: "bg-violet-600",
+ },
+]
+
+const workflowSteps = [
+ {
+ icon: Radio,
+ label: "Observe",
+ title: "Measurements arrive",
+ description: "AirQo monitoring networks provide a continuous view of changing air quality.",
+ },
+ {
+ icon: BrainCircuit,
+ label: "Understand",
+ title: "AI finds the signal",
+ description: "Models clean, enrich, forecast, and explain patterns hidden in environmental data.",
+ },
+ {
+ icon: ShieldCheck,
+ label: "Act",
+ title: "Teams make decisions",
+ description: "Clear maps, reports, and recommendations turn complex evidence into practical action.",
+ },
+]
+
+function normalizeColor(color?: string) {
+ if (!color) return "#2563eb"
+ return color.startsWith("#") ? color : `#${color}`
+}
+
+function getAqiImageByCategory(category?: string) {
+ switch ((category || "").toLowerCase()) {
+ case "good":
+ return "/images/GoodAir.png"
+ case "moderate":
+ return "/images/Moderate.png"
+ case "unhealthy for sensitive groups":
+ return "/images/UnhealthySG.png"
+ case "unhealthy":
+ return "/images/Unhealthy.png"
+ case "very unhealthy":
+ return "/images/VeryUnhealthy.png"
+ case "hazardous":
+ return "/images/Hazardous.png"
+ default:
+ return "/images/Invalid.png"
+ }
+}
+
+function formatForecastDate(value: string) {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return "Daily outlook"
+ return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
+}
+
+function getDistanceInKm(latitude: number, longitude: number, node: MapNode) {
+ const siteLatitude = node.siteDetails?.approximate_latitude
+ const siteLongitude = node.siteDetails?.approximate_longitude
+ if (typeof siteLatitude !== "number" || typeof siteLongitude !== "number") return Number.POSITIVE_INFINITY
+
+ const earthRadiusKm = 6371
+ const latitudeDelta = ((siteLatitude - latitude) * Math.PI) / 180
+ const longitudeDelta = ((siteLongitude - longitude) * Math.PI) / 180
+ const originLatitude = (latitude * Math.PI) / 180
+ const siteLatitudeRadians = (siteLatitude * Math.PI) / 180
+ const value =
+ Math.sin(latitudeDelta / 2) ** 2 +
+ Math.cos(originLatitude) * Math.cos(siteLatitudeRadians) * Math.sin(longitudeDelta / 2) ** 2
+
+ return earthRadiusKm * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value))
+}
+
+function getCoordinatesForCountry(country: string, mapNodes: MapNode[]) {
+ const matchingNodes = mapNodes.filter(
+ (node) =>
+ node.siteDetails?.country?.trim().toLowerCase() === country.trim().toLowerCase() &&
+ typeof node.siteDetails.approximate_latitude === "number" &&
+ typeof node.siteDetails.approximate_longitude === "number",
+ )
+ if (!matchingNodes.length) return null
+
+ return {
+ latitude:
+ matchingNodes.reduce((sum, node) => sum + node.siteDetails.approximate_latitude!, 0) /
+ matchingNodes.length,
+ longitude:
+ matchingNodes.reduce((sum, node) => sum + node.siteDetails.approximate_longitude!, 0) /
+ matchingNodes.length,
+ }
+}
+
+async function resolveCountry(latitude: number, longitude: number, mapNodes: MapNode[]) {
+ const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
+ if (mapboxToken) {
+ const url = new URL("https://api.mapbox.com/search/geocode/v6/reverse")
+ url.searchParams.set("longitude", String(longitude))
+ url.searchParams.set("latitude", String(latitude))
+ url.searchParams.set("types", "country")
+ url.searchParams.set("limit", "1")
+ url.searchParams.set("access_token", mapboxToken)
+
+ try {
+ const response = await fetch(url, { cache: "no-store" })
+ if (response.ok) {
+ const data = (await response.json()) as MapboxCountryResponse
+ const country =
+ data.features?.[0]?.properties?.name ||
+ data.features?.[0]?.properties?.context?.country?.name
+ if (country) return country
+ }
+ } catch (error) {
+ console.error("Unable to reverse geocode visitor country:", error)
+ }
+ }
+
+ return mapNodes
+ .filter((node) => Boolean(node.siteDetails?.country))
+ .reduce<{ node: MapNode; distance: number } | null>((nearest, node) => {
+ const distance = getDistanceInKm(latitude, longitude, node)
+ return !nearest || distance < nearest.distance ? { node, distance } : nearest
+ }, null)
+ ?.node.siteDetails?.country?.trim()
+}
+
+function DailyForecastTicker() {
+ const [collection, setCollection] = useState
(null)
+ const [mapNodes, setMapNodes] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [locationState, setLocationState] = useState("idle")
+ const [visitorCountry, setVisitorCountry] = useState("")
+ const [visitorCoordinates, setVisitorCoordinates] = useState<{ latitude: number; longitude: number } | null>(null)
+ const [isTickerActive, setIsTickerActive] = useState(true)
+ const [isTickerVisible, setIsTickerVisible] = useState(true)
+ const [preferencesLoaded, setPreferencesLoaded] = useState(false)
+
+ useEffect(() => {
+ const savedCountry = window.localStorage.getItem(FORECAST_TICKER_COUNTRY_KEY)?.trim() || ""
+ const savedLocationState = window.localStorage.getItem(FORECAST_TICKER_LOCATION_STATE_KEY)
+ const savedActive = window.localStorage.getItem(FORECAST_TICKER_ACTIVE_KEY)
+ const savedVisible = window.localStorage.getItem(FORECAST_TICKER_VISIBLE_KEY)
+ const savedCoordinates = window.localStorage.getItem(FORECAST_TICKER_COORDINATES_KEY)
+
+ if (savedCountry) {
+ setVisitorCountry(savedCountry)
+ setLocationState("ready")
+ } else if (savedLocationState === "denied" || savedLocationState === "unavailable") {
+ setLocationState(savedLocationState)
+ }
+
+ if (savedActive !== null) setIsTickerActive(savedActive === "true")
+ if (savedVisible !== null) setIsTickerVisible(savedVisible === "true")
+ if (savedCoordinates) {
+ try {
+ const coordinates = JSON.parse(savedCoordinates) as { latitude?: number; longitude?: number }
+ if (typeof coordinates.latitude === "number" && typeof coordinates.longitude === "number") {
+ setVisitorCoordinates({ latitude: coordinates.latitude, longitude: coordinates.longitude })
+ }
+ } catch {
+ window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY)
+ }
+ }
+ setPreferencesLoaded(true)
+ }, [])
+
+ useEffect(() => {
+ if (!preferencesLoaded) return
+ window.localStorage.setItem(FORECAST_TICKER_ACTIVE_KEY, String(isTickerActive))
+ }, [isTickerActive, preferencesLoaded])
+
+ useEffect(() => {
+ if (!preferencesLoaded) return
+ window.localStorage.setItem(FORECAST_TICKER_VISIBLE_KEY, String(isTickerVisible))
+ }, [isTickerVisible, preferencesLoaded])
+
+ useEffect(() => {
+ let active = true
+
+ const loadDailyForecast = async () => {
+ const cached = await readBrowserApiCache>(DAILY_FORECAST_CACHE_KEY)
+ if (
+ isBrowserApiCacheFresh(cached, DAILY_FORECAST_CACHE_MAX_AGE_MS) &&
+ cached.data.forecasts?.length
+ ) {
+ return cached.data
+ }
+
+ const forecastData = await getDailyForecastCollection()
+ if (forecastData?.forecasts?.length) {
+ writeBrowserApiCache(DAILY_FORECAST_CACHE_KEY, forecastData).catch((error) => {
+ console.warn("Unable to cache daily forecast ticker data:", error)
+ })
+ }
+ return forecastData
+ }
+
+ Promise.all([loadDailyForecast(), getMapNodes()])
+ .then(([forecastData, nodes]) => {
+ if (!active) return
+ setCollection(forecastData)
+ setMapNodes(nodes || [])
+ })
+ .finally(() => {
+ if (active) setLoading(false)
+ })
+
+ return () => {
+ active = false
+ }
+ }, [])
+
+ function requestLocation() {
+ if (!navigator.geolocation) {
+ setLocationState("unavailable")
+ window.localStorage.removeItem(FORECAST_TICKER_COUNTRY_KEY)
+ window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY)
+ window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "unavailable")
+ return
+ }
+
+ setLocationState("requesting")
+ navigator.geolocation.getCurrentPosition(
+ async ({ coords }) => {
+ const country = await resolveCountry(coords.latitude, coords.longitude, mapNodes)
+ if (country) {
+ const coordinates = { latitude: coords.latitude, longitude: coords.longitude }
+ setVisitorCountry(country)
+ setVisitorCoordinates(coordinates)
+ setLocationState("ready")
+ window.localStorage.setItem(FORECAST_TICKER_COUNTRY_KEY, country)
+ window.localStorage.setItem(FORECAST_TICKER_COORDINATES_KEY, JSON.stringify(coordinates))
+ window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "ready")
+ } else {
+ setLocationState("unavailable")
+ window.localStorage.removeItem(FORECAST_TICKER_COUNTRY_KEY)
+ window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY)
+ window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "unavailable")
+ }
+ },
+ () => {
+ setLocationState("denied")
+ window.localStorage.removeItem(FORECAST_TICKER_COUNTRY_KEY)
+ window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY)
+ window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "denied")
+ },
+ { enableHighAccuracy: false, timeout: 10000, maximumAge: 30 * 60 * 1000 },
+ )
+ }
+
+ const tickerItems = useMemo(() => {
+ if (!collection?.forecasts?.length || !visitorCountry) return []
+
+ const nodeBySiteId = new Map(
+ mapNodes
+ .filter((node) => node.site_id)
+ .map((node) => [node.site_id, node]),
+ )
+ const visitorCountryKey = visitorCountry.trim().toLowerCase()
+ const sitesInVisitorCountry = collection.forecasts.filter(
+ (site) =>
+ nodeBySiteId.get(site.site_details.site_id)?.siteDetails?.country?.trim().toLowerCase() ===
+ visitorCountryKey,
+ )
+
+ let selectedSites = sitesInVisitorCountry
+ let selectedCountry = visitorCountry
+
+ if (!selectedSites.length) {
+ const origin = visitorCoordinates || getCoordinatesForCountry(visitorCountry, mapNodes)
+ const nearestForecastNode = origin
+ ? collection.forecasts.reduce<{ node: MapNode; distance: number } | null>((nearest, site) => {
+ const node = nodeBySiteId.get(site.site_details.site_id)
+ if (!node?.siteDetails?.country) return nearest
+ const distance = getDistanceInKm(origin.latitude, origin.longitude, node)
+ if (!Number.isFinite(distance)) return nearest
+ return !nearest || distance < nearest.distance ? { node, distance } : nearest
+ }, null)
+ : null
+ const nearestCountry = nearestForecastNode?.node.siteDetails.country?.trim()
+
+ if (nearestCountry) {
+ selectedCountry = nearestCountry
+ selectedSites = collection.forecasts.filter(
+ (site) =>
+ nodeBySiteId.get(site.site_details.site_id)?.siteDetails?.country?.trim().toLowerCase() ===
+ nearestCountry.toLowerCase(),
+ )
+ }
+ }
+
+ return selectedSites
+ .map((site) => {
+ const forecast = site.forecasts?.[0]
+ if (!forecast) return null
+ return {
+ id: `${site.site_details.site_id}-${forecast.date}`,
+ siteName: site.site_details.site_name || "AirQo site",
+ country: selectedCountry,
+ forecast,
+ }
+ })
+ .filter((item): item is ForecastTickerItem => Boolean(item))
+ .slice(0, 24)
+ }, [collection, mapNodes, visitorCoordinates, visitorCountry])
+
+ const renderedItems = tickerItems.length > 0 ? [...tickerItems, ...tickerItems] : []
+
+ if (!isTickerVisible) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Daily forecast
+
+
+
+ {loading ? (
+
Loading today's air quality outlook...
+ ) : locationState !== "ready" ? (
+
+
+
+ {locationState === "denied"
+ ? "Location access was not granted"
+ : locationState === "unavailable"
+ ? "We could not determine your country"
+ : "See forecasts for your country"}
+
+
+ Your location is used only to choose country-specific AirQo forecast sites.
+
+
+
+
+ ) : renderedItems.length > 0 ? (
+
+ {renderedItems.map((item, index) => {
+ const pm25 = item.forecast.forecast.pm2_5_mean
+ const temperature = item.forecast.met?.air_temperature
+ const label =
+ item.forecast.aqi.aqi_category ||
+ item.forecast.aqi.aqi_color_name ||
+ item.forecast.aqi.label ||
+ "Air quality outlook"
+ const color = normalizeColor(item.forecast.aqi.aqi_color)
+
+ return (
+
+
+ {item.siteName}
+ {item.country}
+
+ {formatForecastDate(item.forecast.date)}
+
+
+ {label}
+ {typeof pm25 === "number" && (
+
+ PM2.5 {pm25.toFixed(1)} ug/m3
+
+ )}
+ {typeof temperature === "number" && (
+ Temp {temperature.toFixed(0)} C
+ )}
+
+ )
+ })}
+
+ ) : (
+
+ No daily forecasts are currently available for {visitorCountry}. Open the map for nearby measurements.
+
+ )}
+
-const Home: React.FC = () => {
+
+ {renderedItems.length > 0 && (
+
+ )}
+
+
+
+
+ )
+}
+
+export default function Home() {
return (
-
+
+
+
+
+
+
- {/* Hero Section */}
-
-
-
-
-
-
- AI-Powered Air Quality Monitoring
+
+
+
+
+ AI for cleaner African cities
+
+
+ Turn air quality data into decisions that matter.
-
- AirQo AI provides advanced tools for monitoring, analyzing, and optimizing air quality across African
- cities using artificial intelligence.
+
+ Monitor pollution, anticipate tomorrow's conditions, and plan stronger sensor networks with AirQo's
+ AI-powered environmental intelligence platform.
-
+
+
- Explore Air Quality Map
+ Explore the live map
- Try Site Locator
+ View air quality reports
+
+
+
+
Live
+
Monitoring context
+
+
+
Daily
+
Forecast outlooks
+
+
+
AI
+
Decision support
+
+
-
-
(window.location.href = "/map")}
- >
+
+
+
+
+
+
+
+ Live network view
+
+
+
+
+
+
+
+
Across African communities
+
See what the air is saying now.
+
+
+
+
+
-
-
-
-
-
- {/* Key Features Section */}
-
-
-
-
Powered by Artificial Intelligence
-
- Our platform leverages cutting-edge AI to provide accurate, real-time air quality data and insights for
- researchers, policymakers, and citizens.
-
+
+
-
-
-
-
-
-
-
-
-
-
-
- {/* How It Works Section - Redesigned */}
-
-
-
-
How AirQo AI Works
-
- Our platform combines low-cost sensors, advanced algorithms, and user-friendly interfaces to democratize
- air quality monitoring.
-
+
+
+
+
+
One connected platform
+
+ From street-level evidence to city-level action.
+
+
+
+ Choose a workflow and move from observation to a practical environmental decision without leaving AirQo AI.
+
+
+
+
+ {capabilityCards.map((card) => {
+ const Icon = card.icon
+ return (
+
+
+
+
{card.title}
+
{card.description}
+
+ Open tool
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
Built for the full decision journey.
+
+ Air quality work is more than a number on a map. AirQo AI connects measurements, models, and local context
+ so teams can understand what is happening and decide what to do next.
+
+
+ Learn about AirQo
+
+
-
- {/* Connection lines for desktop */}
-
-
-
-
}
- number="01"
- title="Data Collection"
- description="Our network of sensors continuously collects air quality data across multiple locations."
- imageSrc="/images/model/calibration-header.webp"
- />
-
}
- number="02"
- title="AI Processing"
- description="Advanced algorithms clean, analyze, and interpret the data to generate insights."
- imageSrc="/images/model/modelapi.webp"
- />
-
}
- number="03"
- title="Actionable Insights"
- description="Users access visualizations, reports, and recommendations through our various platform."
- imageSrc="/images/model/analyticsHome.webp"
- />
+
+ {workflowSteps.map((step, index) => {
+ const Icon = step.icon
+ return (
+
+
0{index + 1}
+
+
+
+
{step.label}
+
{step.title}
+
{step.description}
+
+ )
+ })}
+
-
-
-
- {/* CTA Section */}
-
-
-
Ready to Improve Air Quality?
-
- Start using our AI-powered tools to make data-driven decisions for cleaner air.
-
-
-
- Explore the Map
-
+
+
+
+
+
+
+
+ Evidence for action
+
+
+ Make your next air quality decision with clearer evidence.
+
+
- Try Site Locator
+ Explore reports
-
-
+
+
- {/* Footer */}
-
-
- )
-}
-
-// Process Card Component - Redesigned
-const ProcessCard = ({
- icon,
- number,
- title,
- description,
- imageSrc,
-}: {
- icon: React.ReactNode
- number: string
- title: string
- description: string
- imageSrc?: string
-}) => {
- return (
-
- {imageSrc && (
-
-
-
- )}
-
-
{icon}
-
- {/* Enhanced Number Display */}
-
)
-}
-
-export default Home
+}
\ No newline at end of file
diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/reports/page.tsx
index ffa116d..0a69b57 100644
--- a/frontend/src/app/reports/page.tsx
+++ b/frontend/src/app/reports/page.tsx
@@ -892,9 +892,9 @@ function ReportContent() {
}
return (
-
+
-
Air Quality Reports
+
Air Quality Reports
Real-time insights and analytics on air quality across different site categories
@@ -911,7 +911,7 @@ function ReportContent() {
{/* Filters */}
-
+
Filter report visuals
@@ -1002,7 +1002,7 @@ function ReportContent() {
{/* Selected Devices Counter */}
{selectedDevices.length > 0 && (
-
+
{selectedDevices.length}
@@ -1020,7 +1020,7 @@ function ReportContent() {
@@ -1045,7 +1045,7 @@ function ReportContent() {
)}
-
+
Generate a report with your selected devices