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:
ginnoir
2026-06-02 04:26:22 -05:00
parent 48063e4b7f
commit b5a966e3a9
13 changed files with 2002 additions and 2 deletions
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "bang_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"recorded_by" uuid,
"occurred_on" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_recorded_by_users_id_fk" FOREIGN KEY ("recorded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "bang_events_household_occurred_idx" ON "bang_events" USING btree ("household_id","occurred_on");
+2 -2
View File
@@ -1,6 +1,6 @@
{
"id": "fc208bad-8bfd-452d-876f-7e853f699931",
"prevId": "cb06cd57-d5a0-4fb7-bbf3-335173a1168c",
"id": "ff1f5ff1-7539-4c5e-a20d-f01c719ef198",
"prevId": "fc208bad-8bfd-452d-876f-7e853f699931",
"version": "7",
"dialect": "postgresql",
"tables": {
File diff suppressed because it is too large Load Diff
+7
View File
@@ -120,6 +120,13 @@
"when": 1748822400000,
"tag": "0016_garden_improvements",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1780391596552,
"tag": "0017_bang_counter",
"breakpoints": true
}
]
}
+2
View File
@@ -52,6 +52,7 @@
"@playwright/test": "^1.59.1",
"@release-it/conventional-changelog": "^11.0.1",
"@tailwindcss/postcss": "^4.2.4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -79,6 +80,7 @@
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+16
View File
@@ -29,6 +29,9 @@ importers:
'@fullcalendar/timegrid':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -102,6 +105,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.2.4
version: 4.2.4
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/node':
specifier: ^22.9.0
version: 22.19.17
@@ -1844,6 +1850,9 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -2280,6 +2289,9 @@ packages:
caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -6531,6 +6543,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/canvas-confetti@1.9.0': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -6977,6 +6991,8 @@ snapshots:
caniuse-lite@1.0.30001791: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
+78
View File
@@ -1346,3 +1346,81 @@ select {
inset 0 1px 2px rgba(0, 0, 0, 0.12),
0 0 0 2px rgba(244, 239, 229, 0.08);
}
/* ─── Bang Counter celebration animations ─────────────────────────────────── */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10% {
transform: translateX(-6px) rotate(-1deg);
}
20% {
transform: translateX(6px) rotate(1deg);
}
30% {
transform: translateX(-5px) rotate(-0.8deg);
}
40% {
transform: translateX(5px) rotate(0.8deg);
}
50% {
transform: translateX(-4px) rotate(-0.5deg);
}
60% {
transform: translateX(4px) rotate(0.5deg);
}
70% {
transform: translateX(-2px);
}
80% {
transform: translateX(2px);
}
90% {
transform: translateX(-1px);
}
}
@keyframes countPop {
0% {
transform: scale(1);
}
30% {
transform: scale(1.45);
}
60% {
transform: scale(0.9);
}
80% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes emojiFloat {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
50% {
transform: translateY(-60px) scale(1.2);
opacity: 0.9;
}
100% {
transform: translateY(-120px) scale(0.7);
opacity: 0;
}
}
.animate-shake {
animation: shake 0.35s ease-in-out both;
}
.animate-count-pop {
animation: countPop 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
.animate-emoji-float {
animation: emojiFloat 1.8s ease-out both;
}
@@ -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&apos;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" });
}
+46
View File
@@ -0,0 +1,46 @@
import { z } from "zod";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { getBangStats } from "./server/queries";
import { BangWidget } from "./components/bang-widget";
const bangWidgetConfigSchema = z.object({
maxRecentBangs: z.number().int().min(1).max(20).default(5),
});
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
const parsed = bangWidgetConfigSchema.parse(config);
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
}
const bangsManifest: ModuleManifest = {
id: "bangs",
name: "Bangs",
entities: [
{
type: "bangs.event",
label: { singular: "Bang", plural: "Bangs" },
resolveUrl: () => "/",
renderActivity: (entry) => {
const date = entry.payload?.occurredOn as string | undefined;
return date ? `Recorded a bang on ${date}` : "Recorded a bang";
},
},
],
dashboardWidgets: [
{
id: "bangs.counter",
title: "Bang Counter",
description: "Track and celebrate bangs with your household.",
category: "Fun",
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
defaultPriority: 60,
configSchema: bangWidgetConfigSchema,
defaultConfig: { maxRecentBangs: 5 },
render: (props) => <BangWidgetServer {...props} />,
},
],
};
export default bangsManifest;
+18
View File
@@ -0,0 +1,18 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const bangEvents = pgTable(
"bang_events",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
recordedBy: uuid("recorded_by").references(() => users.id, { onDelete: "set null" }),
occurredOn: text("occurred_on").notNull(), // YYYY-MM-DD, text to avoid timezone confusion
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("bang_events_household_occurred_idx").on(t.householdId, t.occurredOn)],
);
export type BangEvent = typeof bangEvents.$inferSelect;
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { bangEvents } from "../schema";
const addBangInput = z.object({
occurredOn: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD")
.optional(),
});
function todayString(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export async function addBang(input: z.input<typeof addBangInput> = {}) {
const parsed = addBangInput.parse(input);
const { user, household } = await getCurrentSession();
const occurredOn = parsed.occurredOn ?? todayString();
const [bang] = await db
.insert(bangEvents)
.values({
householdId: household.id,
recordedBy: user.id,
occurredOn,
})
.returning();
if (!bang) throw new Error("Bang was not recorded");
await logActivity({
entityType: "bangs.event",
entityId: bang.id,
action: "create",
payload: { occurredOn },
});
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
return bang;
}
+39
View File
@@ -0,0 +1,39 @@
import { count, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { bangEvents } from "../schema";
export type BangStatsDto = {
total: number;
recent: RecentBangDto[];
};
export type RecentBangDto = {
id: string;
occurredOn: string;
recordedByName: string | null;
};
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
const [totalRow] = await db
.select({ total: count() })
.from(bangEvents)
.where(eq(bangEvents.householdId, householdId));
const recent = await db
.select({
id: bangEvents.id,
occurredOn: bangEvents.occurredOn,
recordedByName: users.name,
})
.from(bangEvents)
.leftJoin(users, eq(bangEvents.recordedBy, users.id))
.where(eq(bangEvents.householdId, householdId))
.orderBy(desc(bangEvents.occurredOn), desc(bangEvents.createdAt))
.limit(limit);
return {
total: totalRow?.total ?? 0,
recent,
};
}
+2
View File
@@ -4,9 +4,11 @@ import calendarManifest from "./calendar/manifest";
import listsManifest from "./lists/manifest";
import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
import bangsManifest from "./bangs/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
registerModule(listsManifest);
registerModule(notesManifest);
registerModule(gardenManifest);
registerModule(bangsManifest);