diff --git a/bun.lockb b/bun.lockb index a3601142..13076b80 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cspell.config.yaml b/cspell.config.yaml index 6a2e091a..4a265a31 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -3,6 +3,8 @@ version: '0.2' ignorePaths: - node_modules - bun.lockb + - package.json + - src/utils/get-formatted-weather-description.ts - .tsbuildinfo - .gitignore - .next diff --git a/package.json b/package.json index 06f0a59a..7db116c2 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@clerk/themes": "^2.1.36", "@hookform/resolvers": "^3.9.0", "@neondatabase/serverless": "^0.10.1", + "@nextui-org/react": "^2.6.11", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-checkbox": "^1.1.2", @@ -73,9 +74,10 @@ "@react-email/components": "^0.0.25", "@t3-oss/env-nextjs": "^0.11.1", "@tanstack/react-query": "^5.59.15", + "@tanstack/react-query-next-experimental": "^5.71.10", "@trpc/client": "next", "@trpc/react-query": "next", - "@trpc/server": "next", + "@trpc/server": "^11.0.2", "@upstash/redis": "^1.34.3", "@vercel/analytics": "^1.3.1", "@vercel/speed-insights": "^1.0.12", @@ -105,7 +107,7 @@ "timeago.js": "^4.0.2", "use-resize-observer": "^9.1.0", "usehooks-ts": "^3.1.0", - "zod": "^3.23.8" + "zod": "^3.24.2" }, "devDependencies": { "@changesets/cli": "^2.27.9", diff --git a/src/app/(dashboard)/app/_components/weather.tsx b/src/app/(dashboard)/app/_components/weather.tsx new file mode 100644 index 00000000..0a1ab474 --- /dev/null +++ b/src/app/(dashboard)/app/_components/weather.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { type FC } from 'react'; + +import { useCoords } from '@/hooks/useCoords'; +import { trpc } from '@/trpc/client'; +import { getFormattedWeatherDescription } from '@/utils/get-formatted-weather-description'; + +import { Skeleton } from '@/primitives/skeleton'; +import { formatDate } from '@/hooks/useDate'; + +export const WeatherData: FC = () => { + const coords = useCoords(); + const { data: weatherData, isLoading } = trpc.weather.getWeatherData.useQuery( + { + latitude: coords?.latitude ?? 0, + longitude: coords?.longitude ?? 0, + }, + { + enabled: !!coords, + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + + if (isLoading) { + return ( +
+ +
+ + +
+ +
+ ); + } + + return ( +
+

Its

+

+ {formatDate(new Date())} +

+ + {weatherData && ( +

+ You can expect a πŸ‘† high of {weatherData.temp_max.toFixed()}ΒΊ and a πŸ‘‡ + low of {weatherData.temp_min.toFixed()}ΒΊ{' '} + {getFormattedWeatherDescription(weatherData.summary)}. +

+ )} +
+ ); +}; diff --git a/src/app/(dashboard)/app/page.tsx b/src/app/(dashboard)/app/page.tsx index 8d8e6c8e..eb3a21d5 100644 --- a/src/app/(dashboard)/app/page.tsx +++ b/src/app/(dashboard)/app/page.tsx @@ -1,17 +1,20 @@ import { api } from '@/lib/trpc/server'; import { RecentModules } from './_components/recent-modules'; import { WelcomeMessage } from './_components/welcome-message'; +import { WeatherData } from './_components/weather'; export default async function DashboardHome() { const modules = await api.modules.getUserModules(); return ( -
+
-
Right side
+
+ +
); } diff --git a/src/hooks/useCoords.ts b/src/hooks/useCoords.ts new file mode 100644 index 00000000..13854bc4 --- /dev/null +++ b/src/hooks/useCoords.ts @@ -0,0 +1,18 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export const useCoords = () => { + const [coords, setCoords] = useState<{ + latitude: number; + longitude: number; + } | null>(null); + + useEffect(() => { + navigator.geolocation.getCurrentPosition((position) => { + setCoords(position.coords); + }); + }, []); + + return coords; +}; diff --git a/src/hooks/useDate.ts b/src/hooks/useDate.ts new file mode 100644 index 00000000..d724588f --- /dev/null +++ b/src/hooks/useDate.ts @@ -0,0 +1,20 @@ +export const formatDate = (date: Date) => { + // Get day of week, day, month and year + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' }); + const day = date.getDate(); + const month = date.toLocaleDateString('en-US', { month: 'long' }); + const year = date.getFullYear(); + + // Add ordinal suffix to day + let suffix = 'th'; + if (day % 10 === 1 && day !== 11) { + suffix = 'st'; + } else if (day % 10 === 2 && day !== 12) { + suffix = 'nd'; + } else if (day % 10 === 3 && day !== 13) { + suffix = 'rd'; + } + + // Format as "Saturday, the 5th of April 2025" + return `${dayOfWeek}, the ${day.toString()}${suffix} of ${month} ${year.toString()}`; +}; diff --git a/src/server/index.ts b/src/server/index.ts index 769047c2..b24f0902 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,11 +1,13 @@ import { earlyAccessRouter } from './routers/early-access'; import { modulesRouter } from './routers/modules'; +import { weatherRouter } from './routers/weather'; import { createCallerFactory, createRouter, publicProcedure } from './trpc'; export const appRouter = createRouter({ healthcheck: publicProcedure.query(() => 'ok'), earlyAccess: earlyAccessRouter, modules: modulesRouter, + weather: weatherRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/routers/weather.ts b/src/server/routers/weather.ts new file mode 100644 index 00000000..6474cb27 --- /dev/null +++ b/src/server/routers/weather.ts @@ -0,0 +1,125 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { createRouter, protectedProcedure } from '../trpc'; + +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.toString()}&lon=${longitude.toString()}`, + { + 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), + }; + + return weatherDataSchema.parse(weatherData); +}; + +export const weatherRouter = createRouter({ + getWeatherData: protectedProcedure + .input(input) + .output(weatherDataSchema) + .query(async ({ input, ctx }) => { + const date = new Date().toISOString().slice(0, 10); + const cacheKey = `weather:${date}:${ctx.user.id}`; + + if (typeof ctx.redis !== 'undefined' && typeof ctx.redis !== 'string') { + try { + const cachedWeatherData = await ctx.redis.get(cacheKey); + + 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); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch cached weather data', + }); + } + } + + try { + return await getCurrentWeatherData(input); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch weather data', + }); + } + }), +}); diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx new file mode 100644 index 00000000..e8650e69 --- /dev/null +++ b/src/trpc/client.tsx @@ -0,0 +1,48 @@ +// src/trpc/react.tsx +'use client'; + +import type { AppRouter } from '@/server/index'; +import { getBaseUrl } from '@/utils/base-url'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'; +import { httpBatchLink } from '@trpc/client'; +import { createTRPCReact } from '@trpc/react-query'; +import { type inferRouterOutputs } from '@trpc/server'; +import { useState } from 'react'; +import SuperJSON from 'superjson'; + +export const trpc = createTRPCReact(); + +export function TRPCReactProvider(props: { + children: React.ReactNode; + headers: Headers; +}) { + const [queryClient] = useState(() => new QueryClient()); + + const [trpcClient] = useState(() => + trpc.createClient({ + links: [ + httpBatchLink({ + url: getBaseUrl() + '/api/trpc', + headers() { + const headers = new Map(props.headers); + return Object.fromEntries(headers); + }, + transformer: SuperJSON, + }), + ], + }), + ); + + return ( + + + + {props.children} + + + + ); +} + +export type RouterOutputs = inferRouterOutputs; diff --git a/src/utils/get-formatted-weather-description.ts b/src/utils/get-formatted-weather-description.ts new file mode 100644 index 00000000..8b0d612a --- /dev/null +++ b/src/utils/get-formatted-weather-description.ts @@ -0,0 +1,56 @@ +//cSpell:disable +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, +): string | undefined => { + if (!condition || !(condition in weatherCodeToEnglish)) { + return undefined; + } + // eslint-disable-next-line security/detect-object-injection + const weatherDescription = weatherCodeToEnglish[condition]; + return `with ${weatherDescription as string}`; +};