feat: add calendar
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
447
frontend/src/components/CollectionCalendar.tsx
Normal file
447
frontend/src/components/CollectionCalendar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
32
frontend/src/data/mocks/routes.mock.ts
Normal file
32
frontend/src/data/mocks/routes.mock.ts
Normal 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" },
|
||||
];
|
||||
Reference in New Issue
Block a user