Compare commits

...
12 Commits
Author SHA1 Message Date
ginnoir 090366422d chore: release v0.4.6
Release / build-and-push (push) Has been cancelled
2026-06-02 18:16:26 -05:00
ginnoir 4049a73c6b fix(garden): accept relative urls for plant image uploads
Zod v4 changed .url() to use the WHATWG URL spec, which rejects relative
paths like /api/uploads/... that our upload API returns. Relax image URL
validators to z.string().min(1) since these are internal paths.
2026-06-02 18:15:12 -05:00
ginnoir 272d3cbbe6 chore: release v0.4.5
Release / build-and-push (push) Has been cancelled
2026-06-02 04:27:39 -05:00
ginnoir b5a966e3a9 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
2026-06-02 04:26:22 -05:00
ginnoir 48063e4b7f chore: release v0.4.4
Release / build-and-push (push) Has been cancelled
2026-06-02 03:48:52 -05:00
ginnoir 6eab68560e fix(garden): remove trailing slashes from openplantbook api urls and update label
Trailing slashes on /plant/search/ and /plant/detail/ caused Django to
return an HTML redirect page instead of JSON. SDK uses no trailing slashes.

Also updates the helper text to reference OpenPlantBook instead of Perenual.
2026-06-02 03:48:38 -05:00
ginnoir 7d0f02996f chore: release v0.4.3
Release / build-and-push (push) Has been cancelled
2026-06-02 03:25:03 -05:00
ginnoir 32e97b0677 fix(garden): replace perenual with openplantbook for species lookup
Perenual's free tier does not serve care instructions. OpenPlantBook
provides watering, sunlight, and other care data at no cost.

- OAuth2 client_credentials token fetch with module-level cache
- Search via /api/v1/plant/search/, detail via /api/v1/plant/detail/{pid}/?include=care
- SpeciesSuggestion shape unchanged; no frontend or schema changes required
- Env vars: OPENPLANTBOOK_CLIENT_ID + OPENPLANTBOOK_CLIENT_SECRET (replaces PERENUAL_API_KEY)
2026-06-02 03:24:20 -05:00
ginnoir e0b423aad5 chore: remove dead code and unused dependencies
- Delete src/placeholder.ts (empty stub with no references)
- Remove zod-to-json-schema dep (not imported anywhere in src/)
- Remove @eslint/eslintrc devDep (project uses flat config with @eslint/js)
- Remove @types/react-grid-layout devDep (react-grid-layout v2 ships own types)
2026-06-02 02:37:56 -05:00
ginnoir 389ff6040b ci: cache next.js build output and parallelize checks with build 2026-06-02 02:05:34 -05:00
ginnoir 65d36bab4e fix: drive bottom nav from module registry so all modules appear on mobile
Release / build-and-push (push) Has been cancelled
The bottom nav was hardcoded; the sidebar already read from the module
registry. Converted BottomNav to a server component that calls
getRegistry() using the same pattern as Sidebar, so any module with a
nav field automatically populates both nav surfaces.

Bumps version to 0.4.2.
2026-06-02 01:55:36 -05:00
ginnoir 298e6b868d chore: register migration 0016 in drizzle journal
Release / build-and-push (push) Has been cancelled
2026-06-02 00:27:10 -05:00
22 changed files with 2156 additions and 97 deletions
+3 -2
View File
@@ -40,5 +40,6 @@ MINIO_ROOT_USER=famapp
MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=garden
# Perenual plant species API (https://perenual.com — free tier available)
PERENUAL_API_KEY=
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID=
OPENPLANTBOOK_CLIENT_SECRET=
+25 -3
View File
@@ -6,7 +6,7 @@ on:
pull_request:
jobs:
verify:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -28,10 +28,32 @@ jobs:
- run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
${{ runner.os }}-nextjs-
- run: pnpm build
env:
# Build only — no DB connection. Provide a placeholder so any
# module-level reads of DATABASE_URL don't crash the build.
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
AUTH_SECRET: ci-placeholder-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
+24
View File
@@ -1,5 +1,29 @@
# 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
- **garden:** remove trailing slashes from openplantbook api urls and update label ([c744921](https://github.com/ginnoir/famapp/commit/c744921e6cfcf0269992cfd9afea47cc55082602))
## [0.4.3](https://github.com/ginnoir/famapp/compare/v0.4.2...v0.4.3) (2026-06-02)
### Bug Fixes
- **garden:** replace perenual with openplantbook for species lookup ([8722c20](https://github.com/ginnoir/famapp/commit/8722c205e6257a0590b48d0a858ad71d56ab07ac))
All notable changes to famapp are documented here.
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
+2 -1
View File
@@ -16,7 +16,8 @@ ENV CI=true
ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm install --offline --frozen-lockfile
RUN pnpm build
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
pnpm build
# ── Stage 3: production runtime ───────────────────────────────────────────────
FROM node:22-alpine AS runner
+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
+14
View File
@@ -113,6 +113,20 @@
"when": 1748736000000,
"tag": "0015_garden_schema",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1748822400000,
"tag": "0016_garden_improvements",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1780391596552,
"tag": "0017_bang_counter",
"breakpoints": true
}
]
}
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.4.0",
"version": "0.4.6",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -48,15 +48,14 @@
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@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",
"@types/react-grid-layout": "^2.1.0",
"@types/web-push": "^3.6.4",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.10",
@@ -81,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",
@@ -98,7 +98,6 @@
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
"zod": "^4.4.3"
}
}
+16 -24
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
@@ -83,9 +86,6 @@ importers:
zod:
specifier: ^4.4.3
version: 4.4.3
zod-to-json-schema:
specifier: ^3.25.2
version: 3.25.2(zod@4.4.3)
devDependencies:
'@commitlint/cli':
specifier: ^21.0.2
@@ -93,9 +93,6 @@ importers:
'@commitlint/config-conventional':
specifier: ^21.0.2
version: 21.0.2
'@eslint/eslintrc':
specifier: ^3.3.5
version: 3.3.5
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
@@ -108,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
@@ -117,9 +117,6 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/react-grid-layout':
specifier: ^2.1.0
version: 2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
@@ -1853,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==}
@@ -1877,10 +1877,6 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-grid-layout@2.1.0':
resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
@@ -2293,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'}
@@ -6544,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': {}
@@ -6564,13 +6565,6 @@ snapshots:
dependencies:
'@types/react': 19.2.14
'@types/react-grid-layout@2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
react-grid-layout: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
transitivePeerDependencies:
- react
- react-dom
'@types/react@19.2.14':
dependencies:
csstype: 3.2.3
@@ -6997,6 +6991,8 @@ snapshots:
caniuse-lite@1.0.30001791: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -10093,10 +10089,6 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
+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;
}
+11 -11
View File
@@ -1,19 +1,19 @@
"use client";
import { getRegistry } from "@/modules/_core/registry";
import { NavLink } from "@/components/nav-link";
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
{ href: "/", label: "Dashboard", icon: "home" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/lists", label: "Lists", icon: "list" },
{ href: "/notes", label: "Notes", icon: "note" },
{ href: "/settings", label: "Settings", icon: "settings" },
];
export async function BottomNav() {
const { modules } = getRegistry();
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
const items = [
{ href: "/", label: "Dashboard", icon: "home" },
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
{ href: "/settings", label: "Settings", icon: "settings" },
];
export function BottomNav() {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{ITEMS.map((it) => (
{items.map((it) => (
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
))}
</nav>
@@ -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,
};
}
+1 -1
View File
@@ -156,7 +156,7 @@ export function PlantForm({ existingPlant, defaultContainerId, containers }: Pro
initialValue={existingPlant?.scientificName ?? ""}
/>
<p className="text-xs text-[var(--ink-mute)]">
Search Perenual to auto-fill scientific name and care notes.
Search OpenPlantBook to auto-fill scientific name and care notes.
</p>
</div>
+3 -3
View File
@@ -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);
+76 -44
View File
@@ -13,60 +13,89 @@ export type SpeciesSuggestion = {
};
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const BASE_URL = "https://perenual.com";
const BASE_URL = "https://open.plantbook.io/api/v1";
type TokenCache = { access_token: string; expiresAt: number } | null;
let tokenCache: TokenCache = null;
async function getToken(): Promise<string | null> {
const clientId = process.env.OPENPLANTBOOK_CLIENT_ID;
const clientSecret = process.env.OPENPLANTBOOK_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.access_token;
}
try {
const res = await fetch(`${BASE_URL}/token/`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
cache: "no-store",
});
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
const json = (await res.json()) as { access_token: string; expires_in: number };
tokenCache = {
access_token: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
};
return tokenCache.access_token;
} catch (err) {
console.error("[species-lookup] getToken failed", err);
return null;
}
}
function parseSunlight(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value === "string" && value) return value.split(", ");
return [];
}
function parseItem(item: Record<string, unknown>): SpeciesSuggestion {
const rawNames = item.scientific_name;
const scientificName = Array.isArray(rawNames) && rawNames.length > 0 ? String(rawNames[0]) : "";
const sunlight = Array.isArray(item.sunlight) ? item.sunlight.map(String) : [];
const defaultImage = item.default_image as Record<string, unknown> | null | undefined;
const rawImageUrl = defaultImage?.regular_url ?? defaultImage?.medium_url ?? null;
return {
id: String(item.id),
common_name: String(item.common_name ?? ""),
scientific_name: scientificName,
id: String(item.pid ?? ""),
common_name: String(item.alias ?? ""),
scientific_name: String(item.display_pid ?? ""),
watering: String(item.watering ?? ""),
sunlight,
cycle: String(item.cycle ?? ""),
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
sunlight: parseSunlight(item.sunlight),
cycle: "",
default_image_url: item.image_url ? String(item.image_url) : null,
};
}
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return [];
if (!query.trim()) return [];
const token = await getToken();
if (!token) return [];
try {
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
const url = `${BASE_URL}/plant/search?alias=${encodeURIComponent(query)}&limit=20`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) throw new Error(`OpenPlantBook search error: ${res.status} ${res.url}`);
const json = (await res.json()) as { data?: unknown[] };
if (!Array.isArray(json.data)) return [];
const json = (await res.json()) as { results?: unknown[] };
if (!Array.isArray(json.results)) return [];
const results = json.data.map((item) => parseItem(item as Record<string, unknown>));
const pids = json.results
.map((r) => String((r as Record<string, unknown>).pid ?? ""))
.filter(Boolean);
await Promise.allSettled(
results.map((r) =>
db
.insert(gardenSpeciesCache)
.values({
speciesId: r.id,
data: r as unknown as Record<string, unknown>,
cachedAt: new Date(),
})
.onConflictDoUpdate({
target: gardenSpeciesCache.speciesId,
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
}),
),
);
return results;
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
return details
.filter(
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
r.status === "fulfilled" && r.value !== null,
)
.map((r) => r.value);
} catch (err) {
console.error("[species-lookup] searchSpecies failed", err);
return [];
@@ -83,12 +112,15 @@ export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | nu
if (cached) return cached.data as unknown as SpeciesSuggestion;
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return null;
const token = await getToken();
if (!token) return null;
try {
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url, { cache: "no-store" });
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}?include=care`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const item = (await res.json()) as Record<string, unknown>;
+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);
-1
View File
@@ -1 +0,0 @@
export {};