diff --git a/convex/scheduleEntries.ts b/convex/scheduleEntries.ts new file mode 100644 index 0000000..4e3477d --- /dev/null +++ b/convex/scheduleEntries.ts @@ -0,0 +1,181 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; + +async function requireListAccess(ctx: any, listId: Id<"lists">, userDid: string) { + const list = await ctx.db.get(listId); + if (!list) throw new Error("List not found"); + if (list.ownerDid === userDid) return list; + + const publication = await ctx.db + .query("publications") + .withIndex("by_list", (q: any) => q.eq("listId", listId)) + .first(); + + if (publication?.status === "active") return list; + throw new Error("Not authorized for this list"); +} + +export const listForList = query({ + args: { + listId: v.id("lists"), + userDid: v.string(), + monthStart: v.optional(v.number()), + monthEnd: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const list = await requireListAccess(ctx, args.listId, args.userDid); + const rows = await ctx.db + .query("scheduleEntries") + .withIndex("by_owner", (q) => q.eq("ownerDid", list.ownerDid)) + .collect(); + + return rows.filter((entry) => { + if (entry.listId && entry.listId !== args.listId) return false; + const t = entry.scheduledAt ?? entry.nextRunAt; + if (!t) return true; + if (args.monthStart !== undefined && t < args.monthStart) return false; + if (args.monthEnd !== undefined && t > args.monthEnd) return false; + return true; + }); + }, +}); + +export const createScheduleEntry = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + listId: v.optional(v.id("lists")), + agentDid: v.optional(v.string()), + title: v.string(), + description: v.optional(v.string()), + scheduleType: v.union(v.literal("cron"), v.literal("once"), v.literal("recurring")), + cronExpr: v.optional(v.string()), + scheduledAt: v.optional(v.number()), + nextRunAt: v.optional(v.number()), + enabled: v.boolean(), + externalId: v.optional(v.string()), + source: v.optional(v.union(v.literal("manual"), v.literal("openclaw"), v.literal("import"))), + }, + handler: async (ctx, args) => { + if (args.ownerDid !== args.actorDid) throw new Error("Not authorized"); + const now = Date.now(); + return await ctx.db.insert("scheduleEntries", { + ownerDid: args.ownerDid, + listId: args.listId, + agentDid: args.agentDid, + title: args.title, + description: args.description, + scheduleType: args.scheduleType, + cronExpr: args.cronExpr, + scheduledAt: args.scheduledAt, + nextRunAt: args.nextRunAt, + lastRunAt: undefined, + lastStatus: undefined, + enabled: args.enabled, + externalId: args.externalId, + source: args.source ?? "manual", + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const updateScheduleEntry = mutation({ + args: { + entryId: v.id("scheduleEntries"), + actorDid: v.string(), + enabled: v.optional(v.boolean()), + nextRunAt: v.optional(v.number()), + lastRunAt: v.optional(v.number()), + lastStatus: v.optional(v.union(v.literal("ok"), v.literal("error"), v.literal("skipped"))), + }, + handler: async (ctx, args) => { + const entry = await ctx.db.get(args.entryId); + if (!entry) throw new Error("Schedule entry not found"); + if (entry.ownerDid !== args.actorDid) throw new Error("Not authorized"); + + await ctx.db.patch(args.entryId, { + enabled: args.enabled ?? entry.enabled, + nextRunAt: args.nextRunAt ?? entry.nextRunAt, + lastRunAt: args.lastRunAt ?? entry.lastRunAt, + lastStatus: args.lastStatus ?? entry.lastStatus, + updatedAt: Date.now(), + }); + + return { ok: true }; + }, +}); + +export const syncCronSnapshot = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + agentDid: v.optional(v.string()), + entries: v.array(v.object({ + externalId: v.string(), + title: v.string(), + cronExpr: v.optional(v.string()), + nextRunAt: v.optional(v.number()), + lastRunAt: v.optional(v.number()), + lastStatus: v.optional(v.union(v.literal("ok"), v.literal("error"), v.literal("skipped"))), + enabled: v.boolean(), + listId: v.optional(v.id("lists")), + })), + }, + handler: async (ctx, args) => { + if (args.ownerDid !== args.actorDid) throw new Error("Not authorized"); + const now = Date.now(); + const touched = new Set(); + + for (const payload of args.entries) { + touched.add(payload.externalId); + const existing = await ctx.db + .query("scheduleEntries") + .withIndex("by_owner_external", (q) => q.eq("ownerDid", args.ownerDid).eq("externalId", payload.externalId)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + title: payload.title, + cronExpr: payload.cronExpr, + nextRunAt: payload.nextRunAt, + lastRunAt: payload.lastRunAt, + lastStatus: payload.lastStatus, + enabled: payload.enabled, + listId: payload.listId, + source: "openclaw", + agentDid: args.agentDid ?? existing.agentDid, + updatedAt: now, + }); + } else { + await ctx.db.insert("scheduleEntries", { + ownerDid: args.ownerDid, + listId: payload.listId, + agentDid: args.agentDid, + title: payload.title, + description: undefined, + scheduleType: "cron", + cronExpr: payload.cronExpr, + scheduledAt: undefined, + lastRunAt: payload.lastRunAt, + nextRunAt: payload.nextRunAt, + lastStatus: payload.lastStatus, + enabled: payload.enabled, + externalId: payload.externalId, + source: "openclaw", + createdAt: now, + updatedAt: now, + }); + } + } + + const rows = await ctx.db.query("scheduleEntries").withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)).collect(); + for (const row of rows) { + if (row.source !== "openclaw" || !row.externalId || touched.has(row.externalId)) continue; + await ctx.db.patch(row._id, { enabled: false, updatedAt: now }); + } + + return { ok: true, synced: args.entries.length }; + }, +}); diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx index 7302f41..a3ebe11 100644 --- a/src/components/CalendarView.tsx +++ b/src/components/CalendarView.tsx @@ -11,6 +11,7 @@ import { useSettings } from "../hooks/useSettings"; interface CalendarViewProps { listId: Id<"lists">; + userDid: string; onItemClick?: (item: Doc<"items">) => void; } @@ -20,7 +21,7 @@ const MONTHS = [ "July", "August", "September", "October", "November", "December" ]; -export function CalendarView({ listId, onItemClick }: CalendarViewProps) { +export function CalendarView({ listId, userDid, onItemClick }: CalendarViewProps) { const { haptic } = useSettings(); const [currentDate, setCurrentDate] = useState(new Date()); @@ -30,6 +31,13 @@ export function CalendarView({ listId, onItemClick }: CalendarViewProps) { // Filter items with due dates in current month const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59); + + const scheduleEntries = useQuery("scheduleEntries:listForList" as any, { + listId, + userDid, + monthStart: monthStart.getTime(), + monthEnd: monthEnd.getTime(), + }) as Array> | undefined; const items = useMemo(() => { if (!allItems) return []; @@ -53,6 +61,18 @@ export function CalendarView({ listId, onItemClick }: CalendarViewProps) { return map; }, [items]); + const scheduleByDate = useMemo(() => { + const map = new Map>>(); + if (!scheduleEntries) return map; + for (const entry of scheduleEntries) { + const t = entry.scheduledAt ?? entry.nextRunAt; + if (!t) continue; + const key = new Date(t).toDateString(); + map.set(key, [...(map.get(key) ?? []), entry]); + } + return map; + }, [scheduleEntries]); + // Generate calendar days const calendarDays = useMemo(() => { const days: (Date | null)[] = []; @@ -92,7 +112,7 @@ export function CalendarView({ listId, onItemClick }: CalendarViewProps) { return date.toDateString() === today.toDateString(); }; - if (!allItems) { + if (!allItems || !scheduleEntries) { return (
@@ -160,6 +180,7 @@ export function CalendarView({ listId, onItemClick }: CalendarViewProps) { } const dateItems = itemsByDate.get(date.toDateString()) ?? []; + const dateSchedules = scheduleByDate.get(date.toDateString()) ?? []; const today = isToday(date); return ( @@ -178,7 +199,7 @@ export function CalendarView({ listId, onItemClick }: CalendarViewProps) {
- {dateItems.slice(0, 3).map((item) => ( + {dateItems.slice(0, 2).map((item) => ( ))} - {dateItems.length > 3 && ( + {dateSchedules.slice(0, 1).map((entry) => ( +
+ 🕒 {entry.title} +
+ ))} + {(dateItems.length + dateSchedules.length) > 3 && (
- +{dateItems.length - 3} more + +{dateItems.length + dateSchedules.length - 3} more
)}
diff --git a/src/pages/ListView.tsx b/src/pages/ListView.tsx index 680fc2c..cc7afa9 100644 --- a/src/pages/ListView.tsx +++ b/src/pages/ListView.tsx @@ -1157,6 +1157,7 @@ export function ListView() { {viewMode === "calendar" && ( { haptic('light'); setSelectedCalendarItemId(item._id);