mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Serveur : updatedAt exposé au détail OT ; baseUpdatedAt optionnel sur transition/coche/bilan → 409 « Conflit de version » contextualisé (qui, quand) ; toute écriture secondaire (coche, bilan, commentaire, conso, MO) fait avancer la version — sans version fournie, le web est inchangé. ADR-003 : sécurité & protocole de routage mobile (qui vit où sur l'appareil, purge complète à la déconnexion — correctif réel : la file et le cache persisté survivaient au logout). Mobile : file persistée AsyncStorage rejouée dans l'ordre — succès → propagation de la version fraîche aux saisies restantes du même OT (nos écritures ne se conflictent pas entre elles, un écart étranger reste détecté) ; coupure → tout attend ; refus → CONFLIT, la file s'arrête, l'humain tranche (voir l'OT / rejouer sur version à jour / abandonner). Transitions, coches, bilan et photos (D5, compressées ~1600 px) passent par la file avec patch optimiste du cache ; écran Synchro (badge tabbar ambre/rouge) ; préchargement parc + référentiels (le bilan hors-ligne a ses vocabulaires). Recette « mode avion » 13/13 en Expo web piloté : gestes hors-ligne → 3 en file → modification concurrente de Salma → conflit tranché → serveur Terminé avec bilan. 17 tests jest-expo, 74 tests API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
94 lines
3.0 KiB
TypeScript
94 lines
3.0 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { useSyncExternalStore } from 'react';
|
|
import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
|
|
|
|
/** La file d'écriture (D1) : chaque geste hors-ligne devient une saisie
|
|
* datée, persistée dans AsyncStorage — elle survit au redémarrage — et
|
|
* rejouée DANS L'ORDRE au retour du réseau. Chaque saisie porte la version
|
|
* de l'OT lue au moment du geste (D2, verrou optimiste). */
|
|
|
|
export type Saisie = {
|
|
id: string;
|
|
creeA: string; // ISO — affiché à l'écran Synchro
|
|
otId: string;
|
|
otReference: string;
|
|
libelle: string; // « Coche “Jeu des coulisseaux” »
|
|
baseUpdatedAt: string; // version lue (D2)
|
|
statut: 'EN_ATTENTE' | 'ENVOI' | 'CONFLIT';
|
|
erreur?: string; // message du 409 / refus métier — montré tel quel
|
|
} & (
|
|
| { type: 'TRANSITION'; payload: { to: WorkOrderStatus } }
|
|
| { type: 'COCHE'; payload: { itemId: string; state: ChecklistState } }
|
|
| { type: 'BILAN'; payload: Omit<ReportUpsert, 'baseUpdatedAt'> }
|
|
| { type: 'PHOTO'; payload: { uri: string; nom: string; mime: string } }
|
|
);
|
|
|
|
const CLE = 'siop.file.v1';
|
|
let file: Saisie[] = [];
|
|
const abonnes = new Set<() => void>();
|
|
|
|
function notifier(): void {
|
|
for (const a of abonnes) a();
|
|
void AsyncStorage.setItem(CLE, JSON.stringify(file));
|
|
}
|
|
|
|
/** À l'ouverture de l'app : la file survit au redémarrage (D1). */
|
|
export async function chargerFile(): Promise<void> {
|
|
const brut = await AsyncStorage.getItem(CLE);
|
|
if (brut) {
|
|
// Un ENVOI interrompu par un crash redevient EN_ATTENTE (rejouable).
|
|
file = (JSON.parse(brut) as Saisie[]).map((s) =>
|
|
s.statut === 'ENVOI' ? { ...s, statut: 'EN_ATTENTE' } : s,
|
|
);
|
|
for (const a of abonnes) a();
|
|
}
|
|
}
|
|
|
|
export const lireFile = (): Saisie[] => file;
|
|
|
|
export function enfiler(saisie: Omit<Saisie, 'id' | 'creeA' | 'statut'>): Saisie {
|
|
const complete = {
|
|
...saisie,
|
|
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
creeA: new Date().toISOString(),
|
|
statut: 'EN_ATTENTE',
|
|
} as Saisie;
|
|
file = [...file, complete];
|
|
notifier();
|
|
return complete;
|
|
}
|
|
|
|
export function majSaisie(id: string, patch: Partial<Pick<Saisie, 'statut' | 'erreur' | 'baseUpdatedAt'>>): void {
|
|
file = file.map((s) => (s.id === id ? ({ ...s, ...patch } as Saisie) : s));
|
|
notifier();
|
|
}
|
|
|
|
export function retirer(id: string): void {
|
|
file = file.filter((s) => s.id !== id);
|
|
notifier();
|
|
}
|
|
|
|
export function useFile(): Saisie[] {
|
|
return useSyncExternalStore(
|
|
(cb) => {
|
|
abonnes.add(cb);
|
|
return () => abonnes.delete(cb);
|
|
},
|
|
lireFile,
|
|
lireFile,
|
|
);
|
|
}
|
|
|
|
/** Déconnexion : la file appartient à la SESSION — on la purge (sécurité :
|
|
* un autre compte sur le même téléphone ne doit jamais rejouer les saisies
|
|
* du précédent), avec sa persistance. */
|
|
export function viderFile(): void {
|
|
file = [];
|
|
notifier();
|
|
}
|
|
|
|
/** Réservé aux tests : repartir d'une file vide (sans persistance). */
|
|
export function _viderPourTests(): void {
|
|
file = [];
|
|
}
|