Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090366422d | ||
|
|
4049a73c6b | ||
|
|
272d3cbbe6 | ||
|
|
b5a966e3a9 |
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [0.4.6](https://github.com/ginnoir/famapp/compare/v0.4.5...v0.4.6) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** accept relative urls for plant image uploads ([6ca2e52](https://github.com/ginnoir/famapp/commit/6ca2e52e0a63d94826d9b1e208bcdded9c75fb07))
|
||||
|
||||
## [0.4.5](https://github.com/ginnoir/famapp/compare/v0.4.4...v0.4.5) (2026-06-02)
|
||||
|
||||
### Features
|
||||
|
||||
- **bangs:** add bang counter dashboard widget ([c850521](https://github.com/ginnoir/famapp/commit/c850521f3e993a5410f37cf29eb635cbc9540fd1))
|
||||
|
||||
## [0.4.4](https://github.com/ginnoir/famapp/compare/v0.4.3...v0.4.4) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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");
|
||||
@@ -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
@@ -120,6 +120,13 @@
|
||||
"when": 1748822400000,
|
||||
"tag": "0016_garden_improvements",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1780391596552,
|
||||
"tag": "0017_bang_counter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.3",
|
||||
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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'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" });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -118,8 +118,8 @@ const plantInput = z.object({
|
||||
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
|
||||
notes: z.string().trim().max(2000).nullable().optional(),
|
||||
acquisitionDate: z.string().nullable().optional(),
|
||||
images: z.array(z.string().url()).max(10).default([]),
|
||||
primaryImageUrl: z.string().url().nullable().optional(),
|
||||
images: z.array(z.string().min(1)).max(10).default([]),
|
||||
primaryImageUrl: z.string().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function createPlant(input: z.input<typeof plantInput>) {
|
||||
@@ -230,7 +230,7 @@ export async function deletePlant(input: { id: string }) {
|
||||
}
|
||||
|
||||
export async function addPlantImage(input: { id: string; url: string }) {
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string().url() }).parse(input);
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string().min(1) }).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user