diff --git a/bun.lock b/bun.lock index 46f334f..d7200b3 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@types/node": "^24.1.0", "classnames": "^2.5.1", "lodash": "^4.17.21", + "openmeteo": "^1.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-fast-marquee": "^1.6.5", @@ -142,6 +143,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@openmeteo/sdk": ["@openmeteo/sdk@1.20.1", "", { "dependencies": { "flatbuffers": "^25.2.10" } }, "sha512-o5tw3+N617Ms8nDm649PWwWt6PDz8NHWBLjOOFB8bx/EJpvsvKEeHMMoapxQ71bjHzQM+4h39eCe6/nM+nBuwg=="], + "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.8.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A=="], @@ -428,6 +431,8 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + "flatbuffers": ["flatbuffers@25.2.10", "", {}, "sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw=="], + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -598,6 +603,8 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "openmeteo": ["openmeteo@1.2.0", "", { "dependencies": { "@openmeteo/sdk": "^1.19.0", "flatbuffers": "^25.2.10" } }, "sha512-YinFo02TM4wXdm9o2FBAO2u1ka3drNdnFsGNskiO8aCWvZa6nljh3ioH79ipwPdFhCrIiq/LCfpjDGXqH2RBFw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], diff --git a/package.json b/package.json index 1ecc6fd..cb35ab7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/node": "^24.1.0", "classnames": "^2.5.1", "lodash": "^4.17.21", + "openmeteo": "^1.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-fast-marquee": "^1.6.5", diff --git a/src/api/weather.ts b/src/api/weather.ts new file mode 100644 index 0000000..06126f6 --- /dev/null +++ b/src/api/weather.ts @@ -0,0 +1,34 @@ +import { fetchWeatherApi } from "openmeteo"; + +const params = { + latitude: 49.0094, + longitude: 8.4044, + daily: ["temperature_2m_max", "temperature_2m_min"], + hourly: [ + "temperature_2m", + "precipitation", + "rain", + "precipitation_probability", + ], + current: [ + "temperature_2m", + "precipitation", + "rain", + "showers", + "snowfall", + "relative_humidity_2m", + "apparent_temperature", + "weather_code", + "cloud_cover", + "is_day", + ], + timezone: "Europe/Berlin", + timeformat: "unixtime", +}; +const url = "https://api.open-meteo.com/v1/forecast"; + +function fetchWeatherData() { + return fetchWeatherApi(url, params); +} + +export default fetchWeatherData; diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index c6f1517..91f1053 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -1,5 +1,11 @@ import style from "./style.module.css"; -export default function Card({ active, children }) { +export default function Card({ + active, + children, +}: { + active?: boolean; + children: React.ReactNode; +}) { return
{children}
; } diff --git a/src/components/CardHeader/CardHeader.tsx b/src/components/CardHeader/CardHeader.tsx index 482d00e..d04600f 100644 --- a/src/components/CardHeader/CardHeader.tsx +++ b/src/components/CardHeader/CardHeader.tsx @@ -3,7 +3,7 @@ import style from "./style.module.css"; export default function CardHeader({ icon, content, active = false }) { let containerClass = style.container; if (active) { - containerClass += " " + style.active; + containerClass += ` ${style.active}`; } return (
diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index 2921a44..b2a1e52 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -7,7 +7,7 @@ import Flatastic from "@/components/Flatastic/Flatastic"; import Footer from "@/components/Footer/Footer"; import Terminal from "@/components/Terminal/Terminal"; import Timetable from "@/components/Timetable/Timetable"; - +import Weather from "../Weather/Weather"; import style from "./style.module.css"; export default function Dashboard() { @@ -43,14 +43,18 @@ export default function Dashboard() { - -
+
+
+ + + + +
- - + +
- @@ -61,7 +65,6 @@ export default function Dashboard() {
-
diff --git a/src/components/Dashboard/style.module.css b/src/components/Dashboard/style.module.css index 4a312cc..b773a63 100644 --- a/src/components/Dashboard/style.module.css +++ b/src/components/Dashboard/style.module.css @@ -20,6 +20,11 @@ background-color: #2a3f55; } +.clockAndWeather { + display: flex; + align-items: center; +} + .cardWrapper { margin: 30px; height: 100%; diff --git a/src/components/Flatastic/Flatastic.tsx b/src/components/Flatastic/Flatastic.tsx index 0809620..270db81 100644 --- a/src/components/Flatastic/Flatastic.tsx +++ b/src/components/Flatastic/Flatastic.tsx @@ -9,10 +9,12 @@ export default function Flatastic() { const fetchFlatasticData = useFlatasticStore((state) => state.fetch); const flatasticData = useFlatasticStore((state) => state.flatasticData); const chores = (flatasticData?.chores as FlatasticChore[]) || []; + chores.sort( (a, b) => a.timeLeftNext - b.timeLeftNext && b.rotationTime - a.rotationTime, ); + const users = flatasticData?.users; const idToNameMap: Record = {}; users.forEach((user: FlatasticUser) => { diff --git a/src/components/Weather/Weather.tsx b/src/components/Weather/Weather.tsx new file mode 100644 index 0000000..fe8ac0b --- /dev/null +++ b/src/components/Weather/Weather.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { useWeatherStore } from "@/store/weather"; + +import styles from "./style.module.css"; + +export default function Weather() { + const weatherData = useWeatherStore((state) => state.weatherData); + const fetchWeatherData = useWeatherStore((state) => state.fetchWeatherData); + + useEffect(() => { + fetchWeatherData(); + const interval = setInterval(() => { + fetchWeatherData(); + }, 10 * 60000); + return () => clearInterval(interval); + }, [fetchWeatherData]); + + if (!weatherData.current) { + return
Loading...
; + } + + return ( +
+
+ {weatherData.current.weather_description} + {weatherData.current.temperature_2m.toFixed(1)}°C +
+
+ + {weatherData.daily.temperature_2m_min[0].toFixed(1)}°C + + + {weatherData.daily.temperature_2m_max[0].toFixed(1)}°C + +
+
+ ); +} diff --git a/src/components/Weather/style.module.css b/src/components/Weather/style.module.css new file mode 100644 index 0000000..ee8acbd --- /dev/null +++ b/src/components/Weather/style.module.css @@ -0,0 +1,30 @@ +.weatherContainer { + display: flex; + flex-direction: row; + width: 100%; + padding: 3px; + padding-right: 15px; +} + +.currentTemperature { + display: flex; + align-items: center; + font-size: 2rem; + font-weight: bold; + padding-right: 5px; +} + +.dailyTemperatures { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.min-temperature { + color: blue; +} + +.max-temperature { + color: red; +} diff --git a/src/store/Weather/Weather.ts b/src/store/Weather/Weather.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/store/weather.ts b/src/store/weather.ts new file mode 100644 index 0000000..4175599 --- /dev/null +++ b/src/store/weather.ts @@ -0,0 +1,389 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import fetchWeatherData from "@/api/weather"; + +const iconNumberToPng = { + "0": { + day: { + description: "Sunny", + image: "http://openweathermap.org/img/wn/01d@2x.png", + }, + night: { + description: "Clear", + image: "http://openweathermap.org/img/wn/01n@2x.png", + }, + }, + "1": { + day: { + description: "Mainly Sunny", + image: "http://openweathermap.org/img/wn/01d@2x.png", + }, + night: { + description: "Mainly Clear", + image: "http://openweathermap.org/img/wn/01n@2x.png", + }, + }, + "2": { + day: { + description: "Partly Cloudy", + image: "http://openweathermap.org/img/wn/02d@2x.png", + }, + night: { + description: "Partly Cloudy", + image: "http://openweathermap.org/img/wn/02n@2x.png", + }, + }, + "3": { + day: { + description: "Cloudy", + image: "http://openweathermap.org/img/wn/03d@2x.png", + }, + night: { + description: "Cloudy", + image: "http://openweathermap.org/img/wn/03n@2x.png", + }, + }, + "45": { + day: { + description: "Foggy", + image: "http://openweathermap.org/img/wn/50d@2x.png", + }, + night: { + description: "Foggy", + image: "http://openweathermap.org/img/wn/50n@2x.png", + }, + }, + "48": { + day: { + description: "Rime Fog", + image: "http://openweathermap.org/img/wn/50d@2x.png", + }, + night: { + description: "Rime Fog", + image: "http://openweathermap.org/img/wn/50n@2x.png", + }, + }, + "51": { + day: { + description: "Light Drizzle", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Light Drizzle", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "53": { + day: { + description: "Drizzle", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Drizzle", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "55": { + day: { + description: "Heavy Drizzle", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Heavy Drizzle", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "56": { + day: { + description: "Light Freezing Drizzle", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Light Freezing Drizzle", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "57": { + day: { + description: "Freezing Drizzle", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Freezing Drizzle", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "61": { + day: { + description: "Light Rain", + image: "http://openweathermap.org/img/wn/10d@2x.png", + }, + night: { + description: "Light Rain", + image: "http://openweathermap.org/img/wn/10n@2x.png", + }, + }, + "63": { + day: { + description: "Rain", + image: "http://openweathermap.org/img/wn/10d@2x.png", + }, + night: { + description: "Rain", + image: "http://openweathermap.org/img/wn/10n@2x.png", + }, + }, + "65": { + day: { + description: "Heavy Rain", + image: "http://openweathermap.org/img/wn/10d@2x.png", + }, + night: { + description: "Heavy Rain", + image: "http://openweathermap.org/img/wn/10n@2x.png", + }, + }, + "66": { + day: { + description: "Light Freezing Rain", + image: "http://openweathermap.org/img/wn/10d@2x.png", + }, + night: { + description: "Light Freezing Rain", + image: "http://openweathermap.org/img/wn/10n@2x.png", + }, + }, + "67": { + day: { + description: "Freezing Rain", + image: "http://openweathermap.org/img/wn/10d@2x.png", + }, + night: { + description: "Freezing Rain", + image: "http://openweathermap.org/img/wn/10n@2x.png", + }, + }, + "71": { + day: { + description: "Light Snow", + image: "http://openweathermap.org/img/wn/13d@2x.png", + }, + night: { + description: "Light Snow", + image: "http://openweathermap.org/img/wn/13n@2x.png", + }, + }, + "73": { + day: { + description: "Snow", + image: "http://openweathermap.org/img/wn/13d@2x.png", + }, + night: { + description: "Snow", + image: "http://openweathermap.org/img/wn/13n@2x.png", + }, + }, + "75": { + day: { + description: "Heavy Snow", + image: "http://openweathermap.org/img/wn/13d@2x.png", + }, + night: { + description: "Heavy Snow", + image: "http://openweathermap.org/img/wn/13n@2x.png", + }, + }, + "77": { + day: { + description: "Snow Grains", + image: "http://openweathermap.org/img/wn/13d@2x.png", + }, + night: { + description: "Snow Grains", + image: "http://openweathermap.org/img/wn/13n@2x.png", + }, + }, + "80": { + day: { + description: "Light Showers", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Light Showers", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "81": { + day: { + description: "Showers", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Showers", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "82": { + day: { + description: "Heavy Showers", + image: "http://openweathermap.org/img/wn/09d@2x.png", + }, + night: { + description: "Heavy Showers", + image: "http://openweathermap.org/img/wn/09n@2x.png", + }, + }, + "85": { + day: { + description: "Light Snow Showers", + image: "http://openweathermap.org/img/wn/13d@2x.png", + }, + night: { + description: "Light Snow Showers", + image: "http://openweathermap.org/img/wn/13n@2x.png", + }, + }, + "86": { + day: { + description: "Snow Showers", + image: "http://openweathermap.org/img/wn/13d@2x.png", + }, + night: { + description: "Snow Showers", + image: "http://openweathermap.org/img/wn/13n@2x.png", + }, + }, + "95": { + day: { + description: "Thunderstorm", + image: "http://openweathermap.org/img/wn/11d@2x.png", + }, + night: { + description: "Thunderstorm", + image: "http://openweathermap.org/img/wn/11n@2x.png", + }, + }, + "96": { + day: { + description: "Light Thunderstorms With Hail", + image: "http://openweathermap.org/img/wn/11d@2x.png", + }, + night: { + description: "Light Thunderstorms With Hail", + image: "http://openweathermap.org/img/wn/11n@2x.png", + }, + }, + "99": { + day: { + description: "Thunderstorm With Hail", + image: "http://openweathermap.org/img/wn/11d@2x.png", + }, + night: { + description: "Thunderstorm With Hail", + image: "http://openweathermap.org/img/wn/11n@2x.png", + }, + }, +}; + +const useWeatherStore = create( + devtools( + (set) => ({ + weatherData: {}, + fetchWeatherData: async () => { + const data = await fetchWeatherData(); + + // Process first location. Add a for-loop for multiple locations or weather models + const response = data[0]; + + if (response === null) { + console.error("Failed to fetch weather data"); + return; + } + + // Attributes for timezone and location + const utcOffsetSeconds = response.utcOffsetSeconds(); + const current = response.current(); + const hourly = response.hourly(); + const daily = response.daily(); + + if (!current || !hourly || !daily) { + console.error("Failed to fetch weather data"); + return; + } + + // Note: The order of weather variables in the URL query and the indices below need to match! + const weatherData = { + current: { + time: current.time(), + temperature_2m: current.variables(0)?.value(), + precipitation: current.variables(1)?.value(), + rain: current.variables(2)?.value(), + showers: current.variables(3)?.value(), + snowfall: current.variables(4)?.value(), + relative_humidity_2m: current.variables(5)?.value(), + apparent_temperature: current.variables(6)?.value(), + weather_code: current.variables(7)?.value(), + cloud_cover: current.variables(8)?.value(), + is_day: current.variables(9)?.value(), + }, + hourly: { + time: [ + ...Array( + (Number(hourly.timeEnd()) - + Number(hourly.time())) / + hourly.interval(), + ), + ].map( + (_, i) => + new Date( + (Number(hourly.time()) + + i * hourly.interval() + + utcOffsetSeconds) * + 1000, + ), + ), + temperature_2m: hourly.variables(0)?.valuesArray(), + precipitation: hourly.variables(1)?.valuesArray(), + rain: hourly.variables(2)?.valuesArray(), + precipitation_probability: hourly + .variables(3) + ?.valuesArray(), + }, + daily: { + time: [ + ...Array( + (Number(daily.timeEnd()) - + Number(daily.time())) / + daily.interval(), + ), + ].map( + (_, i) => + new Date( + (Number(daily.time()) + + i * daily.interval() + + utcOffsetSeconds) * + 1000, + ), + ), + temperature_2m_max: daily.variables(0)?.valuesArray(), + temperature_2m_min: daily.variables(1)?.valuesArray(), + }, + }; + + const isDay = weatherData.current.is_day === 1; + const weatherCode = weatherData.current.weather_code; + const url = + iconNumberToPng[weatherCode][isDay ? "day" : "night"].image; + + weatherData.current = { ...weatherData.current, icon: url }; + + set({ weatherData }); + }, + }), + { + name: "weather-store", + }, + ), +); + +export { useWeatherStore };