Implement calendar module
This commit is contained in:
@@ -16,9 +16,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is
|
|||||||
|
|
||||||
- **08 — Theming infrastructure**. CSS-variable multi-theme system (`default` + `warm`) × `{light, dark, system}`. `users.theme` + `users.themeMode` columns in migration `0002_naive_starbolt.sql`. Theme registry in `src/modules/_core/themes.ts` (THEMES/THEME_MODES arrays — adding a third theme is one CSS block + one registry entry). Root layout reads session and sets `data-theme`/`dark` on `<html>` server-side; inline pre-paint `<script>` covers system mode and signed-out pages (no flash). `useTheme()` hook optimistically flips attributes, writes `localStorage`, and calls `setUserTheme` server action. `<ThemePicker />` component mounts on `/settings` (select for theme, segmented buttons for mode). `tsc --noEmit`, `pnpm lint`, `pnpm build` all clean.
|
- **08 — Theming infrastructure**. CSS-variable multi-theme system (`default` + `warm`) × `{light, dark, system}`. `users.theme` + `users.themeMode` columns in migration `0002_naive_starbolt.sql`. Theme registry in `src/modules/_core/themes.ts` (THEMES/THEME_MODES arrays — adding a third theme is one CSS block + one registry entry). Root layout reads session and sets `data-theme`/`dark` on `<html>` server-side; inline pre-paint `<script>` covers system mode and signed-out pages (no flash). `useTheme()` hook optimistically flips attributes, writes `localStorage`, and calls `setUserTheme` server action. `<ThemePicker />` component mounts on `/settings` (select for theme, segmented buttons for mode). `tsc --noEmit`, `pnpm lint`, `pnpm build` all clean.
|
||||||
|
|
||||||
|
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
- **09 — Calendar module** (or next task in `docs/tasks/`).
|
- **11 — Lists module** (or next task in `docs/tasks/`).
|
||||||
|
|
||||||
## Phase 1 remaining
|
## Phase 1 remaining
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "calendar_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"calendar_id" uuid NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"start_at" timestamp with time zone NOT NULL,
|
||||||
|
"end_at" timestamp with time zone NOT NULL,
|
||||||
|
"all_day" boolean DEFAULT false NOT NULL,
|
||||||
|
"location" text,
|
||||||
|
"notes" text,
|
||||||
|
"owner_id" uuid NOT NULL,
|
||||||
|
"rrule" text,
|
||||||
|
"external_source" text,
|
||||||
|
"external_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "calendar_events_range_check" CHECK ("calendar_events"."end_at" >= "calendar_events"."start_at")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "calendars" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"owner_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"color" text,
|
||||||
|
"visibility" text DEFAULT 'household' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "calendars_visibility_check" CHECK ("calendars"."visibility" in ('private', 'household'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "calendar_events" ADD CONSTRAINT "calendar_events_calendar_id_calendars_id_fk" FOREIGN KEY ("calendar_id") REFERENCES "public"."calendars"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "calendar_events" ADD CONSTRAINT "calendar_events_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "calendars" ADD CONSTRAINT "calendars_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "calendars" ADD CONSTRAINT "calendars_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "calendar_events_calendar_start_idx" ON "calendar_events" USING btree ("calendar_id","start_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "calendars_household_idx" ON "calendars" USING btree ("household_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "calendars_owner_idx" ON "calendars" USING btree ("owner_id");
|
||||||
+8
-1
@@ -15,6 +15,7 @@
|
|||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -34,13 +36,18 @@
|
|||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^5.6.3",
|
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.15.0"
|
"typescript-eslint": "^8.15.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.11.2",
|
"@auth/drizzle-adapter": "^1.11.2",
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@fullcalendar/core": "^6.1.20",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
|
"@fullcalendar/react": "^6.1.20",
|
||||||
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
|
||||||
|
storageState: process.env.PLAYWRIGHT_STORAGE_STATE,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+112
-5
@@ -14,6 +14,21 @@ importers:
|
|||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.4.1
|
specifier: ^1.4.1
|
||||||
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
'@fullcalendar/core':
|
||||||
|
specifier: ^6.1.20
|
||||||
|
version: 6.1.20
|
||||||
|
'@fullcalendar/daygrid':
|
||||||
|
specifier: ^6.1.20
|
||||||
|
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||||
|
'@fullcalendar/interaction':
|
||||||
|
specifier: ^6.1.20
|
||||||
|
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||||
|
'@fullcalendar/react':
|
||||||
|
specifier: ^6.1.20
|
||||||
|
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
'@fullcalendar/timegrid':
|
||||||
|
specifier: ^6.1.20
|
||||||
|
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -28,10 +43,10 @@ importers:
|
|||||||
version: 1.14.0(react@19.2.5)
|
version: 1.14.0(react@19.2.5)
|
||||||
next:
|
next:
|
||||||
specifier: ^15.5.15
|
specifier: ^15.5.15
|
||||||
version: 15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.31
|
specifier: 5.0.0-beta.31
|
||||||
version: 5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
version: 5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.9
|
specifier: ^3.4.9
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
@@ -63,6 +78,9 @@ importers:
|
|||||||
'@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))
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.59.1
|
||||||
|
version: 1.59.1
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.2.4
|
specifier: ^4.2.4
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
@@ -822,6 +840,31 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.11':
|
'@floating-ui/utils@0.2.11':
|
||||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
|
'@fullcalendar/core@6.1.20':
|
||||||
|
resolution: {integrity: sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==}
|
||||||
|
|
||||||
|
'@fullcalendar/daygrid@6.1.20':
|
||||||
|
resolution: {integrity: sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fullcalendar/core': ~6.1.20
|
||||||
|
|
||||||
|
'@fullcalendar/interaction@6.1.20':
|
||||||
|
resolution: {integrity: sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fullcalendar/core': ~6.1.20
|
||||||
|
|
||||||
|
'@fullcalendar/react@6.1.20':
|
||||||
|
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fullcalendar/core': ~6.1.20
|
||||||
|
react: ^16.7.0 || ^17 || ^18 || ^19
|
||||||
|
react-dom: ^16.7.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
|
'@fullcalendar/timegrid@6.1.20':
|
||||||
|
resolution: {integrity: sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fullcalendar/core': ~6.1.20
|
||||||
|
|
||||||
'@hono/node-server@1.19.14':
|
'@hono/node-server@1.19.14':
|
||||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
@@ -1170,6 +1213,11 @@ packages:
|
|||||||
'@panva/hkdf@1.2.1':
|
'@panva/hkdf@1.2.1':
|
||||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@@ -2273,6 +2321,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -3098,6 +3151,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
|
|
||||||
|
playwright-core@1.59.1:
|
||||||
|
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3127,6 +3190,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
preact: '>=10'
|
preact: '>=10'
|
||||||
|
|
||||||
|
preact@10.12.1:
|
||||||
|
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
|
||||||
|
|
||||||
preact@10.24.3:
|
preact@10.24.3:
|
||||||
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||||
|
|
||||||
@@ -4257,6 +4323,29 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.11': {}
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
|
'@fullcalendar/core@6.1.20':
|
||||||
|
dependencies:
|
||||||
|
preact: 10.12.1
|
||||||
|
|
||||||
|
'@fullcalendar/daygrid@6.1.20(@fullcalendar/core@6.1.20)':
|
||||||
|
dependencies:
|
||||||
|
'@fullcalendar/core': 6.1.20
|
||||||
|
|
||||||
|
'@fullcalendar/interaction@6.1.20(@fullcalendar/core@6.1.20)':
|
||||||
|
dependencies:
|
||||||
|
'@fullcalendar/core': 6.1.20
|
||||||
|
|
||||||
|
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
|
dependencies:
|
||||||
|
'@fullcalendar/core': 6.1.20
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
|
'@fullcalendar/timegrid@6.1.20(@fullcalendar/core@6.1.20)':
|
||||||
|
dependencies:
|
||||||
|
'@fullcalendar/core': 6.1.20
|
||||||
|
'@fullcalendar/daygrid': 6.1.20(@fullcalendar/core@6.1.20)
|
||||||
|
|
||||||
'@hono/node-server@1.19.14(hono@4.12.17)':
|
'@hono/node-server@1.19.14(hono@4.12.17)':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.17
|
hono: 4.12.17
|
||||||
@@ -4523,6 +4612,10 @@ snapshots:
|
|||||||
|
|
||||||
'@panva/hkdf@1.2.1': {}
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.59.1
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
@@ -5750,6 +5843,9 @@ snapshots:
|
|||||||
jsonfile: 6.2.1
|
jsonfile: 6.2.1
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6294,13 +6390,13 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
next-auth@5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
|
next-auth@5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core': 0.41.2
|
'@auth/core': 0.41.2
|
||||||
next: 15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
next: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|
||||||
next@15.5.15(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.5.15
|
'@next/env': 15.5.15
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -6318,6 +6414,7 @@ snapshots:
|
|||||||
'@next/swc-linux-x64-musl': 15.5.15
|
'@next/swc-linux-x64-musl': 15.5.15
|
||||||
'@next/swc-win32-arm64-msvc': 15.5.15
|
'@next/swc-win32-arm64-msvc': 15.5.15
|
||||||
'@next/swc-win32-x64-msvc': 15.5.15
|
'@next/swc-win32-x64-msvc': 15.5.15
|
||||||
|
'@playwright/test': 1.59.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -6494,6 +6591,14 @@ snapshots:
|
|||||||
|
|
||||||
pkce-challenge@5.0.1: {}
|
pkce-challenge@5.0.1: {}
|
||||||
|
|
||||||
|
playwright-core@1.59.1: {}
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.59.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-selector-parser@7.1.1:
|
postcss-selector-parser@7.1.1:
|
||||||
@@ -6521,6 +6626,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
preact: 10.24.3
|
preact: 10.24.3
|
||||||
|
|
||||||
|
preact@10.12.1: {}
|
||||||
|
|
||||||
preact@10.24.3: {}
|
preact@10.24.3: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { households } from "@/modules/_core/schema";
|
import { households } from "@/modules/_core/schema";
|
||||||
|
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
const db = drizzle(client);
|
const db = drizzle(client);
|
||||||
@@ -13,6 +14,9 @@ async function seed() {
|
|||||||
await db.insert(households).values({ name: "Home" });
|
await db.insert(households).values({ name: "Home" });
|
||||||
console.log('Seeded household "Home".');
|
console.log('Seeded household "Home".');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureDefaultCalendars();
|
||||||
|
console.log("Ensured default calendars.");
|
||||||
await client.end();
|
await client.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||||
|
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
||||||
|
|
||||||
|
export default async function CalendarPage() {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now);
|
||||||
|
from.setMonth(from.getMonth() - 2);
|
||||||
|
const to = new Date(now);
|
||||||
|
to.setMonth(to.getMonth() + 10);
|
||||||
|
|
||||||
|
const [calendars, events] = await Promise.all([
|
||||||
|
listCalendars(),
|
||||||
|
listEvents({ from, to, calendarIds: "all" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <CalendarShell calendars={calendars} events={events} />;
|
||||||
|
}
|
||||||
@@ -198,3 +198,40 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc {
|
||||||
|
--fc-border-color: var(--border);
|
||||||
|
--fc-page-bg-color: var(--background);
|
||||||
|
--fc-neutral-bg-color: var(--muted);
|
||||||
|
--fc-neutral-text-color: var(--muted-foreground);
|
||||||
|
--fc-button-bg-color: var(--primary);
|
||||||
|
--fc-button-border-color: var(--primary);
|
||||||
|
--fc-button-text-color: var(--primary-foreground);
|
||||||
|
--fc-button-hover-bg-color: var(--foreground);
|
||||||
|
--fc-button-hover-border-color: var(--foreground);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-button {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-toolbar-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-daygrid-day-number,
|
||||||
|
.fc .fc-col-header-cell-cushion {
|
||||||
|
color: var(--foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc .fc-event {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
verificationTokens,
|
verificationTokens,
|
||||||
} from "@/modules/_core/schema";
|
} from "@/modules/_core/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
|
||||||
|
|
||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -59,6 +60,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
.insert(householdMembers)
|
.insert(householdMembers)
|
||||||
.values({ householdId: household.id, userId: user.id, role })
|
.values({ householdId: household.id, userId: user.id, role })
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
await ensureDefaultCalendarsForMembership({
|
||||||
|
householdId: household.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "@/modules/_core/schema";
|
import * as coreSchema from "@/modules/_core/schema";
|
||||||
|
import * as calendarSchema from "@/modules/calendar/schema";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
|
|
||||||
|
const schema = { ...coreSchema, ...calendarSchema };
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import FullCalendar from "@fullcalendar/react";
|
||||||
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
|
import type {
|
||||||
|
DateSelectArg,
|
||||||
|
EventClickArg,
|
||||||
|
EventDropArg,
|
||||||
|
} from "@fullcalendar/core";
|
||||||
|
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||||
|
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { CalendarDto, CalendarEventDto } from "../server/queries";
|
||||||
|
import {
|
||||||
|
createCalendar,
|
||||||
|
createEvent,
|
||||||
|
deleteCalendar,
|
||||||
|
deleteEvent,
|
||||||
|
renameCalendar,
|
||||||
|
setCalendarColor,
|
||||||
|
setCalendarVisibility,
|
||||||
|
updateEvent,
|
||||||
|
} from "../server/actions";
|
||||||
|
|
||||||
|
type EventDraft = {
|
||||||
|
id?: string;
|
||||||
|
calendarId: string;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
allDay: boolean;
|
||||||
|
location: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = "#2563eb";
|
||||||
|
|
||||||
|
export function CalendarShell({
|
||||||
|
calendars,
|
||||||
|
events,
|
||||||
|
}: {
|
||||||
|
calendars: CalendarDto[];
|
||||||
|
events: CalendarEventDto[];
|
||||||
|
}) {
|
||||||
|
const [calendarRows, setCalendarRows] = useState(calendars);
|
||||||
|
const [eventRows, setEventRows] = useState(events);
|
||||||
|
const [visibleIds, setVisibleIds] = useState(() => new Set(calendars.map((c) => c.id)));
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<EventDraft | null>(null);
|
||||||
|
const [calendarName, setCalendarName] = useState("");
|
||||||
|
const [calendarColor, setCalendarColorValue] = useState(DEFAULT_COLOR);
|
||||||
|
const [calendarVisibility, setCalendarVisibilityValue] = useState<"private" | "household">(
|
||||||
|
"household",
|
||||||
|
);
|
||||||
|
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
|
||||||
|
? lastCalendarId
|
||||||
|
: (calendarRows[0]?.id ?? "");
|
||||||
|
const visibleEvents = useMemo(
|
||||||
|
() => eventRows.filter((event) => visibleIds.has(event.calendarId)),
|
||||||
|
[eventRows, visibleIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleCalendar(id: string) {
|
||||||
|
setVisibleIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewEvent(startAt?: Date, endAt?: Date, allDay = false) {
|
||||||
|
if (!defaultCalendarId) return;
|
||||||
|
const start = startAt ?? new Date();
|
||||||
|
const end = endAt ?? new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
setSelectedEvent({
|
||||||
|
calendarId: defaultCalendarId,
|
||||||
|
title: "",
|
||||||
|
startAt: toInputDateTime(start),
|
||||||
|
endAt: toInputDateTime(end),
|
||||||
|
allDay,
|
||||||
|
location: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExistingEvent({ event }: EventClickArg) {
|
||||||
|
const row = eventRows.find((item) => item.id === event.id);
|
||||||
|
if (!row) return;
|
||||||
|
setSelectedEvent({
|
||||||
|
id: row.id,
|
||||||
|
calendarId: row.calendarId,
|
||||||
|
title: row.title,
|
||||||
|
startAt: toInputDateTime(new Date(row.startAt)),
|
||||||
|
endAt: toInputDateTime(new Date(row.endAt)),
|
||||||
|
allDay: row.allDay,
|
||||||
|
location: row.location ?? "",
|
||||||
|
notes: row.notes ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateSelect(selection: DateSelectArg) {
|
||||||
|
openNewEvent(selection.start, selection.end, selection.allDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSelectedEvent() {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
const eventId = selectedEvent.id;
|
||||||
|
const payload = {
|
||||||
|
calendarId: selectedEvent.calendarId,
|
||||||
|
title: selectedEvent.title,
|
||||||
|
startAt: new Date(selectedEvent.startAt),
|
||||||
|
endAt: new Date(selectedEvent.endAt),
|
||||||
|
allDay: selectedEvent.allDay,
|
||||||
|
location: selectedEvent.location || null,
|
||||||
|
notes: selectedEvent.notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
if (eventId) {
|
||||||
|
await updateEvent({ id: eventId, ...payload });
|
||||||
|
setLastCalendarId(payload.calendarId);
|
||||||
|
setEventRows((current) =>
|
||||||
|
current.map((event) =>
|
||||||
|
event.id === eventId
|
||||||
|
? {
|
||||||
|
...event,
|
||||||
|
calendarId: payload.calendarId,
|
||||||
|
title: payload.title,
|
||||||
|
allDay: payload.allDay,
|
||||||
|
location: payload.location,
|
||||||
|
notes: payload.notes,
|
||||||
|
startAt: payload.startAt.toISOString(),
|
||||||
|
endAt: payload.endAt.toISOString(),
|
||||||
|
}
|
||||||
|
: event,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const created = await createEvent(payload);
|
||||||
|
setLastCalendarId(payload.calendarId);
|
||||||
|
setEventRows((current) => [...current, created]);
|
||||||
|
}
|
||||||
|
setSelectedEvent(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedEvent() {
|
||||||
|
if (!selectedEvent?.id) return;
|
||||||
|
const id = selectedEvent.id;
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteEvent({ id });
|
||||||
|
setEventRows((current) => current.filter((event) => event.id !== id));
|
||||||
|
setSelectedEvent(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveEvent(change: EventDropArg | EventResizeDoneArg) {
|
||||||
|
const start = change.event.start;
|
||||||
|
const end = change.event.end ?? start;
|
||||||
|
if (!start || !end) return;
|
||||||
|
const id = change.event.id;
|
||||||
|
setEventRows((current) =>
|
||||||
|
current.map((event) =>
|
||||||
|
event.id === id
|
||||||
|
? {
|
||||||
|
...event,
|
||||||
|
startAt: start.toISOString(),
|
||||||
|
endAt: end.toISOString(),
|
||||||
|
allDay: change.event.allDay,
|
||||||
|
}
|
||||||
|
: event,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateEvent({
|
||||||
|
id,
|
||||||
|
startAt: start,
|
||||||
|
endAt: end,
|
||||||
|
allDay: change.event.allDay,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCalendar() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const created = await createCalendar({
|
||||||
|
name: calendarName,
|
||||||
|
color: calendarColor,
|
||||||
|
visibility: calendarVisibility,
|
||||||
|
});
|
||||||
|
const calendar = {
|
||||||
|
id: created.id,
|
||||||
|
name: created.name,
|
||||||
|
color: created.color,
|
||||||
|
visibility: created.visibility as "private" | "household",
|
||||||
|
ownerId: created.ownerId,
|
||||||
|
};
|
||||||
|
setCalendarRows((current) => [...current, calendar]);
|
||||||
|
setVisibleIds((current) => new Set([...current, calendar.id]));
|
||||||
|
setLastCalendarId(calendar.id);
|
||||||
|
setCalendarName("");
|
||||||
|
setCalendarColorValue(DEFAULT_COLOR);
|
||||||
|
setCalendarVisibilityValue("household");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
|
||||||
|
const next = { ...calendar, ...values };
|
||||||
|
setCalendarRows((current) =>
|
||||||
|
current.map((item) => (item.id === calendar.id ? next : item)),
|
||||||
|
);
|
||||||
|
startTransition(async () => {
|
||||||
|
if (values.name !== undefined) {
|
||||||
|
await renameCalendar({ id: calendar.id, name: values.name });
|
||||||
|
}
|
||||||
|
if (values.color !== undefined) {
|
||||||
|
await setCalendarColor({ id: calendar.id, color: values.color });
|
||||||
|
}
|
||||||
|
if (values.visibility !== undefined) {
|
||||||
|
await setCalendarVisibility({ id: calendar.id, visibility: values.visibility });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCalendarName(calendar: CalendarDto, name: string) {
|
||||||
|
setCalendarRows((current) =>
|
||||||
|
current.map((item) => (item.id === calendar.id ? { ...item, name } : item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitCalendarName(calendar: CalendarDto) {
|
||||||
|
if (!calendar.name.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
await renameCalendar({ id: calendar.id, name: calendar.name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCalendar(calendarId: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteCalendar({ id: calendarId });
|
||||||
|
setCalendarRows((current) => current.filter((calendar) => calendar.id !== calendarId));
|
||||||
|
setEventRows((current) => current.filter((event) => event.calendarId !== calendarId));
|
||||||
|
setVisibleIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(calendarId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]">
|
||||||
|
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Calendar</h1>
|
||||||
|
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
|
||||||
|
<Plus />
|
||||||
|
<span className="sr-only">New event</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{calendarRows.map((calendar) => (
|
||||||
|
<div key={calendar.id} className="rounded-lg border bg-background p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid size-7 place-items-center rounded-md border"
|
||||||
|
onClick={() => toggleCalendar(calendar.id)}
|
||||||
|
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
|
||||||
|
>
|
||||||
|
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="size-3 rounded-full"
|
||||||
|
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label={`${calendar.name} name`}
|
||||||
|
value={calendar.name}
|
||||||
|
onChange={(event) => updateCalendarName(calendar, event.target.value)}
|
||||||
|
onBlur={() => commitCalendarName(calendar)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label={`${calendar.name} color`}
|
||||||
|
type="color"
|
||||||
|
value={calendar.color ?? DEFAULT_COLOR}
|
||||||
|
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={calendar.visibility}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateCalendar(calendar, {
|
||||||
|
visibility: value as "private" | "household",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={`${calendar.name} visibility`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="household">Household</SelectItem>
|
||||||
|
<SelectItem value="private">Private</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeCalendar(calendar.id)}
|
||||||
|
aria-label={`Delete ${calendar.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border bg-background p-3">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<CalendarPlus className="size-4" />
|
||||||
|
New calendar
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="calendar-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="calendar-name"
|
||||||
|
value={calendarName}
|
||||||
|
onChange={(event) => setCalendarName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-[1fr_1fr] gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="Calendar color"
|
||||||
|
type="color"
|
||||||
|
value={calendarColor}
|
||||||
|
onChange={(event) => setCalendarColorValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={calendarVisibility}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCalendarVisibilityValue(value as "private" | "household")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Calendar visibility">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="household">Household</SelectItem>
|
||||||
|
<SelectItem value="private">Private</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" onClick={addCalendar} disabled={!calendarName || isPending}>
|
||||||
|
<Check />
|
||||||
|
Create calendar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-w-0 p-4">
|
||||||
|
<FullCalendar
|
||||||
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
headerToolbar={{
|
||||||
|
left: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||||
|
}}
|
||||||
|
selectable
|
||||||
|
editable
|
||||||
|
eventResizableFromStart
|
||||||
|
select={handleDateSelect}
|
||||||
|
eventClick={openExistingEvent}
|
||||||
|
eventDrop={moveEvent}
|
||||||
|
eventResize={moveEvent}
|
||||||
|
events={visibleEvents.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
start: event.startAt,
|
||||||
|
end: event.endAt,
|
||||||
|
allDay: event.allDay,
|
||||||
|
backgroundColor:
|
||||||
|
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
||||||
|
DEFAULT_COLOR,
|
||||||
|
borderColor:
|
||||||
|
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
|
||||||
|
DEFAULT_COLOR,
|
||||||
|
}))}
|
||||||
|
height="auto"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4">
|
||||||
|
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{selectedEvent.id ? "Edit event" : "New event"}
|
||||||
|
</h2>
|
||||||
|
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
|
||||||
|
<span aria-hidden>×</span>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label htmlFor="event-title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="event-title"
|
||||||
|
value={selectedEvent.title}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedEvent({ ...selectedEvent, title: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="event-calendar">Calendar</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedEvent.calendarId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="event-calendar">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{calendarRows.map((calendar) => (
|
||||||
|
<SelectItem key={calendar.id} value={calendar.id}>
|
||||||
|
{calendar.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="event-start">Start</Label>
|
||||||
|
<Input
|
||||||
|
id="event-start"
|
||||||
|
type="datetime-local"
|
||||||
|
value={selectedEvent.startAt}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="event-end">End</Label>
|
||||||
|
<Input
|
||||||
|
id="event-end"
|
||||||
|
type="datetime-local"
|
||||||
|
value={selectedEvent.endAt}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Label htmlFor="event-location">Location</Label>
|
||||||
|
<Input
|
||||||
|
id="event-location"
|
||||||
|
value={selectedEvent.location}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedEvent({ ...selectedEvent, location: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="event-notes">Notes</Label>
|
||||||
|
<textarea
|
||||||
|
id="event-notes"
|
||||||
|
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={selectedEvent.notes}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-2">
|
||||||
|
<div>
|
||||||
|
{selectedEvent.id && (
|
||||||
|
<Button variant="destructive" onClick={removeSelectedEvent}>
|
||||||
|
Delete event
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
|
||||||
|
Save event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInputDateTime(date: Date) {
|
||||||
|
const offset = date.getTimezoneOffset();
|
||||||
|
const local = new Date(date.getTime() - offset * 60 * 1000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ModuleManifest } from "../_core/module";
|
|
||||||
|
|
||||||
const manifest: ModuleManifest = {
|
|
||||||
id: "calendar",
|
|
||||||
name: "Calendar",
|
|
||||||
nav: { href: "/calendar", label: "Calendar", icon: "calendar" },
|
|
||||||
entities: [
|
|
||||||
{
|
|
||||||
type: "calendar.calendar",
|
|
||||||
label: { singular: "Calendar", plural: "Calendars" },
|
|
||||||
resolveUrl: (id) => `/calendar?id=${id}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "calendar.event",
|
|
||||||
label: { singular: "Event", plural: "Events" },
|
|
||||||
resolveUrl: (id) => `/calendar/events/${id}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dashboardWidgets: [],
|
|
||||||
quickAdds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default manifest;
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { ModuleManifest } from "../_core/module";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { listCalendars, searchCalendars, searchEvents } from "./server/queries";
|
||||||
|
|
||||||
|
const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
|
||||||
|
|
||||||
|
const manifest: ModuleManifest = {
|
||||||
|
id: "calendar",
|
||||||
|
name: "Calendar",
|
||||||
|
nav: { href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
type: "calendar.calendar",
|
||||||
|
label: { singular: "Calendar", plural: "Calendars" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
search: { search: searchCalendars },
|
||||||
|
resolveUrl: (id) => `/calendar?id=${id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "calendar.event",
|
||||||
|
label: { singular: "Event", plural: "Events" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
reminder: { canRemind: true },
|
||||||
|
search: { search: searchEvents },
|
||||||
|
resolveUrl: (id) => `/calendar/events/${id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dashboardWidgets: [
|
||||||
|
{
|
||||||
|
id: "calendar.upcoming",
|
||||||
|
title: "Upcoming events",
|
||||||
|
description: "Events from selected calendars over the next few days.",
|
||||||
|
category: "Calendar",
|
||||||
|
defaultSize: { w: 4, h: 3 },
|
||||||
|
minSize: { w: 3, h: 2 },
|
||||||
|
defaultPriority: 10,
|
||||||
|
configSchema: z.object({
|
||||||
|
calendarIds: calendarIdsSchema,
|
||||||
|
days: z.number().int().min(1).max(30),
|
||||||
|
}),
|
||||||
|
defaultConfig: { calendarIds: "all", days: 3 },
|
||||||
|
resolveConfigOptions: async () => ({
|
||||||
|
calendars: (await listCalendars()).map((calendar) => ({
|
||||||
|
id: calendar.id,
|
||||||
|
name: calendar.name,
|
||||||
|
visibility: calendar.visibility,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
render: () => <div className="text-sm text-muted-foreground">Upcoming events</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calendar.month",
|
||||||
|
title: "Month calendar",
|
||||||
|
description: "A compact month view for selected calendars.",
|
||||||
|
category: "Calendar",
|
||||||
|
defaultSize: { w: 6, h: 5 },
|
||||||
|
minSize: { w: 4, h: 4 },
|
||||||
|
defaultPriority: 20,
|
||||||
|
configSchema: z.object({ calendarIds: calendarIdsSchema }),
|
||||||
|
defaultConfig: { calendarIds: "all" },
|
||||||
|
resolveConfigOptions: async () => ({
|
||||||
|
calendars: (await listCalendars()).map((calendar) => ({
|
||||||
|
id: calendar.id,
|
||||||
|
name: calendar.name,
|
||||||
|
visibility: calendar.visibility,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
render: () => <div className="text-sm text-muted-foreground">Month calendar</div>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quickAdds: [
|
||||||
|
{
|
||||||
|
id: "calendar.new-event",
|
||||||
|
label: "New event",
|
||||||
|
icon: "calendar-plus",
|
||||||
|
action: () => undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calendar.new-calendar",
|
||||||
|
label: "New calendar",
|
||||||
|
icon: "calendar-days",
|
||||||
|
action: () => undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
check,
|
||||||
|
index,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { households, users } from "../_core/schema";
|
||||||
|
|
||||||
|
export const calendars = pgTable(
|
||||||
|
"calendars",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
householdId: uuid("household_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: "cascade" }),
|
||||||
|
ownerId: uuid("owner_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
color: text("color"),
|
||||||
|
visibility: text("visibility").notNull().default("household"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
check("calendars_visibility_check", sql`${t.visibility} in ('private', 'household')`),
|
||||||
|
index("calendars_household_idx").on(t.householdId),
|
||||||
|
index("calendars_owner_idx").on(t.ownerId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const calendarEvents = pgTable(
|
||||||
|
"calendar_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
calendarId: uuid("calendar_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => calendars.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
startAt: timestamp("start_at", { withTimezone: true }).notNull(),
|
||||||
|
endAt: timestamp("end_at", { withTimezone: true }).notNull(),
|
||||||
|
allDay: boolean("all_day").notNull().default(false),
|
||||||
|
location: text("location"),
|
||||||
|
notes: text("notes"),
|
||||||
|
ownerId: uuid("owner_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
rrule: text("rrule"),
|
||||||
|
externalSource: text("external_source"),
|
||||||
|
externalId: text("external_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("calendar_events_calendar_start_idx").on(t.calendarId, t.startAt),
|
||||||
|
check("calendar_events_range_check", sql`${t.endAt} >= ${t.startAt}`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Calendar = typeof calendars.$inferSelect;
|
||||||
|
export type CalendarEvent = typeof calendarEvents.$inferSelect;
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { calendarEvents, calendars } from "../schema";
|
||||||
|
import { canSeeCalendar } from "./queries";
|
||||||
|
|
||||||
|
const calendarInput = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
color: z.string().trim().min(1).max(32).nullable().optional(),
|
||||||
|
visibility: z.enum(["private", "household"]).default("household"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventInput = z
|
||||||
|
.object({
|
||||||
|
calendarId: z.string().uuid(),
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
startAt: z.coerce.date(),
|
||||||
|
endAt: z.coerce.date(),
|
||||||
|
allDay: z.boolean().default(false),
|
||||||
|
location: z.string().trim().max(300).nullable().optional(),
|
||||||
|
notes: z.string().trim().max(3000).nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine((value) => value.endAt >= value.startAt, {
|
||||||
|
path: ["endAt"],
|
||||||
|
message: "End must be after start",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createCalendar(input: z.input<typeof calendarInput>) {
|
||||||
|
const parsed = calendarInput.parse(input);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
const [calendar] = await db
|
||||||
|
.insert(calendars)
|
||||||
|
.values({
|
||||||
|
householdId: household.id,
|
||||||
|
ownerId: user.id,
|
||||||
|
name: parsed.name,
|
||||||
|
color: parsed.color ?? null,
|
||||||
|
visibility: parsed.visibility,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!calendar) throw new Error("Calendar was not created");
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
return calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameCalendar(input: { id: string; name: string }) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), name: calendarInput.shape.name }).parse(input);
|
||||||
|
await assertOwnsCalendar(user.id, parsed.id);
|
||||||
|
await db
|
||||||
|
.update(calendars)
|
||||||
|
.set({ name: parsed.name, updatedAt: new Date() })
|
||||||
|
.where(eq(calendars.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCalendarVisibility(input: {
|
||||||
|
id: string;
|
||||||
|
visibility: "private" | "household";
|
||||||
|
}) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = z
|
||||||
|
.object({ id: z.string().uuid(), visibility: calendarInput.shape.visibility })
|
||||||
|
.parse(input);
|
||||||
|
await assertOwnsCalendar(user.id, parsed.id);
|
||||||
|
await db
|
||||||
|
.update(calendars)
|
||||||
|
.set({ visibility: parsed.visibility, updatedAt: new Date() })
|
||||||
|
.where(eq(calendars.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCalendarColor(input: { id: string; color: string | null }) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = z
|
||||||
|
.object({ id: z.string().uuid(), color: calendarInput.shape.color })
|
||||||
|
.parse(input);
|
||||||
|
await assertOwnsCalendar(user.id, parsed.id);
|
||||||
|
await db
|
||||||
|
.update(calendars)
|
||||||
|
.set({ color: parsed.color ?? null, updatedAt: new Date() })
|
||||||
|
.where(eq(calendars.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCalendar(input: { id: string }) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
await assertOwnsCalendar(user.id, parsed.id);
|
||||||
|
await db.delete(calendars).where(eq(calendars.id, parsed.id));
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvent(input: z.input<typeof eventInput>) {
|
||||||
|
const parsed = eventInput.parse(input);
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
if (!(await canSeeCalendar(user.id, parsed.calendarId))) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
const [event] = await db
|
||||||
|
.insert(calendarEvents)
|
||||||
|
.values({
|
||||||
|
...parsed,
|
||||||
|
ownerId: user.id,
|
||||||
|
location: parsed.location || null,
|
||||||
|
notes: parsed.notes || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!event) throw new Error("Event was not created");
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
startAt: event.startAt.toISOString(),
|
||||||
|
endAt: event.endAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
|
||||||
|
const parsed = z
|
||||||
|
.object({ id: z.string().uuid() })
|
||||||
|
.and(eventInput.partial())
|
||||||
|
.parse(input);
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ calendarId: calendarEvents.calendarId })
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(eq(calendarEvents.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Event not found");
|
||||||
|
const calendarId = parsed.calendarId ?? existing.calendarId;
|
||||||
|
if (!(await canSeeCalendar(user.id, calendarId))) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(calendarEvents)
|
||||||
|
.set({
|
||||||
|
calendarId,
|
||||||
|
title: parsed.title,
|
||||||
|
startAt: parsed.startAt,
|
||||||
|
endAt: parsed.endAt,
|
||||||
|
allDay: parsed.allDay,
|
||||||
|
location: parsed.location === undefined ? undefined : parsed.location || null,
|
||||||
|
notes: parsed.notes === undefined ? undefined : parsed.notes || null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(calendarEvents.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(input: { id: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ calendarId: calendarEvents.calendarId })
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(eq(calendarEvents.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) return;
|
||||||
|
if (!(await canSeeCalendar(user.id, existing.calendarId))) throw new Error("Forbidden");
|
||||||
|
await db.delete(calendarEvents).where(eq(calendarEvents.id, parsed.id));
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertOwnsCalendar(userId: string, calendarId: string) {
|
||||||
|
const [calendar] = await db
|
||||||
|
.select({ id: calendars.id })
|
||||||
|
.from(calendars)
|
||||||
|
.where(and(eq(calendars.id, calendarId), eq(calendars.ownerId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!calendar) throw new Error("Forbidden");
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||||
|
import { calendars } from "../schema";
|
||||||
|
|
||||||
|
const HOME_COLOR = "#2563eb";
|
||||||
|
const PERSONAL_COLOR = "#16a34a";
|
||||||
|
|
||||||
|
export async function ensureDefaultCalendarsForMembership({
|
||||||
|
householdId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
householdId: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
await ensureHomeCalendar(householdId);
|
||||||
|
await ensurePersonalCalendar({ householdId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDefaultCalendars() {
|
||||||
|
const householdRows = await db.select().from(households);
|
||||||
|
for (const household of householdRows) {
|
||||||
|
await ensureHomeCalendar(household.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await db
|
||||||
|
.select({ householdId: householdMembers.householdId, userId: users.id })
|
||||||
|
.from(householdMembers)
|
||||||
|
.innerJoin(users, eq(householdMembers.userId, users.id));
|
||||||
|
|
||||||
|
for (const membership of memberships) {
|
||||||
|
await ensurePersonalCalendar(membership);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureHomeCalendar(householdId: string) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: calendars.id })
|
||||||
|
.from(calendars)
|
||||||
|
.where(and(eq(calendars.householdId, householdId), eq(calendars.name, "Home")))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const [owner] = await db
|
||||||
|
.select({ userId: householdMembers.userId })
|
||||||
|
.from(householdMembers)
|
||||||
|
.where(eq(householdMembers.householdId, householdId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!owner) return;
|
||||||
|
|
||||||
|
await db.insert(calendars).values({
|
||||||
|
householdId,
|
||||||
|
ownerId: owner.userId,
|
||||||
|
name: "Home",
|
||||||
|
color: HOME_COLOR,
|
||||||
|
visibility: "household",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePersonalCalendar({
|
||||||
|
householdId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
householdId: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: calendars.id })
|
||||||
|
.from(calendars)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendars.householdId, householdId),
|
||||||
|
eq(calendars.ownerId, userId),
|
||||||
|
eq(calendars.name, "Personal"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
await db.insert(calendars).values({
|
||||||
|
householdId,
|
||||||
|
ownerId: userId,
|
||||||
|
name: "Personal",
|
||||||
|
color: PERSONAL_COLOR,
|
||||||
|
visibility: "private",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { householdMembers } from "@/modules/_core/schema";
|
||||||
|
import { calendarEvents, calendars } from "../schema";
|
||||||
|
|
||||||
|
export type CalendarDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
visibility: "private" | "household";
|
||||||
|
ownerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarEventDto = {
|
||||||
|
id: string;
|
||||||
|
calendarId: string;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
allDay: boolean;
|
||||||
|
location: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listEventsSchema = z.object({
|
||||||
|
from: z.coerce.date(),
|
||||||
|
to: z.coerce.date(),
|
||||||
|
calendarIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function canSeeCalendar(userId: string, calendarId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
calendarId: calendars.id,
|
||||||
|
visibility: calendars.visibility,
|
||||||
|
ownerId: calendars.ownerId,
|
||||||
|
memberUserId: householdMembers.userId,
|
||||||
|
})
|
||||||
|
.from(calendars)
|
||||||
|
.leftJoin(
|
||||||
|
householdMembers,
|
||||||
|
and(
|
||||||
|
eq(householdMembers.householdId, calendars.householdId),
|
||||||
|
eq(householdMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(eq(calendars.id, calendarId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) return false;
|
||||||
|
if (row.visibility === "private") return row.ownerId === userId;
|
||||||
|
return row.memberUserId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCalendars(): Promise<CalendarDto[]> {
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: calendars.id,
|
||||||
|
name: calendars.name,
|
||||||
|
color: calendars.color,
|
||||||
|
visibility: calendars.visibility,
|
||||||
|
ownerId: calendars.ownerId,
|
||||||
|
})
|
||||||
|
.from(calendars)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendars.householdId, household.id),
|
||||||
|
or(eq(calendars.visibility, "household"), eq(calendars.ownerId, user.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(calendars.name));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
visibility: row.visibility as "private" | "household",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvents(input: {
|
||||||
|
from: Date | string;
|
||||||
|
to: Date | string;
|
||||||
|
calendarIds: "all" | string[];
|
||||||
|
}): Promise<CalendarEventDto[]> {
|
||||||
|
const parsed = listEventsSchema.parse(input);
|
||||||
|
const visibleCalendars = await listCalendars();
|
||||||
|
const visibleIds = new Set(visibleCalendars.map((calendar) => calendar.id));
|
||||||
|
const calendarIds =
|
||||||
|
parsed.calendarIds === "all"
|
||||||
|
? [...visibleIds]
|
||||||
|
: parsed.calendarIds.filter((id) => visibleIds.has(id));
|
||||||
|
|
||||||
|
if (calendarIds.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: calendarEvents.id,
|
||||||
|
calendarId: calendarEvents.calendarId,
|
||||||
|
title: calendarEvents.title,
|
||||||
|
startAt: calendarEvents.startAt,
|
||||||
|
endAt: calendarEvents.endAt,
|
||||||
|
allDay: calendarEvents.allDay,
|
||||||
|
location: calendarEvents.location,
|
||||||
|
notes: calendarEvents.notes,
|
||||||
|
})
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(calendarEvents.calendarId, calendarIds),
|
||||||
|
lte(calendarEvents.startAt, parsed.to),
|
||||||
|
gte(calendarEvents.endAt, parsed.from),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(calendarEvents.startAt));
|
||||||
|
|
||||||
|
return rows.map(toEventDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCalendars(query: string, householdId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: calendars.id, name: calendars.name })
|
||||||
|
.from(calendars)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendars.householdId, householdId),
|
||||||
|
sql`${calendars.name} ilike ${`%${query}%`}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.name,
|
||||||
|
url: `/calendar?id=${row.id}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEvents(query: string, householdId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: calendarEvents.id,
|
||||||
|
title: calendarEvents.title,
|
||||||
|
location: calendarEvents.location,
|
||||||
|
notes: calendarEvents.notes,
|
||||||
|
})
|
||||||
|
.from(calendarEvents)
|
||||||
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendars.householdId, householdId),
|
||||||
|
or(
|
||||||
|
sql`${calendarEvents.title} ilike ${`%${query}%`}`,
|
||||||
|
sql`${calendarEvents.location} ilike ${`%${query}%`}`,
|
||||||
|
sql`${calendarEvents.notes} ilike ${`%${query}%`}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
url: `/calendar/events/${row.id}`,
|
||||||
|
excerpt: row.location ?? row.notes ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEventDto(row: {
|
||||||
|
id: string;
|
||||||
|
calendarId: string;
|
||||||
|
title: string;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
location: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}): CalendarEventDto {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
startAt: row.startAt.toISOString(),
|
||||||
|
endAt: row.endAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("calendar CRUD happy path", async ({ page }) => {
|
||||||
|
const suffix = Date.now().toString();
|
||||||
|
const calendarName = `E2E Calendar ${suffix}`;
|
||||||
|
const renamedCalendarName = `E2E Renamed ${suffix}`;
|
||||||
|
const eventTitle = `E2E Event ${suffix}`;
|
||||||
|
const editedTitle = `E2E Edited ${suffix}`;
|
||||||
|
|
||||||
|
await page.goto("/calendar");
|
||||||
|
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Name").fill(calendarName);
|
||||||
|
await page.getByRole("button", { name: "Create calendar" }).click();
|
||||||
|
await expect(page.getByDisplayValue(calendarName)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New event" }).click();
|
||||||
|
await page.getByLabel("Title").fill(eventTitle);
|
||||||
|
await page.getByLabel("Start").fill("2026-06-15T09:00");
|
||||||
|
await page.getByLabel("End").fill("2026-06-15T10:00");
|
||||||
|
await page.getByRole("button", { name: "Save event" }).click();
|
||||||
|
await expect(page.getByText(eventTitle)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText(eventTitle).click();
|
||||||
|
await page.getByLabel("Title").fill(editedTitle);
|
||||||
|
await page.getByRole("button", { name: "Save event" }).click();
|
||||||
|
await expect(page.getByText(editedTitle)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText(editedTitle).click();
|
||||||
|
await page.getByRole("button", { name: "Delete event" }).click();
|
||||||
|
await expect(page.getByText(editedTitle)).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByLabel(`${calendarName} name`).fill(renamedCalendarName);
|
||||||
|
await expect(page.getByDisplayValue(renamedCalendarName)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: `Delete ${renamedCalendarName}` }).click();
|
||||||
|
await expect(page.getByDisplayValue(renamedCalendarName)).toBeHidden();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user