Adds a new "bangs" module with a dashboard widget that tracks household bang events. The widget shows the total count, a configurable recent-bang list, and an "Add Bang" button that accepts a backdatable date. On submission, a full celebration sequence fires: screen flash, widget shake, count pop animation, synthesized firework sound (Web Audio API), 50 full-screen emoji particles via portal, canvas-confetti star bursts, side cannons, and a timed sequence of 7 firework bursts. - New bang_events table (migration 0017) - canvas-confetti dependency for fireworks/confetti effects - CSS keyframe animations: shake, countPop, emojiFloat - Fixes pre-existing Drizzle snapshot chain collision (0007/0008) - Adds OpenPlantBook env vars to compose.yaml
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition, useRef, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import confetti from "canvas-confetti";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import type { BangStatsDto } from "../server/queries";
|
|
import { addBang } from "../server/actions";
|
|
|
|
// ─── Celebration engine ───────────────────────────────────────────────────────
|
|
|
|
function launchFirework(originX: number, originY: number) {
|
|
confetti({
|
|
particleCount: 80,
|
|
startVelocity: 45,
|
|
spread: 360,
|
|
origin: { x: originX, y: originY },
|
|
colors: ["#ff0", "#f0f", "#0ff", "#f60", "#0f6", "#06f"],
|
|
shapes: ["circle", "square"],
|
|
gravity: 0.8,
|
|
scalar: 1.2,
|
|
ticks: 200,
|
|
});
|
|
}
|
|
|
|
function launchSideCannon(side: "left" | "right") {
|
|
confetti({
|
|
particleCount: 120,
|
|
angle: side === "left" ? 60 : 120,
|
|
spread: 55,
|
|
origin: { x: side === "left" ? 0 : 1, y: 0.65 },
|
|
colors: ["#ff4e50", "#fc913a", "#f9d423", "#ede574", "#e1f5c4"],
|
|
shapes: ["circle", "square", "star"],
|
|
scalar: 1.1,
|
|
ticks: 300,
|
|
});
|
|
}
|
|
|
|
function launchStarBurst() {
|
|
const defaults = {
|
|
spread: 360,
|
|
ticks: 100,
|
|
gravity: 0,
|
|
decay: 0.94,
|
|
startVelocity: 30,
|
|
shapes: ["star"] as confetti.Shape[],
|
|
colors: ["FFE400", "FFBD00", "E89400", "FFCA6C", "FDFFB8"],
|
|
};
|
|
function shoot() {
|
|
confetti({ ...defaults, particleCount: 40, scalar: 1.2, shapes: ["star"] });
|
|
confetti({ ...defaults, particleCount: 15, scalar: 0.75, shapes: ["circle"] });
|
|
}
|
|
shoot();
|
|
setTimeout(shoot, 100);
|
|
setTimeout(shoot, 200);
|
|
}
|
|
|
|
function synthFireworkSound() {
|
|
try {
|
|
const ctx = new AudioContext();
|
|
// Rising "pew" tone
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.frequency.setValueAtTime(200, ctx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.12);
|
|
gain.gain.setValueAtTime(0.35, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
|
|
osc.start(ctx.currentTime);
|
|
osc.stop(ctx.currentTime + 0.18);
|
|
|
|
// Noise "boom"
|
|
const bufferSize = ctx.sampleRate * 0.4;
|
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
|
|
const noise = ctx.createBufferSource();
|
|
noise.buffer = buffer;
|
|
const noiseGain = ctx.createGain();
|
|
const bpFilter = ctx.createBiquadFilter();
|
|
bpFilter.type = "bandpass";
|
|
bpFilter.frequency.value = 150;
|
|
noise.connect(bpFilter);
|
|
bpFilter.connect(noiseGain);
|
|
noiseGain.connect(ctx.destination);
|
|
noiseGain.gain.setValueAtTime(0.6, ctx.currentTime + 0.15);
|
|
noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
|
|
noise.start(ctx.currentTime + 0.15);
|
|
noise.stop(ctx.currentTime + 0.6);
|
|
} catch {
|
|
// Autoplay policy blocked — silently skip
|
|
}
|
|
}
|
|
|
|
type EmojiParticle = {
|
|
id: number;
|
|
emoji: string;
|
|
x: number;
|
|
y: number;
|
|
size: number;
|
|
delay: number;
|
|
};
|
|
|
|
const CELEBRATION_EMOJIS = [
|
|
"💥",
|
|
"🎉",
|
|
"🔥",
|
|
"✨",
|
|
"🎊",
|
|
"🥳",
|
|
"💦",
|
|
"😈",
|
|
"🎆",
|
|
"⭐",
|
|
"🍆",
|
|
"💫",
|
|
"🌟",
|
|
"🎇",
|
|
"🍒",
|
|
];
|
|
|
|
function EmojiOverlay({ particles }: { particles: EmojiParticle[] }) {
|
|
if (particles.length === 0) return null;
|
|
return createPortal(
|
|
<div className="pointer-events-none fixed inset-0 z-[9999] overflow-hidden" aria-hidden>
|
|
{particles.map((p) => (
|
|
<span
|
|
key={p.id}
|
|
className="absolute select-none animate-emoji-float"
|
|
style={{
|
|
left: `${p.x}%`,
|
|
top: `${p.y}%`,
|
|
fontSize: `${p.size}rem`,
|
|
animationDelay: `${p.delay}ms`,
|
|
}}
|
|
>
|
|
{p.emoji}
|
|
</span>
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
type Props = {
|
|
stats: BangStatsDto;
|
|
maxRecentBangs: number;
|
|
};
|
|
|
|
export function BangWidget({ stats, maxRecentBangs }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [dateValue, setDateValue] = useState(todayValue());
|
|
const [isPending, startTransition] = useTransition();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [emojiParticles, setEmojiParticles] = useState<EmojiParticle[]>([]);
|
|
const [countPop, setCountPop] = useState(false);
|
|
const [shake, setShake] = useState(false);
|
|
const [flash, setFlash] = useState(false);
|
|
const particleIdRef = useRef(0);
|
|
|
|
const spawnEmojis = useCallback(() => {
|
|
const batch: EmojiParticle[] = Array.from({ length: 50 }, () => ({
|
|
id: ++particleIdRef.current,
|
|
emoji: CELEBRATION_EMOJIS[Math.floor(Math.random() * CELEBRATION_EMOJIS.length)]!,
|
|
x: Math.random() * 100,
|
|
y: 20 + Math.random() * 70, // keep out of very top/bottom edges so float-up is visible
|
|
size: 1.5 + Math.random() * 2,
|
|
delay: Math.random() * 600, // stagger spawning over 600ms so it feels like a cascade
|
|
}));
|
|
setEmojiParticles((prev) => [...prev, ...batch]);
|
|
setTimeout(() => {
|
|
const ids = new Set(batch.map((p) => p.id));
|
|
setEmojiParticles((prev) => prev.filter((p) => !ids.has(p.id)));
|
|
}, 2400);
|
|
}, []);
|
|
|
|
const triggerCelebration = useCallback(() => {
|
|
setFlash(true);
|
|
setTimeout(() => setFlash(false), 120);
|
|
|
|
setShake(true);
|
|
setTimeout(() => setShake(false), 350);
|
|
|
|
setCountPop(true);
|
|
setTimeout(() => setCountPop(false), 400);
|
|
|
|
synthFireworkSound();
|
|
spawnEmojis();
|
|
launchStarBurst();
|
|
|
|
launchSideCannon("left");
|
|
setTimeout(() => launchSideCannon("right"), 150);
|
|
|
|
setTimeout(() => launchFirework(0.3, 0.7), 200);
|
|
setTimeout(() => launchFirework(0.7, 0.65), 450);
|
|
setTimeout(() => launchFirework(0.5, 0.6), 700);
|
|
setTimeout(() => launchFirework(0.2, 0.75), 950);
|
|
setTimeout(() => launchFirework(0.8, 0.7), 1100);
|
|
setTimeout(() => {
|
|
launchSideCannon("left");
|
|
launchSideCannon("right");
|
|
}, 1300);
|
|
setTimeout(() => launchFirework(0.5, 0.5), 1600);
|
|
}, [spawnEmojis]);
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
startTransition(async () => {
|
|
try {
|
|
await addBang({ occurredOn: dateValue });
|
|
setOpen(false);
|
|
triggerCelebration();
|
|
} catch {
|
|
setError("Something went wrong. Try again.");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className={`relative flex flex-col gap-3 overflow-hidden ${shake ? "animate-shake" : ""}`}>
|
|
{flash && (
|
|
<div className="pointer-events-none absolute inset-0 z-20 rounded-xl bg-white/70" />
|
|
)}
|
|
|
|
<EmojiOverlay particles={emojiParticles} />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col">
|
|
<span
|
|
className={`text-5xl font-bold leading-none tabular-nums ${countPop ? "animate-count-pop" : ""}`}
|
|
>
|
|
{stats.total}
|
|
</span>
|
|
<span className="text-xs text-[var(--ink-mute)] mt-1">
|
|
{stats.total === 1 ? "bang" : "bangs"}
|
|
</span>
|
|
</div>
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary flex items-center gap-1.5 text-sm px-3 py-1.5"
|
|
/>
|
|
}
|
|
>
|
|
<span aria-hidden>💥</span> Add Bang
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Record a bang</DialogTitle>
|
|
</DialogHeader>
|
|
<form id="bang-form" onSubmit={handleSubmit} className="flex flex-col gap-3 pt-1">
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="bang-date" className="text-sm font-medium">
|
|
Date
|
|
</label>
|
|
<input
|
|
id="bang-date"
|
|
type="date"
|
|
value={dateValue}
|
|
onChange={(e) => setDateValue(e.target.value)}
|
|
className="input input-sm"
|
|
required
|
|
/>
|
|
<p className="text-xs text-[var(--ink-mute)]">
|
|
Change if you're documenting a bang from a previous day.
|
|
</p>
|
|
</div>
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
</form>
|
|
<DialogFooter showCloseButton>
|
|
<button
|
|
type="submit"
|
|
form="bang-form"
|
|
disabled={isPending}
|
|
className="btn btn-primary btn-sm"
|
|
>
|
|
{isPending ? "Recording…" : "🎆 Record it"}
|
|
</button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{stats.recent.length > 0 && (
|
|
<div className="flex flex-col gap-0.5 border-t border-[var(--surface-3)] pt-2">
|
|
<p className="text-xs font-medium text-[var(--ink-mute)] mb-1">
|
|
Last {Math.min(stats.recent.length, maxRecentBangs)}
|
|
</p>
|
|
{stats.recent.map((bang) => (
|
|
<div key={bang.id} className="flex items-center justify-between gap-2 py-0.5">
|
|
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
|
|
{bang.recordedByName && (
|
|
<span className="max-w-[40%] truncate text-xs text-[var(--ink-mute)]">
|
|
{bang.recordedByName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{stats.total === 0 && (
|
|
<p className="text-sm text-[var(--ink-mute)]">No bangs yet. Add the first one!</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function todayValue(): string {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function formatBangDate(iso: string): string {
|
|
const [year, month, day] = iso.split("-").map(Number);
|
|
const d = new Date(year!, month! - 1, day!);
|
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
}
|