From ddf140b6187666b402bdd4c870248f8071f66e26 Mon Sep 17 00:00:00 2001 From: Erlend <49862976+Erb3@users.noreply.github.com> Date: Mon, 6 Nov 2023 09:16:15 +0100 Subject: [PATCH 1/3] Use yr.no as weather source (#354) * Use yr.no as weather source * feat: adds emojis and more conditions --------- Co-authored-by: Ahmed Elsakaan --- .github/workflows/ci.yml | 3 - .../(dashboard)/app/_components/weather.tsx | 8 +- src/env.mjs | 2 - src/server/api/routers/weather.ts | 62 ++++++++++--- src/utils/getFormattedWeatherDescription.ts | 92 ++++++++++--------- 5 files changed, 102 insertions(+), 65 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4ebdd00..a138ed34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,6 @@ env: UPSTASH_REDIS_REST_URL: ${{secrets.UPSTASH_REDIS_REST_URL}} UPSTASH_REDIS_REST_TOKEN: ${{secrets.UPSTASH_REDIS_REST_TOKEN}} - # Openweather - OPENWEATHER_API_KEY: ${{secrets.OPENWEATHER_API_KEY}} - jobs: main: name: CI diff --git a/src/app/(dashboard)/app/_components/weather.tsx b/src/app/(dashboard)/app/_components/weather.tsx index a0a1a280..8e1d0ba9 100644 --- a/src/app/(dashboard)/app/_components/weather.tsx +++ b/src/app/(dashboard)/app/_components/weather.tsx @@ -40,10 +40,10 @@ export const WeatherData: FC = () => { return ( - You can expect a πŸ‘† high of {weatherData.main.temp_max.toFixed()}ΒΊ and a - πŸ‘‡ low of {weatherData.main.temp_min.toFixed()}ΒΊ with{" "} - {getFormattedWeatherDescription(weatherData.weather[0]?.description)}{" "} - today. + You can expect a πŸ‘† high of {weatherData.temp_max.toFixed()}ΒΊ and a πŸ‘‡ low + of {weatherData.temp_min.toFixed()}ΒΊ + {getFormattedWeatherDescription(weatherData.summary)} for today's + weather. ); }; diff --git a/src/env.mjs b/src/env.mjs index fe0e9517..cf284977 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -12,7 +12,6 @@ export const env = createEnv({ UPSTASH_REDIS_REST_URL: z.string().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().optional(), CLERK_SECRET_KEY: z.string().min(1), - OPENWEATHER_API_KEY: z.string().min(1), }, client: { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1), @@ -26,7 +25,6 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, - OPENWEATHER_API_KEY: process.env.OPENWEATHER_API_KEY, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, }); diff --git a/src/server/api/routers/weather.ts b/src/server/api/routers/weather.ts index 4580836c..d5c5c318 100644 --- a/src/server/api/routers/weather.ts +++ b/src/server/api/routers/weather.ts @@ -1,19 +1,31 @@ import { TRPCError } from "@trpc/server"; import fetch from "node-fetch"; import { z } from "zod"; - -import { env } from "@/env.mjs"; - import { createTRPCRouter, protectedProcedure } from "../trpc"; +type RawWeatherData = { + properties: { + timeseries: { + data: { + next_12_hours: { + summary: { + symbol_code: string; + } + }, + instant: { + details: { + air_temperature: number; + } + } + } + }[] + } +} + type WeatherData = { - main: { - temp_max: number; - temp_min: number; - }; - weather: { - description: string; - }[]; + temp_max: number; + temp_min: number; + summary: string; }; export const weatherRouter = createTRPCRouter({ @@ -28,7 +40,12 @@ export const weatherRouter = createTRPCRouter({ const { latitude, longitude } = input; const response = await fetch( - `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${env.OPENWEATHER_API_KEY}&units=metric`, + `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${latitude}&lon=${longitude}`, + { + headers: { + "User-Agent": `noodle.run (https://github.com/noodle-run/noodle)` + } + } ); if (!response.ok) { @@ -38,6 +55,27 @@ export const weatherRouter = createTRPCRouter({ }); } - return response.json() as Promise; + const rawWeatherData: RawWeatherData = await response.json() as RawWeatherData; + + if (rawWeatherData.properties.timeseries.length < 12) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Partial weather data" + }) + } + + const temperatures = []; + + for (const timeseries of rawWeatherData.properties.timeseries) { + temperatures.push(timeseries.data.instant.details.air_temperature); + } + + const weatherData: WeatherData = { + summary: rawWeatherData.properties.timeseries[0]!.data.next_12_hours.summary.symbol_code, + temp_max: Math.max(...temperatures), + temp_min: Math.min(...temperatures) + } + + return weatherData; }), }); diff --git a/src/utils/getFormattedWeatherDescription.ts b/src/utils/getFormattedWeatherDescription.ts index 2fa4cdc7..39642867 100644 --- a/src/utils/getFormattedWeatherDescription.ts +++ b/src/utils/getFormattedWeatherDescription.ts @@ -1,48 +1,52 @@ +const weatherCodeToEnglish: Record = { + clearsky: "🌞 a clear sky", + cloudy: "☁️ clouds", + fair: "β›… fair", + fair_day: "🌀️ fair day", + fog: "🌫️ fog", + heavyrain: "🌧️ heavy rain", + heavyrainandthunder: "β›ˆοΈ heavy rain and thunder", + heavyrainshowers: "🌧️ heavy rain showers", + heavyrainshowersandthunder: "β›ˆοΈ heavy rain showers and thunder", + heavysleet: "🌨️ heavy sleet", + heavysleetandthunder: "β›ˆοΈ heavy sleet and thunder", + heavysleetshowers: "🌨️ heavy sleet showers", + heavysleetshowersandthunder: "β›ˆοΈ heavy sleet showers and thunder", + heavysnow: "❄️ heavy snow", + heavysnowandthunder: "β›ˆοΈ heavy snow and thunder", + heavysnowshowers: "❄️ heavy snow showers", + heavysnowshowersandthunder: "β›ˆοΈ heavy snow showers and thunder", + lightrain: "🌦️ light rain", + lightrainandthunder: "β›ˆοΈ light rain and thunder", + lightrainshowers: "🌦️ light rain showers", + lightrainshowersandthunder: "β›ˆοΈ light rain showers and thunder", + lightsleet: "🌧️ light sleet", + lightsleetandthunder: "β›ˆοΈ light sleet and thunder", + lightsleetshowers: "🌧️ light sleet showers", + lightsnow: "🌨️ light snow", + lightsnowandthunder: "β›ˆοΈ light snow and thunder", + lightsnowshowers: "🌨️ light snow showers", + lightssleetshowersandthunder: "β›ˆοΈ light sleet showers and thunder", + lightssnowshowersandthunder: "β›ˆοΈ light snow showers and thunder", + partlycloudy: "πŸŒ₯️ some clouds", + rain: "🌧️ rain", + rainandthunder: "β›ˆοΈ rain and thunder", + rainshowers: "🌧️ rain showers", + rainshowersandthunder: "β›ˆοΈ rain showers and thunder", + sleet: "🌨️ sleet", + sleetandthunder: "β›ˆοΈ sleet and thunder", + sleetshowers: "🌨️ sleet showers", + sleetshowersandthunder: "β›ˆοΈ sleet showers and thunder", + snow: "❄️ snow", + snowandthunder: "β›ˆοΈ snow and thunder", + snowshowers: "❄️ snow showers", + snowshowersandthunder: "β›ˆοΈ snow showers and thunder", +}; + export const getFormattedWeatherDescription = ( condition: string | undefined, ) => { - if (!condition) return; - - const weatherToEmoji: Record = { - "clear sky": "β˜€οΈ", - "few clouds": "🌀️", - "scattered clouds": "β›…", - "broken clouds": "☁️", - "overcast clouds": "☁️", - rain: "🌧️", - "light rain": "🌧️", - "moderate rain": "🌧️", - "heavy intensity rain": "🌧️", - "very heavy rain": "🌧️", - "extreme rain": "🌧️", - "freezing rain": "🌨️", - "light intensity shower rain": "🌦️", - "shower rain": "🌧️", - "heavy intensity shower rain": "🌧️", - "ragged shower rain": "🌧️", - "light snow": "❄️", - snow: "❄️", - "heavy snow": "❄️", - sleet: "🌨️", - "shower sleet": "🌨️", - "light rain and snow": "🌨️", - "rain and snow": "🌨️", - "light shower snow": "🌨️", - "shower snow": "🌨️", - "heavy shower snow": "🌨️", - mist: "🌫️", - smoke: "🌫️", - haze: "🌫️", - "sand/ dust whirls": "πŸŒͺ️", - fog: "🌫️", - sand: "🌫️", - dust: "🌫️", - "volcanic ash": "🌫️", - squalls: "🌬️", - tornado: "πŸŒͺ️", - clear: "β˜€οΈ", - clouds: "☁️", - }; - - return `${weatherToEmoji[condition.toLowerCase()] ?? ""} ${condition}`; + if (!condition || !weatherCodeToEnglish[condition]) return; + const weatherDescription = weatherCodeToEnglish[condition]; + return `with ${weatherDescription}`; }; From 4fa02bcf98ef965927f9055dc441e103d05f6259 Mon Sep 17 00:00:00 2001 From: Ahmed Elsakaan Date: Mon, 6 Nov 2023 08:25:52 +0000 Subject: [PATCH 2/3] fix: formatting --- src/server/api/routers/weather.ts | 39 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/server/api/routers/weather.ts b/src/server/api/routers/weather.ts index d5c5c318..f261fb10 100644 --- a/src/server/api/routers/weather.ts +++ b/src/server/api/routers/weather.ts @@ -10,17 +10,17 @@ type RawWeatherData = { next_12_hours: { summary: { symbol_code: string; - } - }, + }; + }; instant: { details: { air_temperature: number; - } - } - } - }[] - } -} + }; + }; + }; + }[]; + }; +}; type WeatherData = { temp_max: number; @@ -43,9 +43,9 @@ export const weatherRouter = createTRPCRouter({ `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${latitude}&lon=${longitude}`, { headers: { - "User-Agent": `noodle.run (https://github.com/noodle-run/noodle)` - } - } + "User-Agent": `noodle.run (https://github.com/noodle-run/noodle)`, + }, + }, ); if (!response.ok) { @@ -55,13 +55,14 @@ export const weatherRouter = createTRPCRouter({ }); } - const rawWeatherData: RawWeatherData = await response.json() as RawWeatherData; + const rawWeatherData: RawWeatherData = + (await response.json()) as RawWeatherData; if (rawWeatherData.properties.timeseries.length < 12) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Partial weather data" - }) + message: "Partial weather data", + }); } const temperatures = []; @@ -71,11 +72,13 @@ export const weatherRouter = createTRPCRouter({ } const weatherData: WeatherData = { - summary: rawWeatherData.properties.timeseries[0]!.data.next_12_hours.summary.symbol_code, + summary: + rawWeatherData.properties.timeseries[0]!.data.next_12_hours.summary + .symbol_code, temp_max: Math.max(...temperatures), - temp_min: Math.min(...temperatures) - } - + temp_min: Math.min(...temperatures), + }; + return weatherData; }), }); From 7923b722df8052a65a1bbfe72729f4c34e4b6ac5 Mon Sep 17 00:00:00 2001 From: Ahmed Elsakaan Date: Wed, 8 Nov 2023 22:30:11 +0000 Subject: [PATCH 3/3] feat: caches weather with redis & info tooltip (#358) * feat: caches weather with redis & info tooltip * refactor: literally dropped by 50 lines --- .../(dashboard)/_components/waitlist-form.tsx | 5 +- .../(dashboard)/app/_components/weather.tsx | 35 +++- src/server/api/routers/waitlist.ts | 2 - src/server/api/routers/weather.ts | 172 +++++++++++------- src/server/api/trpc.ts | 5 + 5 files changed, 140 insertions(+), 79 deletions(-) diff --git a/src/app/(dashboard)/_components/waitlist-form.tsx b/src/app/(dashboard)/_components/waitlist-form.tsx index 4a0c7f74..9382e05b 100644 --- a/src/app/(dashboard)/_components/waitlist-form.tsx +++ b/src/app/(dashboard)/_components/waitlist-form.tsx @@ -27,14 +27,11 @@ export const WaitListInvitationForm = ({ const { mutateAsync, isLoading } = trpc.waitlist.sendUserInvitation.useMutation({ - async onSuccess(data) { - console.log(data); + async onSuccess() { await utils.waitlist.invalidate(); }, }); - console.log(form.formState.errors); - const onSubmit = async (values: z.infer) => { try { await mutateAsync(values); diff --git a/src/app/(dashboard)/app/_components/weather.tsx b/src/app/(dashboard)/app/_components/weather.tsx index 8e1d0ba9..60be5970 100644 --- a/src/app/(dashboard)/app/_components/weather.tsx +++ b/src/app/(dashboard)/app/_components/weather.tsx @@ -2,10 +2,11 @@ import { type FC } from "react"; +import { Icon } from "@/components/icon"; import { useCoords } from "@/hooks/useCoords"; import { trpc } from "@/trpc/client"; import { getFormattedWeatherDescription } from "@/utils/getFormattedWeatherDescription"; -import { Skeleton } from "@nextui-org/react"; +import { Skeleton, Tooltip } from "@nextui-org/react"; export const WeatherData: FC = () => { const coords = useCoords(); @@ -39,11 +40,31 @@ export const WeatherData: FC = () => { if (!weatherData) return null; return ( - - You can expect a πŸ‘† high of {weatherData.temp_max.toFixed()}ΒΊ and a πŸ‘‡ low - of {weatherData.temp_min.toFixed()}ΒΊ - {getFormattedWeatherDescription(weatherData.summary)} for today's - weather. - + + + Info + + + The weather conditions are until the end of the day, so the high and + low temps are until midnight. + + + } + placement="left-start" + delay={1000} + > + + ); }; diff --git a/src/server/api/routers/waitlist.ts b/src/server/api/routers/waitlist.ts index 9e8f832f..5a99cd3e 100644 --- a/src/server/api/routers/waitlist.ts +++ b/src/server/api/routers/waitlist.ts @@ -47,8 +47,6 @@ export const waitlistRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { invitationId } = input; - console.log("invitationId", invitationId); - const invitation = await ctx.db .select() .from(waitlist) diff --git a/src/server/api/routers/weather.ts b/src/server/api/routers/weather.ts index f261fb10..b4b8e5c0 100644 --- a/src/server/api/routers/weather.ts +++ b/src/server/api/routers/weather.ts @@ -1,84 +1,124 @@ import { TRPCError } from "@trpc/server"; -import fetch from "node-fetch"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -type RawWeatherData = { - properties: { - timeseries: { - data: { - next_12_hours: { - summary: { - symbol_code: string; - }; - }; - instant: { - details: { - air_temperature: number; - }; - }; - }; - }[]; +const hours = z + .object({ + summary: z.object({ + symbol_code: z.string(), + }), + }) + .optional(); + +const timeseriesSchema = z.array( + z.object({ + time: z.string(), + data: z.object({ + next_12_hours: hours, + next_6_hours: hours, + next_1_hours: hours, + instant: z.object({ + details: z.object({ + air_temperature: z.number(), + }), + }), + }), + }), +); + +const weatherDataSchema = z.object({ + temp_max: z.number(), + temp_min: z.number(), + summary: z.string().optional(), +}); + +const input = z.object({ + latitude: z.number(), + longitude: z.number(), +}); + +const getCurrentWeatherData = async ({ + latitude, + longitude, +}: z.infer) => { + const date = new Date().toISOString().slice(0, 10); + const response = await fetch( + `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${latitude}&lon=${longitude}`, + { + headers: { + "User-Agent": `noodle.run (https://github.com/noodle-run/noodle)`, + }, + }, + ); + + const data = (await response.json()) as { + properties: { timeseries: unknown }; + }; + + const timeseries = timeseriesSchema + .parse(data.properties.timeseries as z.infer) + .filter((one) => one.time.includes(date)); + + const temperatures = timeseries.map( + (t) => t.data.instant.details.air_temperature, + ); + + let summary; + if (timeseries[0]) { + const { next_12_hours, next_6_hours, next_1_hours } = timeseries[0].data; + const nextData = next_12_hours ?? next_6_hours ?? next_1_hours; + summary = nextData?.summary.symbol_code; + } + + const weatherData = { + summary, + temp_max: Math.max(...temperatures), + temp_min: Math.min(...temperatures), }; -}; -type WeatherData = { - temp_max: number; - temp_min: number; - summary: string; + return weatherDataSchema.parse(weatherData); }; export const weatherRouter = createTRPCRouter({ getWeatherData: protectedProcedure - .input( - z.object({ - latitude: z.number(), - longitude: z.number(), - }), - ) - .query(async ({ input }) => { - const { latitude, longitude } = input; - - const response = await fetch( - `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${latitude}&lon=${longitude}`, - { - headers: { - "User-Agent": `noodle.run (https://github.com/noodle-run/noodle)`, - }, - }, - ); - - if (!response.ok) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch weather data", - }); - } + .input(input) + .output(weatherDataSchema) + .query(async ({ input, ctx }) => { + const date = new Date().toISOString().slice(0, 10); + const cacheKey = `weather:${date}:${ctx.auth.userId}`; - const rawWeatherData: RawWeatherData = - (await response.json()) as RawWeatherData; + if (typeof ctx.redis !== "undefined" && typeof ctx.redis !== "string") { + try { + const cachedWeatherData = await ctx.redis.get(cacheKey); - if (rawWeatherData.properties.timeseries.length < 12) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Partial weather data", - }); - } + if (!cachedWeatherData) { + const weatherData = await getCurrentWeatherData(input); + const secondsUntilMidnight = Math.round( + (new Date().setHours(24, 0, 0, 0) - Date.now()) / 1000, + ); - const temperatures = []; + await ctx.redis.set(cacheKey, JSON.stringify(weatherData), { + ex: secondsUntilMidnight, + }); + return weatherData; + } - for (const timeseries of rawWeatherData.properties.timeseries) { - temperatures.push(timeseries.data.instant.details.air_temperature); + return weatherDataSchema.parse(cachedWeatherData); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch cached weather data", + }); + } } - const weatherData: WeatherData = { - summary: - rawWeatherData.properties.timeseries[0]!.data.next_12_hours.summary - .symbol_code, - temp_max: Math.max(...temperatures), - temp_min: Math.min(...temperatures), - }; - - return weatherData; + try { + return getCurrentWeatherData(input); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch weather data", + }); + } }), }); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 4a47887d..748c49c7 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -1,6 +1,8 @@ import { db } from "@/db"; +import { env } from "@/env.mjs"; import { getAuth } from "@clerk/nextjs/server"; import { TRPCError, initTRPC } from "@trpc/server"; +import { Redis } from "@upstash/redis"; import { type NextRequest } from "next/server"; import superjson from "superjson"; import { ZodError } from "zod"; @@ -10,9 +12,12 @@ type CreateContextOptions = { }; const createInnerTRPCContext = ({ auth }: CreateContextOptions) => { + const redis = env.UPSTASH_REDIS_REST_URL && Redis.fromEnv(); + return { auth, db, + redis, }; };