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 (
-
+
);
}
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}`;
+};