Aprovechando que SQLite3 ha llegado a los navegadores web, he decidido crear una aplicación web (que puede ser instalada como nativa) simple que muestra la edad de las personas registradas, mostrando:
- Edad calculada al día de hoy
- Edad precisa incluyendo segundos
- Siguiente cumpleaños
- Tiempo que falta para el próximo cumpleaños
Esta app también puede servir para mostrar el tiempo que ha transcurrido desde un cierto evento (como un aniversario), solo es cuestión de registrar los datos correctamente.
La app es realmente simple, pero era una webapp que siempre quise hacer y poner a disposición del público, pues es open source.
Está hecha con TypeScript y para los estilos he usado TailwindCSS. Utiliza Workers y SQLite3 como almacenamiento; también se puede convertir en una PWA. Para “compilarla” se usa Vite.
Nota: aquí puedes instalar la aplicación para cumpleaños.
Registrar persona
Comencemos viendo la función que guarda la persona usando una consulta SQL. Queda así:
const storePerson = async (name: string, birthDate: string) => {
const rows = await db.exec({
sql: "INSERT INTO people(name, birthDate) VALUES (?, ?) RETURNING *",
bind: [name, birthDate],
returnValue: "resultRows",
rowMode: "object",
});
return rows[0];
}
Por ahora solo se guarda el nombre y la fecha de nacimiento. Ambos son guardados como cadenas, pues más adelante en combinación con Date
podremos darles formato y calcular el tiempo transcurrido.
Después de eso vemos el formulario. Recordemos que estamos usando Tailwind para los estilos, así que queda así:
<div class="p-2 flex flex-col">
<form id="registerForm" action="">
<label for="name" class="text-zinc-950 text-lg">Nombre</label>
<input required type="text" id="name"
class="w-full block focus:border-green-500 border-green-300 border-2 rounded-md p-1">
<label for="birthDate" class="text-zinc-950 text-lg">Fecha de nacimiento</label>
<input type="datetime-local" id="birthDate" required
class="w-full focus:border-green-500 block border-green-300 border-2 rounded-md p-1 ">
<button disabled id="saveButton" type="submit"
class="w-full disabled:bg-green-100 disabled:text-gray-500 bg-green-500 border-green-100 focus:bg-green-400 hover:bg-green-400 border-2 mt-2 rounded-md p-2 text-white">Cargando...</button>
<button id="backButton" type="button" disabled
class="disabled:text-gray-500 disabled:bg-sky-100 w-full bg-sky-500 border-sky-100 focus:bg-sky-400 hover:bg-sky-400 border-2 mt-2 rounded-md p-2 text-white">Cargando...</button>
</form>
</div>
Luego agregamos el funcionamiento con TypeScript, invocando al worker, enviando la persona registrada y esperando que los valores sean insertados:
const worker = new Worker(new URL("./db.ts", import.meta.url), { type: "module" });
const $form = document.querySelector("#registerForm") as HTMLFormElement,
$saveButton = document.querySelector("#saveButton") as HTMLButtonElement,
$backButton = document.querySelector("#backButton") as HTMLButtonElement,
$name = document.querySelector("#name") as HTMLInputElement,
$birthDate = document.querySelector("#birthDate") as HTMLInputElement;
worker.postMessage(["init"]);
worker.onmessage = event => {
const action = event.data[0];
switch (action) {
case "ready":
$saveButton.textContent = "Guardar";
$backButton.textContent = "Volver";
[$saveButton, $backButton].forEach(element => element.disabled = false);
$backButton.addEventListener("click", () => {
$backButton.textContent = "Cargando...";
[$saveButton, $backButton].forEach(element => element.disabled = true);
worker.postMessage(["close_db"]);
});
$form.addEventListener("submit", (event) => {
event.preventDefault();
const name = $name.value, birthDate = $birthDate.value;
[$saveButton, $backButton].forEach(element => element.disabled = true);
$saveButton.textContent = "Guardando...";
worker.postMessage(["store_person", { name, birthDate }]);
});
break;
case "person_stored":
worker.postMessage(["close_db"]);
break;
case "db_closed":
window.location.href = "./index.html";
break;
}
}
Las demás funciones (editar, listar, buscar y eliminar) funcionan de manera similar, ajustando lo necesario.
Al final te dejaré el código completo, por ahora veamos algunas funciones útiles que servirán para mostrar el tiempo transcurrido desde la fecha de nacimiento de la persona.
Funciones para app de cumpleaños
Necesitamos algunas funciones, por ejemplo, la que formatea la fecha usando Intl.DateTimeFormat, un debounce para las búsquedas y todas las funciones para saber el tiempo transcurrido, el tiempo faltante, etcétera.
Las he colocado en un archivo de útiles y queda así:
const formater = new Intl.DateTimeFormat("es-MX", { dateStyle: "full" });
export const debounce = (callback: Function, wait: number) => {
let timerId: number;
return (...args: any[]) => {
clearTimeout(timerId);
timerId = setTimeout(() => {
callback(...args);
}, wait);
};
};
export const getPersonHtml = (person: Person): string => {
return `<h1 class="text-2xl font-bold">${person.name}<small class="text-zinc-700"> ${getReadableBirthDate(person.birthDate)}</small></h1>
<p class="my-2"><strong class="font-bold bg-green-500 text-white rounded-md p-1 mr-1 ">Edad</strong>
<span class="person-age"></span></p>
<p class="my-2"><strong class="font-bold bg-red-500 text-white rounded-md p-1 mr-1 ">Precisa</strong>
<span class="person-actual-age"></span>
</p>
<p class="my-2"><strong class="font-bold bg-sky-500 text-white rounded-md p-1 mr-1 ">Siguiente</strong>
<span class="person-next-birthday"></span>
</p>`;
}
export const getReadableTimeDifference = (differenceInMilliseconds: number): string => {
const daysInAYear = 365.25;
const daysInAMonth = 30.437;
const millisecondsInASecond = 1000;
const millisecondsInAMinute = millisecondsInASecond * 60;
const millisecondsInAHour = millisecondsInAMinute * 60;
const millisecondsInADay = millisecondsInAHour * 24;
const millisecondsInAMonth = millisecondsInADay * daysInAMonth;
const millisecondsInAYear = millisecondsInADay * daysInAYear;
const years = Math.floor(differenceInMilliseconds / millisecondsInAYear);
differenceInMilliseconds -= years * millisecondsInAYear;
const months = Math.floor(differenceInMilliseconds / millisecondsInAMonth);
differenceInMilliseconds -= months * millisecondsInAMonth;
const days = Math.floor(differenceInMilliseconds / millisecondsInADay);
differenceInMilliseconds -= days * millisecondsInADay;
const hours = Math.floor(differenceInMilliseconds / millisecondsInAHour);
differenceInMilliseconds -= hours * millisecondsInAHour;
const minutes = Math.floor(differenceInMilliseconds / millisecondsInAMinute);
differenceInMilliseconds -= minutes * millisecondsInAMinute;
const seconds = Math.floor(differenceInMilliseconds / millisecondsInASecond);
differenceInMilliseconds -= seconds * millisecondsInASecond;
let result = "";
if (years > 0) {
result += `${years} años, `;
}
if (months > 0) {
result += `${months} meses, `;
}
if (days > 0) {
result += `${days} días, `;
}
if (hours > 0) {
result += `${hours} horas, `;
}
if (minutes > 0) {
result += `${minutes} minutos, `;
}
if (seconds > 0) {
result += `${seconds} segundos`;
}
return result;
}
export const getActualAge = (birthDateAsString: string) => {
const now = new Date();
const birthDate = new Date(birthDateAsString);
let differenceInMilliseconds = now.getTime() - birthDate.getTime();
return getReadableTimeDifference(differenceInMilliseconds);
}
export const getThisYearsBirthday = (birthDate: string): Date => {
const now = new Date();
const thisYearsBirthday = new Date(birthDate);
thisYearsBirthday.setFullYear(now.getFullYear());
return thisYearsBirthday;
}
export const getNextBirthday = (birthdate: string) => {
const thisYearsBirthday = getThisYearsBirthday(birthdate);
const now = new Date();
if (now.getTime() > thisYearsBirthday.getTime()) {
thisYearsBirthday.setFullYear(thisYearsBirthday.getFullYear() + 1);
}
return thisYearsBirthday;
}
export const daysInPreviousMonth = (): number => {
const now = new Date();
now.setMonth(now.getMonth() - 1, 0);
return now.getDate();
}
export const getReadableBirthDate = (birthDate: string): string => {
return formater.format(new Date(birthDate));
}
export const getReadableAge = (birthDateAsString: string): string => {
let birthDate = new Date(birthDateAsString);
let result = "";
const now = new Date();
const birthdayAlreadyHappened: boolean = now.getTime() > getThisYearsBirthday(birthDateAsString).getTime();
let years = now.getFullYear() - birthDate.getFullYear() + (birthdayAlreadyHappened ? 0 : -1);
let months: number, days: number;
const thisMonthDate = new Date();
thisMonthDate.setDate(birthDate.getDate());
if (now.getDate() < thisMonthDate.getDate()) {
days = now.getDate() + (daysInPreviousMonth() - birthDate.getDate()) + 1;
} else {
days = now.getDate() - birthDate.getDate();
}
if (birthdayAlreadyHappened) {
months = now.getMonth() - birthDate.getMonth() - 1;
if (months < 0) {
months = 0;
}
} else {
months = (11 - birthDate.getMonth()) + now.getMonth();
}
if (years > 0) {
result += `${years} años`;
}
if (months > 0) {
result += `, ${months} meses`;
}
if (days > 0) {
result += `, ${days} días`;
}
return result;
};
export const getReadableNextBirthday = (birthDate: string): string => {
return formater.format(getNextBirthday(birthDate))
}
En este mismo archivo es en donde existe la función que regresa el HTML de la persona. Debido a que es con JavaScript puro (sin frameworks), el HTML es dibujado concatenando cadenas.
Base de datos completa
El archivo de la base de datos queda como se ve a continuación, en ese archivo se manejan las operaciones para insertar, obtener, actualizar y eliminar. También existe la función para crear las tablas en caso de que no existan.
Todo es con SQL puro, usando SQLite3 en el navegador web para esta app de cumpleaños con JS:
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
const NOMBRE_BASE_DE_DATOS = "birth_data.sqlite"
let db: any;
const closeDb = async () => {
db.close();
}
const init = async () => {
const sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error,
});
if ('opfs' in sqlite3) {
db = new sqlite3.oo1.OpfsDb(NOMBRE_BASE_DE_DATOS);
} else {
db = new sqlite3.oo1.DB(NOMBRE_BASE_DE_DATOS, 'ct');
}
await db.exec(`CREATE TABLE IF NOT EXISTS people(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
birthDate TEXT NOT NULL)`);
}
const storePerson = async (name: string, birthDate: string) => {
const rows = await db.exec({
sql: "INSERT INTO people(name, birthDate) VALUES (?, ?) RETURNING *",
bind: [name, birthDate],
returnValue: "resultRows",
rowMode: "object",
});
return rows[0];
}
const getPeople = async (criteria: string) => {
let sql = "SELECT id, name, birthDate FROM people";
let parameters: string[] = [];
if (criteria) {
sql += " WHERE name LIKE ?";
parameters = [`%${criteria}%`];
}
sql += " ORDER BY birthDate DESC";
return await db.exec({
sql,
bind: parameters,
returnValue: "resultRows",
rowMode: "object",
});
}
const getPersonById = async (id: string) => {
const rows = await db.exec({
sql: "SELECT id, name, birthDate FROM people WHERE id = ?",
bind: [id],
returnValue: "resultRows",
rowMode: "object",
});
return rows[0] as Person;
}
const deletePerson = async (id: string) => {
await db.exec({
sql: "DELETE FROM people WHERE id = ?",
bind: [id],
});
}
const updatePerson = async (name: string, birthDate: string, id: string) => {
const rows = await db.exec({
sql: "UPDATE people SET name = ?, birthDate = ? WHERE id = ? RETURNING *",
bind: [name, birthDate, id],
returnValue: "resultRows",
rowMode: "object",
});
return rows[0];
}
self.onmessage = async (event) => {
const action = event.data[0];
const actionArgs = event.data[1];
switch (action) {
case "init":
await init();
self.postMessage(["ready"]);
break;
case "store_person":
const storedPerson = await storePerson(actionArgs.name, actionArgs.birthDate);
self.postMessage(["person_stored", storedPerson]);
break;
case "get_people":
const people = await getPeople(actionArgs.criteria || "");
self.postMessage(["people_fetched", people]);
break;
case "get_person_details":
const person = await getPersonById(actionArgs.id);
self.postMessage(["person_fetched", person]);
break;
case "delete_person":
await deletePerson(actionArgs.id);
self.postMessage(["person_deleted"]);
break;
case "update_person":
const updatedPerson = await updatePerson(actionArgs.name, actionArgs.birthDate, actionArgs.id);
self.postMessage(["person_updated", updatedPerson]);
break;
case "close_db":
await closeDb();
self.postMessage(["db_closed"]);
break;
}
}
Recuerda que varias líneas de código corresponden a la comunicación con el worker, ya que la única manera que existe (sin librerías) es usando postMessage
y onmessage
.
Código fuente completo
Por ahora te mostré el código más importante, pero puedes descargar el código completo en mi GitHub: https://github.com/parzibyte/birthday-app
Una vez que lo hayas descargado solo necesitas Node en la PATH. Invoca a npm install
, y luego de instalar las dependencias inicia el servidor de desarrollo con npm run dev
.
Cuando quieras llevar la app a producción simplemente ejecuta npm run build
; recuerda configurar correctamente la base en vite.config.js
y servir los archivos WASM con el mime type correcto además de indicar los encabezados COOP.
Si tienes dudas sobre servir el WASM o los encabezados, mira el tutorial básico.
Demostración
Si estás leyendo este post desde un navegador web actualizado, puedes probar la aplicación de cumpleaños justo ahora. Solo ingresa a: https://parzibyte.me/apps/birthday/
Puedes instalar la app como nativa, solo elige la opción adecuada en el menú del navegador.
Para terminar te dejo con más tutoriales de JavaScript en mi blog.