feat: p2 batch 28-31 reminders lists comments bang stats
Calendar events support multiple reminder offsets with presets and per-user defaults. Lists index adds inline task entry and list property editing. Generic entity comments on list detail. New bangs.stats dashboard widget. Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
This commit is contained in:
@@ -23,6 +23,14 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
|||||||
|
|
||||||
- **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
|
- **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
|
||||||
|
|
||||||
|
- **89 — Calendar reminders overhaul** (Gitea #28). Multiple reminders per event via `syncCalendarEventReminders`; `ReminderPicker` with presets + custom offsets; migration `0022_multiple_reminders.sql` drops one-per-entity unique index, adds `offset_minutes` + `users.default_event_reminder_offsets`. Settings → Notifications default reminders editor. Create/edit/quick-add calendar flows updated; drag-reschedule resyncs existing offsets.
|
||||||
|
|
||||||
|
- **90 — Lists index inline add + edit** (Gitea #29). `/lists` cards: inline task add row, pencil edit for name/type via `updateListProperties`.
|
||||||
|
|
||||||
|
- **91 — Comments on lists and tasks** (Gitea #30). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`.
|
||||||
|
|
||||||
|
- **92 — Bang stats dashboard widget** (Gitea #31). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
|
||||||
|
|
||||||
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
||||||
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
||||||
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
||||||
@@ -73,7 +81,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
|
|||||||
|
|
||||||
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19.
|
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19.
|
||||||
|
|
||||||
**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
|
**How to resume:** P2 batch #28–#31 complete. Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`). Run `pnpm db:migrate` for migrations `0022` and `0023`.
|
||||||
|
|
||||||
## Development login/testing notes
|
## Development login/testing notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 89 — Calendar reminders overhaul
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the single "Remind me 30 min before" checkbox with multiple reminders, presets, and custom offsets.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Store multiple reminder rows per calendar event in the generic `reminders` table (drop one-per-entity unique constraint).
|
||||||
|
- Offsets are minutes before event start; presets + custom value/unit in UI.
|
||||||
|
- Per-user default offsets live on `users.default_event_reminder_offsets` (jsonb, default `[30]`).
|
||||||
|
- Notes/garden keep single-reminder behavior via delete-then-insert in `scheduleReminder`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 41 (reminders engine)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migration: drop `reminders_entity_unique`, add `offset_minutes`, add user default column.
|
||||||
|
- `_core/reminders.ts`: `syncRemindersForEntity`, refactor `scheduleReminder`.
|
||||||
|
- Shared `ReminderPicker` component + offset helpers.
|
||||||
|
- Calendar create/edit + quick-add dialogs; sync reminders on create/update/delete.
|
||||||
|
- Optional: settings field for default reminder offsets.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Recurring reminders, snooze.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Users can add/remove multiple reminders on an event
|
||||||
|
- [x] Preset list and custom offset both work
|
||||||
|
- [x] Reminders fire via existing notification bus
|
||||||
|
- [x] Per-user default reminder preferences (stored + applied on new events)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#28](https://gitea.ginnoir.com/ginnoir/famapp/issues/28)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 90 — Lists index: inline task add + list property edit
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
From `/lists`, add tasks and edit list properties without opening the full list page.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Inline add input at the bottom of each list card (Enter to submit, same as detail page).
|
||||||
|
- Inline edit for list name and type via a small edit control on the card header.
|
||||||
|
- Reuse existing `addItem`, `renameList`, and extended `updateList` server actions.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 11 (lists module)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Extend `listUpdateInput` to allow `type` changes.
|
||||||
|
- Update `lists-index.tsx` with add-item row and list property editor.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Add an item to a list from the index
|
||||||
|
- [x] Edit list properties from the index
|
||||||
|
- [x] Changes persist and match detail-page behavior
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#29](https://gitea.ginnoir.com/ginnoir/famapp/issues/29)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 91 — Comments on lists and tasks
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Household members can comment on lists and individual list items using a reusable comment component.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Generic `comments` table in `_core` keyed by `(entity_type, entity_id)`.
|
||||||
|
- Reusable `EntityComments` client component in `src/components/comments/`.
|
||||||
|
- Wired on list detail: list-level thread + per-item expandable comments.
|
||||||
|
- Entity-generic design so notes/other modules can adopt later without schema changes.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 11 (lists module)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migration: `comments` table.
|
||||||
|
- `_core/comments.ts`: list, add, delete (author-only).
|
||||||
|
- `EntityComments` UI.
|
||||||
|
- Lists detail page integration for `lists.list` and `lists.item`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Comments on notes/calendar (future adoption of same component).
|
||||||
|
- Rich-text comments (plain text only).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Comment on a list
|
||||||
|
- [x] Comment on a task/item
|
||||||
|
- [x] Household members see each other's comments
|
||||||
|
- [x] Component is entity-generic (documented in brief)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#30](https://gitea.ginnoir.com/ginnoir/famapp/issues/30)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 92 — Bang stats dashboard widget
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A second bangs dashboard widget showing monthly/yearly counts and average days between bangs.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Widget id `bangs.stats`, separate from `bangs.counter`.
|
||||||
|
- Aggregates computed server-side from `bang_events.occurred_on`.
|
||||||
|
- Works alongside edit/delete from task 83.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- Bangs module, 83 (edit/delete)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `getBangAggregates` query.
|
||||||
|
- `BangStatsWidget` component.
|
||||||
|
- Manifest registration.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Widget registered and placeable on dashboards
|
||||||
|
- [x] Shows monthly and yearly counts
|
||||||
|
- [x] Shows average days between bangs
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#31](https://gitea.ginnoir.com/ginnoir/famapp/issues/31)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX IF EXISTS "reminders_entity_unique";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminders" ADD COLUMN "offset_minutes" integer;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "reminders_entity_idx" ON "reminders" USING btree ("entity_type","entity_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "default_event_reminder_offsets" jsonb DEFAULT '[30]'::jsonb NOT NULL;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"author_id" uuid NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id","created_at");
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||||
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
import {
|
||||||
|
getDefaultEventReminderOffsets,
|
||||||
|
listCalendars,
|
||||||
|
listEvents,
|
||||||
|
} from "@/modules/calendar/server/queries";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import type { CalView } from "@/modules/_core/themes";
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
|
|
||||||
@@ -10,10 +14,11 @@ export default async function CalendarPage() {
|
|||||||
const to = new Date(now);
|
const to = new Date(now);
|
||||||
to.setMonth(to.getMonth() + 10);
|
to.setMonth(to.getMonth() + 10);
|
||||||
|
|
||||||
const [{ user }, calendars, events] = await Promise.all([
|
const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([
|
||||||
getCurrentSession(),
|
getCurrentSession(),
|
||||||
listCalendars(),
|
listCalendars(),
|
||||||
listEvents({ from, to, calendarIds: "all" }),
|
listEvents({ from, to, calendarIds: "all" }),
|
||||||
|
getDefaultEventReminderOffsets(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +26,7 @@ export default async function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
events={events}
|
events={events}
|
||||||
defaultView={user.themeCalView as CalView}
|
defaultView={user.themeCalView as CalView}
|
||||||
|
defaultReminderOffsets={defaultReminderOffsets}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { ListDetail } from "@/modules/lists/components/list-detail";
|
import { ListDetail } from "@/modules/lists/components/list-detail";
|
||||||
import { getList } from "@/modules/lists/server/queries";
|
import { getList } from "@/modules/lists/server/queries";
|
||||||
|
|
||||||
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const list = await getList(id).catch(() => null);
|
const [{ user }, list] = await Promise.all([getCurrentSession(), getList(id).catch(() => null)]);
|
||||||
if (!list) notFound();
|
if (!list) notFound();
|
||||||
|
|
||||||
return <ListDetail initialList={list} />;
|
return <ListDetail initialList={list} currentUserId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { revokeShareLinkAction } from "./actions";
|
|||||||
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
|
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
|
||||||
import { ApiTokenSettings } from "@/components/api-token-settings";
|
import { ApiTokenSettings } from "@/components/api-token-settings";
|
||||||
import { AssistantOptIn } from "@/components/assistant-opt-in";
|
import { AssistantOptIn } from "@/components/assistant-opt-in";
|
||||||
|
import { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting";
|
||||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
import { listLists } from "@/modules/lists/server/queries";
|
import { listLists } from "@/modules/lists/server/queries";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -61,7 +62,12 @@ export default async function SettingsPage({
|
|||||||
{section === "household" && <HouseholdSection household={household} user={user} />}
|
{section === "household" && <HouseholdSection household={household} user={user} />}
|
||||||
{section === "sharing" && <SharingSection />}
|
{section === "sharing" && <SharingSection />}
|
||||||
{section === "notifications" && (
|
{section === "notifications" && (
|
||||||
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
<NotificationsSection
|
||||||
|
user={user}
|
||||||
|
defaultEventReminderOffsets={user.defaultEventReminderOffsets ?? [30]}
|
||||||
|
vapidKey={vapidKey}
|
||||||
|
ntfyConfigured={ntfyConfigured}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{section === "calendars" && <CalendarsAndListsSection />}
|
{section === "calendars" && <CalendarsAndListsSection />}
|
||||||
{section === "appearance" && <AppearanceSection user={user} />}
|
{section === "appearance" && <AppearanceSection user={user} />}
|
||||||
@@ -185,10 +191,12 @@ async function SharingSection() {
|
|||||||
|
|
||||||
function NotificationsSection({
|
function NotificationsSection({
|
||||||
user,
|
user,
|
||||||
|
defaultEventReminderOffsets,
|
||||||
vapidKey,
|
vapidKey,
|
||||||
ntfyConfigured,
|
ntfyConfigured,
|
||||||
}: {
|
}: {
|
||||||
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
||||||
|
defaultEventReminderOffsets: number[];
|
||||||
vapidKey: string;
|
vapidKey: string;
|
||||||
ntfyConfigured: boolean;
|
ntfyConfigured: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -216,6 +224,18 @@ function NotificationsSection({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Default event reminders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DefaultEventRemindersSetting
|
||||||
|
key={defaultEventReminderOffsets.join(",")}
|
||||||
|
initialOffsets={defaultEventReminderOffsets}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
|
import { users } from "@/modules/_core/schema";
|
||||||
|
|
||||||
|
const offsetsSchema = z.array(z.number().int().min(0)).max(20);
|
||||||
|
|
||||||
|
export async function setDefaultEventReminderOffsets(offsets: number[]) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = normalizeReminderOffsets(offsetsSchema.parse(offsets));
|
||||||
|
|
||||||
|
await db.update(users).set({ defaultEventReminderOffsets: parsed }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageSquare, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
currentUserId: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
|
||||||
|
const [open, setOpen] = useState(!compact);
|
||||||
|
const [comments, setComments] = useState<CommentDto[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
listComments(entityType, entityId)
|
||||||
|
.then(setComments)
|
||||||
|
.catch(() => setComments([]));
|
||||||
|
}, [entityType, entityId, open]);
|
||||||
|
|
||||||
|
function submitComment() {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const created = await addComment({ entityType, entityId, body });
|
||||||
|
setComments((current) => [...current, created]);
|
||||||
|
setDraft("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComment(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteComment({ id });
|
||||||
|
setComments((current) => current.filter((comment) => comment.id !== id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
|
||||||
|
{compact ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
{open ? "Hide comments" : "Comments"}
|
||||||
|
{comments.length > 0 ? ` (${comments.length})` : ""}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="eyebrow mb-2">Comments</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<li
|
||||||
|
key={comment.id}
|
||||||
|
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-[var(--ink-mute)]">
|
||||||
|
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
</div>
|
||||||
|
{comment.authorId === currentUserId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Delete comment"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => removeComment(comment.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
disabled={isPending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitComment();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!draft.trim() || isPending}
|
||||||
|
onClick={submitComment}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions";
|
||||||
|
|
||||||
|
export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) {
|
||||||
|
const [saved, setSaved] = useState(initialOffsets);
|
||||||
|
const [offsets, setOffsets] = useState(initialOffsets);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setDefaultEventReminderOffsets(offsets);
|
||||||
|
setSaved(offsets);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="muted text-[13px]">
|
||||||
|
Applied when you create a new calendar event. You can still customize reminders per event.
|
||||||
|
</p>
|
||||||
|
<ReminderPicker offsets={offsets} onChange={setOffsets} disabled={isPending} />
|
||||||
|
<Button type="button" size="sm" disabled={!dirty || isPending} onClick={handleSave}>
|
||||||
|
{isPending ? "Saving…" : "Save defaults"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,7 +23,11 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { createEvent } from "@/modules/calendar/server/actions";
|
import { createEvent } from "@/modules/calendar/server/actions";
|
||||||
import { richTextToPlainText } from "@/components/rich-text";
|
import { richTextToPlainText } from "@/components/rich-text";
|
||||||
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries";
|
import {
|
||||||
|
getDefaultEventReminderOffsets,
|
||||||
|
listCalendars,
|
||||||
|
type CalendarDto,
|
||||||
|
} from "@/modules/calendar/server/queries";
|
||||||
|
|
||||||
const RichTextEditor = dynamic(
|
const RichTextEditor = dynamic(
|
||||||
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
||||||
@@ -59,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [remind, setRemind] = useState(true);
|
const [reminderOffsets, setReminderOffsets] = useState<number[]>([30]);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
setCalendars(rows);
|
setCalendars(rows);
|
||||||
setCalendarId(rows[0]?.id ?? "");
|
setCalendarId(rows[0]?.id ?? "");
|
||||||
});
|
});
|
||||||
|
getDefaultEventReminderOffsets().then(setReminderOffsets);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calendarItems = useMemo(
|
const calendarItems = useMemo(
|
||||||
@@ -86,7 +92,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
allDay: false,
|
allDay: false,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
notes: richTextToPlainText(notes) ? notes : null,
|
notes: richTextToPlainText(notes) ? notes : null,
|
||||||
remindMinutesBefore: remind ? 30 : null,
|
reminderOffsets,
|
||||||
});
|
});
|
||||||
onDone();
|
onDone();
|
||||||
});
|
});
|
||||||
@@ -171,15 +177,11 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
placeholder="Add details…"
|
placeholder="Add details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<ReminderPicker
|
||||||
<input
|
offsets={reminderOffsets}
|
||||||
type="checkbox"
|
onChange={setReminderOffsets}
|
||||||
className="size-4 cursor-pointer"
|
disabled={isPending}
|
||||||
checked={remind}
|
/>
|
||||||
onChange={(e) => setRemind(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Remind me 30 min before
|
|
||||||
</label>
|
|
||||||
</form>
|
</form>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } 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 {
|
||||||
|
REMINDER_PRESETS,
|
||||||
|
customOffsetToMinutes,
|
||||||
|
formatReminderOffset,
|
||||||
|
normalizeReminderOffsets,
|
||||||
|
type ReminderOffsetUnit,
|
||||||
|
} from "@/lib/reminder-offsets";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
offsets: number[];
|
||||||
|
onChange: (offsets: number[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReminderPicker({ offsets, onChange, disabled }: Props) {
|
||||||
|
const [customValue, setCustomValue] = useState("1");
|
||||||
|
const [customUnit, setCustomUnit] = useState<ReminderOffsetUnit>("hours");
|
||||||
|
const normalized = useMemo(() => normalizeReminderOffsets(offsets), [offsets]);
|
||||||
|
|
||||||
|
function addOffset(minutes: number) {
|
||||||
|
onChange(normalizeReminderOffsets([...normalized, minutes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOffset(minutes: number) {
|
||||||
|
onChange(normalized.filter((o) => o !== minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustom() {
|
||||||
|
const parsed = Number.parseInt(customValue, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||||
|
addOffset(customOffsetToMinutes(parsed, customUnit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const availablePresets = REMINDER_PRESETS.filter((p) => !normalized.includes(p.offsetMinutes));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Reminders</Label>
|
||||||
|
{normalized.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-[var(--ink-mute)]">No reminders set.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{normalized.map((offset) => (
|
||||||
|
<li
|
||||||
|
key={offset}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hair)] px-2.5 py-1.5 text-[13px]"
|
||||||
|
>
|
||||||
|
<span>{formatReminderOffset(offset)}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove ${formatReminderOffset(offset)}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeOffset(offset)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availablePresets.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availablePresets.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.offsetMinutes}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => addOffset(preset.offsetMinutes)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[80px_120px_auto] sm:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-custom-value">Custom</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-custom-value"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={customValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-custom-unit">Unit</Label>
|
||||||
|
<Select
|
||||||
|
value={customUnit}
|
||||||
|
onValueChange={(value) => setCustomUnit(value as ReminderOffsetUnit)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="reminder-custom-unit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="minutes">Minutes</SelectItem>
|
||||||
|
<SelectItem value="hours">Hours</SelectItem>
|
||||||
|
<SelectItem value="days">Days</SelectItem>
|
||||||
|
<SelectItem value="weeks">Weeks</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" disabled={disabled} onClick={addCustom}>
|
||||||
|
Add custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type ReminderOffsetUnit = "minutes" | "hours" | "days" | "weeks";
|
||||||
|
|
||||||
|
export type ReminderPreset = {
|
||||||
|
label: string;
|
||||||
|
offsetMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REMINDER_PRESETS: ReminderPreset[] = [
|
||||||
|
{ label: "At event time", offsetMinutes: 0 },
|
||||||
|
{ label: "5 minutes before", offsetMinutes: 5 },
|
||||||
|
{ label: "15 minutes before", offsetMinutes: 15 },
|
||||||
|
{ label: "30 minutes before", offsetMinutes: 30 },
|
||||||
|
{ label: "1 hour before", offsetMinutes: 60 },
|
||||||
|
{ label: "2 hours before", offsetMinutes: 120 },
|
||||||
|
{ label: "1 day before", offsetMinutes: 1440 },
|
||||||
|
{ label: "2 days before", offsetMinutes: 2880 },
|
||||||
|
{ label: "1 week before", offsetMinutes: 10080 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatReminderOffset(offsetMinutes: number): string {
|
||||||
|
const preset = REMINDER_PRESETS.find((p) => p.offsetMinutes === offsetMinutes);
|
||||||
|
if (preset) return preset.label;
|
||||||
|
|
||||||
|
if (offsetMinutes === 0) return "At event time";
|
||||||
|
if (offsetMinutes % 10080 === 0) {
|
||||||
|
const weeks = offsetMinutes / 10080;
|
||||||
|
return weeks === 1 ? "1 week before" : `${weeks} weeks before`;
|
||||||
|
}
|
||||||
|
if (offsetMinutes % 1440 === 0) {
|
||||||
|
const days = offsetMinutes / 1440;
|
||||||
|
return days === 1 ? "1 day before" : `${days} days before`;
|
||||||
|
}
|
||||||
|
if (offsetMinutes % 60 === 0) {
|
||||||
|
const hours = offsetMinutes / 60;
|
||||||
|
return hours === 1 ? "1 hour before" : `${hours} hours before`;
|
||||||
|
}
|
||||||
|
return offsetMinutes === 1 ? "1 minute before" : `${offsetMinutes} minutes before`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customOffsetToMinutes(value: number, unit: ReminderOffsetUnit): number {
|
||||||
|
const safe = Math.max(0, Math.floor(value));
|
||||||
|
switch (unit) {
|
||||||
|
case "minutes":
|
||||||
|
return safe;
|
||||||
|
case "hours":
|
||||||
|
return safe * 60;
|
||||||
|
case "days":
|
||||||
|
return safe * 1440;
|
||||||
|
case "weeks":
|
||||||
|
return safe * 10080;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReminderOffsets(offsets: number[]): number[] {
|
||||||
|
return [...new Set(offsets.filter((n) => Number.isFinite(n) && n >= 0))].toSorted(
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireAtForEventStart(startAt: Date, offsetMinutes: number): Date {
|
||||||
|
return new Date(startAt.getTime() - offsetMinutes * 60_000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { comments, users } from "./schema";
|
||||||
|
|
||||||
|
export type CommentDto = {
|
||||||
|
id: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
body: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commentInput = z.object({
|
||||||
|
entityType: z.string().trim().min(1).max(80),
|
||||||
|
entityId: z.string().uuid(),
|
||||||
|
body: z.string().trim().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listComments(entityType: string, entityId: string): Promise<CommentDto[]> {
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const parsedEntityId = z.string().uuid().parse(entityId);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
entityType: comments.entityType,
|
||||||
|
entityId: comments.entityId,
|
||||||
|
body: comments.body,
|
||||||
|
authorId: comments.authorId,
|
||||||
|
authorName: users.name,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.innerJoin(users, eq(comments.authorId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(comments.householdId, household.id),
|
||||||
|
eq(comments.entityType, entityType),
|
||||||
|
eq(comments.entityId, parsedEntityId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(comments.createdAt));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
body: row.body,
|
||||||
|
authorId: row.authorId,
|
||||||
|
authorName: row.authorName,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addComment(input: z.input<typeof commentInput>): Promise<CommentDto> {
|
||||||
|
const parsed = commentInput.parse(input);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(comments)
|
||||||
|
.values({
|
||||||
|
householdId: household.id,
|
||||||
|
entityType: parsed.entityType,
|
||||||
|
entityId: parsed.entityId,
|
||||||
|
authorId: user.id,
|
||||||
|
body: parsed.body,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) throw new Error("Comment was not created");
|
||||||
|
|
||||||
|
revalidatePath("/lists");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
body: row.body,
|
||||||
|
authorId: row.authorId,
|
||||||
|
authorName: user.name,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(input: { id: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: comments.id, authorId: comments.authorId })
|
||||||
|
.from(comments)
|
||||||
|
.where(and(eq(comments.id, parsed.id), eq(comments.householdId, household.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Comment not found");
|
||||||
|
if (existing.authorId !== user.id) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
await db.delete(comments).where(eq(comments.id, parsed.id));
|
||||||
|
revalidatePath("/lists");
|
||||||
|
}
|
||||||
@@ -24,4 +24,13 @@ export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
|||||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||||
export { sendPush, sendPushToEndpoint } from "./push";
|
export { sendPush, sendPushToEndpoint } from "./push";
|
||||||
export { notify } from "./notify";
|
export { notify } from "./notify";
|
||||||
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
export {
|
||||||
|
scheduleReminder,
|
||||||
|
cancelReminder,
|
||||||
|
listReminders,
|
||||||
|
listReminderOffsets,
|
||||||
|
syncRemindersForEntity,
|
||||||
|
syncCalendarEventReminders,
|
||||||
|
startReminderWorker,
|
||||||
|
} from "./reminders";
|
||||||
|
export { listComments, addComment, deleteComment, type CommentDto } from "./comments";
|
||||||
|
|||||||
+115
-17
@@ -1,11 +1,19 @@
|
|||||||
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
import { fireAtForEventStart } from "@/lib/reminder-offsets";
|
||||||
import { reminders } from "./schema";
|
import { reminders } from "./schema";
|
||||||
import { notify } from "./notify";
|
import { notify } from "./notify";
|
||||||
|
|
||||||
const REMINDER_LOCK_KEY = 7_777_777;
|
const REMINDER_LOCK_KEY = 7_777_777;
|
||||||
|
|
||||||
|
type ReminderInput = {
|
||||||
|
fireAt: Date;
|
||||||
|
offsetMinutes?: number | null;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function scheduleReminder(input: {
|
export async function scheduleReminder(input: {
|
||||||
householdId: string;
|
householdId: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -15,30 +23,92 @@ export async function scheduleReminder(input: {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
offsetMinutes?: number | null;
|
||||||
}) {
|
}) {
|
||||||
await db
|
await db
|
||||||
.insert(reminders)
|
.delete(reminders)
|
||||||
.values({
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminders.entityType, input.entityType),
|
||||||
|
eq(reminders.entityId, input.entityId),
|
||||||
|
isNull(reminders.firedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.insert(reminders).values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
fireAt: input.fireAt,
|
||||||
|
offsetMinutes: input.offsetMinutes ?? null,
|
||||||
|
channel: input.channel ?? "auto",
|
||||||
|
title: input.title ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
firedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncRemindersForEntity(input: {
|
||||||
|
householdId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
createdBy: string;
|
||||||
|
channel?: string;
|
||||||
|
reminders: ReminderInput[];
|
||||||
|
}) {
|
||||||
|
await db
|
||||||
|
.delete(reminders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminders.entityType, input.entityType),
|
||||||
|
eq(reminders.entityId, input.entityId),
|
||||||
|
isNull(reminders.firedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rows = input.reminders.filter((r) => r.fireAt > now);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
await db.insert(reminders).values(
|
||||||
|
rows.map((row) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
fireAt: input.fireAt,
|
fireAt: row.fireAt,
|
||||||
|
offsetMinutes: row.offsetMinutes ?? null,
|
||||||
channel: input.channel ?? "auto",
|
channel: input.channel ?? "auto",
|
||||||
title: input.title ?? null,
|
title: row.title ?? null,
|
||||||
body: input.body ?? null,
|
body: row.body ?? null,
|
||||||
createdBy: input.createdBy,
|
createdBy: input.createdBy,
|
||||||
firedAt: null,
|
firedAt: null,
|
||||||
})
|
})),
|
||||||
.onConflictDoUpdate({
|
);
|
||||||
target: [reminders.entityType, reminders.entityId],
|
}
|
||||||
set: {
|
|
||||||
fireAt: input.fireAt,
|
export async function syncCalendarEventReminders(input: {
|
||||||
title: input.title ?? null,
|
householdId: string;
|
||||||
body: input.body ?? null,
|
eventId: string;
|
||||||
firedAt: null,
|
eventTitle: string;
|
||||||
createdBy: input.createdBy,
|
startAt: Date;
|
||||||
},
|
createdBy: string;
|
||||||
});
|
offsetMinutes: number[];
|
||||||
|
}) {
|
||||||
|
const remindersToSchedule = input.offsetMinutes.map((offset) => ({
|
||||||
|
fireAt: fireAtForEventStart(input.startAt, offset),
|
||||||
|
offsetMinutes: offset,
|
||||||
|
title: input.eventTitle,
|
||||||
|
body: formatReminderOffsetBody(offset, input.eventTitle),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await syncRemindersForEntity({
|
||||||
|
householdId: input.householdId,
|
||||||
|
entityType: "calendar.event",
|
||||||
|
entityId: input.eventId,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
reminders: remindersToSchedule,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelReminder(entityType: string, entityId: string) {
|
export async function cancelReminder(entityType: string, entityId: string) {
|
||||||
@@ -54,6 +124,29 @@ export async function listReminders(entityType: string, entityId: string) {
|
|||||||
.where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId)));
|
.where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listReminderOffsets(entityType: string, entityId: string): Promise<number[]> {
|
||||||
|
const rows = await listReminders(entityType, entityId);
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.offsetMinutes != null && row.firedAt == null)
|
||||||
|
.map((row) => row.offsetMinutes!)
|
||||||
|
.toSorted((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReminderOffsetBody(offsetMinutes: number, title: string): string {
|
||||||
|
if (offsetMinutes === 0) return `${title} is starting now`;
|
||||||
|
if (offsetMinutes % 1440 === 0) {
|
||||||
|
const days = offsetMinutes / 1440;
|
||||||
|
return days === 1 ? `${title} starts in 1 day` : `${title} starts in ${days} days`;
|
||||||
|
}
|
||||||
|
if (offsetMinutes % 60 === 0) {
|
||||||
|
const hours = offsetMinutes / 60;
|
||||||
|
return hours === 1 ? `${title} starts in 1 hour` : `${title} starts in ${hours} hours`;
|
||||||
|
}
|
||||||
|
return offsetMinutes === 1
|
||||||
|
? `${title} starts in 1 minute`
|
||||||
|
: `${title} starts in ${offsetMinutes} minutes`;
|
||||||
|
}
|
||||||
|
|
||||||
async function tickReminders() {
|
async function tickReminders() {
|
||||||
let dueReminders: (typeof reminders.$inferSelect)[] = [];
|
let dueReminders: (typeof reminders.$inferSelect)[] = [];
|
||||||
|
|
||||||
@@ -94,7 +187,12 @@ async function tickReminders() {
|
|||||||
await notify(reminder.createdBy, {
|
await notify(reminder.createdBy, {
|
||||||
title: reminder.title ?? "Reminder",
|
title: reminder.title ?? "Reminder",
|
||||||
body: reminder.body ?? "You have a reminder",
|
body: reminder.body ?? "You have a reminder",
|
||||||
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
|
url:
|
||||||
|
reminder.entityType === "notes.note"
|
||||||
|
? `/notes/${reminder.entityId}`
|
||||||
|
: reminder.entityType === "calendar.event"
|
||||||
|
? "/calendar"
|
||||||
|
: "/",
|
||||||
channels: ["push", "inapp"],
|
channels: ["push", "inapp"],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const users = pgTable("users", {
|
|||||||
notifInApp: boolean("notif_inapp").notNull().default(true),
|
notifInApp: boolean("notif_inapp").notNull().default(true),
|
||||||
notifNtfy: boolean("notif_ntfy").notNull().default(false),
|
notifNtfy: boolean("notif_ntfy").notNull().default(false),
|
||||||
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
|
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
|
||||||
|
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
|
||||||
|
.notNull()
|
||||||
|
.$type<number[]>()
|
||||||
|
.default([30]),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,12 +174,13 @@ export const reminders = pgTable(
|
|||||||
channel: text("channel").notNull().default("auto"),
|
channel: text("channel").notNull().default("auto"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
body: text("body"),
|
body: text("body"),
|
||||||
|
offsetMinutes: integer("offset_minutes"),
|
||||||
firedAt: timestamp("fired_at", { withTimezone: true }),
|
firedAt: timestamp("fired_at", { withTimezone: true }),
|
||||||
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
uniqueIndex("reminders_entity_unique").on(t.entityType, t.entityId),
|
index("reminders_entity_idx").on(t.entityType, t.entityId),
|
||||||
index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt),
|
index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -212,6 +217,25 @@ export const notifications = pgTable(
|
|||||||
(t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)],
|
(t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const comments = pgTable(
|
||||||
|
"comments",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
householdId: uuid("household_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: "cascade" }),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
entityId: uuid("entity_id").notNull(),
|
||||||
|
authorId: uuid("author_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
body: text("body").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("comments_entity_idx").on(t.entityType, t.entityId, t.createdAt)],
|
||||||
|
);
|
||||||
|
|
||||||
export const householdApiTokens = pgTable(
|
export const householdApiTokens = pgTable(
|
||||||
"household_api_tokens",
|
"household_api_tokens",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { BangAggregatesDto } from "../server/queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
aggregates: BangAggregatesDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BangStatsWidget({ aggregates }: Props) {
|
||||||
|
const recentMonths = aggregates.monthlyCounts.slice(-6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<StatBlock label="This month" value={String(aggregates.thisMonth)} />
|
||||||
|
<StatBlock label="This year" value={String(aggregates.thisYear)} />
|
||||||
|
<StatBlock
|
||||||
|
label="Avg gap"
|
||||||
|
value={aggregates.averageDaysBetween == null ? "—" : `${aggregates.averageDaysBetween}d`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentMonths.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-[var(--ink-mute)]">Recent months</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{recentMonths.map((row) => (
|
||||||
|
<div key={row.month} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{formatMonth(row.month)}</span>
|
||||||
|
<span className="tabular-nums font-medium">{row.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">No bangs recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBlock({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--hair)] px-3 py-2">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-[var(--ink-mute)]">{label}</div>
|
||||||
|
<div className="text-2xl font-semibold tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(monthKey: string): string {
|
||||||
|
const [year, month] = monthKey.split("-").map(Number);
|
||||||
|
return new Date(year!, month! - 1, 1).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||||
import { getBangStats } from "./server/queries";
|
import { getBangAggregates, getBangStats } from "./server/queries";
|
||||||
import { BangWidget } from "./components/bang-widget";
|
import { BangWidget } from "./components/bang-widget";
|
||||||
|
import { BangStatsWidget } from "./components/bang-stats-widget";
|
||||||
|
|
||||||
const bangWidgetConfigSchema = z.object({
|
const bangWidgetConfigSchema = z.object({
|
||||||
maxRecentBangs: z.number().int().min(1).max(20).default(5),
|
maxRecentBangs: z.number().int().min(1).max(20).default(5),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bangStatsConfigSchema = z.object({});
|
||||||
|
|
||||||
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
|
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
|
||||||
const parsed = bangWidgetConfigSchema.parse(config);
|
const parsed = bangWidgetConfigSchema.parse(config);
|
||||||
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
|
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
|
||||||
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
|
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function BangStatsWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) {
|
||||||
|
const aggregates = await getBangAggregates(ctx.householdId);
|
||||||
|
return <BangStatsWidget aggregates={aggregates} />;
|
||||||
|
}
|
||||||
|
|
||||||
const bangsManifest: ModuleManifest = {
|
const bangsManifest: ModuleManifest = {
|
||||||
id: "bangs",
|
id: "bangs",
|
||||||
name: "Bangs",
|
name: "Bangs",
|
||||||
@@ -44,6 +52,18 @@ const bangsManifest: ModuleManifest = {
|
|||||||
defaultConfig: { maxRecentBangs: 5 },
|
defaultConfig: { maxRecentBangs: 5 },
|
||||||
render: (props) => <BangWidgetServer {...props} />,
|
render: (props) => <BangWidgetServer {...props} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "bangs.stats",
|
||||||
|
title: "Bang Stats",
|
||||||
|
description: "Monthly and yearly bang counts plus average days between bangs.",
|
||||||
|
category: "Fun",
|
||||||
|
defaultSize: { w: 3, h: 3 },
|
||||||
|
minSize: { w: 2, h: 2 },
|
||||||
|
defaultPriority: 55,
|
||||||
|
configSchema: bangStatsConfigSchema,
|
||||||
|
defaultConfig: {},
|
||||||
|
render: (props) => <BangStatsWidgetServer {...props} />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
quickAdds: [
|
quickAdds: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { asc, count, desc, eq, sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { bangEvents } from "../schema";
|
import { bangEvents } from "../schema";
|
||||||
@@ -14,6 +14,13 @@ export type RecentBangDto = {
|
|||||||
recordedByName: string | null;
|
recordedByName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BangAggregatesDto = {
|
||||||
|
thisMonth: number;
|
||||||
|
thisYear: number;
|
||||||
|
averageDaysBetween: number | null;
|
||||||
|
monthlyCounts: { month: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function getBangStatsForScope(
|
export async function getBangStatsForScope(
|
||||||
householdId: string,
|
householdId: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -48,3 +55,61 @@ export async function getBangStatsForScope(
|
|||||||
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
|
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
|
||||||
return getBangStatsForScope(householdId, limit);
|
return getBangStatsForScope(householdId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBangAggregates(householdId: string): Promise<BangAggregatesDto> {
|
||||||
|
const now = new Date();
|
||||||
|
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const yearPrefix = `${now.getFullYear()}-`;
|
||||||
|
|
||||||
|
const [thisMonthRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(
|
||||||
|
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${monthPrefix}%`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [thisYearRow] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(
|
||||||
|
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${yearPrefix}%`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthlyRows = await db
|
||||||
|
.select({
|
||||||
|
month: sql<string>`substring(${bangEvents.occurredOn}, 1, 7)`,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(eq(bangEvents.householdId, householdId))
|
||||||
|
.groupBy(sql`substring(${bangEvents.occurredOn}, 1, 7)`)
|
||||||
|
.orderBy(asc(sql`substring(${bangEvents.occurredOn}, 1, 7)`));
|
||||||
|
|
||||||
|
const dateRows = await db
|
||||||
|
.select({ occurredOn: bangEvents.occurredOn })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(eq(bangEvents.householdId, householdId))
|
||||||
|
.orderBy(asc(bangEvents.occurredOn));
|
||||||
|
|
||||||
|
let averageDaysBetween: number | null = null;
|
||||||
|
if (dateRows.length >= 2) {
|
||||||
|
const dates = dateRows.map((row) => parseBangDate(row.occurredOn).getTime());
|
||||||
|
let totalGapDays = 0;
|
||||||
|
for (let i = 1; i < dates.length; i++) {
|
||||||
|
totalGapDays += (dates[i]! - dates[i - 1]!) / (1000 * 60 * 60 * 24);
|
||||||
|
}
|
||||||
|
averageDaysBetween = Math.round((totalGapDays / (dates.length - 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thisMonth: thisMonthRow?.count ?? 0,
|
||||||
|
thisYear: thisYearRow?.count ?? 0,
|
||||||
|
averageDaysBetween,
|
||||||
|
monthlyCounts: monthlyRows.map((row) => ({ month: row.month, count: row.count })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBangDate(isoDate: string): Date {
|
||||||
|
const [year, month, day] = isoDate.split("-").map(Number);
|
||||||
|
return new Date(year!, month! - 1, day!);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ShareButton } from "@/components/share-button";
|
import { ShareButton } from "@/components/share-button";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
import type { CalView } from "@/modules/_core/themes";
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { CalendarDto, CalendarEventDto } from "../server/queries";
|
import type { CalendarDto, CalendarEventDto } from "../server/queries";
|
||||||
|
import { getEventReminderOffsets } from "../server/queries";
|
||||||
import {
|
import {
|
||||||
createCalendar,
|
createCalendar,
|
||||||
createEvent,
|
createEvent,
|
||||||
@@ -57,7 +59,7 @@ type EventDraft = {
|
|||||||
allDay: boolean;
|
allDay: boolean;
|
||||||
location: string;
|
location: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
remindMinutesBefore: number | null;
|
reminderOffsets: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type MobileView = "day" | "week" | "agenda";
|
type MobileView = "day" | "week" | "agenda";
|
||||||
@@ -99,10 +101,12 @@ export function CalendarShell({
|
|||||||
calendars,
|
calendars,
|
||||||
events,
|
events,
|
||||||
defaultView = "month",
|
defaultView = "month",
|
||||||
|
defaultReminderOffsets = [30],
|
||||||
}: {
|
}: {
|
||||||
calendars: CalendarDto[];
|
calendars: CalendarDto[];
|
||||||
events: CalendarEventDto[];
|
events: CalendarEventDto[];
|
||||||
defaultView?: CalView;
|
defaultView?: CalView;
|
||||||
|
defaultReminderOffsets?: number[];
|
||||||
}) {
|
}) {
|
||||||
const [calendarRows, setCalendarRows] = useState(calendars);
|
const [calendarRows, setCalendarRows] = useState(calendars);
|
||||||
const [eventRows, setEventRows] = useState(events);
|
const [eventRows, setEventRows] = useState(events);
|
||||||
@@ -160,13 +164,14 @@ export function CalendarShell({
|
|||||||
allDay,
|
allDay,
|
||||||
location: "",
|
location: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
remindMinutesBefore: 30,
|
reminderOffsets: [...defaultReminderOffsets],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openExistingEvent({ event }: EventClickArg) {
|
function openExistingEvent({ event }: EventClickArg) {
|
||||||
const row = eventRows.find((item) => item.id === event.id);
|
const row = eventRows.find((item) => item.id === event.id);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
const eventId = row.id;
|
||||||
setSelectedEvent({
|
setSelectedEvent({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
calendarId: row.calendarId,
|
calendarId: row.calendarId,
|
||||||
@@ -176,7 +181,12 @@ export function CalendarShell({
|
|||||||
allDay: row.allDay,
|
allDay: row.allDay,
|
||||||
location: row.location ?? "",
|
location: row.location ?? "",
|
||||||
notes: row.notes ?? "",
|
notes: row.notes ?? "",
|
||||||
remindMinutesBefore: null,
|
reminderOffsets: [],
|
||||||
|
});
|
||||||
|
void getEventReminderOffsets(eventId).then((offsets) => {
|
||||||
|
setSelectedEvent((current) =>
|
||||||
|
current?.id === eventId ? { ...current, reminderOffsets: offsets } : current,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +209,11 @@ export function CalendarShell({
|
|||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await updateEvent({ id: eventId, ...payload });
|
await updateEvent({
|
||||||
|
id: eventId,
|
||||||
|
...payload,
|
||||||
|
reminderOffsets: selectedEvent.reminderOffsets,
|
||||||
|
});
|
||||||
setLastCalendarId(payload.calendarId);
|
setLastCalendarId(payload.calendarId);
|
||||||
setEventRows((current) =>
|
setEventRows((current) =>
|
||||||
current.map((event) =>
|
current.map((event) =>
|
||||||
@@ -220,7 +234,7 @@ export function CalendarShell({
|
|||||||
} else {
|
} else {
|
||||||
const created = await createEvent({
|
const created = await createEvent({
|
||||||
...payload,
|
...payload,
|
||||||
remindMinutesBefore: selectedEvent.remindMinutesBefore,
|
reminderOffsets: selectedEvent.reminderOffsets,
|
||||||
});
|
});
|
||||||
setLastCalendarId(payload.calendarId);
|
setLastCalendarId(payload.calendarId);
|
||||||
setEventRows((current) => [...current, created]);
|
setEventRows((current) => [...current, created]);
|
||||||
@@ -538,22 +552,11 @@ export function CalendarShell({
|
|||||||
placeholder="Add details…"
|
placeholder="Add details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!selectedEvent.id && (
|
<ReminderPicker
|
||||||
<label className="flex items-center gap-2 text-sm">
|
offsets={selectedEvent.reminderOffsets}
|
||||||
<input
|
onChange={(reminderOffsets) => setSelectedEvent({ ...selectedEvent, reminderOffsets })}
|
||||||
type="checkbox"
|
disabled={isPending}
|
||||||
className="size-4 cursor-pointer"
|
/>
|
||||||
checked={selectedEvent.remindMinutesBefore !== null}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedEvent({
|
|
||||||
...selectedEvent,
|
|
||||||
remindMinutesBefore: e.target.checked ? 30 : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Remind me 30 min before
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-2">
|
<div className="flex items-center justify-between gap-2 pt-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedEvent.id && (
|
{selectedEvent.id && (
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { db } from "@/lib/db";
|
|||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { logActivityForScope } from "@/modules/_core/activity";
|
import { logActivityForScope } from "@/modules/_core/activity";
|
||||||
import { householdMembers } from "@/modules/_core/schema";
|
import { householdMembers } from "@/modules/_core/schema";
|
||||||
import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders";
|
import {
|
||||||
|
cancelReminder,
|
||||||
|
syncCalendarEventReminders,
|
||||||
|
listReminderOffsets,
|
||||||
|
} from "@/modules/_core/reminders";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
import { calendarEvents, calendars } from "../schema";
|
import { calendarEvents, calendars } from "../schema";
|
||||||
import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries";
|
import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries";
|
||||||
import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas";
|
import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas";
|
||||||
@@ -193,17 +198,16 @@ export async function createEventForScope(
|
|||||||
|
|
||||||
if (!event) throw new Error("Event was not created");
|
if (!event) throw new Error("Event was not created");
|
||||||
|
|
||||||
if (parsed.remindMinutesBefore != null && scope.userId) {
|
const reminderOffsets = resolveReminderOffsets(parsed);
|
||||||
const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000);
|
if (reminderOffsets.length > 0 && scope.userId) {
|
||||||
if (fireAt > new Date()) {
|
await syncCalendarEventReminders({
|
||||||
await scheduleReminder({
|
householdId: scope.householdId,
|
||||||
householdId: scope.householdId,
|
eventId: event.id,
|
||||||
entityType: "calendar.event",
|
eventTitle: event.title,
|
||||||
entityId: event.id,
|
startAt: parsed.startAt,
|
||||||
fireAt,
|
createdBy: scope.userId,
|
||||||
createdBy: scope.userId,
|
offsetMinutes: reminderOffsets,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await logActivityForScope(scope, {
|
await logActivityForScope(scope, {
|
||||||
@@ -256,6 +260,43 @@ export async function updateEventForScope(
|
|||||||
})
|
})
|
||||||
.where(eq(calendarEvents.id, parsed.id));
|
.where(eq(calendarEvents.id, parsed.id));
|
||||||
|
|
||||||
|
if (scope.userId) {
|
||||||
|
const [updated] = await db
|
||||||
|
.select({ title: calendarEvents.title, startAt: calendarEvents.startAt })
|
||||||
|
.from(calendarEvents)
|
||||||
|
.where(eq(calendarEvents.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
const startAt = parsed.startAt ?? updated.startAt;
|
||||||
|
let offsets: number[];
|
||||||
|
|
||||||
|
if (parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined) {
|
||||||
|
offsets = resolveReminderOffsets(parsed);
|
||||||
|
} else if (parsed.startAt !== undefined) {
|
||||||
|
offsets = await listReminderOffsets("calendar.event", parsed.id);
|
||||||
|
} else {
|
||||||
|
offsets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
offsets.length === 0 &&
|
||||||
|
(parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined)
|
||||||
|
) {
|
||||||
|
await cancelReminder("calendar.event", parsed.id);
|
||||||
|
} else if (offsets.length > 0) {
|
||||||
|
await syncCalendarEventReminders({
|
||||||
|
householdId: scope.householdId,
|
||||||
|
eventId: parsed.id,
|
||||||
|
eventTitle: updated.title,
|
||||||
|
startAt,
|
||||||
|
createdBy: scope.userId,
|
||||||
|
offsetMinutes: offsets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await logActivityForScope(scope, {
|
await logActivityForScope(scope, {
|
||||||
entityType: "calendar.event",
|
entityType: "calendar.event",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
@@ -304,3 +345,16 @@ async function assertOwnsCalendar(userId: string, calendarId: string) {
|
|||||||
|
|
||||||
if (!calendar) throw new Error("Forbidden");
|
if (!calendar) throw new Error("Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReminderOffsets(input: {
|
||||||
|
reminderOffsets?: number[];
|
||||||
|
remindMinutesBefore?: number | null;
|
||||||
|
}): number[] {
|
||||||
|
if (input.reminderOffsets !== undefined) {
|
||||||
|
return normalizeReminderOffsets(input.reminderOffsets);
|
||||||
|
}
|
||||||
|
if (input.remindMinutesBefore != null) {
|
||||||
|
return normalizeReminderOffsets([input.remindMinutesBefore]);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
|
import { listReminderOffsets } from "@/modules/_core/reminders";
|
||||||
import { householdMembers } from "@/modules/_core/schema";
|
import { householdMembers } from "@/modules/_core/schema";
|
||||||
import { calendarEvents, calendars } from "../schema";
|
import { calendarEvents, calendars } from "../schema";
|
||||||
|
|
||||||
@@ -258,6 +260,19 @@ export async function searchEvents(query: string, householdId: string) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDefaultEventReminderOffsets(): Promise<number[]> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
return normalizeReminderOffsets(user.defaultEventReminderOffsets ?? [30]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventReminderOffsets(eventId: string): Promise<number[]> {
|
||||||
|
const parsed = z.string().uuid().parse(eventId);
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
const event = await getEventForScope({ householdId: household.id, userId: user.id }, parsed);
|
||||||
|
if (!event) throw new Error("Event not found");
|
||||||
|
return listReminderOffsets("calendar.event", parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function toEventDto(row: {
|
function toEventDto(row: {
|
||||||
id: string;
|
id: string;
|
||||||
calendarId: string;
|
calendarId: string;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const eventBaseInput = z.object({
|
|||||||
allDay: z.boolean().default(false),
|
allDay: z.boolean().default(false),
|
||||||
location: z.string().trim().max(300).nullable().optional(),
|
location: z.string().trim().max(300).nullable().optional(),
|
||||||
notes: z.string().trim().max(3000).nullable().optional(),
|
notes: z.string().trim().max(3000).nullable().optional(),
|
||||||
|
reminderOffsets: z.array(z.number().int().min(0)).optional(),
|
||||||
|
/** @deprecated use reminderOffsets */
|
||||||
remindMinutesBefore: z.number().int().min(0).nullable().optional(),
|
remindMinutesBefore: z.number().int().min(0).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Archive, Plus, Trash2 } from "lucide-react";
|
import { Archive, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { DetailBackLink } from "@/components/detail-back-link";
|
import { DetailBackLink } from "@/components/detail-back-link";
|
||||||
|
import { EntityComments } from "@/components/comments/entity-comments";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShareButton } from "@/components/share-button";
|
import { ShareButton } from "@/components/share-button";
|
||||||
@@ -18,7 +19,13 @@ import {
|
|||||||
updateItem,
|
updateItem,
|
||||||
} from "../server/actions";
|
} from "../server/actions";
|
||||||
|
|
||||||
export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
export function ListDetail({
|
||||||
|
initialList,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
initialList: ListDetailDto;
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [list, setList] = useState(initialList);
|
const [list, setList] = useState(initialList);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@@ -156,6 +163,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
|||||||
<ListItemRow
|
<ListItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
currentUserId={currentUserId}
|
||||||
onToggle={(done) => setItemDone(item, done)}
|
onToggle={(done) => setItemDone(item, done)}
|
||||||
onEdit={(text) => editItemText(item, text)}
|
onEdit={(text) => editItemText(item, text)}
|
||||||
onCommit={() => commitItemText(item)}
|
onCommit={() => commitItemText(item)}
|
||||||
@@ -170,6 +178,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
|||||||
<ListItemRow
|
<ListItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
currentUserId={currentUserId}
|
||||||
onToggle={(done) => setItemDone(item, done)}
|
onToggle={(done) => setItemDone(item, done)}
|
||||||
onEdit={(text) => editItemText(item, text)}
|
onEdit={(text) => editItemText(item, text)}
|
||||||
onCommit={() => commitItemText(item)}
|
onCommit={() => commitItemText(item)}
|
||||||
@@ -179,18 +188,22 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EntityComments entityType="lists.list" entityId={list.id} currentUserId={currentUserId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListItemRow({
|
function ListItemRow({
|
||||||
item,
|
item,
|
||||||
|
currentUserId,
|
||||||
onToggle,
|
onToggle,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCommit,
|
onCommit,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
item: ListItemDto;
|
item: ListItemDto;
|
||||||
|
currentUserId: string;
|
||||||
onToggle: (done: boolean) => void;
|
onToggle: (done: boolean) => void;
|
||||||
onEdit: (text: string) => void;
|
onEdit: (text: string) => void;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
@@ -333,6 +346,14 @@ function ListItemRow({
|
|||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-[14px] pb-2">
|
||||||
|
<EntityComments
|
||||||
|
entityType="lists.item"
|
||||||
|
entityId={item.id}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react";
|
import { ChevronDown, ChevronRight, ExternalLink, Pencil, Plus } from "lucide-react";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
|
import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
|
||||||
import { createList, toggleItem } from "../server/actions";
|
import { addItem, createList, toggleItem, updateListProperties } from "../server/actions";
|
||||||
|
|
||||||
export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
||||||
const [listRows, setListRows] = useState(lists);
|
const [listRows, setListRows] = useState(lists);
|
||||||
@@ -61,6 +61,44 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddItem(listId: string, text: string) {
|
||||||
|
setListRows((current) =>
|
||||||
|
current.map((list) =>
|
||||||
|
list.id !== listId
|
||||||
|
? list
|
||||||
|
: {
|
||||||
|
...list,
|
||||||
|
openCount: list.openCount + 1,
|
||||||
|
items: [
|
||||||
|
...list.items,
|
||||||
|
{
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
text,
|
||||||
|
done: false,
|
||||||
|
position: list.items.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await addItem({ listId, text });
|
||||||
|
const { listListsWithItems } = await import("../server/queries");
|
||||||
|
const refreshed = await listListsWithItems();
|
||||||
|
setListRows(refreshed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateList(listId: string, patch: { name?: string; type?: string }) {
|
||||||
|
setListRows((current) =>
|
||||||
|
current.map((list) => (list.id === listId ? { ...list, ...patch } : list)),
|
||||||
|
);
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateListProperties({ id: listId, ...patch });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto grid w-full max-w-5xl gap-6">
|
<div className="mx-auto grid w-full max-w-5xl gap-6">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
@@ -101,7 +139,13 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
|||||||
<div className="eyebrow">{groupType}</div>
|
<div className="eyebrow">{groupType}</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{groupLists.map((list) => (
|
{groupLists.map((list) => (
|
||||||
<ListCard key={list.id} list={list} onToggle={handleToggle} />
|
<ListCard
|
||||||
|
key={list.id}
|
||||||
|
list={list}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onAddItem={handleAddItem}
|
||||||
|
onUpdateList={handleUpdateList}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -114,11 +158,34 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
|
|||||||
function ListCard({
|
function ListCard({
|
||||||
list,
|
list,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onAddItem,
|
||||||
|
onUpdateList,
|
||||||
}: {
|
}: {
|
||||||
list: ListWithItemsDto;
|
list: ListWithItemsDto;
|
||||||
onToggle: (listId: string, item: ListIndexItem, done: boolean) => void;
|
onToggle: (listId: string, item: ListIndexItem, done: boolean) => void;
|
||||||
|
onAddItem: (listId: string, text: string) => void;
|
||||||
|
onUpdateList: (listId: string, patch: { name?: string; type?: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draftName, setDraftName] = useState(list.name);
|
||||||
|
const [draftType, setDraftType] = useState(list.type);
|
||||||
|
const [itemDraft, setItemDraft] = useState("");
|
||||||
|
|
||||||
|
function saveListProperties() {
|
||||||
|
const name = draftName.trim();
|
||||||
|
const type = draftType.trim();
|
||||||
|
if (!name || !type) return;
|
||||||
|
onUpdateList(list.id, { name, type });
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitItem() {
|
||||||
|
const text = itemDraft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
onAddItem(list.id, text);
|
||||||
|
setItemDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -135,22 +202,60 @@ function ListCard({
|
|||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium">
|
{editing ? (
|
||||||
{list.name}
|
<div className="grid gap-2">
|
||||||
</h3>
|
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} />
|
||||||
<div className="meta">
|
<Input value={draftType} onChange={(e) => setDraftType(e.target.value)} />
|
||||||
{list.openCount} open · {list.doneCount} done
|
<div className="flex gap-2">
|
||||||
</div>
|
<Button type="button" size="sm" onClick={saveListProperties}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftName(list.name);
|
||||||
|
setDraftType(list.type);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium">
|
||||||
|
{list.name}
|
||||||
|
</h3>
|
||||||
|
<div className="meta">
|
||||||
|
{list.type} · {list.openCount} open · {list.doneCount} done
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
href={`/lists/${list.id}`}
|
{!editing && (
|
||||||
className="shrink-0 text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
<button
|
||||||
aria-label={`Open ${list.name}`}
|
type="button"
|
||||||
>
|
onClick={() => setEditing(true)}
|
||||||
<ExternalLink className="size-4" />
|
className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||||
</Link>
|
aria-label={`Edit ${list.name}`}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/lists/${list.id}`}
|
||||||
|
className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
|
||||||
|
aria-label={`Open ${list.name}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -204,6 +309,23 @@ function ListCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 px-[14px] py-3 border-t border-[var(--hair)]">
|
||||||
|
<Input
|
||||||
|
value={itemDraft}
|
||||||
|
onChange={(e) => setItemDraft(e.target.value)}
|
||||||
|
placeholder="Add a task…"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitItem();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" disabled={!itemDraft.trim()} onClick={submitItem}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,16 +59,20 @@ export async function updateListForScope(
|
|||||||
.update(lists)
|
.update(lists)
|
||||||
.set({
|
.set({
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
|
type: parsed.type,
|
||||||
archived: parsed.archived,
|
archived: parsed.archived,
|
||||||
})
|
})
|
||||||
.where(eq(lists.id, parsed.id));
|
.where(eq(lists.id, parsed.id));
|
||||||
|
|
||||||
if (parsed.name) {
|
if (parsed.name || parsed.type) {
|
||||||
await logActivityForScope(toScope(scope), {
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "lists.list",
|
entityType: "lists.list",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
payload: { name: parsed.name },
|
payload: {
|
||||||
|
...(parsed.name ? { name: parsed.name } : {}),
|
||||||
|
...(parsed.type ? { type: parsed.type } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (parsed.archived === true) {
|
if (parsed.archived === true) {
|
||||||
@@ -93,6 +97,20 @@ export async function renameList(input: { id: string; name: string }) {
|
|||||||
revalidatePath(`/lists/${parsed.id}`);
|
revalidatePath(`/lists/${parsed.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateListProperties(input: { id: string; name?: string; type?: string }) {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: listInput.shape.name.optional(),
|
||||||
|
type: listInput.shape.type.optional(),
|
||||||
|
})
|
||||||
|
.parse(input);
|
||||||
|
const { household, user } = await getCurrentSession();
|
||||||
|
await updateListForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
|
||||||
|
revalidatePath("/lists");
|
||||||
|
revalidatePath(`/lists/${parsed.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveList(input: { id: string }) {
|
export async function archiveList(input: { id: string }) {
|
||||||
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
const { household, user } = await getCurrentSession();
|
const { household, user } = await getCurrentSession();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const listInput = z.object({
|
|||||||
|
|
||||||
export const listUpdateInput = z.object({
|
export const listUpdateInput = z.object({
|
||||||
name: listInput.shape.name.optional(),
|
name: listInput.shape.name.optional(),
|
||||||
|
type: listInput.shape.type.optional(),
|
||||||
archived: z.boolean().optional(),
|
archived: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user