From 7de53482b1873474d73ad061de0598f95f849909 Mon Sep 17 00:00:00 2001 From: Diego Mireles Date: Sat, 23 May 2026 06:53:28 -0600 Subject: [PATCH] feat: add calendar --- frontend/src/app/guide.tsx | 4 + frontend/src/app/profile.tsx | 1 + .../src/components/CollectionCalendar.tsx | 447 ++++++++++++++++++ frontend/src/data/mocks/routes.mock.ts | 32 ++ 4 files changed, 484 insertions(+) create mode 100644 frontend/src/components/CollectionCalendar.tsx create mode 100644 frontend/src/data/mocks/routes.mock.ts diff --git a/frontend/src/app/guide.tsx b/frontend/src/app/guide.tsx index 7175431..aac9198 100644 --- a/frontend/src/app/guide.tsx +++ b/frontend/src/app/guide.tsx @@ -3,6 +3,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import { COLORS } from "../constants/colors"; +import CollectionCalendar from "../components/CollectionCalendar"; type Category = { key: string; @@ -85,6 +86,9 @@ export default function GuideScreen() { showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 20, paddingBottom: 130 }} > + {/* Calendario de recolección (primer elemento) */} + + {/* Header con título grande + imagen */} diff --git a/frontend/src/app/profile.tsx b/frontend/src/app/profile.tsx index e6c40fa..d73db0d 100644 --- a/frontend/src/app/profile.tsx +++ b/frontend/src/app/profile.tsx @@ -48,6 +48,7 @@ export default function ProfileScreen() { try { await apiFetch<{ message: string }>("/api/tracking/reset-demo", { method: "POST", + body: JSON.stringify({}), }); Alert.alert("Demo reiniciada", "Las notificaciones se borraron."); } catch (err) { diff --git a/frontend/src/components/CollectionCalendar.tsx b/frontend/src/components/CollectionCalendar.tsx new file mode 100644 index 0000000..f9d6ead --- /dev/null +++ b/frontend/src/components/CollectionCalendar.tsx @@ -0,0 +1,447 @@ +/** + * CollectionCalendar.tsx + * + * Calendario mensual de recolección de la ruta ASIGNADA al usuario. + * Respeta el principio de "visión de túnel": el ciudadano NO puede ver + * rutas que no sean la suya. + * + * Reglas de color por día (solo para RUTA-40, RUTA-65 y RUTA-80): + * L, M, V, S → verde (RESIDUOS ORGÁNICOS) + * Mi, J → gris (RESIDUOS SÓLIDOS URBANOS VALORABLES) + * Domingo → vacío + * + * El patrón se aplica TODAS las semanas sin excepción. Las celdas del mes + * anterior/siguiente que aparecen al inicio/final también se colorean + * (en tono más claro) para que no queden huecos entre semana. + * + * Otras rutas: días neutros sin etiqueta. + * + * Si el usuario no ha validado su domicilio, se muestra un CTA. + */ + +import { useMemo, useState } from "react"; +import { View, Text, StyleSheet, Pressable } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; + +import { useApp } from "../context/AppContext"; +import { routes as routeCatalog } from "../data/mocks/routes.mock"; + +const MONTHS_ES = [ + "Enero", + "Febrero", + "Marzo", + "Abril", + "Mayo", + "Junio", + "Julio", + "Agosto", + "Septiembre", + "Octubre", + "Noviembre", + "Diciembre", +]; + +const DAYS_ES = ["Lu", "Ma", "Mi", "Ju", "Vi", "Sa", "Do"]; + +const SPECIAL_ROUTES = new Set(["RUTA-40", "RUTA-65", "RUTA-80"]); + +const COLOR_ORGANIC = "#10b981"; +const COLOR_SOLID = "#9ca3af"; +const COLOR_NEUTRAL = "#E5E7EB"; +const COLOR_EMPTY = "transparent"; + +type DayInfo = { day: number; weekIndex: number; inMonth: boolean }; + +/** Convierte JS getDay (0=Sun..6=Sat) al índice Mon-first (0=Lun..6=Dom). */ +const toMondayFirst = (jsDay: number): number => (jsDay === 0 ? 6 : jsDay - 1); + +/** + * Devuelve 42 celdas (6 semanas × 7 días). Las celdas del inicio/final + * que caen fuera del mes actual también traen su día real del mes vecino + * (con inMonth=false) para que el calendario no tenga huecos entre semana. + */ +function buildMonthGrid(year: number, month: number): DayInfo[] { + const firstDay = new Date(year, month, 1); + const firstWeekIndex = toMondayFirst(firstDay.getDay()); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const daysInPrevMonth = new Date(year, month, 0).getDate(); + + const cells: DayInfo[] = []; + + // Días del mes anterior que aparecen al inicio + for (let i = firstWeekIndex - 1; i >= 0; i--) { + const date = new Date(year, month - 1, daysInPrevMonth - i); + cells.push({ + day: daysInPrevMonth - i, + weekIndex: toMondayFirst(date.getDay()), + inMonth: false, + }); + } + + // Días del mes actual + for (let d = 1; d <= daysInMonth; d++) { + const date = new Date(year, month, d); + cells.push({ + day: d, + weekIndex: toMondayFirst(date.getDay()), + inMonth: true, + }); + } + + // Días del mes siguiente para completar 42 celdas + let next = 1; + while (cells.length < 42) { + const date = new Date(year, month + 1, next); + cells.push({ + day: next, + weekIndex: toMondayFirst(date.getDay()), + inMonth: false, + }); + next++; + } + + return cells; +} + +/** + * Color de fondo según ruta + día de la semana. + * - SPECIAL_ROUTES (40/65/80): patrón verde/gris/vacío todas las semanas + * - Otras rutas: neutro (salvo domingo que va vacío) + */ +function colorForDay(routeId: string, weekIndex: number): string { + if (!SPECIAL_ROUTES.has(routeId)) { + return weekIndex === 6 ? COLOR_EMPTY : COLOR_NEUTRAL; + } + if (weekIndex === 0 || weekIndex === 1 || weekIndex === 4 || weekIndex === 5) + return COLOR_ORGANIC; // Lun, Mar, Vie, Sáb + if (weekIndex === 2 || weekIndex === 3) return COLOR_SOLID; // Mié, Jue + return COLOR_EMPTY; // Domingo +} + +export default function CollectionCalendar() { + const { route } = useApp(); + const router = useRouter(); + const today = new Date(); + const [year, setYear] = useState(today.getFullYear()); + const [month, setMonth] = useState(today.getMonth()); + + const myRouteId = route?.routeId ?? null; + const myRouteName = useMemo( + () => routeCatalog.find((r) => r.routeId === myRouteId)?.name ?? "", + [myRouteId], + ); + const isSpecial = myRouteId ? SPECIAL_ROUTES.has(myRouteId) : false; + + const grid = useMemo(() => buildMonthGrid(year, month), [year, month]); + + const handlePrev = () => { + if (month === 0) { + setMonth(11); + setYear((y) => y - 1); + } else { + setMonth((m) => m - 1); + } + }; + + const handleNext = () => { + if (month === 11) { + setMonth(0); + setYear((y) => y + 1); + } else { + setMonth((m) => m + 1); + } + }; + + // CTA: sin ruta asignada + if (!myRouteId) { + return ( + + + + Calendario de recolección + + + + + Necesitas validar tu domicilio para ver el calendario de tu ruta. + + router.push("/addresses")} + > + Validar domicilio + + + + ); + } + + return ( + + + + Calendario de recolección + + + + + + Tu ruta: {myRouteId} + {myRouteName ? ` · ${myRouteName}` : ""} + + + + {/* Navegador de mes */} + + + + + + {MONTHS_ES[month]} {year} + + + + + + + {/* Encabezado días de la semana */} + + {DAYS_ES.map((d) => ( + + {d} + + ))} + + + {/* Grid de días — 6 filas explícitas para evitar el problema de + alineación que ocurre con flexWrap + porcentajes en React Native. */} + + {Array.from({ length: 6 }).map((_, rowIdx) => ( + + {grid.slice(rowIdx * 7, (rowIdx + 1) * 7).map((cell, colIdx) => { + const bg = colorForDay(myRouteId, cell.weekIndex); + const isToday = + cell.inMonth && + cell.day === today.getDate() && + month === today.getMonth() && + year === today.getFullYear(); + const isColored = + bg === COLOR_ORGANIC || bg === COLOR_SOLID; + const textColor = isColored ? "#FFFFFF" : "#0F172A"; + + return ( + + + + {cell.day} + + + + ); + })} + + ))} + + + {/* Leyenda */} + {isSpecial ? ( + + + + RESIDUOS ORGÁNICOS + + + + + RESIDUOS SÓLIDOS URBANOS VALORABLES + + + + ) : ( + + + + Tu ruta aún no tiene un calendario de separación específico. Te + avisaremos cuando se publique. + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#FFFFFF", + borderRadius: 18, + padding: 16, + marginBottom: 18, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 3, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + }, + title: { + fontSize: 18, + fontWeight: "800", + color: "#0F172A", + marginLeft: 8, + }, + routeBadge: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + backgroundColor: "#ECFDF5", + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 999, + marginBottom: 8, + }, + routeBadgeText: { + fontSize: 12, + color: "#065F46", + marginLeft: 6, + }, + routeBadgeBold: { + fontWeight: "800", + color: "#0E8A61", + }, + monthRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginTop: 8, + marginBottom: 10, + }, + navBtn: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "#ECFDF5", + justifyContent: "center", + alignItems: "center", + }, + monthLabel: { + fontSize: 16, + fontWeight: "700", + color: "#0F172A", + }, + weekHeader: { + flexDirection: "row", + marginBottom: 6, + }, + weekCell: { + flex: 1, + alignItems: "center", + paddingVertical: 4, + paddingHorizontal: 2, + }, + weekText: { + fontSize: 11, + fontWeight: "700", + color: "#9CA3AF", + }, + gridRow: { + flexDirection: "row", + }, + dayCell: { + flex: 1, + padding: 2, + aspectRatio: 1, + }, + dayInner: { + flex: 1, + borderRadius: 8, + justifyContent: "center", + alignItems: "center", + }, + dayText: { + fontSize: 13, + fontWeight: "600", + }, + todayBorder: { + borderWidth: 2, + borderColor: "#0E8A61", + }, + legend: { + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: "#F3F4F6", + }, + legendRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: 6, + }, + legendSwatch: { + width: 14, + height: 14, + borderRadius: 4, + marginRight: 8, + }, + legendText: { + fontSize: 11, + fontWeight: "700", + color: "#374151", + letterSpacing: 0.3, + }, + legendNeutral: { + flexDirection: "row", + alignItems: "center", + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: "#F3F4F6", + }, + legendNeutralText: { + fontSize: 11, + color: "#6B7280", + marginLeft: 6, + flex: 1, + }, + ctaBox: { + alignItems: "center", + padding: 20, + }, + ctaText: { + fontSize: 13, + color: "#6B7280", + textAlign: "center", + marginTop: 10, + marginBottom: 14, + }, + ctaBtn: { + backgroundColor: "#0E8A61", + paddingHorizontal: 18, + paddingVertical: 10, + borderRadius: 10, + }, + ctaBtnText: { + color: "#FFFFFF", + fontWeight: "700", + fontSize: 13, + }, +}); diff --git a/frontend/src/data/mocks/routes.mock.ts b/frontend/src/data/mocks/routes.mock.ts new file mode 100644 index 0000000..16853e4 --- /dev/null +++ b/frontend/src/data/mocks/routes.mock.ts @@ -0,0 +1,32 @@ +/** + * routes.mock.ts (frontend) + * Lista de rutas para el calendario de recolección. + * No incluye coordenadas GPS — eso vive en el backend. + * Solo lo necesario para el selector y el calendario. + */ + +export interface MockRoute { + routeId: string; + name: string; +} + +export const routes: MockRoute[] = [ + { routeId: "RUTA-01", name: "Zona Centro - Las Arboledas" }, + { routeId: "RUTA-02", name: "Sector Norte - Av. Tecnológico" }, + { routeId: "RUTA-03", name: "Sector Poniente - San Juanico" }, + { routeId: "RUTA-04", name: "Oriente - Los Olivos" }, + { routeId: "RUTA-05", name: "Sector Sur - Rancho Seco" }, + { routeId: "RUTA-06", name: "Norte Extremo - Rumbos de Roque" }, + { routeId: "RUTA-07", name: "Nororiente - Ciudad Industrial" }, + { routeId: "RUTA-08", name: "Suroriente - Universidad Latina" }, + { routeId: "RUTA-09", name: "Poniente - Hospital General" }, + { routeId: "RUTA-10", name: "Eje Juan Pablo II" }, + { routeId: "RUTA-11", name: "Zona de Oro - Torres Landa" }, + { routeId: "RUTA-12", name: "Nororiente - Las Insurgentes" }, + { routeId: "RUTA-13", name: "Sector Norte - Trojes e Irrigación" }, + { routeId: "RUTA-14", name: "Sur Poniente - La Toscana" }, + { routeId: "RUTA-15", name: "Norponiente - San José de Celaya" }, + { routeId: "RUTA-40", name: "Norte Industrial" }, + { routeId: "RUTA-65", name: "Centro Comercial" }, + { routeId: "RUTA-80", name: "Sector Residencial Sur" }, +];