Skip to content

Commit fc6e01e

Browse files
Guigui64260claude
andcommitted
fix: ne plus geler la sauvegarde du plan sur une confirmation GPS
Déplacer un arbre géolocalisé demande une confirmation, car cela réécrit son relevé terrain. Mais le verrou portait sur la sauvegarde entière : une confirmation abandonnée sans réponse rendait planches, objets et tous les autres arbres non enregistrables — silencieusement, et sans issue puisque le plan n'a pas de bouton « Enregistrer » manuel. - la sauvegarde diffère les seuls arbres dont la confirmation est pendante, et le toast dit combien ont été laissés de côté ; - l'auto-save n'est plus bloquée par une attente ; - un indicateur « n position GPS à confirmer » rend le verrou visible ; - les attentes sont purgées au changement de parcelle et au démontage, la confirmation ne pouvant plus y être tranchée. Le bandeau du plan signale aussi les arbres dont le relevé GPS tombe hors de la parcelle affichée : ils étaient dessinés à l'origine sans avertissement, ce qui invitait précisément à les déplacer et donc à écraser leurs coordonnées réelles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9a265c5 commit fc6e01e

3 files changed

Lines changed: 182 additions & 23 deletions

File tree

src/app/jardin/page.tsx

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { useFondPlan } from "@/hooks/use-fond-plan"
4949
import { calibrerFond, distance, formatDistance } from "@/lib/plan-fond-utils"
5050
import { croissanceCulture, envergureArbreADate } from "@/lib/plan-croissance"
5151
import { projeterGpsSurPlan, projeterPlanSurGps } from "@/lib/gps-plan-utils"
52+
import { partitionnerArbresPourSauvegarde } from "@/lib/plan-sauvegarde"
5253

5354
// Palier 4 (perf) : dialogs lourds chargés à la demande, hors du bundle
5455
// initial de l'éditeur (le plus gros écran client de l'app).
@@ -380,6 +381,22 @@ function JardinContent() {
380381
}
381382
}, [selectedParcelleId])
382383

384+
/**
385+
* QA 2026-07-30 — Une attente de confirmation GPS ne doit pas survivre au
386+
* changement de parcelle ni au démontage : l'arbre concerné n'est plus à
387+
* l'écran, sa confirmation ne peut plus être tranchée, et le compteur
388+
* resterait affiché sans action possible.
389+
*/
390+
React.useEffect(() => {
391+
const purger = () => {
392+
if (pendingGpsMovesRef.current.size === 0) return
393+
pendingGpsMovesRef.current.clear()
394+
setPendingGpsMovesCount(0)
395+
}
396+
purger()
397+
return purger
398+
}, [selectedParcelleId])
399+
383400
// Charger les especes pour la création de cultures
384401
const fetchEspeces = React.useCallback(async () => {
385402
try {
@@ -596,7 +613,15 @@ function JardinContent() {
596613
selectedParcelleId,
597614
])
598615

599-
const arbresGpsProjetables = React.useMemo(() => {
616+
/**
617+
* QA 2026-07-30 — On comptait seulement les arbres alignables. Un arbre dont
618+
* le relevé GPS tombe hors de la parcelle affichée n'est pas projetable : il
619+
* était dessiné à l'origine du plan sans le moindre avertissement, ce qui
620+
* invitait à le déplacer… et donc à écraser son relevé terrain par une
621+
* position fictive. On distingue désormais les deux populations.
622+
*/
623+
const statsArbresGps = React.useMemo(() => {
624+
const inerte = { projetables: 0, nonProjetables: 0 }
600625
if (
601626
!selectedParcelleId ||
602627
selectedParcelleId === "none" ||
@@ -607,25 +632,36 @@ function JardinContent() {
607632
!fond.contour ||
608633
!fondDimensions
609634
) {
610-
return 0
635+
return inerte
611636
}
612-
return arbres.filter(
613-
(arbre) =>
614-
arbre.parcelleGeoId === selectedParcelleId &&
615-
arbre.gpsLat != null &&
616-
arbre.gpsLng != null &&
617-
projeterGpsSurPlan({
618-
gpsLat: arbre.gpsLat,
619-
gpsLng: arbre.gpsLng,
620-
geometryGeoJson: selectedParcelle.geometry,
621-
contour: fond.contour!,
622-
fond,
623-
imageWidth: fondDimensions.width,
624-
imageHeight: fondDimensions.height,
625-
}) !== null
626-
).length
637+
let projetables = 0
638+
let nonProjetables = 0
639+
for (const arbre of arbres) {
640+
if (
641+
arbre.parcelleGeoId !== selectedParcelleId ||
642+
arbre.gpsLat == null ||
643+
arbre.gpsLng == null
644+
) {
645+
continue
646+
}
647+
const projection = projeterGpsSurPlan({
648+
gpsLat: arbre.gpsLat,
649+
gpsLng: arbre.gpsLng,
650+
geometryGeoJson: selectedParcelle.geometry,
651+
contour: fond.contour!,
652+
fond,
653+
imageWidth: fondDimensions.width,
654+
imageHeight: fondDimensions.height,
655+
})
656+
if (projection !== null) projetables += 1
657+
else nonProjetables += 1
658+
}
659+
return { projetables, nonProjetables }
627660
}, [arbres, fond, fondDimensions, selectedParcelle, selectedParcelleId])
628661

662+
const arbresGpsProjetables = statsArbresGps.projetables
663+
const arbresGpsHorsCadre = statsArbresGps.nonProjetables
664+
629665
const convertirPositionPlanEnGps = React.useCallback(
630666
(arbre: Arbre, x: number, y: number) => {
631667
if (
@@ -948,9 +984,13 @@ function JardinContent() {
948984

949985
// Sauvegarder les positions
950986
const handleSave = async () => {
951-
// Une position GPS en cours de confirmation ne doit jamais être persistée
952-
// avec l'ancien relevé terrain.
953-
if (pendingGpsMovesRef.current.size > 0) return
987+
// QA 2026-07-30 — Une position GPS en attente de confirmation ne doit pas
988+
// être persistée, mais le verrou portait sur TOUTE la sauvegarde : une
989+
// modale abandonnée sans réponse rendait planches, objets et tous les
990+
// autres arbres non sauvegardables, silencieusement et sans issue (il n'y a
991+
// pas de bouton « Enregistrer » manuel). Cf. src/lib/plan-sauvegarde.ts.
992+
const { aEcrire: arbresAEcrire, differes: arbresDifferes } =
993+
partitionnerArbresPourSauvegarde(arbres, pendingGpsMovesRef.current.keys())
954994
setSaving(true)
955995
try {
956996
const planchePromises = planches.map(p =>
@@ -984,7 +1024,7 @@ function JardinContent() {
9841024
})
9851025
)
9861026

987-
const arbrePromises = arbres.map(a =>
1027+
const arbrePromises = arbresAEcrire.map(a =>
9881028
fetch(`/api/arbres/${a.id}`, {
9891029
method: "PUT",
9901030
headers: { "Content-Type": "application/json" },
@@ -1028,7 +1068,9 @@ function JardinContent() {
10281068
if (results.every(r => r.ok)) {
10291069
toast({
10301070
title: "Plan sauvegardé",
1031-
description: "Les positions ont été enregistrées"
1071+
description: arbresDifferes.length > 0
1072+
? `Positions enregistrées. ${arbresDifferes.length} arbre${arbresDifferes.length > 1 ? "s" : ""} en attente de confirmation GPS ${arbresDifferes.length > 1 ? "n'ont" : "n'a"} pas été déplacé${arbresDifferes.length > 1 ? "s" : ""}.`
1073+
: "Les positions ont été enregistrées"
10321074
})
10331075
setHasChanges(false)
10341076
} else {
@@ -1051,7 +1093,10 @@ function JardinContent() {
10511093

10521094
// Auto-save débounced (1s après le dernier changement)
10531095
React.useEffect(() => {
1054-
if (!hasChanges || saving || pendingGpsMovesCount > 0) return
1096+
// `pendingGpsMovesCount` ne bloque plus l'auto-save (cf. handleSave) : il
1097+
// reste dans les dépendances pour relancer une sauvegarde dès qu'une
1098+
// confirmation est tranchée.
1099+
if (!hasChanges || saving) return
10551100
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current)
10561101
autoSaveTimer.current = setTimeout(() => {
10571102
handleSave()
@@ -1803,6 +1848,14 @@ function JardinContent() {
18031848
{saving && (
18041849
<span className="text-xs text-muted-foreground animate-pulse">Sauvegarde...</span>
18051850
)}
1851+
{pendingGpsMovesCount > 0 && (
1852+
<span
1853+
className="rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800"
1854+
title="Confirmez ou annulez le déplacement pour enregistrer la nouvelle position GPS."
1855+
>
1856+
{pendingGpsMovesCount} position GPS à confirmer
1857+
</span>
1858+
)}
18061859
</div>
18071860

18081861
<div className="flex w-full flex-wrap items-center gap-2 xl:hidden">
@@ -1907,6 +1960,11 @@ function JardinContent() {
19071960
{saving && (
19081961
<span className="w-full text-xs text-muted-foreground animate-pulse">Sauvegarde...</span>
19091962
)}
1963+
{pendingGpsMovesCount > 0 && (
1964+
<span className="w-full rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">
1965+
{pendingGpsMovesCount} position GPS à confirmer
1966+
</span>
1967+
)}
19101968
</div>
19111969
</div>
19121970
</header>
@@ -3010,6 +3068,14 @@ function JardinContent() {
30103068
le fond géoréférencé.
30113069
</p>
30123070
)}
3071+
{arbresGpsHorsCadre > 0 && (
3072+
<p className="text-amber-700">
3073+
{arbresGpsHorsCadre} arbre{arbresGpsHorsCadre > 1 ? "s" : ""} {arbresGpsHorsCadre > 1 ? "ont" : "a"} un
3074+
relevé GPS situé hors de cette parcelle : {arbresGpsHorsCadre > 1 ? "leur position" : "sa position"} sur
3075+
le plan n&apos;est pas significative. Corrigez le relevé depuis la fiche de l&apos;arbre plutôt
3076+
qu&apos;en le déplaçant ici, ce qui écraserait les coordonnées réelles.
3077+
</p>
3078+
)}
30133079
</CardContent>
30143080
</Card>
30153081
</div>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { partitionnerArbresPourSauvegarde } from "../plan-sauvegarde"
4+
5+
const arbres = [
6+
{ id: 1, nom: "Pommier" },
7+
{ id: 2, nom: "Poirier" },
8+
{ id: 3, nom: "Cerisier" },
9+
]
10+
11+
describe("partitionnerArbresPourSauvegarde", () => {
12+
it("écrit tous les arbres quand aucune confirmation n'est pendante", () => {
13+
const { aEcrire, differes } = partitionnerArbresPourSauvegarde(arbres, [])
14+
15+
expect(aEcrire).toHaveLength(3)
16+
expect(differes).toEqual([])
17+
})
18+
19+
it("diffère le seul arbre en attente et laisse passer les autres", () => {
20+
const { aEcrire, differes } = partitionnerArbresPourSauvegarde(arbres, [2])
21+
22+
expect(aEcrire.map(a => a.id)).toEqual([1, 3])
23+
expect(differes.map(a => a.id)).toEqual([2])
24+
})
25+
26+
it("ne bloque jamais la sauvegarde entière, même avec plusieurs attentes", () => {
27+
const { aEcrire, differes } = partitionnerArbresPourSauvegarde(arbres, [1, 3])
28+
29+
// Le cœur du correctif : une confirmation abandonnée gelait tout le plan.
30+
expect(aEcrire.map(a => a.id)).toEqual([2])
31+
expect(differes.map(a => a.id)).toEqual([1, 3])
32+
})
33+
34+
it("ignore les identifiants en attente qui ne sont pas à l'écran", () => {
35+
const { aEcrire, differes } = partitionnerArbresPourSauvegarde(arbres, [99])
36+
37+
expect(aEcrire).toHaveLength(3)
38+
expect(differes).toEqual([])
39+
})
40+
41+
it("ne renvoie aucun arbre à écrire quand tous sont en attente", () => {
42+
const { aEcrire, differes } = partitionnerArbresPourSauvegarde(arbres, [1, 2, 3])
43+
44+
expect(aEcrire).toEqual([])
45+
expect(differes).toHaveLength(3)
46+
})
47+
48+
it("ne modifie pas le tableau source", () => {
49+
const source = [...arbres]
50+
partitionnerArbresPourSauvegarde(source, [2])
51+
52+
expect(source).toEqual(arbres)
53+
})
54+
})

src/lib/plan-sauvegarde.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Règle de sauvegarde du plan 2D face aux confirmations GPS en attente.
3+
*
4+
* Déplacer un arbre géolocalisé réécrit son relevé terrain : la nouvelle
5+
* position n'est donc persistée qu'après confirmation explicite (correctif du
6+
* 2026-07-26). Le verrou initial portait sur la sauvegarde entière, si bien
7+
* qu'une confirmation abandonnée sans réponse rendait planches, objets et tous
8+
* les autres arbres non enregistrables — silencieusement, et sans issue puisque
9+
* le plan n'a pas de bouton « Enregistrer » manuel (QA 2026-07-30).
10+
*
11+
* La règle correcte tient en deux points : ne jamais écrire un arbre dont la
12+
* confirmation est pendante, et ne jamais laisser cette attente bloquer le
13+
* reste du plan.
14+
*/
15+
16+
export type ElementPositionne = { id: number }
17+
18+
export type PartitionSauvegardePlan<T extends ElementPositionne> = {
19+
/** Arbres à écrire immédiatement. */
20+
aEcrire: T[]
21+
/** Arbres dont l'écriture attend une confirmation GPS. */
22+
differes: T[]
23+
}
24+
25+
export function partitionnerArbresPourSauvegarde<T extends ElementPositionne>(
26+
arbres: readonly T[],
27+
idsEnAttenteConfirmationGps: Iterable<number>,
28+
): PartitionSauvegardePlan<T> {
29+
const enAttente = new Set(idsEnAttenteConfirmationGps)
30+
if (enAttente.size === 0) return { aEcrire: [...arbres], differes: [] }
31+
32+
const aEcrire: T[] = []
33+
const differes: T[] = []
34+
for (const arbre of arbres) {
35+
if (enAttente.has(arbre.id)) differes.push(arbre)
36+
else aEcrire.push(arbre)
37+
}
38+
return { aEcrire, differes }
39+
}

0 commit comments

Comments
 (0)