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)/_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 a0a1a280..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.main.temp_max.toFixed()}ΒΊ and a - πŸ‘‡ low of {weatherData.main.temp_min.toFixed()}ΒΊ with{" "} - {getFormattedWeatherDescription(weatherData.weather[0]?.description)}{" "} - today. - + + + 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/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/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 4580836c..b4b8e5c0 100644 --- a/src/server/api/routers/weather.ts +++ b/src/server/api/routers/weather.ts @@ -1,43 +1,124 @@ import { TRPCError } from "@trpc/server"; -import fetch from "node-fetch"; import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { env } from "@/env.mjs"; +const hours = z + .object({ + summary: z.object({ + symbol_code: z.string(), + }), + }) + .optional(); -import { createTRPCRouter, protectedProcedure } from "../trpc"; +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, + ); -type WeatherData = { - main: { - temp_max: number; - temp_min: number; + 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), }; - weather: { - description: 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; + .input(input) + .output(weatherDataSchema) + .query(async ({ input, ctx }) => { + const date = new Date().toISOString().slice(0, 10); + const cacheKey = `weather:${date}:${ctx.auth.userId}`; - const response = await fetch( - `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${env.OPENWEATHER_API_KEY}&units=metric`, - ); + if (typeof ctx.redis !== "undefined" && typeof ctx.redis !== "string") { + try { + const cachedWeatherData = await ctx.redis.get(cacheKey); - if (!response.ok) { + if (!cachedWeatherData) { + const weatherData = await getCurrentWeatherData(input); + const secondsUntilMidnight = Math.round( + (new Date().setHours(24, 0, 0, 0) - Date.now()) / 1000, + ); + + await ctx.redis.set(cacheKey, JSON.stringify(weatherData), { + ex: secondsUntilMidnight, + }); + return weatherData; + } + + return weatherDataSchema.parse(cachedWeatherData); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch cached weather data", + }); + } + } + + try { + return getCurrentWeatherData(input); + } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch weather data", }); } - - return response.json() as Promise; }), }); 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, }; }; 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}`; };