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:
ginnoir
2026-07-04 22:17:35 -05:00
parent 7714b1187c
commit e1c2a090fb
31 changed files with 1287 additions and 92 deletions
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ShareButton } from "@/components/share-button";
import { ReminderPicker } from "@/components/reminder-picker";
import type { CalView } from "@/modules/_core/themes";
import {
Select,
@@ -25,6 +26,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { CalendarDto, CalendarEventDto } from "../server/queries";
import { getEventReminderOffsets } from "../server/queries";
import {
createCalendar,
createEvent,
@@ -57,7 +59,7 @@ type EventDraft = {
allDay: boolean;
location: string;
notes: string;
remindMinutesBefore: number | null;
reminderOffsets: number[];
};
type MobileView = "day" | "week" | "agenda";
@@ -99,10 +101,12 @@ export function CalendarShell({
calendars,
events,
defaultView = "month",
defaultReminderOffsets = [30],
}: {
calendars: CalendarDto[];
events: CalendarEventDto[];
defaultView?: CalView;
defaultReminderOffsets?: number[];
}) {
const [calendarRows, setCalendarRows] = useState(calendars);
const [eventRows, setEventRows] = useState(events);
@@ -160,13 +164,14 @@ export function CalendarShell({
allDay,
location: "",
notes: "",
remindMinutesBefore: 30,
reminderOffsets: [...defaultReminderOffsets],
});
}
function openExistingEvent({ event }: EventClickArg) {
const row = eventRows.find((item) => item.id === event.id);
if (!row) return;
const eventId = row.id;
setSelectedEvent({
id: row.id,
calendarId: row.calendarId,
@@ -176,7 +181,12 @@ export function CalendarShell({
allDay: row.allDay,
location: row.location ?? "",
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 () => {
if (eventId) {
await updateEvent({ id: eventId, ...payload });
await updateEvent({
id: eventId,
...payload,
reminderOffsets: selectedEvent.reminderOffsets,
});
setLastCalendarId(payload.calendarId);
setEventRows((current) =>
current.map((event) =>
@@ -220,7 +234,7 @@ export function CalendarShell({
} else {
const created = await createEvent({
...payload,
remindMinutesBefore: selectedEvent.remindMinutesBefore,
reminderOffsets: selectedEvent.reminderOffsets,
});
setLastCalendarId(payload.calendarId);
setEventRows((current) => [...current, created]);
@@ -538,22 +552,11 @@ export function CalendarShell({
placeholder="Add details…"
/>
</div>
{!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
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>
)}
<ReminderPicker
offsets={selectedEvent.reminderOffsets}
onChange={(reminderOffsets) => setSelectedEvent({ ...selectedEvent, reminderOffsets })}
disabled={isPending}
/>
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex gap-2">
{selectedEvent.id && (
+66 -12
View File
@@ -7,7 +7,12 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivityForScope } from "@/modules/_core/activity";
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 { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries";
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 (parsed.remindMinutesBefore != null && scope.userId) {
const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000);
if (fireAt > new Date()) {
await scheduleReminder({
householdId: scope.householdId,
entityType: "calendar.event",
entityId: event.id,
fireAt,
createdBy: scope.userId,
});
}
const reminderOffsets = resolveReminderOffsets(parsed);
if (reminderOffsets.length > 0 && scope.userId) {
await syncCalendarEventReminders({
householdId: scope.householdId,
eventId: event.id,
eventTitle: event.title,
startAt: parsed.startAt,
createdBy: scope.userId,
offsetMinutes: reminderOffsets,
});
}
await logActivityForScope(scope, {
@@ -256,6 +260,43 @@ export async function updateEventForScope(
})
.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, {
entityType: "calendar.event",
entityId: parsed.id,
@@ -304,3 +345,16 @@ async function assertOwnsCalendar(userId: string, calendarId: string) {
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 [];
}
+15
View File
@@ -4,6 +4,8 @@ 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 { normalizeReminderOffsets } from "@/lib/reminder-offsets";
import { listReminderOffsets } from "@/modules/_core/reminders";
import { householdMembers } from "@/modules/_core/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: {
id: string;
calendarId: string;
+2
View File
@@ -20,6 +20,8 @@ export const eventBaseInput = z.object({
allDay: z.boolean().default(false),
location: z.string().trim().max(300).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(),
});