diff --git a/src/components/personalized-recommendations.tsx b/src/components/personalized-recommendations.tsx new file mode 100644 index 0000000..37e492c --- /dev/null +++ b/src/components/personalized-recommendations.tsx @@ -0,0 +1,227 @@ +import React from "react"; +import type { ForecastData, WeatherData } from "@/api/types"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { format } from "date-fns"; +import { Umbrella, Sun, Wind, Thermometer, Snowflake, Cloud, Clock } from "lucide-react"; + +type Props = { + weather: WeatherData; + forecast?: ForecastData | null; + aqi?: number | null; + uv?: number | null; +}; + +function hasPrecip(desc: string) { + const d = desc.toLowerCase(); + return d.includes("rain") || d.includes("drizzle") || d.includes("thunder") || d.includes("storm"); +} + +function isSnow(desc: string) { + return desc.toLowerCase().includes("snow"); +} + +function tempBand(t: number) { + if (t <= 0) return "freezing"; + if (t <= 10) return "cold"; + if (t <= 18) return "cool"; + if (t <= 24) return "mild"; + if (t <= 30) return "warm"; + return "hot"; +} + +function buildWearSuggestions(weather: WeatherData, aqi?: number | null, uv?: number | null) { + const { main, wind } = weather; + const desc = weather.weather?.[0]?.description ?? ""; + const t = Math.round(main?.temp ?? 0); + const band = tempBand(t); + const items: Array<{ icon: React.ReactNode; label: string; reason?: string }> = []; + + // Base clothing by temperature + if (band === "freezing") { + items.push({ icon: , label: "Heavy coat, gloves, scarf", reason: "Freezing temps" }); + } else if (band === "cold") { + items.push({ icon: , label: "Coat or insulated jacket", reason: "Cold weather" }); + } else if (band === "cool") { + items.push({ icon: , label: "Light jacket or hoodie", reason: "Cool breeze" }); + } else if (band === "mild") { + items.push({ icon: , label: "T‑shirt + light layer", reason: "Comfortable" }); + } else if (band === "warm") { + items.push({ icon: , label: "Light, breathable clothes", reason: "Warm temps" }); + } else { + items.push({ icon: , label: "Very light, breathable, hydrate", reason: "Hot weather" }); + } + + // Precipitation + if (hasPrecip(desc)) { + items.push({ icon: , label: "Umbrella / rain jacket", reason: "Rain expected" }); + } + if (isSnow(desc)) { + items.push({ icon: , label: "Thermals, boots", reason: "Snowy conditions" }); + } + + // UV + if ((uv ?? 0) >= 6) { + items.push({ icon: , label: "SPF 30+, hat & sunglasses", reason: "High UV" }); + } + + // Wind + if ((wind?.speed ?? 0) >= 8) { + items.push({ icon: , label: "Windbreaker", reason: "Gusty winds" }); + } + + // Air quality + if ((aqi ?? 0) >= 4) { + items.push({ icon: , label: "Mask (outdoors)", reason: "Poor AQI" }); + } + + return { temp: t, description: desc, items }; +} + +type Scored = { dt: number; temp: number; desc: string; score: number }; + +function scoreForecastSlot(dt: number, temp: number, desc: string, wind: number, uv?: number | null): number { + let score = 1.0; + // Ideal temperature around 22C + const tempScore = Math.max(0, 1 - Math.abs(temp - 22) / 20); + score *= 0.6 + 0.4 * tempScore; + // Penalties + if (hasPrecip(desc) || isSnow(desc)) score *= 0.6; + if (wind > 8) score *= 0.85; + + const hour = new Date(dt * 1000).getHours(); + // Mild bonus for morning/evening + if ((hour >= 6 && hour <= 10) || (hour >= 17 && hour <= 20)) score *= 1.05; + // UV rough penalty midday if currently high + if ((uv ?? 0) >= 7 && hour >= 11 && hour <= 15) score *= 0.85; + return score; +} + +function findBestOutdoorTimes(forecast?: ForecastData | null, uv?: number | null): Scored[] { + if (!forecast?.list?.length) return []; + const next = forecast.list.slice(0, 8); // next ~24h (3h steps) + const scored: Scored[] = next.map((slot) => { + const temp = Math.round(slot.main.temp); + const desc = slot.weather?.[0]?.description ?? ""; + const score = scoreForecastSlot(slot.dt, temp, desc, slot.wind?.speed ?? 0, uv); + return { dt: slot.dt, temp, desc, score }; + }); + return scored + .sort((a, b) => b.score - a.score) + .slice(0, 3); +} + +function buildActivitySuggestions(current: WeatherData, aqi?: number | null) { + const t = Math.round(current.main.temp); + const desc = current.weather?.[0]?.main?.toLowerCase() || ""; + const windy = (current.wind?.speed ?? 0) > 8; + const badAir = (aqi ?? 0) >= 4; + const ideas: { title: string; why: string }[] = []; + + if (badAir) { + ideas.push({ title: "Indoor yoga or gym", why: "Poor air quality outside" }); + } + + if (desc.includes("clear") || desc.includes("cloud")) { + if (t >= 15 && t <= 28) { + ideas.push({ title: "Jogging or brisk walk", why: "Comfortable temps and clear skies" }); + ideas.push({ title: "Picnic or casual outdoor hangout", why: "Mild and pleasant" }); + } + } + + if (hasPrecip(desc)) { + ideas.push({ title: "Museum or café visit", why: "Stay dry during rain" }); + } + + if (isSnow(desc) || t < 5) { + ideas.push({ title: "Cozy indoor activities", why: "Very cold outside" }); + } + + if (t > 30) { + ideas.push({ title: "Early-morning walk or indoor swim", why: "Beat the heat" }); + } + + if (windy) { + ideas.push({ title: "Light wind-friendly stroll", why: "Gusty conditions—avoid strenuous outdoor workouts" }); + } + + // Deduplicate by title and keep top 3 + const seen = new Set(); + return ideas.filter((i) => (seen.has(i.title) ? false : (seen.add(i.title), true))).slice(0, 3); +} + +const PersonalizedRecommendations: React.FC = ({ weather, forecast, aqi, uv }) => { + if (!weather) return null; + + const wear = buildWearSuggestions(weather, aqi, uv); + const bestTimes = findBestOutdoorTimes(forecast, uv); + const activities = buildActivitySuggestions(weather, aqi); + + return ( + + + Personalized Recommendations + + +
+ {/* Wear & carry */} +
+

What to wear or carry

+
    + {wear.items.map((it, i) => ( +
  • +
    {it.icon}
    +
    +

    {it.label}

    + {it.reason &&

    {it.reason}

    } +
    +
  • + ))} +
+
+ + {/* Best outdoor times */} +
+

Best times to be outside (next 24h)

+ {bestTimes.length === 0 ? ( +

No forecast data available.

+ ) : ( +
+ {bestTimes.map((t) => ( +
+
+ +
+

{format(new Date(t.dt * 1000), "EEE, h a")}

+

{t.desc || "Good conditions"}

+
+
+ {t.temp}° +
+ ))} +
+ )} +
+ + {/* Suggested activities */} +
+

Suggested activities

+ {activities.length === 0 ? ( +

We’ll suggest activities when conditions improve.

+ ) : ( +
    + {activities.map((a, idx) => ( +
  • +

    {a.title}

    +

    {a.why}

    +
  • + ))} +
+ )} +
+
+
+
+ ); +}; + +export default PersonalizedRecommendations; diff --git a/src/pages/weather-dashboard.tsx b/src/pages/weather-dashboard.tsx index e4e7ee9..bdf2237 100644 --- a/src/pages/weather-dashboard.tsx +++ b/src/pages/weather-dashboard.tsx @@ -11,6 +11,7 @@ import WeatherDetails from "@/components/weather-details"; import WeatherForecast from "@/components/weather-forecast"; import FavoriteCities from "@/components/favorite-cities"; import HealthRecommendations from "@/components/healthRecommendations"; +import PersonalizedRecommendations from "@/components/personalized-recommendations"; import WeatherPlaylists from "@/components/weather-playlist"; const WeatherDashboard = () => { @@ -102,6 +103,14 @@ const WeatherDashboard = () => { {/* Health Recommendations */} + {/* AI-Powered Personalized Recommendations (rule-based for now) */} + +
{weatherQuery.data && ( diff --git a/vite.config.ts b/vite.config.ts index 79a65e0..93834a3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,9 +6,18 @@ import { defineConfig } from "vite" // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + css: { + // Ensure we do not inherit a parent PostCSS config (which may include Tailwind v3) + // Tailwind v4 is handled by @tailwindcss/vite and `@import "tailwindcss"` in CSS + postcss: { plugins: [] }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, + server: { + port: 8080, + host: true, + }, })