En este post te mostraré una aplicación de checador de transporte público (es web, pero puede ser convertida en app móvil nativa gracias a las PWA) para monitorear y registrar los horarios en los que pasa el transporte público.
Esta app es ideal para la persona que es el “checador” del transporte, mismo que registra los horarios en los que pasa cada unidad hacia cada destino o ruta junto con su número.
Más tarde, se puede saber hace cuánto tiempo pasó el transporte a determinado destino, además de calcular el tiempo promedio y llevar reportes por fecha.
Como varios de mis proyectos, esta PWA de checador de transporte público es open source y gratuita. A lo largo del post te enseñaré los aspectos técnicos, cómo descargar el código y cómo descargar la app.
Apartado técnico
Esta aplicación web está creada usando Vuetify, es decir, Vue con estilos de Material Design. Obviamente se usa el framework Vue con el lenguaje JavaScript.
La he diseñado usando tabs y pensando en que será usada en un dispositivo móvil de pantalla pequeña. A lo largo de toda la app se utilizan componentes para dividir la lógica.
Cuando la aplicación es compilada y preparada para producción se puede alojar en cualquier servidor ya sea local o de internet, y debido a que es una Progressive Web App se puede instalar como una app nativa.
Ya para la base de datos he usado el almacenamiento del lado del cliente con IndexedDB a través de la librería PouchDB.
Propósito de la app
Esta PWA sirve para saber hace cuánto tiempo pasó determinado transporte por si un conductor que se dirige al mismo destino quiere saber. Recuerda que la controla la persona denominada checador.
Por poner un ejemplo, si el conductor A se dirige a New Donk City, y luego el conductor B que se dirige al mismo lugar pregunta hace cuánto tiempo pasó el conductor A, a partir de la respuesta el B sabrá si debe ir más lento, tomar su distancia, esperar, etcétera (todo esto para balancear la frecuencia y brindar un mejor servicio a los pasajeros).
Gestión de destinos o rutas
Comenzamos viendo las rutas. En este apartado se registran todos los destinos de los transportes. Es decir, a cuáles lugares se dirige el transporte público que vamos a monitorear.
Cada ruta tiene la opción de ser eliminada, y podemos agregar una nueva con el botón de la esquina inferior derecha (un FAB).
El código del componente de las rutas es:
<template>
<v-card flat>
<ConfirmarEliminacionRuta @confirmado="eliminarRutaSeleccionada()" @cerrar="cerrarDialogo"
:ruta="rutaSeleccionada"
:mostrar="mostrarDialogoEliminar"></ConfirmarEliminacionRuta>
<AgregarRuta @guardada="onRutaGuardada" :mostrar="mostrarDialogoAgregar"
@cerrar="mostrarDialogoAgregar = false"></AgregarRuta>
<div v-if="rutas.length > 0">
<div v-for="(ruta, i) in rutas" :key="i">
<v-list-item @click="confirmarEliminacion(ruta)" two-line>
<v-list-item-content>
<v-list-item-title>{{ ruta.nombre }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</div>
</div>
<div v-else>
<v-alert
class="mx-2 my-2"
dense
type="info"
>
Todavía no hay rutas. Agrega una con el botón flotante
</v-alert>
</div>
<v-btn
color="primary"
fab
elevation="2"
right
bottom
fixed
@click="mostrarDialogoAgregarRuta()"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
<v-snackbar top timeout="700" v-model="snackbar.mostrar">
{{ snackbar.texto }}
</v-snackbar>
</v-card>
</template>
<script>
import ConfirmarEliminacionRuta from "@/components/ConfirmarEliminacionRuta";
import AgregarRuta from "@/components/AgregarRuta";
import RutasService from "@/RutasService";
export default {
name: "Rutas",
components: {AgregarRuta, ConfirmarEliminacionRuta},
data: () => ({
rutas: [],
rutaSeleccionada: {},
mostrarDialogoEliminar: false,
mostrarDialogoAgregar: false,
snackbar: {
mostrar: false,
texto: "",
}
}),
mounted() {
this.obtenerRutas();
},
methods: {
onRutaGuardada() {
this.snackbar.mostrar = true;
this.snackbar.texto = "Ruta guardada";
this.mostrarDialogoAgregar = false;
this.$emit("actualizadas");
this.obtenerRutas();
},
mostrarDialogoAgregarRuta() {
this.mostrarDialogoAgregar = true;
},
confirmarEliminacion(ruta) {
this.rutaSeleccionada = ruta;
this.mostrarDialogoEliminar = true;
},
async eliminarRutaSeleccionada() {
await RutasService.eliminar(this.rutaSeleccionada);
this.rutaSeleccionada = {};
this.mostrarDialogoEliminar = false;
this.$emit("actualizadas");
this.obtenerRutas();
},
cerrarDialogo() {
this.mostrarDialogoEliminar = false;
},
async obtenerRutas() {
this.rutas = await RutasService.obtener();
}
}
}
</script>
<style scoped>
</style>
Como puedes ver, mostramos la lista de rutas solo si hay rutas. En caso contrario, mostramos una alerta indicando que no hay rutas y que se pueden agregar con el FAB.
También estamos consumiendo un servicio de Vue para confirmar la eliminación en la línea 82. El código del servicio es:
import db from "@/BaseDeDatos";
import Utiles from "@/Utiles";
import Constantes from "@/Constantes";
import HorariosService from "@/HorariosService";
const RutasService = {
async nueva(nombre) {
return await db.put({
_id: Utiles.idConSufijo(Constantes.PREFIJO_RUTAS),
nombre
});
},
async obtener() {
const rutas = await db.find({
selector: {
_id: {
$gte: Constantes.PREFIJO_RUTAS,
$lte: Constantes.PREFIJO_RUTAS + "\ufff0",
}
}
});
return rutas.docs;
},
async eliminar(documento) {
await HorariosService.eliminarDeRuta(documento._id);
return await db.remove(documento);
}
};
export default RutasService;
Como te puedes dar cuenta estoy usando a PouchDB y filtrando los elementos a través del id de manera lexicográfica, ya que en este caso no tenemos unas tablas SQL, sino que todo son documentos, así que para obtenerlos usamos prefijos para limitar lo que obtenemos.
También aquí tenemos todas las funciones que se pueden hacer por cada ruta. Ya dentro de los componentes simplemente invocamos a este código y vamos reutilizándolo. Finalmente veamos el código de la base de datos:
import PouchDB from "pouchdb";
import PouchdbFind from 'pouchdb-find';
PouchDB.plugin(PouchdbFind);
const db = new PouchDB('rutas');
// Para poder ordenar por hora en los horarios
db.createIndex({
index: {fields: ['hora']}
});
export default db;
Simplemente creamos una instancia de PouchDB con el nombre de rutas
, agregamos índices y la exportamos. Me he tomado a la tarea de explicarte todo esto porque en los siguientes módulos se sigue el mismo patrón.
Horarios del checador de transporte público
Este es el módulo principal y en donde se pasa más tiempo.
Pasemos al apartado de los horarios. Aquí se registra cuando acaba de pasar determinada unidad hacia cierto destino (el destino aparece en una alerta dentro del diálogo).
Se debe guardar el tipo y número de unidad, así como la hora en la que pasó.
Una vez que se ha guardado, se muestra el tiempo transcurrido del último horario tomado. De este modo se puede responder rápidamente hace cuánto tiempo pasó el transporte anterior.
Como puedes ver en la imagen, en este caso hay dos tipos de transporte, estas son etiquetas específicas del transporte que se vaya a monitorear.
Por cierto, el tiempo transcurrido se muestra en tiempo real (gracias a setInterval y la reactividad de Vue) pero he integrado el FAB de pausa en caso de ser necesario.
Reporte de checador de transporte público
Gracias a que estamos registrando a qué hora pasa cada unidad podemos sacar promedios y brindar reportes por día. Por ejemplo podemos saber cada cuánto pasa determinado transporte en promedio, ver las horas a las que pasan, las unidades, etcétera.
Por ello es que he creado el módulo de reportes. De este modo se selecciona la fecha (por defecto está la actual):
Y también tenemos la lista de rutas o destinos que sirven para filtrar los reportes. Como puedes ver, los horarios aparecen en orden descendente, mostrando el horario más reciente primero.
Se me hace necesario mostrar el código que calcula los promedios y se encarga del componente en general:
<template>
<v-card class="mx-2" flat>
<v-dialog
ref="dialog"
v-model="modal"
:return-value.sync="fechaSeleccionada"
persistent
width="290px"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
@change="obtenerHorariosConFechaYRutaSeleccionada()"
v-model="fechaSeleccionada"
label="Fecha"
prepend-icon="mdi-calendar"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<v-date-picker
@change="obtenerHorariosConFechaYRutaSeleccionada()"
locale="es-la"
v-model="fechaSeleccionada"
scrollable
>
<v-spacer></v-spacer>
<v-btn
text
color="primary"
@click="modal = false"
>
Cancelar
</v-btn>
<v-btn
text
color="primary"
@click="$refs.dialog.save(fechaSeleccionada)"
>
OK
</v-btn>
</v-date-picker>
</v-dialog>
<v-select
@change="obtenerHorariosConFechaYRutaSeleccionada()"
:items="rutas"
item-text="nombre"
item-value="_id"
label="Ruta"
v-model="rutaSeleccionada"
no-data-text="No has registrado ninguna ruta"
></v-select>
<div v-if="horarios.length > 0">
<v-row justify="center">
<h6 class="text-h5">Promedios</h6>
</v-row>
<v-row justify="center">
<v-chip class="mr-1" color="success">General</v-chip>
<v-chip class="mr-1" color="info">Combi</v-chip>
<v-chip color="red" dark>Rojo</v-chip>
</v-row>
<v-row class="mt-4" justify="center">
<v-chip class="mr-1 my-1" color="success">{{ promedios.general | milisegundosALegible }}</v-chip>
<v-chip class="mr-1 my-1" color="info">{{ promedios.combi | milisegundosALegible }}</v-chip>
<v-chip class="mr-1 my-1" color="red" dark>{{ promedios.rojo | milisegundosALegible }}</v-chip>
</v-row>
<v-divider class="mt-4"></v-divider>
<div v-for="(horario, i) in horarios" :key="i">
<v-list-item two-line>
<v-list-item-content>
<v-list-item-title>
<h6 class="text-h6">
{{ horario.hora }}
</h6>
</v-list-item-title>
<TipoTransporte :horario="horario"></TipoTransporte>
<v-row justify="center">
<v-col>
<p v-show="horario.tiempoMismoTipo">
<strong>{{ horario.tipoUnidad }} anterior:
<br>
</strong> {{
horario.tiempoMismoTipo | milisegundosALegible
}}
</p>
</v-col>
<v-col>
<p v-show="horario.tiempoGeneral">
<strong>Transporte anterior: </strong>
<br>
{{ horario.tiempoGeneral | milisegundosALegible }}
</p>
</v-col>
</v-row>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</div>
</div>
<div v-else>
<v-alert type="info">
No hay horarios para la fecha y ruta seleccionados
</v-alert>
</div>
</v-card>
</template>
<script>
import RutasService from "@/RutasService";
import Utiles from "@/Utiles";
import HorariosService from "@/HorariosService";
import TipoTransporte from "@/components/TipoTransporte";
import Constantes from "@/Constantes";
export default {
name: "Reportes",
components: {TipoTransporte},
data: () => ({
fechaSeleccionada: "",
rutas: [],
modal: false,
rutaSeleccionada: "",
horarios: [],
promedios: {
rojo: "",
combi: "",
general: "",
},
TIPO_COMBI: Constantes.TIPO_COMBI,
}),
async mounted() {
await this.refrescarTodo();
},
methods: {
async refrescarTodo() {
this.fechaSeleccionada = Utiles.formatearFechaActual();
await this.obtenerRutas();
if (this.rutas.length > 0) {
this.rutaSeleccionada = this.rutas[0]._id;
}
await this.obtenerHorariosConFechaYRutaSeleccionada();
},
async obtenerRutas() {
this.rutas = await RutasService.obtener();
},
async obtenerHorariosConFechaYRutaSeleccionada() {
if (!this.fechaSeleccionada || !this.rutaSeleccionada) {
return;
}
const horarios = await HorariosService.obtenerPorFechaEIdRuta(this.fechaSeleccionada, this.rutaSeleccionada);
if (horarios.length > 0) {
let ultimaHoraRojo = "";
let ultimaHoraCombi = "";
let sumatoriaRojo = 0;
let sumatoriaCombi = 0;
let sumatoriaGeneral = 0;
let contadorRojo = 0;
let contadorCombi = 0;
if (horarios[horarios.length - 1].tipoUnidad === Constantes.TIPO_ROJO) {
ultimaHoraRojo = horarios[horarios.length - 1].hora;
} else {
ultimaHoraCombi = horarios[horarios.length - 1].hora;
}
for (let i = horarios.length - 2; i >= 0; i--) {
const tiempoA = horarios[i].hora;
const tiempoB = horarios[i + 1].hora;
let diferenciaGeneral = Utiles.restarHorarios(tiempoA, tiempoB);
sumatoriaGeneral += diferenciaGeneral;
horarios[i].tiempoGeneral = diferenciaGeneral;
if (horarios[i].tipoUnidad === Constantes.TIPO_ROJO) {
if (ultimaHoraRojo) {
let diferencia = Utiles.restarHorarios(tiempoA, ultimaHoraRojo);
sumatoriaRojo += diferencia;
contadorRojo++;
horarios[i].tiempoMismoTipo = diferencia;
}
ultimaHoraRojo = tiempoA;
} else {
if (ultimaHoraCombi) {
let diferencia = Utiles.restarHorarios(tiempoA, ultimaHoraCombi);
sumatoriaCombi += diferencia;
contadorCombi++;
horarios[i].tiempoMismoTipo = diferencia;
}
ultimaHoraCombi = tiempoA;
}
}
this.promedios.general = sumatoriaGeneral / (horarios.length - 1);
this.promedios.rojo = sumatoriaRojo / contadorRojo;
this.promedios.combi = sumatoriaCombi / contadorCombi;
}
this.horarios = horarios;
}
}
}
</script>
Poniendo todo junto
Como siempre lo digo, no puedo poner el código completo aquí, pues me llevaría bastante tiempo explicar cada cosa. Recuerda que esta webapp está creada con la Vue CLI y que necesita NPM para ejecutar el servidor de desarrollo.
Te dejo el código fuente aquí, y la demostración aquí.
Convirtiendo en PWA para instalar app nativa
Como lo dije, al compilar esta webapp vamos a tener solo archivos CSS, JS y HTML. Además, el repositorio ya incluye el manifiesto, por lo que crear una PWA a partir de ello es muy fácil. Aquí las instrucciones:
Ejecutar npm run build
para compilar la app.
Luego instalar sw-precache
con npm install --global sw-precache
. Para generar el archivo service-worker.js
entramos a dist
con cd dist
y ejecutamos: sw-precache
.
Ahora todo lo que necesitamos es publicar toda la carpeta de dist
, puedes subirla a un servidor local para probar, en ese caso abrimos la consola de depuración, pestaña Application > Manifest y todo debe estar bien.
Si quieres hostearla en este mismo repositorio de GitHub, renombra dist
a docs
y configura en GitHub dentro de Environments. Selecciona publicarla, dentro de Branch elige master
y en ruta /docs
.
Instalando app nativa
Ahora subimos la PWA ya lista a un sitio https, puede ser GitHub pages, visitamos la página con dispositivo móvil, de preferencia con Chrome. Seleccionamos Menú > Instalar aplicación. Y así quedará como app nativa.
Nota: solo la he probado en Android, pero debería funcionar en iOS. Si eres de iOS y no te funciona, hay que seguir los siguientes pasos:
- Conseguir un dispositivo Android
- Volver a leer esta sección
Conclusión
Solo resta decir que te invito a ver todos mis proyectos en este enlace, tal vez uno te interese. Aquí puedes leer más sobre Vue o JavaScript.