ENRE_WEB/gestor/reglas.go
SITO ce3127baff Sección «Pedir evento» con gestor de reservas y calendario legible en móvil
- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
2026-09-23 11:32:25 +02:00

252 lines
7.2 KiB
Go

package main
import (
"fmt"
"sort"
"strings"
"time"
)
// Reglas reune los limites que acordo la asamblea. Los valores vienen de
// /etc/enrecal/config.json para poder ajustarlos sin recompilar.
type Reglas struct {
MesesVentana int `json:"meses_ventana"` // no se puede pedir mas alla de hoy + N meses
CupoDias int `json:"cupo_dias"` // dias por colectivo dentro de la ventana
DiasMinimoConfirmar int `json:"dias_minimo_confirmar"` // por debajo de esto, una solicitud sin confirmar caduca
DiasMaximoPendiente int `json:"dias_maximo_pendiente"` // una solicitud no puede esperar mas que esto
DiasReservables []int `json:"dias_reservables"` // 1=lunes .. 7=domingo
AnclaFinde Dia `json:"ancla_finde"` // un viernes de referencia
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
}
func ReglasPorDefecto() Reglas {
return Reglas{
MesesVentana: 3,
CupoDias: 3,
DiasMinimoConfirmar: 7,
DiasMaximoPendiente: 30,
DiasReservables: []int{5, 6, 7},
AnclaFinde: "2026-09-25",
TurnoAncla: Ruido,
}
}
// Ventana devuelve el primer y el ultimo dia que se pueden pedir hoy.
func (rg Reglas) Ventana(hoy Dia) (Dia, Dia) {
primero := hoy.Mas(rg.DiasMinimoConfirmar)
ultimo := DiaDe(hoy.MustTime().AddDate(0, rg.MesesVentana, 0))
return primero, ultimo
}
func (rg Reglas) reservable(d Dia) bool {
iso := d.DiaISO()
for _, v := range rg.DiasReservables {
if v == iso {
return true
}
}
return false
}
// Solicitud es lo que llega del formulario, ya recortado pero sin validar.
type Solicitud struct {
Solicitante string
Rol Rol
Titulo string
Amadrinado string
Contacto string
Desde Dia
Hasta Dia
Hora string
Notas string
Excluir string // ID del evento que se está modificando: no choca consigo mismo
}
// Ocupacion mapea cada dia ocupado con el titulo del evento que lo ocupa.
func Ocupacion(eventos []Evento, excluir string) map[Dia]Evento {
out := map[Dia]Evento{}
for _, e := range eventos {
if e.ID == excluir || !e.Estado.Ocupa() {
continue
}
for _, d := range e.Dias() {
out[d] = e
}
}
return out
}
// CupoUsado cuenta los dias que un colectivo ya tiene ocupados dentro de la
// ventana de reserva, sumando solicitados y confirmados.
func (rg Reglas) CupoUsado(usuario string, eventos []Evento, hoy Dia, excluir string) []Dia {
_, ultimo := rg.Ventana(hoy)
var dias []Dia
for _, e := range eventos {
if e.ID == excluir || e.Solicitante != usuario || !e.Estado.Ocupa() {
continue
}
for _, d := range e.Dias() {
if d >= hoy && d <= ultimo {
dias = append(dias, d)
}
}
}
sort.Slice(dias, func(i, j int) bool { return dias[i] < dias[j] })
return dias
}
// Validar aplica todas las reglas a una solicitud nueva. Devuelve un error con
// el motivo escrito para mostrarlo tal cual en el formulario.
func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
if strings.TrimSpace(s.Titulo) == "" {
return fmt.Errorf("falta el nombre del evento")
}
if len([]rune(s.Titulo)) > 80 {
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
}
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
}
if _, err := s.Desde.Time(); err != nil {
return err
}
if _, err := s.Hasta.Time(); err != nil {
return err
}
if s.Hasta < s.Desde {
return fmt.Errorf("la fecha de fin es anterior a la de inicio")
}
if s.Hora != "" {
if _, err := time.Parse("15:04", s.Hora); err != nil {
return fmt.Errorf("la hora %q no vale, se escribe como 20:00", s.Hora)
}
}
dias := Rango(s.Desde, s.Hasta)
libre := s.Rol == RolEnre // la asamblea de la casa no tiene cupo ni ventana
if !libre {
primero, ultimo := rg.Ventana(hoy)
if s.Desde < primero {
return fmt.Errorf("hay que pedirlo con al menos %d días de antelación: "+
"la primera fecha libre es el %s. Con menos margen la solicitud caducaría sin que "+
"la asamblea llegue a verla",
rg.DiasMinimoConfirmar, primero.Legible())
}
if s.Hasta > ultimo {
return fmt.Errorf("solo se pueden pedir fechas hasta %s, que son %d meses vista",
ultimo.Legible(), rg.MesesVentana)
}
if len(dias) > rg.CupoDias {
return fmt.Errorf("un evento no puede ocupar más de %d días y estás pidiendo %d",
rg.CupoDias, len(dias))
}
for _, d := range dias {
if !rg.reservable(d) {
return fmt.Errorf("el %s no es día reservable: las fechas se piden para %s. "+
"Para otro día, habladlo en la asamblea", d.Legible(), rg.nombresReservables())
}
}
}
ocupados := Ocupacion(eventos, s.Excluir)
for _, d := range dias {
if e, ok := ocupados[d]; ok {
return fmt.Errorf("el %s ya está cogido por %q (%s)", d.Legible(), e.Titulo,
strings.ToLower(e.Estado.Etiqueta()))
}
}
if !libre {
usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir)
if len(usados)+len(dias) > rg.CupoDias {
return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+
"Ya tenéis %s y pedís %d día(s) más. Cancelad una de esas fechas o esperad a que pasen",
rg.CupoDias, rg.MesesVentana, listaDias(usados), len(dias))
}
}
return nil
}
func (rg Reglas) nombresReservables() string {
var out []string
for _, v := range rg.DiasReservables {
if v >= 1 && v <= 7 {
out = append(out, diasSemana[v-1])
}
}
return enumerar(out)
}
// enumerar une una lista en castellano: "viernes, sábado y domingo".
func enumerar(partes []string) string {
switch len(partes) {
case 0:
return ""
case 1:
return partes[0]
}
return strings.Join(partes[:len(partes)-1], ", ") + " y " + partes[len(partes)-1]
}
func listaDias(dias []Dia) string {
var out []string
for _, d := range dias {
out = append(out, d.Legible())
}
return enumerar(out)
}
// Caducidad devuelve el motivo por el que un evento debe caducar, o "" si
// todavia no toca.
func (rg Reglas) Caducidad(e Evento, ahora time.Time) string {
if e.Estado != Solicitado {
return ""
}
hoy := DiaDe(ahora)
if e.Desde < hoy.Mas(rg.DiasMinimoConfirmar) {
return fmt.Sprintf("sin confirmar a menos de %d días de la fecha", rg.DiasMinimoConfirmar)
}
if ahora.Sub(e.Creado) > time.Duration(rg.DiasMaximoPendiente)*24*time.Hour {
return fmt.Sprintf("más de %d días esperando confirmación", rg.DiasMaximoPendiente)
}
return ""
}
// ViernesDe devuelve el viernes del finde al que pertenece un dia, o "" si el
// dia no cae en finde.
func ViernesDe(d Dia) Dia {
iso := d.DiaISO()
if iso < 5 {
return ""
}
return d.Mas(5 - iso)
}
// TurnoDe resuelve si un finde es tranqui o con ruido: primero mira las
// excepciones puestas a mano y si no alterna semana a semana desde el ancla.
func (rg Reglas) TurnoDe(d Dia, exc map[Dia]Turno) Turno {
viernes := ViernesDe(d)
if viernes == "" {
return ""
}
if t, ok := exc[viernes]; ok {
return t
}
ancla := ViernesDe(rg.AnclaFinde)
if ancla == "" {
ancla = rg.AnclaFinde
}
dif := int(viernes.MustTime().Sub(ancla.MustTime()).Hours() / 24 / 7)
if dif < 0 {
dif = -dif
}
otro := Tranqui
if rg.TurnoAncla == Tranqui {
otro = Ruido
}
if dif%2 == 0 {
return rg.TurnoAncla
}
return otro
}