Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272d3cbbe6 | ||
|
|
b5a966e3a9 | ||
|
|
48063e4b7f | ||
|
|
6eab68560e | ||
|
|
7d0f02996f | ||
|
|
32e97b0677 | ||
|
|
e0b423aad5 | ||
|
|
389ff6040b |
+3
-2
@@ -40,5 +40,6 @@ MINIO_ROOT_USER=famapp
|
|||||||
MINIO_ROOT_PASSWORD=changeme
|
MINIO_ROOT_PASSWORD=changeme
|
||||||
MINIO_BUCKET=garden
|
MINIO_BUCKET=garden
|
||||||
|
|
||||||
# Perenual plant species API (https://perenual.com — free tier available)
|
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
||||||
PERENUAL_API_KEY=
|
OPENPLANTBOOK_CLIENT_ID=
|
||||||
|
OPENPLANTBOOK_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -28,10 +28,32 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm format:check
|
- 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
|
- run: pnpm build
|
||||||
env:
|
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
|
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
||||||
AUTH_SECRET: ci-placeholder-secret
|
AUTH_SECRET: ci-placeholder-secret
|
||||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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.
|
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)
|
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@ ENV CI=true
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --offline --frozen-lockfile
|
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 ───────────────────────────────────────────────
|
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|||||||
@@ -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",
|
"id": "ff1f5ff1-7539-4c5e-a20d-f01c719ef198",
|
||||||
"prevId": "cb06cd57-d5a0-4fb7-bbf3-335173a1168c",
|
"prevId": "fc208bad-8bfd-452d-876f-7e853f699931",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1748822400000,
|
"when": 1748822400000,
|
||||||
"tag": "0016_garden_improvements",
|
"tag": "0016_garden_improvements",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780391596552,
|
||||||
|
"tag": "0017_bang_counter",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+4
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.4.2",
|
"version": "0.4.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
@@ -48,15 +48,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.0.2",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"@commitlint/config-conventional": "^21.0.2",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@release-it/conventional-changelog": "^11.0.1",
|
"@release-it/conventional-changelog": "^11.0.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-grid-layout": "^2.1.0",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
@@ -81,6 +80,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -98,7 +98,6 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3"
|
||||||
"zod-to-json-schema": "^3.25.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+16
-24
@@ -29,6 +29,9 @@ importers:
|
|||||||
'@fullcalendar/timegrid':
|
'@fullcalendar/timegrid':
|
||||||
specifier: ^6.1.20
|
specifier: ^6.1.20
|
||||||
version: 6.1.20(@fullcalendar/core@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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -83,9 +86,6 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
zod-to-json-schema:
|
|
||||||
specifier: ^3.25.2
|
|
||||||
version: 3.25.2(zod@4.4.3)
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@commitlint/cli':
|
'@commitlint/cli':
|
||||||
specifier: ^21.0.2
|
specifier: ^21.0.2
|
||||||
@@ -93,9 +93,6 @@ importers:
|
|||||||
'@commitlint/config-conventional':
|
'@commitlint/config-conventional':
|
||||||
specifier: ^21.0.2
|
specifier: ^21.0.2
|
||||||
version: 21.0.2
|
version: 21.0.2
|
||||||
'@eslint/eslintrc':
|
|
||||||
specifier: ^3.3.5
|
|
||||||
version: 3.3.5
|
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
|
version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
|
||||||
@@ -108,6 +105,9 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
|
'@types/canvas-confetti':
|
||||||
|
specifier: ^1.9.0
|
||||||
|
version: 1.9.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.9.0
|
specifier: ^22.9.0
|
||||||
version: 22.19.17
|
version: 22.19.17
|
||||||
@@ -117,9 +117,6 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
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':
|
'@types/web-push':
|
||||||
specifier: ^3.6.4
|
specifier: ^3.6.4
|
||||||
version: 3.6.4
|
version: 3.6.4
|
||||||
@@ -1853,6 +1850,9 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||||
|
|
||||||
|
'@types/canvas-confetti@1.9.0':
|
||||||
|
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1877,10 +1877,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@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':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
@@ -2293,6 +2289,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001791:
|
caniuse-lite@1.0.30001791:
|
||||||
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
|
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4:
|
||||||
|
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -6544,6 +6543,8 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/canvas-confetti@1.9.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -6564,13 +6565,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@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':
|
'@types/react@19.2.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
@@ -6997,6 +6991,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001791: {}
|
caniuse-lite@1.0.30001791: {}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -10093,10 +10089,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.25.76
|
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):
|
zod-validation-error@4.0.2(zod@4.4.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
|
|||||||
@@ -1346,3 +1346,81 @@ select {
|
|||||||
inset 0 1px 2px rgba(0, 0, 0, 0.12),
|
inset 0 1px 2px rgba(0, 0, 0, 0.12),
|
||||||
0 0 0 2px rgba(244, 239, 229, 0.08);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ export function PlantForm({ existingPlant, defaultContainerId, containers }: Pro
|
|||||||
initialValue={existingPlant?.scientificName ?? ""}
|
initialValue={existingPlant?.scientificName ?? ""}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--ink-mute)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,60 +13,89 @@ export type SpeciesSuggestion = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
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 {
|
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 {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.pid ?? ""),
|
||||||
common_name: String(item.common_name ?? ""),
|
common_name: String(item.alias ?? ""),
|
||||||
scientific_name: scientificName,
|
scientific_name: String(item.display_pid ?? ""),
|
||||||
watering: String(item.watering ?? ""),
|
watering: String(item.watering ?? ""),
|
||||||
sunlight,
|
sunlight: parseSunlight(item.sunlight),
|
||||||
cycle: String(item.cycle ?? ""),
|
cycle: "",
|
||||||
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
|
default_image_url: item.image_url ? String(item.image_url) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
||||||
const apiKey = process.env.PERENUAL_API_KEY;
|
|
||||||
if (!apiKey) return [];
|
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
|
const url = `${BASE_URL}/plant/search?alias=${encodeURIComponent(query)}&limit=20`;
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(url, {
|
||||||
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
|
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[] };
|
const json = (await res.json()) as { results?: unknown[] };
|
||||||
if (!Array.isArray(json.data)) return [];
|
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(
|
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
|
||||||
results.map((r) =>
|
return details
|
||||||
db
|
.filter(
|
||||||
.insert(gardenSpeciesCache)
|
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
|
||||||
.values({
|
r.status === "fulfilled" && r.value !== null,
|
||||||
speciesId: r.id,
|
)
|
||||||
data: r as unknown as Record<string, unknown>,
|
.map((r) => r.value);
|
||||||
cachedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: gardenSpeciesCache.speciesId,
|
|
||||||
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[species-lookup] searchSpecies failed", err);
|
console.error("[species-lookup] searchSpecies failed", err);
|
||||||
return [];
|
return [];
|
||||||
@@ -83,12 +112,15 @@ export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | nu
|
|||||||
|
|
||||||
if (cached) return cached.data as unknown as SpeciesSuggestion;
|
if (cached) return cached.data as unknown as SpeciesSuggestion;
|
||||||
|
|
||||||
const apiKey = process.env.PERENUAL_API_KEY;
|
const token = await getToken();
|
||||||
if (!apiKey) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
|
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}?include=care`;
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|
||||||
const item = (await res.json()) as Record<string, unknown>;
|
const item = (await res.json()) as Record<string, unknown>;
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import calendarManifest from "./calendar/manifest";
|
|||||||
import listsManifest from "./lists/manifest";
|
import listsManifest from "./lists/manifest";
|
||||||
import notesManifest from "./notes/manifest";
|
import notesManifest from "./notes/manifest";
|
||||||
import gardenManifest from "./garden/manifest";
|
import gardenManifest from "./garden/manifest";
|
||||||
|
import bangsManifest from "./bangs/manifest";
|
||||||
|
|
||||||
registerModule(coreManifest);
|
registerModule(coreManifest);
|
||||||
registerModule(calendarManifest);
|
registerModule(calendarManifest);
|
||||||
registerModule(listsManifest);
|
registerModule(listsManifest);
|
||||||
registerModule(notesManifest);
|
registerModule(notesManifest);
|
||||||
registerModule(gardenManifest);
|
registerModule(gardenManifest);
|
||||||
|
registerModule(bangsManifest);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
Reference in New Issue
Block a user