CatalogoScuola e PA ›Scuola e formazione

Orario scolastico e sostituzioni

L'orario si **genera da solo** dalle cattedre (con i vincoli veri: giorno libero, ore a disposizione, tetto di ore al giorno) e le assenze diventano un **piano sostituzioni** proposto per equità. Le griglie settimanali di classi e docenti sono pronte da stampare. Tu controlli e adotti: l'app propone, la persona decide. Tutto resta sul tuo dispositivo.

▶ Usa questa app

scuolaorariosostituzionisupplenzedocenticlassicattedrevincoliorario settimanalestampa

Cosa registra

  • italiano, storia
  • 1A
  • part-time, altra sede...
  • 2
  • 1
  • 2
  • matematica
  • lun,mar,mer,gio,ven
  • 6
  • 2
  • malattia, permesso...

Come funziona

Questa è un'app Reactive: un semplice documento di testo che il browser trasforma in un'app funzionante — moduli, dati dal vivo, tabelle, statistiche e persino grafici, senza installare né configurare nulla.

  1. Usala subito. Premi «Usa questa app»: si apre subito nel tuo browser, già pronta, con i dati salvati solo sul tuo dispositivo.
  2. I tuoi dati restano tuoi. Tutto è memorizzato in locale sul dispositivo, separato per ogni app: niente account, niente server, niente da configurare.
  3. Rendila tua. Il testo qui sotto è l'app per intero: copialo, cambia campi, viste e parole, e le modifiche diventano subito interattive.
  4. Condividila come file. Un'app è un semplice file: salvalo, esportalo o condividilo con un link; e con la collaborazione cifrata più persone modificano gli stessi dati in tempo reale.
Il testo dell'app
# 🏫 Orario scolastico e sostituzioni

L'orario si **genera da solo** dalle cattedre (con i vincoli veri: giorno
libero, ore a disposizione, tetto di ore al giorno) e le assenze diventano un
**piano sostituzioni** proposto per equità. Le griglie settimanali di classi e
docenti sono pronte da stampare. Tu controlli e adotti: l'app propone, la
persona decide. Tutto resta sul tuo dispositivo.

::::::page{title="Guida" icon="🧭"}
## Come funziona

:::::steps{}
1. **Dati di base** — inserisci docenti, classi e cattedre (o importale da Excel).
2. **Vincoli e priorità** — indisponibilità e ore a disposizione (rigide), più i desideri: niente ore buche, materie impegnative al mattino, materie distribuite sulla settimana.
3. **Generazione** — il generatore piazza tutte le ore rispettando i vincoli e inseguendo le priorità; rivedi la bozza e adottala.
4. **Moduli da stampare** — le griglie settimanali pronte, una per classe e una per docente.
5. **Assenze** — registri chi manca e quando.
6. **Sostituzioni** — il piano propone il supplente giusto per ogni ora scoperta; lo adotti nel registro.
:::::

:::::grid{cols="3"}
**Docenti**

:count[ndoc]{path="docenti"}

---

**Cattedre**

:count[ncat]{path="cattedre"}

---

**Ore in orario**

:count[nora]{path="orario"}
:::::

## Prova subito con dati di esempio

Una piccola scuola già pronta (12 docenti, 4 classi, 30 ore a settimana):
premi i tre pulsanti qui sotto, poi vai su **Generazione**.

:::::card{}
:::python{into="demo_docenti"}
```python
result = [
    {"nome": "Rossi",    "materie": "italiano, storia",    "giorno_libero": "mer", "max_ore_giorno": ""},
    {"nome": "Bianchi",  "materie": "italiano, storia",    "giorno_libero": "",    "max_ore_giorno": ""},
    {"nome": "Verdi",    "materie": "italiano, storia",    "giorno_libero": "lun", "max_ore_giorno": ""},
    {"nome": "Ferrari",  "materie": "matematica, scienze", "giorno_libero": "",    "max_ore_giorno": ""},
    {"nome": "Russo",    "materie": "matematica, scienze", "giorno_libero": "ven", "max_ore_giorno": ""},
    {"nome": "Esposito", "materie": "inglese",             "giorno_libero": "",    "max_ore_giorno": ""},
    {"nome": "Colombo",  "materie": "francese",            "giorno_libero": "gio", "max_ore_giorno": ""},
    {"nome": "Ricci",    "materie": "tecnologia",          "giorno_libero": "",    "max_ore_giorno": ""},
    {"nome": "Marino",   "materie": "arte",                "giorno_libero": "mar", "max_ore_giorno": ""},
    {"nome": "Greco",    "materie": "musica",              "giorno_libero": "",    "max_ore_giorno": ""},
    {"nome": "Bruno",    "materie": "motoria",             "giorno_libero": "",    "max_ore_giorno": ""},
    {"nome": "Gallo",    "materie": "religione",           "giorno_libero": "lun", "max_ore_giorno": ""},
]
print(f"{len(result)} docenti di esempio pronti.")
```
:::

::adopt{from="demo_docenti" path="docenti" key="nome" label="1. Carica i docenti di esempio"}

:::python{into="demo_classi"}
```python
result = [{"nome": c} for c in ["1A", "1B", "2A", "2B"]]
print("4 classi di esempio pronte.")
```
:::

::adopt{from="demo_classi" path="classi" key="nome" label="2. Carica le classi di esempio"}

:::python{into="demo_cattedre"}
```python
piano = {
    "italiano": (6, ["Rossi", "Bianchi", "Verdi", "Bianchi"]),
    "storia": (4, ["Verdi", "Rossi", "Bianchi", "Verdi"]),
    "matematica": (4, ["Ferrari", "Ferrari", "Russo", "Russo"]),
    "scienze": (2, ["Ferrari", "Ferrari", "Russo", "Russo"]),
    "inglese": (3, ["Esposito"] * 4),
    "francese": (2, ["Colombo"] * 4),
    "tecnologia": (2, ["Ricci"] * 4),
    "arte": (2, ["Marino"] * 4),
    "musica": (2, ["Greco"] * 4),
    "motoria": (2, ["Bruno"] * 4),
    "religione": (1, ["Gallo"] * 4),
}
classi = ["1A", "1B", "2A", "2B"]
result = []
for materia, (ore, prof) in piano.items():
    for i, classe in enumerate(classi):
        result.append({"docente": prof[i], "classe": classe, "materia": materia, "ore": str(ore)})
tot = sum(int(r["ore"]) for r in result) // len(classi)
print(f"{len(result)} cattedre di esempio pronte ({tot} ore a settimana per classe).")
```
:::

::adopt{from="demo_cattedre" path="cattedre" replace="true" label="3. Carica le cattedre di esempio"}
:::::

**Multi-utente.** Dal menu utenti in alto puoi attivare la sincronizzazione e
invitare la segreteria o i colleghi: stessa app, stessi dati, in tempo reale.
La **Griglia dati** (menu dati) è il foglio di lavoro per correzioni veloci.
::::::

::::::page{title="Dati di base" icon="📋"}
## Docenti

:::::grid{cols="2"}
::::form{id="fdoc"}
::input{form="fdoc" field="nome" legend="Nome e cognome" required="true"}
::input{form="fdoc" field="materie" legend="Materie" placeholder="italiano, storia"}
:::select{form="fdoc" field="giorno_libero" legend="Giorno libero (vuoto = nessuno)"}
- lun
- mar
- mer
- gio
- ven
- sab
:::
::input{form="fdoc" field="max_ore_giorno" legend="Max ore al giorno (vuoto = tutte)" type="number"}
::add-form{form="fdoc" path="docenti" label="Aggiungi docente"}
::::

---

:::table{path="docenti" headers="Docente,Materie,Giorno libero,Max ore/g" deletable="true" editform="fdoc"}
{nome} | {materie} | {giorno_libero} | {max_ore_giorno}
:::

::excel{path="docenti" key="nome" label="Importa docenti da Excel"}
:::::

## Classi

:::::grid{cols="2"}
::::form{id="fcla"}
::input{form="fcla" field="nome" legend="Classe" placeholder="1A" required="true"}
::add-form{form="fcla" path="classi" label="Aggiungi classe"}
::::

---

:::list{path="classi" deletable="true"}
{nome}
:::
:::::

## Cattedre (chi insegna cosa, a chi, per quante ore)

:::::grid{cols="2"}
::::form{id="fcat"}
::select-from{from="docenti" form="fcat" field="docente" label="nome" value="nome" legend="Docente"}
::select-from{from="classi" form="fcat" field="classe" label="nome" value="nome" legend="Classe"}
::input{form="fcat" field="materia" legend="Materia" required="true"}
::input{form="fcat" field="ore" legend="Ore a settimana" type="number" required="true"}
::input{form="fcat" field="blocco" legend="Ore consecutive (2 = laboratorio appaiato; vuoto = 1)" type="number"}
::add-form{form="fcat" path="cattedre" label="Aggiungi cattedra"}
::::

---

Totale ore da piazzare: :sum[totore]{path="cattedre" field="ore"}

::excel{path="cattedre" label="Importa cattedre da Excel"}
:::::

:::table{path="cattedre" headers="Docente,Classe,Materia,Ore,Consecutive" deletable="true" editform="fcat" pagesize="15"}
{docente} | {classe} | {materia} | {ore} | {blocco}
:::

::::::

::::::page{title="Vincoli e priorità" icon="🔒"}
I **vincoli rigidi** il generatore non li viola mai: se non riesce a
rispettarli tutti, lascia fuori delle ore e te lo dice. Le **priorità** sono
desideri: l'orario resta valido comunque, ma a parità di validità il
generatore sceglie la soluzione che le rispetta di più.

## Indisponibilità (vincolo rigido)

Le ore in cui un docente non è a scuola e non può ricevere lezioni: part-time,
incarichi esterni, altra sede. Diverse dalle ore a disposizione: qui il docente
non c'è, quindi non viene proposto nemmeno come supplente.

:::::grid{cols="2"}
::::form{id="find"}
::select-from{from="docenti" form="find" field="docente" label="nome" value="nome" legend="Docente"}
:::select{form="find" field="giorno" legend="Giorno"}
- lun
- mar
- mer
- gio
- ven
- sab
:::
::input{form="find" field="ora" legend="Ora (1, 2, 3...)" type="number" required="true"}
::input{form="find" field="motivo" legend="Motivo" placeholder="part-time, altra sede..."}
::add-form{form="find" path="indisponibilita" label="Aggiungi indisponibilità"}
::::

---

:::table{path="indisponibilita" headers="Docente,Giorno,Ora,Motivo" deletable="true" editform="find" pagesize="15"}
{docente} | {giorno} | {ora} | {motivo}
:::

::excel{path="indisponibilita" label="Importa da Excel"}
:::::

## Ore a disposizione (riservate alle supplenze)

Le ore in cui un docente è in sede, non insegna, e può coprire un'assenza. Il
generatore NON piazza lezioni lì; il piano sostituzioni le usa per prime.

:::::grid{cols="2"}
::::form{id="fdis"}
::select-from{from="docenti" form="fdis" field="docente" label="nome" value="nome" legend="Docente"}
:::select{form="fdis" field="giorno" legend="Giorno"}
- lun
- mar
- mer
- gio
- ven
- sab
:::
::input{form="fdis" field="ora" legend="Ora (1, 2, 3...)" type="number" required="true"}
::add-form{form="fdis" path="disposizioni" label="Aggiungi disposizione"}
::::

---

:::table{path="disposizioni" headers="Docente,Giorno,Ora" deletable="true" editform="fdis"}
{docente} | {giorno} | {ora}
:::
:::::

## Priorità: quanto contano (0 = ignora, 3 = tieni molto)

Vale l'ultima riga salvata. Vuoti = buche 2, fascia 1, distribuzione 2.

:::::grid{cols="2"}
::::form{id="fpes"}
::input{form="fpes" field="peso_buche" legend="Evita le ore buche dei docenti" type="number" placeholder="2" min="0" max="3"}
::input{form="fpes" field="peso_fascia" legend="Rispetta la fascia oraria delle materie" type="number" placeholder="1" min="0" max="3"}
::input{form="fpes" field="peso_distribuzione" legend="Distribuisci le materie sulla settimana" type="number" placeholder="2" min="0" max="3"}
::add-form{form="fpes" path="pesi" label="Salva le priorità"}
::::

---

:::list{path="pesi"}
buche: {peso_buche} — fascia: {peso_fascia} — distribuzione: {peso_distribuzione}
:::
:::::

## Fascia oraria preferita per materia (priorità)

Le materie impegnative nelle prime ore, quelle leggere in fondo. Conta solo se
il peso "fascia oraria" è maggiore di zero.

:::::grid{cols="2"}
::::form{id="fpre"}
::input{form="fpre" field="materia" legend="Materia" required="true" placeholder="matematica"}
:::select{form="fpre" field="fascia" legend="Preferisci"}
- prime ore
- ultime ore
:::
::add-form{form="fpre" path="preferenze" label="Aggiungi preferenza"}
::::

---

:::table{path="preferenze" headers="Materia,Fascia" deletable="true" editform="fpre"}
{materia} | {fascia}
:::
:::::
::::::

::::::page{title="Generazione" icon="⚙️"}
## Impostazioni

Salva una riga di impostazioni (vale l'ultima). Vuote = da lunedì a venerdì,
6 ore al giorno, max 2 ore della stessa materia al giorno.

:::::grid{cols="2"}
::::form{id="fimp"}
::input{form="fimp" field="giorni" legend="Giorni di lezione" placeholder="lun,mar,mer,gio,ven"}
::input{form="fimp" field="ore_giorno" legend="Ore al giorno" type="number" placeholder="6"}
::input{form="fimp" field="max_ore_materia_giorno" legend="Max ore stessa materia al giorno" type="number" placeholder="2"}
::add-form{form="fimp" path="impostazioni" label="Salva impostazioni"}
::::

---

:::list{path="impostazioni"}
giorni: {giorni} — ore/giorno: {ore_giorno} — max materia/giorno: {max_ore_materia_giorno}
:::
:::::

## Il generatore

Piazza ogni ora di cattedra rispettando i **vincoli rigidi** — mai un docente in
due classi alla stessa ora, una lezione per classe per ora, giorno libero,
indisponibilità, ore a disposizione intoccabili, tetto di ore al giorno, max ore
della stessa materia al giorno, blocchi di ore consecutive — e inseguendo le
**priorità** che hai scelto. Si rigenera da solo quando cambi cattedre, docenti,
vincoli o impostazioni; se qualche ora non entra, te lo dice esplicitamente, e
in fondo trovi un indice di qualità (ore buche). La bozza NON tocca l'orario
ufficiale finché non la adotti col pulsante.

:::python{data="cattedre,docenti,impostazioni,disposizioni,indisponibilita,preferenze,pesi" into="orario_bozza"}
```python
# Generatore d'orario: ricerca locale min-conflicts con vincoli RIGIDI e
# preferenze SOFT pesate. Un'"unità" è un blocco di 1 o 2 ore consecutive
# della stessa cattedra; si parte da un'assegnazione golosa e si riparano i
# conflitti spostando le unità nello slot di costo minimo.
#   RIGIDI (peso HARD, non negoziabili): docente mai in due classi alla stessa
#   ora, una lezione per classe per ora, giorno libero, indisponibilità, ore a
#   disposizione, max ore materia/giorno per classe, tetto ore/giorno docente.
#   SOFT (pesi 0-3 scelti dall'utente): ore buche del docente, fascia oraria
#   preferita per materia, distribuzione della materia sulla settimana.
import random

GIORNI_TUTTI = ["lun", "mar", "mer", "gio", "ven", "sab"]
HARD = 1000

def _num(v, dflt):
    try:
        return int(float(str(v).replace(",", ".")))
    except (ValueError, TypeError):
        return dflt

def _ultima(coll):
    righe = data.get(coll) or []
    return righe[-1] if righe else {}

imp = _ultima("impostazioni")
giorni = [g.strip().lower()[:3] for g in str(imp.get("giorni", "") or "lun,mar,mer,gio,ven").split(",")]
giorni = [g for g in giorni if g in GIORNI_TUTTI] or GIORNI_TUTTI[:5]
ore_giorno = max(1, min(10, _num(imp.get("ore_giorno"), 6)))
max_materia = max(1, _num(imp.get("max_ore_materia_giorno"), 2))

# Pesi delle preferenze (0 = ignora, 3 = tieni molto).
pesi = _ultima("pesi")
def _peso(campo, dflt):
    return max(0, min(3, _num(pesi.get(campo), dflt)))
W_BUCHE = _peso("peso_buche", 2)
W_FASCIA = _peso("peso_fascia", 1)
W_DISTR = _peso("peso_distribuzione", 2)

# Fascia preferita per materia: "prime" (o "mattino") / "ultime".
pref_fascia = {}
for r in data.get("preferenze", []):
    m = str(r.get("materia", "")).strip().lower()
    f = str(r.get("fascia", "")).strip().lower()
    if m and f:
        pref_fascia[m] = "prime" if f.startswith(("prim", "matt")) else "ultime"

docenti = {}
for d in data.get("docenti", []):
    nome = str(d.get("nome", "")).strip()
    if nome:
        docenti[nome] = d

def giorno_libero(doc):
    return str(docenti.get(doc, {}).get("giorno_libero", "")).strip().lower()[:3]

def max_doc(doc):
    n = _num(docenti.get(doc, {}).get("max_ore_giorno"), 0)
    return n if n > 0 else ore_giorno

# Slot in cui il docente NON può avere lezione: indisponibilità dichiarate
# (part-time, incarichi) + ore a disposizione (riservate alle supplenze).
vietati = set()
for coll in ("indisponibilita", "disposizioni"):
    for r in data.get(coll, []):
        doc = str(r.get("docente", "")).strip()
        g = str(r.get("giorno", "")).strip().lower()[:3]
        o = _num(r.get("ora"), 0)
        if doc and g and o:
            vietati.add((doc, g, o))

catt = []
for c in data.get("cattedre", []):
    doc = str(c.get("docente", "")).strip()
    cla = str(c.get("classe", "")).strip()
    mat = str(c.get("materia", "")).strip()
    n = _num(c.get("ore"), 0)
    blocco = max(1, min(_num(c.get("blocco"), 1), ore_giorno))
    if doc and cla and mat and n > 0:
        catt.append((doc, cla, mat, min(n, len(giorni) * ore_giorno), blocco))

# Un'unità = un blocco di `durata` ore consecutive della stessa cattedra.
unita = []
for doc, cla, mat, n, blocco in catt:
    nb = n // blocco if blocco > 1 else 0
    for _ in range(nb):
        unita.append((doc, cla, mat, blocco))
    for _ in range(n - nb * blocco):
        unita.append((doc, cla, mat, 1))

result = []

if not unita:
    print("Inserisci prima le cattedre (pagina Dati di base).")
else:
    N = len(unita)
    # Slot ammissibili per unità: il blocco deve stare nel giorno e ogni sua
    # ora deve essere libera dai vincoli STATICI (giorno libero, vietati).
    ammessi = []
    for doc, cla, mat, dur in unita:
        gl = giorno_libero(doc)
        ok = []
        for g in giorni:
            if g == gl:
                continue
            for o in range(1, ore_giorno - dur + 2):
                if all((doc, g, h) not in vietati for h in range(o, o + dur)):
                    ok.append((g, o))
        ammessi.append(ok or [(giorni[0], 1)])

    def buche(ore):
        if not ore:
            return 0
        return (max(ore) - min(ore) + 1) - len(ore)

    def risolvi(seed, iterazioni):
        rng = random.Random(seed)
        pos = [None] * N
        occ_doc, occ_cla, cnt_mat, cnt_dg = {}, {}, {}, {}
        ore_dg = {}  # (doc, giorno) -> {ora: quante volte}

        def metti(i, s):
            doc, cla, mat, dur = unita[i]
            g, o = s
            pos[i] = s
            ore = ore_dg.setdefault((doc, g), {})
            for h in range(o, o + dur):
                occ_doc[(doc, g, h)] = occ_doc.get((doc, g, h), 0) + 1
                occ_cla[(cla, g, h)] = occ_cla.get((cla, g, h), 0) + 1
                ore[h] = ore.get(h, 0) + 1
            cnt_mat[(cla, mat, g)] = cnt_mat.get((cla, mat, g), 0) + dur
            cnt_dg[(doc, g)] = cnt_dg.get((doc, g), 0) + dur

        def togli(i):
            doc, cla, mat, dur = unita[i]
            g, o = pos[i]
            ore = ore_dg.get((doc, g), {})
            for h in range(o, o + dur):
                occ_doc[(doc, g, h)] -= 1
                occ_cla[(cla, g, h)] -= 1
                if ore.get(h, 0) <= 1:
                    ore.pop(h, None)
                else:
                    ore[h] -= 1
            cnt_mat[(cla, mat, g)] -= dur
            cnt_dg[(doc, g)] -= dur
            pos[i] = None

        def costo_rigido(i, s):
            doc, cla, mat, dur = unita[i]
            g, o = s
            v = 0
            for h in range(o, o + dur):
                v += occ_doc.get((doc, g, h), 0) + occ_cla.get((cla, g, h), 0)
            if cnt_mat.get((cla, mat, g), 0) + dur > max_materia:
                v += 1
            if cnt_dg.get((doc, g), 0) + dur > max_doc(doc):
                v += 1
            return v

        def costo_soft(i, s):
            doc, cla, mat, dur = unita[i]
            g, o = s
            v = 0
            if W_DISTR:
                # ogni ora già presente della stessa materia quel giorno pesa:
                # spinge a distribuire la materia sulla settimana
                v += W_DISTR * cnt_mat.get((cla, mat, g), 0)
            if W_FASCIA:
                f = pref_fascia.get(mat.lower())
                if f == "prime":
                    v += W_FASCIA * sum(h - 1 for h in range(o, o + dur))
                elif f == "ultime":
                    v += W_FASCIA * sum(ore_giorno - h for h in range(o, o + dur))
            if W_BUCHE:
                ore = ore_dg.get((doc, g)) or {}
                prima = buche(ore)
                dopo = buche(set(ore) | set(range(o, o + dur)))
                v += W_BUCHE * max(0, dopo - prima)
            return v

        def costo(i, s):
            r = costo_rigido(i, s)
            return HARD * r + (costo_soft(i, s) if r == 0 else 0)

        def sposta(i):
            if pos[i] is not None:
                togli(i)
            cands = ammessi[i]
            migliore, cmin = None, None
            base = rng.randrange(len(cands))
            for k in range(len(cands)):
                s = cands[(base + k) % len(cands)]
                c = costo(i, s)
                if cmin is None or c < cmin:
                    migliore, cmin = s, c
                    if c == 0:
                        break
            metti(i, migliore)
            return cmin

        def limatura(passate):
            # Ottimizzazione delle sole preferenze SOFT a orario valido: sposta
            # un'unità solo se il costo cala davvero (hill climbing puro, quindi
            # i vincoli rigidi restano a zero e la qualità non peggiora mai).
            for _ in range(passate):
                mosse = 0
                ordine = list(range(N))
                rng.shuffle(ordine)
                for i in ordine:
                    attuale = pos[i]
                    togli(i)
                    c_att = costo(i, attuale)
                    if c_att == 0:
                        metti(i, attuale)
                        continue
                    scelto, c_best = attuale, c_att
                    for s in ammessi[i]:
                        c = costo(i, s)
                        if c < c_best:
                            scelto, c_best = s, c
                    metti(i, scelto)
                    if c_best < c_att:
                        mosse += 1
                if not mosse:
                    break

        ordine = sorted(range(N), key=lambda i: (len(ammessi[i]), rng.random()))
        for i in ordine:
            sposta(i)

        for it in range(iterazioni):
            confl = []
            for i in range(N):
                s = pos[i]
                togli(i)
                c = costo_rigido(i, s)
                metti(i, s)
                if c > 0:
                    confl.append(i)
            if not confl:
                break
            rng.shuffle(confl)
            for i in confl:
                if rng.random() < 0.08:
                    togli(i)
                    metti(i, rng.choice(ammessi[i]))
                else:
                    sposta(i)
            if it % 12 == 11:
                for i in rng.sample(range(N), max(1, N // 25)):
                    sposta(i)

        # Orario valido: lima le preferenze (se ne è stata chiesta almeno una).
        if W_BUCHE or W_FASCIA or W_DISTR:
            limatura(12)

        fuori = []
        for i in range(N):
            s = pos[i]
            togli(i)
            if costo_rigido(i, s) > 0:
                fuori.append(i)
            else:
                metti(i, s)
        tot_buche = sum(buche(set(o)) for o in ore_dg.values())
        return pos, fuori, tot_buche

    migliore = None
    for seed in (7, 23, 51):
        pos, fuori, tb = risolvi(seed, 140)
        if migliore is None or (len(fuori), tb) < (len(migliore[1]), migliore[2]):
            migliore = (pos, fuori, tb)
        if not fuori and tb == 0:
            break
    pos, fuori, tot_buche = migliore

    ordg = {g: k for k, g in enumerate(GIORNI_TUTTI)}
    righe = []
    appaiate_ok, appaiate_tot = 0, 0
    for i in range(N):
        if pos[i] is None:
            continue
        doc, cla, mat, dur = unita[i]
        g, o = pos[i]
        if dur > 1:
            appaiate_tot += 1
            appaiate_ok += 1  # per costruzione il blocco è consecutivo
        for h in range(o, o + dur):
            righe.append({"classe": cla, "giorno": g, "ora": str(h), "materia": mat, "docente": doc})
    righe.sort(key=lambda r: (r["classe"], ordg.get(r["giorno"], 9), int(r["ora"])))
    result = righe

    print(f"Orario generato: {len(righe)} ore, "
          f"{len({r['classe'] for r in righe})} classi, {len({r['docente'] for r in righe})} docenti.")
    if fuori:
        mancanti = {}
        for i in fuori:
            doc, cla, mat, dur = unita[i]
            k = f"{cla} · {mat} · {doc}"
            mancanti[k] = mancanti.get(k, 0) + dur
        print("ATTENZIONE — ore non piazzabili coi vincoli rigidi attuali:")
        for k, n in sorted(mancanti.items()):
            print(f"  - {k}: {n} ora/e")
        print("Allenta i vincoli (ore al giorno, giorno libero, indisponibilità) e rigenera.")
    else:
        print("Nessuna violazione dei vincoli rigidi.")
    print(f"Qualità: {tot_buche} ore buche dei docenti"
          + (f", {appaiate_ok}/{appaiate_tot} blocchi di ore consecutive rispettati" if appaiate_tot else ""))
    if not fuori:
        print("Controlla la bozza e adottala col pulsante qui sotto.")
```
:::

## Bozza proposta

:::table{path="orario_bozza" headers="Classe,Giorno,Ora,Materia,Docente" pagesize="15"}
{classe} | {giorno} | {ora} | {materia} | {docente}
:::

::adopt{from="orario_bozza" path="orario" replace="true" label="Adotta questo orario"}

Adottando, la bozza SOSTITUISCE l'orario ufficiale (quello delle pagine Orario
e Sostituzioni). Ritocchi puntuali dopo l'adozione: menu dati → Griglia dati.
::::::

::::::page{title="Orario" icon="🗓️"}
## Orario per classe

:::group-by{path="orario" field="classe" as="table" headers="Giorno,Ora,Materia,Docente"}
{giorno} | {ora} | {materia} | {docente}
:::

## Orario per docente

:::group-by{path="orario" field="docente" as="table" headers="Giorno,Ora,Classe,Materia"}
{giorno} | {ora} | {classe} | {materia}
:::

## Esplora (tabella pivot)

Trascina i campi per costruire la griglia che vuoi (es. righe = ora, colonne =
giorno) e filtra per classe o docente.

:::explore{path="orario" group-by="ora" split-by="giorno" height="28rem"}
:::
::::::

::::::page{title="Moduli da stampare" icon="🖨️"}
Le griglie settimanali pronte da appendere e distribuire: una per ogni classe
(da consegnare agli studenti) e una per ogni docente. Si aggiornano da sole
quando l'orario cambia. Il pulsante di stampa apre la finestra del browser, da
cui si salva anche in PDF.

## Orario delle classi (per studenti e famiglie)

:::python{data="orario,impostazioni" into="griglia_classi"}
```python
# Trasforma l'orario in una griglia settimanale per classe: una riga per ora,
# una colonna per giorno. La cella porta materia e docente.
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
ETICH = {"lun": "lun", "mar": "mar", "mer": "mer", "gio": "gio", "ven": "ven", "sab": "sab"}

imp = (data.get("impostazioni") or [{}])[-1] if data.get("impostazioni") else {}
try:
    ore_giorno = max(1, min(10, int(float(str(imp.get("ore_giorno", "6") or "6")))))
except ValueError:
    ore_giorno = 6

celle, ore_viste = {}, set()
for r in data.get("orario", []):
    cla = str(r.get("classe", "")).strip()
    g = str(r.get("giorno", "")).strip().lower()[:3]
    try:
        o = int(float(str(r.get("ora", "0"))))
    except ValueError:
        continue
    if not cla or g not in GIORNI or o < 1:
        continue
    mat = str(r.get("materia", "")).strip()
    doc = str(r.get("docente", "")).strip()
    celle[(cla, o, g)] = f"{mat} · {doc}" if doc else mat
    ore_viste.add(o)

result = []
if celle:
    ultima = max(max(ore_viste), ore_giorno)
    for cla in sorted({k[0] for k in celle}):
        for o in range(1, ultima + 1):
            riga = {"classe": cla, "ora": f"{o}ª"}
            for g in GIORNI:
                riga[ETICH[g]] = celle.get((cla, o, g), "")
            result.append(riga)
    print(f"Griglie pronte per {len({k[0] for k in celle})} classi.")
else:
    print("Genera e adotta prima l'orario (pagina Generazione).")
```
:::

:::::card{id="stampa-classi"}
:::group-by{path="griglia_classi" field="classe" as="table" headers="Ora,Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" stamps="false" search="false"}
{ora} | {lun} | {mar} | {mer} | {gio} | {ven} | {sab}
:::
:::::

::print{target="stampa-classi" label="Stampa l'orario delle classi"}

## Orario dei docenti

:::python{data="orario,impostazioni" into="griglia_docenti"}
```python
# Come sopra, ma per docente: la cella porta classe e materia. Le ore vuote
# sono le ore libere del docente.
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]

imp = (data.get("impostazioni") or [{}])[-1] if data.get("impostazioni") else {}
try:
    ore_giorno = max(1, min(10, int(float(str(imp.get("ore_giorno", "6") or "6")))))
except ValueError:
    ore_giorno = 6

celle, ore_viste = {}, set()
for r in data.get("orario", []):
    doc = str(r.get("docente", "")).strip()
    g = str(r.get("giorno", "")).strip().lower()[:3]
    try:
        o = int(float(str(r.get("ora", "0"))))
    except ValueError:
        continue
    if not doc or g not in GIORNI or o < 1:
        continue
    cla = str(r.get("classe", "")).strip()
    mat = str(r.get("materia", "")).strip()
    celle[(doc, o, g)] = f"{cla} · {mat}" if mat else cla
    ore_viste.add(o)

result = []
if celle:
    ultima = max(max(ore_viste), ore_giorno)
    for doc in sorted({k[0] for k in celle}):
        for o in range(1, ultima + 1):
            riga = {"docente": doc, "ora": f"{o}ª"}
            for g in GIORNI:
                riga[g] = celle.get((doc, o, g), "")
            result.append(riga)
    print(f"Griglie pronte per {len({k[0] for k in celle})} docenti.")
else:
    print("Genera e adotta prima l'orario (pagina Generazione).")
```
:::

:::::card{id="stampa-docenti"}
:::group-by{path="griglia_docenti" field="docente" as="table" headers="Ora,Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" stamps="false" search="false"}
{ora} | {lun} | {mar} | {mer} | {gio} | {ven} | {sab}
:::
:::::

::print{target="stampa-docenti" label="Stampa l'orario dei docenti"}
::::::

::::::page{title="Assenze" icon="🤒"}
## Registra un'assenza

:::::grid{cols="2"}
::::form{id="fass"}
::select-from{from="docenti" form="fass" field="docente" label="nome" value="nome" legend="Docente assente"}
::input{form="fass" field="data" legend="Data" type="date" required="true"}
::input{form="fass" field="ore" legend="Ore (es. 1,2 o 2-4; vuoto = tutto il giorno)"}
::input{form="fass" field="motivo" legend="Motivo" placeholder="malattia, permesso..."}
::add-form{form="fass" path="assenze" label="Registra assenza"}
::::

---

:::table{path="assenze" headers="Docente,Data,Ore,Motivo" deletable="true" editform="fass"}
{docente} | {data} | {ore} | {motivo}
:::
:::::

## Calendario assenze

:::calendar{path="assenze" field="data" view="week" by="docente"}
{docente} — ore {ore} ({motivo})
:::
::::::

::::::page{title="Sostituzioni" icon="🔄"}
## Piano sostituzioni proposto

Per ogni ora scoperta dalle assenze, il piano propone il supplente giusto:
prima chi è **a disposizione** in quell'ora, poi chi è in sede con l'**ora
libera** — mai chi è assente, sta insegnando o ha il giorno libero. A parità,
chi ha fatto meno sostituzioni (equità). Le ore già coperte nel registro
spariscono da sole dal piano.

:::python{data="assenze,orario,docenti,disposizioni,indisponibilita,sostituzioni" into="piano_sost"}
```python
# Piano sostituzioni: propone il supplente per ogni ora scoperta.
import datetime

GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab", "dom"]

def _n(v, d=0):
    try:
        return int(float(str(v).replace(",", ".")))
    except (ValueError, TypeError):
        return d

def ore_di(t):
    # "1,3" / "2-4" / vuoto o "tutte" = l'intera giornata
    t = str(t or "").strip().lower()
    if not t or t in ("tutte", "tutto", "giornata"):
        return None
    out = set()
    for p in t.replace(";", ",").split(","):
        p = p.strip()
        if "-" in p:
            a, _, b = p.partition("-")
            if _n(a) and _n(b):
                out.update(range(_n(a), _n(b) + 1))
        elif _n(p):
            out.add(_n(p))
    return out or None

docs = {str(d.get("nome", "")).strip(): d for d in data.get("docenti", []) if str(d.get("nome", "")).strip()}
gl = {n: str(d.get("giorno_libero", "")).strip().lower()[:3] for n, d in docs.items()}

lez_doc, occupato, in_sede = {}, set(), set()
for r in data.get("orario", []):
    doc = str(r.get("docente", "")).strip()
    g = str(r.get("giorno", "")).strip().lower()[:3]
    o = _n(r.get("ora"))
    if doc and g and o:
        lez_doc.setdefault((doc, g), []).append(r)
        occupato.add((doc, g, o))
        in_sede.add((doc, g))

# Indisponibilità: il docente NON è a scuola, quindi non è nemmeno supplente.
nonc = set()
for r in data.get("indisponibilita", []):
    doc = str(r.get("docente", "")).strip()
    g = str(r.get("giorno", "")).strip().lower()[:3]
    o = _n(r.get("ora"))
    if doc and g and o:
        nonc.add((doc, g, o))

adisp = {}
for r in data.get("disposizioni", []):
    doc = str(r.get("docente", "")).strip()
    g = str(r.get("giorno", "")).strip().lower()[:3]
    o = _n(r.get("ora"))
    if doc and g and o:
        adisp.setdefault((g, o), set()).add(doc)
        in_sede.add((doc, g))

fatte, coperte = {}, set()
for r in data.get("sostituzioni", []):
    s = str(r.get("sostituto", "")).strip()
    if s:
        fatte[s] = fatte.get(s, 0) + 1
    coperte.add((str(r.get("data", "")).strip(), _n(r.get("ora")), str(r.get("classe", "")).strip()))

scoperte, assenti_dt = [], {}
for a in data.get("assenze", []):
    doc = str(a.get("docente", "")).strip()
    dt = str(a.get("data", "")).strip()
    if not doc or not dt:
        continue
    assenti_dt.setdefault(dt, set()).add(doc)
    try:
        g = GIORNI[datetime.date.fromisoformat(dt).weekday()]
    except ValueError:
        continue
    ore = ore_di(a.get("ore"))
    for lez in lez_doc.get((doc, g), []):
        o = _n(lez.get("ora"))
        if ore is not None and o not in ore:
            continue
        if (dt, o, str(lez.get("classe", "")).strip()) in coperte:
            continue
        scoperte.append((dt, g, o, lez))

scoperte.sort(key=lambda s: (s[0], s[2]))
result, proposti = [], {}
for dt, g, o, lez in scoperte:
    assenti = assenti_dt.get(dt, set())
    usati = proposti.setdefault((dt, o), set())
    cands = []
    for nome in docs:
        if nome in assenti or nome in usati or gl.get(nome) == g:
            continue
        if (nome, g, o) in occupato or (nome, g, o) in nonc:
            continue
        if (nome, g) not in in_sede:
            continue
        a_disp = nome in adisp.get((g, o), set())
        cands.append((0 if a_disp else 1, fatte.get(nome, 0), nome,
                      "a disposizione" if a_disp else "ora libera"))
    cands.sort()
    sost, crit = "", "NESSUNO"
    if cands:
        _, _, sost, crit = cands[0]
        usati.add(sost)
        fatte[sost] = fatte.get(sost, 0) + 1
    result.append({
        "data": dt, "giorno": g, "ora": str(o),
        "classe": str(lez.get("classe", "")).strip(),
        "materia": str(lez.get("materia", "")).strip(),
        "assente": str(lez.get("docente", "")).strip(),
        "sostituto": sost, "criterio": crit,
        "alternative": ", ".join(n for _, _, n, _ in cands[1:4]),
    })

if result:
    senza = sum(1 for r in result if not r["sostituto"])
    print(f"Ore da coprire: {len(result)}" + (f" — {senza} SENZA candidato." if senza else ", tutte con un candidato."))
    print("Rivedi il piano e adottalo: le righe entrano nel registro sostituzioni.")
else:
    print("Nessuna ora scoperta: niente assenze, o sono già tutte coperte.")
```
:::

:::::card{id="piano-oggi"}
:::table{path="piano_sost" headers="Data,Ora,Classe,Materia,Assente,Sostituto,Criterio,Alternative" mark="criterio=NESSUNO:text-error font-bold" pagesize="20"}
{data} | {ora} | {classe} | {materia} | {assente} | {sostituto} | {criterio} | {alternative}
:::
:::::

::adopt{from="piano_sost" path="sostituzioni" label="Adotta il piano nel registro"}
::print{target="piano-oggi" label="Stampa il piano"}

## Registro sostituzioni

Il registro ufficiale: si riempie adottando il piano, oppure a mano (o con la
matita per correggere una proposta adottata, ad esempio cambiando il supplente).

:::::grid{cols="2"}
::::form{id="fsos"}
::input{form="fsos" field="data" legend="Data" type="date" required="true"}
::input{form="fsos" field="ora" legend="Ora" type="number" required="true"}
::select-from{from="classi" form="fsos" field="classe" label="nome" value="nome" legend="Classe"}
::select-from{from="docenti" form="fsos" field="assente" label="nome" value="nome" legend="Docente assente"}
::select-from{from="docenti" form="fsos" field="sostituto" label="nome" value="nome" legend="Sostituto"}
::input{form="fsos" field="criterio" legend="Nota (a disposizione, ora libera, pagata...)"}
::add-form{form="fsos" path="sostituzioni" label="Registra sostituzione"}
::::

---

Sostituzioni registrate: :count[nsos]{path="sostituzioni"}

Assenze registrate: :count[nass]{path="assenze"}
:::::

:::table{path="sostituzioni" headers="Data,Ora,Classe,Assente,Sostituto,Nota" deletable="true" editform="fsos" pagesize="15"}
{data} | {ora} | {classe} | {assente} | {sostituto} | {criterio}
:::
::::::

::::::page{title="Statistiche" icon="📊"}
## Il quadro per docente

Ore di lezione in orario, sostituzioni fatte e ore di assenza: il colpo
d'occhio per distribuire le supplenze con equità.

:::python{data="orario,sostituzioni,assenze,docenti" into="stat_docenti"}
```python
ore_lez, sost_fatte, ore_ass = {}, {}, {}
for r in data.get("orario", []):
    d = str(r.get("docente", "")).strip()
    if d:
        ore_lez[d] = ore_lez.get(d, 0) + 1
for r in data.get("sostituzioni", []):
    d = str(r.get("sostituto", "")).strip()
    if d:
        sost_fatte[d] = sost_fatte.get(d, 0) + 1
for r in data.get("assenze", []):
    d = str(r.get("docente", "")).strip()
    t = str(r.get("ore", "")).strip()
    n = len([p for p in t.replace(";", ",").split(",") if p.strip()]) if t else 6
    if "-" in t:
        try:
            a, b = t.split("-")[:2]
            n = max(n, int(b) - int(a) + 1)
        except ValueError:
            pass
    if d:
        ore_ass[d] = ore_ass.get(d, 0) + n
nomi = sorted({str(d.get("nome", "")).strip() for d in data.get("docenti", [])} - {""})
result = [{"docente": n, "ore_lezione": str(ore_lez.get(n, 0)),
           "sostituzioni_fatte": str(sost_fatte.get(n, 0)),
           "ore_assenza": str(ore_ass.get(n, 0))} for n in nomi]
print(f"{len(result)} docenti in quadro.")
```
:::

::chart-bar{data="stat_docenti" x="docente" y="sostituzioni_fatte" title="Sostituzioni fatte (equità)"}

::chart-bar{data="stat_docenti" x="docente" y="ore_assenza" title="Ore di assenza"}

:::table{path="stat_docenti" headers="Docente,Ore lezione,Sostituzioni fatte,Ore assenza"}
{docente} | {ore_lezione} | {sostituzioni_fatte} | {ore_assenza}
:::
::::::