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:
@@ -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 && (
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user