Skip to content

Add streaming bulk soil analysis uploads with batching#459

Merged
SvenVw merged 23 commits into
developmentfrom
FDM458
Feb 13, 2026
Merged

Add streaming bulk soil analysis uploads with batching#459
SvenVw merged 23 commits into
developmentfrom
FDM458

Conversation

@SvenVw
Copy link
Copy Markdown
Collaborator

@SvenVw SvenVw commented Feb 12, 2026

Summary by CodeRabbit

  • New Features

    • Real-time streamed NDJSON bulk upload with per-file progress UI and disabled interactions during processing
    • Interactive, editable date picker with validation and parsing fallbacks
    • Batch-processed bulk imports with RD New → WGS84 coordinate conversion
  • Improvements

    • Automatic analysis→field matching (geometry/name) with explicit match reasons and clearer status tooltips
    • Richer analysis display (depth, NH4/NO3 badges) and improved error/success notifications

Closes #458

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 12, 2026

⚠️ No Changeset found

Latest commit: 71fb625

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@SvenVw SvenVw self-assigned this Feb 12, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

Walkthrough

Adds a streaming NDJSON bulk soil-analysis upload flow with batching (10 files / 20MB), RD New → WGS84 coordinate transformation, per-file progress and error streaming, expanded analysis fields (NH4/NO3, depth, matchReason), interactive date editing in review UI, and safer Dropzone DataTransfer handling.

Changes

Cohort / File(s) Summary
Upload UI & Review
fdm-app/app/components/blocks/soil/bulk-upload-form.tsx, fdm-app/app/components/blocks/soil/bulk-upload-review.tsx
Replaces useFetcher upload with manual fetch to streaming NDJSON endpoint; adds local isUploading state, per-file progress tracking and UI blocking, ReadableStream NDJSON parsing, robust network and parsing error handling with toast notifications; adds interactive DateCell with parsing/validation and extends ProcessedAnalysis with nutrient/depth fields and matchReason.
Dropzone
fdm-app/app/components/custom/dropzone.tsx
Centralizes File[] → hidden input syncing via syncFilesToInput, wraps DataTransfer clearing in try/catch, logs warnings for restricted environments; preserves validation and merging behavior.
NMI integration
fdm-app/app/integrations/nmi.ts
Registers EPSG:28992 via proj4 and converts RD New → WGS84; tightens validation (a_source, sampling date, numeric depths), maps depth fields, and implements batching for bulk requests (max 10 files / 20MB per batch) with sequential batch processing, descriptive batch-level error handling, and aggregation of results.
Streaming API route
fdm-app/app/routes/api.soil-analysis.extract.ts
New authenticated route that accepts FormData files, enforces single session for bulk operation, processes files with concurrency (limit 10), calls extractBulkSoilAnalyses per file, and streams per-file success/error objects as NDJSON via a ReadableStream with proper headers.
Bulk upload pages / save flow
fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx, fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx
Updates upload-success handling to use matchAnalysesToFields; expands handleSave signature to accept updatedAnalyses (ProcessedAnalysis[]); strips UI-only fields before DB persistence; tightens validation for depth/date; returns action errors via handleActionError.
Analysis matching utility
fdm-app/app/components/blocks/soil/bulk-upload-match.ts
New matchAnalysesToFields function that matches analyses to fields by geometry (using booleanPointInPolygon) with name fallback, annotates matchedFieldId and matchReason (geometry/name/both), and logs on matching errors.

Sequence Diagram

sequenceDiagram
    participant Client as Client (Browser)
    participant BulkForm as Bulk Upload Form
    participant API as /api/soil-analysis/extract
    participant NMI as NMI Integration
    participant Remote as NMI API

    Client->>BulkForm: Select files & start upload
    BulkForm->>API: POST FormData (files)
    API->>API: Authenticate session
    API->>API: Extract files from FormData
    API-->>BulkForm: Begin NDJSON stream (response)
    
    par Concurrent file processing (limit: 10)
        loop For each uploaded file
            API->>NMI: extractBulkSoilAnalyses(file)
            NMI->>NMI: Split into batches (<=10 files, <=20MB)
            par Per-batch requests
                NMI->>Remote: POST batch (auth)
                Remote-->>NMI: Batch results
            end
            NMI->>NMI: Validate, transform coords (EPSG:28992→4326), map depths/nutrients
            NMI-->>API: Aggregated analyses for file
            API-->>BulkForm: NDJSON line { success, filename, analyses } or error line
            BulkForm->>BulkForm: Parse NDJSON line, update per-file progress/UI
        end
    end

    alt All files processed
        BulkForm->>BulkForm: Invoke onSuccess with accumulated analyses
    else Any errors
        BulkForm->>Client: Show toast/error per-file
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Suggested reviewers

  • BoraIneviNMI
  • gerardhros

Poem

🐰 In NDJSON streams I hop and hum,
Batching files until the job is done,
RD to WGS I twirl with glee,
Progress bars sparkle — join the spree!

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes significant out-of-scope enhancements beyond issue #458: streaming NDJSON processing, per-file progress tracking, date editing UI, depth field handling, and a new matchAnalysesToFields function. Remove enhancements not specified in #458 (streaming, progress UI, date editing, depth fields) and keep changes focused on batching and 413 error handling only.
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Add streaming bulk soil analysis uploads with batching' accurately and specifically describes the main changes: implementing streaming uploads and batching logic for bulk soil analysis handling.
Linked Issues check ✅ Passed All requirements from issue #458 are met: batching logic with 10-file and 20MB limits is implemented in nmi.ts, error handling for 413 responses is added, and the bulk upload routes are updated accordingly.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch FDM458

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
fdm-app/app/components/blocks/soil/bulk-upload-match.ts (2)

56-66: Geometry-vs-name conflict is silently discarded.

When geometry matches field A but the name matches a different field B, the name match is silently ignored. This is a reasonable "geometry wins" policy, but the user gets no feedback that the name pointed elsewhere. Consider setting matchReason = "geometry" explicitly (it's already set) and optionally logging or surfacing the conflict so users can verify.


17-18: matchedFieldId initialized to "" instead of undefined.

ProcessedAnalysis.matchedFieldId is typed as string | undefined, but here it defaults to "". Downstream code in the route filters by fieldId !== "", so it works, but using undefined would be more idiomatic and consistent with the type.

fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx (1)

169-231: Promise.all inside matches.map can produce undefined elements and provides no atomicity.

Two observations:

  1. When analysis is not found (line 175 condition is false), the callback returns undefined implicitly. This is harmless but creates undefined slots in the resolved array.

  2. Promise.all will reject on the first error but won't roll back already-completed addSoilAnalysis calls. If partial saves are unacceptable, consider wrapping in a transaction. If partial saves are acceptable (user can retry the rest), Promise.allSettled with error aggregation may be more user-friendly.

This is a pre-existing pattern in the codebase, so flagging as optional.

fdm-app/app/components/blocks/soil/bulk-upload-form.tsx (1)

40-40: allResults typed as any[] bypasses ProcessedAnalysis contract.

allResults accumulates server-streamed data that is eventually passed to onSuccess(allResults) which expects ProcessedAnalysis[]. Using any[] means a server-side shape change would silently propagate incorrect data to the review UI without compile-time detection. Consider typing it more strictly or at least adding a runtime guard/parse before calling onSuccess.

fdm-app/app/integrations/nmi.ts (2)

393-489: Significant code duplication between extractSoilAnalysis and the bulk mapping logic.

The field-to-soilAnalysis mapping in extractBulkSoilAnalyses (lines 393–489) duplicates nearly all of the logic in extractSoilAnalysis (lines 216–296): date parsing, depth parsing, a_* parameter extraction, RD New → WGS84 transformation, and WGS84 fallback. Any future fix or enhancement (e.g., new parameters, date format changes) must be applied in both places.

Consider extracting a shared mapFieldToSoilAnalysis(field, index?) helper and calling it from both functions.


276-276: Falsy coordinate guard for RD New is inconsistent with the WGS84 fix.

Lines 276 and 449 use if (x_rd && y_rd) which treats 0 as falsy. The WGS84 fallback at line 475 was corrected to use != null. While RD New coordinates are never 0 in practice (valid range ~100k–300k), using != null here too would be more consistent and future-proof.

Also applies to: 449-449

fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx (1)

166-228: Near-identical action logic duplicated across both bulk routes.

The action in this file (lines 151–241) is nearly identical to the action in farm.$b_id_farm.soil-analysis.bulk.tsx (lines 153–241): same validation, same destructuring, same addSoilAnalysis call. The only difference is the redirect target. Consider extracting a shared saveBulkSoilAnalyses(session, analysesData, matches) helper to reduce duplication and ensure future fixes apply to both routes.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 12, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.04%. Comparing base (27d72d6) to head (71fb625).
⚠️ Report is 24 commits behind head on development.

Additional details and impacted files
@@             Coverage Diff              @@
##           development     #459   +/-   ##
============================================
  Coverage        88.04%   88.04%           
============================================
  Files               91       91           
  Lines             4625     4625           
  Branches          1494     1494           
============================================
  Hits              4072     4072           
  Misses             553      553           
Flag Coverage Δ
fdm-calculator 88.76% <ø> (ø)
fdm-core 86.69% <ø> (ø)
fdm-data 92.12% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot changed the title @coderabbitai Add streaming bulk soil analysis uploads with batching Feb 12, 2026
@coderabbitai coderabbitai Bot added branch:development Issue only affecting development, not the main branch (yet) enhancement New feature or request fdm-app fdm-core labels Feb 12, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@fdm-app/app/components/blocks/soil/bulk-upload-form.tsx`:
- Around line 67-96: The loop that reads NDJSON from reader may leave a
partial/complete last line in buffer when done; after the while loop ends, check
if buffer.trim() is non-empty and run the same JSON.parse and result handling
(increment completedFiles, push analyses into allResults, show toast.error for
result.error, call setCurrentFile and update setUploadProgress) with try/catch
as done inside the for-loop; use the same variables/logic (buffer, decoder,
reader handling, allResults, completedFiles, totalFiles, setCurrentFile,
setUploadProgress) so leftover NDJSON is processed robustly.
- Around line 97-110: The catch/finally flow currently can emit two error
toasts: one in the catch and another in finally when allResults is empty and
totalFiles > 0; add a boolean flag (e.g., errorOccurred or hadError) scoped to
the bulkUpload handler, set it to true inside the catch block when a toast is
shown, and then in the finally block only show the "Geen analyses kunnen
verwerken" toast if errorOccurred is false and allResults.length === 0 &&
totalFiles > 0; keep the existing calls to setIsUploading(false),
setCurrentFile(null) and onSuccess(allResults) unchanged (call onSuccess only
when allResults.length > 0).

In `@fdm-app/app/components/blocks/soil/bulk-upload-review.tsx`:
- Around line 205-286: The cell callback in your column definition illegally
calls hooks (useState) — extract the entire inline cell renderer into a new
top-level React component (e.g., DateCell) and move the local state and handlers
(the useState calls, onDateSelect, onInputBlur, inputValue state, setOpen,
setInputValue) there; have the column's cell return <DateCell ...props/> passing
row.original.id, dates[row.original.id], handleDateChange, endMonth, nl,
parseDateText, format, isValid and any UI components (Input, Popover, Calendar)
as needed; ensure DateCell is a function component (not nested) so hooks are
called at the top level.
- Around line 123-132: The state initializer for dates uses format(new
Date(a.b_sampling_date), "yyyy-MM-dd") which will throw for unparseable dates;
update the initializer in the bulk-upload-review component to validate each date
before formatting (e.g., use date-fns isValid and parseISO or check new
Date(...) with isValid) and fall back to an empty string for invalid or missing
b_sampling_date values so the useState<Record<string,string>> setup (and related
dates / setDates handling) never throws; ensure you import and use isValid (or
equivalent) and only call format(...) when the date is valid.

In `@fdm-app/app/integrations/nmi.ts`:
- Around line 401-409: The b_date parsing silently creates Invalid Date when
parts are non-numeric; update the parsing block that reads field.b_date (the
code that splits into dateParts and uses Number.parseInt to build
soilAnalysis.b_sampling_date) to validate the parsed values before constructing
the Date: ensure each parsed value is a finite integer (e.g.,
Number.isFinite/Number.isInteger checks on the results of parseInt), that
month-1 is in 0–11 and day is in a valid 1–31 range (and year is a sensible
integer), and only assign soilAnalysis.b_sampling_date = new Date(year, month,
day) when validation passes; if validation fails, do not assign the invalid Date
(set null/undefined or log/warn). Apply the same validation/failure-handling
change to the extractSoilAnalysis date parsing block referenced in the comment
(lines ~233-242) so both places reject non-numeric date parts rather than
producing Invalid Date.
- Around line 261-284: The code assigns a GeoJSON Point to soilAnalysis.location
but soilAnalysis is typed as { [key: string]: string | number | Date }, causing
a type mismatch; update the type for soilAnalysis to allow GeoJSON (e.g.,
include a union that permits { type: "Point"; coordinates: [number, number] } or
a GeoJSON type) or create a separate strongly-typed variable for the location
(e.g., locationPoint) and assign the GeoJSON there before storing any
serialized/value-compatible representation on soilAnalysis; adjust references to
soilAnalysis.location accordingly or serialize location to a string if you must
keep the original narrow index signature.
- Around line 457-473: The falsy check `if (lat && lon)` in the fallback
coordinate parsing will incorrectly drop valid coordinates equal to 0; update
the guard to explicitly check for null/undefined (e.g. `lat != null && lon !=
null`) before converting to numbers, then keep the existing numeric conversion
and `Number.isNaN` checks using `numericLat` and `numericLon` and assign
`soilAnalysis.location` as before; look for the block that references
`soilAnalysis.location`, `lat`, `lon`, `numericLat`, and `numericLon` to apply
this change.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.soil-analysis.bulk.tsx:
- Around line 79-80: The isSaving computation uses a case-sensitive check of
navigation.formMethod === "POST", which can fail if React Router normalizes to
lowercase; update the check in the isSaving definition to compare formMethod
case-insensitively (e.g., navigation.formMethod?.toLowerCase() === "post") while
still guarding navigation.state !== "idle", so replace the current comparison in
the isSaving const with a lowercase-safe comparison.
- Around line 217-220: Remove the dead server-side form handler that checks
formData.has("soilAnalysisFile") in the route file handling bulk soil analysis
uploads; specifically delete the conditional block that calls
extractBulkSoilAnalyses(...) and returns data({ analyses }) since
BulkSoilAnalysisUploadForm uses the streaming API (/api/soil-analysis/extract)
via fetch and this branch is unreachable. Ensure you only remove the unreachable
handler block (the formData.has("soilAnalysisFile") conditional and its call to
extractBulkSoilAnalyses) and do not change the streaming API endpoint or the
BulkSoilAnalysisUploadForm component.
🧹 Nitpick comments (7)
fdm-app/app/components/custom/dropzone.tsx (2)

180-191: Inconsistent defensive handling between handleDrop and handleFileChange.

The same DataTransfer sync pattern in handleFileChange (lines 159–165) lacks the try/catch guard and instanceof File filter you've added here. If these defenses are needed for the drop path, they're equally needed for the change-event path—both write to the same inputRef.current.files.

♻️ Suggested: extract a shared helper and use it in both paths
+    const syncFilesToInput = (filesToSync: File[]) => {
+        if (!inputRef.current) return
+        try {
+            const container = new DataTransfer()
+            for (const f of filesToSync) {
+                if (f instanceof File) {
+                    container.items.add(f)
+                }
+            }
+            inputRef.current.files = container.files
+        } catch (err) {
+            console.warn("Could not sync files to hidden input:", err)
+        }
+    }
+

Then in handleFileChange (around line 159):

-            if (inputRef.current?.files) {
-                const container = new DataTransfer()
-                inputFiles.forEach((f) => {
-                    container.items.add(f)
-                })
-                inputRef.current.files = container.files
-            }
+            syncFilesToInput(inputFiles)

And in handleDrop (around line 179):

-            if (inputRef.current) {
-                try {
-                    const container = new DataTransfer()
-                    for (const f of finalFiles) {
-                        if (f instanceof File) {
-                            container.items.add(f)
-                        }
-                    }
-                    inputRef.current.files = container.files
-                } catch (err) {
-                    // Fallback or silent ignore if DataTransfer is restricted
-                    console.warn("Could not sync files to hidden input:", err)
-                }
-            }
+            syncFilesToInput(finalFiles)

193-197: Silent empty catch — consider adding a console.warn for consistency.

The first catch block (line 190) logs a warning, but this one silently swallows the error. A brief console.warn would keep the two guards consistent and aid debugging.

Proposed fix
             try {
                 e.dataTransfer.clearData()
             } catch (err) {
-                // Ignore clearData errors
+                // clearData may throw in some browsers after drop
+                console.warn("Could not clear dataTransfer:", err)
             }
fdm-app/app/routes/api.soil-analysis.extract.ts (2)

43-93: No timeout on NMI API calls; a hung upstream stalls the stream indefinitely.

If the NMI API becomes unresponsive, a worker's await extractBulkSoilAnalyses(...) never resolves. The ReadableStream stays open, and the client's progress bar freezes without feedback. Consider wrapping each call with an AbortSignal.timeout() or a manual timeout wrapper to fail gracefully.


54-62: Consider using extractSoilAnalysis for single-file processing to avoid unnecessary batching overhead.

extractBulkSoilAnalyses runs batching logic (lines 307–331) even when processing a single file. For single-file cases, extractSoilAnalysis skips this overhead and is purpose-built for one file.

⚠️ Compatibility caveat: extractSoilAnalysis returns a single object without id and filename fields that extractBulkSoilAnalyses generates during mapping. If these fields are required downstream, you'd need to add field generation to extractSoilAnalysis or wrap the result here. If the effort outweighs the gains, the current approach is acceptable.

fdm-app/app/integrations/nmi.ts (1)

333-380: All batches are fired concurrently via Promise.all — no rate limiting.

If there are many batches (e.g. 50+ files at ~5MB each → 13+ batches), all hit the NMI API simultaneously. Consider sequential processing or a concurrency limiter to avoid overwhelming the upstream API. The API route already handles concurrency for its per-file approach, but this code path is still reachable from the route actions (line 218 in both route files).

fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx (1)

82-141: Duplicated matching logic between this file and farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx.

The geometry-matching and name-matching logic in handleUploadSuccess (lines 82-141) is nearly identical to the same function in the create route (lines 82-143). Consider extracting this into a shared utility to avoid divergence.

fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx (1)

82-143: Duplicated handleUploadSuccess matching logic — same as the other bulk route.

This is a near-identical copy of the geometry + name matching in farm.$b_id_farm.soil-analysis.bulk.tsx. Extract into a shared utility (e.g. matchAnalysesToFields) to avoid the two copies diverging over time.

Comment thread fdm-app/app/components/blocks/soil/bulk-upload-form.tsx
Comment thread fdm-app/app/components/blocks/soil/bulk-upload-form.tsx
Comment thread fdm-app/app/components/blocks/soil/bulk-upload-review.tsx
Comment thread fdm-app/app/components/blocks/soil/bulk-upload-review.tsx Outdated
Comment thread fdm-app/app/integrations/nmi.ts
Comment thread fdm-app/app/integrations/nmi.ts
Comment thread fdm-app/app/integrations/nmi.ts
Comment thread fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx Outdated
Comment thread fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx (1)

29-29: ⚠️ Potential issue | 🟡 Minor

Remove unused import: extractBulkSoilAnalyses.

The upload no longer uses the streaming API endpoint parser, so this import is not referenced anywhere in this file.

Diff
-import { extractBulkSoilAnalyses } from "~/integrations/nmi"
🤖 Fix all issues with AI agents
In `@fdm-app/app/components/blocks/soil/bulk-upload-form.tsx`:
- Around line 56-58: The current error path throws a generic Error when
response.ok is false, discarding any server-provided message; update the error
handling around the fetch response (the same block using the response variable
in bulk-upload-form.tsx) to read and include the body: attempt to parse
response.json() and use a message field if present, otherwise fallback to
response.text(), and include response.status in the thrown Error so the UI shows
the server-provided message (or raw text) instead of the hardcoded "Fout bij
starten van analyse".

In `@fdm-app/app/routes/farm`.$b_id_farm.soil-analysis.bulk.tsx:
- Around line 258-271: The destructuring that builds dbAnalysis (inside the
block extracting id, location, a_source, matchedFieldId, matchReason, filename,
b_name, b_sampling_date, a_depth_upper, a_depth_lower) currently omits the
UI-only property data, so data leaks into dbAnalysis; update that destructuring
to also extract and omit data (i.e. include data in the list of removed fields)
before passing dbAnalysis into addSoilAnalysis/...soilAnalysisData to ensure the
UI-only data property is not forwarded to the database insert.

In `@fdm-app/app/routes/farm.create`.$b_id_farm.$calendar.soil-analysis.bulk.tsx:
- Around line 256-269: The destructuring that prepares dbAnalysis is leaking the
raw parsed blob because the data property isn't stripped; update the
destructuring of analysis (the const { id, location, a_source, matchedFieldId,
matchReason, filename, b_name, b_sampling_date, a_depth_upper, a_depth_lower,
...dbAnalysis } = analysis) to also extract and discard data (e.g., include data
in the left-hand list) so that dbAnalysis does not include the data field before
saving to the DB.
🧹 Nitpick comments (10)
fdm-app/app/integrations/nmi.ts (2)

219-222: Index signature any makes the other union members meaningless.

Adding any to the union (string | number | Date | any) collapses to just any, so the index signature provides no type safety at all. Consider defining a proper interface for the soil analysis shape, or at minimum use a more specific union that includes the GeoJSON point type.

-    const soilAnalysis: {
-        [key: string]: string | number | Date | any
-        location?: { type: "Point"; coordinates: [number, number] }
-    } = {}
+    const soilAnalysis: {
+        [key: string]: string | number | Date | { type: "Point"; coordinates: [number, number] } | undefined
+        location?: { type: "Point"; coordinates: [number, number] }
+    } = {}

393-485: Duplicated parsing logic between extractSoilAnalysis and extractBulkSoilAnalyses.

Date parsing, depth parsing, and coordinate transformation are nearly identical in both functions (lines 236–290 vs 407–482). Extract shared helpers (e.g. parseSamplingDate, parseDepth, parseLocation) to reduce duplication and keep behavior consistent.

fdm-app/app/components/blocks/soil/bulk-upload-form.tsx (2)

33-134: Consider adding an AbortController to cancel the in-flight fetch when the component unmounts.

If the user navigates away (e.g., via browser back) while the stream is being read, the fetch and its reader will continue running in the background. This can cause state updates on an unmounted component and waste network resources.

🔧 Sketch: wire up AbortController
+    const [abortController, setAbortController] = useState<AbortController | null>(null)
+
     const handleUpload = async () => {
         if (files.length === 0) return
 
         setIsUploading(true)
         setUploadProgress(0)
 
+        const controller = new AbortController()
+        setAbortController(controller)
+
         // ...
         try {
             const response = await fetch("/api/soil-analysis/extract", {
                 method: "POST",
                 body: formData,
                 credentials: "same-origin",
+                signal: controller.signal,
             })
             // ...
         } catch (error) {
+            if (error instanceof DOMException && error.name === 'AbortError') {
+                // User cancelled, ignore
+                return
+            }
             console.error("Bulk upload error:", error)
             toast.error(error instanceof Error ? error.message : "Upload mislukt")
             errorOccurred = true
         } finally {
             setIsUploading(false)
+            setAbortController(null)
             // ...
         }
     }

21-21: onSuccess callback accepts any[] — consider typing it with ProcessedAnalysis[].

The ProcessedAnalysis type is already defined and used in sibling components. Typing this prop avoids silent shape mismatches between the upload form and the review component.

-    onSuccess: (data: any[]) => void
+    onSuccess: (data: ProcessedAnalysis[]) => void

(Would also require importing ProcessedAnalysis from bulk-upload-review.)

fdm-app/app/components/blocks/soil/bulk-upload-review.tsx (2)

108-117: Invalid text input is silently ignored — user gets no feedback.

When onInputBlur fires and the typed text is neither empty nor a parseable date, the stale text remains in the input while no date is propagated. The user may not realize their edit had no effect. Consider resetting to the last valid date or showing a brief validation hint.


242-248: handleSave spreads the original analyses with updated dates but doesn't reflect user-changed field matches.

updatedAnalyses is built from the original analyses array with only b_sampling_date overridden. The matchedFieldId in each analysis object still holds the initial auto-matched value, not the user's manual selection. While the validMatches array carries the correct field mappings, this means updatedAnalyses passed to onSave contains potentially stale matchedFieldId values. If any downstream consumer trusts updatedAnalyses[i].matchedFieldId instead of matches, it would use the wrong field.

Currently the server action uses matches for the field ID (line 278 in the route files), so this doesn't break persistence. But it's a latent inconsistency that could cause confusion.

🔧 Optional: sync matchedFieldId with user selections
     const handleSave = () => {
         const updatedAnalyses = analyses.map((a) => ({
             ...a,
             b_sampling_date: dates[a.id],
+            matchedFieldId: matches[a.id] || a.matchedFieldId,
         }))
         onSave(validMatches, updatedAnalyses)
     }
fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx (2)

82-141: Matching logic is duplicated between this file and farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx.

The handleUploadSuccess function (geometry matching → name matching → "both" logic) is nearly identical across both route files. If the matching algorithm needs to change, it must be updated in both places.

Consider extracting a shared matchAnalysesToFields helper.


226-286: Promise.all can leave partial writes on failure.

If the Nth addSoilAnalysis call throws (e.g., due to invalid depth), the preceding N-1 calls may have already committed. The user sees an error, but some analyses are silently persisted without a way to retry only the failed ones.

This is worth noting as a known limitation. For a future improvement, consider wrapping the batch in a transaction or using Promise.allSettled to collect per-analysis outcomes and surface partial success.

fdm-app/app/routes/farm.create.$b_id_farm.$calendar.soil-analysis.bulk.tsx (2)

83-145: Matching logic is duplicated — same as farm.$b_id_farm.soil-analysis.bulk.tsx.

This is the same geometry + name matching code. A shared helper would eliminate this duplication.


24-24: Remove unused import extractBulkSoilAnalyses.

This import is no longer used after the streaming API change.

-import { extractBulkSoilAnalyses } from "~/integrations/nmi"

Comment thread fdm-app/app/components/blocks/soil/bulk-upload-form.tsx
Comment thread fdm-app/app/routes/farm.$b_id_farm.soil-analysis.bulk.tsx
const fieldMatch = fields.find(
(field) =>
field.b_name.toLowerCase().trim() === analysisName,
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, its only a check if full name is exactly identical?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but ignoring capitals

<div className="flex items-center text-xs text-muted-foreground mt-1">
<Microscope className="h-3 w-3 mr-1" />
<span>{sourceLabel}</span>
<div className="flex flex-col gap-0.5 mt-1 text-xs text-muted-foreground">
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a more generic question: if you have for one field multiple analyses: do you store / show them all or only the recent ones?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this table I show all analyses. So if a pdf contains multiple (e.g. Nmin) it are multiple rows and those are seperatly stored. Also it is not a problem to upload multiple pdf's for the same field

if (!Number.isNaN(numericX) && !Number.isNaN(numericY)) {
try {
const [lon, lat] = proj4("EPSG:28992", "EPSG:4326", [
numericX,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reprojecting here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Copy link
Copy Markdown
Collaborator

@gerardhros gerardhros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea!

Copy link
Copy Markdown
Collaborator

@BoraIneviNMI BoraIneviNMI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any issues, as far as I can test for.

@SvenVw SvenVw merged commit ed9b3ea into development Feb 13, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

branch:development Issue only affecting development, not the main branch (yet) enhancement New feature or request fdm-app fdm-core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Batching and Improved Error Handling for Bulk Soil Analysis Upload

3 participants