Servidor Fastify + Socket.IO + Drizzle/Postgres; front Vite+React (EN/ES, tema claro/oscuro). Tableros por columnas con plantillas, notas privadas/públicas, agrupación, votos configurables (multivoto), timer compartido con alarma, fases, action items, export Markdown/JSON e import, autodestrucción, gestión de columnas. Imagen única (front servido por el servidor) y CI que la construye y publica.
45 lines
1.5 KiB
TypeScript
45 lines
1.5 KiB
TypeScript
// Alarma corta generada con Web Audio (sin ficheros). Dos pitidos rápidos.
|
|
let ctx: AudioContext | null = null;
|
|
|
|
export function playAlarm() {
|
|
try {
|
|
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
ctx = ctx ?? new Ctor();
|
|
if (ctx.state === "suspended") void ctx.resume();
|
|
const now = ctx.currentTime;
|
|
for (let i = 0; i < 2; i++) {
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "square";
|
|
osc.frequency.value = 880;
|
|
const t = now + i * 0.18;
|
|
gain.gain.setValueAtTime(0.0001, t);
|
|
gain.gain.exponentialRampToValueAtTime(0.25, t + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.15);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start(t);
|
|
osc.stop(t + 0.16);
|
|
}
|
|
} catch {
|
|
// sin audio disponible: ignoramos
|
|
}
|
|
}
|
|
|
|
// Parsea "mm", "mm:ss" o "m.5" a segundos. Devuelve null si no es válido.
|
|
export function parseTime(input: string): number | null {
|
|
const s = input.trim();
|
|
if (!s) return null;
|
|
if (s.includes(":")) {
|
|
const [m, sec] = s.split(":");
|
|
const mm = Number(m);
|
|
const ss = Number(sec);
|
|
if (!Number.isFinite(mm) || !Number.isFinite(ss) || ss < 0 || ss >= 60) return null;
|
|
const total = Math.round(mm * 60 + ss);
|
|
return total > 0 ? total : null;
|
|
}
|
|
const mins = Number(s);
|
|
if (!Number.isFinite(mins) || mins <= 0) return null;
|
|
return Math.round(mins * 60);
|
|
}
|