diff --git a/.changeset/bright-pans-exist.md b/.changeset/bright-pans-exist.md new file mode 100644 index 000000000..b1e1a8546 --- /dev/null +++ b/.changeset/bright-pans-exist.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-calculator": minor +--- + +Add Mineralization module to request the nsupply and dyna endpoint at NMI API diff --git a/.changeset/funky-doors-sort.md b/.changeset/funky-doors-sort.md new file mode 100644 index 000000000..1410553b1 --- /dev/null +++ b/.changeset/funky-doors-sort.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Add Mineralisatie app to Labs (now behind posthog feature flag) to learn more about mineralization at farm and field level with MINIP and DYNA diff --git a/fdm-app/app/components/blocks/header/mineralization.tsx b/fdm-app/app/components/blocks/header/mineralization.tsx new file mode 100644 index 000000000..0bdc3bd72 --- /dev/null +++ b/fdm-app/app/components/blocks/header/mineralization.tsx @@ -0,0 +1,118 @@ +import { Check, ChevronDown } from "lucide-react" +import { NavLink, useLocation } from "react-router" +import { useCalendarStore } from "~/store/calendar" +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { cn } from "~/lib/utils" + +type HeaderFieldOption = { + b_id: string + b_name: string | null | undefined +} + +export function HeaderMineralization({ + b_id_farm, + b_id, + fieldOptions, +}: { + b_id_farm: string + b_id: string | undefined + fieldOptions: HeaderFieldOption[] +}) { + const calendar = useCalendarStore((state) => state.calendar) + const location = useLocation() + const isDyna = location.pathname.endsWith("/dyna") + + const selectedField = b_id + ? fieldOptions.find((f) => f.b_id === b_id) + : undefined + + return ( + <> + + + + Mineralisatie + + + + {b_id && ( + <> + + + + + + {selectedField?.b_name ?? + "Kies een perceel"} + + + + + {fieldOptions.map((option) => ( + + + + {option.b_name} + + {b_id === option.b_id && ( + + )} + + + ))} + + + + + )} + + {isDyna ? ( + <> + + + + DYNA + + + + ) : ( + b_id && ( + <> + + + + Bodem N-levering + + + + ) + )} + + ) +} diff --git a/fdm-app/app/components/blocks/mineralization/data-completeness.tsx b/fdm-app/app/components/blocks/mineralization/data-completeness.tsx new file mode 100644 index 000000000..aea5f4176 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/data-completeness.tsx @@ -0,0 +1,215 @@ +import { CircleAlert, CircleX } from "lucide-react" +import { NavLink } from "react-router" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip" +import type { + DataCompleteness, + NSupplyMethod, +} from "~/integrations/mineralization.server" + +const PARAM_LABELS: Record = { + a_som_loi: "Organische stof (LOI)", + a_clay_mi: "Kleigehalte", + a_silt_mi: "Siltgehalte", + a_sand_mi: "Zandgehalte", + a_c_of: "Organisch koolstof (OF)", + a_cn_fr: "C/N-verhouding", + a_n_rt: "Totaal stikstof", + a_n_pmn: "PMN (mineraliseerbaar N)", + b_soiltype_agr: "Bodemtype", + a_depth_lower: "Bemonsteringsdiepte", +} + +const PARAM_UNITS: Record = { + a_som_loi: "%", + a_clay_mi: "%", + a_silt_mi: "%", + a_sand_mi: "%", + a_c_of: "g C/kg", + a_cn_fr: "—", + a_n_rt: "mg N/kg", + a_n_pmn: "mg N/kg", + b_soiltype_agr: "", + a_depth_lower: "cm", +} + +const METHOD_LABELS: Record = { + minip: "MINIP", + pmn: "PMN", + century: "Century", +} + +const NMI_SOURCE = "nl-other-nmi" + +function isNmiEstimate(source: string | undefined): boolean { + return source === NMI_SOURCE +} + +interface DataCompletenessProps { + completeness: DataCompleteness + method: NSupplyMethod + b_id_farm: string + b_id: string + calendar: string +} + +export function DataCompletenessCard({ + completeness, + method, + b_id_farm, + b_id, + calendar, +}: DataCompletenessProps) { + const { available, missing, estimated, score } = completeness + + return ( + + +
+ Bodemgegevens + + + + = 80 + ? "default" + : score >= 60 + ? "secondary" + : "destructive" + } + className="cursor-help" + > + {score}% + + + + Aandeel vereiste parameters gemeten via + labanalyse. NMI BodemSchat-schattingen tellen + niet mee. + {missing.length > 0 && ( + + {" "} + {missing.length} parameter + {missing.length > 1 ? "s" : ""} ontbrek + {missing.length > 1 ? "en" : "t"}. + + )} + + + +
+ + Beschikbare parameters voor {METHOD_LABELS[method]}-methode + +
+ +
    + {available.map((item) => { + const isEstimate = isNmiEstimate(item.source) + const unit = PARAM_UNITS[item.param] ?? "" + return ( +
  • +
    +
    + + {PARAM_LABELS[item.param] ?? + item.param} + + {isEstimate && ( + + NMI BodemSchat + + )} +
    + {!isEstimate && item.date && ( + + {new Date( + item.date, + ).toLocaleDateString("nl-NL")} + + )} +
    + + {typeof item.value === "number" + ? item.value.toFixed(2) + : item.value} + {unit && unit !== "—" ? ` ${unit}` : ""} + +
  • + ) + })} + {missing.map((param) => ( +
  • + + + {PARAM_LABELS[param] ?? param} + + + Ontbreekt + +
  • + ))} + {estimated.map((param) => ( +
  • + + + {PARAM_LABELS[param] ?? param} + + + Geschat + +
  • + ))} +
+ + {missing.length > 0 && ( +
+

+ Voeg een bodemanalyse toe voor een nauwkeurigere + berekening. +

+ +
+ )} +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx b/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx new file mode 100644 index 000000000..3bb3af185 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/dyna-advice.tsx @@ -0,0 +1,121 @@ +import { CalendarCheck, Leaf } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import type { DynaFertilizerAdvice } from "~/integrations/mineralization.server" + +interface DynaAdviceCardProps { + fertilizingRecommendations: DynaFertilizerAdvice | null + harvestingRecommendation: { b_date_harvest: string } | null +} + +function formatDate(dateStr: string): string { + let d: Date + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + const [year, month, day] = dateStr.split("-").map(Number) + d = new Date(year, month - 1, day) + } else { + d = new Date(dateStr) + } + + if (Number.isNaN(d.getTime())) return dateStr + return d.toLocaleDateString("nl-NL", { + day: "numeric", + month: "long", + year: "numeric", + }) +} + +export function DynaAdviceCard({ + fertilizingRecommendations, + harvestingRecommendation, +}: DynaAdviceCardProps) { + return ( + + + Advies + + Bemesting- en oogstadvies op basis van DYNA + + + + {/* Fertilizer advice */} +
+
+ + Bemestingsadvies +
+ {fertilizingRecommendations ? ( +
+
+
+ Aanbevolen gift +
+
+ {Math.round(fertilizingRecommendations.b_n_recommended)}{" "} + kg N/ha +
+
+
+
+ Aanbevolen datum +
+
+ {formatDate( + fertilizingRecommendations.b_date_recommended, + )} +
+
+
+
+ Resterende ruimte +
+
+ {Math.round(fertilizingRecommendations.b_n_remaining)}{" "} + kg N/ha +
+
+
+ ) : ( +

+ Geen bemestingsadvies beschikbaar. +

+ )} +
+ + + + {/* Harvest advice */} +
+
+ + Oogstadvies +
+ {harvestingRecommendation ? ( +
+
+
+ Aanbevolen oogstdatum +
+
+ {formatDate( + harvestingRecommendation.b_date_harvest, + )} +
+
+
+ ) : ( +

+ Geen oogstadvies beschikbaar. +

+ )} +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx new file mode 100644 index 000000000..7750090ea --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/dyna-balance.tsx @@ -0,0 +1,136 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Separator } from "~/components/ui/separator" +import type { DynaNitrogenBalance } from "~/integrations/mineralization.server" + +interface DynaBalanceCardProps { + nitrogenBalance: DynaNitrogenBalance + leaching: number +} + +export function DynaBalanceCard({ nitrogenBalance, leaching }: DynaBalanceCardProps) { + // Mineralisatie = b_nw - b_n_greenmanure - (artificial + organic fertilizer) - preceeding + const mineralisatie = + nitrogenBalance.b_nw - + nitrogenBalance.b_n_greenmanure - + nitrogenBalance.b_n_fertilizer_artificial - + nitrogenBalance.b_n_fertilizer_organic - + nitrogenBalance.b_n_fertilizer_preceeding + + // Total balance = b_nw - b_n_uptake - leaching + const totalBalance = nitrogenBalance.b_nw - nitrogenBalance.b_n_uptake - leaching + + return ( + + + Werkzame N-balans + + Stikstof uit mineralisatie en toevoegingen (kg N/ha/jaar) + + + +
+ {/* Mineralisatie (bodem) */} +
+
+ Bodem mineralisatie +
+
+ {Math.round(mineralisatie)} kg N/ha +
+
+ + {/* Artificial fertilizer */} +
+
+ Kunstmest +
+
+ +{Math.round(nitrogenBalance.b_n_fertilizer_artificial)} kg N/ha +
+
+ + {/* Organic fertilizer */} +
+
+ Organische mest +
+
+ +{Math.round(nitrogenBalance.b_n_fertilizer_organic)} kg N/ha +
+
+ + {/* Preceding crop */} +
+
+ Voorvrucht +
+
+ +{Math.round(nitrogenBalance.b_n_fertilizer_preceeding)} kg N/ha +
+
+ + {/* Green manure */} +
+
+ Groenbemesting +
+
+ +{Math.round(nitrogenBalance.b_n_greenmanure)} kg N/ha +
+
+ + + + {/* Total N aanbod */} +
+
Totaal aanbod
+
+ {Math.round(nitrogenBalance.b_nw)} kg N/ha +
+
+ + {/* Uptake */} +
+
N-opname gewas
+
+ -{Math.round(nitrogenBalance.b_n_uptake)} kg N/ha +
+
+ + {/* Leaching */} +
+
+ N-NO₃ uitspoeling +
+
+ -{Math.round(leaching)} kg N/ha +
+
+ + + + {/* Balance (surplus/deficit) */} +
+
N-balans
+
0 + ? "text-orange-600" + : "text-green-600" + }`} + > + {totalBalance > 0 ? "+" : ""} + {Math.round(totalBalance)} kg N/ha +
+
+
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx new file mode 100644 index 000000000..b382ea061 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/dyna-chart.tsx @@ -0,0 +1,539 @@ +"use client" + +import { + Area, + CartesianGrid, + ComposedChart, + Line, + ReferenceLine, + XAxis, + YAxis, +} from "recharts" +import { useState } from "react" +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, +} from "~/components/ui/chart" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" +import type { + DynaDailyPoint, + DynaFertilizerAdvice, +} from "~/integrations/mineralization.server" + +const MONTH_LABELS_NL = [ + "Jan", + "Feb", + "Mrt", + "Apr", + "Mei", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dec", +] + +function formatDateTick(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return MONTH_LABELS_NL[d.getMonth()] ?? dateStr +} + +function formatDateLabel(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return dateStr + return d.toLocaleDateString("nl-NL", { day: "numeric", month: "long" }) +} + +function getMonthTicks(data: DynaDailyPoint[]): string[] { + const seen = new Set() + return data + .filter((d) => { + const month = d.b_date_calculation.slice(0, 7) + if (seen.has(month)) return false + seen.add(month) + return true + }) + .map((d) => d.b_date_calculation) +} + +const dynaChartConfig = { + band: { + label: "Bandbreedte", + color: "var(--chart-2)", + }, + b_nw: { + label: "N aanbod", + color: "var(--chart-2)", + }, + b_n_uptake: { + label: "N opname", + color: "var(--chart-1)", + }, + b_nw_difference: { + label: "N beschikbaar", + color: "var(--chart-2)", + }, +} satisfies ChartConfig + +export interface DynaChartEvent { + date: string + type: "sowing" | "harvest" | "fertilizer" + label: string +} + +export const EVENT_COLORS: Record = { + sowing: "hsl(142, 71%, 45%)", + harvest: "hsl(38, 92%, 50%)", + fertilizer: "hsl(217, 91%, 60%)", +} + +export function groupEventsByDate( + events: DynaChartEvent[], +): Map { + const map = new Map() + for (const ev of events) { + const arr = map.get(ev.date) ?? [] + arr.push(ev) + map.set(ev.date, arr) + } + return map +} + +interface EventDotProps { + viewBox?: { x?: number; y?: number } + events: DynaChartEvent[] +} + +export function EventDot({ viewBox, events }: EventDotProps) { + if (viewBox?.x === undefined || viewBox.y === undefined) return null + if (!events || events.length === 0) return null + + const x = viewBox.x + const y = (viewBox.y ?? 0) + 10 + const color = EVENT_COLORS[events[0].type] ?? "hsl(var(--chart-1))" + return ( + + ) +} + +type DynaChartPoint = { + date: string + b_nw: number + b_nw_min: number + b_nw_max: number + b_nw_recommended: number | null + b_n_uptake: number | null + b_nw_difference?: number + _events?: DynaChartEvent[] +} + +const SERIES_TO_SHOW = ["b_nw", "b_n_uptake"] as const + +function DynaTooltipContent({ + active, + payload, + isBalance = false, +}: { + active?: boolean + payload?: Array<{ + dataKey: string + value: number + color?: string + payload: DynaChartPoint + }> + isBalance?: boolean +}) { + if (!active || !payload?.length) return null + const point = payload[0]?.payload + if (!point) return null + + // For balance tab, only show difference; for normal tab, show both supply and uptake + const visibleEntries = isBalance + ? payload.filter((p) => p.dataKey === "b_nw_difference") + : payload.filter((p) => + SERIES_TO_SHOW.includes(p.dataKey as (typeof SERIES_TO_SHOW)[number]), + ) + + return ( +
+
{formatDateLabel(point.date)}
+
+ {visibleEntries.map((entry) => ( +
+
+ + {dynaChartConfig[ + entry.dataKey as keyof typeof dynaChartConfig + ]?.label ?? entry.dataKey} + + + {Math.round(entry.value)}{" "} + + kg N/ha + + +
+ ))} +
+ {point._events && point._events.length > 0 && ( +
+ {point._events.map((ev, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: stable ordered list +
+
+ + {ev.label} + +
+ ))} +
+ )} +
+ ) +} + +interface DynaChartProps { + data: DynaDailyPoint[] + fertilizingRecommendations?: DynaFertilizerAdvice | null + events?: DynaChartEvent[] + year?: number +} + +export function DynaChart({ + data, + fertilizingRecommendations, + events = [], + year = new Date().getFullYear(), +}: DynaChartProps) { + const [activeTab, setActiveTab] = useState("dynamics") + const monthTicks = getMonthTicks(data) + const today = new Date().toLocaleDateString("en-CA") + const isCurrentYear = year === new Date().getFullYear() + + // Group events by date and only include dates present in the data + const eventsByDate = groupEventsByDate(events) + const chartDates = new Set(data.map((d) => d.b_date_calculation)) + const uniqueEventDates = Array.from(eventsByDate.entries()).filter( + ([date]) => chartDates.has(date), + ) + + const chartData: DynaChartPoint[] = data.map((d) => ({ + date: d.b_date_calculation, + b_nw: d.b_nw, + b_nw_min: d.b_nw_min, + b_nw_max: d.b_nw_max, + b_nw_recommended: d.b_nw_recommended, + b_n_uptake: d.b_n_uptake, + b_no3_leach: d.b_no3_leach, + _events: eventsByDate.get(d.b_date_calculation), + })) + + // Calculate available N (N aanbod - N opname - N uitspoeling) + const chartDataWithDifference = chartData.map((d) => ({ + ...d, + b_nw_difference: + d.b_n_uptake !== null && d.b_n_uptake !== undefined + ? d.b_nw - d.b_n_uptake - d.b_no3_leach + : null, + })) + + return ( +
+ + + N aanbod & opname + N beschikbaar + + + {/* Tab 1: N aanbod en opname — N aanbod and N opname lines */} + + + + + + + + + + + + + } + /> + } + /> + + {/* Min-max band — only for current year */} + {isCurrentYear && ( + <> + + + + )} + + {/* N aanbod — line only (no area) */} + + + {/* N opname — dashed line for distinction */} + + + {/* Today reference line — only for current year */} + {isCurrentYear && ( + + )} + + {/* Fertilizing Recommendation */} + {fertilizingRecommendations?.b_date_recommended && ( + + )} + + {/* Field events — info dot at top of each vertical line. + Events grouped by date; tooltip shows all events for that day. */} + {uniqueEventDates.map(([date, evs]) => ( + ( + + )} + /> + ))} + + + + + {/* Tab 2: N beschikbaar — area chart showing difference */} + + + + + + + + + + + + + } + /> + } + /> + + {/* Surplus/deficit as area */} + + + {/* Today reference line */} + {isCurrentYear && ( + + )} + + {/* Zero line for reference */} + + + {/* Field events */} + {uniqueEventDates.map(([date, evs]) => ( + ( + + )} + /> + ))} + + + + +
+ ) +} diff --git a/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx new file mode 100644 index 000000000..ce06b6da3 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/dyna-field-list.tsx @@ -0,0 +1,149 @@ +import { CircleCheck, CircleX, Loader2 } from "lucide-react" +import { Suspense, use } from "react" +import { NavLink } from "react-router" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import type { FarmDynaResult } from "~/integrations/mineralization.server" + +interface DynaFieldListProps { + fields: { b_id: string; b_name: string | null }[] + promises: Record> + b_id_farm: string + calendar: string +} + +export function DynaFieldList({ + fields, + promises, + b_id_farm, + calendar, +}: DynaFieldListProps) { + return ( + + + + Perceel + N Aanbod (kg/ha) + N Opname (kg/ha) + Uitspoeling (kg/ha) + Status + + + + {fields.map((field) => { + const promise = promises[field.b_id] + if (!promise) return null + + return ( + + ) + })} + +
+ ) +} + +function DynaTableRow({ + field, + promise, + b_id_farm, + calendar, +}: { + field: { b_id: string; b_name: string | null } + promise: Promise + b_id_farm: string + calendar: string +}) { + return ( + + + + {field.b_name} + + + }> + + + + ) +} + +function DynaCells({ promise }: { promise: Promise }) { + const { result, error } = use(promise) + + const lastPoint = result?.calculationDyna?.[result.calculationDyna.length - 1] + const hasValue = + !error && !!result?.calculationDyna?.length && lastPoint !== undefined + const nAvailability = lastPoint?.b_nw ?? 0 + const nUptake = lastPoint?.b_n_uptake ?? 0 + const leaching = lastPoint?.b_no3_leach ?? 0 + + return ( + <> + + {hasValue ? Math.round(nAvailability) : "—"} + + + {hasValue ? Math.round(nUptake) : "—"} + + + {hasValue ? Math.round(leaching) : "—"} + + + {hasValue ? ( + <> + Succes + + + ) +} + +function DynaCellsSkeleton() { + return ( + <> + + + + + + + + + + + + + + ) +} diff --git a/fdm-app/app/components/blocks/mineralization/field-list.tsx b/fdm-app/app/components/blocks/mineralization/field-list.tsx new file mode 100644 index 000000000..9c62b40b1 --- /dev/null +++ b/fdm-app/app/components/blocks/mineralization/field-list.tsx @@ -0,0 +1,103 @@ +import { CircleX } from "lucide-react" +import { NavLink } from "react-router" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import type { NSupplyResult } from "~/integrations/mineralization.server" +import { getCurrentDoy } from "./mineralization-chart" + +interface FieldListProps { + results: NSupplyResult[] + b_id_farm: string + calendar: string +} + +export function FieldList({ results, b_id_farm, calendar }: FieldListProps) { + const sorted = [...results].sort((a, b) => b.totalAnnualN - a.totalAnnualN) + const currentDoy = getCurrentDoy() + + return ( + + + + Perceel + + N Levering (kg N/ha) + + % vandaag + + + + {sorted.map((result) => ( + + + + {result.b_name} + + + + {result.error + ? "—" + : Math.round(result.totalAnnualN)} + + + + + + ))} + +
+ ) +} + +function NMineralizedToday({ + result, + currentDoy, +}: { + result: NSupplyResult + currentDoy: number +}) { + if (result.error) { + return ( + <> + Fout: {result.error} +