From 78a920b4e265b964b21d3157a6a9289e126ce9b9 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 31 Jul 2026 15:36:27 +0200 Subject: [PATCH 01/12] =?UTF-8?q?Promo=20agosto=202026:=20nuovi=20prezzi,?= =?UTF-8?q?=20validit=C3=A0=201-19=20agosto=20e=20countdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prezzi aggiornati (One to One 200, Small Group 125, Reformer 115, Holistic 75) con migrazione guardata sul vecchio valore, così le righe già ritoccate dal pannello non vengono sovrascritte. I barrati restano invariati. Nota di validità con la finestra 1-19 agosto e countdown alla scadenza sopra le card: la deadline è un tag editabile, il blocco si nasconde da solo quando la promo è finita. Co-Authored-By: Claude Opus 5 (1M context) --- src/data/content-seed.ts | 19 ++++++++--- src/lib/db.ts | 15 +++++++++ src/pages/promo-eventi.astro | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/data/content-seed.ts b/src/data/content-seed.ts index 9fb716a..0c169ad 100644 --- a/src/data/content-seed.ts +++ b/src/data/content-seed.ts @@ -323,7 +323,7 @@ export const contentSeed: SeedEntry[] = [ // Card 1 — One to One T('promo.card.1.eyebrow', 'Personal training', []), T('promo.card.1.title', 'One to One'), - T('promo.card.1.price', '163 €', []), + T('promo.card.1.price', '200 €', []), T('promo.card.1.price-old', '300 €', []), T('promo.card.1.unit', '5 incontri', []), T('promo.card.1.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'), @@ -334,7 +334,7 @@ export const contentSeed: SeedEntry[] = [ T('promo.card.2.badge', 'Più scelto', []), T('promo.card.2.eyebrow', 'Personal training', []), T('promo.card.2.title', 'Small Group'), - T('promo.card.2.price', '110 €', []), + T('promo.card.2.price', '125 €', []), T('promo.card.2.price-old', '220 €', []), T('promo.card.2.unit', '5 incontri', []), T('promo.card.2.feature.1', 'Screening iniziale, definizione dei tuoi obiettivi'), @@ -344,7 +344,7 @@ export const contentSeed: SeedEntry[] = [ // Card 3 — Reformer T('promo.card.3.eyebrow', 'Pilates class', []), T('promo.card.3.title', 'Reformer'), - T('promo.card.3.price', '100 €', []), + T('promo.card.3.price', '115 €', []), T('promo.card.3.price-old', '140 €', []), T('promo.card.3.unit', '5 incontri', []), T('promo.card.3.feature.1', 'Core, postura e mobilità su macchina Reformer'), @@ -353,14 +353,23 @@ export const contentSeed: SeedEntry[] = [ // Card 4 — Holistic class T('promo.card.4.eyebrow', 'Holistic class', []), T('promo.card.4.title', 'Pilates Mat, Mobility, Yoga'), - T('promo.card.4.price', '50 €', []), + T('promo.card.4.price', '75 €', []), T('promo.card.4.price-old', '100 €', []), T('promo.card.4.unit', '5 incontri', []), T('promo.card.4.feature.1', 'Mobilità articolare, respirazione, equilibrio'), T('promo.card.4.feature.2', 'Da solo, o integrato nei tuoi allenamenti'), T('promo.card.4.feature.3', 'Adatto a tutti i livelli'), + // Countdown alla scadenza della promo. `deadline` è una data ISO con offset: la pagina la + // legge, la confronta col tempo del browser e nasconde tutto il blocco quando è passata + // (o se il valore non è una data valida). Editabile dal pannello per la finestra successiva. + T('promo.countdown.label', 'La promo scade tra', []), + T('promo.countdown.deadline', '2026-08-19T23:59:59+02:00', []), + T('promo.countdown.unit.days', 'giorni', []), + T('promo.countdown.unit.hours', 'ore', []), + T('promo.countdown.unit.minutes', 'minuti', []), + T('promo.countdown.unit.seconds', 'secondi', []), // Nota validità - T('promo.validity-note', 'Disponibile solo per l\'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []), + T('promo.validity-note', 'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026', []), // FAQ — le risposte usano il segnaposto {{whatsapp}}, sostituito a runtime con global.whatsapp T('promo.faq.title', 'Tutto quello che vuoi sapere prima di acquistare.'), T('promo.faq.1.q', 'Come funziona il pacchetto a incontri per One to One e Small Group?'), diff --git a/src/lib/db.ts b/src/lib/db.ts index 8fd6b09..705b557 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -177,6 +177,21 @@ export function createDb(path?: string): Database.Database { const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); }); updPromoTx(); + // Listino promo agosto 2026 (finestra 1–19 agosto): nuovi prezzi comunicati dal cliente e nota + // di validità con le date. I prezzi barrati (price-old) restano invariati. UPDATE guardato dal + // vecchio valore → idempotente e non sovrascrive eventuali ritocchi fatti dal pannello. + const PROMO_AGOSTO_2026: [string, string, string][] = [ + ['promo.card.1.price', '163 €', '200 €'], + ['promo.card.2.price', '110 €', '125 €'], + ['promo.card.3.price', '100 €', '115 €'], + ['promo.card.4.price', '50 €', '75 €'], + ['promo.validity-note', + "Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026", + 'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026'], + ]; + const updAgostoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_AGOSTO_2026) upd02.run(newV, tag, oldV); }); + updAgostoTx(); + // Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services, // H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance // Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello. diff --git a/src/pages/promo-eventi.astro b/src/pages/promo-eventi.astro index 5ee9c3a..ce053b9 100644 --- a/src/pages/promo-eventi.astro +++ b/src/pages/promo-eventi.astro @@ -21,6 +21,10 @@ const faqs = [1, 2, 3, 4, 5, 6]; // Numero WhatsApp centralizzato: le risposte FAQ contengono il segnaposto {{whatsapp}} // che qui viene sostituito col valore del tag globale, così si aggiorna in un punto solo. const wa = t('global.whatsapp'); +// Scadenza della promo (tag editabile dal pannello, formato ISO con offset). Viene solo passata +// al client: è il JS a decidere se mostrare il countdown, perché il confronto va fatto col tempo +// del visitatore e non con quello del render (la pagina è SSR ma può essere servita da cache). +const countdownDeadline = t('promo.countdown.deadline'); ---
@@ -47,6 +51,15 @@ const wa = t('global.whatsapp');
+
{cards.map((c) => (
@@ -88,6 +101,32 @@ const wa = t('global.whatsapp'); diff --git a/src/pages/piattaforme/stress-index/impostazioni.astro b/src/pages/piattaforme/stress-index/impostazioni.astro new file mode 100644 index 0000000..bf21a82 --- /dev/null +++ b/src/pages/piattaforme/stress-index/impostazioni.astro @@ -0,0 +1,121 @@ +--- +// Impostazioni: profilo, notifiche, account. I campi sono ancora un modulo dimostrativo — +// non c'è un endpoint di salvataggio — ma la struttura è quella definitiva. +import StressIndex from '../../../layouts/StressIndex.astro'; +import Card from '../../../components/stress-index/Card.astro'; +import { professional } from '../../../lib/stress-index/data'; +export const prerender = false; + +const [nome = '', cognome = ''] = professional.name.replace(/^Dr\.?\s*/i, '').split(' '); +const GIORNI = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica']; +const ORE = ['07:00', '08:00', '09:00', '18:00', '19:00', '20:00']; +const TABS = [ + { key: 'profilo', label: 'Profilo' }, + { key: 'notifiche', label: 'Notifiche' }, + { key: 'account', label: 'Account' }, +]; +--- + +
+

Impostazioni

+

Profilo, notifiche e account

+
+ +
+ {TABS.map((t, i) => ( + + ))} +
+ +
+ +
+ + + + + +
+
+ + +
+ + + + +
+

+
+
+ + + + +
+ + diff --git a/src/pages/piattaforme/stress-index/index.astro b/src/pages/piattaforme/stress-index/index.astro new file mode 100644 index 0000000..5345db2 --- /dev/null +++ b/src/pages/piattaforme/stress-index/index.astro @@ -0,0 +1,88 @@ +--- +// Dashboard "Oggi": ciò che il professionista deve guardare per primo — alert, clienti da +// ricontattare, note recenti. L'accesso è già filtrato dal middleware (ruolo piattaforme/admin). +import StressIndex from '../../../layouts/StressIndex.astro'; +import Card from '../../../components/stress-index/Card.astro'; +import Kpi from '../../../components/stress-index/Kpi.astro'; +import AlertRow from '../../../components/stress-index/AlertRow.astro'; +import AndamentoChart from '../../../components/stress-index/charts/AndamentoChart'; +import { + alerts, clients, measurementsByClient, notes, studioKpis, professional, OGGI, daysSince, +} from '../../../lib/stress-index/data'; +export const prerender = false; + +const prioritari = alerts.slice(0, 4); +const misurazioniOggi = Object.values(measurementsByClient).flat().filter((m) => m.date === OGGI).length; +const daContattare = [...clients] + .sort((a, b) => daysSince(b.lastMeasurement) - daysSince(a.lastMeasurement)) + .slice(0, 5); +const ultimeNote = [...notes] + .sort((a, b) => (a.when < b.when ? 1 : -1)) + .slice(0, 4) + .map((n) => ({ ...n, clientName: clients.find((c) => c.id === n.clientId)?.name })); +--- + +
+

Buongiorno, {professional.name.replace(/^Dr\.\s*/, '')}

+

Oggi, {OGGI}

+
+ +
+ + + + +
+ +
+
+ + + + + + {prioritari.map((a) => )} + + + +
    + {daContattare.map((c) => ( +
  • + + {c.name} + · ultima misurazione {daysSince(c.lastMeasurement)} giorni fa + + Scheda +
  • + ))} +
+
+
+ +
+ + {misurazioniOggi === 0 ? ( +

Nessuna misurazione registrata oggi. Le rilevazioni dei clienti compaiono qui appena arrivano.

+ ) : ( +

{misurazioniOggi}

+ )} +
+ + +
    + {ultimeNote.map((n) => ( +
  • +

    {n.text}

    +

    {n.clientName ?? 'Nota generale'} · {n.when}

    +
  • + ))} +
+
+
+
+
diff --git a/src/pages/piattaforme/stress-index/organizzazione.astro b/src/pages/piattaforme/stress-index/organizzazione.astro new file mode 100644 index 0000000..cef63cd --- /dev/null +++ b/src/pages/piattaforme/stress-index/organizzazione.astro @@ -0,0 +1,33 @@ +--- +// Organizzazione: raggruppa più professionisti sotto un'unica struttura. Finché non c'è +// il backend, il modulo raccoglie solo il nome e il pulsante resta disabilitato a vuoto. +import StressIndex from '../../../layouts/StressIndex.astro'; +export const prerender = false; +--- + +
+
+

Crea la tua organizzazione

+

+ Raggruppa più professionisti sotto un'unica organizzazione per condividere clienti, + dati e impostazioni dello studio. +

+ +

+ +

+
+
+
+ + diff --git a/src/pages/piattaforme/stress-index/sport/index.astro b/src/pages/piattaforme/stress-index/sport/index.astro new file mode 100644 index 0000000..7e389ee --- /dev/null +++ b/src/pages/piattaforme/stress-index/sport/index.astro @@ -0,0 +1,121 @@ +--- +// Modulo Sport: carico di lavoro e sessioni degli atleti seguiti. Le tre viste sono già +// tutte renderizzate: il cambio scheda è solo un mostra/nascondi, non serve un'isola. +import StressIndex from '../../../../layouts/StressIndex.astro'; +import Card from '../../../../components/stress-index/Card.astro'; +import Kpi from '../../../../components/stress-index/Kpi.astro'; +import DataChart from '../../../../components/stress-index/charts/DataChart'; +import { sessions, athletes, sessionsPerWeek } from '../../../../lib/stress-index/data'; +export const prerender = false; + +const trimpMedio = Math.round(sessions.reduce((s, x) => s + x.trimp, 0) / sessions.length); +const sessioniSettimana = sessionsPerWeek[sessionsPerWeek.length - 1]?.count ?? 0; +const nomeAtleta = (id: string) => athletes.find((a) => a.clientId === id)?.name ?? id; +const TABS = [ + { key: 'dashboard', label: 'Dashboard' }, + { key: 'sessioni', label: 'Sessioni' }, + { key: 'atleti', label: 'Atleti' }, +]; +--- + +
+

Sport

+

Sessioni, carico e monitoraggio degli atleti

+
+ +
+ {TABS.map((t, i) => ( + + ))} +
+ +
+
+ + + + +
+ + + + + + +
+ + + + {sessions.map((s) => ( + + + + + + + + + + ))} + +
AtletaDataSportDurataTRIMPHR medioDFA A1
{nomeAtleta(s.athleteId)}{s.date}{s.type}{s.durationMin} min{s.trimp}{s.hrAvg}{s.dfaA1}
+
+
+
+ + + + +
+ + diff --git a/src/pages/piattaforme/stress-index/sport/team-live.astro b/src/pages/piattaforme/stress-index/sport/team-live.astro new file mode 100644 index 0000000..e3d36c1 --- /dev/null +++ b/src/pages/piattaforme/stress-index/sport/team-live.astro @@ -0,0 +1,43 @@ +--- +// Monitoraggio live: finché non arriva la sorgente dati in tempo reale la vista resta +// vuota per costruzione, e spiega cosa succederà quando un atleta avvia una sessione. +import StressIndex from '../../../../layouts/StressIndex.astro'; +export const prerender = false; + +const PASSI = [ + "L'atleta avvia una sessione dall'app Stress Index collegata alla fascia cardio.", + 'I dati di frequenza cardiaca e DFA A1 arrivano in tempo reale a questa vista.', + "La scheda dell'atleta compare qui da sola, con le metriche aggiornate in continuo.", + 'A fine sessione la scheda si chiude e il riepilogo confluisce nello storico Sport.', +]; +--- + +
+
+

Team live

+

Nessun atleta in sessione

+
+ +
+ +
+

Nessun atleta in sessione

+

Quando un atleta avvia una sessione dall'app, la sua scheda compare qui in tempo reale.

+ +
+

Come funziona

+
    + {PASSI.map((p, i) => ( +
  1. {i + 1}{p}
  2. + ))} +
+
+
+
diff --git a/src/styles/stress-index.css b/src/styles/stress-index.css new file mode 100644 index 0000000..e58cbeb --- /dev/null +++ b/src/styles/stress-index.css @@ -0,0 +1,287 @@ +/* Stile della piattaforma Stress Index. + Vive in un foglio globale, non in diff --git a/src/styles/stress-index.css b/src/styles/stress-index.css index e58cbeb..4a8ec2a 100644 --- a/src/styles/stress-index.css +++ b/src/styles/stress-index.css @@ -45,14 +45,6 @@ padding: 1px 7px; font-size: .58rem; letter-spacing: .1em; } -.si-foot { - display: flex; flex-direction: column; gap: 9px; margin-top: auto; padding-top: 20px; - border-top: 1px solid rgba(255,255,255,.12); -} -.si-foot__role { font-size: .74rem; letter-spacing: .1em; text-transform: uppercase; color: var(--c-accent); } -.si-foot a { font-size: .74rem; letter-spacing: .1em; text-transform: uppercase; color: #8d8379; text-decoration: none; } -.si-foot a:hover { color: #fff; } - /* --- shell contenuto --- */ .si-body { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 100vh; } .si-top { @@ -285,3 +277,34 @@ .si-org p { margin: 0; } .si-org .si-field { text-align: left; } .si-org__cta:disabled { opacity: .45; cursor: not-allowed; } + +/* --- analytics: intervallo e classifiche --- */ +.si-range { display: flex; align-items: center; gap: 8px; } +.si-range__sep { color: var(--c-text-light); } +.si-range input { padding: 7px 10px; } +.si-card__head p { margin: 4px 0 0; } +.si-rank { list-style: none; margin: 0; padding: 0; } +.si-rank li { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--si-line); } +.si-rank li:last-child { border-bottom: 0; } +.si-rank a { text-decoration: none; color: var(--c-heading); font-weight: 600; font-size: .9rem; } +.si-rank a:hover { color: var(--c-accent-dark); } +.si-rank__value { display: block; text-align: right; font-family: var(--font-heading); font-size: 1.05rem; } +.si-rank__value--ok { color: var(--si-ok); } +.si-rank__value--bad { color: var(--si-bad); } +.si-rank__count { display: block; text-align: right; font-size: .72rem; color: var(--c-text-light); } + +/* --- modulo sport --- */ +.si-badge--pro { background: var(--c-accent); color: #fff; vertical-align: middle; margin-left: 8px; } +.si-filters { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; } +.si-filters__count { margin: 0 0 8px auto; } +.si-athlete__name { font-size: 1.05rem; text-transform: none; letter-spacing: .02em; margin: 0 0 2px; } +.si-athlete__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.si-athlete__stats > div { background: var(--c-bg-alt); padding: 14px 10px; text-align: center; } +.si-athlete__stats p { margin: 0; } +.si-athlete__num { font-family: var(--font-heading); font-size: 1.3rem; color: var(--c-heading); } +.si-athlete__foot { + display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; + padding-top: 14px; border-top: 1px solid var(--si-line); text-decoration: none; + font-size: .82rem; color: var(--c-text-light); +} +.si-athlete__foot:hover { color: var(--c-accent-dark); } diff --git a/tests/stress-index-analytics.test.ts b/tests/stress-index-analytics.test.ts new file mode 100644 index 0000000..678dbaa --- /dev/null +++ b/tests/stress-index-analytics.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + fascia, misurazioniNelPeriodo, kpis, distribuzione, classificaRecupero, confrontoSegmenti, +} from '../src/lib/stress-index/analytics'; +import type { Alert, Client, Measurement } from '../src/lib/stress-index/data'; + +const PERIODO = { from: '2026-07-01', to: '2026-07-31' }; + +const misura = (date: string, stress: number, recovery: number): Measurement => + ({ date, stress, recovery, balance: 50, energy: 50, type: 'Riposo', durationMin: 20 }); + +const cliente = (id: string, name: string, sex: 'M' | 'F', age: number): Client => + ({ id, name, initials: 'XX', age, sex, tags: ['test'], email: `${id}@esempio.it`, + since: '2024-01-01', lastMeasurement: '2026-07-10', stress: 50, alerts: 0 }); + +const clients = [cliente('a', 'A. Uno', 'M', 30), cliente('b', 'B. Due', 'F', 55), cliente('c', 'C. Tre', 'F', 40)]; +const byClient: Record = { + // dentro il periodo + a: [misura('2026-07-05', 10, 90), misura('2026-07-12', 30, 70)], + // una dentro, una fuori + b: [misura('2026-06-20', 80, 20), misura('2026-07-20', 90, 30)], + // tutte fuori periodo + c: [misura('2026-05-01', 50, 50)], +}; +const alerts: Alert[] = [ + { clientId: 'a', clientName: 'A. Uno', text: 'x', severity: 'warn', when: '2026-07-03' }, + { clientId: 'b', clientName: 'B. Due', text: 'y', severity: 'error', when: '2026-06-01' }, +]; + +describe('fasce di score', () => { + it('assegna gli estremi alla fascia giusta', () => { + expect(fascia(0)).toBe('0-20'); + expect(fascia(20)).toBe('0-20'); + expect(fascia(21)).toBe('21-40'); + expect(fascia(60)).toBe('41-60'); + expect(fascia(81)).toBe('81-100'); + expect(fascia(100)).toBe('81-100'); + }); +}); + +describe('filtro sul periodo', () => { + it('tiene solo le misurazioni comprese fra le due date, estremi inclusi', () => { + const dentro = misurazioniNelPeriodo(byClient, PERIODO); + expect(dentro.a.map((m) => m.date)).toEqual(['2026-07-05', '2026-07-12']); + expect(dentro.b.map((m) => m.date)).toEqual(['2026-07-20']); + expect(dentro.c).toEqual([]); + }); +}); + +describe('kpi', () => { + it('conta clienti attivi, misurazioni, alert del periodo e aderenza', () => { + expect(kpis(clients, byClient, alerts, PERIODO)).toEqual([ + { label: 'Clienti attivi', value: 2, note: '/ 3 totali' }, + { label: 'Misurazioni', value: 3, note: 'nel periodo' }, + { label: 'Alert attivi', value: 1, note: 'nel periodo' }, + { label: 'Aderenza', value: '67%', note: 'hanno misurato' }, + ]); + }); +}); + +describe('distribuzione per fascia', () => { + it('conta ogni metrica nella propria fascia', () => { + const righe = distribuzione(byClient, PERIODO); + const perFascia = Object.fromEntries(righe.map((r) => [r.range, r])); + // stress: 10 → 0-20, 30 → 21-40, 90 → 81-100 + expect(perFascia['0-20'].stress).toBe(1); + expect(perFascia['21-40'].stress).toBe(1); + expect(perFascia['81-100'].stress).toBe(1); + // recovery: 90 → 81-100, 70 → 61-80, 30 → 21-40 + expect(perFascia['81-100'].recovery).toBe(1); + expect(perFascia['61-80'].recovery).toBe(1); + expect(perFascia['21-40'].recovery).toBe(1); + // le tre misurazioni hanno tutte balance ed energy a 50 + expect(perFascia['41-60'].balance).toBe(3); + expect(perFascia['41-60'].energy).toBe(3); + }); +}); + +describe('classifica per recupero', () => { + it('ordina dal recupero medio più alto e ignora chi non ha misurato', () => { + expect(classificaRecupero(clients, byClient, PERIODO)).toEqual([ + { clientId: 'a', name: 'A. Uno', recovery: 80, count: 2 }, + { clientId: 'b', name: 'B. Due', recovery: 30, count: 1 }, + ]); + }); +}); + +describe('confronto segmenti', () => { + it('media stress e recupero per sesso', () => { + expect(confrontoSegmenti(clients, byClient, PERIODO, 'sesso')).toEqual([ + { gruppo: 'Uomini', stress: 20, recovery: 80 }, + { gruppo: 'Donne', stress: 90, recovery: 30 }, + ]); + }); + + it('raggruppa per fascia di età', () => { + expect(confrontoSegmenti(clients, byClient, PERIODO, 'eta')).toEqual([ + { gruppo: 'Fino a 34', stress: 20, recovery: 80 }, + { gruppo: '50 e oltre', stress: 90, recovery: 30 }, + ]); + }); +}); diff --git a/tests/stress-index-sport.test.ts b/tests/stress-index-sport.test.ts new file mode 100644 index 0000000..c1a57b0 --- /dev/null +++ b/tests/stress-index-sport.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { durata, dataBreve, dataOra, ultimi, kpiSport, perSettimana, riepilogoAtleti } from '../src/lib/stress-index/sport'; +import type { Athlete, Session } from '../src/lib/stress-index/data'; + +const RIF = '2026-07-12'; + +const sess = (id: string, athleteId: string, startedAt: string, trimp: number): Session => + ({ id, athleteId, startedAt, durationSec: 1800, sport: null, hrAvg: 120, hrMax: 150, trimp, dfaA1: 1.2, rpe: 5 }); + +const athletes: Athlete[] = [ + { clientId: 'a', name: 'A. Uno', sport: 'Corsa' }, + { clientId: 'b', name: 'B. Due', sport: null }, +]; + +// Ordinate dalla più recente, come le espone data.ts +const sessions: Session[] = [ + sess('s1', 'a', '2026-07-12T09:00', 40), + sess('s2', 'a', '2026-07-08T09:00', 30), + sess('s3', 'a', '2026-07-02T09:00', 60), // settimana precedente + sess('s4', 'b', '2026-06-20T09:00', 20), // oltre i 30 giorni? no: 22 giorni prima + sess('s5', 'b', '2026-04-01T09:00', 15), // fuori da tutte le finestre +]; + +describe('formattazioni', () => { + it('durata: ore, minuti e secondi', () => { + expect(durata(4860)).toBe('1h 21m'); + expect(durata(3275)).toBe('54m 35s'); + expect(durata(5)).toBe('5s'); + }); + + it('date in forma breve ed estesa', () => { + expect(dataBreve('2026-07-30T11:27')).toBe('30 lug 2026'); + expect(dataOra('2026-07-30T11:27')).toBe('30 lug 2026 alle 11:27'); + }); +}); + +describe('finestre temporali', () => { + it('gli ultimi 7 giorni includono il giorno di riferimento ed escludono il settimo indietro', () => { + expect(ultimi(sessions, 7, RIF).map((s) => s.id)).toEqual(['s1', 's2']); + }); + + it('gli ultimi 30 giorni allargano a chi ha misurato nel mese', () => { + expect(ultimi(sessions, 30, RIF).map((s) => s.id)).toEqual(['s1', 's2', 's3', 's4']); + }); +}); + +describe('kpi', () => { + it('conta sessioni, carico settimanale e atleti attivi nel mese', () => { + expect(kpiSport(sessions, RIF)).toEqual([ + { label: 'Sessioni totali', value: 5 }, + { label: 'Questa settimana', value: 2, note: 'ultimi 7gg' }, + { label: 'TRIMP medio', value: 70, note: 'settimanale' }, + { label: 'Atleti attivi', value: 2, note: 'ultimi 30gg' }, + ]); + }); +}); + +describe('sessioni per settimana', () => { + const righe = perSettimana(sessions, 4, RIF); + + it('produce una riga per settimana, la più recente per ultima', () => { + expect(righe).toHaveLength(4); + // 2026-07-12 è una domenica: la sua settimana parte lunedì 06/07 + expect(righe[righe.length - 1].settimana).toBe('06/07'); + }); + + it('conta le sessioni nella settimana giusta', () => { + const perEtichetta = Object.fromEntries(righe.map((r) => [r.settimana, r.sessioni])); + expect(perEtichetta['06/07']).toBe(2); // s1 e s2 + expect(perEtichetta['29/06']).toBe(1); // s3 + }); +}); + +describe('riepilogo atleti', () => { + const riepilogo = riepilogoAtleti(sessions, athletes, RIF); + + it('somma il carico dei 7 giorni e riporta ultima sessione e conteggio mensile', () => { + expect(riepilogo[0]).toEqual({ + clientId: 'a', name: 'A. Uno', sport: 'Corsa', + sessioni30: 3, trimp7: 70, ultimoTrimp: 40, + ultimaSessione: '2026-07-12T09:00', trend: 'stabile', + }); + }); + + it('resta stabile quando lo scarto fra le due settimane è minimo', () => { + // b non ha nulla negli ultimi 7 giorni né nei 7 precedenti → nessuno scarto, resta stabile + expect(riepilogo[1].trend).toBe('stabile'); + expect(riepilogo[1].sessioni30).toBe(1); + }); +}); From 03510d3940019aac001a28bf1c257fb1c2697852 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 31 Jul 2026 16:28:35 +0200 Subject: [PATCH 04/12] Area riservata: menu essenziale e veste grafica del sito MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il menu in alto si riduce alle voci che servono davvero — Articoli, Contenuti, Utenti, Sito, Esci — con la voce corrente evidenziata; ognuna resta visibile solo a chi ha il ruolo per aprirla, e in pagina di login la barra mostra il solo marchio. "Nuovo" esce dal menu perché il pulsante per creare un articolo è già in cima all'elenco Articoli. Il pannello smette di essere un foglio di stile a sé: adotta i font e la palette del sito, come Campus e Stress Index, con tabelle, pulsanti, campi e pillole di stato riportati allo stesso linguaggio. Co-Authored-By: Claude Opus 5 (1M context) --- src/layouts/Admin.astro | 160 ++++++++++++++++++++++++++-------- src/pages/admin/content.astro | 22 +++-- src/pages/admin/login.astro | 4 +- 3 files changed, 140 insertions(+), 46 deletions(-) diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index d76e45e..d1631c3 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -1,7 +1,28 @@ --- +// Layout dell'area riservata. Usa i token e i font del sito, come il Campus e Stress Index: +// il pannello non è più un foglio di stile a sé, così chi passa dal sito all'amministrazione +// resta nello stesso mondo visivo. +import '@fontsource/montserrat/400.css'; +import '@fontsource/montserrat/500.css'; +import '@fontsource/montserrat/600.css'; +import '@fontsource/open-sans/400.css'; +import '@fontsource/open-sans/600.css'; +import '../styles/global.css'; + interface Props { title: string } const { title } = Astro.props; const user = Astro.locals.user; +const path = Astro.url.pathname; +const isManager = user?.role === 'superuser' || user?.role === 'admin'; + +// Voci del menu: ognuna compare solo a chi può davvero aprirla. +const VOCI = [ + { href: '/admin', label: 'Articoli', visibile: true, exact: true }, + { href: '/admin/content', label: 'Contenuti', visibile: isManager }, + { href: '/admin/users', label: 'Utenti', visibile: user?.role === 'admin' }, +]; +const attiva = (v: { href: string; exact?: boolean }) => + v.exact ? path === v.href : path.startsWith(v.href); --- @@ -10,41 +31,110 @@ const user = Astro.locals.user; {title} — Admin InsanityLab - + - -
- InsanityLab · Admin - - {user && ( - <> - {`Ciao, ${user.username}`} - Articoli - {(user.role === 'superuser' || user.role === 'admin') && Contenuti} - {user.role === 'admin' && Utenti} - {user.role === 'admin' && Piattaforme} - Nuovo - Esci - Vedi sito ↗ - - )} - -
-
+ +
+ + InsanityLab + Area riservata + + + {user && ( + + )} + + {user &&

{user.username}

} +
+ +
+ + diff --git a/src/pages/admin/content.astro b/src/pages/admin/content.astro index 41ded06..1dd0d88 100644 --- a/src/pages/admin/content.astro +++ b/src/pages/admin/content.astro @@ -109,22 +109,26 @@ const allStyles = STYLE_GROUPS as Record; From 59d6246c7708fe552e1c642a8fae290effba3891 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 31 Jul 2026 16:38:18 +0200 Subject: [PATCH 06/12] Utenti: pannello password, ruolo come pillola e azioni a icona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le due azioni di riga diventano icone (chiave e cestino) con etichetta accessibile: pesavano quanto il contenuto della tabella. Il ruolo smette di essere il menu a tendina di sistema e prende la forma di una pillola colorata per livello, restando un , con la freccia disegnata a parte perché + quella di sistema non si può vestire. */ + .arole { + appearance: none; -webkit-appearance: none; border: 1px solid #e0d9cd; background-color: #faf8f5; + padding: 6px 30px 6px 12px; font-family: var(--font-heading); font-size: .68rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); cursor: pointer; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%238a8a8a' stroke-width='1.6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 11px center; background-size: 9px; + } + .arole:hover { border-color: var(--c-accent); } + .arole:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; } + .arole[data-role='admin'] { background-color: #efe7dc; border-color: #d9c7ab; color: #6b5334; } + .arole[data-role='superuser'] { background-color: #ece7dc; border-color: #d8cfbe; color: #6f6353; } + .arole[data-role='piattaforme'] { background-color: #e6ede4; border-color: #cbdcc6; color: #4d6a48; } + + /* Riquadro della password appena generata */ + .apw { background: #fff; border: 1px solid var(--c-accent); padding: 18px 20px; margin: 0 0 20px; } + .apw__label { margin: 0 0 10px; font-size: .85rem; color: var(--c-text-light); } + .apw__label strong { color: var(--c-heading); } + .apw__row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .apw__row code { + background: var(--c-bg-alt); border: 1px solid #e4dfd7; padding: 9px 14px; + font-size: 1rem; letter-spacing: .06em; color: var(--c-heading); + } + .apw__hint { margin: 10px 0 0; font-size: .78rem; color: var(--c-text-light); } + .apw__input { width: auto; min-width: 260px; margin-bottom: 0; } + + /* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */ + .aicon { + display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; + padding: 0; background: none; border: 1px solid #ddd6cb; cursor: pointer; color: var(--c-text); + } + .aicon + .aicon { margin-left: 6px; } + .aicon svg { width: 17px; height: 17px; fill: currentColor; } + .aicon:hover { border-color: var(--c-accent); color: var(--c-accent-dark); } + .aicon--danger:hover { border-color: #a4544a; background: #a4544a; color: #fff; } + td.uactions, th.uactions { text-align: right; white-space: nowrap; } + .form-msg { font-size: .88rem; margin-top: 8px; } .form-msg--ok { color: #5f7c5a; } .form-msg--err { color: #a4544a; } diff --git a/src/pages/admin/users.astro b/src/pages/admin/users.astro index ae10d04..bd4426c 100644 --- a/src/pages/admin/users.astro +++ b/src/pages/admin/users.astro @@ -10,20 +10,53 @@ const me = Astro.locals.user!;

Utenti

+ Nuovo utente

+ + + - + {users.map((u) => ( - ))} @@ -44,16 +77,82 @@ const me = Astro.locals.user!; const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }), }); - if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true); + if (res.ok) { sel.dataset.role = sel.value; show(rowMsg, 'Ruolo aggiornato', true); } else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); } })); + const pwbox = document.getElementById('pwbox')!; + const pwUser = document.getElementById('pwbox-user')!; + const pwValue = document.getElementById('pwbox-value')!; + const pwForm = document.getElementById('pwbox-form')!; + const pwResult = document.getElementById('pwbox-result')!; + const pwInput = document.getElementById('pwbox-input') as HTMLInputElement; + const pwMsg = document.getElementById('pwbox-msg')!; + const pwCopy = document.getElementById('pwbox-copy') as HTMLButtonElement; + let idCorrente = ''; + + const chiudi = () => { pwbox.hidden = true; }; + document.getElementById('pwbox-close')!.addEventListener('click', chiudi); + document.getElementById('pwbox-done')!.addEventListener('click', chiudi); + + /** Manda la nuova password: senza corpo il server ne genera una a caso. */ + async function cambiaPassword(password?: string): Promise { + const res = await fetch(`/api/admin/users/${idCorrente}/reset-password`, { + method: 'POST', + ...(password + ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) } + : {}), + }); + if (!res.ok) { + pwMsg.hidden = false; + pwMsg.className = 'form-msg form-msg--err'; + pwMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.'; + return; + } + const dati = await res.json(); + pwValue.textContent = dati.password; + pwForm.hidden = true; + pwResult.hidden = false; + } + + document.getElementById('pwbox-set')!.addEventListener('click', () => { + const scelta = pwInput.value.trim(); + if (scelta.length < 8) { + pwMsg.hidden = false; + pwMsg.className = 'form-msg form-msg--err'; + pwMsg.textContent = 'Password troppo corta (min 8).'; + return; + } + cambiaPassword(scelta); + }); + + document.getElementById('pwbox-gen')!.addEventListener('click', () => cambiaPassword()); + + pwCopy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(pwValue.textContent ?? ''); + pwCopy.textContent = 'Copiata'; + setTimeout(() => { pwCopy.textContent = 'Copia'; }, 1500); + } catch { + // Senza permesso sugli appunti resta la selezione manuale: evidenziamo il testo. + const range = document.createRange(); + range.selectNodeContents(pwValue); + getSelection()?.removeAllRanges(); + getSelection()?.addRange(range); + } + }); + document.querySelectorAll('[data-reset]').forEach((b) => - b.addEventListener('click', async () => { - if (!confirm('Generare una nuova password per questo utente?')) return; - const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' }); - if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); } - else show(rowMsg, (await res.json()).error ?? 'Errore'); + b.addEventListener('click', () => { + idCorrente = b.dataset.reset!; + pwUser.textContent = (b.closest('tr')!.querySelector('td')!.textContent ?? '').replace(' (tu)', ''); + pwInput.value = ''; + pwMsg.hidden = true; + pwResult.hidden = true; + pwForm.hidden = false; + pwbox.hidden = false; + pwbox.scrollIntoView({ block: 'nearest' }); + pwInput.focus(); })); document.querySelectorAll('[data-del]').forEach((b) => diff --git a/src/pages/api/admin/users/[id]/reset-password.ts b/src/pages/api/admin/users/[id]/reset-password.ts index 7101e88..fe6ab81 100644 --- a/src/pages/api/admin/users/[id]/reset-password.ts +++ b/src/pages/api/admin/users/[id]/reset-password.ts @@ -6,11 +6,27 @@ export const prerender = false; const json = (status: number, body: object) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); -export const POST: APIRoute = ({ params }) => { +/** + * Cambia la password di un utente. Senza corpo la genera a caso; con `{ password }` usa + * quella scelta dall'admin. In entrambi i casi la risposta la riporta in chiaro una volta + * sola: nel database finisce solo il suo hash. + */ +export const POST: APIRoute = async ({ params, request }) => { const id = Number(params.id); if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' }); - const password = randomPassword(); + + let scelta = ''; + if (request.headers.get('content-type')?.includes('application/json')) { + try { + const data = (await request.json()) as { password?: unknown }; + scelta = String(data.password ?? ''); + } catch { + return json(400, { error: 'Richiesta non valida.' }); + } + } + if (scelta && scelta.length < 8) return json(400, { error: 'Password troppo corta (min 8).' }); + + const password = scelta || randomPassword(); updateUserPassword(getDb(), id, hashPassword(password)); - // La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna. return json(200, { password }); }; From 6a97a983250a293b12307d1031249a4196de0f82 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 31 Jul 2026 16:47:27 +0200 Subject: [PATCH 07/12] Articoli: cestino con ripristino, e azioni a icona come in Utenti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminare un articolo non lasciava nulla di recuperabile: ora la riga finisce nel cestino (colonna deleted_at, aggiunta anche ai database già esistenti) e sparisce da sito e pannello, ma resta ripristinabile dalla nuova pagina /admin/cestino, dove sta anche la cancellazione definitiva. L'API la accetta solo su articoli già cestinati, così il contenuto non si perde per un clic distratto. Ogni lettura pubblica — elenco, recenti, archivio per mese, pagina del singolo articolo — filtra il cestino, e chi non gestisce vede nel cestino soltanto i propri articoli. Le azioni di riga diventano icone (matita e cestino) come nell'elenco utenti, con etichetta accessibile che nomina l'articolo. Co-Authored-By: Claude Opus 5 (1M context) --- src/layouts/Admin.astro | 4 ++ src/lib/db.ts | 10 ++++- src/lib/posts.ts | 34 ++++++++++++--- src/pages/admin/cestino.astro | 70 +++++++++++++++++++++++++++++++ src/pages/admin/index.astro | 35 ++++++++++++---- src/pages/api/admin/posts/[id].ts | 27 ++++++++++-- tests/posts-cestino.test.ts | 69 ++++++++++++++++++++++++++++++ 7 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 src/pages/admin/cestino.astro create mode 100644 tests/posts-cestino.test.ts diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index 5f6e49d..4eb56b5 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -123,6 +123,9 @@ const attiva = (v: { href: string; exact?: boolean }) => } .pill--draft { background: rgba(192,138,62,.18); color: #96682a; } + .atoolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .amuted { color: var(--c-text-light); font-size: .9rem; } + .aform { max-width: 420px; background: #fff; border: 1px solid #e4dfd7; padding: 26px 24px; } .alabel { display: block; margin-bottom: 6px; font-family: var(--font-heading); font-size: .68rem; @@ -161,6 +164,7 @@ const attiva = (v: { href: string; exact?: boolean }) => .apw__input { width: auto; min-width: 260px; margin-bottom: 0; } /* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */ + a.aicon { text-decoration: none; } .aicon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; background: none; border: 1px solid #ddd6cb; cursor: pointer; color: var(--c-text); diff --git a/src/lib/db.ts b/src/lib/db.ts index 23fe5af..dbfb80e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -16,7 +16,9 @@ CREATE TABLE IF NOT EXISTS posts ( draft INTEGER NOT NULL DEFAULT 1, published_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + -- Cestino: valorizzata quando l'articolo viene rimosso dal sito ma resta recuperabile. + deleted_at TEXT ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -245,6 +247,12 @@ export function createDb(path?: string): Database.Database { // visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti. db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run(); + // Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti. + const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[]; + if (!colonnePost.some((c) => c.name === 'deleted_at')) { + db.exec('ALTER TABLE posts ADD COLUMN deleted_at TEXT'); + } + // Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano // ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna. const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[]; diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 2b346dd..199df79 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -4,6 +4,8 @@ export interface Post { id: number; title: string; slug: string; category: string; excerpt: string; body_html: string; cover: string | null; draft: number; author_id: number | null; published_at: string | null; created_at: string; updated_at: string; + /** Momento in cui è finito nel cestino; null se attivo. */ + deleted_at: string | null; } export interface PostInput { @@ -30,6 +32,16 @@ export function updatePost(db: Database.Database, id: number, input: PostInput): ).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 }); } +/** Sposta nel cestino: l'articolo sparisce dal sito e dall'elenco, ma resta recuperabile. */ +export function trashPost(db: Database.Database, id: number): void { + db.prepare("UPDATE posts SET deleted_at = datetime('now') WHERE id = ?").run(id); +} + +export function restorePost(db: Database.Database, id: number): void { + db.prepare('UPDATE posts SET deleted_at = NULL WHERE id = ?').run(id); +} + +/** Cancellazione definitiva: si usa solo dal cestino. */ export function deletePost(db: Database.Database, id: number): void { db.prepare('DELETE FROM posts WHERE id = ?').run(id); } @@ -39,7 +51,7 @@ export function getPostById(db: Database.Database, id: number): Post | undefined } export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined { - return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined; + return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0 AND deleted_at IS NULL').get(slug) as Post | undefined; } export function listPublished( @@ -47,7 +59,8 @@ export function listPublished( opts: { category?: string; page?: number; perPage?: number } = {}, ): { items: Post[]; total: number } { const { category, page = 1, perPage = 9 } = opts; - const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0'; + const base = 'WHERE draft = 0 AND deleted_at IS NULL'; + const where = category ? `${base} AND category = @category` : base; const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`) .get({ category }) as { n: number }).n; const items = db.prepare( @@ -57,24 +70,33 @@ export function listPublished( } export function listAllPosts(db: Database.Database): Post[] { - return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[]; + return db.prepare('SELECT * FROM posts WHERE deleted_at IS NULL ORDER BY updated_at DESC').all() as Post[]; +} + +/** Articoli nel cestino: tutti per chi gestisce, i propri per gli altri. */ +export function listTrashed(db: Database.Database, authorId?: number): Post[] { + return authorId === undefined + ? db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC').all() as Post[] + : db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL AND author_id = ? ORDER BY deleted_at DESC') + .all(authorId) as Post[]; } export function listRecent(db: Database.Database, limit = 4): Post[] { return db.prepare( - 'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?' + 'SELECT * FROM posts WHERE draft = 0 AND deleted_at IS NULL ORDER BY published_at DESC LIMIT ?' ).all(limit) as Post[]; } export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] { return db.prepare( `SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count - FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC` + FROM posts WHERE draft = 0 AND deleted_at IS NULL GROUP BY month ORDER BY month DESC` ).all() as { month: string; count: number }[]; } export function listByAuthor(db: Database.Database, authorId: number): Post[] { - return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[]; + return db.prepare('SELECT * FROM posts WHERE author_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC') + .all(authorId) as Post[]; } export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean { diff --git a/src/pages/admin/cestino.astro b/src/pages/admin/cestino.astro new file mode 100644 index 0000000..99b3c15 --- /dev/null +++ b/src/pages/admin/cestino.astro @@ -0,0 +1,70 @@ +--- +// Cestino degli articoli: qui si recupera ciò che è stato tolto dal sito, o lo si elimina +// per sempre. Chi gestisce vede tutto, gli altri solo i propri articoli. +import Admin from '../../layouts/Admin.astro'; +import { getDb } from '../../lib/db'; +import { listTrashed } from '../../lib/posts'; +import { listUsers } from '../../lib/auth'; +export const prerender = false; +const user = Astro.locals.user!; +const isManager = user.role === 'superuser' || user.role === 'admin'; +const posts = listTrashed(getDb(), isManager ? undefined : user.id); +const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username])); +const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' }); +--- + +

Cestino

+

← Torna agli articoli

+ + {posts.length === 0 ? ( +

Il cestino è vuoto.

+ ) : ( +
UtenteRuolo
UtenteRuoloAzioni
{u.username}{u.id === me.id && ' (tu)'} - {['user', 'superuser', 'piattaforme', 'admin'].map((r) => )} - - {u.id !== me.id && } + + + {u.id !== me.id && ( + + )}
+ + {isManager && } + + + {posts.map((p) => ( + + + + {isManager && } + + + + ))} + +
TitoloCategoriaAutoreNel cestino dalAzioni
{p.title}{p.category}{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}{p.deleted_at ? fmt.format(new Date(p.deleted_at.replace(' ', 'T') + 'Z')) : '—'} + + +
+ )} +

+ + + diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index 24cb0ca..e5708fa 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -1,20 +1,28 @@ --- import Admin from '../../layouts/Admin.astro'; import { getDb } from '../../lib/db'; -import { listAllPosts, listByAuthor } from '../../lib/posts'; +import { listAllPosts, listByAuthor, listTrashed } from '../../lib/posts'; import { listUsers } from '../../lib/auth'; export const prerender = false; const user = Astro.locals.user!; const isManager = user.role === 'superuser' || user.role === 'admin'; const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id); +const cestinati = listTrashed(getDb(), isManager ? undefined : user.id).length; const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username])); const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' }); ---

Articoli

-

+ Nuovo articolo

+ +

+ + Nuovo articolo + {cestinati > 0 && Cestino ({cestinati})} +

+ - {isManager && } + + {isManager && } + {posts.map((p) => ( @@ -23,20 +31,33 @@ const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 's {isManager && } - + ))}
TitoloCategoriaAutoreStatoAggiornato
TitoloCategoriaAutoreStatoAggiornatoAzioni
{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}{p.draft ? 'Bozza' : 'Pubblicato'} {fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))} + + + + +
- {posts.length === 0 &&

Nessun articolo: creane uno nuovo.

} + {posts.length === 0 &&

Nessun articolo: creane uno nuovo.

} +

diff --git a/src/pages/api/admin/posts/[id].ts b/src/pages/api/admin/posts/[id].ts index 8c7ef21..7b50e77 100644 --- a/src/pages/api/admin/posts/[id].ts +++ b/src/pages/api/admin/posts/[id].ts @@ -1,6 +1,6 @@ import type { APIRoute } from 'astro'; import { getDb } from '../../../../lib/db'; -import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts'; +import { getPostById, updatePost, deletePost, trashPost, restorePost, canModifyPost } from '../../../../lib/posts'; import { parsePostBody } from '../../../../lib/post-body'; export const prerender = false; @@ -25,11 +25,32 @@ export const PUT: APIRoute = async ({ params, request, locals }) => { } }; -export const DELETE: APIRoute = ({ params, locals }) => { +/** + * Sposta l'articolo nel cestino. Con `?definitivo=1` lo cancella davvero: è la sola via per + * perdere il contenuto, e vale solo per ciò che è già nel cestino. + */ +export const DELETE: APIRoute = ({ params, locals, url }) => { const id = Number(params.id); const existing = getPostById(getDb(), id); if (!existing) return json(404, { error: 'Articolo non trovato.' }); if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' }); - deletePost(getDb(), id); + + if (url.searchParams.get('definitivo') === '1') { + if (!existing.deleted_at) return json(400, { error: 'Sposta prima l\'articolo nel cestino.' }); + deletePost(getDb(), id); + return json(200, { ok: true, definitivo: true }); + } + + trashPost(getDb(), id); + return json(200, { ok: true }); +}; + +/** Riporta l'articolo fuori dal cestino, nello stato in cui era (bozza o pubblicato). */ +export const POST: APIRoute = ({ params, locals }) => { + const id = Number(params.id); + const existing = getPostById(getDb(), id); + if (!existing) return json(404, { error: 'Articolo non trovato.' }); + if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi ripristinare questo articolo.' }); + restorePost(getDb(), id); return json(200, { ok: true }); }; diff --git a/tests/posts-cestino.test.ts b/tests/posts-cestino.test.ts new file mode 100644 index 0000000..ae5b91b --- /dev/null +++ b/tests/posts-cestino.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { + createPost, trashPost, restorePost, deletePost, getPostById, listAllPosts, listByAuthor, + listTrashed, listPublished, listRecent, listArchiveMonths, getPublishedBySlug, +} from '../src/lib/posts'; + +let db: Database.Database; +let id: number; + +const articolo = (slug: string, authorId: number | null = 1) => ({ + title: `Titolo ${slug}`, slug, category: 'training', excerpt: 'x', + body_html: '

x

', draft: false, author_id: authorId, +}); + +beforeEach(() => { + db = createDb(':memory:'); + id = createPost(db, articolo('uno')); + createPost(db, articolo('due', 2)); +}); + +describe('cestino', () => { + it('cestinare non cancella: l\'articolo resta leggibile per id', () => { + trashPost(db, id); + const p = getPostById(db, id); + expect(p).toBeDefined(); + expect(p!.deleted_at).not.toBeNull(); + }); + + it('lo toglie da tutti gli elenchi del pannello', () => { + trashPost(db, id); + expect(listAllPosts(db).map((p) => p.slug)).toEqual(['due']); + expect(listByAuthor(db, 1)).toEqual([]); + expect(listTrashed(db).map((p) => p.slug)).toEqual(['uno']); + }); + + it('lo toglie dal sito pubblico: elenco, recenti, archivio e pagina singola', () => { + trashPost(db, id); + expect(listPublished(db).items.map((p) => p.slug)).toEqual(['due']); + expect(listPublished(db).total).toBe(1); + expect(listPublished(db, { category: 'training' }).total).toBe(1); + expect(listRecent(db).map((p) => p.slug)).toEqual(['due']); + expect(listArchiveMonths(db).reduce((n, m) => n + m.count, 0)).toBe(1); + expect(getPublishedBySlug(db, 'uno')).toBeUndefined(); + }); + + it('il cestino di un autore contiene solo i suoi articoli', () => { + trashPost(db, id); + expect(listTrashed(db, 1).map((p) => p.slug)).toEqual(['uno']); + expect(listTrashed(db, 2)).toEqual([]); + }); + + it('il ripristino lo rimette dov\'era', () => { + trashPost(db, id); + restorePost(db, id); + expect(getPostById(db, id)!.deleted_at).toBeNull(); + expect(listTrashed(db)).toEqual([]); + expect(listAllPosts(db).map((p) => p.slug)).toContain('uno'); + expect(getPublishedBySlug(db, 'uno')).toBeDefined(); + }); + + it('la cancellazione definitiva rimuove davvero la riga', () => { + trashPost(db, id); + deletePost(db, id); + expect(getPostById(db, id)).toBeUndefined(); + expect(listTrashed(db)).toEqual([]); + }); +}); From 770073c45cfaf0a6d802b6888b251bbd577ebab4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 31 Jul 2026 16:51:37 +0200 Subject: [PATCH 08/12] Blog: cambio categoria e pagina senza ricaricare tutto Ogni clic su una categoria ricaricava l'intera pagina, header e hero compresi, con lo sfarfallio che ne consegue. Ora si scarica la stessa pagina e si sostituiscono soltanto la griglia e i filtri, con una breve dissolvenza; l'indirizzo resta corretto e avanti/indietro funzionano. I link restano link veri: senza JavaScript, o se la risposta non ha la forma attesa, la navigazione avviene come prima. Co-Authored-By: Claude Opus 5 (1M context) --- src/pages/blog/index.astro | 60 +++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index dcd8a05..f655ef1 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -38,8 +38,9 @@ const linkFor = (cat?: string, p = 1) => { {c} ))} +
{items.length === 0 ? ( -

Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.

+

Nessun articolo pubblicato{category ? ' in questa categoria' : ''}, torna a trovarci presto.

) : (
{items.map((post) => ( @@ -60,14 +61,71 @@ const linkFor = (cat?: string, p = 1) => { ))} )} +
+ +