DSAFFDS / app.py
RM0303's picture
Upload app.py
d5f911e verified
Raw History Blame Contribute Delete
88 kB
# -*- coding: utf-8 -*- """CARPOOLING UTEC · prototipo funcional (Emprendimiento I, Grupo 6) Un solo archivo de Python. Hace todo el trabajo "de negocio": usuarios UTEC, viajes, búsqueda por cercanía a la ruta, precios, reservas, calificaciones, ahorro y panel (SQLite). La página (mapa de Google) solo dibuja y pide datos a la API de este mismo archivo. Dónde corre: · Hugging Face (Space de Gradio, cuenta gratuita = ZeroGPU): Gradio levanta el servidor y esta app va dentro. · Google Colab o tu PC: servidor de la librería estándar, no hay nada que instalar (python app.py). Google Maps: la clave se lee del Secret GOOGLE_MAPS_API_KEY del Space. Si falta o Google la rechaza, la página usa OpenStreetMap automáticamente para que la demo no se rompa. """ import inspect import json import math import os import random import re import sqlite3 import sys import threading import time import unicodedata from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse PORT = int(os.environ.get("PORT") or 7860) DB_PATH = os.environ.get("DB_PATH") or "/tmp/carpooling_utec_v2.db" LIMA = timezone(timedelta(hours=-5)) _raw_key = (os.environ.get("GOOGLE_MAPS_API_KEY") or "").strip().strip("\"'") KEY_STATUS = "no configurada" GM_KEY = "" if _raw_key: if re.fullmatch(r"[A-Za-z0-9_\-]{20,100}", _raw_key): GM_KEY, KEY_STATUS = _raw_key, "configurada" else: KEY_STATUS = "con formato extraño (se ignora)" # ---------------------------------------------------------------- modelo (valores del informe) UTEC = (-12.1355, -77.0223) DIST = { "Los Olivos": (-11.9700, -77.0700), "San Martín de Porres": (-12.0100, -77.0850), "Comas": (-11.9400, -77.0550), "Independencia": (-11.9900, -77.0540), "San Juan de Lurigancho": (-12.0030, -77.0080), "Ate": (-12.0260, -76.9170), "La Molina": (-12.0850, -76.9400), "Santiago de Surco": (-12.1450, -76.9900), "Villa María del Triunfo": (-12.1650, -76.9400), "Chorrillos": (-12.1750, -77.0150), "Callao": (-12.0560, -77.1180), "Miraflores": (-12.1220, -77.0300), "Chosica": (-11.9330, -76.6970), } HOURS = ["07:00", "08:00", "10:00", "14:00", "18:00"] TRAFFIC = {"07:00": 1.9, "08:00": 1.9, "10:00": 1.25, "14:00": 1.3, "18:00": 1.8} # solo para el modo sin Google FUEL, MIN_PRICE, MAX_PRICE, BUS_DAY, TAXI_KM, TAXI_MIN, WEEKS, JAM_KMH = 0.60, 3, 40, 7, 1.4, 10, 4.3, 12.7 EMAIL_RE = re.compile(r"[^@\s]+@utec\.edu\.pe", re.I) NAMES = ["Ana", "Diego", "Camila", "Luis", "Valeria", "Jorge", "Sofía", "Andrés", "Renzo", "Mía", "Piero", "Daniela", "Marco", "Lucía", "Bruno", "Fiorella", "Rodrigo", "Alexandra", "Mateo", "Paula"] CARS = ["Toyota Yaris", "Kia Rio", "Hyundai Accent", "Nissan Versa", "Suzuki Swift", "Chevrolet Sail"] NOTES = ["Puntual", "Salgo a la hora exacta", "Cabe mochila y laptop", ""] SIM_NAMES = ["Pasajero simulado", "Compañero simulado", "Estudiante simulado"] def hav(a, b): t = math.pi / 180 dl, dn = (b[0] - a[0]) * t, (b[1] - a[1]) * t x = math.sin(dl / 2) ** 2 + math.cos(a[0] * t) * math.cos(b[0] * t) * math.sin(dn / 2) ** 2 return 12742 * math.asin(math.sqrt(x)) def road_km(a, b): return hav(a, b) * 1.35 # la calle real es más larga que la línea recta def seg_dist(p, a, b): """Distancia (km) del punto p al segmento a-b: cuánto se desvía el conductor para recogerte.""" k = math.cos((a[0] + b[0]) / 2 * math.pi / 180) def xy(q): return (q[1] * 111.32 * k, q[0] * 110.57) P, A, B = xy(p), xy(a), xy(b) dx, dy = B[0] - A[0], B[1] - A[1] ll = dx * dx + dy * dy t = 0 if ll == 0 else max(0, min(1, ((P[0] - A[0]) * dx + (P[1] - A[1]) * dy) / ll)) return math.hypot(P[0] - (A[0] + t * dx), P[1] - (A[1] + t * dy)) def suggest(km, seats): """Aporte por pasajero: el combustible del trayecto repartido entre todos los ocupantes, a múltiplos de S/ 0.50.""" return max(MIN_PRICE, round(km * FUEL / (seats + 1) * 2) / 2) def mins(h): a, b = h.split(":") return int(a) * 60 + int(b) def nearest_district(p): return min(DIST, key=lambda k: hav(p, DIST[k])) def now_lima(): return datetime.now(LIMA) def today_str(): return now_lima().strftime("%Y-%m-%d") def next_weekday(after=None): d = (after or now_lima()) + timedelta(days=1) while d.weekday() >= 5: d += timedelta(days=1) return d.strftime("%Y-%m-%d") def slug(s): return re.sub(r"[^a-z]", "", unicodedata.normalize("NFD", s).encode("ascii", "ignore").decode().lower()) # ---------------------------------------------------------------- base de datos LOCK = threading.RLock() def _connect(): for path in (DB_PATH, ":memory:"): try: c = sqlite3.connect(path, check_same_thread=False) c.row_factory = sqlite3.Row c.execute("PRAGMA journal_mode=MEMORY") return c, path except Exception as e: # sin permisos de escritura: seguimos en memoria print(f"[arranque] no pude abrir {path}: {e!r}", flush=True) raise RuntimeError("sin base de datos") DB, DB_USED = _connect() DB.executescript(""" CREATE TABLE IF NOT EXISTS drivers(email TEXT PRIMARY KEY, name TEXT, car TEXT, base REAL DEFAULT 0, base_n INTEGER DEFAULT 0, sum REAL DEFAULT 0, n INTEGER DEFAULT 0, seeded INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS trips(id INTEGER PRIMARY KEY AUTOINCREMENT, driver TEXT, from_name TEXT, lat REAL, lon REAL, date TEXT, time TEXT, seats INTEGER, free INTEGER, price REAL, km REAL, notes TEXT, status TEXT, seeded INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS bookings(id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, email TEXT, name TEXT, plat REAL, plon REAL, price REAL, code TEXT, status TEXT, stars INTEGER, comment TEXT, pickup TEXT DEFAULT '', created REAL); """) def seed(): """Conductores de ejemplo (ficticios) repartidos por 13 distritos; siempre los mismos.""" r = random.Random(7) date = next_weekday() n = 0 for dn, c in DIST.items(): for j in range(2 + (1 if r.random() < 0.4 else 0)): name = NAMES[n % len(NAMES)] email = f"{slug(name)}{n}@utec.edu.pe" n += 1 lat, lon = c[0] + (r.random() - .5) * .024, c[1] + (r.random() - .5) * .024 seats = 1 + int(r.random() * 3) km = road_km((lat, lon), UTEC) DB.execute("INSERT INTO drivers(email,name,car,base,base_n,seeded) VALUES(?,?,?,?,?,1)", (email, name, r.choice(CARS), round(4.3 + r.random() * .6, 1), 4 + int(r.random() * 27))) hour = "08:00" if j == 0 else r.choice(HOURS) DB.execute("INSERT INTO trips(driver,from_name,lat,lon,date,time,seats,free,price,km,notes,status,seeded) " "VALUES(?,?,?,?,?,?,?,?,?,?,?, 'active',1)", (email, dn, lat, lon, date, hour, seats, seats, suggest(km, seats), round(km, 1), r.choice(NOTES))) DB.commit() with LOCK: if DB.execute("SELECT COUNT(*) FROM trips").fetchone()[0] == 0: seed() # ---------------------------------------------------------------- utilidades de validación class ApiError(Exception): def __init__(self, msg, code=400): super().__init__(msg) self.msg, self.code = msg, code def clean(s, n): """Texto seguro: sin caracteres de control ni < >, espacios normalizados y largo máximo.""" s = "" if s is None else str(s) s = "".join(ch for ch in s if ch.isprintable() and ch not in "<>") return re.sub(r"\s+", " ", s).strip()[:n] def num(q, key, lo, hi, default=None): v = q.get(key, default) try: v = float(v) except (TypeError, ValueError): raise ApiError(f"Dato inválido: {key}.") if math.isnan(v) or v < lo or v > hi: raise ApiError(f"Dato fuera de rango: {key}.") return v def user_email(q): e = clean(q.get("email"), 80).lower() if not EMAIL_RE.fullmatch(e): raise ApiError("Ingresa con tu correo @utec.edu.pe.", 401) return e def point(q, a="lat", b="lng"): """Punto dentro de Lima (la demo solo cubre esa zona).""" la, lo = num(q, a, -13.5, -11.0), num(q, b, -78.0, -76.0) return (la, lo) def rating(row): n = (row["base_n"] or 0) + (row["n"] or 0) return (None if n == 0 else round((row["base"] * row["base_n"] + row["sum"]) / n, 1)), n def trip_json(r, me=None): avg, n = rating(r) return {"id": r["id"], "driver": {"name": r["name"], "car": r["car"], "rating": avg, "n": n}, "from": r["from_name"], "lat": r["lat"], "lon": r["lon"], "date": r["date"], "time": r["time"], "seats": r["seats"], "free": r["free"], "price": r["price"], "km": r["km"], "notes": r["notes"], "status": r["status"], "own": me is not None and r["driver"] == me} TRIP_SQL = ("SELECT t.*, d.name, d.car, d.base, d.base_n, d.sum, d.n FROM trips t " "JOIN drivers d ON d.email = t.driver ") def default_name(e): return e.split("@")[0].split(".")[0].capitalize() # ---------------------------------------------------------------- API def h_login(q): e = clean(q.get("email"), 80).lower() if not EMAIL_RE.fullmatch(e): raise ApiError("Escribe un correo que termine en @utec.edu.pe.") with LOCK: d = DB.execute("SELECT seeded FROM drivers WHERE email=?", (e,)).fetchone() if d and d["seeded"]: raise ApiError("Ese correo es de un conductor de ejemplo. Usa el tuyo.") name = clean(q.get("name"), 30) or default_name(e) DB.execute("UPDATE drivers SET name=? WHERE email=?", (name, e)) DB.commit() return {"user": {"email": e, "name": name}} def h_search(q): la, lo = point(q) hour = clean(q.get("time"), 5) or "*" if hour != "*" and hour not in HOURS: raise ApiError("Hora inválida.") radius = num(q, "radius", 1, 25, 8) me = clean(q.get("email"), 80).lower() or None today = today_str() out = [] booked = set() with LOCK: cur = DB.execute("UPDATE trips SET date=? WHERE seeded=1 AND status='active' AND date<?", (next_weekday(), today)) if cur.rowcount: DB.commit() # los viajes de ejemplo nunca quedan en el pasado rows = DB.execute(TRIP_SQL + "WHERE t.status='active' AND t.date>=?", (today,)).fetchall() if me: booked = {x[0] for x in DB.execute("SELECT trip_id FROM bookings WHERE email=? AND status='confirmed'", (me,))} for r in rows: if r["free"] < 1 and r["id"] not in booked and r["driver"] != me: continue # un viaje lleno solo lo ven su conductor y quien ya tiene asiento dt = 0 if hour == "*" else abs(mins(r["time"]) - mins(hour)) if dt > 60: continue pk = seg_dist((la, lo), (r["lat"], r["lon"]), UTEC) if pk > radius: continue j = trip_json(r, me) j["pass_km"] = round(pk, 1) j["booked"] = r["id"] in booked j["score"] = pk + dt / 60 * 1.5 out.append(j) out.sort(key=lambda t: t["score"]) return {"trips": out[:25], "total": len(out)} def h_quote(q): """Aporte sugerido para publicar un viaje desde un punto.""" km = road_km(point(q), UTEC) return {"km": round(km, 1), "price": suggest(km, int(num(q, "seats", 1, 4, 2)))} def h_savings(q): """Cuánto cuesta hoy viajar a UTEC y cuánto con carpooling (ida y vuelta, por semana).""" ds = clean(q.get("district"), 40) if ds: if ds not in DIST: raise ApiError("Distrito inválido.") pt = DIST[ds] else: pt = point(q) mode = clean(q.get("mode"), 5) or "bus" if mode not in ("bus", "auto", "taxi"): raise ApiError("Modo de viaje inválido.") days = int(num(q, "days", 1, 7, 5)) riders = int(num(q, "riders", 1, 4, 2)) km = road_km(pt, UTEC) price = suggest(km, riders) now = BUS_DAY * days if mode == "bus" else km * FUEL * 2 * days if mode == "auto" else max(TAXI_MIN, km * TAXI_KM) * 2 * days cp = price * 2 * days gasto = km * FUEL * 2 * days rec = price * riders * 2 * days return {"km": round(km, 1), "price": price, "now": round(now, 2), "carpool": round(cp, 2), "month": round((now - cp) * WEEKS, 2), "fuel": round(gasto, 2), "recovered": round(rec, 2), "net": round(max(0, gasto - rec), 2), "mode": mode, "riders": riders} def h_panel(q): me = clean(q.get("email"), 80).lower() with LOCK: a = DB.execute("SELECT COUNT(*) c, COALESCE(SUM(free),0) s, COALESCE(AVG(price),0) p FROM trips WHERE status='active'").fetchone() books = DB.execute("SELECT COUNT(*) FROM bookings b JOIN trips t ON t.id=b.trip_id WHERE b.status!='cancelled' AND t.status!='deleted'").fetchone()[0] rated = DB.execute("SELECT COUNT(*) FROM bookings b JOIN trips t ON t.id=b.trip_id WHERE b.stars IS NOT NULL AND t.status!='deleted'").fetchone()[0] mine = DB.execute("SELECT COUNT(*) FROM trips WHERE driver=? AND status!='deleted'", (me,)).fetchone()[0] if me else 0 zones = DB.execute("SELECT from_name, COUNT(*) c FROM trips WHERE status='active' GROUP BY from_name ORDER BY c DESC, from_name").fetchall() return {"trips": a["c"], "seats": a["s"], "bookings": books, "avg_price": round(a["p"], 2), "mine": mine, "ratings": rated, "zones": [[z[0], z[1]] for z in zones]} def h_publish(q): e = user_email(q) la, lo = point(q, "lat", "lon") time_ = clean(q.get("time"), 5) date = clean(q.get("date"), 10) try: d = datetime.strptime(date, "%Y-%m-%d") if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) else None except ValueError: d = None today = datetime.strptime(today_str(), "%Y-%m-%d") if d is None or d < today: raise ApiError("Elige una fecha de hoy en adelante.") if d > today + timedelta(days=60): raise ApiError("Publica con máximo 60 días de anticipación.") if time_ not in HOURS: raise ApiError("Elige una hora de llegada de la lista.") try: seats_f = float(q.get("seats")) except (TypeError, ValueError): seats_f = float("nan") if not (1 <= seats_f <= 4) or seats_f != int(seats_f): raise ApiError("Los asientos libres deben ser entre 1 y 4.") seats = int(seats_f) try: price = float(q.get("price")) except (TypeError, ValueError): price = float("nan") if not (MIN_PRICE <= price <= MAX_PRICE): raise ApiError(f"El aporte por pasajero debe estar entre S/ {MIN_PRICE} y S/ {MAX_PRICE}.") price = round(price * 2) / 2 notes, car = clean(q.get("notes"), 80), clean(q.get("car"), 40) or "Auto particular" name = clean(q.get("name"), 30) or default_name(e) with LOCK: if DB.execute("SELECT 1 FROM drivers WHERE email=? AND seeded=1", (e,)).fetchone(): raise ApiError("Ese correo es de un conductor de ejemplo.") if DB.execute("SELECT COUNT(*) FROM trips").fetchone()[0] > 2000: raise ApiError("La demo está llena, intenta más tarde.", 503) if DB.execute("SELECT 1 FROM trips WHERE driver=? AND date=? AND time=? AND status='active'", (e, date, time_)).fetchone(): raise ApiError("Ya tienes un viaje activo para esa fecha y hora.") if DB.execute("SELECT COUNT(*) FROM trips WHERE driver=? AND status='active'", (e,)).fetchone()[0] >= 5: raise ApiError("Máximo 5 viajes activos por conductor en la demo.") DB.execute("INSERT INTO drivers(email,name,car) VALUES(?,?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.name, car=excluded.car", (e, name, car)) km = road_km((la, lo), UTEC) cur = DB.execute("INSERT INTO trips(driver,from_name,lat,lon,date,time,seats,free,price,km,notes,status,seeded) " "VALUES(?,?,?,?,?,?,?,?,?,?,?, 'active',0)", (e, nearest_district((la, lo)), la, lo, date, time_, seats, seats, price, round(km, 1), notes)) DB.commit() r = DB.execute(TRIP_SQL + "WHERE t.id=?", (cur.lastrowid,)).fetchone() return {"trip": trip_json(r, e)} def h_book(q): e = user_email(q) pla, plo = point(q, "plat", "plng") tid = int(num(q, "trip_id", 1, 10 ** 9)) name = clean(q.get("name"), 30) or default_name(e) pickup = clean(q.get("pickup"), 5) if not re.fullmatch(r"\d\d:\d\d", pickup): pickup = "" with LOCK: t = DB.execute("SELECT * FROM trips WHERE id=?", (tid,)).fetchone() if not t or t["status"] != "active": raise ApiError("Ese viaje ya no está disponible.", 409) if t["driver"] == e: raise ApiError("No puedes reservar tu propio viaje.") if t["free"] < 1: raise ApiError("El viaje se llenó.", 409) if DB.execute("SELECT 1 FROM bookings WHERE trip_id=? AND email=? AND status='confirmed'", (tid, e)).fetchone(): raise ApiError("Ya tienes un asiento en este viaje.") if DB.execute("SELECT COUNT(*) FROM bookings").fetchone()[0] > 5000: raise ApiError("La demo está llena, intenta más tarde.", 503) code = "UTEC-" + str(random.randint(1000, 9999)) DB.execute("INSERT INTO bookings(trip_id,email,name,plat,plon,price,code,status,pickup,created) VALUES(?,?,?,?,?,?,?, 'confirmed',?,?)", (tid, e, name, pla, plo, t["price"], code, pickup, time.time())) DB.execute("UPDATE trips SET free=free-1 WHERE id=?", (tid,)) DB.commit() d = DB.execute("SELECT name FROM drivers WHERE email=?", (t["driver"],)).fetchone() return {"code": code, "price": t["price"], "driver": d["name"] if d else ""} def _own_booking(e, bid): b = DB.execute("SELECT * FROM bookings WHERE id=? AND email=?", (bid, e)).fetchone() if not b: raise ApiError("No encontré esa reserva en tu cuenta.", 404) return b def h_cancel(q): e = user_email(q) with LOCK: b = _own_booking(e, int(num(q, "booking_id", 1, 10 ** 9))) if b["status"] != "confirmed": raise ApiError("Esa reserva ya no se puede cancelar.") DB.execute("UPDATE bookings SET status='cancelled' WHERE id=?", (b["id"],)) DB.execute("UPDATE trips SET free=MIN(seats, free+1) WHERE id=? AND status='active'", (b["trip_id"],)) DB.commit() return {} def h_finish(q): """Simula que el viaje terminó (en la demo no hay GPS): habilita calificar.""" e = user_email(q) with LOCK: b = _own_booking(e, int(num(q, "booking_id", 1, 10 ** 9))) if b["status"] != "confirmed": raise ApiError("Solo se puede completar una reserva confirmada.") DB.execute("UPDATE bookings SET status='completed' WHERE id=?", (b["id"],)) DB.commit() return {} def h_rate(q): e = user_email(q) stars = int(num(q, "stars", 1, 5)) comment = clean(q.get("comment"), 120) with LOCK: b = _own_booking(e, int(num(q, "booking_id", 1, 10 ** 9))) if b["status"] != "completed": raise ApiError("Solo puedes calificar viajes completados.") if b["stars"]: raise ApiError("Ya calificaste este viaje.") t = DB.execute("SELECT t.driver, d.name FROM trips t JOIN drivers d ON d.email=t.driver WHERE t.id=?", (b["trip_id"],)).fetchone() DB.execute("UPDATE bookings SET stars=?, comment=? WHERE id=?", (stars, comment, b["id"])) DB.execute("UPDATE drivers SET sum=sum+?, n=n+1 WHERE email=?", (stars, t["driver"])) DB.commit() return {"driver": t["name"]} def _own_trip(e, tid): t = DB.execute("SELECT * FROM trips WHERE id=? AND driver=?", (tid, e)).fetchone() if not t: raise ApiError("No encontré ese viaje en tu cuenta.", 404) if t["status"] != "active": raise ApiError("Ese viaje ya no está activo.") return t def h_trip_cancel(q): e = user_email(q) tid = int(num(q, "trip_id", 1, 10 ** 9)) with LOCK: _own_trip(e, tid) DB.execute("UPDATE trips SET status='cancelled' WHERE id=?", (tid,)) DB.execute("UPDATE bookings SET status='cancelled' WHERE trip_id=? AND status='confirmed'", (tid,)) DB.commit() return {} def h_trip_complete(q): e = user_email(q) tid = int(num(q, "trip_id", 1, 10 ** 9)) with LOCK: _own_trip(e, tid) DB.execute("UPDATE trips SET status='completed' WHERE id=?", (tid,)) DB.execute("UPDATE bookings SET status='completed' WHERE trip_id=? AND status='confirmed'", (tid,)) DB.commit() return {} def h_sim_passenger(q): """Para demostrar: suma un pasajero ficticio al viaje del conductor.""" e = user_email(q) tid = int(num(q, "trip_id", 1, 10 ** 9)) with LOCK: t = _own_trip(e, tid) if t["free"] < 1: raise ApiError("Ese viaje no tiene asientos libres.") k = DB.execute("SELECT COUNT(*) FROM bookings WHERE trip_id=?", (tid,)).fetchone()[0] name = SIM_NAMES[t["seats"] - t["free"]] if t["seats"] - t["free"] < len(SIM_NAMES) else SIM_NAMES[0] DB.execute("INSERT INTO bookings(trip_id,email,name,plat,plon,price,code,status,pickup,created) VALUES(?,?,?,?,?,?,?, 'confirmed','',?)", (tid, f"sim{tid}x{k}@utec.edu.pe", name, t["lat"], t["lon"], t["price"], "UTEC-" + str(random.randint(1000, 9999)), time.time())) DB.execute("UPDATE trips SET free=free-1 WHERE id=?", (tid,)) DB.commit() return {} def h_mine(q): e = user_email(q) with LOCK: rider = DB.execute( "SELECT b.id, b.code, b.status, b.stars, b.comment, b.price, b.pickup, t.id AS tid, t.from_name, t.date, t.time, t.status AS tstatus, " "d.name AS dname, d.car FROM bookings b JOIN trips t ON t.id=b.trip_id JOIN drivers d ON d.email=t.driver " "WHERE b.email=? ORDER BY b.id DESC LIMIT 30", (e,)).fetchall() trips = DB.execute("SELECT * FROM trips WHERE driver=? AND status!='deleted' ORDER BY id DESC LIMIT 20", (e,)).fetchall() riders = {} if trips: ids = [t["id"] for t in trips] for b in DB.execute("SELECT trip_id, name FROM bookings WHERE status!='cancelled' AND trip_id IN (%s) ORDER BY id" % ",".join("?" * len(ids)), ids): riders.setdefault(b["trip_id"], []).append(b["name"]) return {"rider": [dict(r) for r in rider], "driver": [dict({k: t[k] for k in ("id", "from_name", "date", "time", "seats", "free", "price", "status")}, riders=riders.get(t["id"], [])) for t in trips]} def h_reset_me(q): """Borra SOLO los datos de quien lo pide: sus reservas, sus calificaciones y sus viajes.""" e = user_email(q) with LOCK: for b in DB.execute("SELECT b.stars, t.driver FROM bookings b JOIN trips t ON t.id=b.trip_id WHERE b.email=? AND b.stars IS NOT NULL", (e,)).fetchall(): DB.execute("UPDATE drivers SET sum=sum-?, n=MAX(0, n-1) WHERE email=?", (b["stars"], b["driver"])) for b in DB.execute("SELECT trip_id FROM bookings WHERE email=? AND status IN ('confirmed','completed')", (e,)).fetchall(): DB.execute("UPDATE trips SET free=MIN(seats, free+1) WHERE id=? AND status='active'", (b["trip_id"],)) DB.execute("DELETE FROM bookings WHERE email=?", (e,)) DB.execute("UPDATE bookings SET status='cancelled' WHERE status='confirmed' AND trip_id IN (SELECT id FROM trips WHERE driver=?)", (e,)) DB.execute("UPDATE trips SET status='deleted' WHERE driver=?", (e,)) DB.execute("UPDATE drivers SET sum=0, n=0 WHERE email=?", (e,)) DB.commit() return {} GET_ROUTES = {"/api/search": h_search, "/api/quote": h_quote, "/api/savings": h_savings, "/api/panel": h_panel, "/api/mine": h_mine} POST_ROUTES = {"/api/login": h_login, "/api/publish": h_publish, "/api/book": h_book, "/api/cancel": h_cancel, "/api/finish": h_finish, "/api/rate": h_rate, "/api/trip_cancel": h_trip_cancel, "/api/trip_complete": h_trip_complete, "/api/sim_passenger": h_sim_passenger, "/api/reset_me": h_reset_me} # ---------------------------------------------------------------- página PAGE = r"""<!DOCTYPE html> <html lang="es"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="referrer" content="strict-origin-when-cross-origin"> <title>CARPOOLING UTEC</title> <meta name="theme-color" content="#10243E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%23FFC233'/%3E%3Cpath transform='translate(3.6 3.6) scale(.7)' fill='%2310243E' fill-rule='evenodd' d='M5.6 9.6 7 6.2C7.3 5.4 8 5 8.8 5h6.4c.8 0 1.5.4 1.8 1.2l1.4 3.4c.6.4 1 1.1 1 2V16c0 .6-.4 1-1 1h-.6v1.3c0 .4-.3.7-.7.7h-1.3c-.4 0-.7-.3-.7-.7V17H8.3v1.3c0 .4-.3.7-.7.7H6.3c-.4 0-.7-.3-.7-.7V17H5c-.6 0-1-.4-1-1v-4.4c0-.9.6-1.6 1.6-2zM8.9 7.2 8 9.7h8l-.9-2.5zM7.5 12.1a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6zm9 0a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6z'/%3E%3C/svg%3E"> <meta name="description" content="Prototipo de carpooling universitario para UTEC: comparte auto con compañeros que salen cerca de ti."> <link rel="preconnect" href="https://fonts.googleapis.com"> <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;800&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet"> <style> :root{--bg:#F3F6F9;--surf:#fff;--ink:#10243E;--mute:#586A80;--line:#D9E1EA;--brand:#10243E;--teal:#0E9AA7;--tealink:#0A7480;--sun:#FFC233;--ok:#17814F;--err:#B3261E;--on:#fff} @media (prefers-color-scheme:dark){:root{--bg:#0B1624;--surf:#122238;--ink:#E8EEF5;--mute:#9BAABD;--line:#24374F;--brand:#0E9AA7;--teal:#3CC7D3;--tealink:#3CC7D3;--ok:#4CC38A;--err:#FF8A80;--on:#06121F}} *{box-sizing:border-box} html,body{margin:0;height:100%} body{background:var(--bg);color:var(--ink);font:15px/1.5 "DM Sans",system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-text-size-adjust:100%} h3,.logo,.price,.num{font-family:"Bricolage Grotesque","DM Sans",system-ui,sans-serif} h3{font-size:17px;margin:22px 0 2px} p{margin:8px 0} .app{display:grid;grid-template-columns:minmax(360px,440px) minmax(0,1fr);height:100vh;height:100dvh} #side{min-width:0;overflow-y:auto;padding:14px 16px 36px;border-right:1px solid var(--line)} #map{position:relative;height:100%;min-height:280px;z-index:0;background:#dde4ea} .mapc{width:100%;height:100%} .nomap{padding:24px;font-weight:700;color:#10243E} .tilesw{position:absolute;left:8px;bottom:22px;z-index:1000;padding:6px 10px;border:1px solid rgba(0,0,0,.25);border-radius:8px;background:rgba(255,255,255,.92);color:#10243E;font:700 12px "DM Sans",system-ui,sans-serif;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.25)} .logo{font-size:22px;font-weight:800;letter-spacing:.02em} .mute{color:var(--mute);font-size:13px} .num{font-weight:800;font-variant-numeric:tabular-nums} .big{font-size:26px;line-height:1.2} .price{font-size:18px;font-weight:800;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums} .between{display:flex;justify-content:space-between;gap:10px;align-items:center} .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;align-items:end} .card{background:var(--surf);border:1px solid var(--line);border-radius:12px;padding:12px;margin-top:10px} label{display:block;font-weight:700;font-size:13px;margin:0 0 4px} input,select{width:100%;padding:9px 11px;border:1.5px solid var(--line);border-radius:9px;background:var(--surf);color:var(--ink);font:inherit} input[type=range]{padding:0;accent-color:var(--teal);height:34px} .field{margin-top:10px} .btn{border:0;border-radius:9px;padding:10px 14px;font:700 14px "DM Sans",system-ui,sans-serif;cursor:pointer;background:var(--brand);color:var(--on);display:inline-flex;align-items:center;justify-content:center;gap:6px} .btn:hover{filter:brightness(1.1)} .btn.sun{background:var(--sun);color:#10243E} .btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)} .btn.danger{background:transparent;color:var(--err);border:1.5px solid var(--err)} .btn.sm{padding:6px 10px;font-size:13px} .btn.full{width:100%} .btn[disabled]{opacity:.5;cursor:default;filter:none} :focus-visible{outline:3px solid var(--teal);outline-offset:2px} .tabs{display:flex;flex-wrap:wrap;gap:4px;margin:14px 0 4px} .tabs button{flex:1 0 auto;padding:9px 7px;border:1.5px solid var(--line);background:var(--surf);color:var(--ink);border-radius:9px;font:700 13px "DM Sans",system-ui,sans-serif;cursor:pointer;white-space:nowrap} .tabs button[aria-selected=true]{background:var(--brand);color:var(--on);border-color:var(--brand)} .panel{display:none}.panel.on{display:block} .trip{cursor:pointer;display:flex;gap:10px;justify-content:space-between;align-items:center} .trip.on{border-color:var(--teal);box-shadow:0 0 0 2px var(--teal)} .who{display:flex;gap:10px;align-items:center} .av{width:38px;height:38px;border-radius:50%;background:var(--brand);color:var(--on);display:grid;place-items:center;font-weight:800;flex:none} .tag{font-size:11px;font-weight:700;padding:1px 7px;border-radius:99px;margin-left:6px;background:color-mix(in srgb,var(--teal) 18%,transparent);color:var(--tealink)} .tag.me{background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)} .chip{font-size:12px;font-weight:700;padding:2px 9px;border-radius:99px;margin-left:6px} .chip.active,.chip.confirmed{background:color-mix(in srgb,var(--teal) 18%,transparent);color:var(--tealink)} .chip.completed{background:color-mix(in srgb,var(--ok) 18%,transparent);color:var(--ok)} .chip.cancelled{background:color-mix(in srgb,var(--mute) 20%,transparent);color:var(--mute)} .kv{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin:10px 0} .kv span{display:block;color:var(--mute);font-size:12px} .kv b{display:block;font:800 18px "Bricolage Grotesque",system-ui,sans-serif;font-variant-numeric:tabular-nums} .msg{margin-top:8px;font-weight:700;font-size:14px;min-height:20px;color:var(--mute)} .msg.ok{color:var(--ok)}.msg.err{color:var(--err)} .bar{height:10px;border-radius:5px;background:var(--line);overflow:hidden;margin:4px 0 12px} .bar i{display:block;height:100%;border-radius:5px} .ticket{margin-top:10px;padding:8px 12px;border:1.5px dashed var(--line);border-radius:9px;display:flex;justify-content:space-between;align-items:center} .ticket b{font:800 17px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.08em} .actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .rate{margin-top:10px;display:grid;gap:8px} .stars{display:flex;gap:2px} .star{border:0;background:none;font-size:28px;line-height:1;cursor:pointer;color:var(--line);padding:2px 4px} .star.on{color:var(--sun)} .tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:10px} .tile{background:var(--surf);border:1px solid var(--line);border-radius:12px;padding:10px} .tile b{display:block;font-size:22px} /* pines del mapa: auto (conductor) con globito de asientos libres, gorro (UTEC) y punto "Tú" con halo */ .pin{position:relative;width:38px;height:38px;border-radius:50%;background:#10243E;color:#fff;display:grid;place-items:center;font:800 12px "DM Sans",system-ui,sans-serif;border:3px solid #fff;box-shadow:0 3px 9px rgba(16,36,62,.5);transition:transform .15s ease} .pin svg{width:24px;height:24px;display:block;fill:currentColor} .pin .t{display:none} .pin:hover{transform:scale(1.1)} .pin[data-free]::after{content:attr(data-free);position:absolute;top:-8px;right:-9px;min-width:20px;height:20px;padding:0 4px;box-sizing:border-box;border-radius:10px;border:2px solid #fff;background:#FFC233;color:#10243E;font:800 11px/16px "DM Sans",system-ui,sans-serif;text-align:center} .pin[data-free="0"]::after{background:#8C9AAB;color:#fff} .pin.mine{background:#17814F} .pin.sel{background:#FFC233;color:#10243E;transform:scale(1.2)} .pin.sel:hover{transform:scale(1.25)} .pin.sel::after{background:#10243E;color:#fff} .pin.sel::before,.pin.me::before{content:"";position:absolute;inset:-3px;border-radius:50%;border:3px solid #FFC233;pointer-events:none;animation:ring 1.7s ease-out infinite} .pin.me{background:#0E9AA7;width:36px;height:36px} .pin.me::before{border-color:#0E9AA7} .pin.me svg{display:none} .pin.me .t{display:block} .pin.u{background:#FFC233;color:#10243E;border-radius:12px;width:44px;height:44px} .pin.u svg{width:26px;height:26px} @keyframes ring{0%{transform:scale(.85);opacity:.85}100%{transform:scale(1.7);opacity:0}} @media (pointer:coarse){.pin{width:42px;height:42px}.pin.me{width:40px;height:40px}.pin.u{width:46px;height:46px}} .legend{position:absolute;left:8px;top:8px;z-index:1000;display:flex;flex-wrap:wrap;gap:4px 10px;max-width:calc(100% - 76px);padding:5px 10px;border-radius:10px;background:rgba(255,255,255,.94);color:#10243E;font:700 11.5px/1.3 "DM Sans",system-ui,sans-serif;box-shadow:0 1px 5px rgba(0,0,0,.25);pointer-events:none} .legend span{display:inline-flex;align-items:center;gap:5px;white-space:nowrap} .legend i{width:11px;height:11px;border-radius:50%;background:#10243E;display:inline-block} .legend i.u{background:#FFC233;border-radius:3px}.legend i.me{background:#0E9AA7}.legend i.mine{background:#17814F} .legend b{display:inline-grid;place-items:center;min-width:14px;height:14px;border-radius:7px;background:#FFC233;font-size:10px;padding:0 3px} .brand{display:flex;align-items:center;gap:10px} .mark{flex:none;width:36px;height:36px;border-radius:11px;background:#FFC233;color:#10243E;display:grid;place-items:center} .mark svg{width:23px;height:23px;fill:currentColor} .pills{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px} .pill{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:700;padding:2px 9px 2px 7px;border-radius:99px;background:color-mix(in srgb,var(--ink) 8%,transparent);color:var(--ink)} .pill svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round} .pill.warn{background:color-mix(in srgb,var(--sun) 40%,transparent)} .info{min-width:0} .trip{transition:border-color .15s,box-shadow .15s} .trip:hover{border-color:var(--teal)} .list li{margin:4px 0} #detail{scroll-margin-top:10px} @media (max-width:820px){ #detail{scroll-margin-top:calc(clamp(220px,40vh,360px) + 10px);scroll-margin-top:calc(clamp(220px,40dvh,360px) + 10px)} .app{grid-template-columns:minmax(0,1fr);height:auto} #map{order:-1;height:clamp(220px,40vh,360px);height:clamp(220px,40dvh,360px);min-height:0;position:sticky;top:0;z-index:5} #side{overflow:visible;border-right:0} .kv{grid-template-columns:1fr 1fr} } @media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} </style> </head> <body> <noscript>Esta página necesita JavaScript para funcionar.</noscript> <div class="app"> <aside id="side"> <div class="brand"><span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill-rule="evenodd" d="M5.6 9.6 7 6.2C7.3 5.4 8 5 8.8 5h6.4c.8 0 1.5.4 1.8 1.2l1.4 3.4c.6.4 1 1.1 1 2V16c0 .6-.4 1-1 1h-.6v1.3c0 .4-.3.7-.7.7h-1.3c-.4 0-.7-.3-.7-.7V17H8.3v1.3c0 .4-.3.7-.7.7H6.3c-.4 0-.7-.3-.7-.7V17H5c-.6 0-1-.4-1-1v-4.4c0-.9.6-1.6 1.6-2zM8.9 7.2 8 9.7h8l-.9-2.5zM7.5 12.1a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6zm9 0a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6z"/></svg></span><div class="logo">CARPOOLING UTEC</div></div> <div class="mute">Simulación para validar la idea. Conductores, precios y calificaciones de ejemplo son ficticios; el mapa y las rutas son reales.</div> <div class="mute" id="mapState" role="status" aria-live="polite">Cargando el mapa…</div> <section class="card" id="auth" aria-label="Tu cuenta"></section> <section class="card" aria-label="Tu punto en el mapa"> <div class="row2"> <div><label for="dist">Tu punto</label><select id="dist"></select></div> <button class="btn sun" id="geo" type="button" data-act="locate"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><circle cx="12" cy="12" r="3.2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/></svg>Usar mi ubicación</button> </div> <div class="msg" id="geoMsg" role="status" aria-live="polite">Toca el mapa o arrastra el punto “Tú” para afinar.</div> </section> <div class="tabs" role="tablist" aria-label="Secciones"> <button role="tab" id="tb-pas" aria-selected="true" aria-controls="t-pas" data-tab="pas">Pasajero</button> <button role="tab" id="tb-con" aria-selected="false" aria-controls="t-con" data-tab="con">Conductor</button> <button role="tab" id="tb-mis" aria-selected="false" aria-controls="t-mis" data-tab="mis">Mis viajes</button> <button role="tab" id="tb-aho" aria-selected="false" aria-controls="t-aho" data-tab="aho">Ahorro</button> <button role="tab" id="tb-pan" aria-selected="false" aria-controls="t-pan" data-tab="pan">Panel</button> <button role="tab" id="tb-inf" aria-selected="false" aria-controls="t-inf" data-tab="inf">Info</button> </div> <div class="panel on" id="t-pas" role="tabpanel" aria-labelledby="tb-pas"> <div class="row2" style="margin-top:8px"> <div><label for="hora">Llegar a UTEC a las</label><select id="hora"></select></div> <div><label for="rad">Radio: <span id="radv">8</span> km</label><input id="rad" type="range" min="1" max="20" value="8"></div> </div> <div class="msg" id="count" role="status" aria-live="polite"></div> <div id="list"></div> <div id="detail"></div> <div class="msg" id="bookMsg" role="status" aria-live="polite"></div> </div> <div class="panel" id="t-con" role="tabpanel" aria-labelledby="tb-con"> <p class="mute">Sales desde “Tu punto” (el pin del mapa) hacia UTEC. El aporte sugerido reparte el combustible entre todos los ocupantes del auto.</p> <div class="row2 field"><div><label for="pcar">Tu auto</label><input id="pcar" maxlength="40" placeholder="Toyota Yaris"></div><div><label for="phora">Llegas a UTEC a las</label><select id="phora"></select></div></div> <div class="row2 field"><div><label for="pdate">Fecha</label><input id="pdate" type="date"></div><div><label for="pseats">Asientos libres</label><select id="pseats"><option>1</option><option selected>2</option><option>3</option><option>4</option></select></div></div> <div class="row2 field"><div><label for="pprice">Aporte por pasajero (S/)</label><input id="pprice" type="number" min="3" max="40" step="0.5" inputmode="decimal"></div><button class="btn ghost" id="psuggest" type="button" data-act="suggest">Calcular aporte sugerido</button></div> <div class="field"><label for="pnotes">Notas para pasajeros</label><input id="pnotes" maxlength="80" placeholder="Ej. Salgo puntual; punto de recojo en la Av. Universitaria"></div> <button class="btn full" id="ppub" type="button" data-act="publish" style="margin-top:14px">Publicar viaje</button> <div class="msg" id="pubMsg" role="status" aria-live="polite"></div> </div> <div class="panel" id="t-mis" role="tabpanel" aria-labelledby="tb-mis"> <div class="msg" id="mineMsg" role="status" aria-live="polite"></div> <h3>Como pasajero</h3><div id="mineRider"></div> <h3>Como conductor</h3><div id="mineDriver"></div> </div> <div class="panel" id="t-aho" role="tabpanel" aria-labelledby="tb-aho"> <div class="row2 field"><div><label for="sdist">Desde</label><select id="sdist"></select></div><div><label for="smode">Hoy viajas en</label><select id="smode"><option value="bus">Transporte público</option><option value="auto">Auto propio, solo</option><option value="taxi">Taxi / app</option></select></div></div> <div class="row2 field"><div><label for="sdays">Días por semana</label><select id="sdays"><option>1</option><option>2</option><option>3</option><option>4</option><option selected>5</option><option>6</option></select></div><div><label for="sriders">Pasajeros en el auto</label><select id="sriders"><option>1</option><option selected>2</option><option>3</option><option>4</option></select></div></div> <div id="sout"></div> </div> <div class="panel" id="t-pan" role="tabpanel" aria-labelledby="tb-pan"> <p class="mute">Estado de la plataforma simulada, con los datos de este servidor.</p> <div class="tiles" id="tiles"></div> <h3>Viajes activos por zona</h3><div id="byDist"></div> </div> <div class="panel" id="t-inf" role="tabpanel" aria-labelledby="tb-inf"> <h3>Qué valida este prototipo</h3> <p>Que estudiantes con trayectos largos aceptarían coordinar un auto compartido si hay confianza (correo UTEC, calificaciones) y una hora de salida acordada.</p> <h3>Qué no resuelve</h3> <p>El tráfico de Lima. Compartir auto fija la hora de salida y reparte el gasto, pero el tiempo de viaje sigue dependiendo de las vías.</p> <h3>Limitaciones</h3> <ul class="list mute" style="padding-left:18px"> <li>El correo solo se valida por su dominio (@utec.edu.pe), sin código de verificación ni contraseña.</li> <li>Los datos viven en el servidor de este Space: todos ven los mismos viajes, pero se borran cuando el Space se reinicia o se duerme. Los conductores y calificaciones de ejemplo son simulados.</li> <li>El tiempo de viaje sale de Google Maps con el tráfico estimado para esa hora cuando el Space tiene clave de Google; sin clave usa la ruta de OpenStreetMap (OSRM) multiplicada por un factor de tráfico según la hora, no datos en vivo.</li> <li>Las cifras de costo salen de las entrevistas del informe: S/ 30 a 60 semanales en pasajes, S/ 30 a 35 diarios en combustible, 12.7 km/h en hora punta (TomTom).</li> </ul> <h3>Datos de la demo</h3> <p class="mute">Borra solo tus viajes, reservas y calificaciones. Los conductores de ejemplo y los datos de otras personas no se tocan.</p> <button class="btn danger" id="reset" type="button">Restablecer mis datos</button> <div class="msg" id="resetMsg" role="status" aria-live="polite"></div> </div> </aside> <div id="map" role="application" aria-label="Mapa de Lima con conductores disponibles"></div> </div> <script> 'use strict'; const CFG=/*__CFG__*/null; const $=id=>document.getElementById(id); const UTEC=CFG.utec,DIST=CFG.districts,HOURS=CFG.hours,MODEL=CFG.model; const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const pad=n=>String(n).padStart(2,'0'); const mins=h=>{const p=h.split(':');return +p[0]*60+ +p[1]}; const hm=m=>{m=((Math.round(m)%1440)+1440)%1440;return pad(Math.floor(m/60))+':'+pad(m%60)}; const money=n=>'S/ '+Number(n).toFixed(2); const money0=n=>'S/ '+Math.round(n); const initial=s=>(Array.from(String(s||'?').trim())[0]||'?').toUpperCase(); const qs=o=>Object.keys(o).map(k=>k+'='+encodeURIComponent(o[k])).join('&'); const EMAIL=/^[^@\s]+@utec\.edu\.pe$/i; const LBL={active:'Activo',confirmed:'Confirmada',completed:'Completado',cancelled:'Cancelado'}; function hav(a,b){const t=Math.PI/180,dl=(b[0]-a[0])*t,dn=(b[1]-a[1])*t,x=Math.sin(dl/2)**2+Math.cos(a[0]*t)*Math.cos(b[0]*t)*Math.sin(dn/2)**2;return 12742*Math.asin(Math.sqrt(x))} const roadKm=(a,b)=>hav(a,b)*1.35; function say(id,text,kind){const e=$(id);if(!e)return;e.className='msg'+(kind?' '+kind:'');e.textContent=text} /* ---------- sesión (solo el correo y el nombre; sin contraseñas, es una demo) ---------- */ let ME=null; try{const j=JSON.parse(localStorage.getItem('carpool-me')||'null');if(j&&EMAIL.test(j.email||''))ME={email:j.email,name:String(j.name||'')}}catch(e){} function saveMe(){try{if(ME)localStorage.setItem('carpool-me',JSON.stringify(ME));else localStorage.removeItem('carpool-me')}catch(e){}} /* ---------- API de Python ---------- */ async function api(path,body){ try{ const r=await fetch(path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined); const j=await r.json().catch(()=>null); if(!j)return {ok:false,error:'El servidor respondió algo inesperado ('+r.status+').'}; if(!r.ok&&!j.error)j.error='Error '+r.status; return j; }catch(e){return {ok:false,error:'No hay conexión con el servidor. Reintenta en unos segundos.'}} } /* ---------- rutas de respaldo (sin Google): OSRM público + factor de tráfico, o línea recta ---------- */ async function osrmRoute(pts,hour){ const f=CFG.traffic[hour]||1.5;let to; try{ const ac=new AbortController();to=setTimeout(()=>ac.abort(),7000); const r=await fetch('https://router.project-osrm.org/route/v1/driving/'+pts.map(p=>p[1]+','+p[0]).join(';')+'?overview=full&geometries=geojson',{signal:ac.signal}); clearTimeout(to);if(!r.ok)throw new Error('http '+r.status); const q=(await r.json()).routes[0]; return {km:q.distance/1000,min:q.duration/60*f,path:q.geometry.coordinates.map(c=>[c[1],c[0]]),legs:q.legs.map(l=>({km:l.distance/1000,min:l.duration/60*f})),src:'osrm'}; }catch(e){ clearTimeout(to); const legs=[];for(let i=1;i<pts.length;i++){const km=roadKm(pts[i-1],pts[i]);legs.push({km,min:km/CFG.jamKmh*60})} return {km:legs.reduce((s,l)=>s+l.km,0),min:legs.reduce((s,l)=>s+l.min,0),path:pts,legs,src:'estimate'}; } } function loadScript(src,ms,cb){ return new Promise((resolve,reject)=>{ let done=false;const to=setTimeout(()=>fin(new Error('tardó demasiado: '+src.split('?')[0])),ms); function fin(err){if(done)return;done=true;clearTimeout(to);err?reject(err):resolve()} if(cb)window[cb]=()=>fin(); const s=document.createElement('script');s.src=src;s.async=true;s.onerror=()=>fin(new Error('no se pudo descargar '+src.split('?')[0])); if(!cb)s.onload=()=>fin(); document.head.appendChild(s); }); } const once=fn=>{let t=0;return ()=>{const n=Date.now();if(n-t<300)return;t=n;fn()}}; // Google puede avisar un mismo clic o arrastre por dos eventos /* ---------- íconos de los pines (SVG en línea: sirven igual en Google Maps y en Leaflet) ---------- */ const IC={ car:'<svg viewBox="0 0 24 24" aria-hidden="true"><path fill-rule="evenodd" d="M5.6 9.6 7 6.2C7.3 5.4 8 5 8.8 5h6.4c.8 0 1.5.4 1.8 1.2l1.4 3.4c.6.4 1 1.1 1 2V16c0 .6-.4 1-1 1h-.6v1.3c0 .4-.3.7-.7.7h-1.3c-.4 0-.7-.3-.7-.7V17H8.3v1.3c0 .4-.3.7-.7.7H6.3c-.4 0-.7-.3-.7-.7V17H5c-.6 0-1-.4-1-1v-4.4c0-.9.6-1.6 1.6-2zM8.9 7.2 8 9.7h8l-.9-2.5zM7.5 12.1a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6zm9 0a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6z"/></svg>', cap:'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4 1.5 9.2 12 14.4l8.6-4.3V16h1.9V9.2L12 4zM5.5 12.8v4c0 1.4 2.9 3.2 6.5 3.2s6.5-1.8 6.5-3.2v-4L12 16.6l-6.5-3.8z"/></svg>', seat:'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M5.2 20c.6-3.7 3.2-5.6 6.8-5.6s6.2 1.9 6.8 5.6"/></svg>', pin:'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s6.5-5.6 6.5-11A6.5 6.5 0 0 0 5.5 10c0 5.4 6.5 11 6.5 11z"/><circle cx="12" cy="10" r="2.2"/></svg>' }; // el contenido del pin: el texto (inicial, "Tú" o "U") sigue en el DOM para accesibilidad; el ícono lo reemplaza a la vista const pinHTML=o=>(o.cls==='me'?'':o.cls==='u'?IC.cap:IC.car)+'<span class="t">'+esc(o.text)+'</span>'; const pinFree=o=>o.free==null?'':' data-free="'+(+o.free>0?Math.round(+o.free):0)+'"'; /* ---------- mapa: Google Maps ---------- */ let gmDenied='',gmCode='',gmFired=false; // Google explica el motivo exacto en la consola ("RefererNotAllowedMapError", "BillingNotEnabledMapError"…): lo guardamos para mostrarlo en la insignia const consoleError=console.error; console.error=function(){try{const x=/Google Maps JavaScript API error: (\w+)/.exec(Array.prototype.join.call(arguments,' '));if(x)gmCode=x[1]}catch(e){}return consoleError.apply(console,arguments)}; window.gm_authFailure=()=>{ // Google la llama si la clave es inválida, falta facturación/API o el dominio no está permitido if(gmFired)return;gmFired=true; setTimeout(()=>{ // el motivo llega por la consola casi al mismo tiempo; esperamos un instante para leerlo gmDenied='Google rechazó la clave'+(gmCode?' ['+gmCode+']':'')+' (revisa facturación, APIs habilitadas y restricciones de dominio)'; if(KIT&&KIT.name==='google')switchToLeaflet(gmDenied); },80); }; async function googleKit(el){ await loadScript('https://maps.googleapis.com/maps/api/js?'+qs({key:CFG.gmKey,loading:'async',v:'weekly',callback:'__gmReady',language:'es',region:'PE'}),10000,'__gmReady'); await google.maps.importLibrary('core');await google.maps.importLibrary('maps');await google.maps.importLibrary('marker'); const map=new google.maps.Map(el,{center:{lat:-12.05,lng:-77.03},zoom:11,mapId:'DEMO_MAP_ID',mapTypeControl:false,streetViewControl:false,fullscreenControl:false,clickableIcons:false,gestureHandling:'greedy'}); const ll=p=>({lat:p[0],lng:p[1]}); const pos=p=>typeof p.lat==='function'?[p.lat(),p.lng()]:[p.lat,p.lng]; return { name:'google', onClick(fn){map.addListener('click',e=>fn([e.latLng.lat(),e.latLng.lng()]))}, marker(o){ const pin=document.createElement('div');pin.className='pin '+(o.cls||'');pin.innerHTML=pinHTML(o); if(o.free!=null)pin.setAttribute('data-free',String(+o.free>0?Math.round(+o.free):0)); const w=document.createElement('div');w.style.transform='translateY(50%)';w.appendChild(pin); // el pin queda centrado sobre el punto const m=new google.maps.marker.AdvancedMarkerElement({map:map,position:ll(o.pos),content:w,title:o.title||'',gmpDraggable:!!o.draggable,gmpClickable:!!o.onClick,zIndex:o.z||1}); w.addEventListener('click',ev=>ev.stopPropagation()); // tocar un pin no debe mover “Tu punto” if(o.onClick){ const f=once(o.onClick);w.style.cursor='pointer'; w.addEventListener('click',f); try{m.addEventListener('gmp-click',f)}catch(e){} } if(o.draggable&&o.onDrag){ const f=once(()=>o.onDrag(pos(m.position))); try{m.addEventListener('gmp-dragend',f)}catch(e){} try{if(typeof m.addListener==='function')m.addListener('dragend',f)}catch(e){} } return {setPos(p){m.position=ll(p)},remove(){m.map=null}}; }, line(path,o){const pl=new google.maps.Polyline({path:path.map(ll),strokeColor:o.color,strokeWeight:o.weight,strokeOpacity:.95,map:map});return {remove(){pl.setMap(null)}}}, fit(pts){const b=new google.maps.LatLngBounds();pts.forEach(p=>b.extend(ll(p)));map.fitBounds(b,48)}, center(p,z){map.setCenter(ll(p));if(z)map.setZoom(z)}, resize(){}, async route(pts,depart,hour){ try{ const {Route}=await google.maps.importLibrary('routes'); const req={origin:ll(pts[0]),destination:ll(pts[pts.length-1]),travelMode:'DRIVING',routingPreference:'TRAFFIC_AWARE',departureTime:depart,fields:['path','distanceMeters','durationMillis','legs']}; if(pts.length>2)req.intermediates=pts.slice(1,-1).map(ll); const res=await Route.computeRoutes(req); const r=res.routes&&res.routes[0];if(!r)throw new Error('Google no devolvió rutas'); const km=r.distanceMeters/1000,min=r.durationMillis/60000; let legs=(r.legs||[]).map(l=>({km:l.distanceMeters/1000,min:l.durationMillis/60000})); if(legs.length!==pts.length-1||legs.some(l=>!isFinite(l.min))){ // si faltan los tramos, se reparten en proporción a la distancia const w=[];for(let i=1;i<pts.length;i++)w.push(hav(pts[i-1],pts[i]));const t=w.reduce((a,b)=>a+b,0)||1; legs=w.map(x=>({km:km*x/t,min:min*x/t})); } return {km,min,path:r.path.map(pos),legs,src:'google'}; }catch(e){ console.warn('Google Routes falló:',e); const o=await osrmRoute(pts,hour); o.note='Google Routes no respondió ('+((e&&e.message)||e)+'): se usó '+(o.src==='osrm'?'OpenStreetMap':'una estimación'); return o; } } }; } /* ---------- mapa: OpenStreetMap con Leaflet (respaldo) ---------- */ /* Mapas sin clave. CARTO ya no sirve sin API key (dibuja "API KEY REQUIRED" en cada tile), así que el principal es OpenStreetMap (necesita Referer: lo mandamos con referrerPolicy). Esri es la alternativa con el botón "Mapa: …". */ const TILES=[ {n:'OSM',u:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',o:{maxZoom:19,referrerPolicy:'origin',attribution:'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'}}, {n:'Esri',u:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',o:{maxZoom:19,attribution:'Tiles © Esri'}} ]; async function leafletKit(el){ const css=document.createElement('link');css.rel='stylesheet';css.href='https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css';document.head.appendChild(css); await loadScript('https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js',10000); if(typeof L==='undefined')throw new Error('Leaflet no cargó'); const map=L.map(el).setView([-12.05,-77.03],11); let ti=0,tl=null,btn=null,ok=0,bad=0; function useTiles(i){ if(tl)map.removeLayer(tl); ti=i%TILES.length;ok=0;bad=0;const t=TILES[ti]; tl=L.tileLayer(t.u,t.o).addTo(map); tl.on('tileload',()=>{ok++}); tl.on('tileerror',()=>{bad++;if(ok===0&&bad>=4&&ti<TILES.length-1)useTiles(ti+1)}); if(btn)btn.textContent='Mapa: '+t.n+' ⟳'; } useTiles(0); btn=document.createElement('button');btn.type='button';btn.className='tilesw';btn.id='tileSw';btn.title='Si ves un cartel en vez del mapa, cambia de proveedor';btn.textContent='Mapa: '+TILES[0].n+' ⟳'; ['click','dblclick','mousedown','touchstart'].forEach(ev=>btn.addEventListener(ev,e=>e.stopPropagation())); btn.addEventListener('click',()=>useTiles(ti+1)); el.appendChild(btn); return { name:'leaflet', onClick(fn){map.on('click',e=>fn([e.latlng.lat,e.latlng.lng]))}, marker(o){ const ic=L.divIcon({className:'',html:'<div class="pin '+esc(o.cls||'')+'"'+pinFree(o)+'>'+pinHTML(o)+'</div>',iconSize:[38,38],iconAnchor:[19,19]}); const m=L.marker(o.pos,{icon:ic,draggable:!!o.draggable,title:o.title||'',zIndexOffset:o.z||0}).addTo(map); if(o.onClick)m.on('click',()=>o.onClick()); if(o.draggable&&o.onDrag)m.on('dragend',()=>{const p=m.getLatLng();o.onDrag([p.lat,p.lng])}); return {setPos(p){m.setLatLng(p)},remove(){map.removeLayer(m)}}; }, line(path,o){const pl=L.polyline(path,{color:o.color,weight:o.weight,opacity:.95}).addTo(map);return {remove(){map.removeLayer(pl)}}}, fit(pts){map.fitBounds(pts,{padding:[40,40],maxZoom:15})}, center(p,z){map.setView(p,z||map.getZoom())}, resize(){map.invalidateSize()}, route:(pts,depart,hour)=>osrmRoute(pts,hour) }; } const nullKit={name:'none',onClick(){},marker(){return {setPos(){},remove(){}}},line(){return {remove(){}}},fit(){},center(){},resize(){},route:(pts,d,h)=>osrmRoute(pts,h)}; /* ---------- control del mapa ---------- */ let KIT=nullKit,mk={u:null,me:null,trips:[],route:null}; const mapEl=$('map'); function freshMapDiv(){ mapEl.innerHTML='';const d=document.createElement('div');d.className='mapc';mapEl.appendChild(d); const lg=document.createElement('div');lg.className='legend';lg.setAttribute('aria-hidden','true'); // leyenda: no recibe toques lg.innerHTML='<span><i class="u"></i>UTEC</span><span><i class="me"></i>Tú</span><span><i></i>Conductor</span><span><i class="mine"></i>Tu viaje</span><span><b>3</b>asientos libres</span>'; mapEl.appendChild(lg);return d} function mapState(t){$('mapState').textContent=t} async function startMap(){ let reason=''; if(CFG.gmKey){ try{KIT=await googleKit(freshMapDiv());if(gmDenied)throw new Error(gmDenied);mapState('Mapa: Google Maps');} catch(e){KIT=nullKit;reason=(e&&e.message)||String(e);console.warn('Google Maps no disponible:',reason)} }else reason='falta el Secret GOOGLE_MAPS_API_KEY en el Space'; if(KIT===nullKit)await switchToLeaflet(reason); else afterKit(); } async function switchToLeaflet(reason){ try{ KIT=await leafletKit(freshMapDiv()); mapState('Mapa: OpenStreetMap — Google Maps no está activo ('+reason+').'); }catch(e){ KIT=nullKit;mapEl.innerHTML='<div class="nomap">No se pudo cargar el mapa. Revisa tu conexión y recarga la página; la búsqueda y las reservas siguen funcionando.</div>'; mapState('Sin mapa: '+((e&&e.message)||e)); } routeCache.clear(); afterKit(); } function afterKit(){ KIT.onClick(p=>setOrigin(p[0],p[1])); mk={u:null,me:null,trips:[],route:null}; drawMap(); if(results.length)KIT.fit([origin,UTEC].concat(results.map(t=>[t.lat,t.lon]))); } function drawMap(){ mk.trips.forEach(h=>h.remove());mk.trips=[]; if(mk.route){mk.route.remove();mk.route=null} if(!mk.u)mk.u=KIT.marker({pos:UTEC,text:'U',cls:'u',title:'UTEC, Barranco',z:5}); if(!mk.me)mk.me=KIT.marker({pos:origin,text:'Tú',cls:'me',title:'Tu punto (arrástralo)',draggable:true,onDrag:p=>setOrigin(p[0],p[1]),z:20}); else mk.me.setPos(origin); results.forEach(t=>mk.trips.push(KIT.marker({pos:[t.lat,t.lon],text:t.own?'Tú':initial(t.driver.name),cls:t.own?'mine':t.id===selId?'sel':'',free:t.free,title:t.driver.name+' · llega '+t.time+' · '+t.free+' asiento'+(t.free===1?'':'s')+' libre'+(t.free===1?'':'s'),onClick:()=>pick(t.id),z:t.id===selId?9:2}))); if(sel&&sel.via)mk.route=KIT.line(sel.via.path,{color:'#E8A200',weight:6}); } /* ---------- punto del pasajero ---------- */ let origin=DIST['Los Olivos'].slice(),results=[],total=0,selId=null,sel=null,pickTok=0,searchTok=0,searchT=null,priceTouched=false; const routeCache=new Map(); const visibleTab=()=>{const p=document.querySelector('.panel.on');return p?p.id.slice(2):'pas'}; function setOrigin(la,lo,opt){ opt=opt||{}; if(hav([la,lo],UTEC)>80){say('geoMsg','Ese punto está fuera de Lima. Elige un punto cerca de la ciudad.','err');drawMap();return} origin=[la,lo]; if(!opt.keepSelect)$('dist').value=''; if(opt.msg)say('geoMsg',opt.msg,'ok'); clearSel(); if(mk.me)mk.me.setPos(origin); if(opt.pan)KIT.center(origin,13); runSearch(true); if(!priceTouched)suggest(true); if(visibleTab()==='aho'&&!$('sdist').value)renderSavings(); } function locate(){ if(!navigator.geolocation){say('geoMsg','Tu navegador no permite ubicación. Toca el mapa para elegir tu punto.','err');return} say('geoMsg','Buscando tu ubicación…'); navigator.geolocation.getCurrentPosition(p=>{ const la=p.coords.latitude,lo=p.coords.longitude,d=hav([la,lo],UTEC); if(d>80){say('geoMsg','Tu ubicación real está a '+Math.round(d)+' km de UTEC (fuera de Lima). Para la demo se mantiene el punto elegido; toca el mapa para cambiarlo.','err');return} setOrigin(la,lo,{pan:true,msg:'Ubicación encontrada.'}); },e=>say('geoMsg','No se pudo obtener tu ubicación ('+((e&&e.message)||'permiso denegado')+'). Permite el acceso o abre el Space en su URL directa (.hf.space). Mientras tanto, toca el mapa.','err'),{enableHighAccuracy:true,timeout:9000,maximumAge:60000}); } /* ---------- búsqueda (la hace Python) ---------- */ function runSearch(fit){clearTimeout(searchT);searchT=setTimeout(()=>doSearch(fit),150)} async function doSearch(fit){ const tok=++searchTok; const j=await api('/api/search?'+qs({lat:origin[0],lng:origin[1],time:$('hora').value,radius:$('rad').value,email:ME?ME.email:''})); if(tok!==searchTok)return; if(!j.ok){results=[];total=0;$('count').textContent='';$('list').innerHTML='<div class="card msg err">'+esc(j.error)+'</div>';drawMap();return} results=j.trips;total=j.total;renderList();drawMap();renderDetail(); if(fit)KIT.fit([origin,UTEC].concat(results.map(t=>[t.lat,t.lon]))); } const starsTxt=d=>d.rating==null?'nuevo':'★ '+d.rating.toFixed(1)+' ('+d.n+')'; function tripCard(t){ const on=selId===t.id; return '<article class="card trip'+(on?' on':'')+'" data-act="pick" data-id="'+t.id+'" tabindex="0" role="button" aria-pressed="'+on+'">'+ '<div class="who"><div class="av">'+esc(initial(t.driver.name))+'</div><div class="info"><b>'+esc(t.driver.name)+'</b>'+(t.own?'<span class="tag me">Tu viaje</span>':'<span class="tag">UTEC</span>')+ '<div class="mute">'+starsTxt(t.driver)+' · sale de '+esc(t.from)+' · llega '+esc(t.time)+'</div>'+ '<div class="pills"><span class="pill'+(t.free<=1?' warn':'')+'">'+IC.seat+t.free+' asiento'+(t.free===1?'':'s')+'</span><span class="pill">'+IC.pin+'pasa a '+t.pass_km.toFixed(1)+' km de ti</span></div></div></div>'+ '<div class="price">'+money(t.price)+'</div></article>'; } function renderList(){ $('count').textContent=results.length?'Hay '+total+' viaje'+(total===1?'':'s')+' que pasan cerca de tu punto. Toca uno para ver su ruta.':''; $('list').innerHTML=results.length?results.map(tripCard).join(''):'<div class="card mute">No hay viajes a esa hora cerca de tu punto. Prueba con otro radio u otra hora, o publica el tuyo en la pestaña Conductor.</div>'; } function clearSel(){pickTok++;selId=null;sel=null;$('detail').innerHTML='';say('bookMsg','')} /* ---------- ruta de un viaje ---------- */ function departure(t){ // salida estimada del conductor (45 min antes de llegar); Google exige una hora futura const d=t.date.split('-').map(Number),h=t.time.split(':').map(Number); const arrive=Date.UTC(d[0],d[1]-1,d[2],h[0]+5,h[1]); // Lima = UTC-5 return new Date(Math.max(arrive-45*60000,Date.now()+120000)); } function routeFor(pts,t){ const key=KIT.name+'|'+pts.map(p=>p[0].toFixed(4)+','+p[1].toFixed(4)).join('|')+'@'+t.date+t.time; if(!routeCache.has(key)){if(routeCache.size>60)routeCache.clear();routeCache.set(key,KIT.route(pts,departure(t),t.time))} return routeCache.get(key); } function focusDetail(){ // el detalle queda debajo de la lista: se lleva a la vista para que se vea la ruta, el aporte y el botón try{$('detail').scrollIntoView({block:'start',behavior:matchMedia('(prefers-reduced-motion:reduce)').matches?'auto':'smooth'})}catch(e){} } async function pick(id){ const t=results.find(x=>x.id===id);if(!t)return; const tok=++pickTok;selId=id;sel=null;say('bookMsg',''); renderList();drawMap(); $('detail').innerHTML='<div class="card mute">Calculando ruta…</div>'; focusDetail(); const o=[t.lat,t.lon],here=origin.slice(); const [via,direct]=await Promise.all([routeFor([o,here,UTEC],t),routeFor([o,UTEC],t)]); if(tok!==pickTok)return; sel={id:id,via:via,direct:direct,pickup:here}; drawMap();KIT.fit(via.path);renderDetail(); focusDetail(); // otra vez: la tarjeta final es más alta que el aviso “Calculando ruta…” } function renderDetail(){ const box=$('detail'); if(!sel){if(!selId)box.innerHTML='';return} const t=results.find(x=>x.id===sel.id); if(!t){box.innerHTML='<div class="card mute">Ese viaje ya no está disponible.</div>';return} const v=sel.via,detour=Math.max(0,v.min-sel.direct.min),arr=mins(t.time),pickT=arr-(v.legs[1]?v.legs[1].min:0),dep=arr-v.min; const label=t.own?'Es tu propio viaje':t.booked?'Asiento solicitado':t.free<1?'Sin asientos':'Pedir asiento'; const src={google:'Ruta y tiempo con tráfico de Google Maps para esa hora.',osrm:'Ruta real de OpenStreetMap (OSRM). El tiempo se ajusta con un factor de tráfico estimado para esa hora.',estimate:'No se pudo conectar al servicio de rutas: valores estimados en línea recta a '+CFG.jamKmh+' km/h.'}[v.src]; box.innerHTML='<div class="card"><b style="font-size:16px">Viaje con '+esc(t.driver.name)+'</b> <span class="mute">'+starsTxt(t.driver)+'</span>'+ '<div class="mute">'+esc(t.driver.car)+' · sale de '+esc(t.from)+' · '+esc(t.date)+(t.notes?' · <i>'+esc(t.notes)+'</i>':'')+'</div>'+ '<div class="kv"><div><span>Distancia total</span><b>'+v.km.toFixed(1)+' km</b></div><div><span>Tiempo con tráfico</span><b>'+Math.round(v.min)+' min</b></div><div><span>Desvío del conductor</span><b>+'+Math.round(detour)+' min</b></div>'+ '<div><span>Sale el conductor</span><b>'+hm(dep)+'</b></div><div><span>Te recoge hacia las</span><b>'+hm(pickT)+'</b></div><div><span>Tu aporte</span><b>'+money(t.price)+'</b></div></div>'+ '<button class="btn sun full" data-act="book" data-id="'+t.id+'"'+(t.own||t.booked||t.free<1?' disabled':'')+'>'+label+'</button>'+ '<div class="mute" style="margin-top:8px">'+src+(v.note?' <b>'+esc(v.note)+'.</b>':'')+'</div></div>'; } /* ---------- cuenta ---------- */ function renderAuth(){ $('auth').innerHTML=ME?'<div class="between"><div><b>Hola, '+esc(ME.name)+'</b><div class="mute">'+esc(ME.email)+'</div></div><button class="btn ghost sm" data-act="logout" type="button">Salir</button></div>' :'<div class="row2"><div><label for="mail">Correo UTEC</label><input id="mail" type="email" autocomplete="email" placeholder="nombre.apellido@utec.edu.pe"></div><div><label for="uname">Tu nombre</label><input id="uname" autocomplete="given-name" maxlength="30" placeholder="Ej. Raúl"></div></div>'+ '<button class="btn full" data-act="login" type="button" style="margin-top:10px">Ingresar</button><div class="msg" id="authMsg" role="status" aria-live="polite"></div>'; } async function login(){ const mail=$('mail').value.trim().toLowerCase(); if(!EMAIL.test(mail)){say('authMsg','Escribe un correo que termine en @utec.edu.pe.','err');$('mail').focus();return} const j=await api('/api/login',{email:mail,name:$('uname').value}); if(!j.ok){say('authMsg',j.error,'err');return} ME=j.user;saveMe();renderAuth();say('bookMsg','');afterChange(); } function logout(){ME=null;saveMe();renderAuth();clearSel();afterChange()} function needLogin(id){say(id,'Ingresa con tu correo UTEC para continuar.','err');const m=$('mail');if(m)m.focus()} async function afterChange(){ // vuelve a pedir a Python lo que cambió await doSearch();loadMine(); const t=visibleTab();if(t==='pan')loadPanel();if(t==='aho')renderSavings(); } /* ---------- reservas ---------- */ async function book(id){ if(!ME)return needLogin('bookMsg'); const t=results.find(x=>x.id===id); if(!t)return say('bookMsg','Ese viaje ya no está disponible.','err'); if(!sel||sel.id!==id)return say('bookMsg','Espera a que termine de calcularse la ruta.','err'); const v=sel.via,arr=mins(t.time),here=sel.pickup; const j=await api('/api/book',{email:ME.email,name:ME.name,trip_id:id,plat:here[0],plng:here[1],pickup:hm(arr-(v.legs[1]?v.legs[1].min:0))}); if(!j.ok){say('bookMsg',j.error,'err');doSearch();return} say('bookMsg','Asiento confirmado con '+j.driver+'. Tu código es '+j.code+'. Míralo en Mis viajes.','ok'); afterChange(); } async function mineAct(path,body,okMsg){ if(!ME)return needLogin('mineMsg'); const j=await api(path,Object.assign({email:ME.email},body)); say('mineMsg',j.ok?(typeof okMsg==='function'?okMsg(j):okMsg):j.error,j.ok?'ok':'err'); if(j.ok)afterChange(); } function rateSend(bid){ const box=$('rate-'+bid),v=Number(box&&box.dataset.v||0); if(!(v>=1&&v<=5))return say('mineMsg','Elige de 1 a 5 estrellas.','err'); mineAct('/api/rate',{booking_id:bid,stars:v,comment:$('rc-'+bid).value},j=>'Gracias por tu calificación. El promedio de '+j.driver+' se actualizó.'); } async function loadMine(){ const none='<div class="card mute">Ingresa con tu correo UTEC para ver tus viajes.</div>'; if(!ME){$('mineRider').innerHTML=none;$('mineDriver').innerHTML=none;return} const j=await api('/api/mine?'+qs({email:ME.email})); if(!j.ok){const e='<div class="card msg err">'+esc(j.error)+'</div>';$('mineRider').innerHTML=e;$('mineDriver').innerHTML=e;return} $('mineRider').innerHTML=j.rider.length?j.rider.map(b=>{ let act=''; if(b.status==='confirmed')act='<div class="actions"><button class="btn ghost sm" data-act="simFinish" data-id="'+b.id+'" type="button">Simular fin del viaje</button><button class="btn danger sm" data-act="cancelBooking" data-id="'+b.id+'" type="button">Cancelar reserva</button></div>'; else if(b.status==='completed'&&!b.stars)act='<div class="rate" id="rate-'+b.id+'" data-v="0"><div class="mute">¿Cómo estuvo el viaje con '+esc(b.dname)+'?</div><div class="stars" role="group" aria-label="Calificación">'+[1,2,3,4,5].map(n=>'<button type="button" class="star" data-act="star" data-id="'+b.id+'" data-v="'+n+'" aria-label="'+n+' estrella'+(n>1?'s':'')+'" aria-pressed="false">★</button>').join('')+'</div><input id="rc-'+b.id+'" maxlength="120" placeholder="Comentario (opcional)" aria-label="Comentario"><div><button class="btn sm" data-act="rate" data-id="'+b.id+'" type="button">Enviar calificación</button></div></div>'; else if(b.stars)act='<div class="mute" style="margin-top:8px">Tu calificación: <b style="color:var(--ink)">'+'★'.repeat(b.stars)+'☆'.repeat(5-b.stars)+'</b>'+(b.comment?' · “'+esc(b.comment)+'”':'')+'</div>'; return '<article class="card"><div class="between"><div><b>'+esc(b.dname)+'</b><span class="chip '+esc(b.status)+'">'+esc(LBL[b.status]||b.status)+'</span><div class="mute">'+esc(b.from_name)+' → UTEC · '+esc(b.date)+' · llega '+esc(b.time)+(b.pickup?' · recojo ~'+esc(b.pickup):'')+'</div></div><div class="price">'+money(b.price)+'</div></div>'+ (b.status==='cancelled'?'':'<div class="ticket"><span class="mute">Código de abordaje</span><b>'+esc(b.code)+'</b></div>')+act+'</article>'; }).join(''):'<div class="card mute">Aún no has pedido ningún asiento. Búscalo en la pestaña Pasajero.</div>'; $('mineDriver').innerHTML=j.driver.length?j.driver.map(t=>{ const act=t.status==='active'?'<div class="actions">'+(t.free>0?'<button class="btn ghost sm" data-act="simPassenger" data-id="'+t.id+'" type="button">Simular pasajero</button>':'')+'<button class="btn sm" data-act="completeTrip" data-id="'+t.id+'" type="button">Marcar como completado</button><button class="btn danger sm" data-act="cancelTrip" data-id="'+t.id+'" type="button">Cancelar viaje</button></div>':''; return '<article class="card"><b>'+esc(t.from_name)+' → UTEC</b><span class="chip '+esc(t.status)+'">'+esc(LBL[t.status]||t.status)+'</span><div class="mute">'+esc(t.date)+' · llega '+esc(t.time)+' · '+t.free+'/'+t.seats+' asientos libres · '+money(t.price)+'</div><div class="mute">Pasajeros: '+(t.riders.length?t.riders.map(esc).join(', '):'ninguno todavía')+'</div>'+act+'</article>'; }).join(''):'<div class="card mute">Aún no has publicado ningún viaje. Hazlo en la pestaña Conductor.</div>'; } /* ---------- conductor ---------- */ async function suggest(silent){ const j=await api('/api/quote?'+qs({lat:origin[0],lng:origin[1],seats:$('pseats').value})); if(!j.ok)return; $('pprice').value=j.price;priceTouched=false; if(!silent)say('pubMsg','Aporte sugerido: '+money(j.price)+' por pasajero ('+j.km.toFixed(0)+' km hasta UTEC).'); } async function publish(){ if(!ME)return needLogin('pubMsg'); const hour=$('phora').value; const j=await api('/api/publish',{email:ME.email,name:ME.name,lat:origin[0],lon:origin[1],date:$('pdate').value,time:hour,seats:$('pseats').value,price:$('pprice').value,car:$('pcar').value,notes:$('pnotes').value}); if(!j.ok){say('pubMsg',j.error,'err');return} say('pubMsg','Viaje publicado desde tu punto. Ya aparece en el mapa con tu pin verde (pestaña Pasajero, hora '+hour+').','ok'); $('hora').value=hour;afterChange(); } /* ---------- ahorro ---------- */ let savTok=0; async function renderSavings(){ const tok=++savTok,ds=$('sdist').value,p={mode:$('smode').value,days:$('sdays').value,riders:$('sriders').value}; if(ds)p.district=ds;else{p.lat=origin[0];p.lng=origin[1]} const j=await api('/api/savings?'+qs(p)); if(tok!==savTok)return; if(!j.ok){$('sout').innerHTML='<div class="card msg err">'+esc(j.error)+'</div>';return} const mx=Math.max(j.now,j.carpool,1),name={bus:'transporte público',auto:'auto propio, solo',taxi:'taxi / app'}[j.mode],r=j.riders; $('sout').innerHTML='<div class="card"><b style="font-size:16px">Como pasajero · '+Math.round(j.km)+' km hasta UTEC</b>'+ '<div class="mute" style="margin-top:8px">Hoy ('+name+')</div><div class="num big">'+money0(j.now)+' <span class="mute">por semana</span></div><div class="bar"><i style="width:'+(j.now/mx*100)+'%;background:var(--mute)"></i></div>'+ '<div class="mute">Con Carpooling UTEC ('+r+' pasajero'+(r>1?'s':'')+' en el auto)</div><div class="num big">'+money0(j.carpool)+' <span class="mute">por semana</span></div><div class="bar"><i style="width:'+(j.carpool/mx*100)+'%;background:var(--teal)"></i></div>'+ (j.month>0?'<div class="num big" style="color:var(--ok)">Ahorras ~'+money0(j.month)+' al mes</div>':'<p class="mute">En tu caso el pasaje ya es barato: el valor está en la <b>hora de salida acordada</b> y en viajar sentado, no en el dinero.</p>')+'</div>'+ '<div class="card"><b style="font-size:16px">Como conductor con '+r+' pasajero'+(r>1?'s':'')+'</b><div class="mute" style="margin-top:6px">Gastas '+money0(j.fuel)+' por semana en combustible y recuperas '+money0(j.recovered)+'.</div><div class="num big">Tu gasto neto: '+money0(j.net)+' por semana</div></div>'+ '<p class="mute">Supuestos: ida y vuelta, combustible S/ '+MODEL.fuel.toFixed(2)+' por km, pasaje S/ '+MODEL.bus+' por día, taxi S/ '+MODEL.taxiKm.toFixed(2)+' por km (mínimo S/ '+MODEL.taxiMin+') y '+MODEL.weeks+' semanas por mes. Salen de las entrevistas del informe; no son tarifas oficiales.</p>'; } /* ---------- panel ---------- */ async function loadPanel(){ const j=await api('/api/panel?'+qs({email:ME?ME.email:''})); if(!j.ok)return; const tiles=[[j.trips,'Viajes activos'],[j.seats,'Asientos libres'],[j.bookings,'Reservas'],[money(j.avg_price),'Aporte promedio'],[j.mine,'Publicados por ti'],[j.ratings,'Calificaciones']]; $('tiles').innerHTML=tiles.map(x=>'<div class="tile"><b class="num">'+esc(x[0])+'</b><span class="mute">'+x[1]+'</span></div>').join(''); const mx=Math.max(1,...j.zones.map(z=>z[1])); $('byDist').innerHTML=j.zones.length?j.zones.map(z=>'<div class="between" style="margin-top:8px"><span>'+esc(z[0])+'</span><b class="num">'+z[1]+'</b></div><div class="bar" style="margin:3px 0 0"><i style="width:'+z[1]/mx*100+'%;background:var(--teal)"></i></div>').join(''):'<div class="card mute">No hay viajes activos.</div>'; } /* ---------- interfaz ---------- */ function showTab(name){ document.querySelectorAll('.tabs button').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.tab===name))); document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('on',p.id==='t-'+name)); if(name==='mis')loadMine();if(name==='pan')loadPanel();if(name==='aho')renderSavings(); setTimeout(()=>KIT.resize(),50); } const ACTIONS={ pick:id=>pick(id),book:id=>book(id),login:()=>login(),logout:()=>logout(),locate:()=>locate(),publish:()=>publish(),suggest:()=>suggest(false), simFinish:id=>mineAct('/api/finish',{booking_id:id},'Viaje completado (simulado). Ya puedes calificar al conductor.'), cancelBooking:id=>mineAct('/api/cancel',{booking_id:id},'Reserva cancelada. El asiento volvió a estar libre.'), simPassenger:id=>mineAct('/api/sim_passenger',{trip_id:id},'Se unió un pasajero simulado a tu viaje.'), completeTrip:id=>mineAct('/api/trip_complete',{trip_id:id},'Viaje completado. Tus pasajeros ya pueden calificarte.'), cancelTrip:id=>mineAct('/api/trip_cancel',{trip_id:id},'Viaje cancelado. Se avisó a los pasajeros (simulado).'), rate:id=>rateSend(id), star:(id,el)=>{const box=$('rate-'+id);if(!box)return;box.dataset.v=el.dataset.v;box.querySelectorAll('.star').forEach(s=>{const on=Number(s.dataset.v)<=Number(el.dataset.v);s.classList.toggle('on',on);s.setAttribute('aria-pressed',String(on))})} }; document.addEventListener('click',e=>{ const el=e.target.closest('[data-act]');if(!el||el.disabled)return; const f=ACTIONS[el.dataset.act];if(f)f(el.dataset.id?Number(el.dataset.id):undefined,el); }); document.addEventListener('keydown',e=>{ if((e.key==='Enter'||e.key===' ')&&e.target.matches&&e.target.matches('article[data-act]')){e.preventDefault();e.target.click()} if(e.key==='Enter'&&e.target.closest&&e.target.closest('#auth')&&e.target.tagName==='INPUT')login(); }); document.querySelectorAll('.tabs button').forEach(b=>b.addEventListener('click',()=>showTab(b.dataset.tab))); $('dist').addEventListener('change',()=>{const v=$('dist').value;if(!v)return;say('geoMsg','');const c=DIST[v];setOrigin(c[0],c[1],{pan:true,keepSelect:true})}); $('hora').addEventListener('change',()=>{clearSel();runSearch(true)}); $('rad').addEventListener('input',()=>{$('radv').textContent=$('rad').value;clearSel();runSearch(true)}); $('pseats').addEventListener('change',()=>{if(!priceTouched)suggest(true)}); $('pprice').addEventListener('input',()=>{priceTouched=true}); ['sdist','smode','sdays','sriders'].forEach(i=>$(i).addEventListener('change',renderSavings)); let resetArmed=null; $('reset').addEventListener('click',async()=>{ const b=$('reset'); if(!ME){say('resetMsg','Ingresa con tu correo UTEC para borrar tus datos.','err');return} if(!resetArmed){b.textContent='¿Seguro? Pulsa otra vez para borrar tus datos';resetArmed=setTimeout(()=>{resetArmed=null;b.textContent='Restablecer mis datos'},4000);return} clearTimeout(resetArmed);resetArmed=null;b.textContent='Restablecer mis datos'; const j=await api('/api/reset_me',{email:ME.email}); say('resetMsg',j.ok?'Listo: se borraron tus viajes, reservas y calificaciones.':j.error,j.ok?'ok':'err'); if(j.ok){clearSel();afterChange()} }); window.addEventListener('resize',()=>KIT.resize()); /* ---------- arranque ---------- */ (function init(){ const opts=Object.keys(DIST).map(k=>'<option>'+esc(k)+'</option>').join(''); $('dist').innerHTML='<option value="">Punto del mapa</option>'+opts;$('dist').value='Los Olivos'; $('sdist').innerHTML='<option value="">Mi punto actual</option>'+opts; $('hora').innerHTML='<option value="*">Cualquier hora</option>'+HOURS.map(h=>'<option'+(h==='08:00'?' selected':'')+'>'+h+'</option>').join(''); $('phora').innerHTML=HOURS.map(h=>'<option'+(h==='08:00'?' selected':'')+'>'+h+'</option>').join(''); $('pdate').min=CFG.today;$('pdate').value=CFG.nextDay; renderAuth();suggest(true);loadMine();doSearch(true);startMap(); })(); </script> </body> </html> """ def render_page(): cfg = {"gmKey": GM_KEY, "utec": UTEC, "districts": DIST, "hours": HOURS, "traffic": TRAFFIC, "jamKmh": JAM_KMH, "model": {"fuel": FUEL, "bus": BUS_DAY, "taxiKm": TAXI_KM, "taxiMin": TAXI_MIN, "weeks": WEEKS}, "today": today_str(), "nextDay": next_weekday()} blob = json.dumps(cfg, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") return PAGE.replace("/*__CFG__*/null", blob).encode("utf-8") # ---------------------------------------------------------------- respuestas (las usan los dos servidores) SEC = {"X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "Cache-Control": "no-store"} MAX_BODY = 16384 JSON_T = "application/json; charset=utf-8" def _json(code, obj): return code, JSON_T, json.dumps(obj, ensure_ascii=False).encode("utf-8") def _run(fn, q): try: res = fn(q) res["ok"] = True return _json(200, res) except ApiError as e: return _json(e.code, {"ok": False, "error": e.msg}) except Exception as e: # nunca devolvemos trazas al navegador print("[error]", repr(e), flush=True) return _json(500, {"ok": False, "error": "Error interno del servidor."}) def route(method, path, query, raw): """Único lugar donde se decide qué responder (lo usan el servidor de Python y el de Gradio). raw = cuerpo ya leído (bytes), o None si superaba el límite. Devuelve (código, tipo, cuerpo).""" if method in ("GET", "HEAD"): if path in ("/", "/index.html"): return 200, "text/html; charset=utf-8", render_page() if path == "/api/health": return _json(200, {"ok": True}) if path == "/favicon.ico": return 204, "image/x-icon", b"" fn = GET_ROUTES.get(path) if fn: return _run(fn, {k: v[0] for k, v in parse_qs(query, keep_blank_values=True).items()}) return _json(404, {"ok": False, "error": "No existe."}) if method == "POST": if raw is None: return _json(413, {"ok": False, "error": "Solicitud demasiado grande."}) fn = POST_ROUTES.get(path) if not fn: return _json(404, {"ok": False, "error": "No existe."}) try: q = json.loads(raw.decode("utf-8") or "{}") if not isinstance(q, dict): raise ValueError except ValueError: return _json(400, {"ok": False, "error": "JSON inválido."}) return _run(fn, q) return _json(405, {"ok": False, "error": "Método no permitido."}) # ---------------------------------------------------------------- servidor 1: librería estándar (Colab, tu PC) class Handler(BaseHTTPRequestHandler): # HTTP/1.0 a propósito: cada respuesta cierra la conexión, así no hay choques con proxies server_version = "CarpoolingUTEC/2.0" timeout = 30 def log_message(self, fmt, *args): if "/api/health" not in (args[0] if args else ""): sys.stdout.write("[web] %s\n" % (fmt % args)) sys.stdout.flush() def _answer(self, method): u = urlparse(self.path) raw = b"" if method == "POST": try: n = int(self.headers.get("Content-Length") or 0) except ValueError: n = -1 raw = None if (n < 0 or n > MAX_BODY) else (self.rfile.read(n) if n else b"") code, ctype, body = route(method, u.path, u.query, raw) self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) for k, v in SEC.items(): self.send_header(k, v) self.end_headers() if method != "HEAD": self.wfile.write(body) def do_GET(self): self._answer("GET") def do_HEAD(self): self._answer("HEAD") def do_POST(self): self._answer("POST") class WebServer(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True request_queue_size = 64 def handle_error(self, request, client_address): # conexiones cortadas por el navegador: sin ruido if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError, TimeoutError)): return super().handle_error(request, client_address) def serve_stdlib(): srv = WebServer(("0.0.0.0", PORT), Handler) print(f"[arranque] escuchando en http://0.0.0.0:{PORT}", flush=True) try: srv.serve_forever() except KeyboardInterrupt: pass # ---------------------------------------------------------------- servidor 2: Gradio (Hugging Face, cuenta gratuita) # Con cuenta gratuita, Hugging Face solo deja un Space de Gradio sobre ZeroGPU, y ZeroGPU exige (1) que el servidor # lo levante Gradio y (2) que exista al menos una función @spaces.GPU. Esa función no hace nada ni se usa: # solo está para pasar el chequeo de arranque. Nuestra página y nuestra API van en el mismo servidor. try: import spaces @spaces.GPU def gpu_ping(texto: str) -> str: return texto except Exception: # fuera de Hugging Face no existe la librería "spaces" spaces = None gpu_ping = None def en_hf(): """True en un Space de Hugging Face. USAR_GRADIO=1 / 0 lo fuerza (útil para pruebas).""" forzar = os.environ.get("USAR_GRADIO") if forzar in ("0", "1"): return forzar == "1" return bool(os.environ.get("SPACE_ID") or os.environ.get("SPACES_ZERO_GPU") or os.environ.get("SYSTEM") == "spaces") def _acepta(fn, nombre): try: ps = inspect.signature(fn).parameters except (TypeError, ValueError): return False return nombre in ps or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in ps.values()) def _rutas_asgi(app, al_frente=False): """Registra nuestras rutas en el servidor FastAPI que trae Gradio, para compartir el mismo puerto.""" from starlette.concurrency import run_in_threadpool from starlette.requests import Request from starlette.responses import Response async def leer(request): try: n = int(request.headers.get("content-length") or 0) except ValueError: return None if n < 0 or n > MAX_BODY: return None buf = b"" async for trozo in request.stream(): buf += trozo if len(buf) > MAX_BODY: return None return buf def crear(path): async def endpoint(request: Request): raw = await leer(request) if request.method == "POST" else b"" consulta = request.scope.get("query_string", b"").decode("latin-1") code, ctype, body = await run_in_threadpool(route, request.method, path, consulta, raw) cab = dict(SEC) cab["Content-Type"] = ctype return Response(content=body, status_code=code, headers=cab) return endpoint gets = ["/", "/index.html", "/api/health", "/favicon.ico"] + list(GET_ROUTES) n0 = len(app.router.routes) for p in dict.fromkeys(gets + list(POST_ROUTES)): metodos = (["GET", "HEAD"] if p in gets else []) + (["POST"] if p in POST_ROUTES else []) app.add_api_route(p, crear(p), methods=metodos, include_in_schema=False) if al_frente: # servidores que ya traen su propia página en "/": las nuestras van primero mias = app.router.routes[n0:] del app.router.routes[n0:] app.router.routes[0:0] = mias def serve_gradio(): import gradio as gr print(f"[arranque] Gradio {getattr(gr, '__version__', '?')} · librería spaces: {'sí' if spaces else 'no'}", flush=True) kw = {"server_name": "0.0.0.0", "server_port": PORT} Server = getattr(gr, "Server", None) if Server is not None: # camino oficial: gradio.Server = FastAPI + motor de Gradio (admite ZeroGPU) app = Server() _rutas_asgi(app) if gpu_ping is not None: try: @app.api(name="ping") def api_ping(texto: str) -> str: return gpu_ping(texto) except Exception as e: print(f"[arranque] no pude registrar /ping ({e!r}); sigo", flush=True) if _acepta(app.launch, "ssr_mode"): kw["ssr_mode"] = False print("[arranque] el servidor es gradio.Server (sirve la página y la API)", flush=True) app.launch(**kw) return # Gradio más antiguo, sin gradio.Server: lanzamos un Blocks mínimo y ponemos nuestras rutas por delante print("[arranque] esta versión de Gradio no tiene gradio.Server; uso gr.Blocks", flush=True) with gr.Blocks(title="Carpooling UTEC") as demo: gr.Markdown("# Carpooling UTEC") if gpu_ping is not None: caja = gr.Textbox(visible=False) boton = gr.Button("ping", visible=False) boton.click(gpu_ping, caja, caja, api_name="ping") kw["prevent_thread_lock"] = True if _acepta(demo.launch, "ssr_mode"): kw["ssr_mode"] = False try: app = demo.launch(**kw)[0] _rutas_asgi(app, al_frente=True) except Exception: try: demo.close() except Exception: pass raise demo.block_thread() def main(): print(f"[arranque] Python {sys.version.split()[0]} · puerto {PORT} · base de datos: {DB_USED} · " f"clave de Google Maps: {KEY_STATUS}", flush=True) if en_hf(): try: serve_gradio() return except KeyboardInterrupt: return except Exception as e: print(f"[arranque] Gradio no pudo arrancar ({e!r}); uso el servidor estándar de Python", flush=True) serve_stdlib() if __name__ == "__main__": main()
# -*- coding: utf-8 -*-
"""CARPOOLING UTEC · prototipo funcional (Emprendimiento I, Grupo 6)
Un solo archivo de Python. Hace todo el trabajo "de negocio": usuarios UTEC, viajes, búsqueda por cercanía a la
ruta, precios, reservas, calificaciones, ahorro y panel (SQLite). La página (mapa de Google) solo dibuja y pide
datos a la API de este mismo archivo.
Dónde corre:
· Hugging Face (Space de Gradio, cuenta gratuita = ZeroGPU): Gradio levanta el servidor y esta app va dentro.
· Google Colab o tu PC: servidor de la librería estándar, no hay nada que instalar (python app.py).
Google Maps: la clave se lee del Secret GOOGLE_MAPS_API_KEY del Space. Si falta o Google la rechaza,
la página usa OpenStreetMap automáticamente para que la demo no se rompa.
"""
import inspect
import json
import math
import os
import random
import re
import sqlite3
import sys
import threading
import time
import unicodedata
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
PORT = int(os.environ.get("PORT") or 7860)
DB_PATH = os.environ.get("DB_PATH") or "/tmp/carpooling_utec_v2.db"
LIMA = timezone(timedelta(hours=-5))
_raw_key = (os.environ.get("GOOGLE_MAPS_API_KEY") or "").strip().strip("\"'")
KEY_STATUS = "no configurada"
GM_KEY = ""
if _raw_key:
if re.fullmatch(r"[A-Za-z0-9_\-]{20,100}", _raw_key):
GM_KEY, KEY_STATUS = _raw_key, "configurada"
else:
KEY_STATUS = "con formato extraño (se ignora)"
# ---------------------------------------------------------------- modelo (valores del informe)
UTEC = (-12.1355, -77.0223)
DIST = {
"Los Olivos": (-11.9700, -77.0700), "San Martín de Porres": (-12.0100, -77.0850),
"Comas": (-11.9400, -77.0550), "Independencia": (-11.9900, -77.0540),
"San Juan de Lurigancho": (-12.0030, -77.0080), "Ate": (-12.0260, -76.9170),
"La Molina": (-12.0850, -76.9400), "Santiago de Surco": (-12.1450, -76.9900),
"Villa María del Triunfo": (-12.1650, -76.9400), "Chorrillos": (-12.1750, -77.0150),
"Callao": (-12.0560, -77.1180), "Miraflores": (-12.1220, -77.0300), "Chosica": (-11.9330, -76.6970),
}
HOURS = ["07:00", "08:00", "10:00", "14:00", "18:00"]
TRAFFIC = {"07:00": 1.9, "08:00": 1.9, "10:00": 1.25, "14:00": 1.3, "18:00": 1.8} # solo para el modo sin Google
FUEL, MIN_PRICE, MAX_PRICE, BUS_DAY, TAXI_KM, TAXI_MIN, WEEKS, JAM_KMH = 0.60, 3, 40, 7, 1.4, 10, 4.3, 12.7
EMAIL_RE = re.compile(r"[^@\s]+@utec\.edu\.pe", re.I)
NAMES = ["Ana", "Diego", "Camila", "Luis", "Valeria", "Jorge", "Sofía", "Andrés", "Renzo", "Mía", "Piero", "Daniela",
"Marco", "Lucía", "Bruno", "Fiorella", "Rodrigo", "Alexandra", "Mateo", "Paula"]
CARS = ["Toyota Yaris", "Kia Rio", "Hyundai Accent", "Nissan Versa", "Suzuki Swift", "Chevrolet Sail"]
NOTES = ["Puntual", "Salgo a la hora exacta", "Cabe mochila y laptop", ""]
SIM_NAMES = ["Pasajero simulado", "Compañero simulado", "Estudiante simulado"]
def hav(a, b):
t = math.pi / 180
dl, dn = (b[0] - a[0]) * t, (b[1] - a[1]) * t
x = math.sin(dl / 2) ** 2 + math.cos(a[0] * t) * math.cos(b[0] * t) * math.sin(dn / 2) ** 2
return 12742 * math.asin(math.sqrt(x))
def road_km(a, b):
return hav(a, b) * 1.35 # la calle real es más larga que la línea recta
def seg_dist(p, a, b):
"""Distancia (km) del punto p al segmento a-b: cuánto se desvía el conductor para recogerte."""
k = math.cos((a[0] + b[0]) / 2 * math.pi / 180)
def xy(q):
return (q[1] * 111.32 * k, q[0] * 110.57)
P, A, B = xy(p), xy(a), xy(b)
dx, dy = B[0] - A[0], B[1] - A[1]
ll = dx * dx + dy * dy
t = 0 if ll == 0 else max(0, min(1, ((P[0] - A[0]) * dx + (P[1] - A[1]) * dy) / ll))
return math.hypot(P[0] - (A[0] + t * dx), P[1] - (A[1] + t * dy))
def suggest(km, seats):
"""Aporte por pasajero: el combustible del trayecto repartido entre todos los ocupantes, a múltiplos de S/ 0.50."""
return max(MIN_PRICE, round(km * FUEL / (seats + 1) * 2) / 2)
def mins(h):
a, b = h.split(":")
return int(a) * 60 + int(b)
def nearest_district(p):
return min(DIST, key=lambda k: hav(p, DIST[k]))