v0.2.0 — config admin (météo forcée, flash info, édition classe) + PWA

- DB : table day_weather (override admin par jour), flash_message sur classes
- API : PUT/DELETE /api/config/day-weather, PUT /api/config/flash, PATCH /api/config/class
- SSE : day_weather.updated/deleted, flash.updated, class.updated
- Front : bandeau flash info, indicateur 'forcé' sur la frise, modale météo admin
- Onglet Plus : panneau admin (flash info + édition classe), tout admin modifiable
- PWA : manifest.webmanifest, service worker, meta Apple, icônes 192/512
- Web version accessible sur iOS via Safari (Ajouter à l'écran d'accueil)
This commit is contained in:
Mathis 2026-10-04 17:05:56 +02:00
parent 6d154ed5fb
commit 246f989bdc
16 changed files with 782 additions and 86 deletions

108
scripts/gen-icons.mjs Normal file
View file

@ -0,0 +1,108 @@
/**
* Génère des icônes PWA simples (dégradé bleu + pastille blanche).
* Usage : node scripts/gen-icons.mjs
*/
import { writeFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { deflateSync } from 'node:zlib'
const OUT = resolve(import.meta.dirname, '../web/public')
mkdirSync(OUT, { recursive: true })
function crc32(buf) {
const table = []
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c >>> 0
}
let crc = 0xffffffff
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8)
return (crc ^ 0xffffffff) >>> 0
}
function chunk(type, data) {
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length)
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(body))
return Buffer.concat([len, body, crc])
}
function encodePng(width, height, rgba) {
const raw = Buffer.alloc(height * (1 + width * 4))
for (let y = 0; y < height; y++) {
raw[y * (1 + width * 4)] = 0
rgba.copy(raw, y * (1 + width * 4) + 1, y * width * 4, (y + 1) * width * 4)
}
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8
ihdr[9] = 6
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
])
}
function drawIcon(size) {
const buf = Buffer.alloc(size * size * 4)
const c0 = [56, 189, 248]
const c1 = [99, 102, 241]
const cx = size / 2
const cy = size / 2
const rInner = size * 0.3
const rPad = size * 0.16
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4
const dx = x - cx
const dy = y - cy
const dist = Math.sqrt(dx * dx + dy * dy)
// Fond arrondi : on coupe les coins
let transparent = false
if (x < rPad && y < rPad) transparent = Math.hypot(rPad - x, rPad - y) > rPad
else if (x >= size - rPad && y < rPad) transparent = Math.hypot(x - (size - rPad), rPad - y) > rPad
else if (x < rPad && y >= size - rPad) transparent = Math.hypot(rPad - x, y - (size - rPad)) > rPad
else if (x >= size - rPad && y >= size - rPad) transparent = Math.hypot(x - (size - rPad), y - (size - rPad)) > rPad
if (transparent) {
buf[i + 3] = 0
continue
}
const t = (x + y) / (size * 2)
let r = c0[0] + (c1[0] - c0[0]) * t
let g = c0[1] + (c1[1] - c0[1]) * t
let b = c0[2] + (c1[2] - c0[2]) * t
if (dist < rInner) {
r = 255
g = 255
b = 255
} else if (dist < rInner + size * 0.025) {
r = Math.min(255, r + 50)
g = Math.min(255, g + 50)
b = Math.min(255, b + 50)
}
buf[i] = Math.round(r)
buf[i + 1] = Math.round(g)
buf[i + 2] = Math.round(b)
buf[i + 3] = 255
}
}
return buf
}
for (const size of [192, 512]) {
const png = encodePng(size, size, drawIcon(size))
writeFileSync(resolve(OUT, `icon-${size}.png`), png)
console.log(`icon-${size}.png (${png.length} octets)`)
}

View file

@ -11,6 +11,7 @@ interface MemberRow {
role: Role role: Role
class_name: string class_name: string
class_level: string | null class_level: string | null
class_flash_message: string | null
} }
export const newToken = (): string => randomBytes(32).toString('base64url') export const newToken = (): string => randomBytes(32).toString('base64url')
@ -51,7 +52,7 @@ export function createClass(name: string, level: string | null, nickname: string
return { return {
token, token,
member: { id: memberId, classId, nickname, role: 'admin' }, member: { id: memberId, classId, nickname, role: 'admin' },
klass: { id: classId, name, level }, klass: { id: classId, name, level, flashMessage: null },
} }
}) })
return { code, ...create() } return { code, ...create() }
@ -71,7 +72,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
return { return {
token, token,
member: { id: memberId, classId: row.id, nickname, role: 'student' }, member: { id: memberId, classId: row.id, nickname, role: 'student' },
klass: { id: row.id, name: row.name, level: row.level }, klass: { id: row.id, name: row.name, level: row.level, flashMessage: null },
} }
} }
@ -79,7 +80,8 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
const row = db const row = db
.prepare<[string], MemberRow>( .prepare<[string], MemberRow>(
`SELECT m.id, m.class_id, m.nickname, m.role, `SELECT m.id, m.class_id, m.nickname, m.role,
c.name AS class_name, c.level AS class_level c.name AS class_name, c.level AS class_level,
c.flash_message AS class_flash_message
FROM members m FROM members m
JOIN classes c ON c.id = m.class_id JOIN classes c ON c.id = m.class_id
WHERE m.token = ?`, WHERE m.token = ?`,
@ -88,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
if (!row) return null if (!row) return null
return { return {
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role }, member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role },
klass: { id: row.class_id, name: row.class_name, level: row.class_level }, klass: { id: row.class_id, name: row.class_name, level: row.class_level, flashMessage: row.class_flash_message },
} }
} }

View file

@ -13,11 +13,12 @@ db.pragma('foreign_keys = ON')
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS classes ( CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
level TEXT, level TEXT,
code TEXT NOT NULL UNIQUE, code TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL flash_message TEXT,
created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS members ( CREATE TABLE IF NOT EXISTS members (
@ -50,8 +51,23 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS idx_exams_class_start ON exams(class_id, starts_at); CREATE INDEX IF NOT EXISTS idx_exams_class_start ON exams(class_id, starts_at);
CREATE TABLE IF NOT EXISTS day_weather (
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
day_key TEXT NOT NULL,
kind TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (class_id, day_key)
);
`) `)
// Migration légère : ajouter flash_message si la table existe déjà sans.
try {
db.exec(`ALTER TABLE classes ADD COLUMN flash_message TEXT`)
} catch {
// colonne déjà présente : rien à faire
}
export const nowIso = (): string => new Date().toISOString() export const nowIso = (): string => new Date().toISOString()
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */ /** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */

View file

@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { classesRouter } from './routes/classes.js' import { classesRouter } from './routes/classes.js'
import { examsRouter } from './routes/exams.js' import { examsRouter } from './routes/exams.js'
import { streamRouter } from './routes/stream.js' import { streamRouter } from './routes/stream.js'
import { configRouter } from './routes/config.js'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
const PORT = Number(process.env['PORT'] ?? 8787) const PORT = Number(process.env['PORT'] ?? 8787)
@ -42,6 +43,7 @@ app.get('/api/health', (_req, res) => {
app.use('/api/classes', classesRouter) app.use('/api/classes', classesRouter)
app.use('/api/exams', examsRouter) app.use('/api/exams', examsRouter)
app.use('/api/stream', streamRouter) app.use('/api/stream', streamRouter)
app.use('/api/config', configRouter)
app.use('/api', (_req, res) => { app.use('/api', (_req, res) => {
res.status(404).json({ error: 'Route inconnue' }) res.status(404).json({ error: 'Route inconnue' })

144
server/src/routes/config.ts Normal file
View file

@ -0,0 +1,144 @@
import { Router } from 'express'
import { z } from 'zod'
import type { ClassInfo, DayWeather, FlashInfo, WeatherKind } from '@meteo-exam/shared'
import { isWeatherKind } from '@meteo-exam/shared'
import { db, nowIso } from '../db.js'
import { requireSession, requireAdmin } from '../auth.js'
import { broadcast } from '../events.js'
export const configRouter = Router()
// --- Override météo par jour -------------------------------------------------
const dayWeatherSchema = z.object({
dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format AAAA-MM-JJ attendu'),
kind: z.string().refine(isWeatherKind, 'Statut météo inconnu'),
})
/** Tous les overrides météo de la classe. */
configRouter.get('/day-weather', requireSession, (req, res) => {
const classId = req.session!.member.classId
const rows = db
.prepare<[number], { day_key: string; kind: WeatherKind; updated_at: string }>(
'SELECT day_key, kind, updated_at FROM day_weather WHERE class_id = ? ORDER BY day_key ASC',
)
.all(classId)
res.json(
rows.map((r) => ({
classId,
dayKey: r.day_key,
kind: r.kind,
updatedAt: r.updated_at,
})) satisfies DayWeather[],
)
})
/** Pose (ou remplace) l'override météo d'un jour. */
configRouter.put('/day-weather', requireSession, requireAdmin, (req, res) => {
const parsed = dayWeatherSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
return
}
const { dayKey, kind } = parsed.data
const classId = req.session!.member.classId
const now = nowIso()
db.prepare(
`INSERT INTO day_weather (class_id, day_key, kind, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(class_id, day_key) DO UPDATE SET kind = excluded.kind, updated_at = excluded.updated_at`,
).run(classId, dayKey, kind, now)
const dayWeather: DayWeather = { classId, dayKey, kind, updatedAt: now }
broadcast(classId, { type: 'day_weather.updated', dayWeather })
res.json(dayWeather)
})
/** Supprime l'override d'un jour (la météo redevient celle des évaluations). */
configRouter.delete('/day-weather/:dayKey', requireSession, requireAdmin, (req, res) => {
const raw = req.params['dayKey']
const dayKey = Array.isArray(raw) ? raw[0] : raw
if (!dayKey) {
res.status(400).json({ error: 'Jour manquant' })
return
}
const classId = req.session!.member.classId
const info = db
.prepare('DELETE FROM day_weather WHERE class_id = ? AND day_key = ?')
.run(classId, dayKey)
if (info.changes === 0) {
res.status(404).json({ error: 'Aucun override pour ce jour' })
return
}
broadcast(classId, { type: 'day_weather.deleted', dayKey })
res.status(204).end()
})
// --- Flash info --------------------------------------------------------------
const flashSchema = z.object({
message: z.string().trim().max(500, 'Message trop long (500 caractères max)'),
})
configRouter.get('/flash', requireSession, (req, res) => {
const row = db
.prepare<[number], { flash_message: string | null }>(
'SELECT flash_message FROM classes WHERE id = ?',
)
.get(req.session!.member.classId)
const message = row?.flash_message ?? null
res.json({ classId: req.session!.member.classId, message, updatedAt: '' } satisfies FlashInfo)
})
/** Écrit (ou efface avec message vide) le flash info du jour. */
configRouter.put('/flash', requireSession, requireAdmin, (req, res) => {
const parsed = flashSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
return
}
const classId = req.session!.member.classId
const message = (parsed.data.message || '').trim() || null
const now = nowIso()
db.prepare('UPDATE classes SET flash_message = ? WHERE id = ?').run(message ?? '', classId)
const flash: FlashInfo = { classId, message, updatedAt: now }
broadcast(classId, { type: 'flash.updated', flash })
res.json(flash)
})
// --- Édition de la classe ----------------------------------------------------
const classSchema = z.object({
name: z.string().trim().min(2, 'Nom trop court').max(60),
level: z.string().trim().max(40).nullish(),
})
configRouter.patch('/class', requireSession, requireAdmin, (req, res) => {
const parsed = classSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
return
}
const classId = req.session!.member.classId
const { name, level } = parsed.data
db.prepare('UPDATE classes SET name = ?, level = ? WHERE id = ?').run(name, level ?? '', classId)
const row = db
.prepare<[number], { id: number; name: string; level: string | null; flash_message: string | null }>(
'SELECT id, name, level, flash_message FROM classes WHERE id = ?',
)
.get(classId)!
const klass: ClassInfo = {
id: row.id,
name: row.name,
level: row.level,
flashMessage: row.flash_message,
}
broadcast(classId, { type: 'class.updated', klass })
res.json(klass)
})

View file

@ -83,8 +83,8 @@ const exams: SeedExam[] = [
] ]
const seed = db.transaction(() => { const seed = db.transaction(() => {
db.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO classes (name, level, code, flash_message, created_at) VALUES (?, ?, ?, ?, ?)')
.run(CLASS_NAME, LEVEL, CLASS_CODE, nowIso()) .run(CLASS_NAME, LEVEL, CLASS_CODE, 'Bon courage pour les révisions cette semaine ! 🌤️', nowIso())
const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?') const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?')
.get(CLASS_CODE)!.id .get(CLASS_CODE)!.id

View file

@ -146,6 +146,8 @@ export interface ClassInfo {
id: number id: number
name: string name: string
level: string | null level: string | null
/** Message d'annonce du jour (flash info), lu par tous les membres. */
flashMessage: string | null
} }
export interface Member { export interface Member {
@ -165,11 +167,31 @@ export interface ApiError {
error: string error: string
} }
/** Override météo posé par l'admin pour un jour donné (clé locale AAAA-MM-JJ). */
export interface DayWeather {
classId: number
dayKey: string
kind: WeatherKind
updatedAt: string
}
/** Flash info : message quotidien rédigé par l'admin. */
export interface FlashInfo {
classId: number
/** null = pas de message publié. */
message: string | null
updatedAt: string
}
/** Payloads émis par le flux SSE. */ /** Payloads émis par le flux SSE. */
export type StreamEvent = export type StreamEvent =
| { type: 'exam.created'; exam: Exam } | { type: 'exam.created'; exam: Exam }
| { type: 'exam.updated'; exam: Exam } | { type: 'exam.updated'; exam: Exam }
| { type: 'exam.deleted'; examId: number } | { type: 'exam.deleted'; examId: number }
| { type: 'day_weather.updated'; dayWeather: DayWeather }
| { type: 'day_weather.deleted'; dayKey: string }
| { type: 'flash.updated'; flash: FlashInfo | null }
| { type: 'class.updated'; klass: ClassInfo }
| { type: 'ping' } | { type: 'ping' }
export const CLASS_CODE_LENGTH = 6 export const CLASS_CODE_LENGTH = 6

View file

@ -2,9 +2,20 @@
<html lang="fr" class="h-full"> <html lang="fr" class="h-full">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" /> <meta name="theme-color" content="#020617" />
<title>Météo Exam</title> <title>Météo Exam</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Météo Exam" />
<meta name="description" content="Le calendrier des évaluations scolaires, en bulletin météo interactif." />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
@ -19,5 +30,12 @@
<body class="bg-slate-950 text-slate-100 font-sans h-full"> <body class="bg-slate-950 text-slate-100 font-sans h-full">
<div id="app" class="flex items-center justify-center min-h-screen"></div> <div id="app" class="flex items-center justify-center min-h-screen"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {})
})
}
</script>
</body> </body>
</html> </html>

BIN
web/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
web/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,31 @@
{
"name": "Météo Exam",
"short_name": "Météo Exam",
"description": "Le calendrier des évaluations scolaires, en bulletin météo interactif.",
"start_url": "/",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#020617",
"orientation": "portrait",
"lang": "fr",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

39
web/public/sw.js Normal file
View file

@ -0,0 +1,39 @@
/**
* Service worker Météo Exam.
* - Cache les assets statiques (app shell) pour un démarrage hors ligne.
* - Les appels API passent en network-first : les données restent fraîches.
*/
const CACHE_NAME = 'meteo-exam-v2'
const STATIC_ASSETS = ['/', '/index.html', '/manifest.webmanifest']
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
)
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
),
)
self.clients.claim()
})
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// API et flux SSE : toujours réseau, pas de cache.
if (url.pathname.startsWith('/api/')) return
// Assets statiques : cache d'abord, réseau en secours.
if (request.method === 'GET' && (url.origin === self.location.origin || url.hostname === 'localhost')) {
event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request).catch(() => caches.match('/index.html'))),
)
}
})

View file

@ -1,5 +1,5 @@
import { Capacitor, CapacitorHttp } from '@capacitor/core' import { Capacitor, CapacitorHttp } from '@capacitor/core'
import type { Exam, Session, StreamEvent } from '@meteo-exam/shared' import type { ClassInfo, DayWeather, Exam, FlashInfo, Session, StreamEvent, WeatherKind } from '@meteo-exam/shared'
import { getBaseUrl, getToken } from './config' import { getBaseUrl, getToken } from './config'
export class ApiFailure extends Error { export class ApiFailure extends Error {
@ -13,7 +13,7 @@ export class ApiFailure extends Error {
} }
interface RequestOptions { interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown body?: unknown
/** Jeton explicite, sinon celui du stockage local. */ /** Jeton explicite, sinon celui du stockage local. */
token?: string | null token?: string | null
@ -106,6 +106,23 @@ export const api = {
request<Exam>(`/api/exams/${id}`, { method: 'PATCH', body: input }), request<Exam>(`/api/exams/${id}`, { method: 'PATCH', body: input }),
deleteExam: (id: number) => request<void>(`/api/exams/${id}`, { method: 'DELETE' }), deleteExam: (id: number) => request<void>(`/api/exams/${id}`, { method: 'DELETE' }),
// --- Config admin ---
listDayWeather: () => request<DayWeather[]>('/api/config/day-weather'),
setDayWeather: (dayKey: string, kind: WeatherKind) =>
request<DayWeather>('/api/config/day-weather', { method: 'PUT', body: { dayKey, kind } }),
clearDayWeather: (dayKey: string) =>
request<void>(`/api/config/day-weather/${encodeURIComponent(dayKey)}`, { method: 'DELETE' }),
getFlash: () => request<FlashInfo>('/api/config/flash'),
setFlash: (message: string) =>
request<FlashInfo>('/api/config/flash', { method: 'PUT', body: { message } }),
updateClass: (name: string, level: string | null) =>
request<ClassInfo>('/api/config/class', { method: 'PATCH', body: { name, level } }),
} }
/** /**

View file

@ -1,4 +1,4 @@
import type { Exam, Session } from '@meteo-exam/shared' import type { ClassInfo, Exam, FlashInfo, Session } from '@meteo-exam/shared'
import { dominantKind, type WeatherKind } from '@meteo-exam/shared' import { dominantKind, type WeatherKind } from '@meteo-exam/shared'
import { api, openStream } from './api' import { api, openStream } from './api'
import { clearToken, getToken, setToken } from './config' import { clearToken, getToken, setToken } from './config'
@ -11,13 +11,17 @@ type Listener = () => void
interface State { interface State {
session: Session | null session: Session | null
exams: Exam[] exams: Exam[]
/** Jours affichés par la frise, indépendamment de la fenêtre chargée. */
stripDays: Date[] stripDays: Date[]
selectedDay: string | null selectedDay: string | null
loading: boolean loading: boolean
error: string | null error: string | null
/** Passé à true quand le jeton est refusé : l'app revient à l'écran d'accueil. */
signedOut: boolean signedOut: boolean
/** Overrides météo posés par l'admin, indexés par jour local AAAA-MM-JJ. */
dayWeather: Map<string, WeatherKind>
/** Flash info du jour (peut être null). */
flash: FlashInfo | null
/** Infos classe en direct (nom, niveau). */
klass: ClassInfo | null
} }
const state: State = { const state: State = {
@ -28,6 +32,9 @@ const state: State = {
loading: false, loading: false,
error: null, error: null,
signedOut: false, signedOut: false,
dayWeather: new Map(),
flash: null,
klass: null,
} }
const listeners = new Set<Listener>() const listeners = new Set<Listener>()
@ -47,7 +54,6 @@ export const getState = (): Readonly<State> => state
const windowBounds = (): { from: string; to: string } => { const windowBounds = (): { from: string; to: string } => {
const from = new Date() const from = new Date()
from.setHours(0, 0, 0, 0) from.setHours(0, 0, 0, 0)
// On charge large : la frise fait 7 jours mais les cartes listent la fenêtre.
const to = new Date(from.getTime() + WINDOW_DAYS * 86_400_000) const to = new Date(from.getTime() + WINDOW_DAYS * 86_400_000)
return { from: from.toISOString(), to: to.toISOString() } return { from: from.toISOString(), to: to.toISOString() }
} }
@ -65,17 +71,48 @@ export async function loadExams(): Promise<void> {
emit() emit()
} }
export async function loadConfig(): Promise<void> {
try {
const [dayWeather, flash] = await Promise.all([api.listDayWeather(), api.getFlash()])
state.dayWeather = new Map(dayWeather.map((dw) => [dw.dayKey, dw.kind]))
state.flash = flash
} catch {
// config secondaire : on ignore silencieusement
}
emit()
}
function connectStream(): void { function connectStream(): void {
closeStream?.() closeStream?.()
closeStream = openStream((event) => { closeStream = openStream((event) => {
if (event.type === 'ping') return switch (event.type) {
if (event.type === 'exam.deleted') { case 'ping':
const idx = state.exams.findIndex((e) => e.id === event.examId) return
if (idx >= 0) state.exams.splice(idx, 1) case 'exam.deleted': {
} else { const idx = state.exams.findIndex((e) => e.id === event.examId)
const index = state.exams.findIndex((e) => e.id === event.exam.id) if (idx >= 0) state.exams.splice(idx, 1)
if (index >= 0) state.exams[index] = event.exam break
else state.exams.push(event.exam) }
case 'exam.created':
case 'exam.updated': {
const index = state.exams.findIndex((e) => e.id === event.exam.id)
if (index >= 0) state.exams[index] = event.exam
else state.exams.push(event.exam)
break
}
case 'day_weather.updated':
state.dayWeather.set(event.dayWeather.dayKey, event.dayWeather.kind)
break
case 'day_weather.deleted':
state.dayWeather.delete(event.dayKey)
break
case 'flash.updated':
state.flash = event.flash
break
case 'class.updated':
state.klass = event.klass
if (state.session) state.session = { ...state.session, klass: event.klass }
break
} }
state.exams.sort((a, b) => a.startsAt.localeCompare(b.startsAt)) state.exams.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
emit() emit()
@ -93,13 +130,17 @@ export async function bootstrap(): Promise<void> {
emit() emit()
try { try {
state.session = await api.me(token) state.session = await api.me(token)
state.klass = state.session.klass
state.signedOut = false state.signedOut = false
await loadExams() await Promise.all([loadExams(), loadConfig()])
connectStream() connectStream()
} catch { } catch {
clearToken() clearToken()
state.session = null state.session = null
state.exams = [] state.exams = []
state.dayWeather.clear()
state.flash = null
state.klass = null
state.signedOut = true state.signedOut = true
} finally { } finally {
state.loading = false state.loading = false
@ -110,10 +151,11 @@ export async function bootstrap(): Promise<void> {
export async function loginAs(token: string, session: Session): Promise<void> { export async function loginAs(token: string, session: Session): Promise<void> {
setToken(token) setToken(token)
state.session = session state.session = session
state.klass = session.klass
state.signedOut = false state.signedOut = false
state.loading = true state.loading = true
emit() emit()
await loadExams() await Promise.all([loadExams(), loadConfig()])
connectStream() connectStream()
state.loading = false state.loading = false
emit() emit()
@ -128,6 +170,9 @@ export function signOut(): void {
state.selectedDay = null state.selectedDay = null
state.signedOut = false state.signedOut = false
state.error = null state.error = null
state.dayWeather.clear()
state.flash = null
state.klass = null
emit() emit()
} }
@ -156,8 +201,14 @@ export function examsByDay(): Map<string, Exam[]> {
return grouped return grouped
} }
/** Statut d'un jour : override admin > statut dominant des évaluations. */
export function dayStatus(key: string): WeatherKind { export function dayStatus(key: string): WeatherKind {
return dominantKind(examsByDay().get(key) ?? []) return state.dayWeather.get(key) ?? dominantKind(examsByDay().get(key) ?? [])
}
/** true si l'admin a forcé la météo de ce jour. */
export function isDayOverridden(key: string): boolean {
return state.dayWeather.has(key)
} }
export function todayExams(): Exam[] { export function todayExams(): Exam[] {
@ -168,6 +219,14 @@ export function findExam(id: number): Exam | undefined {
return state.exams.find((e) => e.id === id) return state.exams.find((e) => e.id === id)
} }
export function currentFlash(): FlashInfo | null {
return state.flash
}
export function currentKlass(): ClassInfo | null {
return state.klass ?? state.session?.klass ?? null
}
// --- Écriture (admin) ------------------------------------------------------- // --- Écriture (admin) -------------------------------------------------------
export async function saveExam(input: unknown, id?: number): Promise<Exam> { export async function saveExam(input: unknown, id?: number): Promise<Exam> {
@ -186,3 +245,28 @@ export async function removeExam(id: number): Promise<void> {
state.exams = state.exams.filter((e) => e.id !== id) state.exams = state.exams.filter((e) => e.id !== id)
emit() emit()
} }
export async function setDayWeather(dayKey: string, kind: WeatherKind): Promise<void> {
await api.setDayWeather(dayKey, kind)
state.dayWeather.set(dayKey, kind)
emit()
}
export async function clearDayWeather(dayKey: string): Promise<void> {
await api.clearDayWeather(dayKey)
state.dayWeather.delete(dayKey)
emit()
}
export async function setFlash(message: string): Promise<void> {
const flash = await api.setFlash(message)
state.flash = flash
emit()
}
export async function updateClass(name: string, level: string | null): Promise<void> {
const klass = await api.updateClass(name, level)
state.klass = klass
if (state.session) state.session = { ...state.session, klass }
emit()
}

View file

@ -1,16 +1,40 @@
import { h, mount } from '../dom' import { h, mount } from '../dom'
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config' import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
import { signOut } from '../store' import {
currentFlash,
currentKlass,
isAdmin,
setFlash,
signOut,
updateClass,
} from '../store'
import { showToast } from '../toast' import { showToast } from '../toast'
const inputClass =
'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors'
const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5'
export function renderPlus(root: HTMLElement, rerender: () => void): void { export function renderPlus(root: HTMLElement, rerender: () => void): void {
const klass = currentKlass()
const flash = currentFlash()
const admin = isAdmin()
const serverInput = h('input', { const serverInput = h('input', {
class: 'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-sky-500 transition-colors', class: inputClass,
value: getBaseUrl(), value: getBaseUrl(),
autocomplete: 'off', autocomplete: 'off',
inputMode: 'url', inputMode: 'url',
}) })
const nameInput = h('input', { class: inputClass, value: klass?.name ?? '', placeholder: 'Terminale S2' })
const levelInput = h('input', { class: inputClass, value: klass?.level ?? '', placeholder: 'Lycée' })
const flashInput = h('textarea', {
class: `${inputClass} min-h-[70px] resize-none`,
placeholder: 'Message du jour pour la classe…',
})
flashInput.value = flash?.message ?? ''
const legendItems: { icon: string; title: string; accent: string; desc: string }[] = [ const legendItems: { icon: string; title: string; accent: string; desc: string }[] = [
{ icon: '☀️', title: 'Grand Soleil', accent: 'text-sky-400', desc: 'Journée calme. Aucun examen répertorié ni suspecté.' }, { icon: '☀️', title: 'Grand Soleil', accent: 'text-sky-400', desc: 'Journée calme. Aucun examen répertorié ni suspecté.' },
{ icon: '🌫️', title: 'Brouillard', accent: 'text-purple-400', desc: 'Zone d’incertitude. Rumeur ou sous-entendu d’un professeur.' }, { icon: '🌫️', title: 'Brouillard', accent: 'text-purple-400', desc: 'Zone d’incertitude. Rumeur ou sous-entendu d’un professeur.' },
@ -19,69 +43,140 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
{ icon: '🚨', title: 'Alerte Rouge / Tempête', accent: 'text-red-500', desc: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.' }, { icon: '🚨', title: 'Alerte Rouge / Tempête', accent: 'text-red-500', desc: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.' },
] ]
mount( const children: (Node | null)[] = []
root,
// --- Panneau admin ---
if (admin) {
children.push(
h(
'div',
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' },
h('div', { class: 'flex items-center gap-2' },
h('i', { class: 'fa-solid fa-user-shield text-sky-400' }),
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'),
),
// Flash info
h('div', {},
h('span', { class: labelClass }, 'Flash info du jour'),
flashInput,
h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' },
'Message visible par toute la classe sur l’écran Météo.'),
h(
'button',
{
class: 'mt-2 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: async () => {
try {
await setFlash(flashInput.value.trim())
showToast('Flash info mis à jour', 'success')
rerender()
} catch (error) {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
}
},
},
'Publier le flash info',
),
),
// Édition classe
h('div', { class: 'pt-3 border-t border-slate-700/60' },
h('span', { class: labelClass }, 'Nom de la classe'),
h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput),
h(
'button',
{
class: 'mt-2 w-full bg-indigo-500 text-white text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: async () => {
try {
await updateClass(nameInput.value.trim(), levelInput.value.trim() || null)
showToast('Classe mise à jour', 'success')
rerender()
} catch (error) {
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
}
},
},
'Enregistrer la classe',
),
),
),
)
}
// --- Serveur ---
children.push(
h( h(
'div', 'div',
{ class: 'space-y-5' }, { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' },
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-3' }, 'Serveur API'),
serverInput,
h('p', { class: 'text-[10px] text-slate-500 font-semibold mt-2' },
'Adresse de l’API. Émulateur : 10.0.2.2:8787 · Réseau local : IP du PC · Public : URL Tailscale.'),
h( h(
'div', 'button',
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' }, {
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-3' }, 'Serveur API'), class: 'mt-3 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
serverInput, onclick: () => {
h('p', { class: 'text-[10px] text-slate-500 font-semibold mt-2' }, setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
'Adresse de l’API. Sur l’émulateur Android : 10.0.2.2:8787'), showToast('Adresse du serveur enregistrée', 'success')
h( rerender()
'button',
{
class: 'mt-3 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: () => {
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
showToast('Adresse du serveur enregistrée', 'success')
rerender()
},
}, },
'Enregistrer l’adresse', },
), 'Enregistrer l’adresse',
), ),
),
)
// --- Session ---
children.push(
h(
'div',
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' },
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-1' }, 'Session'),
h( h(
'div', 'button',
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' }, {
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-1' }, 'Session'), class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
h( onclick: () => {
'button', signOut()
{ rerender()
class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: () => {
signOut()
rerender()
},
}, },
h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), },
'Se déconnecter', h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }),
), 'Se déconnecter',
), ),
h('div', {}, ),
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-2' }, 'Légende météorologique'), )
h('div', { class: 'grid grid-cols-1 gap-2.5' },
...legendItems.map((item) => // --- Légende ---
h( children.push(
'div', h('div', {},
{ class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' }, h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-2' }, 'Légende météorologique'),
h('span', { class: 'text-3xl' }, item.icon), h('div', { class: 'grid grid-cols-1 gap-2.5' },
h('div', {}, ...legendItems.map((item) =>
h('h4', { class: `text-sm font-black ${item.accent}` }, item.title), h(
h('p', { class: 'text-xs text-slate-300 font-medium' }, item.desc), 'div',
), { class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' },
h('span', { class: 'text-3xl' }, item.icon),
h('div', {},
h('h4', { class: `text-sm font-black ${item.accent}` }, item.title),
h('p', { class: 'text-xs text-slate-300 font-medium' }, item.desc),
), ),
), ),
), ),
), ),
h(
'p',
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
'Météo Exam — v0.1.0 · Radar de rumeurs et métriques en phase 2',
),
), ),
) )
children.push(
h(
'p',
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
'Météo Exam — v0.2.0 · Radar de rumeurs en phase 2',
),
)
mount(root, ...children.filter((c): c is Node => c !== null))
} }

View file

@ -13,12 +13,16 @@ import {
} from '../dates' } from '../dates'
import { examCard, examDetail, weatherBadge } from './cards' import { examCard, examDetail, weatherBadge } from './cards'
import { import {
clearDayWeather,
currentFlash,
dayStatus, dayStatus,
examsByDay, examsByDay,
getState, getState,
isAdmin, isAdmin,
isDayOverridden,
removeExam, removeExam,
selectDay, selectDay,
setDayWeather,
setError, setError,
} from '../store' } from '../store'
import { showToast } from '../toast' import { showToast } from '../toast'
@ -105,7 +109,7 @@ function todayHero(onOpen: (exam: Exam) => void): HTMLElement {
} }
/** Frise horizontale des 7 prochains jours. */ /** Frise horizontale des 7 prochains jours. */
function weekStrip(): HTMLElement { function weekStrip(onAdminDay: (dayKey: string) => void): HTMLElement {
const { stripDays, selectedDay } = getState() const { stripDays, selectedDay } = getState()
const today = new Date() const today = new Date()
@ -118,6 +122,7 @@ function weekStrip(): HTMLElement {
const count = examsByDay().get(key)?.length ?? 0 const count = examsByDay().get(key)?.length ?? 0
const isToday = isSameDay(date, today) const isToday = isSameDay(date, today)
const active = selectedDay === key const active = selectedDay === key
const overridden = isDayOverridden(key)
return h( return h(
'button', 'button',
@ -127,7 +132,10 @@ function weekStrip(): HTMLElement {
? 'bg-slate-700/60 border-sky-400/60' ? 'bg-slate-700/60 border-sky-400/60'
: 'bg-slate-800/70 border-slate-700/60' : 'bg-slate-800/70 border-slate-700/60'
}`, }`,
onclick: () => selectDay(key), onclick: () => {
if (isAdmin()) onAdminDay(key)
else selectDay(key)
},
}, },
h('span', { class: `text-[9px] font-black tracking-wider ${isToday ? 'text-sky-400' : 'text-slate-500'}` }, h('span', { class: `text-[9px] font-black tracking-wider ${isToday ? 'text-sky-400' : 'text-slate-500'}` },
isToday ? "AUJ." : dayNameShort(date)), isToday ? "AUJ." : dayNameShort(date)),
@ -135,11 +143,118 @@ function weekStrip(): HTMLElement {
h('span', { class: 'text-base leading-none' }, WEATHER[status].icon), h('span', { class: 'text-base leading-none' }, WEATHER[status].icon),
h('span', { class: 'text-[9px] font-bold text-slate-500' }, h('span', { class: 'text-[9px] font-bold text-slate-500' },
count === 0 ? '—' : `${count} éval.`), count === 0 ? '—' : `${count} éval.`),
overridden
? h('span', { class: 'text-[7px] font-black text-sky-400 uppercase' }, 'forcé')
: null,
) )
}), }),
) )
} }
/** Bandeau flash info du jour (message rédigé par l'admin). */
function flashBanner(): HTMLElement | null {
const flash = currentFlash()
if (!flash?.message) return null
return h(
'div',
{ class: 'bg-gradient-to-r from-sky-500/15 to-indigo-500/15 border border-sky-400/25 rounded-2xl p-3.5 flex items-start gap-2.5' },
h('span', { class: 'text-lg leading-none mt-0.5' }, '📻'),
h(
'div',
{ class: 'flex-1' },
h('p', { class: 'text-[10px] font-black uppercase tracking-wider text-sky-400 mb-0.5' }, 'Flash info du jour'),
h('p', { class: 'text-xs font-bold text-slate-200 leading-relaxed' }, flash.message),
),
)
}
/** Modale admin : forcer la météo d'un jour. */
function openDayWeatherModal(dayKeyValue: string, rerender: () => void): void {
const overlay = h('div', {
class: 'fixed inset-0 z-[95] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center',
})
const close = (): void => overlay.remove()
const current = dayStatus(dayKeyValue)
const [y, m, d] = dayKeyValue.split('-').map(Number)
const date = new Date(y!, (m ?? 1) - 1, d ?? 1)
const options = h(
'div',
{ class: 'grid grid-cols-1 gap-2' },
...(['soleil', 'brouillard', 'vent', 'orage', 'tempete'] as const).map((kind) => {
const meta = WEATHER[kind]
const active = current === kind && isDayOverridden(dayKeyValue)
return h(
'button',
{
class: `flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
active ? meta.badge : 'bg-slate-800/60 border-slate-700 text-slate-300'
}`,
onclick: async () => {
try {
await setDayWeather(dayKeyValue, kind)
showToast(`Météo forcée : ${meta.label}`, 'success')
close()
rerender()
} catch (error) {
setError(error instanceof Error ? error.message : 'Erreur')
showToast('Erreur lors de la mise à jour', 'reject')
}
},
},
h('span', { class: 'text-2xl' }, meta.icon),
h('div', {},
h('p', { class: 'text-sm font-black' }, meta.title),
h('p', { class: 'text-[11px] font-semibold opacity-70' }, meta.hint),
),
)
}),
isDayOverridden(dayKeyValue)
? h(
'button',
{
class: 'w-full mt-2 bg-slate-800 border border-slate-700 text-slate-300 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
onclick: async () => {
try {
await clearDayWeather(dayKeyValue)
showToast('Override météo retiré', 'info')
close()
rerender()
} catch (error) {
setError(error instanceof Error ? error.message : 'Erreur')
}
},
},
h('i', { class: 'fa-solid fa-rotate-left mr-1.5' }),
'Retirer l’override (météo auto)',
)
: null,
)
const panel = h(
'div',
{ class: 'w-full sm:max-w-md bg-slate-900 border border-slate-700 rounded-t-[2rem] sm:rounded-[2rem] p-5 max-h-[85vh] overflow-y-auto no-scrollbar shadow-2xl' },
h(
'div',
{ class: 'flex items-start justify-between gap-3 mb-4' },
h('div', {},
h('h2', { class: 'text-lg font-black text-slate-100' }, 'Météo du jour'),
h('p', { class: 'text-xs text-slate-400 font-semibold mt-0.5' }, longDate(date)),
),
h('button', { class: 'text-slate-400 hover:text-slate-200 text-lg', onclick: close },
h('i', { class: 'fa-solid fa-xmark' })),
),
options,
)
overlay.append(panel)
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close()
})
document.body.append(overlay)
}
/** Liste groupée par jour, filtrée ou non par la frise. */ /** Liste groupée par jour, filtrée ou non par la frise. */
function dayList(onOpen: (exam: Exam) => void): HTMLElement { function dayList(onOpen: (exam: Exam) => void): HTMLElement {
const { selectedDay, stripDays } = getState() const { selectedDay, stripDays } = getState()
@ -261,16 +376,19 @@ function openDetail(exam: Exam, rerender: () => void): void {
export function renderWeek(root: HTMLElement, rerender: () => void): void { export function renderWeek(root: HTMLElement, rerender: () => void): void {
const { error, session } = getState() const { error, session } = getState()
const open = (exam: Exam): void => openDetail(exam, rerender) const open = (exam: Exam): void => openDetail(exam, rerender)
const openAdminDay = (dk: string): void => openDayWeatherModal(dk, rerender)
const children: (Node | null)[] = [ const children: (Node | null)[] = [
flashBanner(),
todayHero(open), todayHero(open),
h( h(
'div', 'div',
{ class: 'flex justify-between items-center pt-2' }, { class: 'flex justify-between items-center pt-2' },
h('h3', { class: 'text-base font-extrabold text-slate-200 uppercase tracking-wider' }, 'La carte de la semaine'), h('h3', { class: 'text-base font-extrabold text-slate-200 uppercase tracking-wider' }, 'La carte de la semaine'),
h('span', { class: 'text-xs font-bold text-sky-400' }, '7 prochains jours'), h('span', { class: 'text-xs font-bold text-sky-400' },
isAdmin() ? 'Tapez un jour pour forcer la météo' : '7 prochains jours'),
), ),
weekStrip(), weekStrip(openAdminDay),
] ]
if (error) { if (error) {