feat(bangs): add bang counter dashboard widget
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
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
"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" });
|
||||
}
|
||||
Reference in New Issue
Block a user