Skip to content

Commit

Permalink
feat: caches weather with redis & info tooltip (#358)
Browse files Browse the repository at this point in the history
* feat: caches weather with redis & info tooltip

* refactor: literally dropped by 50 lines
  • Loading branch information
ixahmedxi authored Nov 8, 2023
1 parent 4fa02bc commit 7923b72
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 79 deletions.
5 changes: 1 addition & 4 deletions src/app/(dashboard)/_components/waitlist-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof formSchema>) => {
try {
await mutateAsync(values);
Expand Down
35 changes: 28 additions & 7 deletions src/app/(dashboard)/app/_components/weather.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -39,11 +40,31 @@ export const WeatherData: FC = () => {
if (!weatherData) return null;

return (
<span className="text-tiny text-default-500">
You can expect a 👆 high of {weatherData.temp_max.toFixed()}º and a 👇 low
of {weatherData.temp_min.toFixed()}º
{getFormattedWeatherDescription(weatherData.summary)} for today&apos;s
weather.
</span>
<Tooltip
color="default"
showArrow
classNames={{
content: "border dark:border-default-100 border-default-200",
}}
content={
<div className="max-w-[30ch] px-1 py-2">
<span className="flex items-center gap-3 pb-2">
<Icon name="BadgeInfo" size={16} /> Info
</span>
<span className="text-tiny leading-5 text-default-500">
The weather conditions are until the end of the day, so the high and
low temps are until midnight.
</span>
</div>
}
placement="left-start"
delay={1000}
>
<button className="cursor-default text-left text-tiny leading-5 text-default-500">
You can expect a 👆 high of {weatherData.temp_max.toFixed()}º and a 👇
low of {weatherData.temp_min.toFixed()}º{" "}
{getFormattedWeatherDescription(weatherData.summary)}.
</button>
</Tooltip>
);
};
2 changes: 0 additions & 2 deletions src/server/api/routers/waitlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
172 changes: 106 additions & 66 deletions src/server/api/routers/weather.ts
Original file line number Diff line number Diff line change
@@ -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<typeof input>) => {
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<typeof timeseriesSchema>)
.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",
});
}
}),
});
5 changes: 5 additions & 0 deletions src/server/api/trpc.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,9 +12,12 @@ type CreateContextOptions = {
};

const createInnerTRPCContext = ({ auth }: CreateContextOptions) => {
const redis = env.UPSTASH_REDIS_REST_URL && Redis.fromEnv();

return {
auth,
db,
redis,
};
};

Expand Down

0 comments on commit 7923b72

Please sign in to comment.