From b5a966e3a92ff5223c61d12df586028d68ffdf47 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Tue, 2 Jun 2026 04:26:22 -0500 Subject: [PATCH] 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 --- drizzle/0017_bang_counter.sql | 13 + drizzle/meta/0008_snapshot.json | 4 +- drizzle/meta/0009_snapshot.json | 1397 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 2 + pnpm-lock.yaml | 16 + src/app/globals.css | 78 + src/modules/bangs/components/bang-widget.tsx | 332 +++++ src/modules/bangs/manifest.tsx | 46 + src/modules/bangs/schema.ts | 18 + src/modules/bangs/server/actions.ts | 50 + src/modules/bangs/server/queries.ts | 39 + src/modules/index.ts | 2 + 13 files changed, 2002 insertions(+), 2 deletions(-) create mode 100644 drizzle/0017_bang_counter.sql create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 src/modules/bangs/components/bang-widget.tsx create mode 100644 src/modules/bangs/manifest.tsx create mode 100644 src/modules/bangs/schema.ts create mode 100644 src/modules/bangs/server/actions.ts create mode 100644 src/modules/bangs/server/queries.ts diff --git a/drizzle/0017_bang_counter.sql b/drizzle/0017_bang_counter.sql new file mode 100644 index 0000000..5a50206 --- /dev/null +++ b/drizzle/0017_bang_counter.sql @@ -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"); diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json index a4e2186..7e4b296 100644 --- a/drizzle/meta/0008_snapshot.json +++ b/drizzle/meta/0008_snapshot.json @@ -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": { diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..737e7ac --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1397 @@ +{ + "id": "a7c89adf-0d22-49a9-81cb-bbf3d4472251", + "prevId": "ff1f5ff1-7539-4c5e-a20d-f01c719ef198", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_provider_account_id_pk": { + "name": "accounts_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_household_created_idx": { + "name": "activity_log_household_created_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_household_id_households_id_fk": { + "name": "activity_log_household_id_households_id_fk", + "tableFrom": "activity_log", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_id_users_id_fk": { + "name": "activity_log_actor_id_users_id_fk", + "tableFrom": "activity_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_members": { + "name": "household_members", + "schema": "", + "columns": { + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "household_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": {}, + "foreignKeys": { + "household_members_household_id_households_id_fk": { + "name": "household_members_household_id_households_id_fk", + "tableFrom": "household_members", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "household_members_user_id_users_id_fk": { + "name": "household_members_user_id_users_id_fk", + "tableFrom": "household_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "household_members_household_id_user_id_pk": { + "name": "household_members_household_id_user_id_pk", + "columns": [ + "household_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminders": { + "name": "reminders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fire_at": { + "name": "fire_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'in_app'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reminders_entity_unique": { + "name": "reminders_entity_unique", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reminders_household_fire_at_idx": { + "name": "reminders_household_fire_at_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fire_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reminders_household_id_households_id_fk": { + "name": "reminders_household_id_households_id_fk", + "tableFrom": "reminders", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "theme_mode": { + "name": "theme_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "default_dashboard_layout": { + "name": "default_dashboard_layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "calendar_id": { + "name": "calendar_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_source": { + "name": "external_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_events_calendar_start_idx": { + "name": "calendar_events_calendar_start_idx", + "columns": [ + { + "expression": "calendar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_events_calendar_id_calendars_id_fk": { + "name": "calendar_events_calendar_id_calendars_id_fk", + "tableFrom": "calendar_events", + "tableTo": "calendars", + "columnsFrom": [ + "calendar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_owner_id_users_id_fk": { + "name": "calendar_events_owner_id_users_id_fk", + "tableFrom": "calendar_events", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "calendar_events_range_check": { + "name": "calendar_events_range_check", + "value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\"" + } + }, + "isRLSEnabled": false + }, + "public.calendars": { + "name": "calendars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'household'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendars_household_idx": { + "name": "calendars_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendars_owner_idx": { + "name": "calendars_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendars_household_id_households_id_fk": { + "name": "calendars_household_id_households_id_fk", + "tableFrom": "calendars", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendars_owner_id_users_id_fk": { + "name": "calendars_owner_id_users_id_fk", + "tableFrom": "calendars", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "calendars_visibility_check": { + "name": "calendars_visibility_check", + "value": "\"calendars\".\"visibility\" in ('private', 'household')" + } + }, + "isRLSEnabled": false + }, + "public.list_items": { + "name": "list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "qty": { + "name": "qty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "list_items_list_position_idx": { + "name": "list_items_list_position_idx", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "list_items_assignee_idx": { + "name": "list_items_assignee_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "list_items_list_id_lists_id_fk": { + "name": "list_items_list_id_lists_id_fk", + "tableFrom": "list_items", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_items_assignee_id_users_id_fk": { + "name": "list_items_assignee_id_users_id_fk", + "tableFrom": "list_items", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lists_household_type_idx": { + "name": "lists_household_type_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lists_household_archived_idx": { + "name": "lists_household_archived_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lists_household_id_households_id_fk": { + "name": "lists_household_id_households_id_fk", + "tableFrom": "lists", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remind_at": { + "name": "remind_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notes_household_pinned_updated_idx": { + "name": "notes_household_pinned_updated_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_author_idx": { + "name": "notes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_household_id_households_id_fk": { + "name": "notes_household_id_households_id_fk", + "tableFrom": "notes", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notes_author_id_users_id_fk": { + "name": "notes_author_id_users_id_fk", + "tableFrom": "notes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bang_events": { + "name": "bang_events", + "schema": "public", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_by": { + "name": "recorded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "occurred_on": { + "name": "occurred_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bang_events_household_occurred_idx": { + "name": "bang_events_household_occurred_idx", + "columns": [ + { + "expression": "household_id", + "asc": true, + "nulls": "last", + "isExpression": false + }, + { + "expression": "occurred_on", + "asc": true, + "nulls": "last", + "isExpression": false + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree" + } + }, + "foreignKeys": { + "bang_events_household_id_households_id_fk": { + "name": "bang_events_household_id_households_id_fk", + "tableFrom": "bang_events", + "tableTo": "households", + "schemaTo": "public", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bang_events_recorded_by_users_id_fk": { + "name": "bang_events_recorded_by_users_id_fk", + "tableFrom": "bang_events", + "tableTo": "users", + "schemaTo": "public", + "columnsFrom": [ + "recorded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.household_member_role": { + "name": "household_member_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 33f83e7..51e5ee4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1748822400000, "tag": "0016_garden_improvements", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1780391596552, + "tag": "0017_bang_counter", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 634698c..6c0c1a1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9da1b7d..f8e67f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/globals.css b/src/app/globals.css index b536e30..2a8fa90 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; +} diff --git a/src/modules/bangs/components/bang-widget.tsx b/src/modules/bangs/components/bang-widget.tsx new file mode 100644 index 0000000..dadd032 --- /dev/null +++ b/src/modules/bangs/components/bang-widget.tsx @@ -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( +
+ {particles.map((p) => ( + + {p.emoji} + + ))} +
, + 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(null); + const [emojiParticles, setEmojiParticles] = useState([]); + 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 ( +
+ {flash && ( +
+ )} + + + +
+
+ + {stats.total} + + + {stats.total === 1 ? "bang" : "bangs"} + +
+ + + + } + > + 💥 Add Bang + + + + Record a bang + +
+
+ + setDateValue(e.target.value)} + className="input input-sm" + required + /> +

+ Change if you're documenting a bang from a previous day. +

+
+ {error &&

{error}

} +
+ + + +
+
+
+ + {stats.recent.length > 0 && ( +
+

+ Last {Math.min(stats.recent.length, maxRecentBangs)} +

+ {stats.recent.map((bang) => ( +
+ {formatBangDate(bang.occurredOn)} + {bang.recordedByName && ( + + {bang.recordedByName} + + )} +
+ ))} +
+ )} + + {stats.total === 0 && ( +

No bangs yet. Add the first one!

+ )} +
+ ); +} + +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" }); +} diff --git a/src/modules/bangs/manifest.tsx b/src/modules/bangs/manifest.tsx new file mode 100644 index 0000000..3dd31d0 --- /dev/null +++ b/src/modules/bangs/manifest.tsx @@ -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 ; +} + +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) => , + }, + ], +}; + +export default bangsManifest; diff --git a/src/modules/bangs/schema.ts b/src/modules/bangs/schema.ts new file mode 100644 index 0000000..84b64f3 --- /dev/null +++ b/src/modules/bangs/schema.ts @@ -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; diff --git a/src/modules/bangs/server/actions.ts b/src/modules/bangs/server/actions.ts new file mode 100644 index 0000000..1aa8e7d --- /dev/null +++ b/src/modules/bangs/server/actions.ts @@ -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 = {}) { + 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; +} diff --git a/src/modules/bangs/server/queries.ts b/src/modules/bangs/server/queries.ts new file mode 100644 index 0000000..dacccc7 --- /dev/null +++ b/src/modules/bangs/server/queries.ts @@ -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 { + 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, + }; +} diff --git a/src/modules/index.ts b/src/modules/index.ts index eca8720..303cccc 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -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);