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
+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,
};
}