diff --git a/.changeset/rotten-rocks-argue.md b/.changeset/rotten-rocks-argue.md new file mode 100644 index 000000000..8d8bda2f3 --- /dev/null +++ b/.changeset/rotten-rocks-argue.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Now some application calculation results are cached in the browser local storage. The server only performs a cached calculation again if the cache's input hash doesn't match the current input's hash. diff --git a/fdm-app/app/components/blocks/header/balance.tsx b/fdm-app/app/components/blocks/header/balance.tsx index af469be8d..3735ce783 100644 --- a/fdm-app/app/components/blocks/header/balance.tsx +++ b/fdm-app/app/components/blocks/header/balance.tsx @@ -30,8 +30,10 @@ export function HeaderBalance({ <> - - Nutriƫntenbalans + + + Nutriƫntenbalans + diff --git a/fdm-app/app/components/blocks/header/nutrient-advice.tsx b/fdm-app/app/components/blocks/header/nutrient-advice.tsx index 6bd2423f7..3336d5e78 100644 --- a/fdm-app/app/components/blocks/header/nutrient-advice.tsx +++ b/fdm-app/app/components/blocks/header/nutrient-advice.tsx @@ -30,10 +30,13 @@ export function HeaderNutrientAdvice({ <> - - Bemestingsadvies + + + Bemestingsadvies + {b_id ? ( diff --git a/fdm-app/app/lib/middleware.ts b/fdm-app/app/lib/middleware.ts new file mode 100644 index 000000000..f9204832c --- /dev/null +++ b/fdm-app/app/lib/middleware.ts @@ -0,0 +1,47 @@ +import { redirect } from "react-router" +import type { Route } from "../+types/root" +import type { CacheStore, DataWithInputHash } from "../store/calculation-cache" + +/** + * Client middleware that redirects with the most recent cacheHash, obtained from the provided cache store, when the route matches the provided matcher, if needed. + * + * @param matcherProvider function that return a regexp that matches url strings like `/farm/b_id_farm/calendar/balance/nitrogen` + * @param storeProvider cache store to use for this match + * @param getId function to oobtain the id out of the client middleware function args + * + * @returns a client middleware function that either throws redirect or calls next as needed + */ +export function splatCacheMiddleware( + matcherProvider: () => RegExp, + storeProvider: () => CacheStore, + getId: (args: Parameters[0]) => string, +): Route.ClientMiddlewareFunction { + return (args, next) => { + const { request } = args + if (typeof window === "undefined") return next() + + const requestUrl = new URL(request.url) + if (!matcherProvider().test(requestUrl.pathname)) return next() + + const previousCacheHash = requestUrl.searchParams.get("cacheHash") + let newCacheHash: string | null = previousCacheHash + + // Get cache hash for the cache we (possibly) have + const cachedData = storeProvider().get(getId(args)) + if (cachedData?.inputHash) { + newCacheHash = cachedData.inputHash + } else { + newCacheHash = null + } + + // Redirect if the `cacheHash` search param was wrong + if (previousCacheHash !== newCacheHash) { + newCacheHash + ? requestUrl.searchParams.set("cacheHash", newCacheHash) + : requestUrl.searchParams.delete("cacheHash") + throw redirect(requestUrl.toString()) + } + + return next() + } +} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance._index.tsx deleted file mode 100644 index 80c839111..000000000 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance._index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from "react-router" - -export async function loader() { - // Redirect to nitrogen page - return redirect("./nitrogen") -} diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx index c849e8596..90d4d9136 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen.$b_id.tsx @@ -11,7 +11,8 @@ import { CircleAlert, CircleCheck, } from "lucide-react" -import { Suspense, use } from "react" +import hash from "object-hash" +import { Suspense, use, useEffect } from "react" import { data, type LoaderFunctionArgs, @@ -19,6 +20,7 @@ import { NavLink, useLoaderData, useLocation, + useSearchParams, } from "react-router" import { NitrogenBalanceChart } from "~/components/blocks/balance/nitrogen-chart" import NitrogenBalanceDetails from "~/components/blocks/balance/nitrogen-details" @@ -38,6 +40,7 @@ import { clientConfig } from "~/lib/config" import { fdm } from "~/lib/fdm.server" import { useCalendarStore } from "~/store/calendar" import { serverConfig } from "../lib/config.server" +import { useFieldNitrogenBalanceCache } from "../store/calculation-cache" // Meta export const meta: MetaFunction = () => { @@ -91,6 +94,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get details of field const field = await getField(fdm, session.principal_id, b_id) + const url = new URL(request.url) + const cacheHash = url.searchParams.get("cacheHash") + // Return promise directly for React Router v7 Suspense pattern const nitrogenBalancePromise = collectInputForNitrogenBalance( fdm, @@ -100,6 +106,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { datasetsUrl, ) .then(async (input) => { + const inputHash = hash(input) + if (inputHash === cacheHash) { + return { useCache: true } + } const result = await calculateNitrogenBalance(input) return { input: input.fields.find( @@ -110,6 +120,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { (field: { b_id: string }) => field.b_id === b_id, ), errorMessage: null, + inputHash: inputHash, } }) .catch((error) => ({ @@ -154,12 +165,38 @@ function NitrogenBalance({ field, nitrogenBalanceResult, }: Awaited>) { - const { input, result, errorMessage } = use(nitrogenBalanceResult) + const data = use(nitrogenBalanceResult) const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() const page = location.pathname const calendar = useCalendarStore((state) => state.calendar) + const fieldNitrogenBalanceCache = useFieldNitrogenBalanceCache() + + const cachedData = fieldNitrogenBalanceCache.get(field.b_id) + + useEffect(() => { + if ( + (!data.useCache || !cachedData?.inputHash) && + !data.errorMessage && + data.inputHash + ) { + fieldNitrogenBalanceCache.set(field.b_id, data) + } + }, [field.b_id, data, cachedData?.inputHash, fieldNitrogenBalanceCache.set]) + + if (data.useCache && !cachedData && searchParams.get("cacheHash")) { + setSearchParams((searchParams) => { + searchParams.delete("cacheHash") + return searchParams + }) + return null + } + + const { input, result, errorMessage } = + data.useCache && cachedData ? cachedData : data + if (!input) { return (
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx index 97c8ed505..72191d79b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.nitrogen._index.tsx @@ -12,7 +12,8 @@ import { CircleAlert, CircleCheck, } from "lucide-react" -import { Suspense, use } from "react" +import hash from "object-hash" +import { Suspense, use, useEffect } from "react" import { data, type LoaderFunctionArgs, @@ -20,6 +21,7 @@ import { NavLink, useLoaderData, useLocation, + useSearchParams, } from "react-router" import { NitrogenBalanceChart } from "~/components/blocks/balance/nitrogen-chart" import { NitrogenBalanceFallback } from "~/components/blocks/balance/skeletons" @@ -36,6 +38,7 @@ import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { fdm } from "~/lib/fdm.server" import { useFieldFilterStore } from "~/store/field-filter" +import { useFarmNitrogenBalanceCache } from "../store/calculation-cache" // Meta export const meta: MetaFunction = () => { @@ -78,6 +81,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get details of fields const fields = await getFields(fdm, session.principal_id, b_id_farm) + const url = new URL(request.url) + const cacheHash = url.searchParams.get("cacheHash") + const asyncData = (async () => { // Collect input data for nutrient balance calculation const nitrogenBalanceInput = await collectInputForNitrogenBalance( @@ -87,6 +93,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { timeframe, ) + const inputHash = hash(nitrogenBalanceInput) + if (inputHash === cacheHash) { + return { useCache: true } + } + let nitrogenBalanceResult = null as NitrogenBalanceNumeric | null let errorMessage = null as string | null try { @@ -99,6 +110,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return { nitrogenBalanceResult: nitrogenBalanceResult, errorMessage: errorMessage, + inputHash: inputHash, } })() @@ -140,10 +152,42 @@ function FarmBalanceNitrogenOverview({ }: Awaited>) { const location = useLocation() const page = location.pathname - const { nitrogenBalanceResult, errorMessage } = use(asyncData) + const [searchParams, setSearchParams] = useSearchParams() + const data = use(asyncData) const { showProductiveOnly } = useFieldFilterStore() - const resolvedNitrogenBalanceResult = nitrogenBalanceResult + const farmNitrogenBalanceCache = useFarmNitrogenBalanceCache() + + const cachedData = farmNitrogenBalanceCache.get(farm.b_id_farm) + + useEffect(() => { + if ( + (!data.useCache || !cachedData?.inputHash) && + !data.errorMessage && + data.inputHash + ) { + farmNitrogenBalanceCache.set(farm.b_id_farm, data) + } + }, [ + farm.b_id_farm, + data, + cachedData?.inputHash, + farmNitrogenBalanceCache.set, + ]) + + if (data.useCache && !cachedData && searchParams.get("cacheHash")) { + setSearchParams((searchParams) => { + searchParams.delete("cacheHash") + return searchParams + }) + return null + } + + const { + nitrogenBalanceResult: resolvedNitrogenBalanceResult, + errorMessage, + } = data.useCache && cachedData ? cachedData : data + if (errorMessage) { return (
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx new file mode 100644 index 000000000..20e139081 --- /dev/null +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.tsx @@ -0,0 +1,49 @@ +import { redirect } from "react-router" +import { splatCacheMiddleware } from "~/lib/middleware" +import { + useFarmNitrogenBalanceCache, + useFieldNitrogenBalanceCache, +} from "~/store/calculation-cache" +import type { Route } from "./+types/farm.$b_id_farm.$calendar.balance" + +// In case the user navigated directly by URL +export function loader({ params, request }: Route.LoaderArgs) { + if (/\/balance\/?($|\?)/.test(request.url)) { + throw redirect( + `/farm/${params.b_id_farm}/${params.calendar}/balance/nitrogen`, + ) + } + + return {} +} + +// In case the user navigated within the application +const redirectMiddleware: Route.ClientMiddlewareFunction = ( + { request, params }, + next, +) => { + if (/\/balance\/?($|\?)/.test(request.url)) { + throw redirect( + `/farm/${params.b_id_farm}/${params.calendar}/balance/nitrogen`, + ) + } + + return next() +} + +export const clientMiddleware = [ + // Redirect to nitrogen balance if what kind of balance analysis needed is not known yet + redirectMiddleware, + // Farm nitrogen + splatCacheMiddleware( + () => /\/nitrogen\/?$/, + () => useFarmNitrogenBalanceCache.getState(), + ({ params }) => params.b_id_farm || "", + ), + // Field nitrogen + splatCacheMiddleware( + () => /\/nitrogen\/.+\/?$/, + () => useFieldNitrogenBalanceCache.getState(), + ({ params }) => params.b_id || "", + ), +] diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx index fd3f464de..6f7dbd37b 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.norms.tsx @@ -4,14 +4,17 @@ import { type GebruiksnormResult, } from "@svenvw/fdm-calculator" import { getFarm, getFarms, getFields } from "@svenvw/fdm-core" -import { Suspense, use } from "react" +import hash from "object-hash" +import { Suspense, use, useEffect } from "react" import { data, type LoaderFunctionArgs, type MetaFunction, NavLink, + redirect, useLoaderData, useLocation, + useSearchParams, } from "react-router" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { Header } from "~/components/blocks/header/base" @@ -30,7 +33,9 @@ import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { useFarmNormsCache } from "~/store/calculation-cache" import { useFieldFilterStore } from "~/store/field-filter" +import type { Route } from "./+types/farm.$b_id_farm.$calendar.norms" interface FieldNorm { b_id: string @@ -119,19 +124,45 @@ export async function loader({ request, params }: LoaderFunctionArgs) { let errorMessage = null as string | null let hasFieldNormErrors = false const fieldErrorMessages: string[] = [] + + const url = new URL(request.url) + const cacheHash = url.searchParams.get("cacheHash") + + let inputHash: string | undefined try { // Calculate norms per field const functionsForms = createFunctionsForNorms("NL", calendar) - const fieldNormPromises = fields.map(async (field) => { + const inputPromises = fields.map(async (field) => { try { // Collect the input - const input = await functionsForms.collectInputForNorms( + return await functionsForms.collectInputForNorms( fdm, session.principal_id, field.b_id, ) + } catch (error) { + return { + b_id: field.b_id, + b_area: field.b_area, + errorMessage: String(error).replace("Error: ", ""), + } + } + }) + + const inputs = await Promise.all(inputPromises) + inputHash = hash(inputs) + if (inputHash === cacheHash) { + return { useCache: true } + } + + const fieldNormPromises = inputs.map(async (input) => { + if (input.errorMessage) { + return input + } + const { field } = input + try { // Calculate the norms const [normManure, normPhosphate, normNitrogen] = await Promise.all([ @@ -219,6 +250,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Return user information from loader return { + inputHash: inputHash, errorMessage: errorMessage, fieldNorms: fieldNorms, farmNorms: farmNorms, @@ -241,6 +273,42 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } +/** + * Client middleware that redirects with the most recent cacheHash search parameter as needed + */ +const clientCacheMiddleware: Route.ClientMiddlewareFunction = async ( + { params, request }, + next, +) => { + if (typeof window === "undefined") return next() + const requestUrl = new URL(request.url) + + const previousCacheHash = requestUrl.searchParams.get("cacheHash") + let newCacheHash: string | null = previousCacheHash + + // Get cache hash for the cache we (possibly) have + const cachedData = useFarmNormsCache.getState().get(params.b_id_farm) + if (cachedData?.inputHash) { + newCacheHash = cachedData.inputHash + } else { + newCacheHash = null + } + + // Redirect if the `cacheHash` search param was wrong + if (previousCacheHash !== newCacheHash) { + newCacheHash + ? requestUrl.searchParams.set("cacheHash", newCacheHash) + : requestUrl.searchParams.delete("cacheHash") + throw redirect(requestUrl.toString()) + } + + return next() +} + +export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ + clientCacheMiddleware, +] + export default function FarmNormsBlock() { const loaderData = useLoaderData() @@ -281,13 +349,42 @@ export default function FarmNormsBlock() { * would not render until `asyncData` resolves and the fallback would never be shown. */ function Norms(loaderData: Awaited>) { + const [searchParams, setSearchParams] = useSearchParams() + const data = use(loaderData.asyncData) + + const farmNormsCache = useFarmNormsCache() + + const cachedData = farmNormsCache.get(loaderData.b_id_farm) + + useEffect(() => { + if ( + (!data.useCache || !cachedData?.inputHash) && + !data.errorMessage && + data.inputHash + ) { + farmNormsCache.set(loaderData.b_id_farm, data) + } + }, [loaderData.b_id_farm, data, cachedData?.inputHash, farmNormsCache.set]) + + if (data.useCache && !cachedData && searchParams.get("cacheHash")) { + setSearchParams((searchParams) => { + searchParams.delete("cacheHash") + return searchParams + }) + return null + } + const { farmNorms, fieldNorms, errorMessage, hasFieldNormErrors, fieldErrorMessages, - } = use(loaderData.asyncData) + } = + data.useCache && cachedData + ? farmNormsCache.get(loaderData.b_id_farm) + : data + const { showProductiveOnly } = useFieldFilterStore() const location = useLocation() diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx index ef0605278..d95059743 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.$b_id.tsx @@ -6,12 +6,14 @@ import { getFertilizers, getField, } from "@svenvw/fdm-core" -import { Suspense, use } from "react" +import hash from "object-hash" +import { Suspense, use, useEffect } from "react" import { type LoaderFunctionArgs, type MetaFunction, useLoaderData, useLocation, + useSearchParams, } from "react-router" import { FieldNutrientAdviceLayout } from "~/components/blocks/nutrient-advice/layout" import { getNutrientsDescription } from "~/components/blocks/nutrient-advice/nutrients" @@ -28,6 +30,7 @@ import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { useFieldNutrientAdviceCache } from "../store/calculation-cache" // Meta export const meta: MetaFunction = () => { @@ -75,6 +78,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const field = await getField(fdm, session.principal_id, b_id) + const url = new URL(request.url) + const cacheHash = url.searchParams.get("cacheHash") const asyncData = (async () => { try { const currentSoilData = getCurrentSoilData( @@ -120,6 +125,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // For now take the first cultivation const b_lu_catalogue = cultivations[0].b_lu_catalogue + const inputHash = hash([ + resolvedCurrentSoilData, + resolvedFertilizerApplications, + resolvedFertilizers, + b_lu_catalogue, + ]) + if (inputHash === cacheHash) { + return { useCache: true } + } + const doses = calculateDose({ applications: resolvedFertilizerApplications, fertilizers: resolvedFertilizers, @@ -138,6 +153,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { fertilizerApplications: resolvedFertilizerApplications, fertilizers: resolvedFertilizers, errorMessage: undefined, + inputHash: inputHash, } } catch (error) { return { errorMessage: String(error).replace("Error: ", "") } @@ -211,8 +227,42 @@ function FieldNutrientAdvice({ traceNutrients: NutrientDescription[] }) { const { field, calendar, nutrientsDescription } = loaderData - const asyncData = use(loaderData.asyncData) + const serverAsyncData = use(loaderData.asyncData) const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + + const fieldNutrientAdviceCache = useFieldNutrientAdviceCache() + const cachedData = fieldNutrientAdviceCache.get(field.b_id) + useEffect(() => { + if ( + (!serverAsyncData.useCache || !cachedData?.inputHash) && + !serverAsyncData.errorMessage && + serverAsyncData.inputHash + ) { + fieldNutrientAdviceCache.set(field.b_id, serverAsyncData) + } + }, [ + field.b_id, + serverAsyncData, + cachedData?.inputHash, + fieldNutrientAdviceCache.set, + ]) + + if ( + serverAsyncData.useCache && + !cachedData && + searchParams.get("cacheHash") + ) { + setSearchParams((searchParams) => { + searchParams.delete("cacheHash") + return searchParams + }) + return null + } + + const asyncData = ( + serverAsyncData.useCache ? cachedData : serverAsyncData + ) as typeof serverAsyncData if (typeof asyncData.errorMessage === "string") { return ( diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx index 54e8cf3fb..44df482e5 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.nutrient_advice.tsx @@ -4,6 +4,7 @@ import { type LoaderFunctionArgs, type MetaFunction, Outlet, + redirect, useLoaderData, } from "react-router" import { FarmTitle } from "~/components/blocks/farm/farm-title" @@ -16,6 +17,9 @@ import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { splatCacheMiddleware } from "../lib/middleware" +import { useFieldNutrientAdviceCache } from "../store/calculation-cache" +import type { Route } from "./+types/farm.$b_id_farm.$calendar.nutrient_advice" // Meta export const meta: MetaFunction = () => { @@ -118,6 +122,43 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } +// In case the user navigated within the application +const redirectMiddleware: Route.ClientMiddlewareFunction = ( + { request, params }, + next, +) => { + const url = new URL(request.url) + + if (/\/nutrient_advice\/?$/.test(url.pathname)) { + const nutrientAdviceCache = useFieldNutrientAdviceCache.getState() + + const cachedFieldId = Object.keys(nutrientAdviceCache.db)[0] + + if (cachedFieldId) { + const cachedData = nutrientAdviceCache.get(cachedFieldId) + + if (cachedData?.inputHash) { + throw redirect( + `/farm/${params.b_id_farm}/${params.calendar}/nutrient_advice/${cachedFieldId}?cacheHash=${cachedData.inputHash}`, + ) + } + } + } + + return next() +} + +export const clientMiddleware = [ + // Redirect to nitrogen balance if what kind of balance analysis needed is not known yet + redirectMiddleware, + // Farm nitrogen + splatCacheMiddleware( + () => /\/nutrient_advice\/.+\/?$/, + () => useFieldNutrientAdviceCache.getState(), + ({ params }) => params.b_id || "", + ), +] + /** * Renders the layout for managing farm settings. * diff --git a/fdm-app/app/store/calculation-cache.ts b/fdm-app/app/store/calculation-cache.ts new file mode 100644 index 000000000..5920b0bd6 --- /dev/null +++ b/fdm-app/app/store/calculation-cache.ts @@ -0,0 +1,37 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +export interface DataWithInputHash { + inputHash?: string | undefined +} + +export interface CacheStore { + db: Record + get: (id: string) => T | undefined + set: (id: string, val: T) => void +} + +function createCache(name: string) { + return create( + persist>( + (_set, _get) => ({ + db: {}, + get: (id) => _get().db[id], + set: (id, val) => _set({ db: { ..._get().db, [id]: val } }), + }), + { + name, + version: `fdm-calculator:${PUBLIC_FDM_CALCULATOR_VERSION}`, + }, + ), + ) +} + +export const useFarmNormsCache = createCache("farm-norms-cache") +export const useFarmNitrogenBalanceCache = createCache( + "farm-nitrogen-balance-cache", +) +export const useFieldNitrogenBalanceCache = createCache( + "field-nitrogen-balance-cache", +) +export const useFieldNutrientAdviceCache = createCache("field-norms-cache") diff --git a/fdm-app/package.json b/fdm-app/package.json index fa98e58c9..8ac23b3de 100644 --- a/fdm-app/package.json +++ b/fdm-app/package.json @@ -52,6 +52,7 @@ "lucide-react": "^0.544.0", "mapbox-gl": "^3.15.0", "next-themes": "^0.4.6", + "object-hash": "^3.0.0", "postgres": "^3.4.7", "posthog-js": "^1.266.0", "posthog-node": "^5.8.4", @@ -93,6 +94,7 @@ "@types/mapbox-gl": "^3.4.1", "@types/mapbox__geojson-extent": "^1.0.3", "@types/mapbox__mapbox-gl-geocoder": "^5.0.0", + "@types/object-hash": "^3.0.6", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@types/react-map-gl": "^6.1.7", diff --git a/fdm-app/vite.config.ts b/fdm-app/vite.config.ts index 8b7b5fa19..9ea1230f6 100644 --- a/fdm-app/vite.config.ts +++ b/fdm-app/vite.config.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises" + import { reactRouter } from "@react-router/dev/vite" import { type SentryReactRouterBuildOptions, @@ -7,7 +9,16 @@ import tailwindcss from "@tailwindcss/vite" import { defineConfig } from "vite" import tsconfigPaths from "vite-tsconfig-paths" -export default defineConfig((config) => { +export default defineConfig(async (config) => { + // We need to go one directory up since package.json is not inside the dist folder + const fdmCalculatorPackageJsonPath = new URL( + "../package.json", + import.meta.resolve("@svenvw/fdm-calculator"), + ) + const fdmCalculatorPackage = JSON.parse( + await fs.readFile(fdmCalculatorPackageJsonPath, { encoding: "utf-8" }), + ) + return { plugins: [ reactRouter(), @@ -35,6 +46,9 @@ export default defineConfig((config) => { envPrefix: "PUBLIC_", define: { global: {}, + PUBLIC_FDM_CALCULATOR_VERSION: JSON.stringify( + fdmCalculatorPackage.version || "0.7.0", + ), }, ssr: { noExternal: ["posthog-js", "posthog-js/react"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abaf3dcbd..1a5a5ec26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + object-hash: + specifier: ^3.0.0 + version: 3.0.0 postgres: specifier: ^3.4.7 version: 3.4.7 @@ -302,6 +305,9 @@ importers: '@types/mapbox__mapbox-gl-geocoder': specifier: ^5.0.0 version: 5.0.0 + '@types/object-hash': + specifier: ^3.0.6 + version: 3.0.6 '@types/react': specifier: ^19.1.13 version: 19.1.13 @@ -4823,6 +4829,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/object-hash@3.0.6': + resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} + '@types/pbf@3.0.5': resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} @@ -8084,6 +8093,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -16671,6 +16684,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/object-hash@3.0.6': {} + '@types/pbf@3.0.5': {} '@types/pg-pool@2.0.6': @@ -20419,6 +20434,8 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {}