Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5f8c4b8
feat: implement phase 1 for Mineralisatie app
SvenVw Apr 3, 2026
f3531aa
feat: implement phase 2 of mineralisatie
SvenVw Apr 3, 2026
300f7f6
refactor: migrate mineralisation logic to fdm-calculator
SvenVw Apr 3, 2026
4266ecb
Merge branch 'development' into FDM539
SvenVw Apr 3, 2026
c0ef031
fix: include the harvests
SvenVw Apr 3, 2026
714e942
Merge branch 'development' into FDM539
SvenVw Apr 9, 2026
0340691
Merge branch 'development' into FDM539
SvenVw Apr 20, 2026
f59af91
feat: improvements
SvenVw Apr 20, 2026
23b7b63
refactor: renaming for consistency
SvenVw Apr 20, 2026
23453bb
refactor: make better clear the difference between minip and dyna
SvenVw Apr 20, 2026
a325780
feat: improve charts and balances
SvenVw Apr 20, 2026
02fed73
feat: show harvest events
SvenVw Apr 21, 2026
7940f23
feat: show dyna results on farm page
SvenVw Apr 21, 2026
c90b82a
refactor: implement review feedback
SvenVw Apr 21, 2026
dc3255e
Merge branch 'development' into FDM539
SvenVw Apr 21, 2026
cb310b8
refactor: implement review feedback
SvenVw Apr 21, 2026
172e2f8
refactor: implement review feedback
SvenVw Apr 21, 2026
42f92f8
feat: make Mineralisatie available with feature flag
SvenVw Apr 21, 2026
16692f1
chore: add changesets
SvenVw Apr 21, 2026
f9214f8
refactor: remove unused import
SvenVw Apr 22, 2026
d5d8a8f
fix: types
SvenVw Apr 22, 2026
f5abb0c
Merge branch 'development' into FDM539
SvenVw Apr 22, 2026
f9cabf8
tests: expand coverage for mineralization
SvenVw Apr 22, 2026
c68f562
nitpicks
SvenVw Apr 22, 2026
aa215e4
Fix truncation issue with the nitrogen supply method selector
BoraIneviNMI Apr 28, 2026
28aa9fc
Merge branch 'development' into FDM539
SvenVw Apr 28, 2026
c3dd3c0
Remove the unused isFront prop
BoraIneviNMI Apr 28, 2026
3721837
Merge branch 'development' into FDM539
SvenVw Apr 28, 2026
fb60b33
fix: chart colors at mineralization
SvenVw Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-pans-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-calculator": minor
---

Add Mineralization module to request the nsupply and dyna endpoint at NMI API
5 changes: 5 additions & 0 deletions .changeset/funky-doors-sort.md
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions fdm-app/app/components/blocks/header/mineralization.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<BreadcrumbSeparator className="hidden xl:block" />
<BreadcrumbItem className="hidden xl:block">
<BreadcrumbLink
href={`/farm/${b_id_farm}/${calendar}/mineralization`}
>
Mineralisatie
</BreadcrumbLink>
</BreadcrumbItem>

{b_id && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1 max-w-[120px] sm:max-w-[200px] md:max-w-none outline-none">
<span className="truncate">
{selectedField?.b_name ??
"Kies een perceel"}
</span>
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{fieldOptions.map((option) => (
<DropdownMenuItem
key={option.b_id}
asChild
className={cn(
"flex items-center justify-between gap-2 cursor-pointer",
b_id === option.b_id &&
"bg-accent text-accent-foreground",
)}
>
<NavLink
to={`/farm/${b_id_farm}/${calendar}/mineralization/${option.b_id}`}
>
<span className="truncate">
{option.b_name}
</span>
{b_id === option.b_id && (
<Check className="h-4 w-4 shrink-0" />
)}
</NavLink>
</DropdownMenuItem>
))}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
</>
)}

{isDyna ? (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
href={`/farm/${b_id_farm}/${calendar}/mineralization/${b_id}/dyna`}
>
DYNA
</BreadcrumbLink>
</BreadcrumbItem>
</>
) : (
b_id && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
href={`/farm/${b_id_farm}/${calendar}/mineralization/${b_id}`}
>
Bodem N-levering
</BreadcrumbLink>
</BreadcrumbItem>
</>
)
)}
</>
)
}
215 changes: 215 additions & 0 deletions fdm-app/app/components/blocks/mineralization/data-completeness.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {
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<NSupplyMethod, string> = {
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Bodemgegevens</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={
score >= 80
? "default"
: score >= 60
? "secondary"
: "destructive"
}
className="cursor-help"
>
{score}%
</Badge>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-[220px]"
>
Aandeel vereiste parameters gemeten via
labanalyse. NMI BodemSchat-schattingen tellen
niet mee.
{missing.length > 0 && (
<span>
{" "}
{missing.length} parameter
{missing.length > 1 ? "s" : ""} ontbrek
{missing.length > 1 ? "en" : "t"}.
</span>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<CardDescription>
Beschikbare parameters voor {METHOD_LABELS[method]}-methode
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ul className="space-y-2">
{available.map((item) => {
const isEstimate = isNmiEstimate(item.source)
const unit = PARAM_UNITS[item.param] ?? ""
return (
<li
key={item.param}
className="flex items-start gap-2 text-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-muted-foreground">
{PARAM_LABELS[item.param] ??
item.param}
</span>
{isEstimate && (
<Badge
variant="secondary"
className="text-[10px] px-1 py-0 h-4"
>
NMI BodemSchat
</Badge>
)}
</div>
{!isEstimate && item.date && (
<span className="text-[10px] text-muted-foreground">
{new Date(
item.date,
).toLocaleDateString("nl-NL")}
</span>
)}
</div>
<span className="font-mono text-xs shrink-0">
{typeof item.value === "number"
? item.value.toFixed(2)
: item.value}
{unit && unit !== "—" ? ` ${unit}` : ""}
</span>
</li>
)
})}
{missing.map((param) => (
<li
key={param}
className="flex items-center gap-2 text-sm"
>
<CircleX className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 font-medium">
{PARAM_LABELS[param] ?? param}
</span>
<Badge
variant="destructive"
className="text-[10px] px-1 py-0 h-4"
>
Ontbreekt
</Badge>
</li>
))}
{estimated.map((param) => (
<li
key={param}
className="flex items-center gap-2 text-sm"
>
<CircleAlert className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-muted-foreground">
{PARAM_LABELS[param] ?? param}
</span>
<span className="text-[10px] text-muted-foreground">
Geschat
</span>
</li>
))}
</ul>

{missing.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground mb-2">
Voeg een bodemanalyse toe voor een nauwkeurigere
berekening.
</p>
<Button asChild size="sm" variant="outline">
<NavLink
to={`/farm/${b_id_farm}/${calendar}/field/${b_id}/soil`}
>
Bodemanalyse toevoegen
</NavLink>
</Button>
</div>
)}
</CardContent>
</Card>
)
}
Loading
Loading