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" },
+];