From 9d7679006beccc7f02f2424bee4b6bb86500ed1d Mon Sep 17 00:00:00 2001 From: "MarvinL.com" Date: Wed, 23 Oct 2024 20:01:57 -0400 Subject: [PATCH] feat!: add water statuses --- server/db/schema.ts | 21 ++- server/index.ts | 78 +++++++++- src/components/status-history-item.tsx | 52 +++++++ src/components/status-marker-list.tsx | 81 ++++++---- src/components/status-submission.tsx | 115 ++++++++++++++ src/components/time-ago.tsx | 8 +- src/routeTree.gen.ts | 31 +++- src/routes/__root.tsx | 34 ++++- src/routes/index.lazy.tsx | 204 +++++++++++++------------ src/routes/pani-dlo.lazy.tsx | 6 + 10 files changed, 477 insertions(+), 153 deletions(-) create mode 100644 src/components/status-history-item.tsx create mode 100644 src/components/status-submission.tsx create mode 100644 src/routes/pani-dlo.lazy.tsx diff --git a/server/db/schema.ts b/server/db/schema.ts index c80092a..c42c6b7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,12 +1,23 @@ -import { sqliteTable, real, text, integer } from "drizzle-orm/sqlite-core" +import { sqliteTable, real, text, integer } from "drizzle-orm/sqlite-core"; export const powerStatuses = sqliteTable("power_statuses", { id: text("id").primaryKey(), latitude: real("latitude").notNull(), longitude: real("longitude").notNull(), - hasPower: integer("has_power", { mode: "boolean" }).notNull(), + isOn: integer("has_power", { mode: "boolean" }).notNull(), createdAt: integer("created_at", { mode: "timestamp" }).notNull(), -}) +}); -export type PowerStatus = typeof powerStatuses.$inferSelect -export type InsertPowerStatus = typeof powerStatuses.$inferInsert +export type PowerStatus = typeof powerStatuses.$inferSelect; +export type InsertPowerStatus = typeof powerStatuses.$inferInsert; + +export const waterStatuses = sqliteTable("water_statuses", { + id: text("id").primaryKey(), + latitude: real("latitude").notNull(), + longitude: real("longitude").notNull(), + isOn: integer("has_water", { mode: "boolean" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), +}); + +export type WaterStatus = typeof waterStatuses.$inferSelect; +export type InsertWaterStatus = typeof waterStatuses.$inferInsert; diff --git a/server/index.ts b/server/index.ts index 6517a33..8657e12 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,13 +1,17 @@ import { Hono } from "hono"; -import { powerStatuses } from "./db/schema"; +import { powerStatuses, waterStatuses } from "./db/schema"; import { cors } from "hono/cors"; +import { logger } from "hono/logger"; import { db } from "./db"; +import { desc, getTableColumns, gte, sql } from "drizzle-orm"; const obfuscateCoordinate = (coord: number, factor = 0.001) => { return coord + (Math.random() - 0.667) * factor; }; const app = new Hono(); +app.use(logger()); + app.use( "/api/*", cors({ @@ -15,10 +19,12 @@ app.use( "http://localhost:5173", "https://pani-limie.netlify.app", "https://www.pani-limie.netlify.app", - "https://pani-limie.marvinl.com", "https://pani-limye.marvinl.com", "https://www.pani-limye.marvinl.com", + "https://pani-limie.marvinl.com", "https://www.pani-limie.marvinl.com", + "https://pani-dlo.marvinl.com", + "https://www.pani-dlo.marvinl.com", ], }), ); @@ -27,15 +33,24 @@ app.get("/", (c) => c.text("Pani limyè!")); app.get("/api/power-statuses", async (c) => { try { - const statuses = await db.query.powerStatuses.findMany({ - where: (ps, { gte }) => - gte(ps.createdAt, new Date(Date.now() - 6 * 60 * 60 * 1000)), //limit to 6 hours ago - }); + const statuses = await db + .select({ + ...getTableColumns(powerStatuses), + lastStatus: sql`MAX(${powerStatuses.createdAt})`, + }) + .from(powerStatuses) + .where( + gte(powerStatuses.createdAt, new Date(Date.now() - 6 * 60 * 60 * 1000)), + ) //limit to 6 hours ago + .orderBy(desc(powerStatuses.createdAt)) + .groupBy(powerStatuses.latitude, powerStatuses.longitude); + const obscuredStatuses = statuses.map((status) => ({ ...status, latitude: obfuscateCoordinate(status.latitude), longitude: obfuscateCoordinate(status.longitude), })); + return c.json(obscuredStatuses); } catch (error) { console.error("Error fetching power statuses:", error); @@ -45,14 +60,14 @@ app.get("/api/power-statuses", async (c) => { app.post("/api/power-status", async (c) => { try { - const { latitude, longitude, hasPower } = await c.req.json(); + const { latitude, longitude, isOn } = await c.req.json(); const newStatus = await db .insert(powerStatuses) .values({ id: crypto.randomUUID(), latitude, longitude, - hasPower, + isOn, createdAt: new Date(), }) .returning() @@ -64,6 +79,53 @@ app.post("/api/power-status", async (c) => { } }); +app.get("/api/water-statuses", async (c) => { + try { + const statuses = await db + .select({ + ...getTableColumns(waterStatuses), + lastStatus: sql`MAX(${waterStatuses.createdAt})`, + }) + .from(waterStatuses) + .where( + gte(waterStatuses.createdAt, new Date(Date.now() - 6 * 60 * 60 * 1000)), + ) //limit to 6 hours ago + .orderBy(desc(waterStatuses.createdAt)) + .groupBy(waterStatuses.latitude, waterStatuses.longitude); + + const obscuredStatuses = statuses.map((status) => ({ + ...status, + latitude: obfuscateCoordinate(status.latitude), + longitude: obfuscateCoordinate(status.longitude), + })); + return c.json(obscuredStatuses); + } catch (error) { + console.error("Error fetching water statuses:", error); + return c.json({ error: "Failed to fetch water statuses" }, 500); + } +}); + +app.post("/api/water-status", async (c) => { + try { + const { latitude, longitude, isOn } = await c.req.json(); + const newStatus = await db + .insert(waterStatuses) + .values({ + id: crypto.randomUUID(), + latitude, + longitude, + isOn, + createdAt: new Date(), + }) + .returning() + .get(); + return c.json(newStatus, 201); + } catch (error) { + console.error("Error creating water status:", error); + return c.json({ error: "Failed to create water status" }, 500); + } +}); + const port = 3000; console.log(`Server is running on port ${port}`); diff --git a/src/components/status-history-item.tsx b/src/components/status-history-item.tsx new file mode 100644 index 0000000..d72c1f5 --- /dev/null +++ b/src/components/status-history-item.tsx @@ -0,0 +1,52 @@ +import { FC } from "react"; +import { type Map } from "leaflet"; +import { twMerge } from "tailwind-merge"; +import TimeAgo from "./time-ago"; + +const StatusHistoryItem: FC<{ + latitude: number; + longitude: number; + isOn: boolean; + createdAt: Date; + map: Map | null; + type: string | "power" | "water"; +}> = ({ latitude, longitude, isOn, createdAt, map, type }) => { + return ( + + ); +}; +export default StatusHistoryItem; diff --git a/src/components/status-marker-list.tsx b/src/components/status-marker-list.tsx index fe985e6..fd71b19 100644 --- a/src/components/status-marker-list.tsx +++ b/src/components/status-marker-list.tsx @@ -1,31 +1,36 @@ -import { Lightbulb, LightbulbOff } from "lucide-react"; +import { Droplets, Lightbulb, LightbulbOff, Milk } from "lucide-react"; import { useState } from "react"; import { Marker, Popup, useMapEvents } from "react-leaflet"; import MarkerClusterGroup from "react-leaflet-cluster"; -import { type PowerStatus } from "../../server/db/schema"; +import { WaterStatus, type PowerStatus } from "../../server/db/schema"; import TimeAgo from "./time-ago"; import { divIcon, point } from "leaflet"; -import { counting } from "radash"; import { twMerge } from "tailwind-merge"; const StatusList = ({ - powerStatuses, -}: { - powerStatuses: PowerStatus[] | undefined; -}) => { + statuses, + type, +}: + | { + statuses: PowerStatus[] | undefined; + type: "power"; + } + | { + statuses: WaterStatus[] | undefined; + type: "water"; + }) => { const [userLocation, setUserLocation] = useState<[number, number] | null>( null, ); - const [test, setTest] = useState(null); const map = useMapEvents({ locationfound: (e) => { console.log("location found", e); setUserLocation([e.latlng.lat, e.latlng.lng]); map.fitBounds( - powerStatuses?.map(({ latitude, longitude }) => [ - latitude, - longitude, - ]) as [number, number][], + statuses?.map(({ latitude, longitude }) => [latitude, longitude]) as [ + number, + number, + ][], { animate: true, duration: 0.2, @@ -40,20 +45,27 @@ const StatusList = ({ {userLocation && } { - const markers = cluster.getAllChildMarkers(); - const res = counting(markers, (m) => - m.options.title === "💡" ? "light" : "dark", - ); + const markers = cluster.getAllChildMarkers().sort((a, b) => { + const first = a.options.title!.split("_"); + const second = b.options.title!.split("_"); + return new Date(second[2]).getTime() - new Date(first[2]).getTime(); + }); + const [type, latestStatus, _] = markers[0].options?.title!.split("_"); return divIcon({ html: `
- ${cluster.getChildCount()}
+ ${cluster.getChildCount()} `, className: twMerge( "border-4 rounded-full ", - (res?.light ?? 0) > (res?.dark ?? 0) - ? "border-green-500/10 bg-green-500/90" - : "border-red-500/10 bg-red-500/90", + type === "power" && + (latestStatus === "on" + ? "border-green-500/10 bg-green-500/90" + : "border-red-500/10 bg-red-500/90"), + type === "water" && + (latestStatus === "on" + ? "border-cyan-500/10 bg-cyan-500/90" + : "border-cyan-900/10 bg-cyan-900/90"), ), iconSize: point(60, 60), }); @@ -66,25 +78,34 @@ const StatusList = ({ showCoverageOnHover zoomToBoundsOnClick > - {powerStatuses?.map( - ({ id, latitude, longitude, hasPower, createdAt }) => ( + {statuses?.map((data) => { + const { id, latitude, longitude, createdAt, isOn } = data; + + return ( - {hasPower ? ( - - ) : ( - - )} + {type === "power" && + (isOn ? ( + + ) : ( + + ))} + {type === "water" && + (isOn ? ( + + ) : ( + + ))}
- ), - )} + ); + })}
); diff --git a/src/components/status-submission.tsx b/src/components/status-submission.tsx new file mode 100644 index 0000000..599952d --- /dev/null +++ b/src/components/status-submission.tsx @@ -0,0 +1,115 @@ +import { Droplet, Droplets, Lightbulb, LightbulbOff, Milk } from "lucide-react"; +import { POWER_STATE, StatusType } from "../routes/index.lazy"; +import { FC, useState } from "react"; +import { twMerge } from "tailwind-merge"; + +const StatusSubmission: FC<{ + handleSubmit: (hasPower: boolean) => void; + isPending: boolean; + isSuccess: boolean; + type?: "power" | "water"; + className?: string; +}> = ({ handleSubmit, isPending, isSuccess, type = "power", className }) => { + const [selected, setSelected] = useState(POWER_STATE.UNKNOWN); + + const doSubmit = (hasPower: boolean) => { + setSelected(hasPower ? POWER_STATE.ON : POWER_STATE.OFF); + handleSubmit(hasPower); + }; + + return ( +
+ {((!isSuccess && selected !== POWER_STATE.OFF) || + (isSuccess && selected === POWER_STATE.OFF)) && ( + + )} + {isSuccess && selected === POWER_STATE.ON && ( +

+ Super ! Reviens nous dire si ça change. +

+ )} + {((!isSuccess && selected !== POWER_STATE.ON) || + (isSuccess && selected === POWER_STATE.ON)) && ( + + )} + {isSuccess && selected === POWER_STATE.OFF && ( +

+ 😵‍💫 Bon Courage…
+ Reviens nous dire quand ça change. +

+ )} +
+ ); +}; +export default StatusSubmission; diff --git a/src/components/time-ago.tsx b/src/components/time-ago.tsx index 2094327..c4558eb 100644 --- a/src/components/time-ago.tsx +++ b/src/components/time-ago.tsx @@ -1,10 +1,10 @@ import { twMerge } from "tailwind-merge"; import useRelativeTime from "../useRelativeTime"; -const TimeAgo: React.FC<{ date: Date; className?: string }> = ({ - date, - className, -}) => { +const TimeAgo: React.FC<{ + date: Date; + className?: string; +}> = ({ date, className }) => { const relativeTime = useRelativeTime(date); return ( diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 230d9f9..67d5f5c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,11 +16,18 @@ import { Route as rootRoute } from './routes/__root' // Create Virtual Routes +const PaniDloLazyImport = createFileRoute('/pani-dlo')() const MentionsLegalesLazyImport = createFileRoute('/mentions-legales')() const IndexLazyImport = createFileRoute('/')() // Create/Update Routes +const PaniDloLazyRoute = PaniDloLazyImport.update({ + id: '/pani-dlo', + path: '/pani-dlo', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/pani-dlo.lazy').then((d) => d.Route)) + const MentionsLegalesLazyRoute = MentionsLegalesLazyImport.update({ id: '/mentions-legales', path: '/mentions-legales', @@ -53,6 +60,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MentionsLegalesLazyImport parentRoute: typeof rootRoute } + '/pani-dlo': { + id: '/pani-dlo' + path: '/pani-dlo' + fullPath: '/pani-dlo' + preLoaderRoute: typeof PaniDloLazyImport + parentRoute: typeof rootRoute + } } } @@ -61,36 +75,41 @@ declare module '@tanstack/react-router' { export interface FileRoutesByFullPath { '/': typeof IndexLazyRoute '/mentions-legales': typeof MentionsLegalesLazyRoute + '/pani-dlo': typeof PaniDloLazyRoute } export interface FileRoutesByTo { '/': typeof IndexLazyRoute '/mentions-legales': typeof MentionsLegalesLazyRoute + '/pani-dlo': typeof PaniDloLazyRoute } export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexLazyRoute '/mentions-legales': typeof MentionsLegalesLazyRoute + '/pani-dlo': typeof PaniDloLazyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/mentions-legales' + fullPaths: '/' | '/mentions-legales' | '/pani-dlo' fileRoutesByTo: FileRoutesByTo - to: '/' | '/mentions-legales' - id: '__root__' | '/' | '/mentions-legales' + to: '/' | '/mentions-legales' | '/pani-dlo' + id: '__root__' | '/' | '/mentions-legales' | '/pani-dlo' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexLazyRoute: typeof IndexLazyRoute MentionsLegalesLazyRoute: typeof MentionsLegalesLazyRoute + PaniDloLazyRoute: typeof PaniDloLazyRoute } const rootRouteChildren: RootRouteChildren = { IndexLazyRoute: IndexLazyRoute, MentionsLegalesLazyRoute: MentionsLegalesLazyRoute, + PaniDloLazyRoute: PaniDloLazyRoute, } export const routeTree = rootRoute @@ -106,7 +125,8 @@ export const routeTree = rootRoute "filePath": "__root.tsx", "children": [ "/", - "/mentions-legales" + "/mentions-legales", + "/pani-dlo" ] }, "/": { @@ -114,6 +134,9 @@ export const routeTree = rootRoute }, "/mentions-legales": { "filePath": "mentions-legales.lazy.tsx" + }, + "/pani-dlo": { + "filePath": "pani-dlo.lazy.tsx" } } } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 64688d4..cc2c1ed 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -6,11 +6,35 @@ const queryClient = new QueryClient(); export const Route = createRootRoute({ component: () => (
-
- -

Pani limyè !

- - On dit merci EDF PEI… +
+
+ +

Pani limyè

+ + {location.hostname.includes("pani-limye") && ( + On dit merci EDF PEI… + )} +
+
+ +

Pani Dlo

+ + {location.hostname.includes("pani-dlo") && ( + On dit merci la Guadeloupe + )} +
diff --git a/src/routes/index.lazy.tsx b/src/routes/index.lazy.tsx index 3a79ee2..8daf5e2 100644 --- a/src/routes/index.lazy.tsx +++ b/src/routes/index.lazy.tsx @@ -12,23 +12,31 @@ import { import { useEffect, useMemo, useState } from "react"; import { MapContainer, Marker, TileLayer } from "react-leaflet"; import { twMerge } from "tailwind-merge"; -import { InsertPowerStatus, type PowerStatus } from "../../server/db/schema"; +import { + InsertPowerStatus, + InsertWaterStatus, + WaterStatus, + type PowerStatus, +} from "../../server/db/schema"; import StatusList from "../components/status-marker-list"; import TimeAgo from "../components/time-ago"; import { createLazyFileRoute, Link } from "@tanstack/react-router"; +import StatusSubmission from "../components/status-submission"; +import StatusHistoryItem from "../components/status-history-item"; +import { LayersControl } from "react-leaflet"; export const Route = createLazyFileRoute("/")({ component: HomePage, }); -const POWER_STATE = Object.freeze({ ON: 1, OFF: 0, UNKNOWN: 2 }); -type StatusType = (typeof POWER_STATE)[keyof typeof POWER_STATE]; +export const POWER_STATE = Object.freeze({ ON: 1, OFF: 0, UNKNOWN: 2 }); +export type StatusType = (typeof POWER_STATE)[keyof typeof POWER_STATE]; -function HomePage() { +export function HomePage({ isWater = false }: { isWater?: boolean }) { const [userLocation, setUserLocation] = useState<[number, number] | null>( null, ); const [map, setMap] = useState(null); - const [selected, setSelected] = useState(POWER_STATE.UNKNOWN); + const [error, setError] = useState(null); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -39,13 +47,19 @@ function HomePage() { await ky.get(import.meta.env.VITE_API_URL + "/power-statuses").json(), }); + const { data: waterStatuses } = useQuery({ + queryKey: ["waterStatuses"], + queryFn: async () => + await ky.get(import.meta.env.VITE_API_URL + "/water-statuses").json(), + }); + const { mutate, isPending, isSuccess } = useMutation({ - mutationFn: async (hasPower: boolean) => { + mutationFn: async (isOn: boolean) => { const newStatus: InsertPowerStatus = { id: Date.now().toString(), latitude: userLocation?.[0] ?? 0, longitude: userLocation?.[1] ?? 0, - hasPower, + isOn, createdAt: new Date(), }; await ky.post(import.meta.env.VITE_API_URL + "/power-status", { @@ -54,8 +68,11 @@ function HomePage() { return newStatus; }, - onSettled: (data, error) => { - if (error) setSelected(POWER_STATE.UNKNOWN); + onSettled: async (data, error) => { + if (error) { + setError(error); + return; + } if (map && data) map.flyTo([data.latitude, data.longitude], 16, { animate: true, @@ -65,6 +82,54 @@ function HomePage() { }, }); + const { + mutate: mutateWater, + isPending: isPendingWater, + isSuccess: isSuccessWater, + } = useMutation({ + mutationFn: async (isOn: boolean) => { + const newStatus: InsertWaterStatus = { + id: Date.now().toString(), + latitude: userLocation?.[0] ?? 0, + longitude: userLocation?.[1] ?? 0, + isOn, + createdAt: new Date(), + }; + await ky.post(import.meta.env.VITE_API_URL + "/water-status", { + json: newStatus, + }); + return newStatus; + }, + + onSettled: async (data, error) => { + if (error) { + setError(error); + return; + } + if (map && data) + map.flyTo([data.latitude, data.longitude], 16, { + animate: true, + duration: 0.2, + }); + return queryClient.invalidateQueries({ queryKey: ["waterStatuses"] }); + }, + }); + const statuses = useMemo< + ((PowerStatus | WaterStatus) & { type: string })[] + >(() => { + const result = []; + if (powerStatuses) + result.push(...powerStatuses.map((ps) => ({ ...ps, type: "power" }))); + if (waterStatuses) + result.push(...waterStatuses.map((ws) => ({ ...ws, type: "water" }))); + if (result.length > 0) + return result.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return result; + }, [powerStatuses, waterStatuses]); + const getLocation = () => { setLoading(true); navigator.geolocation.getCurrentPosition( @@ -94,16 +159,19 @@ function HomePage() { style={{ height: "100%", minHeight: "400px" }} > + + + + + + + + {userLocation && } - ), - [powerStatuses], + [powerStatuses, waterStatuses, userLocation], ); - const handleSubmit = (hasPower: boolean) => { - setSelected(hasPower ? POWER_STATE.ON : POWER_STATE.OFF); - mutate(hasPower); - }; return ( <>
@@ -134,102 +202,44 @@ function HomePage() {
)} <> + {error && ( + + Une erreur s'est produite, essaie de recommencer… + + )} {userLocation && ( -
- {((!isSuccess && selected !== POWER_STATE.OFF) || - (isSuccess && selected === POWER_STATE.OFF)) && ( - - )} - {isSuccess && selected === POWER_STATE.ON && ( -

- Super ! Reviens nous dire si ça change. -

- )} - {((!isSuccess && selected !== POWER_STATE.ON) || - (isSuccess && selected === POWER_STATE.ON)) && ( - - )} - {isSuccess && selected === POWER_STATE.OFF && ( -

- 🕯️ Bon Courage…
- Reviens nous dire quand ça change. -

- )} -
+ <> + + + )} +

Historique

- {powerStatuses?.length === 0 && ( + {statuses?.length === 0 && (

Pas de contributions dans les 6 dernières heures.{" "}

)} - {powerStatuses + {statuses ?.sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ) - .map(({ id, latitude, longitude, hasPower, createdAt }) => ( - + .map(({ id, ...rest }) => ( + ))}
diff --git a/src/routes/pani-dlo.lazy.tsx b/src/routes/pani-dlo.lazy.tsx new file mode 100644 index 0000000..0851eb8 --- /dev/null +++ b/src/routes/pani-dlo.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { HomePage } from "./index.lazy"; + +export const Route = createLazyFileRoute("/pani-dlo")({ + component: () => HomePage({ isWater: true }), +});