feat: add calendar

This commit is contained in:
Diego Mireles
2026-05-23 06:53:28 -06:00
parent d280b3865e
commit 7de53482b1
4 changed files with 484 additions and 0 deletions

View File

@@ -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) */}
<CollectionCalendar />
{/* Header con título grande + imagen */}
<View style={styles.headerRow}>
<View style={{ flex: 1, paddingRight: 8 }}>

View File

@@ -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) {

View File

@@ -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 (
<View style={styles.container}>
<View style={styles.headerRow}>
<Ionicons name="calendar-outline" size={22} color="#0E8A61" />
<Text style={styles.title}>Calendario de recolección</Text>
</View>
<View style={styles.ctaBox}>
<Ionicons name="lock-closed-outline" size={28} color="#9CA3AF" />
<Text style={styles.ctaText}>
Necesitas validar tu domicilio para ver el calendario de tu ruta.
</Text>
<Pressable
style={styles.ctaBtn}
onPress={() => router.push("/addresses")}
>
<Text style={styles.ctaBtnText}>Validar domicilio</Text>
</Pressable>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Ionicons name="calendar-outline" size={22} color="#0E8A61" />
<Text style={styles.title}>Calendario de recolección</Text>
</View>
<View style={styles.routeBadge}>
<Ionicons name="bus-outline" size={14} color="#0E8A61" />
<Text style={styles.routeBadgeText}>
Tu ruta: <Text style={styles.routeBadgeBold}>{myRouteId}</Text>
{myRouteName ? ` · ${myRouteName}` : ""}
</Text>
</View>
{/* Navegador de mes */}
<View style={styles.monthRow}>
<Pressable onPress={handlePrev} style={styles.navBtn} hitSlop={10}>
<Ionicons name="chevron-back" size={20} color="#0E8A61" />
</Pressable>
<Text style={styles.monthLabel}>
{MONTHS_ES[month]} {year}
</Text>
<Pressable onPress={handleNext} style={styles.navBtn} hitSlop={10}>
<Ionicons name="chevron-forward" size={20} color="#0E8A61" />
</Pressable>
</View>
{/* Encabezado días de la semana */}
<View style={styles.weekHeader}>
{DAYS_ES.map((d) => (
<View key={d} style={styles.weekCell}>
<Text style={styles.weekText}>{d}</Text>
</View>
))}
</View>
{/* Grid de días — 6 filas explícitas para evitar el problema de
alineación que ocurre con flexWrap + porcentajes en React Native. */}
<View>
{Array.from({ length: 6 }).map((_, rowIdx) => (
<View key={rowIdx} style={styles.gridRow}>
{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 (
<View key={colIdx} style={styles.dayCell}>
<View
style={[
styles.dayInner,
{ backgroundColor: bg },
!cell.inMonth && { opacity: 0.4 },
isToday && styles.todayBorder,
]}
>
<Text style={[styles.dayText, { color: textColor }]}>
{cell.day}
</Text>
</View>
</View>
);
})}
</View>
))}
</View>
{/* Leyenda */}
{isSpecial ? (
<View style={styles.legend}>
<View style={styles.legendRow}>
<View
style={[styles.legendSwatch, { backgroundColor: COLOR_ORGANIC }]}
/>
<Text style={styles.legendText}>RESIDUOS ORGÁNICOS</Text>
</View>
<View style={styles.legendRow}>
<View
style={[styles.legendSwatch, { backgroundColor: COLOR_SOLID }]}
/>
<Text style={styles.legendText}>
RESIDUOS SÓLIDOS URBANOS VALORABLES
</Text>
</View>
</View>
) : (
<View style={styles.legendNeutral}>
<Ionicons
name="information-circle-outline"
size={16}
color="#6B7280"
/>
<Text style={styles.legendNeutralText}>
Tu ruta aún no tiene un calendario de separación específico. Te
avisaremos cuando se publique.
</Text>
</View>
)}
</View>
);
}
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,
},
});

View File

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