Reporte de horarios de transporte público y promedios con app checador de transporte público

Checador de transporte público – Aplicación gratuita

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.

Reporte de horarios de transporte público y promedios con app checador de transporte público
Reporte de horarios de transporte público y promedios con app checador de 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

Gestión de destinos o rutas en app móvil de transporte
Gestión de destinos o rutas en app móvil de transporte

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.

Horarios en los que pasa cada transporte - Checador de transporte público
Horarios en los que pasa cada transporte – Checador de transporte público

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ó.

Registrar horario de pase de transporte público
Registrar horario de pase de transporte público

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

Reporte de horarios de transporte público y promedios con app checador de transporte público
Reporte de horarios de transporte público y promedios con app 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):

Seleccionar fecha para reporte
Seleccionar fecha para reporte

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:

  1. Conseguir un dispositivo Android
  2. 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.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *