commit b412d51658c36f5cdfd9a15ca36e9d8816bc39eb Author: luciano Date: Mon Sep 7 14:40:49 2026 +0200 Pollice Verde: gestione dell'orto e del frutteto con irrigazione calcolata App PWA che dice cosa fare nell'orto e, soprattutto, quanta acqua dare a ogni coltura, calcolandolo sul meteo reale invece che su un calendario fisso. Motore idrico (metodo FAO-56) - il terreno come serbatoio: capacità dal tipo di suolo per la profondità radicale, prelievo giornaliero pari a ET0 × Kc, ricarica da pioggia efficace e irrigazioni al netto dell'efficienza del metodo - bilancio ricostruito su 21 giorni reali e proiettato su 10 di previsione, così si sa anche fra quanti giorni servirà il prossimo turno e quando saltarlo - soglia di intervento dipendente dal metodo: con la goccia si bagna al 55% della riserva utile, con l'aspersione all'85%, a mano si sfrutta tutto - correzioni per pacciamatura, serra e coltura in vaso Dati - 58 schede fra ortaggi, frutta e aromatiche con coefficienti colturali, profondità radicali e fattori di deplezione da FAO-56, più calendario, sesti, consociazioni, cure e problemi tipici - ricerca che capisce i nomi dialettali: cocomero, popone, petonciano, ramerino - meteo ed evapotraspirazione da Open-Meteo, senza chiave API, con cache offline Automazione - generatore di programma per centraline a settori: ritmo di regime da impostare e giorni esatti con le piogge già scontate - agente Node che custodisce lo stato, esegue i turni un settore alla volta e registra nel diario quello che ha davvero erogato - driver per ESP32, Shelly Gen1/Gen2, Tasmota, più modalità simulata predefinita - sonde Watermark: veto sui turni, riancoraggio del bilancio su misura reale, confronto fra sonda e calcolo - sicurezze: tetti su minuti e litri, blocchi gelo/vento/pioggia, chiusura all'avvio e all'uscita, doppia chiusura a tempo lato dispositivo, un settore per volta, sospensione dei settori con sonda muta o guasta Firmware ESP32 incluso, con schema di collegamento e istruzioni di posa. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GFnqupGt8T5HNNhPPHCjKs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0980dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.DS_Store +*.local +tsconfig.tsbuildinfo +agente/dist +agente/dati/* +!agente/dati/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..a370df6 --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# 🌱 Pollice Verde + +App per gestire orto e frutteto: dice **cosa fare**, **quando**, e soprattutto **quanta acqua dare a ogni coltura**, +calcolandolo sul meteo reale della tua zona invece che su un calendario fisso. + +Funziona nel browser, si installa sulla schermata home del telefono e resta consultabile anche senza rete in giardino. +Nessun account, nessun server: i dati vivono nel browser del tuo dispositivo. + +## Avvio + +```bash +npm install +npm run dev # sviluppo, http://localhost:5173 +npm run build # versione ottimizzata in dist/ +npm run preview # prova la build, anche da telefono sulla rete di casa +``` + +Per usarla dal telefono: lancia `npm run preview`, apri sul telefono l'indirizzo `Network:` che compare nel terminale +(computer e telefono sulla stessa rete Wi-Fi), poi "Aggiungi a schermata Home". + +Per averla sempre disponibile senza tenere acceso il computer, la cartella `dist/` è un sito statico: caricala su un +qualsiasi hosting gratuito (Netlify, Vercel, GitHub Pages) e avrai un indirizzo tuo. + +## Cosa fa + +| Sezione | A cosa serve | +|---|---| +| **Oggi** | Meteo, allerte (gelate, ondate di calore, vento, piogge lunghe), cosa bagnare adesso e con quanti litri, operazioni del mese | +| **Giardino** | Aiuole (terreno, metodo irriguo, pacciamatura, serra, vaso) e colture piantate, con avvisi di rotazione | +| **Acqua** | Per ogni coltura: riserva d'acqua nel terreno, consumo giornaliero, dose, minuti di impianto, andamento a 31 giorni | +| **Turni** | Programma pronto per una centralina a settori: minuti e intervallo da impostare, giorni esatti con le piogge già scontate, avvisi su settori mal raggruppati e turni troppo lunghi. Con l'agente collegato, anche il pannello di comando delle valvole | +| **Guida** | 58 schede fra ortaggi, frutta e aromatiche: calendario, sesti, consociazioni, cure, problemi tipici e rimedi. La ricerca capisce i nomi dialettali: *cocomero*, *popone*, *petonciano*, *ramerino*, *rughetta*, *granoturco* | +| **Diario** | Registro di irrigazioni, concimazioni, trattamenti, potature e raccolti | + +## Come viene calcolata l'irrigazione + +Il metodo è quello **FAO-56**, lo stesso usato dai consorzi irrigui, riportato alla scala di un orto familiare. + +Il terreno è trattato come un serbatoio: + +1. **Capacità** — dipende dal tipo di suolo (un terreno franco trattiene 160 mm d'acqua per metro di profondità, uno + sabbioso 80) moltiplicato per la profondità delle radici di quella specie. Un pomodoro su terreno franco ha a + disposizione ~128 mm; un'insalata su sabbia ~24. +2. **Prelievo** — ogni giorno la pianta consuma `ET0 × Kc`, dove ET0 è l'evapotraspirazione di riferimento misurata + dal servizio meteo e Kc un coefficiente che dipende dalla specie e dalla fase in cui si trova. +3. **Ricarica** — pioggia (al netto dei primi millimetri che evaporano) e irrigazioni registrate, corrette per + l'efficienza del metodo: a goccia arriva alle radici il 90% dell'acqua, con l'aspersore il 65%. +4. **Soglia** — quando il consumo raggiunge la frazione di riserva che quella specie tollera (il sedano soffre già al + 20%, l'olivo regge fino al 65%) è il momento di irrigare, e la dose è l'acqua mancante. + +Il bilancio viene ricostruito sui 21 giorni passati con dati meteo reali e proiettato sui 10 giorni di previsione, così +l'app sa dire anche **fra quanti giorni** servirà la prossima irrigazione — e quando conviene saltare un turno perché +sta per piovere. + +La **soglia di intervento** non coincide con la soglia di stress e dipende dal metodo: con la goccia si bagna al 55% +della riserva utile (turni brevi e frequenti, bulbo umido costante), con l'aspersione all'85%, irrigando a mano si +sfrutta tutta la riserva con turni radi e abbondanti. + +Correzioni applicate: pacciamatura (−25% di consumo, regolabile), serra (−25% di evaporazione e nessun apporto di +pioggia), coltura in vaso (il serbatoio è il terriccio, non il terreno). + +## Automazione con una centralina + +La sezione **Turni** traduce il calcolo in qualcosa da digitare sulla centralina. Per ogni aiuola con un'elettrovalvola +serve indicare, nel form dell'aiuola, il **settore** (il nome della stazione) e la **portata**, in litri/ora del settore +oppure per m². La portata si misura meglio di quanto si calcoli: apri solo quella valvola, raccogli per un minuto +l'acqua di un gocciolatore, moltiplica per il numero di gocciolatori e per 60. + +L'app produce due cose diverse, perché rispondono a due domande diverse: + +- **il programma fisso** — intervallo e minuti di regime, simulati a terreno in equilibrio e tempo asciutto: è il caso + peggiore, quello da lasciare impostato sulla centralina; +- **i giorni esatti** — la sequenza reale dei prossimi 10 giorni, che parte dall'acqua che c'è adesso nel terreno, + recupera l'eventuale arretrato e sconta le piogge previste. + +Su un settore con più colture il programma è tarato sulla più esigente, e l'app avvisa quando su una stessa valvola +convivono piante con ritmi troppo diversi (l'insalata vuole acqua ogni tre giorni, l'olivo ogni tre settimane). + +### Controllo automatico delle valvole + +L'apertura vera è gestita dall'**agente** (`agente/`): un piccolo programma Node che gira su qualcosa di sempre acceso — +Raspberry Pi, NAS, vecchio portatile — e fa tre cose: + +1. custodisce lo stato del giardino, che l'app legge e aggiorna via rete; +2. all'orario stabilito calcola il programma del giorno e apre le valvole, **un settore alla volta**; +3. registra nel diario quello che ha davvero erogato, così il bilancio idrico resta agganciato alla realtà. + +```bash +npm run agente # compila app e agente, poi lo avvia sulla porta 8088 +``` + +L'agente serve anche l'app compilata: apri `http://:8088` dal telefono e hai tutto. Sta in HTTP sulla +rete locale di proposito — è l'unico modo perché una pagina possa parlare con dispositivi locali senza inciampare nel +blocco dei contenuti misti. **Non esporlo su internet.** + +Dispositivi supportati: **ESP32** col firmware incluso (`firmware/`), **Shelly Gen 1 e Gen 2**, **Tasmota**, più la +modalità **simulata** che è quella predefinita. + +#### Sicurezze + +L'automazione parte sempre spenta e va attivata a mano. Quando è attiva: + +| Blocco | Cosa fa | +|---|---| +| Minuti massimi per turno | Tronca i turni troppo lunghi (predefinito 180) | +| Litri massimi al giorno | Tetto su tutto il giardino (predefinito 2000) | +| Blocco gelo | Niente acqua con minima sotto 3 °C | +| Blocco vento | Salta i settori ad aspersione sopra 30 km/h | +| Blocco pioggia | Annulla il turno se sono previsti più di 5 mm | +| Veto delle sonde | Se una sonda misura il terreno ancora umido, il turno salta: la misura batte la stima | +| Sonda guasta o muta | Un settore con sonda scollegata, in corto o senza letture recenti non viene irrigato | +| Canale mancante | Un'aiuola senza canale assegnato non viene mai aperta | +| Chiusura all'avvio | L'agente chiude tutto all'accensione e all'uscita | +| Doppia chiusura a tempo | La durata è nota anche al dispositivo: se l'agente muore, l'acqua si chiude lo stesso | +| Un settore per volta | Mai due valvole aperte insieme | + +La prova a secco (`Turni → Prova a secco`) usa sempre il driver simulato: non fa scattare i relè nemmeno per un istante. + +### Sonde nel terreno (Watermark) + +Il bilancio idrico è un modello: bravo a prevedere, ma cieco. Una sonda **Watermark +200SS** misura la tensione idrica reale in kPa — la fatica che la radice fa per +estrarre l'acqua — e il sistema la usa in tre modi: + +- **veto**: se prima di un turno il terreno risulta ancora umido, il turno salta; +- **riancoraggio**: quando una sonda legge la capacità di campo (≤ 15 kPa), il + bilancio riparte da un dato misurato invece che da una stima; +- **confronto**: nella sezione Acqua, sonda e calcolo vengono messi a confronto, e + quando non vanno d'accordo l'app dice quale manopola guardare. + +Soglie di intervento predefinite, in kPa: sabbioso 25, franco-sabbioso 30, franco +40, franco-argilloso 50, argilloso 60. Modificabili per singola aiuola. + +Il firmware legge le sonde in corrente alternata (due misure a polarità invertita +da 50 µs) e converte resistenza in kPa con le equazioni di Shock, Barnum e Seddigh, +le stesse usate da Irrometer, con compensazione della temperatura del terreno. +Schema di collegamento e istruzioni di posa in `firmware/README.md`. + +Restano fuori portata di un'app nel browser le notifiche ad app chiusa: per quelle servirebbe un servizio di push o un +bot di messaggistica sull'agente. + +## Struttura + +``` +src/ + lib/ + types.ts modello dati + colture.ts indice delle colture + colture.ortaggi.ts 30 ortaggi con coefficienti FAO-56 e schede colturali + colture.aromatiche.ts 9 aromatiche + colture.frutta.ts 19 fruttiferi + meteo.ts client Open-Meteo (geocoding, previsioni, cache offline) + irrigazione.ts motore a bilancio idrico + programma.ts generatore del programma per centraline a settori + agente.ts client dell'agente della centralina + sonde.ts soglie, livelli e confronto sonda/calcolo + attivita.ts operazioni del mese, allerte meteo, rotazioni + state/ + store.tsx stato dell'app, persistenza in localStorage + consigli.ts calcolo dei consigli per tutte le piantagioni + pages/ le sette schermate + components/comuni.tsx modale, campi, barre, grafico del bilancio +public/ + sw.js service worker per l'uso offline + manifest.webmanifest installazione come app +agente/ + src/agente.ts pianificazione, esecuzione dei turni, registro + src/server.ts server HTTP e orologio di partenza + src/driver.ts ESP32, Shelly Gen1/Gen2, Tasmota, simulato + src/sicurezza.ts blocchi gelo, vento, pioggia, sonde, tetti giornalieri + src/sonde.ts lettura delle Watermark dal dispositivo +firmware/ + pollice-verde-esp32.ino firmware ESP32: relè, sonde Watermark, chiusura a tempo +``` + +## Fonti + +- Meteo ed evapotraspirazione: [Open-Meteo](https://open-meteo.com) — gratuito, senza chiave API. +- Coefficienti colturali, profondità radicali e fattori di deplezione: FAO Irrigation and Drainage Paper 56, + *Crop evapotranspiration*. + +## Nota + +Il calcolo è una stima molto buona ma non conosce il tuo microclima: l'ombra del muro a nord, la zona che resta sempre +umida, il vento che secca un angolo. Usalo come guida e verifica col dito nel terreno — due falangi: se esce asciutto è +ora di bagnare. Dopo qualche settimana saprai di quanto correggere, e le manopole in Impostazioni servono a quello. diff --git a/agente/dati/.gitkeep b/agente/dati/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agente/src/agente.ts b/agente/src/agente.ts new file mode 100644 index 0000000..8f144d4 --- /dev/null +++ b/agente/src/agente.ts @@ -0,0 +1,445 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from "node:fs" +import { dirname, join } from "node:path" +import type { Aiuola, Evento, LetturaSonda, Stato } from "../../src/lib/types" +import { caricaMeteo, isoOggi, type DatiMeteo } from "../../src/lib/meteo" +import { calcolaConsiglio } from "../../src/lib/irrigazione" +import { programmaSettore } from "../../src/lib/programma" +import { coltura } from "../../src/lib/colture" +import { creaDriver, type Driver } from "./driver" +import { controllaSettore, limitaDurata, litriErogatiOggi } from "./sicurezza" +import { leggiSonde } from "./sonde" + +/** + * L'agente è il pezzo sempre acceso. Fa tre cose: + * 1. custodisce lo stato del giardino, che il telefono legge e aggiorna; + * 2. ogni minuto guarda l'orologio e, all'orario di partenza, calcola il + * programma del giorno ed esegue i turni un settore alla volta; + * 3. registra nel diario quello che ha davvero erogato, così il bilancio + * idrico dell'app resta agganciato alla realtà. + * + * Gira su un Raspberry Pi, un NAS, un vecchio portatile: qualsiasi cosa con + * Node e la corrente attaccata. + */ + +const CARTELLA = process.env.POLLICE_DATI ?? join(process.cwd(), "agente", "dati") +const FILE_STATO = join(CARTELLA, "stato.json") +const FILE_REGISTRO = join(CARTELLA, "registro.log") +const FILE_PARTENZA = join(CARTELLA, "ultima-partenza.txt") + +export interface Esecuzione { + quando: string + aiuolaId: string + aiuola: string + canale: number + minuti: number + litri: number + mm: number + esito: "eseguito" | "simulato" | "saltato" | "errore" + dettaglio: string +} + +export class Agente { + stato: Stato + meteo: DatiMeteo | null = null + registro: string[] = [] + esecuzioni: Esecuzione[] = [] + /** Ultima lettura di ogni sonda, tenuta in memoria per l'interfaccia. */ + sonde: LetturaSonda[] = [] + ultimaLetturaSonde: string | null = null + erroreSonde: string | null = null + /** Canali attualmente aperti, per poterli chiudere in emergenza. */ + aperti = new Set() + inEsecuzione = false + + constructor(stato?: Stato) { + this.stato = stato ?? leggiStato() + } + + /** + * Giorno dell'ultima partenza automatica, su file: se l'agente si riavvia + * proprio all'orario di partenza non deve rieseguire il programma. + */ + get ultimaPartenza(): string | null { + try { + return existsSync(FILE_PARTENZA) ? readFileSync(FILE_PARTENZA, "utf8").trim() : null + } catch { + return null + } + } + + set ultimaPartenza(giorno: string | null) { + try { + mkdirSync(CARTELLA, { recursive: true }) + writeFileSync(FILE_PARTENZA, giorno ?? "") + } catch { + /* se non si può scrivere, si rischia solo una doppia partenza */ + } + } + + scrivi(msg: string): void { + const riga = `${new Date().toISOString()} ${msg}` + this.registro.push(riga) + if (this.registro.length > 500) this.registro.shift() + console.log(riga) + try { + mkdirSync(dirname(FILE_REGISTRO), { recursive: true }) + appendFileSync(FILE_REGISTRO, riga + "\n") + } catch { + /* il registro su file è un di più: se non si può scrivere, pazienza */ + } + } + + get driver(): Driver { + const c = this.stato.impostazioni.centralina + // Senza automazione attiva si resta sempre sul driver simulato, qualunque + // cosa dica la configurazione: è l'interruttore generale. + const tipo = c.automazioneAttiva ? c.driver : "simulato" + return creaDriver(tipo, c.host, (m) => this.scrivi(m)) + } + + salva(): void { + salvaStato(this.stato) + } + + /** + * Unisce lo stato che arriva dall'app. L'app è padrona della configurazione del + * giardino e del diario; l'agente lo è solo di quello che ha registrato oggi, + * che il telefono può non aver ancora visto. Gli eventi automatici più vecchi + * seguono la lista dell'app: così se cancelli dal diario una lettura sbagliata, + * resta cancellata invece di ricomparire al primo aggiornamento. + */ + fondiStato(inArrivo: Stato): Stato { + const oggi = isoOggi() + const uniti = new Map(inArrivo.eventi.map((e) => [e.id, e])) + for (const e of this.stato.eventi) { + if (e.automatico && e.data === oggi && !uniti.has(e.id)) uniti.set(e.id, e) + } + this.stato = { ...inArrivo, eventi: [...uniti.values()].sort((a, b) => b.data.localeCompare(a.data)) } + this.salva() + return this.stato + } + + async aggiornaMeteo(forza = false): Promise { + const luogo = this.stato.impostazioni.luogo + if (!luogo) return + try { + this.meteo = await caricaMeteo(luogo, forza) + } catch (e) { + this.scrivi(`Meteo non raggiungibile: ${(e as Error).message}`) + } + } + + /** + * Interroga le sonde e ne annota il valore nel diario, una volta al giorno per + * aiuola. Conserviamo il valore più basso della giornata: è quello che dice se + * a un certo punto il profilo si è davvero riempito, ed è quindi il buon punto + * di partenza per il bilancio idrico. + */ + async aggiornaSonde(): Promise { + const c = this.stato.impostazioni.centralina + if (!c.hostSensori && !c.host) return + if (c.driver === "simulato") return + try { + const tempC = this.meteo?.giorni.find((g) => g.data === isoOggi()) + const media = tempC ? (tempC.tMax + tempC.tMin) / 2 : undefined + this.sonde = await leggiSonde(c, media) + this.ultimaLetturaSonde = new Date().toISOString() + this.erroreSonde = null + this.annotaSonde() + } catch (e) { + this.erroreSonde = (e as Error).message + this.scrivi(`Sonde non leggibili: ${this.erroreSonde}`) + } + } + + private annotaSonde(): void { + const oggi = isoOggi() + let cambiato = false + const eventi = [...this.stato.eventi] + + for (const a of this.stato.aiuole) { + if (a.sensore === undefined) continue + const lettura = this.sonde.find((x) => x.sensore === a.sensore) + if (!lettura || lettura.salute !== "ok") continue + + const i = eventi.findIndex((e) => e.tipo === "sonda" && e.data === oggi && e.aiuolaId === a.id) + if (i >= 0) { + const precedente = eventi[i].quantita ?? 999 + if (lettura.kpa >= precedente) continue + eventi[i] = { ...eventi[i], quantita: Math.round(lettura.kpa) } + } else { + eventi.unshift({ + id: `sonda-${a.id}-${oggi}`, + data: oggi, + tipo: "sonda", + aiuolaId: a.id, + quantita: Math.round(lettura.kpa), + unita: "kPa", + testo: `Sonda ${a.sensore}: valore più umido della giornata`, + automatico: true, + }) + } + cambiato = true + } + + if (cambiato) { + this.stato = { ...this.stato, eventi } + this.salva() + } + } + + /** Turni previsti per oggi, settore per settore, già passati dai controlli. */ + pianoDiOggi(): { aiuola: Aiuola; minuti: number; litri: number; mm: number; blocco?: string }[] { + if (!this.meteo) return [] + const oggi = isoOggi() + const meteoOggi = this.meteo.giorni.find((g) => g.data === oggi) + const sic = this.stato.impostazioni.centralina.sicurezze + const giaErogati = litriErogatiOggi(this.stato, oggi) + + const consigli = this.stato.piantagioni + .filter((p) => !p.archiviata) + .map((p) => { + const c = coltura(p.colturaId) + const a = this.stato.aiuole.find((x) => x.id === p.aiuolaId) + if (!c || !a || !this.meteo) return null + return calcolaConsiglio(p, c, a, this.stato.eventi, this.meteo.giorni, this.stato.impostazioni) + }) + .filter((x): x is NonNullable => x !== null) + + // Una lettura vecchia non vale: la si considera scaduta dopo tre intervalli + // di lettura, con un minimo di due ore. + const c = this.stato.impostazioni.centralina + const scadenzaMs = Math.max(c.intervalloLetturaMinuti * 3, 120) * 60_000 + const sondeFresche = this.sonde.filter((x) => Date.now() - Date.parse(x.quando) < scadenzaMs) + const sondeAttese = + c.driver !== "simulato" && + Boolean(c.hostSensori || c.host) && + this.stato.aiuole.some((a) => a.sensore !== undefined) + + const piano: { aiuola: Aiuola; minuti: number; litri: number; mm: number; blocco?: string }[] = [] + let litriPrenotati = giaErogati + + for (const a of this.stato.aiuole) { + const prog = programmaSettore(a, consigli, this.meteo.giorni, this.stato.impostazioni) + if (!prog) continue + const turnoOggi = prog.turni.find((t) => t.data === oggi) + if (!turnoOggi) continue + + const controllo = controllaSettore(a, meteoOggi, sic, sondeFresche, sondeAttese) + if (!controllo.consentito) { + piano.push({ aiuola: a, minuti: 0, litri: 0, mm: turnoOggi.mm, blocco: controllo.motivo }) + continue + } + if (turnoOggi.minuti === null) { + piano.push({ + aiuola: a, + minuti: 0, + litri: turnoOggi.litri, + mm: turnoOggi.mm, + blocco: `${a.nome}: manca la portata del settore, non so per quanti minuti tenere aperto.`, + }) + continue + } + + const lim = limitaDurata(turnoOggi.minuti, turnoOggi.litri, litriPrenotati, sic) + if (lim.minuti <= 0) { + piano.push({ aiuola: a, minuti: 0, litri: 0, mm: turnoOggi.mm, blocco: lim.avviso }) + continue + } + litriPrenotati += lim.litri + piano.push({ + aiuola: a, + minuti: lim.minuti, + litri: lim.litri, + mm: Math.round(((turnoOggi.mm * lim.minuti) / turnoOggi.minuti) * 10) / 10, + blocco: lim.avviso, + }) + } + + return piano + } + + /** Esegue il programma del giorno, un settore alla volta. */ + async esegui(soloProva = false): Promise { + if (this.inEsecuzione) { + this.scrivi("Esecuzione già in corso: ignoro la richiesta.") + return [] + } + this.inEsecuzione = true + const fatte: Esecuzione[] = [] + try { + await this.aggiornaMeteo() + // Prima di decidere, si guarda il terreno: la misura batte la stima. + await this.aggiornaSonde() + const piano = this.pianoDiOggi() + if (piano.length === 0) { + this.scrivi("Nessun turno previsto oggi.") + return [] + } + + const c = this.stato.impostazioni.centralina + const simulazione = soloProva || !c.automazioneAttiva || c.driver === "simulato" + // In simulazione si usa il driver finto, non quello vero: una prova a secco + // non deve far scattare i relè nemmeno per un istante. + const driver = simulazione ? creaDriver("simulato", "", (m) => this.scrivi(m)) : this.driver + + for (const voce of piano) { + if (voce.minuti <= 0) { + const e: Esecuzione = { + quando: new Date().toISOString(), + aiuolaId: voce.aiuola.id, + aiuola: voce.aiuola.nome, + canale: voce.aiuola.canale ?? -1, + minuti: 0, + litri: 0, + mm: 0, + esito: "saltato", + dettaglio: voce.blocco ?? "Turno non necessario.", + } + fatte.push(e) + this.scrivi(`SALTATO ${voce.aiuola.nome}: ${e.dettaglio}`) + continue + } + + const canale = voce.aiuola.canale as number + try { + this.scrivi( + `${simulazione ? "PROVA" : "APRO"} ${voce.aiuola.nome} (canale ${canale}) per ${voce.minuti} min = ${voce.litri} litri` + ) + await driver.apri(canale, voce.minuti * 60) + this.aperti.add(canale) + + if (!simulazione) await attendi(voce.minuti * 60_000) + + await driver.chiudi(canale) + this.aperti.delete(canale) + + const esec: Esecuzione = { + quando: new Date().toISOString(), + aiuolaId: voce.aiuola.id, + aiuola: voce.aiuola.nome, + canale, + minuti: voce.minuti, + litri: voce.litri, + mm: voce.mm, + esito: simulazione ? "simulato" : "eseguito", + dettaglio: voce.blocco ?? "Turno completato.", + } + fatte.push(esec) + + if (!simulazione) this.registraIrrigazione(voce.aiuola, voce.mm, voce.litri) + + if (c.pausaFraSettori > 0 && !simulazione) await attendi(c.pausaFraSettori * 60_000) + } catch (err) { + // Qualsiasi cosa vada storta, la prima mossa è chiudere. + await driver.chiudi(canale).catch(() => {}) + this.aperti.delete(canale) + const messaggio = (err as Error).message + fatte.push({ + quando: new Date().toISOString(), + aiuolaId: voce.aiuola.id, + aiuola: voce.aiuola.nome, + canale, + minuti: 0, + litri: 0, + mm: 0, + esito: "errore", + dettaglio: messaggio, + }) + this.scrivi(`ERRORE su ${voce.aiuola.nome}: ${messaggio} — valvola richiusa.`) + } + } + } finally { + this.inEsecuzione = false + } + + this.esecuzioni = [...fatte, ...this.esecuzioni].slice(0, 200) + this.salva() + return fatte + } + + /** Apertura manuale, con chiusura automatica garantita. */ + async apriManuale(canale: number, minuti: number): Promise { + const driver = this.driver + const secondi = Math.min(Math.max(minuti, 1), this.stato.impostazioni.centralina.sicurezze.minutiMassimiPerTurno) * 60 + this.scrivi(`Apertura manuale del canale ${canale} per ${Math.round(secondi / 60)} minuti`) + await driver.apri(canale, secondi) + this.aperti.add(canale) + // Il dispositivo ha già la sua chiusura a tempo; questa è la seconda. + setTimeout(() => { + driver.chiudi(canale).catch((e: Error) => this.scrivi(`Chiusura del canale ${canale} non riuscita: ${e.message}`)) + this.aperti.delete(canale) + }, secondi * 1000) + } + + async chiudiTutto(): Promise { + const driver = this.driver + const canali = new Set([...this.aperti, ...this.stato.aiuole.map((a) => a.canale ?? -1).filter((n) => n >= 0)]) + for (const c of canali) await driver.chiudi(c).catch(() => {}) + this.aperti.clear() + this.scrivi("Tutte le valvole chiuse.") + } + + private registraIrrigazione(aiuola: Aiuola, mm: number, litri: number): void { + const evento: Evento = { + id: `auto-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, + data: isoOggi(), + tipo: "irrigazione", + aiuolaId: aiuola.id, + mm, + quantita: litri, + unita: "litri", + testo: `Turno automatico sul canale ${aiuola.canale}`, + automatico: true, + } + this.stato = { ...this.stato, eventi: [evento, ...this.stato.eventi] } + this.salva() + } +} + +function attendi(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +export function leggiStato(): Stato { + try { + if (existsSync(FILE_STATO)) return JSON.parse(readFileSync(FILE_STATO, "utf8")) as Stato + } catch (e) { + console.error("Stato illeggibile, riparto da vuoto:", (e as Error).message) + } + return { + versione: 1, + impostazioni: { + luogo: null, + risparmioPacciamatura: 0.25, + efficienza: { goccia: 0.9, subirrigazione: 0.95, manuale: 0.75, aspersione: 0.65 }, + temaScuro: null, + centralina: { + urlAgente: "", + automazioneAttiva: false, + driver: "simulato", + host: "", + orarioPartenza: "06:00", + pausaFraSettori: 2, + hostSensori: "", + intervalloLetturaMinuti: 15, + sicurezze: { + minutiMassimiPerTurno: 180, + litriMassimiAlGiorno: 2000, + bloccoGeloSotto: 3, + bloccoVentoSopra: 30, + bloccoPioggiaSopra: 5, + vetoSonde: true, + }, + }, + }, + aiuole: [], + piantagioni: [], + eventi: [], + } +} + +export function salvaStato(s: Stato): void { + mkdirSync(CARTELLA, { recursive: true }) + writeFileSync(FILE_STATO, JSON.stringify(s, null, 2)) +} diff --git a/agente/src/driver.ts b/agente/src/driver.ts new file mode 100644 index 0000000..626a8d5 --- /dev/null +++ b/agente/src/driver.ts @@ -0,0 +1,142 @@ +import type { DriverCentralina } from "../../src/lib/types" + +/** + * Driver delle valvole. Ogni driver sa fare due cose: aprire un canale per un + * numero di secondi e chiuderlo. Tutti i comandi di apertura passano anche una + * durata al dispositivo, quando il dispositivo la supporta: è la seconda rete di + * sicurezza, quella che chiude l'acqua anche se l'agente muore nel frattempo. + */ + +export interface Driver { + nome: string + /** Apre il canale per la durata indicata. */ + apri(canale: number, secondi: number): Promise + chiudi(canale: number): Promise + /** Verifica che il dispositivo risponda, senza toccare le valvole. */ + verifica(): Promise +} + +const TIMEOUT_MS = 6000 + +async function chiama(url: string): Promise { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS) + try { + const r = await fetch(url, { signal: ctrl.signal }) + const testo = await r.text() + if (!r.ok) throw new Error(`HTTP ${r.status}: ${testo.slice(0, 120)}`) + return testo + } finally { + clearTimeout(t) + } +} + +/** Non tocca niente: scrive soltanto cosa avrebbe fatto. È il default. */ +function simulato(registra: (m: string) => void): Driver { + return { + nome: "simulato", + async apri(canale, secondi) { + registra(`[SIMULATO] aprirei il canale ${canale} per ${Math.round(secondi / 60)} minuti`) + }, + async chiudi(canale) { + registra(`[SIMULATO] chiuderei il canale ${canale}`) + }, + async verifica() { + return "Modalità simulazione: nessun dispositivo contattato, nessuna valvola aperta." + }, + } +} + +/** Firmware Pollice Verde su ESP32: vedi firmware/pollice-verde-esp32.ino */ +function esp32(host: string): Driver { + const base = `http://${host}` + return { + nome: `ESP32 ${host}`, + async apri(canale, secondi) { + await chiama(`${base}/valvola?canale=${canale}&secondi=${Math.round(secondi)}`) + }, + async chiudi(canale) { + await chiama(`${base}/valvola?canale=${canale}&secondi=0`) + }, + async verifica() { + return await chiama(`${base}/stato`) + }, + } +} + +/** Shelly di seconda generazione (Plus 1, Pro, Plus 2PM…): API RPC. */ +function shellyGen2(host: string): Driver { + const base = `http://${host}/rpc` + return { + nome: `Shelly Gen2 ${host}`, + async apri(canale, secondi) { + // toggle_after fa spegnere il relè dal firmware stesso: se l'agente si + // pianta a metà turno, l'acqua si chiude comunque. + await chiama(`${base}/Switch.Set?id=${canale}&on=true&toggle_after=${Math.round(secondi)}`) + }, + async chiudi(canale) { + await chiama(`${base}/Switch.Set?id=${canale}&on=false`) + }, + async verifica() { + return await chiama(`${base}/Shelly.GetDeviceInfo`) + }, + } +} + +/** Shelly di prima generazione (Shelly 1, 1PM, 2.5). */ +function shellyGen1(host: string): Driver { + const base = `http://${host}` + return { + nome: `Shelly Gen1 ${host}`, + async apri(canale, secondi) { + await chiama(`${base}/relay/${canale}?turn=on&timer=${Math.round(secondi)}`) + }, + async chiudi(canale) { + await chiama(`${base}/relay/${canale}?turn=off`) + }, + async verifica() { + return await chiama(`${base}/status`) + }, + } +} + +/** Qualsiasi dispositivo con firmware Tasmota. */ +function tasmota(host: string): Driver { + const base = `http://${host}/cm?cmnd=` + return { + nome: `Tasmota ${host}`, + async apri(canale, secondi) { + // PulseTime è in decimi di secondo, con offset 100 per i valori oltre i 111 s. + const decimi = Math.round(secondi * 10) + const pulse = decimi <= 111 ? decimi : Math.min(Math.round(secondi) + 100, 64900) + await chiama(`${base}${encodeURIComponent(`PulseTime${canale + 1} ${pulse}`)}`) + await chiama(`${base}${encodeURIComponent(`Power${canale + 1} ON`)}`) + }, + async chiudi(canale) { + await chiama(`${base}${encodeURIComponent(`Power${canale + 1} OFF`)}`) + }, + async verifica() { + return await chiama(`${base}${encodeURIComponent("Status 0")}`) + }, + } +} + +export function creaDriver( + tipo: DriverCentralina, + host: string, + registra: (m: string) => void +): Driver { + if (tipo === "simulato" || !host) return simulato(registra) + switch (tipo) { + case "esp32": + return esp32(host) + case "shelly-gen2": + return shellyGen2(host) + case "shelly-gen1": + return shellyGen1(host) + case "tasmota": + return tasmota(host) + default: + return simulato(registra) + } +} diff --git a/agente/src/server.ts b/agente/src/server.ts new file mode 100644 index 0000000..6f58119 --- /dev/null +++ b/agente/src/server.ts @@ -0,0 +1,241 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import { readFileSync, existsSync, statSync } from "node:fs" +import { extname, join, normalize } from "node:path" +import { Agente } from "./agente" +import { creaDriver } from "./driver" +import type { Stato } from "../../src/lib/types" +import { isoOggi } from "../../src/lib/meteo" + +/** + * Server dell'agente. Serve l'app compilata (così apri lo stesso indirizzo dal + * telefono e dal computer) ed espone le poche API che le servono. + * + * Sta volutamente in HTTP sulla rete di casa: è l'unico modo perché una pagina + * possa parlare con dispositivi locali senza inciampare nel blocco dei contenuti + * misti. Non esporlo su internet. + */ + +const PORTA = Number(process.env.POLLICE_PORTA ?? 8088) +const CARTELLA_APP = process.env.POLLICE_APP ?? join(process.cwd(), "dist") + +const agente = new Agente() + +const TIPI: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", +} + +function json(res: ServerResponse, dati: unknown, codice = 200): void { + const corpo = JSON.stringify(dati) + res.writeHead(codice, { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, OPTIONS", + "access-control-allow-headers": "content-type", + }) + res.end(corpo) +} + +function corpo(req: IncomingMessage): Promise { + return new Promise((risolvi, rifiuta) => { + let dati = "" + req.on("data", (c) => { + dati += c + if (dati.length > 5_000_000) rifiuta(new Error("Richiesta troppo grande")) + }) + req.on("end", () => risolvi(dati)) + req.on("error", rifiuta) + }) +} + +function serviFile(res: ServerResponse, percorso: string): boolean { + const sicuro = normalize(percorso) + if (!sicuro.startsWith(normalize(CARTELLA_APP))) return false + if (!existsSync(sicuro) || !statSync(sicuro).isFile()) return false + res.writeHead(200, { "content-type": TIPI[extname(sicuro)] ?? "application/octet-stream" }) + res.end(readFileSync(sicuro)) + return true +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`) + const via = url.pathname + + if (req.method === "OPTIONS") return json(res, {}) + + try { + // ── API ─────────────────────────────────────────────────────────────── + if (via === "/api/stato" && req.method === "GET") { + return json(res, { stato: agente.stato }) + } + + if (via === "/api/stato" && req.method === "PUT") { + const inArrivo = JSON.parse(await corpo(req)) as Stato + if (!Array.isArray(inArrivo.aiuole)) return json(res, { errore: "Stato non valido" }, 400) + return json(res, { stato: agente.fondiStato(inArrivo) }) + } + + if (via === "/api/centralina" && req.method === "GET") { + await agente.aggiornaMeteo() + const c = agente.stato.impostazioni.centralina + return json(res, { + versione: 1, + automazioneAttiva: c.automazioneAttiva, + driver: c.driver, + host: c.host, + orarioPartenza: c.orarioPartenza, + modalita: c.automazioneAttiva && c.driver !== "simulato" ? "attiva" : "simulazione", + aperti: [...agente.aperti], + inEsecuzione: agente.inEsecuzione, + ultimaPartenza: agente.ultimaPartenza, + piano: agente.pianoDiOggi().map((v) => ({ + aiuolaId: v.aiuola.id, + aiuola: v.aiuola.nome, + settore: v.aiuola.settore ?? null, + canale: v.aiuola.canale ?? null, + minuti: v.minuti, + litri: v.litri, + mm: v.mm, + blocco: v.blocco ?? null, + })), + sonde: agente.sonde, + ultimaLetturaSonde: agente.ultimaLetturaSonde, + erroreSonde: agente.erroreSonde, + esecuzioni: agente.esecuzioni.slice(0, 30), + registro: agente.registro.slice(-40), + litriOggi: agente.stato.eventi + .filter((e) => e.tipo === "irrigazione" && e.data === isoOggi() && e.automatico) + .reduce((s, e) => s + (e.quantita ?? 0), 0), + }) + } + + if (via === "/api/sonde" && req.method === "POST") { + await agente.aggiornaSonde() + return json(res, { sonde: agente.sonde, quando: agente.ultimaLetturaSonde, errore: agente.erroreSonde }) + } + + if (via === "/api/verifica" && req.method === "POST") { + const c = agente.stato.impostazioni.centralina + const d = creaDriver(c.driver, c.host, (m) => agente.scrivi(m)) + try { + const risposta = await d.verifica() + return json(res, { ok: true, driver: d.nome, risposta: risposta.slice(0, 400) }) + } catch (e) { + return json(res, { ok: false, driver: d.nome, errore: (e as Error).message }) + } + } + + if (via === "/api/valvola" && req.method === "POST") { + const { canale, minuti } = JSON.parse(await corpo(req)) as { canale: number; minuti: number } + if (typeof canale !== "number" || typeof minuti !== "number") { + return json(res, { errore: "Servono canale e minuti" }, 400) + } + await agente.apriManuale(canale, minuti) + return json(res, { ok: true, canale, minuti }) + } + + if (via === "/api/stop" && req.method === "POST") { + await agente.chiudiTutto() + return json(res, { ok: true }) + } + + if (via === "/api/prova" && req.method === "POST") { + const fatte = await agente.esegui(true) + return json(res, { ok: true, esecuzioni: fatte }) + } + + if (via === "/api/esegui" && req.method === "POST") { + // Un programma vero dura decine di minuti: si avvia e si risponde subito, + // altrimenti la richiesta resterebbe appesa fino alla fine dei turni. + void agente.esegui(false).catch((e: Error) => agente.scrivi(`Programma interrotto: ${e.message}`)) + return json(res, { ok: true, avviato: true }) + } + + // ── App compilata ───────────────────────────────────────────────────── + if (req.method === "GET") { + const richiesto = join(CARTELLA_APP, via === "/" ? "index.html" : decodeURIComponent(via)) + if (serviFile(res, richiesto)) return + // Applicazione a pagina singola: qualsiasi altro percorso torna l'index. + if (serviFile(res, join(CARTELLA_APP, "index.html"))) return + res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }) + return res.end( + "App non compilata. Esegui «npm run build» nella cartella del progetto, poi riavvia l'agente." + ) + } + + json(res, { errore: "Non trovato" }, 404) + } catch (e) { + agente.scrivi(`Errore nella richiesta ${via}: ${(e as Error).message}`) + json(res, { errore: (e as Error).message }, 500) + } +}) + +/** Le sonde si interrogano di rado: la tensione idrica si muove in ore. */ +function letturaSonde(): void { + const prossima = () => { + const minuti = Math.max(agente.stato.impostazioni.centralina.intervalloLetturaMinuti, 5) + setTimeout(async () => { + await agente.aggiornaSonde() + prossima() + }, minuti * 60_000) + } + prossima() +} + +/** Ogni minuto: è l'ora di partire? */ +function orologio(): void { + setInterval(async () => { + const c = agente.stato.impostazioni.centralina + const ora = new Date() + const adesso = `${String(ora.getHours()).padStart(2, "0")}:${String(ora.getMinutes()).padStart(2, "0")}` + const oggi = isoOggi() + if (adesso !== c.orarioPartenza) return + if (agente.ultimaPartenza === oggi) return + agente.ultimaPartenza = oggi + agente.scrivi(`Orario di partenza (${adesso}): avvio il programma del giorno.`) + await agente.esegui(false) + }, 60_000) +} + +async function avvia(): Promise { + // Prima cosa all'accensione: chiudere tutto. Se l'agente è stato riavviato a + // metà turno, una valvola potrebbe essere rimasta aperta. + await agente.chiudiTutto().catch(() => {}) + await agente.aggiornaMeteo(true) + await agente.aggiornaSonde() + + server.listen(PORTA, () => { + const c = agente.stato.impostazioni.centralina + console.log(`\n🌱 Agente Pollice Verde in ascolto sulla porta ${PORTA}`) + console.log(` App: http://:${PORTA}`) + console.log(` Modalità: ${c.automazioneAttiva && c.driver !== "simulato" ? "ATTIVA — le valvole si apriranno davvero" : "simulazione (nessuna valvola verrà aperta)"}`) + console.log(` Driver: ${c.driver}${c.host ? ` su ${c.host}` : ""}`) + console.log(` Partenza: ${c.orarioPartenza}`) + console.log(` Sonde: ${agente.sonde.length ? `${agente.sonde.length} collegate` : "nessuna"}${agente.erroreSonde ? ` (${agente.erroreSonde})` : ""}\n`) + }) + orologio() + letturaSonde() +} + +// Qualunque sia il motivo dell'uscita, l'acqua si chiude. +for (const segnale of ["SIGINT", "SIGTERM"] as const) { + process.on(segnale, () => { + console.log("\nChiudo le valvole e termino…") + agente + .chiudiTutto() + .catch(() => {}) + .finally(() => process.exit(0)) + }) +} +process.on("uncaughtException", (e) => { + console.error("Errore non gestito:", e) + agente.chiudiTutto().catch(() => {}) +}) + +avvia() diff --git a/agente/src/sicurezza.ts b/agente/src/sicurezza.ts new file mode 100644 index 0000000..59ff952 --- /dev/null +++ b/agente/src/sicurezza.ts @@ -0,0 +1,117 @@ +import type { Aiuola, LetturaSonda, Sicurezze, Stato } from "../../src/lib/types" +import { sogliaDi } from "../../src/lib/sonde" +import type { GiornoMeteo } from "../../src/lib/meteo" + +/** + * Controlli da fare PRIMA di aprire una valvola. Un'automazione che sbaglia non + * fa un errore di calcolo: allaga un giardino per una notte intera. Ogni blocco + * qui dentro esiste perché il caso corrispondente è successo a qualcuno. + */ + +export interface EsitoControllo { + consentito: boolean + motivo?: string +} + +export function controllaSettore( + aiuola: Aiuola, + meteoOggi: GiornoMeteo | undefined, + s: Sicurezze, + sonde: LetturaSonda[] = [], + /** Vero quando è configurato un dispositivo che dovrebbe fornire letture. */ + sondeAttese = false +): EsitoControllo { + if (aiuola.canale === undefined || aiuola.canale === null) { + return { consentito: false, motivo: `${aiuola.nome}: nessun canale assegnato sulla centralina.` } + } + if (!meteoOggi) { + return { consentito: false, motivo: "Dati meteo del giorno mancanti: meglio non muovere niente." } + } + if (meteoOggi.tMin <= s.bloccoGeloSotto) { + return { + consentito: false, + motivo: `Minima prevista ${meteoOggi.tMin.toFixed(0)} °C: sotto ${s.bloccoGeloSotto} °C non si irriga, l'acqua nelle tubazioni gela e il terreno bagnato peggiora il danno da gelata.`, + } + } + if (meteoOggi.pioggia >= s.bloccoPioggiaSopra) { + return { + consentito: false, + motivo: `Previsti ${meteoOggi.pioggia.toFixed(0)} mm di pioggia oggi: turno annullato.`, + } + } + if (aiuola.metodo === "aspersione" && meteoOggi.vento >= s.bloccoVentoSopra) { + return { + consentito: false, + motivo: `Vento a ${meteoOggi.vento.toFixed(0)} km/h su un settore ad aspersione: l'acqua finirebbe ovunque tranne che sulle piante.`, + } + } + + // Il veto della sonda: se il terreno è misurato ancora umido, il calcolo può + // dire quello che vuole. La misura batte la stima. + if (s.vetoSonde && aiuola.sensore !== undefined) { + const lettura = sonde.find((x) => x.sensore === aiuola.sensore) + if (lettura && lettura.salute === "ok") { + const soglia = sogliaDi(aiuola) + if (lettura.kpa < soglia) { + return { + consentito: false, + motivo: `La sonda legge ${lettura.kpa.toFixed(0)} kPa, sotto la soglia di ${soglia} kPa: il terreno è ancora umido, turno annullato.`, + } + } + } else if (lettura) { + return { + consentito: false, + motivo: `Sonda ${aiuola.sensore} ${lettura.salute} (${lettura.ohm.toFixed(0)} ohm): con una sonda guasta non mi fido ad aprire da solo. Controlla il collegamento, o togli il veto delle sonde in Impostazioni.`, + } + } else if (sondeAttese) { + // Nessuna lettura recente: il dispositivo è spento, irraggiungibile, o la + // sonda non esiste. Con una misura vecchia di ore si rischia di irrigare su + // un dato che non vale più niente: meglio fermarsi. + return { + consentito: false, + motivo: `Nessuna lettura recente dalla sonda ${aiuola.sensore}: il dispositivo non risponde. Il turno è sospeso finché non torna, oppure togli il veto delle sonde in Impostazioni.`, + } + } + } + + return { consentito: true } +} + +/** Litri già erogati oggi dall'automazione, per non superare il tetto giornaliero. */ +export function litriErogatiOggi(stato: Stato, giorno: string): number { + return stato.eventi + .filter((e) => e.tipo === "irrigazione" && e.data === giorno && e.automatico) + .reduce((somma, e) => somma + (e.quantita ?? 0), 0) +} + +/** Tronca la durata di un turno ai limiti impostati e spiega perché. */ +export function limitaDurata( + minuti: number, + litri: number, + litriGiaErogati: number, + s: Sicurezze +): { minuti: number; litri: number; avviso?: string } { + let m = minuti + let l = litri + let avviso: string | undefined + + if (m > s.minutiMassimiPerTurno) { + const fattore = s.minutiMassimiPerTurno / m + m = s.minutiMassimiPerTurno + l = Math.round(l * fattore) + avviso = `Turno troncato a ${m} minuti dal limite di sicurezza (ne servirebbero ${Math.round(minuti)}).` + } + + const residuo = s.litriMassimiAlGiorno - litriGiaErogati + if (residuo <= 0) { + return { minuti: 0, litri: 0, avviso: `Tetto giornaliero di ${s.litriMassimiAlGiorno} litri già raggiunto.` } + } + if (l > residuo) { + const fattore = residuo / l + m = Math.max(Math.round(m * fattore), 1) + l = residuo + avviso = `Turno ridotto a ${m} minuti per restare sotto il tetto di ${s.litriMassimiAlGiorno} litri al giorno.` + } + + return { minuti: m, litri: l, avviso } +} diff --git a/agente/src/sonde.ts b/agente/src/sonde.ts new file mode 100644 index 0000000..948c1f9 --- /dev/null +++ b/agente/src/sonde.ts @@ -0,0 +1,42 @@ +import type { Centralina, LetturaSonda } from "../../src/lib/types" + +/** + * Lettura delle sonde Watermark dal dispositivo. + * + * Si interrogano di rado — ogni quarto d'ora basta e avanza: la tensione idrica + * di un terreno si muove in ore, non in secondi, e ogni lettura consuma un po' + * di gesso della sonda. + */ + +export interface RispostaSonde { + temperatura: number + sonde: { sensore: number; ohm: number; kpa: number; salute: string }[] +} + +const TIMEOUT_MS = 8000 + +export async function leggiSonde(c: Centralina, tempC?: number): Promise { + const host = c.hostSensori || c.host + if (!host || c.driver === "simulato") return [] + + const url = new URL(`http://${host}/sensori`) + if (typeof tempC === "number" && Number.isFinite(tempC)) url.searchParams.set("tempC", tempC.toFixed(1)) + + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS) + try { + const r = await fetch(url, { signal: ctrl.signal }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const dati = (await r.json()) as RispostaSonde + const quando = new Date().toISOString() + return (dati.sonde ?? []).map((s) => ({ + sensore: s.sensore, + kpa: s.kpa, + ohm: s.ohm, + salute: s.salute, + quando, + })) + } finally { + clearTimeout(t) + } +} diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..4cec970 --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,123 @@ +# Firmware ESP32 per Pollice Verde + +Trasforma un ESP32 e una scheda a relè nella centralina comandata dall'app. + +## Materiale + +| Pezzo | Indicativo | +|---|---| +| ESP32 DevKit v1 | 8 € | +| Scheda a 4 relè con optoisolatore | 7 € | +| Trasformatore 24 V AC per elettrovalvole (se non ne hai già uno) | 15 € | +| Alimentatore 5 V per l'ESP32 | 6 € | +| Scatola stagna IP65 | 10 € | +| Sonda Watermark 200SS (una per settore) | 35 € | +| Resistenza 10 kΩ 1% (una per sonda) | pochi centesimi | + +Le elettrovalvole da giardino sono quasi sempre a **24 V AC**. Il relè commuta +quella tensione, non la 230 V: il comune del trasformatore va a tutte le valvole, +e ogni relè chiude il ritorno di una valvola. Se il tuo impianto usa tensione di +rete, fallo vedere a un elettricista prima di metterci le mani. + +## Collegamenti + +``` +ESP32 GPIO 26 → IN1 della scheda relè (canale 0) +ESP32 GPIO 27 → IN2 (canale 1) +ESP32 GPIO 14 → IN3 (canale 2) +ESP32 GPIO 12 → IN4 (canale 3) +ESP32 5V → VCC scheda relè +ESP32 GND → GND scheda relè + +Trasformatore 24V AC ─┬─ comune di tutte le elettrovalvole + └─ COM di ogni relè +Ogni NO del relè ─────── filo della rispettiva elettrovalvola +``` + +Il numero di canale che scrivi nella scheda dell'aiuola, dentro l'app, è la +posizione nell'array `PIN_RELE`: il primo pin è il canale 0. + +### Sonde Watermark + +Ogni sonda va in serie a una resistenza da 10 kΩ, fra due pin di eccitazione, e +l'ADC legge il punto di mezzo: + +``` +GPIO 32 ──┬── filo 1 della sonda 0 + │ + sonda 0 (Watermark 200SS) + │ + ├── GPIO 34 (lettura ADC) + │ + resistenza 10 kΩ + │ +GPIO 33 ──┘ + +GPIO 25 / 35 / GPIO 4 → stessa cosa per la sonda 1 +``` + +I fili della Watermark non hanno polarità. GPIO 34 e 35 sono di **solo ingresso** +sull'ESP32 ed è il motivo per cui sono stati scelti per la lettura. + +**La cosa da non sbagliare: i Watermark si leggono in corrente alternata.** Una +tensione continua elettrolizza il gesso e in poche settimane la sonda è da +buttare. Il firmware lo fa già: ogni misura è due letture a polarità invertita da +50 microsecondi l'una, e fra una misura e l'altra i pin restano entrambi a zero. +Non aumentare `MICROSECONDI_ECCITAZIONE` e non leggere più spesso di ogni +quarto d'ora. + +### Dove infilare la sonda + +A **metà della profondità delle radici** — 15-20 cm per l'orto, 30-40 cm per un +albero — e a 15-20 cm di lato dal gocciolatore, mai sotto: sotto il gocciolatore +misureresti il bulbo bagnato, non il terreno che la pianta esplora davvero. + +Fai un foro con una punta di legno del diametro della sonda, bagna la sonda in +acqua per qualche minuto prima di inserirla, e **fai fango**: versa un po' di +terra stemperata nel foro prima di calare la sonda, così il gesso resta a +contatto pieno col terreno. Una sonda con aria attorno legge sempre secco. + +A fine stagione, sui terreni che gelano, tirale fuori. + +## Caricamento + +1. Arduino IDE → *Preferenze* → URL aggiuntive: + `https://espressif.github.io/arduino-esp32/package_esp32_index.json` +2. *Gestore schede* → installa **esp32** by Espressif. +3. Apri `pollice-verde-esp32.ino`, scrivi rete Wi-Fi e password in cima. +4. Scheda: *ESP32 Dev Module*. Carica. +5. Apri il monitor seriale a 115200: stampa l'indirizzo IP da mettere in + *Impostazioni → Centralina → Indirizzo del dispositivo*. + +Assegna all'ESP32 un **IP fisso** dal router, altrimenti prima o poi cambierà e +l'agente non lo troverà più. + +## Sicurezze già nel firmware + +- **Chiusura a tempo autonoma.** Ogni apertura porta con sé la sua durata: la + scheda chiude allo scadere anche se l'agente è morto o la rete è caduta. +- **Tetto di 3 ore** per singola apertura, non aggirabile dall'esterno. +- **Un solo canale alla volta**, perché la pressione di un impianto domestico + non basta per due settori insieme (disattivabile con `UNO_ALLA_VOLTA`). +- **Relè chiusi prima di configurare i pin**, così un riavvio dell'ESP32 non + apre le valvole per un istante. +- **Riconnessione Wi-Fi** ogni 30 secondi senza interrompere un turno in corso. + +## Prova prima di collegare l'acqua + +Carica il firmware, alimenta solo l'ESP32 e la scheda relè, e da un browser: + +``` +http:///stato +http:///valvola?canale=0&secondi=10 +http:///sensori +``` + +Devi sentire il relè scattare e, dopo dieci secondi, scattare di nuovo in +chiusura. Solo quando tutti i canali fanno così, collega le valvole. + +Per le sonde, la prova che vale: immergi la sonda in un bicchiere d'acqua e +aspetta dieci minuti. `/sensori` deve leggere **0-5 kPa**. Tirala fuori, lasciala +asciugare all'aria un giorno, e deve salire **oltre i 100 kPa**. Se resta +inchiodata su un valore o dice sempre `scollegata`, il problema è nel cablaggio, +non nel terreno. diff --git a/firmware/pollice-verde-esp32.ino b/firmware/pollice-verde-esp32.ino new file mode 100644 index 0000000..c1b1886 --- /dev/null +++ b/firmware/pollice-verde-esp32.ino @@ -0,0 +1,288 @@ +/* + * Pollice Verde — firmware per ESP32 con scheda a relè + * ──────────────────────────────────────────────────── + * Espone tre indirizzi HTTP sulla rete di casa: + * + * GET /stato → JSON con lo stato dei canali + * GET /valvola?canale=0&secondi=1200 → apre il canale 0 per 20 minuti + * GET /valvola?canale=0&secondi=0 → chiude subito il canale 0 + * GET /stop → chiude tutto + * GET /sensori → lettura delle sonde Watermark, in kPa + * GET /sensori?tempC=21 → idem, con compensazione di temperatura + * + * Il pezzo importante è che la durata la conosce la scheda, non solo l'agente: + * se l'agente si spegne, se cade la rete o se qualcuno stacca il Raspberry a + * metà turno, il canale si chiude comunque allo scadere del tempo. Un allagamento + * notturno è il tipo di guasto che non si vuole scoprire la mattina dopo. + * + * ⚠️ Le elettrovalvole da giardino sono a 24 V AC o 9 V a impulso: il relè + * commuta quella tensione, NON la 230 V. Se il tuo impianto usa la rete + * elettrica, falla vedere a un elettricista prima di toccarla. + * + * Librerie: nessuna oltre a quelle del core ESP32 (WiFi, WebServer). + */ + +#include +#include + +// ── Da personalizzare ─────────────────────────────────────────────────────── +const char* WIFI_SSID = "NOME-DELLA-TUA-RETE"; +const char* WIFI_PASSWORD = "PASSWORD-DELLA-TUA-RETE"; + +// Pin dei relè, uno per canale. L'ordine qui è l'ordine dei canali: il primo +// elemento è il canale 0, che è quello da scrivere nella scheda dell'aiuola. +const int PIN_RELE[] = {26, 27, 14, 12}; +const int NUM_CANALI = sizeof(PIN_RELE) / sizeof(PIN_RELE[0]); + +// Quasi tutte le schede a relè cinesi sono attive a livello basso: il relè +// scatta quando il pin va a 0. Se la tua fa il contrario, metti false. +const bool RELE_ATTIVO_BASSO = true; + +// Nessun canale può restare aperto più di così, qualunque cosa chieda l'agente. +const unsigned long MASSIMO_SECONDI = 3 * 60 * 60; // 3 ore + +// Un solo settore per volta: la pressione di un impianto domestico non basta +// per due valvole aperte insieme. Metti false se il tuo impianto lo regge. +const bool UNO_ALLA_VOLTA = true; + +// ── Sonde Watermark ───────────────────────────────────────────────────────── +// Ogni sonda va letta con un partitore: la sonda in serie a una resistenza nota, +// il tutto fra due pin di eccitazione, e l'ADC che legge il punto di mezzo. +// +// La cosa da NON sbagliare: i Watermark si leggono in **corrente alternata**. +// Una tensione continua elettrolizza il gesso e in poche settimane la sonda è +// da buttare. Per questo ogni misura si fa due volte a polarità invertita, per +// pochi microsecondi, e si media. Fra una misura e l'altra i pin restano a zero. +const int PIN_ECCITAZIONE_A[] = {32, 25}; // un pin per sonda +const int PIN_ECCITAZIONE_B[] = {33, 4}; // il pin opposto della stessa sonda +const int PIN_LETTURA[] = {34, 35}; // ingressi ADC (solo pin di solo-ingresso) +const int NUM_SONDE = sizeof(PIN_LETTURA) / sizeof(PIN_LETTURA[0]); + +const float RESISTENZA_NOTA = 10000.0; // ohm, in serie alla sonda +const float TENSIONE_ECCITAZIONE = 3.3; +const int MICROSECONDI_ECCITAZIONE = 50; // brevissimo: più a lungo, più si consuma la sonda +const float TEMPERATURA_SUOLO_PREDEFINITA = 24.0; // °C, se l'agente non ne passa una +// ──────────────────────────────────────────────────────────────────────────── + +WebServer server(80); +unsigned long chiusuraPrevista[8]; // millis() a cui chiudere, 0 = chiuso + +void scriviRele(int canale, bool acceso) { + int livello = acceso ? (RELE_ATTIVO_BASSO ? LOW : HIGH) + : (RELE_ATTIVO_BASSO ? HIGH : LOW); + digitalWrite(PIN_RELE[canale], livello); +} + +void chiudi(int canale) { + scriviRele(canale, false); + chiusuraPrevista[canale] = 0; +} + +void chiudiTutto() { + for (int i = 0; i < NUM_CANALI; i++) chiudi(i); +} + +void apri(int canale, unsigned long secondi) { + if (secondi > MASSIMO_SECONDI) secondi = MASSIMO_SECONDI; + if (UNO_ALLA_VOLTA) { + for (int i = 0; i < NUM_CANALI; i++) + if (i != canale && chiusuraPrevista[i] != 0) chiudi(i); + } + scriviRele(canale, true); + chiusuraPrevista[canale] = millis() + secondi * 1000UL; +} + +/** + * Misura la resistenza di una sonda con doppia eccitazione a polarità invertita. + * Restituisce la media delle due letture, in ohm. + */ +float leggiResistenza(int sonda) { + float somma = 0; + for (int verso = 0; verso < 2; verso++) { + int alto = verso == 0 ? PIN_ECCITAZIONE_A[sonda] : PIN_ECCITAZIONE_B[sonda]; + int basso = verso == 0 ? PIN_ECCITAZIONE_B[sonda] : PIN_ECCITAZIONE_A[sonda]; + + digitalWrite(alto, HIGH); + digitalWrite(basso, LOW); + delayMicroseconds(MICROSECONDI_ECCITAZIONE); + int grezzo = analogRead(PIN_LETTURA[sonda]); + digitalWrite(alto, LOW); // subito a riposo: niente continua sulla sonda + + float volt = (grezzo / 4095.0) * TENSIONE_ECCITAZIONE; + // Nel verso invertito il partitore si legge specchiato. + float frazione = verso == 0 ? volt / TENSIONE_ECCITAZIONE + : 1.0 - (volt / TENSIONE_ECCITAZIONE); + if (frazione <= 0.001) frazione = 0.001; + if (frazione >= 0.999) frazione = 0.999; + somma += RESISTENZA_NOTA * (frazione / (1.0 - frazione)); + + delay(90); // riposo fra i due versi + } + return somma / 2.0; +} + +/** + * Da resistenza a centibar (= kPa). Equazioni di Shock, Barnum e Seddigh (1998), + * le stesse usate da Irrometer: la relazione non è lineare e cambia forma su tre + * intervalli, e dipende dalla temperatura del terreno. + */ +float resistenzaInKpa(float ohm, float tempC) { + float kohm = ohm / 1000.0; + float kpa; + + if (ohm > 550.0) { + if (ohm > 8000.0) { + kpa = -2.246 - 5.239 * kohm * (1.0 + 0.018 * (tempC - 24.0)) - + 0.06756 * kohm * kohm * pow((1.0 + 0.018 * (tempC - 24.0)), 2.0); + } else if (ohm > 1000.0) { + kpa = (-3.213 * kohm - 4.093) / (1.0 - 0.009733 * kohm - 0.01205 * tempC); + } else { + kpa = (kohm * 23.156 - 12.736) * (1.0 + 0.018 * (tempC - 24.0)); + } + } else { + // Sotto i 550 ohm il terreno è saturo: la sonda non distingue più niente. + kpa = 0.0; + } + + if (kpa < 0.0) kpa = 0.0; + if (kpa > 239.0) kpa = 239.0; // fondo scala del sensore + return kpa; +} + +/** Diagnostica del collegamento, per non scambiare un cavo staccato per siccità. */ +const char* saluteSonda(float ohm) { + if (ohm > 60000.0) return "scollegata"; // circuito aperto: filo staccato + if (ohm < 200.0) return "in corto"; // due fili che si toccano, o acqua nel morsetto + if (ohm > 35000.0) return "fuori scala"; // oltre il campo utile del Watermark + return "ok"; +} + +String jsonSensori(float tempC) { + String s = "{\"temperatura\":" + String(tempC, 1) + ",\"sonde\":["; + for (int i = 0; i < NUM_SONDE; i++) { + if (i) s += ","; + float ohm = leggiResistenza(i); + float kpa = resistenzaInKpa(ohm, tempC); + s += "{\"sensore\":" + String(i) + + ",\"ohm\":" + String(ohm, 0) + + ",\"kpa\":" + String(kpa, 1) + + ",\"salute\":\"" + String(saluteSonda(ohm)) + "\"}"; + } + s += "]}"; + return s; +} + +String jsonStato() { + String s = "{\"firmware\":\"pollice-verde-1.0\",\"canali\":["; + unsigned long ora = millis(); + for (int i = 0; i < NUM_CANALI; i++) { + if (i) s += ","; + unsigned long restanti = 0; + if (chiusuraPrevista[i] != 0 && chiusuraPrevista[i] > ora) + restanti = (chiusuraPrevista[i] - ora) / 1000UL; + s += "{\"canale\":" + String(i) + + ",\"aperto\":" + (chiusuraPrevista[i] != 0 ? "true" : "false") + + ",\"secondiRestanti\":" + String(restanti) + "}"; + } + s += "],\"ip\":\"" + WiFi.localIP().toString() + "\"}"; + return s; +} + +void rispondi(int codice, const String& corpo) { + server.sendHeader("Access-Control-Allow-Origin", "*"); + server.send(codice, "application/json", corpo); +} + +void gestisciValvola() { + if (!server.hasArg("canale") || !server.hasArg("secondi")) { + rispondi(400, "{\"errore\":\"servono canale e secondi\"}"); + return; + } + int canale = server.arg("canale").toInt(); + long secondi = server.arg("secondi").toInt(); + + if (canale < 0 || canale >= NUM_CANALI) { + rispondi(400, "{\"errore\":\"canale inesistente\"}"); + return; + } + if (secondi <= 0) { + chiudi(canale); + } else { + apri(canale, (unsigned long)secondi); + } + rispondi(200, jsonStato()); +} + +void setup() { + Serial.begin(115200); + + // Prima cosa all'accensione: tutti i relè chiusi, PRIMA di impostarli come + // uscite. Al contrario, un ESP32 che riparte apre le valvole per un istante. + for (int i = 0; i < NUM_CANALI; i++) { + digitalWrite(PIN_RELE[i], RELE_ATTIVO_BASSO ? HIGH : LOW); + pinMode(PIN_RELE[i], OUTPUT); + scriviRele(i, false); + chiusuraPrevista[i] = 0; + } + + // Pin delle sonde: a riposo entrambi bassi, così fra una lettura e l'altra + // non passa corrente e il gesso non si consuma. + for (int i = 0; i < NUM_SONDE; i++) { + pinMode(PIN_ECCITAZIONE_A[i], OUTPUT); + pinMode(PIN_ECCITAZIONE_B[i], OUTPUT); + digitalWrite(PIN_ECCITAZIONE_A[i], LOW); + digitalWrite(PIN_ECCITAZIONE_B[i], LOW); + pinMode(PIN_LETTURA[i], INPUT); + } + analogSetAttenuation(ADC_11db); // fondo scala ~3,3 V + + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + Serial.print("Mi collego al Wi-Fi"); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(); + Serial.print("Pronto. Indirizzo da mettere in Pollice Verde: "); + Serial.println(WiFi.localIP()); + + server.on("/stato", []() { rispondi(200, jsonStato()); }); + server.on("/sensori", []() { + float t = server.hasArg("tempC") ? server.arg("tempC").toFloat() + : TEMPERATURA_SUOLO_PREDEFINITA; + rispondi(200, jsonSensori(t)); + }); + server.on("/valvola", gestisciValvola); + server.on("/stop", []() { + chiudiTutto(); + rispondi(200, jsonStato()); + }); + server.onNotFound([]() { rispondi(404, "{\"errore\":\"non trovato\"}"); }); + server.begin(); +} + +void loop() { + server.handleClient(); + + // Il vero guardiano: chiude i canali scaduti anche se nessuno lo chiede. + unsigned long ora = millis(); + for (int i = 0; i < NUM_CANALI; i++) { + if (chiusuraPrevista[i] != 0 && (long)(ora - chiusuraPrevista[i]) >= 0) { + Serial.println("Tempo scaduto: chiudo il canale " + String(i)); + chiudi(i); + } + } + + // Se il Wi-Fi cade non chiudiamo subito (un turno in corso deve finire), ma + // proviamo a riconnetterci: senza rete l'agente non può più dare lo stop. + static unsigned long ultimoControllo = 0; + if (ora - ultimoControllo > 30000) { + ultimoControllo = ora; + if (WiFi.status() != WL_CONNECTED) { + Serial.println("Wi-Fi caduto, riprovo"); + WiFi.disconnect(); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + } + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..cd2be18 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Pollice Verde + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aee84aa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2217 @@ +{ + "name": "pollice-verde", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pollice-verde", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "esbuild": "^0.24.0", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2594c1a --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "pollice-verde", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "Gestione e manutenzione dell'orto e del frutteto, con piano di irrigazione basato su bilancio idrico e dati meteo reali.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview --host", + "typecheck": "tsc -b --noEmit", + "build:agente": "esbuild agente/src/server.ts --bundle --platform=node --target=node20 --format=esm --outfile=agente/dist/agente.mjs --log-level=warning", + "agente": "npm run build && npm run build:agente && node agente/dist/agente.mjs" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "esbuild": "^0.24.0" + } +} \ No newline at end of file diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..f276ffc Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..b2076ba Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable.png b/public/icon-maskable.png new file mode 100644 index 0000000..6d1ffbe Binary files /dev/null and b/public/icon-maskable.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..e130179 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..4dd43d6 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Pollice Verde — orto e frutteto", + "short_name": "Pollice Verde", + "description": "Gestione dell'orto e del frutteto con piano di irrigazione calcolato sul meteo reale.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#f7f8f5", + "theme_color": "#1f7a4d", + "lang": "it", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e8eae19 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,52 @@ +/* Service worker minimale: l'app deve aprirsi anche in fondo all'orto senza campo. + - guscio dell'app: cache-first, aggiornata in sottofondo + - meteo: rete per prima, con l'ultima risposta buona come rete di sicurezza */ +const CACHE = "pollice-verde-v1" +const GUSCIO = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"] + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(GUSCIO)).then(() => self.skipWaiting())) +}) + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((chiavi) => Promise.all(chiavi.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ) +}) + +self.addEventListener("fetch", (e) => { + const req = e.request + if (req.method !== "GET") return + const url = new URL(req.url) + + if (url.hostname.endsWith("open-meteo.com")) { + e.respondWith( + fetch(req) + .then((r) => { + const copia = r.clone() + caches.open(CACHE).then((c) => c.put(req, copia)) + return r + }) + .catch(() => caches.match(req)) + ) + return + } + + if (url.origin !== location.origin) return + + e.respondWith( + caches.match(req).then((colpo) => { + const rete = fetch(req) + .then((r) => { + if (r.ok) { + const copia = r.clone() + caches.open(CACHE).then((c) => c.put(req, copia)) + } + return r + }) + .catch(() => colpo || caches.match("/index.html")) + return colpo || rete + }) + ) +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..4afe080 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react" +import { Provider, useApp } from "./state/store" +import Oggi from "./pages/Oggi" +import Giardino from "./pages/Giardino" +import Acqua from "./pages/Acqua" +import Programma from "./pages/Programma" +import Guida from "./pages/Guida" +import Diario from "./pages/Diario" +import Impostazioni from "./pages/Impostazioni" + +type Pagina = "oggi" | "giardino" | "acqua" | "programma" | "guida" | "diario" | "impostazioni" + +const VOCI: { v: Pagina; ico: string; l: string }[] = [ + { v: "oggi", ico: "🌤️", l: "Oggi" }, + { v: "giardino", ico: "🪴", l: "Orto" }, + { v: "acqua", ico: "💧", l: "Acqua" }, + { v: "programma", ico: "📅", l: "Turni" }, + { v: "guida", ico: "📖", l: "Guida" }, + { v: "diario", ico: "📔", l: "Diario" }, +] + +function Interno() { + const { stato } = useApp() + const [pagina, setPagina] = useState("oggi") + + // Il tema segue il sistema, salvo scelta esplicita. + useEffect(() => { + const scelta = stato.impostazioni.temaScuro + const sistema = window.matchMedia("(prefers-color-scheme: dark)") + const applica = () => { + const scuro = scelta === null ? sistema.matches : scelta + document.documentElement.dataset.tema = scuro ? "scuro" : "chiaro" + document.querySelector('meta[name="theme-color"]')?.setAttribute("content", scuro ? "#0f1613" : "#1f7a4d") + } + applica() + sistema.addEventListener("change", applica) + return () => sistema.removeEventListener("change", applica) + }, [stato.impostazioni.temaScuro]) + + useEffect(() => { + window.scrollTo({ top: 0 }) + }, [pagina]) + + return ( +
+
+
+ 🌱 + Pollice Verde +
+
+ +
+ + + +
+ {pagina === "oggi" && setPagina(p as Pagina)} />} + {pagina === "giardino" && } + {pagina === "acqua" && } + {pagina === "programma" && } + {pagina === "guida" && } + {pagina === "diario" && } + {pagina === "impostazioni" && } +
+
+ ) +} + +export default function App() { + return ( + + + + ) +} diff --git a/src/components/Centralina.tsx b/src/components/Centralina.tsx new file mode 100644 index 0000000..9a5970b --- /dev/null +++ b/src/components/Centralina.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useState } from "react" +import { useApp } from "../state/store" +import { + apriValvola, + chiudiTutto, + eseguiOra, + leggiCentralina, + leggiSondeOra, + provaProgramma, + type StatoCentralina, +} from "../lib/agente" +import { COLORE_LIVELLO, DESCRIZIONE_LIVELLO, livello, sogliaDi } from "../lib/sonde" +import { Barra } from "./comuni" +import { formattaDurata } from "../lib/irrigazione" + +/** Pannello di comando della centralina: cosa farà oggi, cosa ha fatto, e i comandi a mano. */ +export default function Centralina() { + const { stato } = useApp() + const url = stato.impostazioni.centralina.urlAgente + const [dati, setDati] = useState(null) + const [errore, setErrore] = useState(null) + const [occupato, setOccupato] = useState(false) + const [messaggio, setMessaggio] = useState(null) + + const aggiorna = useCallback(() => { + if (!url) return + leggiCentralina(url) + .then((d) => { + setDati(d) + setErrore(null) + }) + .catch((e: Error) => setErrore(e.message)) + }, [url]) + + useEffect(() => { + aggiorna() + const t = setInterval(aggiorna, 20_000) + return () => clearInterval(t) + }, [aggiorna]) + + if (!url) return null + + async function comando(azione: () => Promise, testo: string) { + setOccupato(true) + setMessaggio(null) + try { + await azione() + setMessaggio(testo) + aggiorna() + } catch (e) { + setMessaggio(`Non riuscito: ${(e as Error).message}`) + } finally { + setOccupato(false) + } + } + + if (errore) { + return ( +
+
+

🎛️ Centralina

+ +
+
+ 📡 +
+
Agente non raggiungibile
+

+ {errore}. Controlla che l'agente sia acceso e che l'indirizzo in Impostazioni sia giusto. Finché non + risponde, l'automazione è ferma e il giardino non viene irrigato da solo. +

+
+
+
+ ) + } + + if (!dati) { + return ( +
+

🎛️ Centralina

+

Mi collego all'agente…

+
+ ) + } + + const attiva = dati.modalita === "attiva" + const totaleOggi = dati.piano.reduce((s, v) => s + v.litri, 0) + + return ( +
+
+
+

🎛️ Centralina

+

+ {dati.driver} + {dati.host ? ` · ${dati.host}` : ""} · partenza alle {dati.orarioPartenza} +

+
+ {attiva ? "Automazione attiva" : "Simulazione"} +
+ + {!attiva && ( +
+ 🧪 +

+ Modalità simulazione: l'agente calcola tutto e scrive cosa farebbe, ma nessuna valvola si apre. È il modo + giusto per verificare che i canali siano assegnati bene prima di dare acqua vera. Quando ti fidi, accendi + l'automazione in Impostazioni. +

+
+ )} + + {dati.inEsecuzione && ( +
+ 💦 +

+ Irrigazione in corso.{" "} + {dati.aperti.length ? `Canale ${dati.aperti.join(", ")} aperto.` : "Fra un settore e l'altro."} +

+
+ )} + +
+
+
Previsti oggi
+
{Math.round(totaleOggi)}litri
+
+
+
Già erogati
+
{Math.round(dati.litriOggi)}litri
+
+
+
Settori in programma
+
{dati.piano.filter((v) => v.minuti > 0).length}
+
+
+ + {dati.sonde.length > 0 && ( + <> +

+ SONDE NEL TERRENO +

+
    + {dati.sonde.map((s) => { + const aiuola = stato.aiuole.find((a) => a.sensore === s.sensore) + const soglia = aiuola ? sogliaDi(aiuola) : 40 + const l = livello(s, soglia) + return ( +
  • +
    + + 🌡️ {aiuola?.nome ?? `Sonda ${s.sensore}`} + + + {s.salute === "ok" ? `${s.kpa.toFixed(0)} kPa` : s.salute} + +
    + {/* La barra è l'acqua rimasta: piena a capacità di campo, vuota alla soglia. */} + +

    {DESCRIZIONE_LIVELLO[l]}

    +
  • + ) + })} +
+
+ + {dati.ultimaLetturaSonde && ( + + ultima lettura {new Date(dati.ultimaLetturaSonde).toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" })} + + )} +
+ + )} + + {dati.erroreSonde && ( +
+ 🌡️ +

+ Sonde non leggibili: {dati.erroreSonde}. Con il veto delle sonde attivo i settori che ne hanno una + assegnata non verranno irrigati finché non tornano a rispondere. +

+
+ )} + + {dati.piano.length > 0 && ( + <> +

+ PROGRAMMA DI OGGI +

+
    + {dati.piano.map((v) => ( +
  • +
    +
    +
    + {v.settore ?? v.aiuola} + {v.canale !== null && · canale {v.canale}} +
    +
    + {v.minuti > 0 ? `${formattaDurata(v.minuti)} · ${v.litri} litri · ${v.mm} mm` : "nessun turno"} +
    +
    + {v.canale !== null && ( + + )} +
    + {v.blocco && ( +

    {v.blocco}

    + )} +
  • + ))} +
+ + )} + +
+ + + +
+ + {messaggio &&

{messaggio}

} + + {dati.esecuzioni.length > 0 && ( + <> +

+ ULTIME ESECUZIONI +

+ + + {dati.esecuzioni.slice(0, 8).map((e, i) => ( + + + + + ))} + +
+ {new Date(e.quando).toLocaleString("it-IT", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })} + + {e.aiuola} ·{" "} + + {e.esito} + + {e.minuti > 0 && ` · ${formattaDurata(e.minuti)}, ${e.litri} litri`} +
{e.dettaglio}
+
+ + )} +
+ ) +} diff --git a/src/components/comuni.tsx b/src/components/comuni.tsx new file mode 100644 index 0000000..c681c98 --- /dev/null +++ b/src/components/comuni.tsx @@ -0,0 +1,143 @@ +import { useEffect, type ReactNode } from "react" + +export function Modale({ + titolo, + onChiudi, + children, +}: { + titolo: string + onChiudi: () => void + children: ReactNode +}) { + useEffect(() => { + const esc = (e: KeyboardEvent) => e.key === "Escape" && onChiudi() + document.addEventListener("keydown", esc) + document.body.style.overflow = "hidden" + return () => { + document.removeEventListener("keydown", esc) + document.body.style.overflow = "" + } + }, [onChiudi]) + + return ( +
e.target === e.currentTarget && onChiudi()}> +
+
+

{titolo}

+ +
+ {children} +
+
+ ) +} + +export function Campo({ + label, + aiuto, + children, +}: { + label: string + aiuto?: string + children: ReactNode +}) { + return ( +
+ + {children} + {aiuto &&
{aiuto}
} +
+ ) +} + +export function Vuoto({ icona, titolo, testo, azione }: { icona: string; titolo: string; testo: string; azione?: ReactNode }) { + return ( +
+ {icona} +
{titolo}
+

{testo}

+ {azione} +
+ ) +} + +export function Metrica({ nome, valore, unita }: { nome: string; valore: string | number; unita?: string }) { + return ( +
+
{nome}
+
+ {valore} + {unita && {unita}} +
+
+ ) +} + +export function Barra({ percentuale, colore }: { percentuale: number; colore: string }) { + return ( +
+ +
+ ) +} + +/** Andamento della riserva d'acqua nel terreno: storico a sinistra, previsione a destra. */ +export function GraficoBilancio({ + serie, + raw, + taw, +}: { + serie: { data: string; deficit: number; previsione: boolean; pioggia: number; irrigazione: number }[] + raw: number + taw: number +}) { + if (serie.length < 2) return null + const W = 320 + const H = 130 + const pad = { alto: 8, basso: 18, sx: 4, dx: 4 } + const max = Math.max(taw, raw * 1.3, ...serie.map((s) => s.deficit)) || 1 + const x = (i: number) => pad.sx + (i / (serie.length - 1)) * (W - pad.sx - pad.dx) + // Il grafico mostra l'acqua RIMASTA: in alto pieno, in basso vuoto. + const y = (d: number) => pad.alto + (d / max) * (H - pad.alto - pad.basso) + + const linea = serie.map((s, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(s.deficit).toFixed(1)}`).join(" ") + const area = `${linea} L${x(serie.length - 1).toFixed(1)},${H - pad.basso} L${x(0).toFixed(1)},${H - pad.basso} Z` + const iPrimaPrevisione = serie.findIndex((s) => s.previsione) + const xSoglia = iPrimaPrevisione > 0 ? x(iPrimaPrevisione) : null + + return ( +
+ +
+ 3 settimane fa + — — soglia di stress + tra 10 giorni +
+
+ ) +} + +export function dataBreve(iso: string): string { + return new Date(iso + "T12:00:00").toLocaleDateString("it-IT", { day: "numeric", month: "short" }) +} + +export function dataCompleta(iso: string): string { + return new Date(iso + "T12:00:00").toLocaleDateString("it-IT", { weekday: "long", day: "numeric", month: "long" }) +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..0e52eff --- /dev/null +++ b/src/index.css @@ -0,0 +1,321 @@ +/* ── Pollice Verde — design system ────────────────────────────────────────── + Palette presa dall'orto: verde foglia, terra bagnata, cielo dopo la pioggia. + Mobile-first: l'app si usa col telefono in mano, tra le aiuole, con una mano + sola e spesso in pieno sole — da qui i contrasti alti e i bersagli grandi. */ + +:root { + --verde-900: #0f2e1d; + --verde-700: #1f7a4d; + --verde-600: #2a9463; + --verde-500: #3fae7a; + --verde-100: #dcf0e4; + --verde-50: #f1f9f4; + + --terra: #8a5a3b; + --sole: #e8a33d; + --acqua: #3b82c4; + + --bg: #f7f8f5; + --card: #ffffff; + --fg: #16241c; + --muted-fg: #64756b; + --bordo: #e2e8e3; + --bordo-forte: #cbd6cd; + + --ok: #2a9463; + --warn: #d98518; + --danger: #cf4437; + --info: #3b82c4; + + --ombra: 0 1px 2px rgba(16, 40, 26, .06), 0 4px 16px rgba(16, 40, 26, .06); + --ombra-alta: 0 8px 32px rgba(16, 40, 26, .14); + --r: 14px; + --r-s: 9px; + --safe: env(safe-area-inset-bottom, 0px); + + color-scheme: light; +} + +:root[data-tema="scuro"] { + --bg: #0f1613; + --card: #182320; + --fg: #e8f0ea; + --muted-fg: #96a89d; + --bordo: #26332d; + --bordo-forte: #34443c; + --verde-100: #1c3a2a; + --verde-50: #16241e; + --ombra: 0 1px 2px rgba(0, 0, 0, .3), 0 4px 16px rgba(0, 0, 0, .28); + --ombra-alta: 0 8px 32px rgba(0, 0, 0, .5); + --ok: #3fae7a; + --warn: #e8a33d; + --danger: #e46a5c; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + overscroll-behavior-y: none; +} + +h1, h2, h3, h4 { margin: 0; line-height: 1.25; font-weight: 650; letter-spacing: -.01em; } +h1 { font-size: 1.45rem; } +h2 { font-size: 1.12rem; } +h3 { font-size: 1rem; } +p { margin: 0; } +button, input, select, textarea { font: inherit; color: inherit; } + +/* ── Struttura ─────────────────────────────────────────────────────────── */ + +.app { display: flex; flex-direction: column; min-height: 100%; } + +.testata { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 12px; + padding: 14px 16px calc(14px) 16px; + padding-top: max(14px, env(safe-area-inset-top, 0px)); + background: color-mix(in srgb, var(--bg) 86%, transparent); + backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--bordo); +} +.testata .marchio { display: flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: -.02em; } +.testata .marchio span.logo { font-size: 1.35rem; } +.testata .spazio { flex: 1; } + +.contenuto { + flex: 1; + width: 100%; + max-width: 940px; + margin: 0 auto; + padding: 16px 16px calc(96px + var(--safe)) 16px; + display: flex; flex-direction: column; gap: 16px; +} + +/* Barra di navigazione: in basso su telefono, in alto su desktop. */ +.navigazione { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; + display: grid; grid-template-columns: repeat(6, 1fr); + gap: 1px; padding: 6px 4px calc(6px + var(--safe)) 4px; + background: color-mix(in srgb, var(--card) 94%, transparent); + backdrop-filter: saturate(180%) blur(14px); + border-top: 1px solid var(--bordo); +} +.navigazione button { + display: flex; flex-direction: column; align-items: center; gap: 3px; + padding: 7px 1px 5px; border: 0; border-radius: var(--r-s); + background: transparent; color: var(--muted-fg); + font-size: .64rem; font-weight: 600; cursor: pointer; + transition: color .15s, background .15s; +} +.navigazione button .ico { font-size: 1.2rem; line-height: 1; } +.navigazione button[aria-current="page"] { color: var(--verde-700); background: var(--verde-50); } + +@media (min-width: 760px) { + .navigazione { + position: sticky; top: 0; bottom: auto; + max-width: 940px; margin: 0 auto; + grid-template-columns: repeat(6, auto); justify-content: center; + border-top: 0; border-bottom: 1px solid var(--bordo); + padding: 8px; + } + .navigazione button { flex-direction: row; gap: 8px; padding: 9px 14px; font-size: .85rem; } + .contenuto { padding-bottom: 48px; } +} + +/* ── Elementi ──────────────────────────────────────────────────────────── */ + +.scheda { + background: var(--card); + border: 1px solid var(--bordo); + border-radius: var(--r); + padding: 16px; + box-shadow: var(--ombra); +} +.scheda.stretta { padding: 13px 14px; } + +.titolo-sezione { + display: flex; align-items: baseline; gap: 10px; + margin-bottom: 2px; +} +.titolo-sezione .conta { + font-size: .78rem; color: var(--muted-fg); font-weight: 600; + background: var(--verde-50); border-radius: 20px; padding: 2px 9px; +} + +.sommario { color: var(--muted-fg); font-size: .88rem; } + +.griglia { display: grid; gap: 12px; } +.griglia.due { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } +.griglia.tre { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } + +.riga { display: flex; align-items: center; gap: 10px; } +.riga.spazia { justify-content: space-between; } +.riga.avvolgi { flex-wrap: wrap; } + +.pulsante { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + padding: 10px 16px; min-height: 42px; + border: 1px solid var(--verde-700); border-radius: var(--r-s); + background: var(--verde-700); color: #fff; + font-weight: 600; font-size: .92rem; cursor: pointer; + transition: filter .15s, transform .05s; +} +.pulsante:hover { filter: brightness(1.08); } +.pulsante:active { transform: translateY(1px); } +.pulsante.secondario { background: transparent; color: var(--fg); border-color: var(--bordo-forte); } +.pulsante.secondario:hover { background: var(--verde-50); } +.pulsante.pericolo { background: transparent; border-color: var(--danger); color: var(--danger); } +.pulsante.piccolo { padding: 6px 11px; min-height: 34px; font-size: .82rem; } +.pulsante:disabled { opacity: .5; cursor: not-allowed; } + +.etichetta { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: 20px; + font-size: .74rem; font-weight: 650; letter-spacing: .01em; + background: var(--verde-50); color: var(--verde-700); + border: 1px solid transparent; +} +.etichetta.attenzione { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); } +.etichetta.critico { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); } +.etichetta.neutro { background: transparent; border-color: var(--bordo); color: var(--muted-fg); } +.etichetta.acqua { background: color-mix(in srgb, var(--acqua) 14%, transparent); color: var(--acqua); } + +.campo { display: flex; flex-direction: column; gap: 5px; } +.campo > label { font-size: .8rem; font-weight: 650; color: var(--muted-fg); } +.campo .aiuto { font-size: .76rem; color: var(--muted-fg); line-height: 1.4; } +input[type="text"], input[type="number"], input[type="date"], select, textarea { + width: 100%; padding: 10px 12px; min-height: 42px; + border: 1px solid var(--bordo-forte); border-radius: var(--r-s); + background: var(--card); +} +input:focus, select:focus, textarea:focus { + outline: 2px solid var(--verde-500); outline-offset: 1px; border-color: transparent; +} +textarea { min-height: 74px; resize: vertical; } +.interruttore { display: flex; align-items: center; gap: 9px; cursor: pointer; padding: 4px 0; } +.interruttore input { width: 19px; height: 19px; accent-color: var(--verde-700); } + +.vuoto { + text-align: center; padding: 34px 18px; color: var(--muted-fg); + border: 1px dashed var(--bordo-forte); border-radius: var(--r); +} +.vuoto .icona { font-size: 2.2rem; display: block; margin-bottom: 8px; } + +/* Barra di riempimento del serbatoio d'acqua nel terreno. */ +.barra { height: 9px; border-radius: 6px; background: var(--verde-100); overflow: hidden; } +.barra > i { display: block; height: 100%; border-radius: 6px; transition: width .4s ease; } + +.metrica { display: flex; flex-direction: column; gap: 1px; } +.metrica .valore { font-size: 1.3rem; font-weight: 700; letter-spacing: -.02em; } +.metrica .unita { font-size: .8rem; font-weight: 600; color: var(--muted-fg); } +.metrica .nome { font-size: .74rem; color: var(--muted-fg); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } + +.lista-pulita { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; } +.lista-punti { margin: 0; padding-left: 19px; display: flex; flex-direction: column; gap: 6px; } +.lista-punti li { line-height: 1.5; } + +.separatore { height: 1px; background: var(--bordo); margin: 4px 0; } + +/* Fascia meteo scorrevole */ +.strisciaMeteo { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; } +.strisciaMeteo > div { + flex: 0 0 68px; text-align: center; padding: 9px 4px; + border: 1px solid var(--bordo); border-radius: var(--r-s); background: var(--card); +} +.strisciaMeteo .g { font-size: .7rem; color: var(--muted-fg); font-weight: 650; text-transform: capitalize; } +.strisciaMeteo .i { font-size: 1.25rem; line-height: 1.5; } +.strisciaMeteo .t { font-size: .78rem; font-weight: 650; } +.strisciaMeteo .p { font-size: .7rem; color: var(--acqua); font-weight: 650; min-height: 1em; } +.strisciaMeteo .oggi { border-color: var(--verde-500); box-shadow: 0 0 0 1px var(--verde-500); } + +/* Calendario a 12 mesi */ +.calendario { display: grid; grid-template-columns: repeat(12, 1fr); gap: 2px; } +.calendario > i { + height: 20px; border-radius: 3px; background: var(--verde-100); font-style: normal; + display: flex; align-items: center; justify-content: center; font-size: .58rem; color: var(--muted-fg); +} +.calendario > i.semina { background: var(--sole); color: #3b2606; } +.calendario > i.trapianto { background: var(--verde-500); color: #08240f; } +.calendario > i.raccolta { background: var(--danger); color: #fff; } +.legenda { display: flex; gap: 12px; flex-wrap: wrap; font-size: .74rem; color: var(--muted-fg); } +.legenda span { display: inline-flex; align-items: center; gap: 5px; } +.legenda b { width: 11px; height: 11px; border-radius: 3px; display: inline-block; } + +/* Finestra modale */ +.velo { + position: fixed; inset: 0; z-index: 50; background: rgba(10, 22, 16, .55); + display: flex; align-items: flex-end; justify-content: center; padding: 0; + backdrop-filter: blur(3px); +} +.modale { + background: var(--card); width: 100%; max-width: 560px; max-height: 92vh; overflow-y: auto; + border-radius: 18px 18px 0 0; padding: 18px 16px calc(24px + var(--safe)); + box-shadow: var(--ombra-alta); + display: flex; flex-direction: column; gap: 14px; + animation: sali .22s cubic-bezier(.2, .8, .3, 1); +} +@keyframes sali { from { transform: translateY(20px); opacity: .4; } } +@media (min-width: 700px) { + .velo { align-items: center; padding: 20px; } + .modale { border-radius: var(--r); } +} +.modale .intestazione { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.chiudi { + border: 0; background: var(--verde-50); color: var(--fg); width: 34px; height: 34px; + border-radius: 50%; font-size: 1.1rem; cursor: pointer; line-height: 1; +} + +.avviso { + display: flex; gap: 11px; padding: 13px 14px; border-radius: var(--r); + border: 1px solid; align-items: flex-start; +} +.avviso .em { font-size: 1.25rem; line-height: 1.2; } +.avviso.info { background: color-mix(in srgb, var(--info) 9%, transparent); border-color: color-mix(in srgb, var(--info) 30%, transparent); } +.avviso.attenzione { background: color-mix(in srgb, var(--warn) 10%, transparent); border-color: color-mix(in srgb, var(--warn) 32%, transparent); } +.avviso.grave { background: color-mix(in srgb, var(--danger) 10%, transparent); border-color: color-mix(in srgb, var(--danger) 32%, transparent); } + +.cerca { + display: flex; gap: 8px; align-items: center; + border: 1px solid var(--bordo-forte); border-radius: var(--r-s); + padding: 0 12px; background: var(--card); +} +.cerca input { border: 0; padding: 11px 0; background: transparent; } +.cerca input:focus { outline: none; } + +.chip { + border: 1px solid var(--bordo-forte); background: var(--card); color: var(--muted-fg); + border-radius: 20px; padding: 6px 13px; font-size: .82rem; font-weight: 600; cursor: pointer; + white-space: nowrap; +} +.chip[aria-pressed="true"] { background: var(--verde-700); border-color: var(--verde-700); color: #fff; } +.chip-riga { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 3px; } + +.voce { + display: flex; gap: 12px; align-items: center; width: 100%; text-align: left; + padding: 12px; border: 1px solid var(--bordo); border-radius: var(--r); + background: var(--card); cursor: pointer; +} +.voce:hover { border-color: var(--verde-500); } +.voce .em { font-size: 1.5rem; } +.voce .corpo { flex: 1; min-width: 0; } +.voce .corpo .t { font-weight: 650; } +.voce .corpo .s { font-size: .8rem; color: var(--muted-fg); } + +.grafico { width: 100%; height: 130px; display: block; } + +.tabellina { width: 100%; border-collapse: collapse; font-size: .86rem; } +.tabellina td { padding: 7px 0; border-bottom: 1px solid var(--bordo); vertical-align: top; } +.tabellina td:first-child { color: var(--muted-fg); width: 45%; } +.tabellina tr:last-child td { border-bottom: 0; } + +.sottile { font-size: .82rem; color: var(--muted-fg); line-height: 1.5; } +.centrato { text-align: center; } +.grassetto { font-weight: 650; } diff --git a/src/lib/agente.ts b/src/lib/agente.ts new file mode 100644 index 0000000..dab8e0b --- /dev/null +++ b/src/lib/agente.ts @@ -0,0 +1,115 @@ +import type { LetturaSonda, Stato } from "./types" + +/** + * Client dell'agente della centralina. L'app resta perfettamente utilizzabile + * senza: se l'indirizzo non è configurato o l'agente non risponde, tutto + * continua a funzionare in locale e l'automazione semplicemente non c'è. + */ + +export interface VoceProgramma { + aiuolaId: string + aiuola: string + settore: string | null + canale: number | null + minuti: number + litri: number + mm: number + blocco: string | null +} + +export interface EsecuzioneRemota { + quando: string + aiuola: string + canale: number + minuti: number + litri: number + mm: number + esito: "eseguito" | "simulato" | "saltato" | "errore" + dettaglio: string +} + +export interface StatoCentralina { + automazioneAttiva: boolean + driver: string + host: string + orarioPartenza: string + modalita: "attiva" | "simulazione" + aperti: number[] + inEsecuzione: boolean + ultimaPartenza: string | null + piano: VoceProgramma[] + esecuzioni: EsecuzioneRemota[] + registro: string[] + litriOggi: number + sonde: LetturaSonda[] + ultimaLetturaSonde: string | null + erroreSonde: string | null +} + +const TIMEOUT = 8000 + +async function chiama(url: string, opzioni?: RequestInit): Promise { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), TIMEOUT) + try { + const r = await fetch(url, { ...opzioni, signal: ctrl.signal }) + const dati = (await r.json()) as T & { errore?: string } + if (!r.ok) throw new Error(dati.errore ?? `HTTP ${r.status}`) + return dati + } catch (e) { + if ((e as Error).name === "AbortError") throw new Error("L'agente non risponde") + throw e + } finally { + clearTimeout(t) + } +} + +function base(url: string): string { + return url.replace(/\/+$/, "") +} + +export async function leggiStatoRemoto(url: string): Promise { + const r = await chiama<{ stato: Stato }>(`${base(url)}/api/stato`) + return r.stato +} + +export async function inviaStato(url: string, stato: Stato): Promise { + const r = await chiama<{ stato: Stato }>(`${base(url)}/api/stato`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(stato), + }) + return r.stato +} + +export function leggiCentralina(url: string): Promise { + return chiama(`${base(url)}/api/centralina`) +} + +export function verificaDispositivo(url: string): Promise<{ ok: boolean; driver: string; risposta?: string; errore?: string }> { + return chiama(`${base(url)}/api/verifica`, { method: "POST" }) +} + +export function apriValvola(url: string, canale: number, minuti: number): Promise<{ ok: boolean }> { + return chiama(`${base(url)}/api/valvola`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ canale, minuti }), + }) +} + +export function chiudiTutto(url: string): Promise<{ ok: boolean }> { + return chiama(`${base(url)}/api/stop`, { method: "POST" }) +} + +export function provaProgramma(url: string): Promise<{ esecuzioni: EsecuzioneRemota[] }> { + return chiama(`${base(url)}/api/prova`, { method: "POST" }) +} + +export function eseguiOra(url: string): Promise<{ ok: boolean }> { + return chiama(`${base(url)}/api/esegui`, { method: "POST" }) +} + +export function leggiSondeOra(url: string): Promise<{ sonde: LetturaSonda[]; quando: string | null; errore: string | null }> { + return chiama(`${base(url)}/api/sonde`, { method: "POST" }) +} diff --git a/src/lib/attivita.ts b/src/lib/attivita.ts new file mode 100644 index 0000000..8d2e899 --- /dev/null +++ b/src/lib/attivita.ts @@ -0,0 +1,235 @@ +import type { Aiuola, Coltura, Evento, Piantagione } from "./types" +import { COLTURE, coltura } from "./colture" +import { isoOggi } from "./meteo" + +export type Priorita = "urgente" | "consigliata" | "opzionale" + +export interface Attivita { + id: string + titolo: string + dettaglio: string + categoria: "semina" | "trapianto" | "raccolta" | "potatura" | "cura" | "concimazione" + priorita: Priorita + colturaId?: string + piantagioneId?: string + emoji: string +} + +const GIORNI_MS = 86400000 + +function giorniDa(data: string): number { + return Math.round((Date.now() - Date.parse(data)) / GIORNI_MS) +} + +/** Cosa c'è da fare questo mese, dedotto dalle colture presenti e dal calendario. */ +export function attivitaDelMese( + mese: number, + aiuole: Aiuola[], + piantagioni: Piantagione[], + eventi: Evento[] +): Attivita[] { + const out: Attivita[] = [] + const attive = piantagioni.filter((p) => !p.archiviata) + + // 1. Operazioni sulle colture già in campo. + for (const p of attive) { + const c = coltura(p.colturaId) + const a = aiuole.find((x) => x.id === p.aiuolaId) + if (!c || !a) continue + const eta = giorniDa(p.dataMessaADimora) + + if (c.raccolta.includes(mese)) { + out.push({ + id: `raccolta-${p.id}`, + titolo: `Raccolta di ${c.nome.toLowerCase()}`, + dettaglio: `In ${a.nome}. Siamo nel periodo di raccolta: controlla ogni 2-3 giorni, raccogliere spesso stimola nuova produzione.`, + categoria: "raccolta", + priorita: "consigliata", + colturaId: c.id, + piantagioneId: p.id, + emoji: c.emoji, + }) + } + + if (c.potatura?.includes(mese)) { + out.push({ + id: `potatura-${p.id}`, + titolo: `Potatura di ${c.nome.toLowerCase()}`, + dettaglio: `In ${a.nome}. ${c.cure.find((x) => x.toLowerCase().includes("pot")) ?? "Mese giusto per intervenire sulla struttura della pianta."}`, + categoria: "potatura", + priorita: "consigliata", + colturaId: c.id, + piantagioneId: p.id, + emoji: "✂️", + }) + } + + // Concimazione: circa un mese dopo l'impianto e poi ogni 4-6 settimane in stagione. + const ultimaConcimazione = eventi + .filter((e) => e.tipo === "concimazione" && (e.piantagioneId === p.id || e.aiuolaId === a.id)) + .map((e) => e.data) + .sort() + .pop() + const giorniDaConcime = ultimaConcimazione ? giorniDa(ultimaConcimazione) : eta + const inStagione = c.perenne + ? mese >= (c.stagioneVegetativa?.[0] ?? 3) && mese <= (c.stagioneVegetativa?.[1] ?? 10) + : eta > 25 && eta < 200 + if (inStagione && giorniDaConcime > 35) { + out.push({ + id: `concime-${p.id}`, + titolo: `Concimare ${c.nome.toLowerCase()}`, + dettaglio: `${ultimaConcimazione ? `Ultima concimazione ${giorniDaConcime} giorni fa. ` : ""}${c.concimazione}`, + categoria: "concimazione", + priorita: giorniDaConcime > 60 ? "consigliata" : "opzionale", + colturaId: c.id, + piantagioneId: p.id, + emoji: "🌾", + }) + } + + if (!a.pacciamatura && mese >= 5 && mese <= 8 && c.sensibilitaIdrica === "alta") { + out.push({ + id: `pacciama-${p.id}-${a.id}`, + titolo: `Pacciamare ${a.nome}`, + dettaglio: `${c.nome} è molto sensibile alla siccità e l'aiuola non è pacciamata. Uno strato di 5-8 cm di paglia o sfalcio secco taglia il fabbisogno d'acqua di circa un quarto e tiene giù le infestanti.`, + categoria: "cura", + priorita: "consigliata", + emoji: "🌾", + }) + } + } + + // 2. Semine e trapianti possibili questo mese, escludendo ciò che è già in campo. + const giaPresenti = new Set(attive.map((p) => p.colturaId)) + for (const c of COLTURE) { + if (giaPresenti.has(c.id)) continue + if (c.seminaSemenzaio?.includes(mese)) { + out.push({ + id: `semenzaio-${c.id}`, + titolo: `Semina in semenzaio: ${c.nome.toLowerCase()}`, + dettaglio: `Mese giusto per avviare le piantine al riparo. Trapianto previsto a ${nomiMesi(c.trapianto)}. ${c.sesto}.`, + categoria: "semina", + priorita: "opzionale", + colturaId: c.id, + emoji: c.emoji, + }) + } + if (c.seminaDiretta?.includes(mese)) { + out.push({ + id: `semina-${c.id}`, + titolo: `Semina in pieno campo: ${c.nome.toLowerCase()}`, + dettaglio: `Si può seminare direttamente a dimora. ${c.sesto}.`, + categoria: "semina", + priorita: "opzionale", + colturaId: c.id, + emoji: c.emoji, + }) + } + if (c.trapianto?.includes(mese) && !c.seminaDiretta?.includes(mese)) { + out.push({ + id: `trapianto-${c.id}`, + titolo: `Trapianto: ${c.nome.toLowerCase()}`, + dettaglio: `Periodo adatto alla messa a dimora. ${c.sesto}. Esposizione: ${c.esposizione}.`, + categoria: "trapianto", + priorita: "opzionale", + colturaId: c.id, + emoji: c.emoji, + }) + } + } + + const ordine: Record = { urgente: 0, consigliata: 1, opzionale: 2 } + return out.sort((a, b) => ordine[a.priorita] - ordine[b.priorita]) +} + +function nomiMesi(mesi?: number[]): string { + if (!mesi?.length) return "—" + const n = ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"] + return mesi.map((m) => n[m - 1]).join(", ") +} + +/** Avvisi meteo che richiedono un intervento immediato nell'orto. */ +export interface Allerta { + livello: "info" | "attenzione" | "grave" + titolo: string + testo: string + emoji: string +} + +export function allerteMeteo(giorni: { data: string; tMax: number; tMin: number; pioggia: number; vento: number }[]): Allerta[] { + const oggi = isoOggi() + const prossimi = giorni.filter((g) => g.data >= oggi).slice(0, 5) + const out: Allerta[] = [] + + const gelo = prossimi.find((g) => g.tMin <= 2) + if (gelo) { + out.push({ + livello: gelo.tMin <= 0 ? "grave" : "attenzione", + titolo: `Rischio gelata: ${gelo.tMin.toFixed(0)} °C il ${formatta(gelo.data)}`, + testo: "Copri le colture sensibili con tessuto non tessuto entro il tramonto. Un terreno umido rilascia calore di notte: una bagnatura leggera il pomeriggio prima aiuta davvero. Ritira i vasi di agrumi e aromatiche delicate.", + emoji: "❄️", + }) + } + + const caldo = prossimi.find((g) => g.tMax >= 34) + if (caldo) { + out.push({ + livello: caldo.tMax >= 38 ? "grave" : "attenzione", + titolo: `Ondata di calore: ${caldo.tMax.toFixed(0)} °C il ${formatta(caldo.data)}`, + testo: "Irriga prima delle 8 del mattino e aumenta la dose, non la frequenza. Ombreggia al 30-50% pomodori, peperoni e insalate: sopra i 35 °C i fiori non allegano. Verifica che la pacciamatura sia spessa almeno 5 cm.", + emoji: "🔥", + }) + } + + const pioggiaTot = prossimi.slice(0, 3).reduce((s, g) => s + g.pioggia, 0) + if (pioggiaTot >= 25) { + out.push({ + livello: "attenzione", + titolo: `Attesi ${pioggiaTot.toFixed(0)} mm di pioggia in 3 giorni`, + testo: "Sospendi le irrigazioni. Dopo piogge prolungate con temperature miti sale il rischio di peronospora su pomodoro, patata e vite: valuta un trattamento preventivo a base di rame appena torna il sereno. Controlla che non ci siano ristagni.", + emoji: "🌧️", + }) + } + + const vento = prossimi.find((g) => g.vento >= 45) + if (vento) { + out.push({ + livello: "attenzione", + titolo: `Vento forte: ${vento.vento.toFixed(0)} km/h il ${formatta(vento.data)}`, + testo: "Controlla tutori e legature di pomodori, fagioli rampicanti e alberi giovani. Rimuovi teli e reti ombreggianti che possono fare vela. Il vento asciuga il terreno molto più in fretta del sole: prevedi un'irrigazione in più.", + emoji: "💨", + }) + } + + return out +} + +function formatta(iso: string): string { + const d = new Date(iso + "T12:00:00") + return d.toLocaleDateString("it-IT", { weekday: "short", day: "numeric", month: "short" }) +} + +/** Suggerimento di rotazione: cosa NON rimettere nella stessa aiuola. */ +export function rotazione(aiuolaId: string, piantagioni: Piantagione[]): { famiglie: string[]; nota: string } { + const famiglie = new Set() + const limite = Date.now() - 3 * 365 * GIORNI_MS + for (const p of piantagioni) { + if (p.aiuolaId !== aiuolaId) continue + if (Date.parse(p.dataMessaADimora) < limite) continue + const c = coltura(p.colturaId) + if (c && !c.perenne) famiglie.add(c.famiglia) + } + const lista = [...famiglie] + return { + famiglie: lista, + nota: lista.length + ? `In questa aiuola negli ultimi 3 anni sono passate: ${lista.join(", ")}. Evita di ripetere le stesse famiglie: si accumulano parassiti e malattie specifiche e il terreno si impoverisce sempre degli stessi elementi.` + : "Nessuna coltura registrata qui negli ultimi 3 anni: aiuola libera per qualsiasi famiglia botanica.", + } +} + +export function coltureAdatte(c: Coltura, mese: number): boolean { + return Boolean( + c.seminaDiretta?.includes(mese) || c.seminaSemenzaio?.includes(mese) || c.trapianto?.includes(mese) + ) +} diff --git a/src/lib/colture.aromatiche.ts b/src/lib/colture.aromatiche.ts new file mode 100644 index 0000000..7781ff1 --- /dev/null +++ b/src/lib/colture.aromatiche.ts @@ -0,0 +1,294 @@ +import type { Coltura } from "./types" + +/** Aromatiche: consumi bassi, ma alcune (basilico, menta) sono sorprendentemente esigenti. */ +export const AROMATICHE: Coltura[] = [ + { + id: "basilico", + nome: "Basilico", + sinonimi: ["basilico genovese"], + categoria: "aromatica", + famiglia: "Labiate", + emoji: "🌿", + perenne: false, + seminaSemenzaio: [3, 4], + seminaDiretta: [4, 5, 6], + trapianto: [5, 6], + raccolta: [6, 7, 8, 9], + kc: { iniziale: 0.6, sviluppo: 0.8, piena: 1.0, finale: 0.9 }, + fasiGiorni: { iniziale: 20, sviluppo: 25, piena: 60, finale: 15 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutta l'estate: appassisce in poche ore e le foglie perdono aroma.", + sesto: "25 cm in tutte le direzioni", + piantePerMq: 16, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Pomodoro", "Peperone", "Melanzana"], + daEvitare: ["Ruta"], + concimazione: "Compost leggero. Troppo azoto diluisce gli oli essenziali.", + cure: [ + "Cima sempre sopra una coppia di foglie: la pianta si allarga a cespuglio.", + "Togli i fiori appena spuntano o le foglie diventano dure e amare.", + "Irriga alla base, mai sulle foglie, e mai in pieno sole.", + "Teme il freddo: sotto i 10 °C annerisce." + ], + problemi: [ + { nome: "Peronospora del basilico", sintomo: "Ingiallimento tra le nervature, muffa grigia sotto.", rimedio: "Varietà resistenti, niente bagnature fogliari, sesto arieggiato." } + ] + }, + { + id: "prezzemolo", + nome: "Prezzemolo", + sinonimi: ["petrosino", "petrosemolo"], + categoria: "aromatica", + famiglia: "Ombrellifere", + emoji: "🌿", + perenne: false, + seminaDiretta: [3, 4, 5, 8, 9], + raccolta: [5, 6, 7, 8, 9, 10, 11], + kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.0, finale: 0.95 }, + fasiGiorni: { iniziale: 30, sviluppo: 30, piena: 90, finale: 20 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "media", + fasiCritiche: "Germinazione, lenta: 20-25 giorni con superficie sempre umida.", + sesto: "15 cm sulla fila, 25 cm tra le file", + piantePerMq: 25, + esposizione: "mezz'ombra", + phIdeale: [6, 7], + consociazioni: ["Pomodoro", "Asparago", "Rosa"], + daEvitare: ["Lattuga", "Carota"], + concimazione: "Azoto leggero dopo ogni taglio.", + cure: [ + "Metti i semi a bagno 24 ore prima di seminarli: accorci di una settimana la germinazione.", + "Taglia gli steli esterni alla base, non la punta.", + "È biennale: il secondo anno va a seme, meglio riseminarlo ogni anno." + ], + problemi: [ + { nome: "Oidio", sintomo: "Patina bianca in estate.", rimedio: "Arieggia, taglia basso, riprende pulito." } + ] + }, + { + id: "rosmarino", + nome: "Rosmarino", + sinonimi: ["ramerino"], + categoria: "aromatica", + famiglia: "Labiate", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 9, 10], + raccolta: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.4, sviluppo: 0.5, piena: 0.6, finale: 0.5 }, + profonditaRadici: 0.8, + fattoreDeplezione: 0.6, + sensibilitaIdrica: "bassa", + fasiCritiche: "Nessuna, una volta radicato. Muore più spesso per troppa acqua che per troppo poca.", + sesto: "80 cm in tutte le direzioni", + piantePerMq: 1.5, + esposizione: "pieno sole", + phIdeale: [6.5, 8], + consociazioni: ["Cavolo", "Carota", "Fagiolo"], + daEvitare: [], + concimazione: "Nessuna. Preferisce terreni poveri, sassosi e drenati.", + cure: [ + "Il drenaggio è tutto: in vaso metti argilla espansa sul fondo.", + "Potatura leggera dopo la fioritura, mai sul legno vecchio.", + "Si moltiplica facilissimo per talea in agosto." + ], + problemi: [ + { nome: "Marciume radicale", sintomo: "Rami che seccano dall'interno.", rimedio: "Quasi sempre eccesso d'acqua o ristagno: riduci drasticamente." }, + { nome: "Crisolina", sintomo: "Coleotteri metallici verdi che spogliano i rametti.", rimedio: "Raccolta manuale." } + ] + }, + { + id: "salvia", + nome: "Salvia", + sinonimi: ["salvia officinale"], + categoria: "aromatica", + famiglia: "Labiate", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 9], + raccolta: [3, 4, 5, 6, 7, 8, 9, 10, 11], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.4, sviluppo: 0.55, piena: 0.65, finale: 0.55 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.55, + sensibilitaIdrica: "bassa", + fasiCritiche: "Nessuna: molto resistente alla siccità.", + sesto: "60 cm in tutte le direzioni", + piantePerMq: 2.5, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Cavolo", "Carota", "Rosmarino"], + daEvitare: ["Cetriolo"], + concimazione: "Nessuna.", + cure: [ + "Potala corta ogni primavera o si spoglia e lignifica.", + "Rinnovala ogni 4-5 anni per talea.", + "Teme molto più l'umidità invernale del gelo." + ], + problemi: [ + { nome: "Oidio", sintomo: "Patina bianca in estate umida.", rimedio: "Sfoltisci il cespuglio, zolfo." } + ] + }, + { + id: "timo", + nome: "Timo", + sinonimi: ["timo serpillo", "pepolino"], + categoria: "aromatica", + famiglia: "Labiate", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 9], + raccolta: [4, 5, 6, 7, 8, 9, 10], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.4, sviluppo: 0.5, piena: 0.6, finale: 0.5 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.6, + sensibilitaIdrica: "bassa", + fasiCritiche: "Nessuna.", + sesto: "30 cm in tutte le direzioni", + piantePerMq: 9, + esposizione: "pieno sole", + phIdeale: [6.5, 8], + consociazioni: ["Cavolo", "Melanzana", "Pomodoro"], + daEvitare: [], + concimazione: "Nessuna: in terreno grasso perde aroma.", + cure: [ + "Raccogli poco prima della fioritura, quando gli oli essenziali sono al massimo.", + "Potatura di riordino a fine inverno.", + "Perfetto in vaso e nelle bordure assolate." + ], + problemi: [ + { nome: "Marciume", sintomo: "Ciuffi che seccano in inverno.", rimedio: "Drenaggio, terreno sabbioso, niente pacciamatura umida a contatto." } + ] + }, + { + id: "menta", + nome: "Menta", + sinonimi: ["mentuccia", "menta piperita"], + categoria: "aromatica", + famiglia: "Labiate", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 9], + raccolta: [5, 6, 7, 8, 9, 10], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.6, sviluppo: 0.85, piena: 1.05, finale: 0.9 }, + profonditaRadici: 0.5, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutta l'estate: è l'aromatica che beve di più.", + sesto: "40 cm in tutte le direzioni", + piantePerMq: 6, + esposizione: "mezz'ombra", + phIdeale: [6, 7.5], + consociazioni: ["Cavolo", "Pomodoro"], + daEvitare: ["Prezzemolo", "Camomilla"], + concimazione: "Compost in primavera.", + cure: [ + "Coltivala SEMPRE in vaso o in un contenitore interrato: gli stoloni invadono tutto l'orto in due stagioni.", + "Vuole terreno fresco e mezz'ombra, al contrario delle altre aromatiche.", + "Taglia spesso: i getti giovani sono i più profumati.", + "Dividi il cespo ogni 2-3 anni." + ], + problemi: [ + { nome: "Ruggine della menta", sintomo: "Pustole arancioni sotto le foglie.", rimedio: "Taglia tutto a raso ed elimina i residui: ricaccia pulita." } + ] + }, + { + id: "origano", + nome: "Origano", + sinonimi: ["maggiorana"], + categoria: "aromatica", + famiglia: "Labiate", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 9], + raccolta: [6, 7, 8, 9], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.4, sviluppo: 0.5, piena: 0.65, finale: 0.5 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.6, + sensibilitaIdrica: "bassa", + fasiCritiche: "Nessuna.", + sesto: "30 cm in tutte le direzioni", + piantePerMq: 9, + esposizione: "pieno sole", + phIdeale: [6.5, 8], + consociazioni: ["Pomodoro", "Cavolo"], + daEvitare: [], + concimazione: "Nessuna.", + cure: [ + "Raccogli i rametti in piena fioritura e falli seccare a mazzi all'ombra.", + "Taglia il cespo a metà a fine estate.", + "Più il terreno è arido e sassoso, più è profumato." + ], + problemi: [] + }, + { + id: "erba-cipollina", + nome: "Erba cipollina", + sinonimi: ["cipollina"], + categoria: "aromatica", + famiglia: "Liliacee", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 9], + raccolta: [4, 5, 6, 7, 8, 9, 10], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.6, sviluppo: 0.8, piena: 1.0, finale: 0.9 }, + profonditaRadici: 0.3, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "media", + fasiCritiche: "Estate.", + sesto: "25 cm in tutte le direzioni", + piantePerMq: 16, + esposizione: "mezz'ombra", + phIdeale: [6, 7], + consociazioni: ["Carota", "Pomodoro", "Rosa", "Fragola"], + daEvitare: ["Fagiolo", "Pisello"], + concimazione: "Compost in primavera.", + cure: [ + "Taglia a 3 cm dal suolo: ricaccia in due settimane.", + "I fiori viola sono commestibili e ottimi in insalata.", + "Dividi il cespo ogni 3 anni." + ], + problemi: [] + }, + { + id: "alloro", + nome: "Alloro", + sinonimi: ["lauro"], + categoria: "aromatica", + famiglia: "Lauracee", + emoji: "🌿", + perenne: true, + trapianto: [3, 4, 10], + raccolta: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.5, sviluppo: 0.6, piena: 0.7, finale: 0.65 }, + profonditaRadici: 1.0, + fattoreDeplezione: 0.55, + sensibilitaIdrica: "bassa", + fasiCritiche: "Solo i primi due anni dopo l'impianto.", + sesto: "150 cm", + piantePerMq: 0.4, + esposizione: "pieno sole", + phIdeale: [6, 8], + consociazioni: [], + daEvitare: [], + concimazione: "Compost ogni primavera.", + cure: [ + "Ottimo come siepe frangivento per proteggere l'orto.", + "Tosalo in maggio e a fine agosto per tenerlo compatto.", + "Una volta adulto non ha praticamente bisogno di irrigazione." + ], + problemi: [ + { nome: "Cocciniglia e fumaggine", sintomo: "Scudetti sui rami, patina nera appiccicosa.", rimedio: "Olio bianco in inverno, sfoltimento dei rami interni." } + ] + } +] diff --git a/src/lib/colture.frutta.ts b/src/lib/colture.frutta.ts new file mode 100644 index 0000000..0134ac5 --- /dev/null +++ b/src/lib/colture.frutta.ts @@ -0,0 +1,630 @@ +import type { Coltura } from "./types" + +/** + * Frutteto. Per le piante perenni il kc varia con la stagione: "iniziale" vale al + * germogliamento, "piena" in estate a chioma completa, "finale" dopo la raccolta. + * Fuori dalla stagione vegetativa il fabbisogno è quasi nullo (pianta in riposo). + */ +export const FRUTTA: Coltura[] = [ + { + id: "melo", + nome: "Melo", + sinonimi: ["mela", "mele", "meli"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🍎", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [8, 9, 10], + potatura: [1, 2, 6], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.95, finale: 0.7 }, + profonditaRadici: 1.5, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Allegagione (aprile-maggio) e ingrossamento dei frutti (giugno-luglio).", + sesto: "Vaso: 400 cm. Fusetto: 120 cm sulla fila, 350 cm tra le file.", + piantePerMq: 0.1, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Erba medica", "Tagete", "Aglio ai piedi"], + daEvitare: ["Noce (juglone)", "Prato fitto sotto la chioma"], + concimazione: "Letame maturo in autunno nell'area di gocciolamento; azoto alla ripresa, potassio in estate.", + cure: [ + "Potatura invernale di formazione e potatura verde a giugno per far entrare la luce.", + "Dirada i frutticini a giugno lasciandone uno per mazzetto ogni 15 cm: frutti più grossi e niente alternanza di produzione.", + "Servono due varietà compatibili per l'impollinazione incrociata.", + "Irriga in profondità e di rado: le radici arrivano a 1,5 m. Bagnature superficiali fanno solo danni.", + "Fascia di terreno nudo o pacciamata di 1 m attorno al tronco nei primi anni." + ], + problemi: [ + { nome: "Ticchiolatura", sintomo: "Macchie olivastre su foglie e frutti dopo primavere piovose.", rimedio: "Rame o zolfo alla ripresa, raccolta e distruzione delle foglie cadute, varietà resistenti." }, + { nome: "Carpocapsa", sintomo: "Frutti bacati con foro e rosura.", rimedio: "Trappole a feromoni, confusione sessuale, virus della granulosi." }, + { nome: "Afide grigio", sintomo: "Foglie accartocciate agli apici in primavera.", rimedio: "Olio bianco in inverno, sapone molle, coccinelle." } + ], + note: "Un melo adulto in piena estate può consumare 40-70 litri al giorno." + }, + { + id: "pero", + nome: "Pero", + sinonimi: ["pera", "pere", "peri"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🍐", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [7, 8, 9], + potatura: [1, 2, 6], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.95, finale: 0.7 }, + profonditaRadici: 1.5, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Allegagione e ingrossamento.", + sesto: "350-400 cm", + piantePerMq: 0.1, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Tagete", "Aglio"], + daEvitare: ["Noce"], + concimazione: "Come il melo, ma più contenuta in azoto: gli eccessi favoriscono il colpo di fuoco.", + cure: [ + "Raccogli le pere leggermente acerbe e falle maturare in casa: sull'albero diventano farinose.", + "Potatura di formazione a palmetta o fusetto.", + "Dirada lasciando 1-2 frutti per mazzetto." + ], + problemi: [ + { nome: "Colpo di fuoco batterico", sintomo: "Rami che seccano di colpo con aspetto bruciato e uncino apicale.", rimedio: "Taglia 40 cm sotto la lesione disinfettando le lame; è malattia da denuncia obbligatoria." }, + { nome: "Psilla", sintomo: "Melata appiccicosa e fumaggine nera.", rimedio: "Olio bianco, argilla caolinica, equilibrio dell'azoto." } + ] + }, + { + id: "pesco", + nome: "Pesco e nettarina", + sinonimi: ["pesca", "pesche", "nettarina", "nettarine", "pescanoce", "percoca"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🍑", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [6, 7, 8], + potatura: [2, 8], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.9, finale: 0.65 }, + profonditaRadici: 1.2, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "alta", + fasiCritiche: "Le 4 settimane prima della raccolta: è lì che il frutto fa il 60% del peso.", + sesto: "400 cm", + piantePerMq: 0.1, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Aglio", "Tagete"], + daEvitare: ["Reimpianto su vecchio pesco (stanchezza del terreno)"], + concimazione: "Azoto alla ripresa, potassio da giugno. Letame in autunno.", + cure: [ + "Pota ogni anno e con decisione: il pesco fruttifica solo sui rami dell'anno precedente.", + "Dirada i frutti a 15-20 cm l'uno dall'altro entro maggio.", + "Trattamento con rame alla caduta delle foglie e alla rottura delle gemme contro la bolla.", + "Vita produttiva più breve degli altri fruttiferi: 15-20 anni.", + "Non tollera il ristagno: piantalo su un dosso se il terreno è pesante." + ], + problemi: [ + { nome: "Bolla del pesco", sintomo: "Foglie primaverili bollose, rosse e deformate.", rimedio: "Rame a fine inverno PRIMA della schiusa delle gemme: dopo non serve più a nulla." }, + { nome: "Cidia e mosca", sintomo: "Frutti bacati.", rimedio: "Trappole a feromoni, raccolta dei frutti caduti." }, + { nome: "Gommosi", sintomo: "Colature di resina ambrata sul tronco.", rimedio: "Evita ferite e ristagni, disinfetta i tagli di potatura." } + ] + }, + { + id: "albicocco", + nome: "Albicocco", + sinonimi: ["albicocca", "albicocche", "crisommola"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🍑", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [6, 7], + potatura: [8, 9], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.9, finale: 0.65 }, + profonditaRadici: 1.3, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Maggio-giugno, ingrossamento.", + sesto: "450 cm", + piantePerMq: 0.08, + esposizione: "pieno sole", + phIdeale: [6.5, 8], + consociazioni: [], + daEvitare: ["Zone con gelate tardive: fiorisce prestissimo"], + concimazione: "Moderata. Tollera bene i terreni calcarei.", + cure: [ + "Pota in estate, dopo la raccolta: i tagli invernali favoriscono i cancri rameali.", + "Fiorisce a febbraio-marzo: scegli una posizione riparata dalle gelate tardive.", + "Dirada i frutti se l'allegagione è troppo abbondante." + ], + problemi: [ + { nome: "Monilia", sintomo: "Rami e fiori che seccano, frutti mummificati sull'albero.", rimedio: "Elimina le mummie, rame in fioritura, potatura arieggiante." } + ] + }, + { + id: "susino", + nome: "Susino", + sinonimi: ["susina", "susine", "prugna", "prugne", "prugno"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🫐", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [7, 8, 9], + potatura: [8, 9], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.9, finale: 0.65 }, + profonditaRadici: 1.2, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Giugno-luglio.", + sesto: "400 cm", + piantePerMq: 0.1, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: [], + daEvitare: [], + concimazione: "Contenuta: tende a produrre troppo e a spezzarsi.", + cure: [ + "Dirada abbondantemente: il susino carica troppo e i rami si rompono.", + "Elimina i polloni radicali ogni anno.", + "Molto rustico: è tra i fruttiferi più facili per chi inizia." + ], + problemi: [ + { nome: "Cidia del susino", sintomo: "Frutti bacati che cadono presto.", rimedio: "Trappole a feromoni, raccolta dei frutti a terra." }, + { nome: "Afidi", sintomo: "Foglie accartocciate in primavera.", rimedio: "Olio bianco invernale, sapone molle." } + ] + }, + { + id: "ciliegio", + nome: "Ciliegio", + sinonimi: ["ciliegia", "ciliegie", "visciola", "amarena", "duroni"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🍒", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [5, 6, 7], + potatura: [7, 8], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.45, sviluppo: 0.7, piena: 0.95, finale: 0.7 }, + profonditaRadici: 1.5, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Aprile-maggio. Attenzione: acqua abbondante a ridosso della raccolta spacca le ciliegie.", + sesto: "500 cm (franco) o 300 cm su portinnesto nanizzante", + piantePerMq: 0.06, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: [], + daEvitare: ["Terreni pesanti e asfittici"], + concimazione: "Poca. Il ciliegio non ama le concimazioni azotate spinte.", + cure: [ + "Pota SOLO in estate dopo la raccolta: i tagli invernali provocano gommosi.", + "Riduci l'acqua nelle 2 settimane prima della raccolta, altrimenti i frutti spaccano.", + "Reti anti-uccello: altrimenti la raccolta la fanno i merli.", + "La maggior parte delle varietà ha bisogno di un impollinatore." + ], + problemi: [ + { nome: "Mosca del ciliegio", sintomo: "Vermetti bianchi nella polpa.", rimedio: "Trappole cromotropiche gialle da metà maggio, varietà precoci." }, + { nome: "Cracking", sintomo: "Frutti spaccati dopo la pioggia.", rimedio: "Irrigazione regolare senza picchi, teli di copertura." } + ] + }, + { + id: "fico", + nome: "Fico", + sinonimi: ["fichi", "ficaia", "fioroni"], + categoria: "frutta", + famiglia: "Moracee", + emoji: "🫒", + perenne: true, + trapianto: [10, 11, 3], + raccolta: [6, 8, 9], + potatura: [2, 3], + stagioneVegetativa: [4, 10], + kc: { iniziale: 0.4, sviluppo: 0.65, piena: 0.85, finale: 0.6 }, + profonditaRadici: 1.5, + fattoreDeplezione: 0.6, + sensibilitaIdrica: "bassa", + fasiCritiche: "Solo l'ingrossamento estivo, e solo in annate molto siccitose.", + sesto: "500 cm", + piantePerMq: 0.05, + esposizione: "pieno sole", + phIdeale: [6, 8], + consociazioni: [], + daEvitare: ["Vicinanza a muri e tubature: le radici sono invadenti"], + concimazione: "Praticamente nulla. In terreno grasso fa legno e non frutti.", + cure: [ + "Il più facile dei fruttiferi: si accontenta di tutto e si moltiplica per talea in un bicchiere d'acqua.", + "Potatura leggera: i fioroni nascono sul legno dell'anno prima, i forniti sui getti nuovi.", + "Troppa acqua rende i frutti insipidi e acquosi.", + "Attenzione alle radici: piantalo lontano da muri e pavimentazioni." + ], + problemi: [ + { nome: "Marciume dei frutti", sintomo: "Fichi che fermentano sulla pianta con l'umidità.", rimedio: "Raccogli tempestivamente, arieggia la chioma." } + ] + }, + { + id: "vite", + nome: "Vite da tavola", + sinonimi: ["uva", "vigna", "vigneto", "filare", "grappolo"], + categoria: "frutta", + famiglia: "Vitacee", + emoji: "🍇", + perenne: true, + trapianto: [11, 12, 1, 2, 3], + raccolta: [8, 9, 10], + potatura: [1, 2, 6], + stagioneVegetativa: [4, 10], + kc: { iniziale: 0.3, sviluppo: 0.6, piena: 0.85, finale: 0.45 }, + profonditaRadici: 1.5, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "bassa", + fasiCritiche: "Dalla fioritura all'invaiatura. Dopo l'invaiatura si asciuga per concentrare gli zuccheri.", + sesto: "100-150 cm sulla fila, 250 cm tra le file", + piantePerMq: 0.4, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Rosa (indicatore precoce di oidio)", "Senape come sovescio"], + daEvitare: ["Cavoli"], + concimazione: "Molto contenuta: letame ogni 3 anni. L'eccesso di azoto porta malattie e acini che spaccano.", + cure: [ + "Potatura secca in inverno (Guyot o cordone speronato) e potatura verde in giugno.", + "Sfogliatura della zona dei grappoli a luglio: sole e aria riducono la botrite.", + "Trattamenti a zolfo (oidio) e rame (peronospora) scanditi dalle piogge.", + "Sospendi l'irrigazione dall'invaiatura in poi: è quello che fa l'uva dolce." + ], + problemi: [ + { nome: "Peronospora", sintomo: "Macchie d'olio sulle foglie, muffa bianca sotto, grappoli bruniti.", rimedio: "Rame preventivo secondo la regola dei tre dieci (10 cm di germoglio, 10 mm di pioggia, 10 °C)." }, + { nome: "Oidio", sintomo: "Polvere grigia su foglie e acini che spaccano.", rimedio: "Zolfo dalla ripresa vegetativa, ogni 10-12 giorni in stagione." }, + { nome: "Botrite", sintomo: "Muffa grigia sui grappoli maturi.", rimedio: "Sfogliatura, grappoli spargoli, raccolta tempestiva." } + ] + }, + { + id: "olivo", + nome: "Olivo", + sinonimi: ["ulivo", "oliva", "olive", "olivi"], + categoria: "frutta", + famiglia: "Oleacee", + emoji: "🫒", + perenne: true, + trapianto: [3, 4, 10], + raccolta: [10, 11, 12], + potatura: [3, 4], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.6, sviluppo: 0.65, piena: 0.7, finale: 0.65 }, + profonditaRadici: 1.5, + fattoreDeplezione: 0.65, + sensibilitaIdrica: "bassa", + fasiCritiche: "Fioritura (maggio) e indurimento del nocciolo (luglio): anche solo 2-3 irrigazioni di soccorso raddoppiano la resa.", + sesto: "600 cm", + piantePerMq: 0.03, + esposizione: "pieno sole", + phIdeale: [6.5, 8.5], + consociazioni: ["Fava e favino come sovescio"], + daEvitare: ["Ristagni idrici"], + concimazione: "Azoto in primavera, potassio in estate. Non esagerare.", + cure: [ + "Potatura a vaso policonico: la luce deve arrivare dentro la chioma.", + "Anche in asciutta produce, ma 2-3 irrigazioni di soccorso in luglio-agosto fanno una differenza enorme.", + "Alterna: potando ogni anno si riduce il fenomeno.", + "Raccogli all'invaiatura (da verde a violaceo) per l'olio migliore." + ], + problemi: [ + { nome: "Mosca dell'olivo", sintomo: "Fori nelle drupe, cascola, olio con acidità alta.", rimedio: "Trappole cromotropiche e a feromoni da luglio, argilla caolinica, anticipo della raccolta." }, + { nome: "Occhio di pavone", sintomo: "Macchie tonde concentriche sulle foglie, defogliazione.", rimedio: "Rame dopo la raccolta e in primavera, potatura arieggiante." } + ] + }, + { + id: "limone", + nome: "Limone e agrumi", + sinonimi: ["arancio", "arancia", "arance", "mandarino", "mandarini", "agrume", "agrumi", "clementina", "cedro", "pompelmo", "limoni"], + categoria: "frutta", + famiglia: "Rutacee", + emoji: "🍋", + perenne: true, + trapianto: [3, 4, 5], + raccolta: [11, 12, 1, 2, 3, 4], + potatura: [3, 4], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.65, sviluppo: 0.65, piena: 0.7, finale: 0.7 }, + profonditaRadici: 1.0, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Tutta la stagione calda: gli agrumi hanno radici superficiali e soffrono in fretta.", + sesto: "400 cm in piena terra; vaso da almeno 50 litri", + piantePerMq: 0.1, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: [], + daEvitare: ["Terreni calcarei (clorosi ferrica)", "Ristagni"], + concimazione: "Concime specifico per agrumi da marzo a settembre, ogni 3-4 settimane. Ferro chelato contro la clorosi.", + cure: [ + "In vaso: rinvaso ogni 2-3 anni e drenaggio perfetto. È il contenitore, non il clima, il limite principale.", + "Sotto i -2 °C proteggi con tessuto non tessuto o ricovera il vaso.", + "Potatura leggera dopo la raccolta: togli i rami secchi, i succhioni e i polloni sotto il punto d'innesto.", + "Foglie gialle con nervature verdi = carenza di ferro, non sete." + ], + problemi: [ + { nome: "Cocciniglia", sintomo: "Scudetti sui rami, melata e fumaggine nera.", rimedio: "Olio bianco a fine inverno, lavaggi con sapone molle." }, + { nome: "Minatrice serpentina", sintomo: "Gallerie argentee sulle foglie giovani.", rimedio: "Colpisce i getti nuovi: tollerabile su piante adulte, olio di neem sui giovani." }, + { nome: "Clorosi ferrica", sintomo: "Foglie gialle a nervature verdi.", rimedio: "Ferro chelato, acidificazione del substrato, acqua non troppo calcarea." } + ] + }, + { + id: "kiwi", + nome: "Kiwi (actinidia)", + sinonimi: ["actinidia", "kiwi giallo"], + categoria: "frutta", + famiglia: "Actinidiacee", + emoji: "🥝", + perenne: true, + trapianto: [11, 12, 3], + raccolta: [10, 11], + potatura: [1, 2, 6, 7], + stagioneVegetativa: [4, 11], + kc: { iniziale: 0.4, sviluppo: 0.75, piena: 1.05, finale: 0.95 }, + profonditaRadici: 0.7, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutta l'estate: è il fruttifero che beve di più in assoluto.", + sesto: "400 cm sulla fila, 500 cm tra le file, su pergola o tendone", + piantePerMq: 0.05, + esposizione: "pieno sole", + phIdeale: [5.5, 6.8], + consociazioni: [], + daEvitare: ["Terreni calcarei", "Posizioni ventose"], + concimazione: "Esigente: azoto in primavera, potassio in estate, sostanza organica abbondante.", + cure: [ + "Serve una pianta maschio ogni 6-8 femmine.", + "Radici superficiali e grande superficie fogliare: richiede molta acqua e non tollera la siccità nemmeno per pochi giorni.", + "Pacciamatura spessa obbligatoria.", + "Struttura di sostegno robusta: una pianta adulta pesa quintali." + ], + problemi: [ + { nome: "Batteriosi (PSA)", sintomo: "Cancri con essudato rossastro, disseccamento dei tralci.", rimedio: "Rame, potature con attrezzi disinfettati e solo in tempo asciutto, varietà tolleranti." } + ] + }, + { + id: "lampone", + nome: "Lampone", + sinonimi: ["lamponi", "mora", "more", "rovo", "ribes"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🫐", + perenne: true, + trapianto: [10, 11, 2, 3], + raccolta: [6, 7, 9, 10], + potatura: [11, 12, 2], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.4, sviluppo: 0.75, piena: 1.0, finale: 0.85 }, + profonditaRadici: 0.5, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "alta", + fasiCritiche: "Fioritura e maturazione. Radici superficiali: turni frequenti.", + sesto: "50 cm sulla fila, 200 cm tra le file", + piantePerMq: 1, + esposizione: "mezz'ombra", + phIdeale: [5.5, 6.5], + consociazioni: ["Aglio", "Tagete"], + daEvitare: ["Solanacee (verticillium)"], + concimazione: "Compost e pacciamatura organica ogni primavera.", + cure: [ + "Unifere: taglia a terra i tralci che hanno fruttificato subito dopo la raccolta.", + "Rifiorenti: taglia tutto a raso in inverno, produrranno in autunno sui getti nuovi.", + "Pacciamatura permanente di paglia o cippato: le radici sono a 20 cm e soffrono il secco.", + "Serve una struttura con due fili per contenere i tralci." + ], + problemi: [ + { nome: "Botrite", sintomo: "Frutti ammuffiti.", rimedio: "Raccolta ogni 2 giorni, sfoltimento dei polloni, niente acqua sui frutti." } + ] + }, + { + id: "mirtillo", + nome: "Mirtillo", + sinonimi: ["mirtilli", "blueberry"], + categoria: "frutta", + famiglia: "Ericacee", + emoji: "🫐", + perenne: true, + trapianto: [10, 11, 3], + raccolta: [6, 7, 8], + potatura: [1, 2], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.4, sviluppo: 0.7, piena: 1.0, finale: 0.85 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutta la stagione: radici fascicolate e superficialissime, senza peli radicali.", + sesto: "120 cm sulla fila, 250 cm tra le file", + piantePerMq: 0.7, + esposizione: "pieno sole", + phIdeale: [4.5, 5.5], + consociazioni: ["Altre acidofile"], + daEvitare: ["Terreno calcareo", "Acqua di rubinetto dura"], + concimazione: "Concime per acidofile. Mai calce, mai cenere.", + cure: [ + "Il pH è tutto: sopra 6 la pianta ingiallisce e muore lentamente. Coltivalo in torba acida o in vaso.", + "Irriga con acqua piovana: l'acqua calcarea alza il pH del substrato in pochi mesi.", + "Pacciamatura di aghi di pino o corteccia.", + "Pianta 2-3 varietà diverse: l'impollinazione incrociata aumenta la resa." + ], + problemi: [ + { nome: "Clorosi", sintomo: "Foglie gialle con nervature verdi.", rimedio: "pH troppo alto: acidifica, usa acqua piovana, ferro chelato." } + ] + }, + { + id: "melograno", + nome: "Melograno", + sinonimi: ["melagrana", "melagrane", "melograni", "granato"], + categoria: "frutta", + famiglia: "Lythracee", + emoji: "🍎", + perenne: true, + trapianto: [11, 12, 3], + raccolta: [9, 10, 11], + potatura: [2, 3], + stagioneVegetativa: [4, 11], + kc: { iniziale: 0.4, sviluppo: 0.65, piena: 0.85, finale: 0.65 }, + profonditaRadici: 1.2, + fattoreDeplezione: 0.6, + sensibilitaIdrica: "bassa", + fasiCritiche: "Ingrossamento estivo: irregolarità idrica = frutti spaccati.", + sesto: "400 cm", + piantePerMq: 0.08, + esposizione: "pieno sole", + phIdeale: [6, 8], + consociazioni: [], + daEvitare: ["Ristagni"], + concimazione: "Contenuta.", + cure: [ + "Regolarità nell'irrigazione: gli sbalzi fanno spaccare i frutti, che poi marciscono.", + "Elimina i polloni ogni anno, allevalo a cespuglio con 3-4 branche.", + "Molto resistente alla siccità e alla salinità: ideale al Sud." + ], + problemi: [ + { nome: "Cracking dei frutti", sintomo: "Frutti spaccati a settembre.", rimedio: "Turni regolari da agosto, raccolta anticipata se arrivano piogge." } + ] + }, + { + id: "cachi", + nome: "Cachi (kaki)", + sinonimi: ["kaki", "caco", "loto", "diospero", "cachi vaniglia"], + categoria: "frutta", + famiglia: "Ebenacee", + emoji: "🍊", + perenne: true, + trapianto: [11, 12, 3], + raccolta: [10, 11, 12], + potatura: [1, 2], + stagioneVegetativa: [4, 11], + kc: { iniziale: 0.4, sviluppo: 0.7, piena: 0.95, finale: 0.75 }, + profonditaRadici: 1.2, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Giugno-agosto: la carenza provoca la cascola dei frutti.", + sesto: "500 cm", + piantePerMq: 0.05, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: [], + daEvitare: [], + concimazione: "Moderata, letame in autunno.", + cure: [ + "Potatura leggera: fruttifica sui getti dell'anno nati da gemme apicali, se poti troppo elimini la produzione.", + "La cascola estiva dei frutticini è spesso stress idrico: mantieni turni regolari.", + "Raccogli a fine autunno e lascia ammezzire in cassetta con una mela." + ], + problemi: [ + { nome: "Cascola dei frutti", sintomo: "Frutticini che cadono a giugno-luglio.", rimedio: "Irrigazione regolare, equilibrio dell'azoto, diradamento." } + ] + }, + { + id: "nocciolo", + nome: "Nocciolo", + sinonimi: ["nocciola", "nocciole", "avellana"], + categoria: "frutta", + famiglia: "Betulacee", + emoji: "🌰", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [8, 9], + potatura: [1, 2], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.4, sviluppo: 0.7, piena: 0.95, finale: 0.75 }, + profonditaRadici: 1.0, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Giugno-luglio, riempimento del seme.", + sesto: "450 cm", + piantePerMq: 0.06, + esposizione: "mezz'ombra", + phIdeale: [6, 7.5], + consociazioni: [], + daEvitare: [], + concimazione: "Letame ogni autunno.", + cure: [ + "Servono almeno due varietà per l'impollinazione (fiorisce in pieno inverno).", + "Allevalo a cespuglio con 5-6 polloni, rinnovandone uno all'anno.", + "Raccogli le nocciole cadute a terra e falle asciugare bene prima di conservarle." + ], + problemi: [ + { nome: "Balanino", sintomo: "Nocciole vuote con un forellino.", rimedio: "Raccolta e distruzione delle nocciole cadute precocemente." }, + { nome: "Cimice asiatica", sintomo: "Semi con aree brune e amare (cimiciato).", rimedio: "Trappole, reti, gestione del cotico erboso." } + ] + }, + { + id: "noce", + nome: "Noce", + sinonimi: ["noci", "gheriglio"], + categoria: "frutta", + famiglia: "Juglandacee", + emoji: "🌰", + perenne: true, + trapianto: [11, 12, 1, 2], + raccolta: [9, 10], + potatura: [8, 9], + stagioneVegetativa: [4, 10], + kc: { iniziale: 0.5, sviluppo: 0.8, piena: 1.1, finale: 0.7 }, + profonditaRadici: 1.8, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Luglio-agosto.", + sesto: "800 cm", + piantePerMq: 0.02, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: [], + daEvitare: ["Qualsiasi coltura sotto la chioma: il juglone inibisce le altre piante"], + concimazione: "Letame in autunno; pianta longeva e poco esigente da adulta.", + cure: [ + "Non piantarlo vicino all'orto: il juglone delle radici inibisce pomodori, patate e meli.", + "Pota solo in agosto-settembre: in inverno e primavera 'piange' linfa senza fermarsi.", + "Radici profondissime: da adulto quasi non si irriga." + ], + problemi: [ + { nome: "Antracnosi", sintomo: "Macchie nere su foglie e mallo dopo primavere piovose.", rimedio: "Rame alla ripresa, raccolta delle foglie cadute." } + ] + }, + { + id: "nespolo", + nome: "Nespolo del Giappone", + sinonimi: ["nespola", "nespole", "nespolo giapponese"], + categoria: "frutta", + famiglia: "Rosacee", + emoji: "🍑", + perenne: true, + trapianto: [3, 4, 10], + raccolta: [4, 5], + potatura: [6, 7], + stagioneVegetativa: [3, 11], + kc: { iniziale: 0.5, sviluppo: 0.65, piena: 0.8, finale: 0.7 }, + profonditaRadici: 1.0, + fattoreDeplezione: 0.55, + sensibilitaIdrica: "bassa", + fasiCritiche: "Marzo-aprile, ingrossamento dei frutti.", + sesto: "500 cm", + piantePerMq: 0.05, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: [], + daEvitare: [], + concimazione: "Poca, in primavera dopo la raccolta.", + cure: [ + "Fiorisce in autunno e matura in primavera: è il primo frutto dell'anno.", + "Dirada i frutticini per averli più grossi.", + "Pota subito dopo la raccolta, a maggio-giugno.", + "Sempreverde, ornamentale e molto rustico." + ], + problemi: [ + { nome: "Colpo di fuoco", sintomo: "Getti anneriti e ripiegati.", rimedio: "Taglio ampio sotto la lesione, attrezzi disinfettati." } + ] + } +] diff --git a/src/lib/colture.ortaggi.ts b/src/lib/colture.ortaggi.ts new file mode 100644 index 0000000..42b9e3b --- /dev/null +++ b/src/lib/colture.ortaggi.ts @@ -0,0 +1,1063 @@ +import type { Coltura } from "./types" + +/** + * Ortaggi. I coefficienti colturali (kc), la profondità radicale e il fattore di + * deplezione seguono le tabelle FAO-56 "Crop evapotranspiration": sono gli stessi + * numeri usati dai consorzi irrigui, applicati qui alla scala dell'orto familiare. + * I calendari sono tarati sul clima italiano di pianura; in montagna si ritarda + * di 2-4 settimane, al Sud si anticipa altrettanto. + */ +export const ORTAGGI: Coltura[] = [ + { + id: "pomodoro", + nome: "Pomodoro", + sinonimi: ["pomodori", "pummarola", "tomate"], + categoria: "ortaggio", + famiglia: "Solanacee", + emoji: "🍅", + perenne: false, + seminaSemenzaio: [2, 3], + trapianto: [4, 5], + raccolta: [7, 8, 9], + kc: { iniziale: 0.6, sviluppo: 0.85, piena: 1.15, finale: 0.8 }, + fasiGiorni: { iniziale: 30, sviluppo: 40, piena: 45, finale: 30 }, + profonditaRadici: 0.8, + fattoreDeplezione: 0.4, + sensibilitaIdrica: "alta", + fasiCritiche: "Dalla fioritura all'ingrossamento dei frutti: sbalzi idrici in questa fase causano marciume apicale e spaccature.", + sesto: "50 cm sulla fila, 80-100 cm tra le file", + piantePerMq: 2.5, + esposizione: "pieno sole", + phIdeale: [6, 6.8], + consociazioni: ["Basilico", "Prezzemolo", "Carota", "Cipolla", "Tagete"], + daEvitare: ["Patata", "Melanzana", "Peperone", "Finocchio"], + concimazione: "Compost maturo (4-5 kg/m²) all'impianto; dalla prima allegagione un concime ricco di potassio ogni 12-15 giorni.", + cure: [ + "Tutorare subito dopo il trapianto: canna, spirale o filo teso.", + "Scacchiare le femminelle una volta a settimana quando sono ancora piccole.", + "Cimare sopra il 5°-7° palco a fine luglio per far maturare quello che c'è.", + "Bagnare solo alla base, mai sulle foglie: la peronospora parte dall'acqua sul fogliame.", + "Pacciamare con paglia: mantiene l'umidità costante e riduce del 30% il fabbisogno." + ], + problemi: [ + { nome: "Marciume apicale", sintomo: "Macchia scura e coriacea sul fondo del frutto.", rimedio: "Non è un fungo: è calcio non assorbito per irrigazione irregolare. Regolarizza i turni e pacciama." }, + { nome: "Peronospora", sintomo: "Macchie oleose sulle foglie, muffa grigia sotto, frutti bruni.", rimedio: "Rame dopo piogge prolungate, arieggia le piante, elimina subito le foglie basse." }, + { nome: "Tuta absoluta", sintomo: "Mine serpentine nelle foglie, gallerie nei frutti.", rimedio: "Trappole a feromoni, Bacillus thuringiensis, rimozione delle foglie colpite." } + ], + note: "Un pomodoro adulto in piena estate consuma 1,5-2,5 litri al giorno." + }, + { + id: "zucchina", + nome: "Zucchina", + sinonimi: ["zucchino", "zucchine", "cocuzza"], + categoria: "ortaggio", + famiglia: "Cucurbitacee", + emoji: "🥒", + perenne: false, + seminaSemenzaio: [3, 4], + seminaDiretta: [4, 5, 6], + trapianto: [4, 5, 6], + raccolta: [6, 7, 8, 9], + kc: { iniziale: 0.5, sviluppo: 0.75, piena: 0.95, finale: 0.75 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 35, finale: 25 }, + profonditaRadici: 0.7, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "alta", + fasiCritiche: "Fioritura e allegagione continua: la zucchina produce ogni giorno, quindi non tollera vuoti d'acqua.", + sesto: "80 cm sulla fila, 120 cm tra le file", + piantePerMq: 1, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Fagiolino", "Mais", "Nasturzio", "Borragine"], + daEvitare: ["Patata", "Cetriolo (stessa famiglia)"], + concimazione: "Grande consumatrice: letame maturo alla buca, poi azoto e potassio ogni 15 giorni in produzione.", + cure: [ + "Raccogli i frutti a 15-20 cm: se ne lasci uno grosso la pianta smette di produrne altri.", + "Elimina le foglie più vecchie e ingiallite per far circolare l'aria.", + "Irriga alla base la mattina presto: l'acqua sulle foglie chiama l'oidio.", + "Se i frutti si fermano e marciscono da piccoli, mancano gli impollinatori: impollina a mano col pennello." + ], + problemi: [ + { nome: "Oidio (mal bianco)", sintomo: "Patina bianca farinosa sulle foglie.", rimedio: "Zolfo bagnabile o bicarbonato di potassio; sfoltisci il fogliame, evita bagnature serali." }, + { nome: "Marciume del colletto", sintomo: "Pianta che collassa di colpo alla base.", rimedio: "Non irrigare sul colletto: usa un anello di gocciolatori a 15 cm dal fusto." } + ] + }, + { + id: "melanzana", + nome: "Melanzana", + sinonimi: ["melanzane", "petonciano", "marignano"], + categoria: "ortaggio", + famiglia: "Solanacee", + emoji: "🍆", + perenne: false, + seminaSemenzaio: [2, 3], + trapianto: [5], + raccolta: [7, 8, 9], + kc: { iniziale: 0.6, sviluppo: 0.85, piena: 1.05, finale: 0.9 }, + fasiGiorni: { iniziale: 30, sviluppo: 40, piena: 45, finale: 30 }, + profonditaRadici: 0.9, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "alta", + fasiCritiche: "Fioritura e ingrossamento: la carenza fa cadere i fiori e rende i frutti amari.", + sesto: "60 cm sulla fila, 90 cm tra le file", + piantePerMq: 2, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Fagiolino", "Tagete", "Timo"], + daEvitare: ["Pomodoro", "Patata", "Peperone"], + concimazione: "Compost all'impianto, poi potassio dalla prima allegagione. Teme il ristagno di azoto.", + cure: [ + "Tutora: i frutti sono pesanti e spezzano i rami.", + "Lascia 5-6 frutti per pianta se li vuoi grandi.", + "Elimina i primi fiori se la pianta è ancora piccola.", + "Ama il caldo: non trapiantare sotto i 15 °C notturni." + ], + problemi: [ + { nome: "Dorifora", sintomo: "Larve rosse che spogliano le foglie.", rimedio: "Raccolta manuale, Bacillus thuringiensis tenebrionis." }, + { nome: "Ragnetto rosso", sintomo: "Foglie punteggiate di giallo, sottili ragnatele.", rimedio: "Aumenta l'umidità ambientale, sapone molle, olio di neem." } + ] + }, + { + id: "peperone", + nome: "Peperone e peperoncino", + sinonimi: ["peperoni", "peperoncino", "friggitello"], + categoria: "ortaggio", + famiglia: "Solanacee", + emoji: "🫑", + perenne: false, + seminaSemenzaio: [2, 3], + trapianto: [5], + raccolta: [7, 8, 9, 10], + kc: { iniziale: 0.6, sviluppo: 0.8, piena: 1.05, finale: 0.9 }, + fasiGiorni: { iniziale: 30, sviluppo: 40, piena: 45, finale: 30 }, + profonditaRadici: 0.7, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "alta", + fasiCritiche: "Fioritura: sopra i 35 °C o con suolo asciutto i fiori cadono e non allegano.", + sesto: "40 cm sulla fila, 70 cm tra le file", + piantePerMq: 3.5, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Basilico", "Carota", "Cipolla"], + daEvitare: ["Pomodoro", "Melanzana", "Finocchio"], + concimazione: "Moderata in azoto, generosa in potassio. Troppo azoto = tanta foglia e pochi frutti.", + cure: [ + "Radici superficiali e delicate: p basso, cioè turni frequenti e leggeri.", + "Pacciama sempre: il peperone soffre lo shock termico del suolo.", + "Tutora le varietà a frutto grosso.", + "Ombreggia al 30% nelle ondate di calore per non far cadere i fiori." + ], + problemi: [ + { nome: "Marciume apicale", sintomo: "Macchia molle sulla punta del frutto.", rimedio: "Irrigazione regolare, non a strappi. Pacciamatura." }, + { nome: "Afidi", sintomo: "Foglie accartocciate e appiccicose.", rimedio: "Sapone molle potassico, coccinelle, getto d'acqua." } + ] + }, + { + id: "cetriolo", + nome: "Cetriolo", + sinonimi: ["cetrioli", "citriolo"], + categoria: "ortaggio", + famiglia: "Cucurbitacee", + emoji: "🥒", + perenne: false, + seminaSemenzaio: [3, 4], + seminaDiretta: [4, 5, 6], + trapianto: [5, 6], + raccolta: [6, 7, 8, 9], + kc: { iniziale: 0.6, sviluppo: 0.8, piena: 1.0, finale: 0.75 }, + fasiGiorni: { iniziale: 20, sviluppo: 30, piena: 40, finale: 15 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutta la raccolta: uno stress idrico rende i frutti amari in modo irreversibile.", + sesto: "40 cm sulla fila, 100 cm tra le file", + piantePerMq: 2.5, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Fagiolino", "Mais", "Aneto", "Girasole"], + daEvitare: ["Patata", "Salvia"], + concimazione: "Sostanza organica abbondante e umidità costante; concimazioni leggere e frequenti.", + cure: [ + "Coltiva in verticale su rete: frutti dritti, puliti e meno malattie.", + "Raccogli ogni 2 giorni.", + "Mai far asciugare il terreno: è la causa numero uno dell'amaro." + ], + problemi: [ + { nome: "Oidio", sintomo: "Patina bianca sulle foglie.", rimedio: "Zolfo, varietà resistenti, buona circolazione d'aria." }, + { nome: "Amaro dei frutti", sintomo: "Sapore amaro, soprattutto vicino al peduncolo.", rimedio: "Cucurbitacina da stress: irrigazione costante e niente sbalzi termici." } + ] + }, + { + id: "fagiolino", + nome: "Fagiolino", + sinonimi: ["fagiolini", "cornetti", "tegoline", "fagiolo"], + categoria: "ortaggio", + famiglia: "Leguminose", + emoji: "🫛", + perenne: false, + seminaDiretta: [4, 5, 6, 7], + raccolta: [6, 7, 8, 9], + kc: { iniziale: 0.5, sviluppo: 0.75, piena: 1.05, finale: 0.9 }, + fasiGiorni: { iniziale: 20, sviluppo: 30, piena: 30, finale: 10 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "media", + fasiCritiche: "Fioritura e formazione dei baccelli: siccità qui dimezza la produzione.", + sesto: "Nano: 10 cm sulla fila, 40 cm tra le file. Rampicante: postarelle da 4-5 semi ogni 30 cm.", + piantePerMq: 20, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Mais", "Zucchina", "Cetriolo", "Patata", "Fragola"], + daEvitare: ["Cipolla", "Aglio", "Porro", "Finocchio"], + concimazione: "Nessun azoto: fissa il proprio con i rizobi. Solo potassio e fosforo se il terreno è povero.", + cure: [ + "Semina scalare ogni 20 giorni per raccogliere da giugno a settembre.", + "Non bagnare i fiori: fanno cascola.", + "Raccogli i baccelli teneri ogni 2-3 giorni per prolungare la produzione.", + "A fine ciclo taglia la pianta a raso e lascia le radici: arricchiscono il terreno di azoto." + ], + problemi: [ + { nome: "Ruggine", sintomo: "Pustole arancioni sotto le foglie.", rimedio: "Rotazione, evita bagnature fogliari, rame in casi gravi." }, + { nome: "Afide nero", sintomo: "Colonie sugli apici.", rimedio: "Sapone molle, cimatura degli apici infestati." } + ] + }, + { + id: "pisello", + nome: "Pisello", + sinonimi: ["piselli", "bisi"], + categoria: "ortaggio", + famiglia: "Leguminose", + emoji: "🫛", + perenne: false, + seminaDiretta: [2, 3, 10, 11], + raccolta: [4, 5, 6], + kc: { iniziale: 0.5, sviluppo: 0.8, piena: 1.15, finale: 1.1 }, + fasiGiorni: { iniziale: 25, sviluppo: 30, piena: 30, finale: 15 }, + profonditaRadici: 0.7, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "media", + fasiCritiche: "Fioritura e riempimento del baccello.", + sesto: "5 cm sulla fila, 60 cm tra le file", + piantePerMq: 30, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Carota", "Ravanello", "Lattuga"], + daEvitare: ["Aglio", "Cipolla", "Porro"], + concimazione: "Nessun azoto. Gradisce terreno ben drenato e calcareo.", + cure: [ + "Semina autunnale al Centro-Sud, di fine inverno al Nord.", + "Fornisci un sostegno a rete anche alle varietà semi-nane.", + "Rincalza quando le piantine hanno 10 cm.", + "Coltura da clima fresco: sopra i 27 °C smette di allegare." + ], + problemi: [ + { nome: "Oidio", sintomo: "Foglie bianche, baccelli deformati.", rimedio: "Semine precoci, zolfo, varietà resistenti." }, + { nome: "Tonchio", sintomo: "Fori nei semi conservati.", rimedio: "Congela i semi da conservare per 48 ore." } + ] + }, + { + id: "fava", + nome: "Fava", + sinonimi: ["fave", "baggiana"], + categoria: "ortaggio", + famiglia: "Leguminose", + emoji: "🫘", + perenne: false, + seminaDiretta: [10, 11, 2], + raccolta: [4, 5, 6], + kc: { iniziale: 0.5, sviluppo: 0.8, piena: 1.15, finale: 1.1 }, + fasiGiorni: { iniziale: 30, sviluppo: 45, piena: 40, finale: 20 }, + profonditaRadici: 0.8, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "media", + fasiCritiche: "Fioritura primaverile.", + sesto: "20 cm sulla fila, 70 cm tra le file", + piantePerMq: 7, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Patata", "Sedano", "Mais"], + daEvitare: ["Aglio", "Cipolla"], + concimazione: "Nessuna. È lei a concimare: ottima come sovescio prima delle solanacee.", + cure: [ + "Cima le piante a fine fioritura: previene gli afidi neri e anticipa la raccolta.", + "Con la semina autunnale la pianta è più robusta e produce prima.", + "Rincalza contro il vento." + ], + problemi: [ + { nome: "Afide nero", sintomo: "Apici ricoperti di afidi.", rimedio: "Cimatura degli apici, sapone molle." }, + { nome: "Ruggine", sintomo: "Pustole brune sulle foglie a fine ciclo.", rimedio: "Tollerabile a fine ciclo; anticipa la raccolta." } + ] + }, + { + id: "lattuga", + nome: "Lattuga e insalate", + sinonimi: ["insalata", "lattughe", "cappuccina", "gentile", "romana", "iceberg"], + categoria: "ortaggio", + famiglia: "Composite", + emoji: "🥬", + perenne: false, + seminaSemenzaio: [1, 2, 3, 7, 8, 9], + seminaDiretta: [3, 4, 5, 8, 9], + trapianto: [3, 4, 5, 9, 10], + raccolta: [4, 5, 6, 7, 10, 11, 12], + kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.0, finale: 0.95 }, + fasiGiorni: { iniziale: 20, sviluppo: 30, piena: 15, finale: 10 }, + profonditaRadici: 0.3, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutto il ciclo: radici corte, serbatoio piccolo, quindi turni brevi e leggeri.", + sesto: "25-30 cm in tutte le direzioni", + piantePerMq: 12, + esposizione: "mezz'ombra", + phIdeale: [6, 7], + consociazioni: ["Carota", "Ravanello", "Fragola", "Cetriolo"], + daEvitare: ["Prezzemolo", "Sedano"], + concimazione: "Compost leggero. Eccesso di azoto = accumulo di nitrati e marciumi.", + cure: [ + "Semina scalare ogni 15 giorni: 10 cespi alla volta bastano per una famiglia.", + "In estate mettila all'ombra dei pomodori: sopra i 25 °C va a seme.", + "Bagna poco e spesso, mai a inzuppare.", + "Raccogli al mattino presto: è più croccante." + ], + problemi: [ + { nome: "Salita a seme", sintomo: "Fusto che si allunga, foglie amare.", rimedio: "Varietà estive resistenti, ombreggio, raccolta tempestiva." }, + { nome: "Lumache", sintomo: "Foglie mangiate di notte, scie argentate.", rimedio: "Trappole a birra, fosfato ferrico, raccolta serale." } + ] + }, + { + id: "radicchio", + nome: "Radicchio e cicoria", + sinonimi: ["cicoria", "catalogna", "trevisana", "puntarelle"], + categoria: "ortaggio", + famiglia: "Composite", + emoji: "🥬", + perenne: false, + seminaSemenzaio: [6, 7], + seminaDiretta: [6, 7, 8], + trapianto: [7, 8], + raccolta: [10, 11, 12, 1, 2], + kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.0, finale: 0.95 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 40, finale: 20 }, + profonditaRadici: 0.5, + fattoreDeplezione: 0.4, + sensibilitaIdrica: "media", + fasiCritiche: "Prime settimane dopo il trapianto estivo, quando fa ancora molto caldo.", + sesto: "30 cm sulla fila, 40 cm tra le file", + piantePerMq: 8, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Finocchio", "Porro"], + daEvitare: ["Lattuga"], + concimazione: "Poco azoto, buona dotazione di potassio per il colore rosso.", + cure: [ + "Il freddo è un alleato: le prime gelate danno colore e dolcezza.", + "Lega i cespi per imbianchire le varietà da forzatura.", + "Riduci l'acqua nell'ultimo mese per una testa più compatta." + ], + problemi: [ + { nome: "Marciume del colletto", sintomo: "Cespi che marciscono alla base con l'umidità autunnale.", rimedio: "Baulatura del terreno, niente ristagni, sesto largo." } + ] + }, + { + id: "spinacio", + nome: "Spinacio", + sinonimi: ["spinaci"], + categoria: "ortaggio", + famiglia: "Chenopodiacee", + emoji: "🥬", + perenne: false, + seminaDiretta: [2, 3, 8, 9, 10], + raccolta: [4, 5, 10, 11, 12, 1], + kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.0, finale: 0.95 }, + fasiGiorni: { iniziale: 20, sviluppo: 20, piena: 15, finale: 5 }, + profonditaRadici: 0.35, + fattoreDeplezione: 0.2, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutto il ciclo. Fattore di deplezione bassissimo: non tollera di asciugare.", + sesto: "10 cm sulla fila, 25 cm tra le file", + piantePerMq: 40, + esposizione: "mezz'ombra", + phIdeale: [6.5, 7.5], + consociazioni: ["Fragola", "Ravanello", "Cavolo"], + daEvitare: ["Bietola (stessa famiglia)"], + concimazione: "Azoto moderato in copertura; teme i terreni acidi.", + cure: [ + "Coltura da clima fresco: primavera e autunno, mai piena estate.", + "Raccogli a foglia esterna per prolungare il ciclo.", + "Il caldo lo manda a seme in pochi giorni." + ], + problemi: [ + { nome: "Peronospora", sintomo: "Macchie gialle sopra, muffa violacea sotto.", rimedio: "Varietà resistenti, semine rade, niente bagnature serali." } + ] + }, + { + id: "bietola", + nome: "Bietola da coste", + sinonimi: ["bieta", "erbette", "coste", "costa", "bietole"], + categoria: "ortaggio", + famiglia: "Chenopodiacee", + emoji: "🥬", + perenne: false, + seminaDiretta: [3, 4, 5, 8, 9], + raccolta: [5, 6, 7, 8, 9, 10, 11], + kc: { iniziale: 0.6, sviluppo: 0.85, piena: 1.05, finale: 0.95 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 60, finale: 20 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "media", + fasiCritiche: "Ripresa dopo ogni taglio.", + sesto: "25 cm sulla fila, 40 cm tra le file", + piantePerMq: 9, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Cipolla", "Cavolo", "Lattuga"], + daEvitare: ["Spinacio"], + concimazione: "Azoto in copertura dopo ogni raccolta a foglia.", + cure: [ + "Taglia le foglie esterne, lascia il cuore: produce per mesi.", + "Una semina di primavera copre fino all'autunno.", + "Rustica e generosa: ottima per iniziare." + ], + problemi: [ + { nome: "Cercospora", sintomo: "Macchie tonde grigie con bordo rosso.", rimedio: "Elimina le foglie colpite, arieggia, rame se necessario." } + ] + }, + { + id: "cavolo", + nome: "Cavolo cappuccio e verza", + sinonimi: ["cappuccio", "verza", "cavoli", "cavolo cappuccio"], + categoria: "ortaggio", + famiglia: "Crucifere", + emoji: "🥬", + perenne: false, + seminaSemenzaio: [5, 6, 7], + trapianto: [6, 7, 8], + raccolta: [10, 11, 12, 1, 2], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.05, finale: 0.95 }, + fasiGiorni: { iniziale: 30, sviluppo: 40, piena: 50, finale: 20 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "media", + fasiCritiche: "Formazione della testa: acqua irregolare la fa spaccare.", + sesto: "45 cm sulla fila, 60 cm tra le file", + piantePerMq: 3.5, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Sedano", "Aneto", "Camomilla", "Menta", "Bietola"], + daEvitare: ["Fragola", "Pomodoro", "Altre crucifere"], + concimazione: "Molto esigente: letame maturo all'impianto e azoto in copertura a metà ciclo.", + cure: [ + "Rincalza a metà ciclo: dà stabilità e nuove radici.", + "Rete anti-insetto da subito contro la cavolaia.", + "Non ripetere le crucifere sulla stessa aiuola per 3-4 anni (ernia del cavolo)." + ], + problemi: [ + { nome: "Cavolaia", sintomo: "Bruchi verdi che divorano le foglie.", rimedio: "Rete anti-insetto, Bacillus thuringiensis, raccolta manuale delle uova gialle." }, + { nome: "Altica", sintomo: "Foglioline bucherellate a colpi di spillo.", rimedio: "Mantieni il terreno umido, tessuto non tessuto sulle piantine giovani." } + ] + }, + { + id: "cavolfiore", + nome: "Cavolfiore e broccolo", + sinonimi: ["broccolo", "broccoli", "cavolfiori", "cimone"], + categoria: "ortaggio", + famiglia: "Crucifere", + emoji: "🥦", + perenne: false, + seminaSemenzaio: [5, 6, 7], + trapianto: [7, 8], + raccolta: [10, 11, 12, 1, 2, 3], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.05, finale: 0.95 }, + fasiGiorni: { iniziale: 30, sviluppo: 45, piena: 45, finale: 20 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "alta", + fasiCritiche: "Differenziazione del corimbo: stress idrico = testa piccola e sgranata.", + sesto: "50 cm sulla fila, 70 cm tra le file", + piantePerMq: 3, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Sedano", "Bietola", "Menta"], + daEvitare: ["Altre crucifere", "Fragola"], + concimazione: "Esigente in azoto e boro: una carenza di boro annerisce il torsolo.", + cure: [ + "Ripiega due foglie sopra il corimbo per tenerlo bianco.", + "Il broccolo ripete: dopo la testa centrale dà getti laterali per settimane.", + "Non far mai seccare: la testa si blocca e non riparte." + ], + problemi: [ + { nome: "Ernia del cavolo", sintomo: "Radici deformate, piante appassite in pieno giorno.", rimedio: "Rotazione lunga, correzione del pH verso 7,2 con calce." } + ] + }, + { + id: "carota", + nome: "Carota", + sinonimi: ["carote"], + categoria: "ortaggio", + famiglia: "Ombrellifere", + emoji: "🥕", + perenne: false, + seminaDiretta: [2, 3, 4, 5, 6, 7, 8], + raccolta: [6, 7, 8, 9, 10, 11], + kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.05, finale: 0.95 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 50, finale: 20 }, + profonditaRadici: 0.7, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "media", + fasiCritiche: "Germinazione (15-20 giorni di superficie sempre umida) e ingrossamento della radice.", + sesto: "3-5 cm sulla fila, 25 cm tra le file", + piantePerMq: 80, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Cipolla", "Porro", "Ravanello", "Lattuga", "Pisello"], + daEvitare: ["Aneto", "Prezzemolo"], + concimazione: "Mai letame fresco: fa le radici biforcute. Solo compost molto maturo dell'anno prima.", + cure: [ + "Terreno lavorato in profondità e privo di sassi: è tutto lì il segreto delle carote dritte.", + "Copri la semina con un telo fino alla nascita per non far seccare la superficie.", + "Dirada a 4-5 cm: senza diradamento resta tutto piccolo.", + "Riprendere a irrigare dopo un periodo secco spacca le radici: meglio turni regolari." + ], + problemi: [ + { nome: "Mosca della carota", sintomo: "Gallerie brune nella radice.", rimedio: "Rete anti-insetto, consociazione con cipolla, semine dopo metà maggio." } + ] + }, + { + id: "ravanello", + nome: "Ravanello", + sinonimi: ["rapanello", "ravanelli", "radicchiella"], + categoria: "ortaggio", + famiglia: "Crucifere", + emoji: "🌶️", + perenne: false, + seminaDiretta: [2, 3, 4, 5, 6, 7, 8, 9], + raccolta: [4, 5, 6, 7, 8, 9, 10], + kc: { iniziale: 0.7, sviluppo: 0.8, piena: 0.9, finale: 0.85 }, + fasiGiorni: { iniziale: 10, sviluppo: 10, piena: 12, finale: 3 }, + profonditaRadici: 0.3, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutto il ciclo, che dura appena 25-30 giorni.", + sesto: "4 cm sulla fila, 15 cm tra le file", + piantePerMq: 150, + esposizione: "mezz'ombra", + phIdeale: [6, 7], + consociazioni: ["Carota", "Lattuga", "Cetriolo", "Spinacio"], + daEvitare: ["Altre crucifere in successione"], + concimazione: "Nessuna, se il terreno è decente.", + cure: [ + "Il più veloce dell'orto: perfetto per marcare le file di carote a lenta germinazione.", + "Se manca acqua diventa piccante, legnoso e vuoto.", + "Semina scalare ogni 10 giorni." + ], + problemi: [ + { nome: "Altica", sintomo: "Foglie crivellate.", rimedio: "Terreno sempre umido, velo protettivo." } + ] + }, + { + id: "patata", + nome: "Patata", + sinonimi: ["patate"], + categoria: "ortaggio", + famiglia: "Solanacee", + emoji: "🥔", + perenne: false, + seminaDiretta: [2, 3, 4], + raccolta: [6, 7, 8], + kc: { iniziale: 0.5, sviluppo: 0.85, piena: 1.15, finale: 0.75 }, + fasiGiorni: { iniziale: 25, sviluppo: 30, piena: 45, finale: 30 }, + profonditaRadici: 0.5, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "alta", + fasiCritiche: "Dalla fioritura alla tuberificazione: è lì che si decide la resa.", + sesto: "30 cm sulla fila, 70 cm tra le file", + piantePerMq: 4.5, + esposizione: "pieno sole", + phIdeale: [5.5, 6.5], + consociazioni: ["Fagiolo", "Mais", "Cavolo", "Rafano"], + daEvitare: ["Pomodoro", "Zucca", "Girasole", "Sedano"], + concimazione: "Compost maturo alla preparazione, potassio abbondante. Poco azoto in fase finale.", + cure: [ + "Pregermina i tuberi in cassetta alla luce per 3-4 settimane prima di piantarli.", + "Rincalza due volte: i tuberi esposti alla luce diventano verdi e tossici.", + "Sospendi l'irrigazione quando la parte aerea ingiallisce.", + "Raccogli con terreno asciutto e lascia asciugare i tuberi all'ombra prima di conservarli." + ], + problemi: [ + { nome: "Peronospora", sintomo: "Macchie brune su foglie e fusti dopo piogge calde.", rimedio: "Rame preventivo, rincalzatura alta, varietà precoci." }, + { nome: "Dorifora", sintomo: "Larve rosse voraci.", rimedio: "Raccolta manuale, Bacillus thuringiensis tenebrionis." } + ] + }, + { + id: "cipolla", + nome: "Cipolla", + sinonimi: ["cipolle", "cipollotto", "scalogno"], + categoria: "ortaggio", + famiglia: "Liliacee", + emoji: "🧅", + perenne: false, + seminaSemenzaio: [1, 2, 8, 9], + seminaDiretta: [2, 3, 9, 10], + trapianto: [2, 3, 10, 11], + raccolta: [6, 7, 8], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.05, finale: 0.75 }, + fasiGiorni: { iniziale: 20, sviluppo: 45, piena: 70, finale: 25 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "media", + fasiCritiche: "Ingrossamento del bulbo. Poi si sospende del tutto.", + sesto: "12 cm sulla fila, 30 cm tra le file", + piantePerMq: 28, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Carota", "Bietola", "Fragola", "Pomodoro", "Lattuga"], + daEvitare: ["Fagiolo", "Pisello", "Fava"], + concimazione: "Mai letame fresco. Compost dell'anno precedente e potassio.", + cure: [ + "Sospendi l'acqua 3 settimane prima della raccolta: i bulbi si conservano molto meglio.", + "Quando lo stelo si piega, piegalo tutto e attendi 10 giorni.", + "Asciuga i bulbi al sole per una settimana prima di intrecciarli." + ], + problemi: [ + { nome: "Peronospora della cipolla", sintomo: "Foglie con feltro violaceo che collassano.", rimedio: "Sesto largo, rotazione, rame." }, + { nome: "Marciume bianco", sintomo: "Feltro bianco alla base del bulbo.", rimedio: "Rotazione lunghissima, drenaggio, niente ristagni." } + ] + }, + { + id: "aglio", + nome: "Aglio", + sinonimi: ["agli"], + categoria: "ortaggio", + famiglia: "Liliacee", + emoji: "🧄", + perenne: false, + seminaDiretta: [10, 11, 12, 1, 2], + raccolta: [6, 7], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.0, finale: 0.7 }, + fasiGiorni: { iniziale: 25, sviluppo: 50, piena: 90, finale: 30 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "bassa", + fasiCritiche: "Primavera, durante la formazione dei bulbilli.", + sesto: "12 cm sulla fila, 25 cm tra le file", + piantePerMq: 33, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Fragola", "Pomodoro", "Rosa", "Carota"], + daEvitare: ["Fagiolo", "Pisello", "Cavolo"], + concimazione: "Quasi nessuna. Teme il terreno grasso e umido.", + cure: [ + "Pianta gli spicchi con la punta in su, a 3-4 cm di profondità.", + "Praticamente non si irriga: solo in primavere molto siccitose.", + "Taglia lo scapo fiorale (l'aglio 'a spirale') per bulbi più grossi: si mangia saltato in padella.", + "Sospendi l'acqua un mese prima della raccolta." + ], + problemi: [ + { nome: "Ruggine", sintomo: "Pustole arancioni sulle foglie.", rimedio: "Sesto arieggiato, evita eccessi di azoto." } + ] + }, + { + id: "porro", + nome: "Porro", + sinonimi: ["porri"], + categoria: "ortaggio", + famiglia: "Liliacee", + emoji: "🧅", + perenne: false, + seminaSemenzaio: [2, 3, 4], + trapianto: [5, 6, 7], + raccolta: [10, 11, 12, 1, 2, 3], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.0, finale: 1.0 }, + fasiGiorni: { iniziale: 25, sviluppo: 45, piena: 70, finale: 30 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "media", + fasiCritiche: "Estate, durante l'allungamento del fusto.", + sesto: "15 cm sulla fila, 35 cm tra le file", + piantePerMq: 20, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Carota", "Sedano", "Fragola"], + daEvitare: ["Fagiolo", "Pisello", "Barbabietola"], + concimazione: "Buona dotazione organica, azoto in copertura a luglio.", + cure: [ + "Al trapianto accorcia radici e foglie di un terzo: attecchisce meglio.", + "Trapianta in solchi profondi e rincalza progressivamente: è così che si allunga la parte bianca.", + "Resiste al gelo: si raccoglie tutto l'inverno, uno alla volta." + ], + problemi: [ + { nome: "Tignola del porro", sintomo: "Gallerie nelle foglie, piante che marciscono.", rimedio: "Rete anti-insetto da maggio a settembre." } + ] + }, + { + id: "sedano", + nome: "Sedano", + sinonimi: ["sellero", "sedano rapa"], + categoria: "ortaggio", + famiglia: "Ombrellifere", + emoji: "🌿", + perenne: false, + seminaSemenzaio: [2, 3, 4], + trapianto: [5, 6], + raccolta: [9, 10, 11], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.05, finale: 1.0 }, + fasiGiorni: { iniziale: 25, sviluppo: 40, piena: 95, finale: 20 }, + profonditaRadici: 0.4, + fattoreDeplezione: 0.2, + sensibilitaIdrica: "alta", + fasiCritiche: "Tutto il ciclo: è la coltura dell'orto che tollera meno la siccità.", + sesto: "30 cm sulla fila, 40 cm tra le file", + piantePerMq: 8, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Cavolo", "Porro", "Pomodoro", "Fagiolo"], + daEvitare: ["Mais", "Patata", "Lattuga"], + concimazione: "Molto esigente: sostanza organica abbondante e azoto costante.", + cure: [ + "Terreno che non deve mai asciugare: piantalo nell'aiuola più umida.", + "Imbianca le coste legandole e avvolgendole nelle ultime 3 settimane.", + "Vuole terreno fresco e ricchissimo di humus." + ], + problemi: [ + { nome: "Septoriosi", sintomo: "Macchie brune con puntini neri sulle foglie.", rimedio: "Seme sano, rotazione, rame." }, + { nome: "Cuore nero", sintomo: "Cuore che annerisce.", rimedio: "Carenza di calcio da irrigazione irregolare: regolarizza i turni." } + ] + }, + { + id: "finocchio", + nome: "Finocchio", + sinonimi: ["finocchi", "grumolo"], + categoria: "ortaggio", + famiglia: "Ombrellifere", + emoji: "🌿", + perenne: false, + seminaSemenzaio: [6, 7], + seminaDiretta: [6, 7, 8], + trapianto: [7, 8], + raccolta: [10, 11, 12], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.05, finale: 0.95 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 40, finale: 20 }, + profonditaRadici: 0.5, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "alta", + fasiCritiche: "Formazione del grumolo.", + sesto: "25 cm sulla fila, 45 cm tra le file", + piantePerMq: 9, + esposizione: "pieno sole", + phIdeale: [6, 7.5], + consociazioni: ["Radicchio", "Lattuga"], + daEvitare: ["Pomodoro", "Fagiolo", "Coriandolo"], + concimazione: "Compost maturo. Poco azoto o va a seme.", + cure: [ + "Rincalza il grumolo quando ha la dimensione di un uovo: resta bianco e tenero.", + "Semine troppo precoci mandano a seme: aspetta che le giornate si accorcino.", + "Irrigazione costante o diventa filaccioso." + ], + problemi: [ + { nome: "Prefioritura", sintomo: "Stelo che si allunga, grumolo che non si forma.", rimedio: "Semina dopo il solstizio, varietà resistenti, niente stress idrici." } + ] + }, + { + id: "zucca", + nome: "Zucca", + sinonimi: ["zucche", "zucca gialla", "cocuzza"], + categoria: "ortaggio", + famiglia: "Cucurbitacee", + emoji: "🎃", + perenne: false, + seminaSemenzaio: [4], + seminaDiretta: [4, 5, 6], + trapianto: [5, 6], + raccolta: [9, 10], + kc: { iniziale: 0.5, sviluppo: 0.75, piena: 1.0, finale: 0.8 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 45, finale: 30 }, + profonditaRadici: 1.0, + fattoreDeplezione: 0.35, + sensibilitaIdrica: "media", + fasiCritiche: "Allegagione e ingrossamento dei frutti (luglio-agosto).", + sesto: "100 cm sulla fila, 200 cm tra le file", + piantePerMq: 0.5, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Mais", "Fagiolo (le tre sorelle)", "Nasturzio"], + daEvitare: ["Patata"], + concimazione: "Letame maturo abbondante in buca: è una divoratrice.", + cure: [ + "Radici profonde: irrigazioni abbondanti e distanziate, non superficiali.", + "Metti una tavoletta o della paglia sotto i frutti per non farli marcire.", + "Lascia 2-3 frutti per pianta se le vuoi grandi.", + "Raccogli col picciolo attaccato e lasciale al sole 10 giorni: si conservano tutto l'inverno." + ], + problemi: [ + { nome: "Oidio", sintomo: "Foglie bianche a fine estate.", rimedio: "A fine ciclo è tollerabile; zolfo se compare presto." } + ] + }, + { + id: "melone", + nome: "Melone", + sinonimi: ["poponi", "popone", "meloni", "mellone", "cantalupo"], + ancheIn: ["frutta"], + categoria: "ortaggio", + famiglia: "Cucurbitacee", + emoji: "🍈", + perenne: false, + seminaSemenzaio: [3, 4], + trapianto: [5], + raccolta: [7, 8], + kc: { iniziale: 0.5, sviluppo: 0.8, piena: 1.05, finale: 0.75 }, + fasiGiorni: { iniziale: 25, sviluppo: 35, piena: 40, finale: 20 }, + profonditaRadici: 0.9, + fattoreDeplezione: 0.4, + sensibilitaIdrica: "media", + fasiCritiche: "Allegagione. Nell'ultima settimana prima della raccolta va invece asciugato.", + sesto: "80 cm sulla fila, 150 cm tra le file", + piantePerMq: 0.8, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Mais", "Nasturzio"], + daEvitare: ["Patata", "Cetriolo"], + concimazione: "Compost in buca e potassio in fase di ingrossamento.", + cure: [ + "Cima sopra la 5ª foglia per stimolare i rami secondari che portano i fiori femminili.", + "Riduci l'acqua nelle ultime 2 settimane: è quello che concentra gli zuccheri.", + "È maturo quando il peduncolo si stacca da solo e profuma.", + "Pacciamatura nera: anticipa la raccolta di 2 settimane." + ], + problemi: [ + { nome: "Frutti insipidi", sintomo: "Meloni acquosi e senza sapore.", rimedio: "Troppa acqua a fine ciclo: sospendi 10-14 giorni prima." } + ] + }, + { + id: "anguria", + nome: "Anguria", + sinonimi: ["cocomero", "cocomeri", "angurie", "melone d'acqua", "sandia"], + ancheIn: ["frutta"], + categoria: "ortaggio", + famiglia: "Cucurbitacee", + emoji: "🍉", + perenne: false, + seminaSemenzaio: [3, 4], + trapianto: [5], + raccolta: [7, 8], + kc: { iniziale: 0.4, sviluppo: 0.75, piena: 1.0, finale: 0.75 }, + fasiGiorni: { iniziale: 25, sviluppo: 40, piena: 45, finale: 25 }, + profonditaRadici: 1.2, + fattoreDeplezione: 0.4, + sensibilitaIdrica: "media", + fasiCritiche: "Ingrossamento dei frutti; poi asciugare per concentrare gli zuccheri.", + sesto: "100 cm sulla fila, 200 cm tra le file", + piantePerMq: 0.5, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Mais", "Fagiolo"], + daEvitare: ["Patata"], + concimazione: "Letame in buca, potassio in ingrossamento.", + cure: [ + "Radici molto profonde: bagna raro ma in profondità.", + "Lascia 2-3 frutti per pianta.", + "È matura quando il viticcio più vicino al frutto secca e il fondo diventa giallo crema." + ], + problemi: [ + { nome: "Fusariosi", sintomo: "Appassimento improvviso di un tralcio.", rimedio: "Piante innestate, rotazione lunga, drenaggio." } + ] + }, + { + id: "mais-dolce", + nome: "Mais dolce", + sinonimi: ["granoturco", "granturco", "pannocchia", "mais", "granone"], + categoria: "ortaggio", + famiglia: "Graminacee", + emoji: "🌽", + perenne: false, + seminaDiretta: [4, 5, 6], + raccolta: [7, 8, 9], + kc: { iniziale: 0.3, sviluppo: 0.8, piena: 1.2, finale: 1.05 }, + fasiGiorni: { iniziale: 20, sviluppo: 30, piena: 35, finale: 15 }, + profonditaRadici: 1.0, + fattoreDeplezione: 0.5, + sensibilitaIdrica: "alta", + fasiCritiche: "Fioritura (emissione delle sete): 10 giorni in cui l'acqua decide tutta la resa.", + sesto: "20 cm sulla fila, 70 cm tra le file", + piantePerMq: 7, + esposizione: "pieno sole", + phIdeale: [6, 7], + consociazioni: ["Fagiolo rampicante", "Zucca (le tre sorelle)"], + daEvitare: ["Sedano", "Barbabietola"], + concimazione: "Azoto abbondante: è la coltura più esigente dell'orto in questo senso.", + cure: [ + "Semina a blocco quadrato, mai su una fila sola: l'impollinazione è anemofila.", + "Rincalza a 40 cm di altezza contro l'allettamento.", + "Raccogli quando le sete sono brune e il chicco schiacciato butta latte." + ], + problemi: [ + { nome: "Piralide", sintomo: "Fori nel fusto e nella pannocchia.", rimedio: "Trichogramma, eliminazione degli steli a fine ciclo." } + ] + }, + { + id: "fragola", + nome: "Fragola", + sinonimi: ["fragole", "fragoline"], + ancheIn: ["frutta"], + categoria: "ortaggio", + famiglia: "Rosacee", + emoji: "🍓", + perenne: true, + trapianto: [8, 9, 10, 3], + raccolta: [5, 6, 7, 9], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.4, sviluppo: 0.7, piena: 0.85, finale: 0.75 }, + profonditaRadici: 0.25, + fattoreDeplezione: 0.2, + sensibilitaIdrica: "alta", + fasiCritiche: "Fioritura e maturazione. Radici cortissime: serbatoio minimo, turni molto brevi.", + sesto: "30 cm sulla fila, 40 cm tra le file", + piantePerMq: 8, + esposizione: "pieno sole", + phIdeale: [5.5, 6.8], + consociazioni: ["Aglio", "Cipolla", "Spinacio", "Lattuga", "Borragine"], + daEvitare: ["Cavoli", "Pomodoro (verticillium)"], + concimazione: "Compost in autunno e potassio alla ripresa vegetativa.", + cure: [ + "Pacciamatura obbligatoria (paglia o telo): tiene i frutti puliti e il terreno fresco.", + "Taglia gli stoloni se vuoi frutti, tienili se vuoi nuove piante.", + "Rinnova l'impianto ogni 3 anni: dopo produce sempre meno.", + "Irriga a goccia sotto la pacciamatura: l'acqua sui frutti li fa marcire." + ], + problemi: [ + { nome: "Botrite (muffa grigia)", sintomo: "Frutti coperti di muffa grigia.", rimedio: "Pacciamatura, sesto arieggiato, raccolta frequente, mai acqua sui frutti." }, + { nome: "Oidio", sintomo: "Foglie accartocciate verso l'alto con patina bianca.", rimedio: "Zolfo, sfoltimento delle foglie." } + ] + }, + { + id: "asparago", + nome: "Asparago", + sinonimi: ["asparagi", "sparagi"], + categoria: "ortaggio", + famiglia: "Liliacee", + emoji: "🌱", + perenne: true, + trapianto: [2, 3], + raccolta: [4, 5, 6], + stagioneVegetativa: [3, 10], + kc: { iniziale: 0.5, sviluppo: 0.7, piena: 0.95, finale: 0.3 }, + profonditaRadici: 1.2, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "bassa", + fasiCritiche: "Estate, durante la crescita delle fronde che ricaricano la zampa per l'anno dopo.", + sesto: "40 cm sulla fila, 150 cm tra le file", + piantePerMq: 1.7, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Pomodoro", "Prezzemolo", "Basilico"], + daEvitare: ["Aglio", "Cipolla"], + concimazione: "Letame abbondante alla piantagione e ogni autunno: l'asparagiaia dura 15 anni.", + cure: [ + "Non raccogliere per i primi 2 anni: servono a costruire la pianta.", + "Smetti di raccogliere a fine maggio e lascia crescere le fronde tutta l'estate.", + "Taglia le fronde secche a fine inverno e rincalza con compost.", + "Investimento lento ma poi produce per 15 anni." + ], + problemi: [ + { nome: "Criocera", sintomo: "Coleotteri arancioni e neri sulle fronde.", rimedio: "Raccolta manuale, piretro naturale in caso di forte attacco." } + ] + }, + { + id: "carciofo", + nome: "Carciofo", + sinonimi: ["carciofi", "carciofaia"], + categoria: "ortaggio", + famiglia: "Composite", + emoji: "🌿", + perenne: true, + trapianto: [3, 8, 9], + raccolta: [3, 4, 5, 10, 11], + stagioneVegetativa: [9, 6], + kc: { iniziale: 0.5, sviluppo: 0.8, piena: 1.0, finale: 0.95 }, + profonditaRadici: 0.9, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "media", + fasiCritiche: "Autunno (ripresa) e formazione dei capolini.", + sesto: "80 cm sulla fila, 120 cm tra le file", + piantePerMq: 1, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Fava", "Pisello"], + daEvitare: ["Lattuga"], + concimazione: "Letame maturo ogni autunno, azoto alla ripresa.", + cure: [ + "Si propaga per carducci staccati dalla pianta madre in agosto.", + "In estate va in riposo: sospendi le irrigazioni da luglio a fine agosto.", + "Pacciama e proteggi il ceppo dalle gelate forti.", + "Rinnova la carciofaia ogni 4-5 anni." + ], + problemi: [ + { nome: "Afide del carciofo", sintomo: "Colonie fitte alla base delle brattee.", rimedio: "Sapone molle, coccinelle." } + ] + }, + { + id: "rucola", + nome: "Rucola", + sinonimi: ["rughetta", "ruchetta", "rucola selvatica"], + categoria: "ortaggio", + famiglia: "Crucifere", + emoji: "🌿", + perenne: false, + seminaDiretta: [3, 4, 5, 8, 9, 10], + raccolta: [4, 5, 6, 9, 10, 11], + kc: { iniziale: 0.7, sviluppo: 0.85, piena: 1.0, finale: 0.95 }, + fasiGiorni: { iniziale: 12, sviluppo: 15, piena: 15, finale: 5 }, + profonditaRadici: 0.3, + fattoreDeplezione: 0.3, + sensibilitaIdrica: "media", + fasiCritiche: "Tutto il ciclo, breve.", + sesto: "5 cm sulla fila, 20 cm tra le file", + piantePerMq: 100, + esposizione: "mezz'ombra", + phIdeale: [6, 7], + consociazioni: ["Lattuga", "Carota"], + daEvitare: ["Crucifere in successione"], + concimazione: "Nessuna.", + cure: [ + "Taglia a 2 cm dal suolo: ricaccia 3-4 volte.", + "Con caldo e secco diventa piccantissima e va a fiore.", + "Semina scalare ogni 15 giorni." + ], + problemi: [ + { nome: "Altica", sintomo: "Foglie bucherellate.", rimedio: "Rete anti-insetto, terreno umido." } + ] + }, + { + id: "cavolo-nero", + nome: "Cavolo nero", + sinonimi: ["cavolo toscano", "cavolo riccio", "kale"], + categoria: "ortaggio", + famiglia: "Crucifere", + emoji: "🥬", + perenne: false, + seminaSemenzaio: [5, 6, 7], + trapianto: [6, 7, 8], + raccolta: [10, 11, 12, 1, 2, 3], + kc: { iniziale: 0.7, sviluppo: 0.9, piena: 1.05, finale: 0.95 }, + fasiGiorni: { iniziale: 30, sviluppo: 40, piena: 80, finale: 20 }, + profonditaRadici: 0.6, + fattoreDeplezione: 0.45, + sensibilitaIdrica: "bassa", + fasiCritiche: "Estate, durante la crescita giovanile.", + sesto: "50 cm sulla fila, 60 cm tra le file", + piantePerMq: 3, + esposizione: "pieno sole", + phIdeale: [6.5, 7.5], + consociazioni: ["Sedano", "Menta", "Aneto"], + daEvitare: ["Crucifere", "Fragola"], + concimazione: "Letame maturo all'impianto, azoto in copertura a settembre.", + cure: [ + "Raccogli le foglie dal basso: la pianta continua ad allungarsi e produce fino a marzo.", + "Dopo la prima gelata diventa più dolce.", + "Il più rustico e generoso dei cavoli." + ], + problemi: [ + { nome: "Cavolaia", sintomo: "Bruchi verdi.", rimedio: "Rete anti-insetto, Bacillus thuringiensis." }, + { nome: "Afide ceroso", sintomo: "Colonie grigiastre sugli apici.", rimedio: "Sapone molle, getto d'acqua." } + ] + } +] diff --git a/src/lib/colture.ts b/src/lib/colture.ts new file mode 100644 index 0000000..a59f718 --- /dev/null +++ b/src/lib/colture.ts @@ -0,0 +1,46 @@ +import type { Coltura } from "./types" +import { ORTAGGI } from "./colture.ortaggi" +import { AROMATICHE } from "./colture.aromatiche" +import { FRUTTA } from "./colture.frutta" + +export const COLTURE: Coltura[] = [...ORTAGGI, ...AROMATICHE, ...FRUTTA].sort((a, b) => + a.nome.localeCompare(b.nome, "it") +) + +const INDICE = new Map(COLTURE.map((c) => [c.id, c])) + +export function coltura(id: string): Coltura | undefined { + return INDICE.get(id) +} + +/** Toglie accenti e maiuscole: "petonciano" e "Petónciano" devono pescare lo stesso. */ +function normalizza(t: string): string { + return t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim() +} + +/** + * Ricerca per nome, sinonimi regionali e famiglia botanica. In Italia la stessa + * pianta cambia nome ogni cento chilometri — cocomero e anguria, zucchino e + * zucchina, ramerino e rosmarino — e chi cerca usa la parola di casa sua. + */ +export function corrisponde(c: Coltura, query: string): boolean { + const q = normalizza(query) + if (!q) return true + if (normalizza(c.nome).includes(q)) return true + if ((c.sinonimi ?? []).some((s) => normalizza(s).includes(q))) return true + // La famiglia botanica si cerca solo con parole lunghe: altrimenti "mora" + // pescherebbe il fico, che è una Moracea. + return q.length >= 5 && normalizza(c.famiglia).includes(q) +} + +/** Vero se la coltura va mostrata sotto quella categoria, anche come seconda casa. */ +export function nellaCategoria(c: Coltura, categoria: string): boolean { + return categoria === "tutte" || c.categoria === categoria || Boolean(c.ancheIn?.includes(categoria as Coltura["categoria"])) +} + +export const MESI = [ + "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", + "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre", +] + +export const MESI_BREVI = ["G", "F", "M", "A", "M", "G", "L", "A", "S", "O", "N", "D"] diff --git a/src/lib/irrigazione.ts b/src/lib/irrigazione.ts new file mode 100644 index 0000000..ce02318 --- /dev/null +++ b/src/lib/irrigazione.ts @@ -0,0 +1,402 @@ +import type { Aiuola, Coltura, Evento, Impostazioni, Metodo, Piantagione, TipoSuolo } from "./types" +import type { GiornoMeteo } from "./meteo" +import { KPA_CAPACITA_DI_CAMPO } from "./sonde" +import { isoOggi } from "./meteo" + +/** + * Motore di irrigazione a bilancio idrico (metodo FAO-56). + * + * L'idea: il terreno è un serbatoio. Ogni giorno la pianta ne toglie acqua + * (evapotraspirazione ETc = ET0 × Kc), la pioggia e le irrigazioni lo ricaricano. + * Sappiamo quanto è grande il serbatoio (tipo di suolo × profondità delle radici) + * e quanto se ne può svuotare prima che la pianta soffra (fattore di deplezione). + * Quando il conto arriva a quella soglia, è il momento di irrigare — e sappiamo + * anche di quanti millimetri, perché è esattamente l'acqua mancante. + * + * Ricostruiamo il bilancio sui 21 giorni passati con i dati meteo reali, poi lo + * proiettiamo in avanti sulle previsioni per dire quando toccherà la prossima volta. + */ + +/** Acqua disponibile per metro di profondità, in mm (capacità di campo − punto di appassimento). */ +const ACQUA_DISPONIBILE: Record = { + sabbioso: 80, + "franco-sabbioso": 120, + franco: 160, + "franco-argilloso": 190, + argilloso: 200, +} + +export const DESCRIZIONE_SUOLO: Record = { + sabbioso: "Scorre tra le dita, drena in fretta, si asciuga in 2-3 giorni. Turni brevi e dosi piccole.", + "franco-sabbioso": "Leggero ma con un po' di corpo. Buon compromesso, turni medio-brevi.", + franco: "Il terreno ideale dell'orto: trattiene bene senza ristagnare.", + "franco-argilloso": "Pesante ma fertile, trattiene molta acqua. Turni lunghi e dosi generose.", + argilloso: "Compatto, si spacca da secco e diventa colloso da bagnato. Irrigazioni rade e lente per non far scorrere via l'acqua.", +} + +/** Quanta acqua erogata arriva davvero alle radici. */ +export const EFFICIENZA_PREDEFINITA = { + goccia: 0.9, + subirrigazione: 0.95, + manuale: 0.75, + aspersione: 0.65, +} + +/** + * Soglia a cui conviene intervenire, che non coincide con la soglia di stress. + * Con la goccia si tiene un bulbo umido costante: si bagna a metà della riserva + * utile, più spesso e meno per volta. Con un turno manuale o per aspersione, + * che si dà di rado, si lascia invece consumare tutta la riserva disponibile. + */ +export function sogliaIntervento(metodo: Metodo, raw: number): number { + if (metodo === "goccia" || metodo === "subirrigazione") return raw * 0.55 + if (metodo === "aspersione") return raw * 0.85 + return raw +} + +export type Stato = "ottimale" | "attenzione" | "critico" | "eccesso" | "riposo" + +export interface Bilancio { + data: string + et0: number + kc: number + etc: number + pioggia: number + pioggiaEfficace: number + irrigazione: number + /** Acqua mancante rispetto alla capacità di campo, mm. */ + deficit: number + previsione: boolean +} + +export interface Consiglio { + piantagioneId: string + coltura: Coltura + aiuola: Aiuola + /** Giorni dalla messa a dimora. */ + eta: number + fase: string + kc: number + /** Consumo di oggi, mm (= litri per m²). */ + etcOggi: number + litriGiornoPerPianta: number + /** Capacità totale del serbatoio suolo esplorato dalle radici, mm. */ + taw: number + /** Quota consumabile senza stress, mm. */ + raw: number + /** Millimetri di consumo a cui conviene far partire il turno, dato il metodo. */ + soglia: number + deficit: number + /** 0-100: quanto della riserva utile è stata consumata. */ + consumo: number + stato: Stato + irrigareOggi: boolean + /** Dose netta da riportare alla capacità di campo, mm. */ + doseMm: number + /** Litri effettivamente da erogare sull'aiuola, considerando l'efficienza del metodo. */ + doseLitri: number + minuti: number | null + giorniAllaProssima: number | null + pioggiaPrevista48h: number + /** Superficie effettivamente occupata dalla coltura, m². */ + areaBagnata: number + /** Vero finché non c'è nessuna irrigazione registrata a cui ancorare il bilancio. */ + stimaProvvisoria: boolean + motivazione: string + serie: Bilancio[] +} + +/** Fase fenologica e Kc del giorno, per annuali (dai giorni) e perenni (dal mese). */ +function kcDelGiorno(c: Coltura, giorniDaImpianto: number, mese: number): { kc: number; fase: string } { + if (c.perenne) { + const [inizio, fine] = c.stagioneVegetativa ?? [3, 10] + const inStagione = inizio <= fine ? mese >= inizio && mese <= fine : mese >= inizio || mese <= fine + if (!inStagione) return { kc: c.kc.iniziale * 0.35, fase: "riposo vegetativo" } + const durata = inizio <= fine ? fine - inizio : 12 - inizio + fine + const avanzamento = (inizio <= fine ? mese - inizio : (mese - inizio + 12) % 12) / Math.max(durata, 1) + if (avanzamento < 0.2) return { kc: c.kc.iniziale, fase: "germogliamento" } + if (avanzamento < 0.4) return { kc: c.kc.sviluppo, fase: "sviluppo della chioma" } + if (avanzamento < 0.8) return { kc: c.kc.piena, fase: "piena vegetazione" } + return { kc: c.kc.finale, fase: "post-raccolta" } + } + + const f = c.fasiGiorni ?? { iniziale: 25, sviluppo: 35, piena: 45, finale: 25 } + const g = giorniDaImpianto + if (g < 0) return { kc: 0, fase: "non ancora messa a dimora" } + if (g <= f.iniziale) return { kc: c.kc.iniziale, fase: "attecchimento" } + if (g <= f.iniziale + f.sviluppo) { + // Nella fase di sviluppo il Kc sale in modo lineare fino al valore di piena copertura. + const t = (g - f.iniziale) / Math.max(f.sviluppo, 1) + return { kc: c.kc.iniziale + (c.kc.piena - c.kc.iniziale) * t, fase: "sviluppo" } + } + if (g <= f.iniziale + f.sviluppo + f.piena) return { kc: c.kc.piena, fase: "piena produzione" } + const t = Math.min((g - f.iniziale - f.sviluppo - f.piena) / Math.max(f.finale, 1), 1) + return { kc: c.kc.piena + (c.kc.finale - c.kc.piena) * t, fase: "maturazione" } +} + +/** + * Non tutta la pioggia entra nel terreno: i primi millimetri bagnano solo foglie e + * superficie ed evaporano, e i grandi acquazzoni scorrono via. + */ +function pioggiaEfficace(mm: number, sottoSerra: boolean): number { + if (sottoSerra) return 0 + if (mm <= 2) return 0 + const utile = (mm - 2) * 0.85 + return Math.min(utile, 40) +} + +function giorniTra(a: string, b: string): number { + return Math.round((Date.parse(b) - Date.parse(a)) / 86400000) +} + +export function calcolaConsiglio( + p: Piantagione, + c: Coltura, + aiuola: Aiuola, + eventi: Evento[], + meteo: GiornoMeteo[], + imp: Impostazioni +): Consiglio { + const oggi = isoOggi() + const inVaso = aiuola.contenitore === "vaso" + + // Dimensione del serbatoio. + const profondita = Math.min(c.profonditaRadici, inVaso ? 0.35 : c.profonditaRadici) + // In vaso il serbatoio non è il terreno ma il terriccio: circa il 30% del suo + // volume è acqua che la pianta può usare. Lo convertiamo in mm dividendo per la + // chioma che quel vaso può sostenere (regola pratica: ~1 m² ogni 50 litri di + // substrato), così il resto del calcolo resta identico alla piena terra. + const areaVaso = Math.min(Math.max((aiuola.litriVaso ?? 20) / 50, 0.05), areaPerPianta(c)) + const taw = inVaso && aiuola.litriVaso + ? (aiuola.litriVaso * 0.3) / areaVaso + : ACQUA_DISPONIBILE[aiuola.suolo] * profondita + const raw = taw * c.fattoreDeplezione + const soglia = sogliaIntervento(aiuola.metodo, raw) + + // Riduzioni: la pacciamatura taglia l'evaporazione dal suolo, la serra taglia + // vento e radiazione (ma esclude del tutto la pioggia). + const fattorePacciamatura = aiuola.pacciamatura ? 1 - imp.risparmioPacciamatura : 1 + const fattoreSerra = aiuola.serra ? 0.75 : 1 + + const irrigazioniPerData = new Map() + for (const e of eventi) { + if (e.tipo !== "irrigazione" || !e.mm) continue + if (e.piantagioneId ? e.piantagioneId !== p.id : e.aiuolaId !== aiuola.id) continue + irrigazioniPerData.set(e.data, (irrigazioniPerData.get(e.data) ?? 0) + e.mm) + } + const pioggiaManuale = new Map() + for (const e of eventi) { + if (e.tipo === "pioggia" && e.mm) pioggiaManuale.set(e.data, (pioggiaManuale.get(e.data) ?? 0) + e.mm) + } + + const efficienza = imp.efficienza[aiuola.metodo] ?? 0.8 + + // Bilancio giorno per giorno, partendo dal terreno a capacità di campo. + // Il punto di partenza è l'ultima irrigazione registrata: è l'unico momento in + // cui sappiamo con certezza che il terreno era pieno. Senza registrazioni + // risaliamo a tre settimane fa e lo diciamo apertamente all'utente, perché la + // stima resta un'ipotesi finché non arriva il primo dato vero. + // Vale come "serbatoio pieno" solo un'irrigazione abbondante: una bagnatura + // leggera non ricarica il profilo e non può fare da punto di partenza. + const daIrrigazioni = [...irrigazioniPerData.entries()] + .filter(([, mm]) => mm * efficienza >= raw * 0.6) + .map(([data]) => data) + + // Una sonda che legge la capacità di campo è l'ancoraggio migliore in assoluto: + // non è una stima di quanto abbiamo dato, è una misura di quanto c'è. + const daSonde = eventi + .filter( + (e) => + e.tipo === "sonda" && + e.unita === "kPa" && + typeof e.quantita === "number" && + e.quantita <= KPA_CAPACITA_DI_CAMPO && + (e.piantagioneId ? e.piantagioneId === p.id : e.aiuolaId === aiuola.id) + ) + .map((e) => e.data) + + const ultimaIrrigazione = [...daIrrigazioni, ...daSonde].sort().pop() ?? null + const inizio = [p.dataMessaADimora, ultimaIrrigazione].filter(Boolean).sort().pop() as string | undefined + + let deficit = 0 + const serie: Bilancio[] = [] + const mese = new Date().getMonth() + 1 + + for (const g of meteo) { + if (inizio && g.data < inizio) continue + if (g.data < p.dataMessaADimora && !c.perenne) continue + const eta = giorniTra(p.dataMessaADimora, g.data) + const meseGiorno = Number(g.data.slice(5, 7)) + const { kc: k } = kcDelGiorno(c, eta, meseGiorno) + const kcGiorno = inVaso ? Math.max(k, 0.35) : k + const etc = g.et0 * kcGiorno * fattorePacciamatura * fattoreSerra + const pio = (pioggiaManuale.get(g.data) ?? g.pioggia) + const peff = pioggiaEfficace(pio, aiuola.serra) + const irr = (irrigazioniPerData.get(g.data) ?? 0) * efficienza + + deficit = Math.min(Math.max(deficit + etc - peff - irr, 0), taw) + serie.push({ + data: g.data, + et0: g.et0, + kc: kcGiorno, + etc, + pioggia: pio, + pioggiaEfficace: peff, + irrigazione: irrigazioniPerData.get(g.data) ?? 0, + deficit, + previsione: g.previsione, + }) + if (g.data === oggi) break + } + + const etaOggi = giorniTra(p.dataMessaADimora, oggi) + const { kc: kcGrezzo, fase } = kcDelGiorno(c, etaOggi, mese) + // Una pianta in vaso non va mai in riposo davvero: il terriccio si asciuga + // comunque, e sotto una tettoia non prende nemmeno la pioggia invernale. + // Un agrume in vaso a gennaio beve poco, ma beve. + const kc = inVaso ? Math.max(kcGrezzo, 0.35) : kcGrezzo + const et0Oggi = meteo.find((g) => g.data === oggi)?.et0 ?? 3 + const etcOggi = et0Oggi * kc * fattorePacciamatura * fattoreSerra + const deficitOggi = serie.length ? serie[serie.length - 1].deficit : 0 + + // Proiezione sulle previsioni: quando il serbatoio scende sotto la soglia? + const futuri = meteo.filter((g) => g.data > oggi) + let d = deficitOggi + let giorniAllaProssima: number | null = null + const seriePrevista: Bilancio[] = [] + for (let i = 0; i < futuri.length; i++) { + const g = futuri[i] + const meseGiorno = Number(g.data.slice(5, 7)) + const { kc: kGrezzo } = kcDelGiorno(c, giorniTra(p.dataMessaADimora, g.data), meseGiorno) + const k = inVaso ? Math.max(kGrezzo, 0.35) : kGrezzo + const etc = g.et0 * k * fattorePacciamatura * fattoreSerra + const peff = pioggiaEfficace(g.pioggia, aiuola.serra) + d = Math.min(Math.max(d + etc - peff, 0), taw) + seriePrevista.push({ + data: g.data, et0: g.et0, kc: k, etc, pioggia: g.pioggia, + pioggiaEfficace: peff, irrigazione: 0, deficit: d, previsione: true, + }) + if (giorniAllaProssima === null && d >= soglia) giorniAllaProssima = i + 1 + } + + const pioggiaPrevista48h = futuri.slice(0, 2).reduce((s, g) => s + g.pioggia, 0) + const consumo = soglia > 0 ? Math.min((deficitOggi / soglia) * 100, 130) : 0 + + let stato: Stato + if (kc < 0.3) stato = "riposo" + else if (consumo >= 100) stato = "critico" + else if (consumo >= 70) stato = "attenzione" + else stato = "ottimale" + + // Decisione. La pioggia in arrivo può farci risparmiare un turno, ma solo se + // la pianta non è già oltre la soglia di stress. + const pioggiaSufficiente = pioggiaEfficace(pioggiaPrevista48h, aiuola.serra) >= deficitOggi * 0.8 + const irrigareOggi = stato !== "riposo" && consumo >= 90 && !(pioggiaSufficiente && consumo < 110) + + // Non ha senso proporre in un colpo solo più acqua di quanta il terreno ne + // trattenga senza perderla in profondità: oltre la riserva utile si irriga in + // due riprese, e il giorno dopo il calcolo lo ricorda da solo. + const terrenoMoltoAsciutto = deficitOggi > raw + const doseMm = irrigareOggi ? Math.min(Math.max(deficitOggi, 0), Math.max(soglia, raw * 0.55)) : 0 + // I litri si riferiscono alla superficie che questa coltura occupa davvero, non + // a tutta l'aiuola: in un cassone con insalata, pomodori e un melo le tre dosi + // sono diverse e vanno date in punti diversi. + const areaBagnata = inVaso + ? areaVaso * Math.max(p.quantita, 1) + : Math.min(Math.max(p.quantita, 1) * areaPerPianta(c), aiuola.superficie) + const doseLitri = (doseMm / efficienza) * areaBagnata + const minuti = minutiPerDose(aiuola, doseMm / efficienza) + + const litriGiornoPerPianta = etcOggi * (inVaso ? areaVaso : areaPerPianta(c)) + + let motivazione: string + if (stato === "riposo") { + motivazione = "Pianta in riposo vegetativo: consumo minimo, non serve irrigare." + } else if (irrigareOggi && terrenoMoltoAsciutto) { + motivazione = `Il terreno è ben oltre la soglia di stress: mancano ${deficitOggi.toFixed(0)} mm. Dai questi ${doseMm.toFixed(0)} mm in due riprese a distanza di un paio d'ore — tutti insieme scorrerebbero via invece di penetrare — e ripeti domani per finire di ricaricare.` + } else if (irrigareOggi) { + motivazione = `Il terreno è arrivato alla soglia in cui conviene intervenire: mancano ${doseMm.toFixed(0)} mm per riportarlo a posto.` + } else if (pioggiaSufficiente && pioggiaPrevista48h > 2) { + motivazione = `Attesi ${pioggiaPrevista48h.toFixed(0)} mm di pioggia entro 48 ore: coprono il fabbisogno, salta il turno e risparmia acqua.` + } else if (giorniAllaProssima !== null) { + motivazione = `Riserva ancora al ${Math.round(100 - consumo)}%. Con le previsioni attuali la prossima irrigazione servirà tra ${giorniAllaProssima} ${giorniAllaProssima === 1 ? "giorno" : "giorni"}.` + } else { + motivazione = `Riserva ancora al ${Math.round(100 - consumo)}%: nei prossimi 10 giorni, tra pioggia prevista e consumi, non dovrebbe servire irrigare.` + } + + return { + piantagioneId: p.id, + coltura: c, + aiuola, + eta: etaOggi, + fase, + kc, + etcOggi, + litriGiornoPerPianta, + taw, + raw, + soglia, + deficit: deficitOggi, + consumo, + stato, + irrigareOggi, + doseMm, + doseLitri, + minuti, + giorniAllaProssima, + pioggiaPrevista48h, + areaBagnata, + stimaProvvisoria: ultimaIrrigazione === null && etaOggi > 21, + motivazione, + serie: [...serie, ...seriePrevista], + } +} + +export function areaPerPianta(c: Coltura): number { + return 1 / Math.max(c.piantePerMq, 0.01) +} + +/** Momento migliore per bagnare, in base alla temperatura del giorno. */ +export function orarioConsigliato(tMax: number): string { + if (tMax >= 28) return "Prima delle 8 del mattino: a metà giornata perderesti fino al 40% per evaporazione, e l'acqua fredda su terreno rovente stressa le radici." + if (tMax >= 18) return "Mattina presto o dopo il tramonto. In estate meglio il mattino: le foglie asciugano in giornata e i funghi non attecchiscono." + return "Metà mattina, quando il terreno si è scaldato un po'. Mai la sera nei mesi freddi: l'acqua ferma di notte favorisce i marciumi." +} + +export const COLORI_STATO: Record = { + ottimale: "var(--ok)", + attenzione: "var(--warn)", + critico: "var(--danger)", + eccesso: "var(--info)", + riposo: "var(--muted-fg)", +} + +export const ETICHETTA_STATO: Record = { + ottimale: "Ben irrigata", + attenzione: "Da tenere d'occhio", + critico: "Irriga oggi", + eccesso: "Acqua in eccesso", + riposo: "In riposo", +} + +/** I turni lunghi si leggono meglio in ore: "3 h 20" invece di "200 min". */ +export function formattaDurata(minuti: number): string { + if (minuti < 90) return `${minuti} min` + const ore = Math.floor(minuti / 60) + const resto = minuti % 60 + return resto < 5 ? `${ore} h` : `${ore} h ${String(resto).padStart(2, "0")}` +} + +/** + * Da millimetri a minuti di valvola aperta. La portata si può dichiarare in due + * modi: per metro quadro (la si ricava dai gocciolatori) o come portata totale + * del settore, che si misura in un minuto con un secchio ed è quasi sempre il + * dato più affidabile. + */ +export function minutiPerDose(a: Aiuola, doseMmLorda: number): number | null { + if (a.portataSettoreLh && a.portataSettoreLh > 0) { + return Math.round(((doseMmLorda * a.superficie) / a.portataSettoreLh) * 60) + } + if (a.portataLh && a.portataLh > 0) return Math.round((doseMmLorda / a.portataLh) * 60) + return null +} diff --git a/src/lib/meteo.ts b/src/lib/meteo.ts new file mode 100644 index 0000000..120484f --- /dev/null +++ b/src/lib/meteo.ts @@ -0,0 +1,152 @@ +import type { Luogo } from "./types" + +/** + * Client Open-Meteo: nessuna chiave API, nessun account. + * Scarichiamo 21 giorni passati (per ricostruire il bilancio idrico reale del + * terreno) e 10 giorni di previsione (per anticipare la prossima irrigazione). + * Il risultato è messo in cache: l'app resta usabile anche offline in giardino. + */ + +export interface GiornoMeteo { + data: string + tMax: number + tMin: number + /** Pioggia caduta o prevista, mm. */ + pioggia: number + /** Probabilità di pioggia, % (solo previsione). */ + probPioggia: number | null + /** Evapotraspirazione di riferimento FAO-56, mm: quanta acqua evapora da un prato standard. */ + et0: number + codice: number + vento: number + umidita: number + previsione: boolean +} + +export interface DatiMeteo { + luogo: Luogo + aggiornato: number + giorni: GiornoMeteo[] +} + +const CACHE_KEY = "pollice-verde:meteo" +const VALIDITA_MS = 3 * 60 * 60 * 1000 + +export interface RisultatoGeocoding { + nome: string + regione: string + lat: number + lon: number +} + +export async function cercaLuogo(query: string): Promise { + const url = new URL("https://geocoding-api.open-meteo.com/v1/search") + url.searchParams.set("name", query) + url.searchParams.set("count", "8") + url.searchParams.set("language", "it") + url.searchParams.set("format", "json") + const r = await fetch(url) + if (!r.ok) throw new Error("Ricerca non riuscita") + const j = (await r.json()) as { results?: Array> } + return (j.results ?? []).map((x) => ({ + nome: String(x.name), + regione: [x.admin1, x.country].filter(Boolean).join(", "), + lat: Number(x.latitude), + lon: Number(x.longitude), + })) +} + +function leggiCache(): DatiMeteo | null { + try { + const raw = localStorage.getItem(CACHE_KEY) + return raw ? (JSON.parse(raw) as DatiMeteo) : null + } catch { + return null + } +} + +export function meteoInCache(luogo: Luogo | null): DatiMeteo | null { + const c = leggiCache() + if (!c || !luogo) return null + if (Math.abs(c.luogo.lat - luogo.lat) > 0.05 || Math.abs(c.luogo.lon - luogo.lon) > 0.05) return null + return c +} + +/** Scarica il meteo, ricadendo sulla cache se la rete non c'è. */ +export async function caricaMeteo(luogo: Luogo, forza = false): Promise { + const cache = meteoInCache(luogo) + if (cache && !forza && Date.now() - cache.aggiornato < VALIDITA_MS) return cache + + const url = new URL("https://api.open-meteo.com/v1/forecast") + url.searchParams.set("latitude", String(luogo.lat)) + url.searchParams.set("longitude", String(luogo.lon)) + url.searchParams.set( + "daily", + [ + "temperature_2m_max", + "temperature_2m_min", + "precipitation_sum", + "precipitation_probability_max", + "et0_fao_evapotranspiration", + "weather_code", + "wind_speed_10m_max", + "relative_humidity_2m_mean", + ].join(",") + ) + url.searchParams.set("past_days", "21") + url.searchParams.set("forecast_days", "10") + url.searchParams.set("timezone", "auto") + + try { + const r = await fetch(url) + if (!r.ok) throw new Error("Meteo non disponibile") + const j = (await r.json()) as { daily: Record & { time: string[] } } + const d = j.daily + const oggi = isoOggi() + const giorni: GiornoMeteo[] = d.time.map((data, i) => ({ + data, + tMax: num(d.temperature_2m_max[i]), + tMin: num(d.temperature_2m_min[i]), + pioggia: num(d.precipitation_sum[i]), + probPioggia: d.precipitation_probability_max[i] === null ? null : num(d.precipitation_probability_max[i]), + et0: num(d.et0_fao_evapotranspiration[i]), + codice: num(d.weather_code[i]), + vento: num(d.wind_speed_10m_max[i]), + umidita: num(d.relative_humidity_2m_mean[i]), + previsione: data > oggi, + })) + const dati: DatiMeteo = { luogo, aggiornato: Date.now(), giorni } + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(dati)) + } catch { + /* quota piena: pazienza, i dati restano in memoria */ + } + return dati + } catch (e) { + if (cache) return cache + throw e + } +} + +function num(v: number | null | undefined): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0 +} + +export function isoOggi(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` +} + +/** Descrizione testuale del codice meteo WMO. */ +export function descrizioneMeteo(codice: number): { testo: string; icona: string } { + if (codice === 0) return { testo: "Sereno", icona: "☀️" } + if (codice <= 2) return { testo: "Poco nuvoloso", icona: "🌤️" } + if (codice === 3) return { testo: "Coperto", icona: "☁️" } + if (codice <= 48) return { testo: "Nebbia", icona: "🌫️" } + if (codice <= 57) return { testo: "Pioviggine", icona: "🌦️" } + if (codice <= 67) return { testo: "Pioggia", icona: "🌧️" } + if (codice <= 77) return { testo: "Neve", icona: "🌨️" } + if (codice <= 82) return { testo: "Rovesci", icona: "🌧️" } + if (codice <= 86) return { testo: "Rovesci di neve", icona: "🌨️" } + return { testo: "Temporale", icona: "⛈️" } +} diff --git a/src/lib/programma.ts b/src/lib/programma.ts new file mode 100644 index 0000000..a932a82 --- /dev/null +++ b/src/lib/programma.ts @@ -0,0 +1,191 @@ +import type { Aiuola, Impostazioni } from "./types" +import type { Consiglio } from "./irrigazione" +import { minutiPerDose, orarioConsigliato } from "./irrigazione" +import type { GiornoMeteo } from "./meteo" +import { isoOggi } from "./meteo" + +/** + * Programma settimanale per una centralina a settori. + * + * Una centralina non ragiona per coltura ma per valvola: tutto ciò che sta sullo + * stesso settore riceve gli stessi millimetri negli stessi giorni. Quindi per + * ogni aiuola scegliamo la coltura che si asciuga prima — quella con meno giorni + * di autonomia — e programmiamo su di lei: le altre riceveranno un po' più + * acqua del necessario, che è l'errore giusto da fare. Il contrario, tarare + * sulla pianta più resistente, farebbe soffrire tutte le altre. + * + * I turni non sono a intervallo fisso: vengono simulati giorno per giorno sulle + * previsioni, così una pioggia in arrivo sposta il turno invece di sprecarlo. + */ + +export interface Turno { + data: string + mm: number + litri: number + minuti: number | null + pioggiaQuelGiorno: number +} + +export interface ProgrammaSettore { + aiuola: Aiuola + /** La coltura su cui è tarato il programma: la più esigente del settore. */ + guida: Consiglio + turni: Turno[] + /** Giorni fra un turno e l'altro, arrotondato. */ + intervallo: number | null + minutiTipici: number | null + mmTipici: number + orario: string + /** Pioggia prevista nei 10 giorni, mm. */ + pioggiaPrevista: number + /** Colture del settore che avrebbero bisogno di un ritmo molto diverso. */ + disallineate: { nome: string; autonomia: number }[] + avvisi: string[] +} + +/** Giorni di autonomia residua: quanto manca prima di toccare la soglia di stress. */ +function autonomia(c: Consiglio): number { + if (c.etcOggi <= 0.01) return 99 + return Math.max((c.soglia - c.deficit) / c.etcOggi, 0) +} + +function pioggiaEfficace(mm: number, serra: boolean): number { + if (serra) return 0 + if (mm <= 2) return 0 + return Math.min((mm - 2) * 0.85, 40) +} + +export function programmaSettore( + aiuola: Aiuola, + consigli: Consiglio[], + meteo: GiornoMeteo[], + imp: Impostazioni, + giorni = 10 +): ProgrammaSettore | null { + const attive = consigli.filter((c) => c.aiuola.id === aiuola.id && c.stato !== "riposo") + if (attive.length === 0) return null + + // La coltura guida è quella che si asciuga per prima. + const guida = [...attive].sort((a, b) => autonomia(a) - autonomia(b))[0] + + const oggi = isoOggi() + const futuri = meteo.filter((g) => g.data >= oggi).slice(0, giorni) + const efficienza = imp.efficienza[aiuola.metodo] ?? 0.8 + + const riduzione = (aiuola.pacciamatura ? 1 - imp.risparmioPacciamatura : 1) * (aiuola.serra ? 0.75 : 1) + + /** Simula i turni sui prossimi giorni partendo da un certo deficit iniziale. */ + const simula = (deficitIniziale: number, conPioggia: boolean): Turno[] => { + const out: Turno[] = [] + let deficit = deficitIniziale + for (const g of futuri) { + const etc = g.et0 * guida.kc * riduzione + deficit = Math.max(deficit + etc - (conPioggia ? pioggiaEfficace(g.pioggia, aiuola.serra) : 0), 0) + if (deficit >= guida.soglia) { + // Il turno riporta il terreno alla soglia, non oltre: dosi più grandi + // finirebbero sotto le radici senza essere usate. + const mm = Math.min(deficit, guida.soglia) + const lorda = mm / efficienza + out.push({ + data: g.data, + mm: Math.round(mm * 10) / 10, + litri: Math.round(lorda * aiuola.superficie), + minuti: minutiPerDose(aiuola, lorda), + pioggiaQuelGiorno: g.pioggia, + }) + deficit -= mm + } + } + return out + } + + // Due domande diverse, due simulazioni. I turni veri partono dall'acqua che c'è + // adesso nel terreno e includono il recupero dell'arretrato e le piogge + // previste. Il programma fisso da lasciare sulla centralina è invece il ritmo + // di regime: terreno in equilibrio e tempo asciutto, cioè il caso peggiore. + const turni = simula(guida.deficit, true) + const regime = simula(0, false) + + const intervallo = + regime.length >= 2 + ? Math.max(Math.round(giorniTra(regime[0].data, regime[regime.length - 1].data) / (regime.length - 1)), 1) + : regime.length === 1 + ? Math.max(Math.round(guida.soglia / Math.max(guida.etcOggi * riduzione, 0.1)), 1) + : null + + const base = regime.length ? regime : turni + const mmTipici = base.length ? base.reduce((s, t) => s + t.mm, 0) / base.length : 0 + const minutiTipici = base.length + ? Math.round(base.reduce((s, t) => s + (t.minuti ?? 0), 0) / base.length) || null + : null + + const tMax = Math.max(...futuri.map((g) => g.tMax), 0) + const pioggiaPrevista = futuri.reduce((s, g) => s + g.pioggia, 0) + + // Colture che su questo settore ricevono un ritmo molto diverso dal loro. + const autGuida = autonomia(guida) + const disallineate = attive + .filter((c) => c.piantagioneId !== guida.piantagioneId && autonomia(c) > Math.max(autGuida * 2.5, autGuida + 3)) + .map((c) => ({ nome: c.coltura.nome, autonomia: Math.round(autonomia(c)) })) + + const avvisi: string[] = [] + if (!aiuola.portataLh && !aiuola.portataSettoreLh) { + avvisi.push( + "Manca la portata del settore, quindi posso darti i litri ma non i minuti. Misurala così: apri la valvola per un minuto esatto raccogliendo l'acqua in un secchio graduato da uno dei gocciolatori terminali, moltiplica per il numero di gocciolatori e per 60. Oppure, più semplice: se il settore riempie un secchio da 10 litri in 40 secondi, sono 900 litri/ora." + ) + } + if (disallineate.length) { + avvisi.push( + `Su questo settore ${disallineate.map((d) => d.nome.toLowerCase()).join(" e ")} ${disallineate.length === 1 ? "avrebbe" : "avrebbero"} bisogno di turni molto più radi (${disallineate.map((d) => `${d.autonomia} giorni`).join(", ")} contro ${autGuida < 1 ? "meno di uno" : Math.round(autGuida)}). Il programma è tarato su ${guida.coltura.nome.toLowerCase()}, quindi ${disallineate.length === 1 ? "riceverà" : "riceveranno"} più acqua del necessario. Se puoi, spostale su un'altra valvola.` + ) + } + if (minutiTipici && minutiTipici > 180) { + // Quasi tutte le centraline domestiche permettono al massimo 3-4 orari di + // avvio per programma: oltre, il problema è la portata, non la programmazione. + const parti = Math.min(Math.ceil(minutiTipici / 120), 4) + const perParte = Math.round(minutiTipici / parti) + avvisi.push( + `Un turno da ${Math.round(minutiTipici / 60)} ore tutto di fila, su ${aiuola.superficie} m², rischia di scorrere via o di finire sotto le radici invece di restare dove serve. Spezzalo in ${parti} partenze da circa ${perParte} minuti, distanziate di un'ora — quasi tutte le centraline accettano più orari di avvio sullo stesso programma.` + + (perParte > 120 + ? " Restano comunque turni lunghissimi: il vero problema qui è la portata del settore, troppo bassa per la superficie. Valuta di sdoppiare il settore o di usare ala gocciolante con portata maggiore." + : "") + ) + } + if (guida.stimaProvvisoria) { + avvisi.push( + `Il primo turno è più abbondante degli altri perché recupera l'arretrato: non essendoci ancora irrigazioni registrate per ${guida.coltura.nome.toLowerCase()}, il calcolo presume tre settimane di sola pioggia. Registra la prima irrigazione e dal turno successivo le dosi si assestano.` + ) + } + if (aiuola.metodo === "aspersione") { + avvisi.push( + "Con l'aspersione un terzo dell'acqua evapora prima di arrivare a terra e le foglie restano bagnate: se puoi convertire questo settore a goccia, risparmi acqua e ti togli metà dei problemi fungini." + ) + } + if (pioggiaPrevista >= 15) { + avvisi.push( + `Nei prossimi ${giorni} giorni sono previsti ${pioggiaPrevista.toFixed(0)} mm di pioggia: i turni qui sotto ne tengono già conto. Se la centralina ha un sensore pioggia, lascialo attivo come rete di sicurezza.` + ) + } + + return { + aiuola, + guida, + turni, + intervallo, + minutiTipici, + mmTipici: Math.round(mmTipici * 10) / 10, + orario: orarioConsigliato(tMax), + pioggiaPrevista, + disallineate, + avvisi, + } +} + +function giorniTra(a: string, b: string): number { + return Math.round((Date.parse(b) - Date.parse(a)) / 86400000) +} + +/** Etichetta breve del giorno: "lun 8 set". */ +export function giornoBreve(iso: string): string { + return new Date(iso + "T12:00:00").toLocaleDateString("it-IT", { weekday: "short", day: "numeric", month: "short" }) +} diff --git a/src/lib/sonde.ts b/src/lib/sonde.ts new file mode 100644 index 0000000..28a262c --- /dev/null +++ b/src/lib/sonde.ts @@ -0,0 +1,76 @@ +import type { Aiuola, LetturaSonda, TipoSuolo } from "./types" + +/** + * Sonde Watermark (Irrometer 200SS). + * + * Misurano la **tensione idrica** in kPa, cioè la fatica che la radice fa per + * estrarre l'acqua: 0 significa terreno zuppo, 200 significa che l'acqua c'è + * ancora ma è trattenuta così forte che la pianta non la prende. È il parametro + * giusto, molto più dell'umidità percentuale, che senza conoscere il terreno non + * vuol dire niente: il 20% di umidità è fango in una sabbia e siccità in + * un'argilla, mentre 40 kPa vogliono dire la stessa cosa ovunque. + */ + +/** Soglie di intervento per tipo di terreno, in kPa. */ +export const SOGLIA_KPA: Record = { + sabbioso: 25, + "franco-sabbioso": 30, + franco: 40, + "franco-argilloso": 50, + argilloso: 60, +} + +/** Sotto questa tensione il terreno è alla capacità di campo: il serbatoio è pieno. */ +export const KPA_CAPACITA_DI_CAMPO = 15 + +export function sogliaDi(a: Aiuola): number { + return a.sogliaKpa ?? SOGLIA_KPA[a.suolo] +} + +export type LivelloSonda = "zuppo" | "umido" | "da-irrigare" | "secco" | "guasta" + +export function livello(lettura: LetturaSonda | undefined, soglia: number): LivelloSonda { + if (!lettura || lettura.salute !== "ok") return "guasta" + if (lettura.kpa <= 8) return "zuppo" + if (lettura.kpa < soglia) return "umido" + if (lettura.kpa < soglia * 1.6) return "da-irrigare" + return "secco" +} + +export const DESCRIZIONE_LIVELLO: Record = { + zuppo: "Terreno saturo: c'è più acqua di quanta le radici possano usare. Se dura più di un giorno o due è un problema di drenaggio.", + umido: "Riserva ancora sufficiente: non serve irrigare.", + "da-irrigare": "Il terreno ha raggiunto la soglia: è il momento di dare acqua.", + secco: "Ben oltre la soglia: la pianta sta già chiudendo gli stomi e ha smesso di crescere.", + guasta: "Nessuna lettura valida. Controlla il cavo e il collegamento della sonda.", +} + +export const COLORE_LIVELLO: Record = { + zuppo: "var(--info)", + umido: "var(--ok)", + "da-irrigare": "var(--warn)", + secco: "var(--danger)", + guasta: "var(--muted-fg)", +} + +/** + * Stima grossolana di quanta riserva è stata consumata, per confrontare la sonda + * col bilancio calcolato. È un'interpolazione lineare fra capacità di campo e + * soglia di stress: la curva vera del terreno non è lineare, quindi serve a + * capire se modello e realtà vanno d'accordo, non a sostituire il modello. + */ +export function consumoStimato(kpa: number, soglia: number): number { + if (kpa <= KPA_CAPACITA_DI_CAMPO) return 0 + return Math.min(((kpa - KPA_CAPACITA_DI_CAMPO) / (soglia - KPA_CAPACITA_DI_CAMPO)) * 100, 150) +} + +/** Quanto sonda e calcolo sono d'accordo, e cosa fare se non lo sono. */ +export function confronta(consumoCalcolato: number, kpa: number, soglia: number): string | null { + const misurato = consumoStimato(kpa, soglia) + const scarto = misurato - consumoCalcolato + if (Math.abs(scarto) < 35) return null + if (scarto > 0) { + return `La sonda legge ${kpa.toFixed(0)} kPa: il terreno è più asciutto di quanto il calcolo preveda. Se succede spesso, il consumo reale è più alto della stima — prova ad alzare l'efficienza del metodo in Impostazioni, o verifica che l'impianto eroghi davvero la portata dichiarata.` + } + return `La sonda legge ${kpa.toFixed(0)} kPa: il terreno è più umido di quanto il calcolo preveda. Può essere una falda, una zona d'ombra, o semplicemente la sonda piazzata proprio sotto un gocciolatore. Controlla dov'è infilata prima di correggere i parametri.` +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..27dbab8 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,216 @@ +/** Modello dati di Pollice Verde. Tutto è persistito in locale (localStorage). */ + +export type Categoria = 'ortaggio' | 'frutta' | 'aromatica' +export type Esposizione = 'pieno sole' | 'mezz\'ombra' | 'ombra luminosa' +export type Sensibilita = 'bassa' | 'media' | 'alta' + +/** Coefficienti colturali FAO-56: quanta acqua consuma la pianta rispetto al prato di riferimento. */ +export interface Kc { + iniziale: number + sviluppo: number + piena: number + finale: number +} + +/** Durata in giorni delle quattro fasi (solo colture annuali). */ +export interface FasiGiorni { + iniziale: number + sviluppo: number + piena: number + finale: number +} + +export interface Problema { + nome: string + sintomo: string + rimedio: string +} + +export interface Coltura { + id: string + nome: string + /** Altri nomi con cui la stessa pianta è conosciuta in giro per l'Italia. */ + sinonimi?: string[] + categoria: Categoria + /** Categorie aggiuntive in cui ha senso trovarla: l'anguria si coltiva nell'orto ma la si cerca fra la frutta. */ + ancheIn?: Categoria[] + famiglia: string + emoji: string + perenne: boolean + + /** Mesi 1-12. */ + seminaSemenzaio?: number[] + seminaDiretta?: number[] + trapianto?: number[] + raccolta: number[] + potatura?: number[] + /** Solo perenni: mesi di attività vegetativa [inizio, fine]. */ + stagioneVegetativa?: [number, number] + + kc: Kc + fasiGiorni?: FasiGiorni + /** Profondità delle radici attive, in metri: definisce il "serbatoio" di suolo utile. */ + profonditaRadici: number + /** Frazione di acqua disponibile consumabile prima di stressare la pianta (p FAO-56). */ + fattoreDeplezione: number + sensibilitaIdrica: Sensibilita + /** Fasi in cui la carenza d'acqua costa di più in resa. */ + fasiCritiche: string + + sesto: string + piantePerMq: number + esposizione: Esposizione + phIdeale: [number, number] + consociazioni: string[] + daEvitare: string[] + concimazione: string + cure: string[] + problemi: Problema[] + note?: string +} + +export type TipoSuolo = 'sabbioso' | 'franco-sabbioso' | 'franco' | 'franco-argilloso' | 'argilloso' +export type Metodo = 'goccia' | 'aspersione' | 'manuale' | 'subirrigazione' +export type Contenitore = 'piena terra' | 'vaso' | 'cassone rialzato' + +export interface Aiuola { + id: string + nome: string + suolo: TipoSuolo + metodo: Metodo + contenitore: Contenitore + /** m² della superficie coltivata. */ + superficie: number + pacciamatura: boolean + serra: boolean + /** Solo vaso: litri di terriccio per pianta. */ + litriVaso?: number + /** Portata dell'impianto in litri/ora per m² (goccia) — per stimare i minuti. */ + portataLh?: number + /** In alternativa: litri/ora erogati dall'intero settore. Più facile da misurare col secchio. */ + portataSettoreLh?: number + /** Ingresso della sonda Watermark che sorveglia questo settore (0, 1, 2…). */ + sensore?: number + /** Tensione idrica (kPa) oltre la quale il terreno è da irrigare. Se manca si usa il valore del tipo di suolo. */ + sogliaKpa?: number + /** Nome o numero della stazione sulla centralina, per ritrovarla quando programmi. */ + settore?: string + /** Uscita fisica sulla centralina (relè 1, 2, 3…). Se manca, il settore non viene mai aperto. */ + canale?: number + note?: string +} + +export interface Piantagione { + id: string + aiuolaId: string + colturaId: string + /** Data di semina o trapianto in campo (ISO yyyy-mm-dd). */ + dataMessaADimora: string + quantita: number + varieta?: string + /** Deficit idrico del suolo in mm, aggiornato dal motore. */ + note?: string + archiviata?: boolean +} + +export type TipoEvento = + | 'irrigazione' + | 'automazione' + | 'sonda' + | 'pioggia' + | 'concimazione' + | 'trattamento' + | 'potatura' + | 'raccolta' + | 'semina' + | 'nota' + +export interface Evento { + id: string + data: string + tipo: TipoEvento + aiuolaId?: string + piantagioneId?: string + /** Millimetri d'acqua (= litri/m²) per irrigazione e pioggia. */ + mm?: number + /** Vero se l'evento è stato registrato dall'agente della centralina, non da te. */ + automatico?: boolean + quantita?: number + unita?: string + testo?: string +} + +/** Una lettura di sonda, come arriva dal dispositivo. */ +export interface LetturaSonda { + /** Ingresso fisico sul dispositivo. */ + sensore: number + /** Tensione idrica in kPa (= centibar). 0 = zuppo, 200 = secchissimo. */ + kpa: number + /** Resistenza grezza in ohm, utile per accorgersi di un cavo staccato. */ + ohm: number + /** Momento della lettura (ISO). */ + quando: string + /** Diagnostica: "ok", "scollegata", "in corto", "fuori scala". */ + salute: string +} + +export interface Luogo { + nome: string + lat: number + lon: number +} + +export type DriverCentralina = 'simulato' | 'esp32' | 'shelly-gen2' | 'shelly-gen1' | 'tasmota' + +/** Blocchi che impediscono a un'automazione di fare danni mentre non guardi. */ +export interface Sicurezze { + /** Minuti massimi per singola apertura: oltre, il turno viene troncato. */ + minutiMassimiPerTurno: number + /** Litri massimi erogabili in un giorno su tutto il giardino. */ + litriMassimiAlGiorno: number + /** Sotto questa minima prevista non si irriga: acqua e gelo non vanno d'accordo. */ + bloccoGeloSotto: number + /** Sopra questo vento (km/h) si saltano i settori ad aspersione: l'acqua finisce altrove. */ + bloccoVentoSopra: number + /** Millimetri di pioggia prevista oggi oltre i quali si salta comunque il turno. */ + bloccoPioggiaSopra: number + /** Se una sonda dice che il terreno è ancora umido, il turno salta. */ + vetoSonde: boolean +} + +export interface Centralina { + /** Indirizzo dell'agente che comanda le valvole, es. http://192.168.1.50:8088 */ + urlAgente: string + /** Quando è false l'agente calcola e registra ma non apre nulla. */ + automazioneAttiva: boolean + driver: DriverCentralina + /** Indirizzo del dispositivo relè, es. 192.168.1.60 */ + host: string + /** Orario di partenza del programma, formato HH:MM. */ + orarioPartenza: string + /** Pausa fra un settore e il successivo, in minuti: la pressione deve riprendersi. */ + pausaFraSettori: number + /** Dispositivo che espone le sonde: se vuoto si usa lo stesso della centralina. */ + hostSensori: string + /** Ogni quanti minuti interrogare le sonde. I Watermark non vanno letti di continuo. */ + intervalloLetturaMinuti: number + sicurezze: Sicurezze +} + +export interface Impostazioni { + luogo: Luogo | null + /** Riduzione dell'evapotraspirazione sotto pacciamatura (0-1). */ + risparmioPacciamatura: number + /** Efficienza del metodo irriguo: quanta dell'acqua erogata arriva alle radici. */ + efficienza: Record + temaScuro: boolean | null + centralina: Centralina +} + +export interface Stato { + versione: number + impostazioni: Impostazioni + aiuole: Aiuola[] + piantagioni: Piantagione[] + eventi: Evento[] +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..337787b --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import App from "./App" +import "./index.css" + +createRoot(document.getElementById("root")!).render( + + + +) + +// Service worker: l'app resta apribile e consultabile anche senza rete in giardino. +if ("serviceWorker" in navigator && location.protocol !== "file:") { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch(() => { + /* niente offline, ma l'app funziona lo stesso */ + }) + }) +} diff --git a/src/pages/Acqua.tsx b/src/pages/Acqua.tsx new file mode 100644 index 0000000..267f401 --- /dev/null +++ b/src/pages/Acqua.tsx @@ -0,0 +1,260 @@ +import { useState } from "react" +import { useApp, id, oggi } from "../state/store" +import { useConsigli } from "../state/consigli" +import { COLORI_STATO, ETICHETTA_STATO, formattaDurata, orarioConsigliato } from "../lib/irrigazione" +import { Barra, GraficoBilancio, Modale, Vuoto } from "../components/comuni" +import { isoOggi } from "../lib/meteo" +import { confronta, sogliaDi } from "../lib/sonde" + +export default function Acqua() { + const { stato, meteo, dispatch } = useApp() + const consigli = useConsigli() + const [aperto, setAperto] = useState(null) + const [registra, setRegistra] = useState(null) + + const meteoOggi = meteo?.giorni.find((g) => g.data === isoOggi()) + const totaleOggi = consigli.filter((c) => c.irrigareOggi).reduce((s, c) => s + c.doseLitri, 0) + + if (consigli.length === 0) { + return ( + + ) + } + + return ( + <> +
+

Acqua

+

+ {totaleOggi > 0 + ? `Oggi servono circa ${Math.round(totaleOggi)} litri in tutto.` + : "Oggi non serve irrigare: il terreno ha ancora riserva."} +

+
+ + {meteoOggi && ( +
+
+
+
Evapotraspirazione oggi
+
{meteoOggi.et0.toFixed(1)}mm
+
+
+
Pioggia ultimi 7 giorni
+
+ {(meteo?.giorni.filter((g) => !g.previsione).slice(-7).reduce((s, g) => s + g.pioggia, 0) ?? 0).toFixed(0)} + mm +
+
+
+
Prevista 7 giorni
+
+ {(meteo?.giorni.filter((g) => g.previsione).slice(0, 7).reduce((s, g) => s + g.pioggia, 0) ?? 0).toFixed(0)} + mm +
+
+
+

+ L'evapotraspirazione è quanta acqua un prato di riferimento perde oggi per evaporazione e traspirazione: + {" "}{meteoOggi.et0.toFixed(1)} mm equivalgono a {meteoOggi.et0.toFixed(1)} litri per ogni metro quadro. Ogni coltura consuma + una frazione di questo valore, più o meno grande a seconda di quanto è sviluppata. +

+

+ Orario: {orarioConsigliato(meteoOggi.tMax)} +

+
+ )} + + {consigli.map((c) => ( +
+
+
+ {c.coltura.emoji} +
+

{c.coltura.nome}

+

{c.aiuola.nome} · {c.fase} · {c.eta} giorni

+
+
+ + {ETICHETTA_STATO[c.stato]} + +
+ +
+
+ Riserva prima del prossimo turno + {Math.max(Math.round(100 - c.consumo), 0)}% +
+ +
+ +

{c.motivazione}

+ + {(() => { + // Se questo settore ha una sonda, confrontiamo la misura col calcolo: + // quando i due non vanno d'accordo, è un'informazione da dare, non da + // nascondere dietro una media. + if (c.aiuola.sensore === undefined) return null + const lettura = stato.eventi + .filter((e) => e.tipo === "sonda" && e.aiuolaId === c.aiuola.id && e.unita === "kPa") + .sort((a, b) => b.data.localeCompare(a.data))[0] + if (!lettura?.quantita) return null + const soglia = sogliaDi(c.aiuola) + const nota = confronta(c.consumo, lettura.quantita, soglia) + return ( +

+ 🌡️ Sonda: {lettura.quantita} kPa su una soglia di {soglia}.{" "} + {nota ?? "La misura conferma il calcolo."} +

+ ) + })()} + + {c.stimaProvvisoria && ( +

+ Stima provvisoria: non c'è ancora nessuna irrigazione registrata per questa coltura, quindi il calcolo + presume tre settimane senza acqua a parte la pioggia. Registra la prima irrigazione e da lì in poi il + bilancio parte da un dato certo. +

+ )} + +
+
+
Consumo al giorno
+
{c.etcOggi.toFixed(1)}mm
+
+
+
Per pianta
+
{c.litriGiornoPerPianta.toFixed(1)}l/g
+
+ {c.irrigareOggi && c.minuti !== null && ( +
+
Impianto aperto
+
{formattaDurata(c.minuti)}
+
+ )} +
+
{c.irrigareOggi ? "Dose ora" : "Prossimo turno"}
+
+ {c.irrigareOggi + ? <>{Math.round(c.doseLitri)}litri + : c.giorniAllaProssima !== null + ? <>{c.giorniAllaProssima}{c.giorniAllaProssima === 1 ? "giorno" : "giorni"} + : oltre 10 giorni} +
+
+
+ +
+ + +
+ + {aperto === c.piantagioneId && ( +
+ +

+ La linea è l'acqua mancante nel terreno: sale quando la pianta consuma, scende quando piove o irrighi. + Sopra la linea tratteggiata rossa la pianta è in stress. I puntini in alto sono piogge (blu) e + irrigazioni registrate (verde). +

+ + + + + + + + + + +
Serbatoio utile del terreno{c.taw.toFixed(0)} mm ({(c.taw * c.aiuola.superficie).toFixed(0)} litri su tutta l'aiuola)
Riserva utile{c.raw.toFixed(0)} mm — oltre questa soglia la pianta va in stress ({Math.round(c.coltura.fattoreDeplezione * 100)}% dell'acqua del terreno)
Soglia di interventoa {c.soglia.toFixed(0)} mm di consumo — {c.aiuola.metodo === "goccia" || c.aiuola.metodo === "subirrigazione" ? "con la goccia conviene bagnare prima e meno per volta, tenendo il bulbo umido" : c.aiuola.metodo === "aspersione" ? "con l'aspersione si lascia un piccolo margine di sicurezza" : "irrigando a mano si sfrutta tutta la riserva, con turni radi e abbondanti"}
Superficie occupata{c.areaBagnata.toFixed(1)} m² dei {c.aiuola.superficie} m² dell'aiuola — è a questa che si riferiscono i litri
Profondità radici{c.coltura.profonditaRadici.toFixed(1)} m
Coefficiente colturale kc{c.kc.toFixed(2)}
Fase critica{c.coltura.fasiCritiche}
+
+ )} +
+ ))} + +
+

Come si irriga bene

+
    +
  • Poco e spesso è quasi sempre sbagliato. Bagnature superficiali tengono le radici nei primi 5 cm, dove il terreno si asciuga per primo. Meglio dosi abbondanti e turni distanziati: le radici scendono, e la pianta diventa autonoma.
  • +
  • Bagna il terreno, non la pianta. L'acqua sulle foglie fa evaporare metà di quello che dai e apre la porta a peronospora, oidio e botrite.
  • +
  • Verifica con il dito. Infila l'indice per due falangi: se esce asciutto e pulito è ora, se esce fresco e sporco puoi aspettare. Il calcolo dell'app è una stima molto buona, ma il terreno è lì.
  • +
  • Pacciama. Cinque centimetri di paglia o sfalcio secco riducono di un quarto l'acqua necessaria, tengono la temperatura del suolo stabile e soffocano le infestanti.
  • +
  • L'appassimento di mezzogiorno non è sete. Molte piante a foglia larga si afflosciano nelle ore calde e si riprendono al tramonto: è un meccanismo di difesa. Se sono ancora mosce la mattina presto, allora sì.
  • +
  • Troppa acqua uccide più della poca. Radici sott'acqua non respirano e marciscono. Foglie gialle e mosce con terreno bagnato = asfissia radicale, non sete.
  • +
+
+ + {registra && ( + c.piantagioneId === registra)!} + onChiudi={() => setRegistra(null)} + onSalva={(mm, litri) => { + const c = consigli.find((x) => x.piantagioneId === registra)! + dispatch({ + tipo: "evento/aggiungi", + evento: { + id: id(), data: oggi(), tipo: "irrigazione", piantagioneId: registra, aiuolaId: c.aiuola.id, + mm, quantita: litri, unita: "litri", + }, + }) + setRegistra(null) + }} + /> + )} + + ) +} + +function FormIrrigazione({ + consiglio, + onChiudi, + onSalva, +}: { + consiglio: ReturnType[number] + onChiudi: () => void + onSalva: (mm: number, litri: number) => void +}) { + const suggerita = Math.max(Math.round(Math.min(consiglio.deficit, consiglio.raw)), 1) + const [litri, setLitri] = useState(Math.round((suggerita * consiglio.areaBagnata) / 0.9)) + const mm = litri / consiglio.areaBagnata + + return ( + +

+ Quanti litri hai dato (o stai per dare) alla {consiglio.coltura.nome.toLowerCase()} in {consiglio.aiuola.nome}? + Il valore proposto ricarica la riserva utile sui {consiglio.areaBagnata.toFixed(1)} m² che occupa. +

+
+ +
+ Corrispondono a +
+ {mm.toFixed(1)} mm +
+
+
+
+ {[0.5, 1, 1.5].map((f) => ( + + ))} +
+ +
+ ) +} diff --git a/src/pages/Diario.tsx b/src/pages/Diario.tsx new file mode 100644 index 0000000..de006e4 --- /dev/null +++ b/src/pages/Diario.tsx @@ -0,0 +1,230 @@ +import { useMemo, useState } from "react" +import { useApp, id, oggi } from "../state/store" +import type { Evento, TipoEvento } from "../lib/types" +import { coltura } from "../lib/colture" +import { Campo, Modale, Vuoto } from "../components/comuni" +import { dataCompleta } from "../components/comuni" + +const TIPI: { v: TipoEvento; e: string; l: string }[] = [ + { v: "irrigazione", e: "💧", l: "Irrigazione" }, + { v: "pioggia", e: "🌧️", l: "Pioggia" }, + { v: "concimazione", e: "🌾", l: "Concimazione" }, + { v: "trattamento", e: "🧪", l: "Trattamento" }, + { v: "potatura", e: "✂️", l: "Potatura" }, + { v: "raccolta", e: "🧺", l: "Raccolta" }, + { v: "semina", e: "🌱", l: "Semina" }, + { v: "nota", e: "📝", l: "Nota" }, +] + +export default function Diario() { + const { stato, dispatch } = useApp() + const [nuovo, setNuovo] = useState(false) + const [filtro, setFiltro] = useState("tutti") + + const eventi = useMemo( + () => [...stato.eventi].filter((e) => filtro === "tutti" || e.tipo === filtro).sort((a, b) => b.data.localeCompare(a.data)), + [stato.eventi, filtro] + ) + + const raggruppati = useMemo(() => { + const m = new Map() + for (const e of eventi) { + const arr = m.get(e.data) ?? [] + arr.push(e) + m.set(e.data, arr) + } + return [...m.entries()] + }, [eventi]) + + const acquaMese = stato.eventi + .filter((e) => e.tipo === "irrigazione" && e.data.slice(0, 7) === oggi().slice(0, 7)) + .reduce((s, e) => { + if (e.unita === "litri" && e.quantita) return s + e.quantita + // Registrazioni vecchie o inserite a mano: risaliamo ai litri dai millimetri. + const a = stato.aiuole.find((x) => x.id === e.aiuolaId) + return s + (e.mm ?? 0) * (a?.superficie ?? 1) + }, 0) + + return ( + <> +
+
+

Diario

+

+ {stato.eventi.length} {stato.eventi.length === 1 ? "registrazione" : "registrazioni"} + {acquaMese > 0 && ` · ${Math.round(acquaMese)} litri erogati questo mese`} +

+
+ +
+ +
+ + {TIPI.map((t) => ( + + ))} +
+ + {raggruppati.length === 0 ? ( + setNuovo(true)}>Registra qualcosa} + /> + ) : ( + raggruppati.map(([data, lista]) => ( +
+

{dataCompleta(data)}

+
    + {lista.map((e) => { + const t = TIPI.find((x) => x.v === e.tipo)! + const p = e.piantagioneId ? stato.piantagioni.find((x) => x.id === e.piantagioneId) : null + const c = p ? coltura(p.colturaId) : null + const a = stato.aiuole.find((x) => x.id === e.aiuolaId) + return ( +
  • +
    + {t.e} +
    +
    + {t.l} + {e.mm !== undefined && ` · ${e.mm} mm`} + {e.quantita !== undefined && ` · ${e.quantita} ${e.unita ?? ""}`} +
    +
    + {[c?.nome, a?.nome].filter(Boolean).join(" · ") || "tutto il giardino"} + {e.testo && ` — ${e.testo}`} +
    +
    +
    + +
  • + ) + })} +
+
+ )) + )} + + {nuovo && setNuovo(false)} />} + + ) +} + +function FormEvento({ onChiudi }: { onChiudi: () => void }) { + const { stato, dispatch } = useApp() + const [tipo, setTipo] = useState("raccolta") + const [data, setData] = useState(oggi()) + const [aiuolaId, setAiuolaId] = useState(stato.aiuole[0]?.id ?? "") + const [piantagioneId, setPiantagioneId] = useState("") + const [mm, setMm] = useState(10) + const [quantita, setQuantita] = useState(1) + const [unita, setUnita] = useState("kg") + const [testo, setTesto] = useState("") + + const piante = stato.piantagioni.filter((p) => p.aiuolaId === aiuolaId && !p.archiviata) + const conAcqua = tipo === "irrigazione" || tipo === "pioggia" + const conQuantita = tipo === "raccolta" + + return ( + +
+ {TIPI.map((t) => ( + + ))} +
+ + + setData(e.target.value)} /> + + + {tipo !== "pioggia" && stato.aiuole.length > 0 && ( + <> + + + + {piante.length > 0 && ( + + + + )} + + )} + + {conAcqua && ( + + setMm(Number(e.target.value))} /> + + )} + + {conQuantita && ( +
+ + setQuantita(Number(e.target.value))} /> + + + + +
+ )} + + +