-
{person.name}
+
+ {person.name}
+
-
{person.summary && (
-
+
{person.summary.split(/(#[a-zA-Z0-9_]+)/).map((part, i) =>
part.startsWith("#") ? (
diff --git a/src/app/features/people/widgets/person-form.tsx b/src/app/features/people/widgets/person-form.tsx
index 467e8b6..f04b2f8 100644
--- a/src/app/features/people/widgets/person-form.tsx
+++ b/src/app/features/people/widgets/person-form.tsx
@@ -26,6 +26,7 @@ import {
import { AvatarField } from "../parts/avatar-field"
import { AvatarCropperDialog } from "../parts/avatar-cropper-dialog"
import { getCroppedImg } from "../lib/image-crop"
+import { testIds } from "#shared/lib/test-ids"
export { PersonForm }
@@ -121,6 +122,7 @@ function PersonForm({
@@ -142,6 +144,7 @@ function PersonForm({
placeholder={t("person.form.summary.placeholder")}
rows={4}
onKeyDown={submitOnCtrlEnter}
+ data-testid={testIds.person.formSummaryInput}
{...field}
/>
@@ -155,7 +158,11 @@ function PersonForm({
+
{form.formState.isSubmitting ? (
) : (
diff --git a/src/app/features/reminders/lib/reminder-actions.ts b/src/app/features/reminders/lib/reminder-actions.ts
index 6046877..0af9708 100644
--- a/src/app/features/reminders/lib/reminder-actions.ts
+++ b/src/app/features/reminders/lib/reminder-actions.ts
@@ -39,14 +39,12 @@ async function handleCreateReminder(
t: T,
): Promise<{ ok: boolean }> {
let result = await tryCatch(
- createReminder(
- {
- text: values.text,
- dueAtDate: values.dueAtDate,
- repeat: values.repeat,
- },
- { personId, worker: me },
- ),
+ createReminder(me, {
+ personId,
+ text: values.text,
+ dueAtDate: values.dueAtDate,
+ repeat: values.repeat,
+ }),
)
if (!result.ok) {
toast.error(errorMessage(result.error))
@@ -57,9 +55,7 @@ async function handleCreateReminder(
}
async function handleMarkDone(me: Me, ref: ReminderRef, t: T) {
- let result = await tryCatch(
- updateReminder({ done: true }, { worker: me, ...ref }),
- )
+ let result = await tryCatch(updateReminder(me, { ...ref, done: true }))
if (!result.ok) {
toast.error(errorMessage(result.error))
return
@@ -80,7 +76,7 @@ async function handleMarkDone(me: Me, ref: ReminderRef, t: T) {
? { done: false, dueAtDate: result.data.previous.dueAtDate }
: { done: false }
let undo = await tryCatch(
- updateReminder(undoUpdates, { worker: me, ...ref }),
+ updateReminder(me, { ...ref, ...undoUpdates }),
)
if (undo.ok) {
toast.success(
@@ -98,9 +94,7 @@ async function handleMarkDone(me: Me, ref: ReminderRef, t: T) {
}
async function handleMarkUndone(me: Me, ref: ReminderRef, t: T) {
- let result = await tryCatch(
- updateReminder({ done: false }, { worker: me, ...ref }),
- )
+ let result = await tryCatch(updateReminder(me, { ...ref, done: false }))
if (!result.ok) {
toast.error(errorMessage(result.error))
return
@@ -110,9 +104,7 @@ async function handleMarkUndone(me: Me, ref: ReminderRef, t: T) {
action: {
label: t("common.undo"),
onClick: async () => {
- let undo = await tryCatch(
- updateReminder({ done: true }, { worker: me, ...ref }),
- )
+ let undo = await tryCatch(updateReminder(me, { ...ref, done: true }))
if (undo.ok) {
toast.success(t("reminder.toast.markedDoneAgain"))
} else {
@@ -130,22 +122,30 @@ async function handleEditReminder(
t: T,
): Promise<{ ok: boolean }> {
let result = await tryCatch(
- updateReminder(
- { text: values.text, dueAtDate: values.dueAtDate, repeat: values.repeat },
- { worker: me, ...ref },
- ),
+ updateReminder(me, {
+ ...ref,
+ text: values.text,
+ dueAtDate: values.dueAtDate,
+ repeat: values.repeat,
+ }),
)
if (!result.ok) {
toast.error(errorMessage(result.error))
return { ok: false }
}
+ let { previous } = result.data
toast.success(t("reminder.toast.updated"), {
action: {
label: t("common.undo"),
onClick: async () => {
let undo = await tryCatch(
- updateReminder(result.data.previous, { worker: me, ...ref }),
+ updateReminder(me, {
+ ...ref,
+ text: previous.text,
+ dueAtDate: previous.dueAtDate,
+ repeat: previous.repeat,
+ }),
)
if (undo.ok) {
toast.success(t("reminder.toast.updateUndone"))
@@ -160,7 +160,7 @@ async function handleEditReminder(
async function handleDeleteReminder(me: Me, ref: ReminderRef, t: T) {
let result = await tryCatch(
- updateReminder({ deletedAt: new Date() }, { worker: me, ...ref }),
+ updateReminder(me, { ...ref, deletedAt: new Date() }),
)
if (!result.ok) {
toast.error(errorMessage(result.error))
@@ -172,7 +172,7 @@ async function handleDeleteReminder(me: Me, ref: ReminderRef, t: T) {
label: t("common.undo"),
onClick: async () => {
let undo = await tryCatch(
- updateReminder({ deletedAt: undefined }, { worker: me, ...ref }),
+ updateReminder(me, { ...ref, deletedAt: undefined }),
)
if (undo.ok) {
toast.success(t("reminder.toast.restored"))
@@ -190,7 +190,7 @@ async function handleRestoreReminder(
t: T,
): Promise<{ ok: boolean }> {
let result = await tryCatch(
- updateReminder({ deletedAt: undefined }, { worker: me, ...ref }),
+ updateReminder(me, { ...ref, deletedAt: undefined }),
)
if (!result.ok) {
toast.error(errorMessage(result.error))
diff --git a/src/app/features/reminders/lib/reminder-dates.test.ts b/src/app/features/reminders/lib/reminder-dates.test.ts
new file mode 100644
index 0000000..a77226c
--- /dev/null
+++ b/src/app/features/reminders/lib/reminder-dates.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import type { co } from "jazz-tools"
+import { UserAccount } from "#shared/schema/user"
+import {
+ createAccount,
+ seedPerson,
+ seedReminder,
+} from "#shared/tools/test-helpers"
+import { getReferenceDate, getDeletedDate } from "./reminder-dates"
+
+describe("getReferenceDate", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("returns updatedAt when present", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "call",
+ dueAtDate: "2025-06-01",
+ })
+ let date = getReferenceDate(reminder)
+ expect(date).toEqual(reminder.updatedAt)
+ })
+
+ test("returns a Date instance", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "call",
+ dueAtDate: "2025-06-01",
+ })
+ expect(getReferenceDate(reminder)).toBeInstanceOf(Date)
+ })
+})
+
+describe("getDeletedDate", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("returns deletedAt when present", async () => {
+ let deletedAt = new Date("2025-03-01")
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "old",
+ dueAtDate: "2025-02-01",
+ deletedAt,
+ })
+ expect(getDeletedDate(reminder)).toEqual(deletedAt)
+ })
+
+ test("falls back to reference date when not deleted", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "active",
+ dueAtDate: "2025-06-01",
+ })
+ expect(getDeletedDate(reminder)).toEqual(getReferenceDate(reminder))
+ })
+})
diff --git a/src/app/features/reminders/parts/reminder-fields.tsx b/src/app/features/reminders/parts/reminder-fields.tsx
index 999af18..3d69884 100644
--- a/src/app/features/reminders/parts/reminder-fields.tsx
+++ b/src/app/features/reminders/parts/reminder-fields.tsx
@@ -27,6 +27,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "#shared/ui/tooltip"
import { Kbd, KbdGroup } from "#shared/ui/kbd"
import { isMac } from "#app/hooks/use-pwa"
+import { testIds } from "#shared/lib/test-ids"
export { ReminderFields, type ReminderFieldValues }
@@ -96,6 +97,7 @@ function ReminderFields({
@@ -115,6 +117,7 @@ function ReminderFields({
@@ -230,6 +233,7 @@ function ReminderFields({
type="submit"
disabled={form.formState.isSubmitting}
className="flex-1"
+ data-testid={testIds.reminder.formSubmit}
>
{form.formState.isSubmitting ? (
diff --git a/src/app/features/reminders/widgets/active-reminder.tsx b/src/app/features/reminders/widgets/active-reminder.tsx
index bc4b707..169cccb 100644
--- a/src/app/features/reminders/widgets/active-reminder.tsx
+++ b/src/app/features/reminders/widgets/active-reminder.tsx
@@ -27,6 +27,7 @@ import {
import { cn } from "#app/lib/utils"
import { Button } from "#shared/ui/button"
import { ButtonGroup } from "#shared/ui/button-group"
+import { testIds } from "#shared/lib/test-ids"
export { ActiveReminder }
@@ -83,7 +84,11 @@ function ActiveReminder({
}
return (
- <>
+
-
+
- setEditing(true)}>
+ setEditing(true)}
+ data-testid={testIds.reminder.editButton}
+ >
@@ -156,6 +169,7 @@ function ActiveReminder({
variant="outline"
onClick={remove}
className="text-destructive"
+ data-testid={testIds.reminder.deleteButton}
>
@@ -188,6 +202,6 @@ function ActiveReminder({
/>
- >
+
)
}
diff --git a/src/app/features/reminders/widgets/deleted-reminder.tsx b/src/app/features/reminders/widgets/deleted-reminder.tsx
index 3c76800..5f86d65 100644
--- a/src/app/features/reminders/widgets/deleted-reminder.tsx
+++ b/src/app/features/reminders/widgets/deleted-reminder.tsx
@@ -23,6 +23,7 @@ import {
import { cn } from "#app/lib/utils"
import { Button } from "#shared/ui/button"
import { ButtonGroup } from "#shared/ui/button-group"
+import { testIds } from "#shared/lib/test-ids"
export { DeletedReminder }
@@ -73,7 +74,11 @@ function DeletedReminder({
}
return (
- <>
+
-
+
@@ -157,6 +166,6 @@ function DeletedReminder({
onOpenChange={setConfirmingDelete}
onConfirm={onConfirmPermanentDelete}
/>
- >
+
)
}
diff --git a/src/app/features/reminders/widgets/done-reminder.tsx b/src/app/features/reminders/widgets/done-reminder.tsx
index 25661d1..c27aa0c 100644
--- a/src/app/features/reminders/widgets/done-reminder.tsx
+++ b/src/app/features/reminders/widgets/done-reminder.tsx
@@ -18,6 +18,7 @@ import {
import { cn } from "#app/lib/utils"
import { Button } from "#shared/ui/button"
import { ButtonGroup } from "#shared/ui/button-group"
+import { testIds } from "#shared/lib/test-ids"
export { DoneReminder }
@@ -67,82 +68,88 @@ function DoneReminder({
}
return (
-
- ,
- label: ,
- onAction: markUndone,
- }}
- leftAction={{
- variant: "destructive",
- icon: ,
- label: ,
- onAction: remove,
- }}
- >
- }
- className="flex-1"
+
+
+ ,
+ label: ,
+ onAction: markUndone,
+ }}
+ leftAction={{
+ variant: "destructive",
+ icon: ,
+ label: ,
+ onAction: remove,
+ }}
>
-
-
-
+ }
+ className="flex-1"
+ >
+
+
+
-
-
-
- setExpanded(false)}>
-
-
-
-
-
-
-
-
-
-
-
- {showPerson !== false && (
-
-
+
+
+ setExpanded(false)}>
+
-
+
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {showPerson !== false && (
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/app/features/reminders/widgets/new-reminder.tsx b/src/app/features/reminders/widgets/new-reminder.tsx
index c4d2202..80bcec0 100644
--- a/src/app/features/reminders/widgets/new-reminder.tsx
+++ b/src/app/features/reminders/widgets/new-reminder.tsx
@@ -65,17 +65,12 @@ function NewReminder(props: {
}) {
if (!me.$isLoaded || !selectedPersonId) return
let result = await tryCatch(
- createReminder(
- {
- text: values.text,
- dueAtDate: values.dueAtDate,
- repeat: values.repeat,
- },
- {
- personId: selectedPersonId,
- worker: me,
- },
- ),
+ createReminder(me, {
+ personId: selectedPersonId,
+ text: values.text,
+ dueAtDate: values.dueAtDate,
+ repeat: values.repeat,
+ }),
)
if (!result.ok) {
toast.error(
@@ -84,7 +79,7 @@ function NewReminder(props: {
return
}
- props.onSuccess?.(result.data.reminderID)
+ props.onSuccess?.(result.data.current.reminderId)
toast.success(t("reminders.created.success"))
setDrawerOpen(false)
setSelectedPersonId(props.personId ?? "")
diff --git a/src/app/features/reminders/widgets/reminder-form.tsx b/src/app/features/reminders/widgets/reminder-form.tsx
index 938150c..a1969d5 100644
--- a/src/app/features/reminders/widgets/reminder-form.tsx
+++ b/src/app/features/reminders/widgets/reminder-form.tsx
@@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "#shared/ui/tooltip"
import { Kbd, KbdGroup } from "#shared/ui/kbd"
import { isMac } from "#app/hooks/use-pwa"
import { T, useIntl } from "#shared/intl/setup"
+import { testIds } from "#shared/lib/test-ids"
export { ReminderForm }
@@ -94,6 +95,7 @@ function ReminderForm({
@@ -115,6 +117,7 @@ function ReminderForm({
WebkitAppearance: "none",
}}
type="date"
+ data-testid={testIds.reminder.formDueInput}
{...field}
/>
@@ -233,6 +236,7 @@ function ReminderForm({
type="submit"
disabled={form.formState.isSubmitting}
className="flex-1"
+ data-testid={testIds.reminder.formSubmit}
>
{form.formState.isSubmitting ? (
diff --git a/src/app/features/tour/screens/tour-screen.tsx b/src/app/features/tour/screens/tour-screen.tsx
index f0f9623..4c344f6 100644
--- a/src/app/features/tour/screens/tour-screen.tsx
+++ b/src/app/features/tour/screens/tour-screen.tsx
@@ -6,6 +6,7 @@ import { TypographyH1 } from "#shared/ui/typography"
import { Button } from "#shared/ui/button"
import { T } from "#shared/intl"
import { useTourStore } from "../lib/store"
+import { testIds } from "#shared/lib/test-ids"
import {
Stars,
GearFill,
@@ -142,6 +143,7 @@ function TourHeader({
nativeButton={false}
variant="outline"
render={ setTourSkipped(true)} />}
+ data-testid={testIds.tour.skipButton}
>
diff --git a/src/app/hooks/use-cleanups.test.ts b/src/app/hooks/use-cleanups.test.ts
new file mode 100644
index 0000000..b683092
--- /dev/null
+++ b/src/app/hooks/use-cleanups.test.ts
@@ -0,0 +1,216 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import { co } from "jazz-tools"
+import { Note, Person, Reminder, UserAccount } from "#shared/schema/user"
+import {
+ createAccount,
+ seedPerson,
+ seedNote,
+ seedReminder,
+} from "#shared/tools/test-helpers"
+import { cleanupInactiveLists, isStale } from "./use-cleanups"
+
+function daysAgo(n: number): Date {
+ let d = new Date()
+ d.setDate(d.getDate() - n)
+ return d
+}
+
+async function loadForCleanup(account: co.loaded) {
+ return await account.$jazz.ensureLoaded({
+ resolve: {
+ root: {
+ people: {
+ $each: {
+ notes: { $each: { $onError: "catch" } },
+ inactiveNotes: { $each: { $onError: "catch" } },
+ reminders: { $each: { $onError: "catch" } },
+ inactiveReminders: { $each: { $onError: "catch" } },
+ $onError: "catch",
+ },
+ },
+ inactivePeople: {
+ $each: {
+ notes: { $each: { $onError: "catch" } },
+ reminders: { $each: { $onError: "catch" } },
+ $onError: "catch",
+ },
+ },
+ },
+ },
+ })
+}
+
+async function ensureInactiveLists(person: co.loaded) {
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { inactiveNotes: true, inactiveReminders: true },
+ })
+ if (!loaded.inactiveNotes) {
+ person.$jazz.set(
+ "inactiveNotes",
+ co.list(Note).create([], person.$jazz.owner),
+ )
+ }
+ if (!loaded.inactiveReminders) {
+ person.$jazz.set(
+ "inactiveReminders",
+ co.list(Reminder).create([], person.$jazz.owner),
+ )
+ }
+}
+
+describe("isStale", () => {
+ test("returns true for dates older than 30 days", () => {
+ expect(isStale(daysAgo(31))).toBe(true)
+ expect(isStale(daysAgo(60))).toBe(true)
+ })
+
+ test("returns false for dates within 30 days", () => {
+ expect(isStale(daysAgo(29))).toBe(false)
+ expect(isStale(daysAgo(0))).toBe(false)
+ expect(isStale(new Date())).toBe(false)
+ })
+})
+
+describe("cleanupInactiveLists", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("moves soft-deleted person from active to inactivePeople", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ person.$jazz.set("deletedAt", daysAgo(1))
+
+ let loaded = await loadForCleanup(owner)
+ await cleanupInactiveLists(loaded)
+
+ let after = await owner.$jazz.ensureLoaded({
+ resolve: {
+ root: {
+ people: { $each: true },
+ inactivePeople: { $each: true },
+ },
+ },
+ })
+ expect(after.root.people.some(p => p?.$jazz.id === person.$jazz.id)).toBe(
+ false,
+ )
+ expect(
+ after.root.inactivePeople?.some(p => p?.$jazz.id === person.$jazz.id),
+ ).toBe(true)
+ })
+
+ test("leaves active people untouched", async () => {
+ let person = await seedPerson(owner, { name: "Active" })
+
+ let loaded = await loadForCleanup(owner)
+ await cleanupInactiveLists(loaded)
+
+ let after = await owner.$jazz.ensureLoaded({
+ resolve: { root: { people: { $each: true } } },
+ })
+ expect(after.root.people.some(p => p?.$jazz.id === person.$jazz.id)).toBe(
+ true,
+ )
+ })
+
+ test("moves deleted note from notes to inactiveNotes", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await ensureInactiveLists(person)
+ let note = await seedNote(person, {
+ content: "to delete",
+ deletedAt: daysAgo(1),
+ })
+
+ let loaded = await loadForCleanup(owner)
+ await cleanupInactiveLists(loaded)
+
+ let after = await person.$jazz.ensureLoaded({
+ resolve: {
+ notes: { $each: true },
+ inactiveNotes: { $each: true },
+ },
+ })
+ expect(after.notes.some(n => n?.$jazz.id === note.$jazz.id)).toBe(false)
+ expect(after.inactiveNotes?.some(n => n?.$jazz.id === note.$jazz.id)).toBe(
+ true,
+ )
+ })
+
+ test("moves deleted reminder from reminders to inactiveReminders", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await ensureInactiveLists(person)
+ let reminder = await seedReminder(person, {
+ text: "old task",
+ dueAtDate: "2025-01-01",
+ deletedAt: daysAgo(1),
+ })
+
+ let loaded = await loadForCleanup(owner)
+ await cleanupInactiveLists(loaded)
+
+ let after = await person.$jazz.ensureLoaded({
+ resolve: {
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ })
+ expect(after.reminders.some(r => r?.$jazz.id === reminder.$jazz.id)).toBe(
+ false,
+ )
+ expect(
+ after.inactiveReminders?.some(r => r?.$jazz.id === reminder.$jazz.id),
+ ).toBe(true)
+ })
+
+ test("moves done reminder to inactiveReminders", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await ensureInactiveLists(person)
+ let reminder = await seedReminder(person, {
+ text: "done task",
+ dueAtDate: "2025-01-01",
+ done: true,
+ })
+
+ let loaded = await loadForCleanup(owner)
+ await cleanupInactiveLists(loaded)
+
+ let after = await person.$jazz.ensureLoaded({
+ resolve: {
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ })
+ expect(after.reminders.some(r => r?.$jazz.id === reminder.$jazz.id)).toBe(
+ false,
+ )
+ expect(
+ after.inactiveReminders?.some(r => r?.$jazz.id === reminder.$jazz.id),
+ ).toBe(true)
+ })
+
+ test("keeps active notes and reminders in their lists", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await ensureInactiveLists(person)
+ let note = await seedNote(person, { content: "active note" })
+ let reminder = await seedReminder(person, {
+ text: "active task",
+ dueAtDate: "2025-12-01",
+ })
+
+ let loaded = await loadForCleanup(owner)
+ await cleanupInactiveLists(loaded)
+
+ let after = await person.$jazz.ensureLoaded({
+ resolve: {
+ notes: { $each: true },
+ reminders: { $each: true },
+ },
+ })
+ expect(after.notes.some(n => n?.$jazz.id === note.$jazz.id)).toBe(true)
+ expect(after.reminders.some(r => r?.$jazz.id === reminder.$jazz.id)).toBe(
+ true,
+ )
+ })
+})
diff --git a/src/app/hooks/use-cleanups.ts b/src/app/hooks/use-cleanups.ts
index be320e0..934f95a 100644
--- a/src/app/hooks/use-cleanups.ts
+++ b/src/app/hooks/use-cleanups.ts
@@ -2,11 +2,13 @@ export {
useCleanupInactiveLists,
useCleanupEmptyGroups,
useCleanupInaccessiblePeople,
+ cleanupInactiveLists,
+ isStale,
}
import { useEffect, useRef } from "react"
import { useAccount } from "jazz-tools/react"
-import { Group, type co, type ResolveQuery } from "jazz-tools"
+import { Account, Group, type co, type ResolveQuery } from "jazz-tools"
import { UserAccount, Person, isDeleted } from "#shared/schema/user"
import {
permanentlyDeleteNote,
@@ -303,7 +305,9 @@ function cleanupEmptyInviteGroups(person: co.loaded): boolean {
}
function getPersonGroup(person: co.loaded): Group | null {
- let group = person.$jazz.owner
- if (!group || !(group instanceof Group)) return null
- return group
+ let owner = person.$jazz.owner
+ if (!owner || !(owner instanceof Group)) return null
+ let me = Account.getMe()
+ if (owner.$jazz.id === me.$jazz.id) return null
+ return owner
}
diff --git a/src/shared/lib/co-list-utils.test.ts b/src/shared/lib/co-list-utils.test.ts
new file mode 100644
index 0000000..511277e
--- /dev/null
+++ b/src/shared/lib/co-list-utils.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, test } from "vitest"
+import {
+ getLoadedCoListValues,
+ removeCoListRefsById,
+ removeCoValueRefsByLoadingStates,
+ removeDeletedCoValueRefs,
+} from "./co-list-utils"
+
+function createMockCoList(items: T[]) {
+ let list = Object.assign([...items], {
+ $jazz: {
+ splice(start: number, deleteCount: number) {
+ list.splice(start, deleteCount)
+ },
+ },
+ values() {
+ return list[Symbol.iterator]()
+ },
+ })
+ return list
+}
+
+describe("getLoadedCoListValues", () => {
+ test("returns only loaded values", () => {
+ let list = createMockCoList([
+ { $isLoaded: true, name: "a" },
+ { $isLoaded: false, name: "b" },
+ null,
+ { $isLoaded: true, name: "c" },
+ ])
+ let result = getLoadedCoListValues<{ $isLoaded: boolean; name: string }>(
+ list,
+ )
+ expect(result.map(r => r.name)).toEqual(["a", "c"])
+ })
+
+ test("returns empty array for non-list input", () => {
+ expect(getLoadedCoListValues(null)).toEqual([])
+ expect(getLoadedCoListValues("string")).toEqual([])
+ expect(getLoadedCoListValues(42)).toEqual([])
+ })
+
+ test("returns empty array for list with all null values", () => {
+ let list = createMockCoList([null, null, undefined])
+ expect(getLoadedCoListValues(list)).toEqual([])
+ })
+})
+
+describe("removeDeletedCoValueRefs", () => {
+ test("removes refs with 'deleted' loading state", () => {
+ let list = createMockCoList([
+ { $isLoaded: true, name: "keep" },
+ { $isLoaded: false, $jazz: { loadingState: "deleted" } },
+ { $isLoaded: true, name: "also keep" },
+ ])
+
+ removeDeletedCoValueRefs(list)
+ expect(list.length).toBe(2)
+ })
+
+ test("does not remove refs with other loading states", () => {
+ let list = createMockCoList([
+ { $isLoaded: false, $jazz: { loadingState: "loading" } },
+ { $isLoaded: false, $jazz: { loadingState: "unavailable" } },
+ ])
+
+ removeDeletedCoValueRefs(list)
+ expect(list.length).toBe(2)
+ })
+
+ test("handles empty list", () => {
+ let list = createMockCoList([])
+ removeDeletedCoValueRefs(list)
+ expect(list.length).toBe(0)
+ })
+})
+
+describe("removeCoValueRefsByLoadingStates", () => {
+ test("removes refs matching any of the given states", () => {
+ let list = createMockCoList([
+ { $isLoaded: false, $jazz: { loadingState: "deleted" } },
+ { $isLoaded: true, name: "loaded" },
+ { $isLoaded: false, $jazz: { loadingState: "unavailable" } },
+ { $isLoaded: false, $jazz: { loadingState: "loading" } },
+ ])
+
+ removeCoValueRefsByLoadingStates(list, ["deleted", "unavailable"])
+ expect(list.length).toBe(2)
+ })
+
+ test("skips loaded items even if they have jazz metadata", () => {
+ let list = createMockCoList([
+ { $isLoaded: true, $jazz: { loadingState: "deleted" } },
+ ])
+
+ removeCoValueRefsByLoadingStates(list, ["deleted"])
+ expect(list.length).toBe(1)
+ })
+
+ test("skips null entries", () => {
+ let list = createMockCoList([
+ null,
+ { $isLoaded: false, $jazz: { loadingState: "deleted" } },
+ ])
+
+ removeCoValueRefsByLoadingStates(list, ["deleted"])
+ expect(list.length).toBe(1)
+ })
+
+ test("is a no-op on non-list input", () => {
+ removeCoValueRefsByLoadingStates(null, ["deleted"])
+ removeCoValueRefsByLoadingStates({}, ["deleted"])
+ })
+})
+
+describe("removeCoListRefsById", () => {
+ test("removes all refs matching the given id", () => {
+ let list = createMockCoList([
+ { $jazz: { id: "co_a" } },
+ { $jazz: { id: "co_b" } },
+ { $jazz: { id: "co_a" } },
+ ])
+
+ removeCoListRefsById(list, "co_a")
+ expect(list.length).toBe(1)
+ expect(list[0].$jazz.id).toBe("co_b")
+ })
+
+ test("handles null entries in the list", () => {
+ let list = createMockCoList([null, { $jazz: { id: "co_a" } }, null])
+
+ removeCoListRefsById(list, "co_a")
+ expect(list.length).toBe(2)
+ })
+
+ test("is a no-op when id not found", () => {
+ let list = createMockCoList([{ $jazz: { id: "co_b" } }])
+
+ removeCoListRefsById(list, "co_a")
+ expect(list.length).toBe(1)
+ })
+})
diff --git a/src/shared/lib/test-ids.ts b/src/shared/lib/test-ids.ts
new file mode 100644
index 0000000..4cdb4cf
--- /dev/null
+++ b/src/shared/lib/test-ids.ts
@@ -0,0 +1,50 @@
+export { testIds }
+
+let testIds = {
+ tour: {
+ skipButton: "tour-skip-button",
+ },
+ nav: {
+ peopleTab: "nav-people-tab",
+ remindersTab: "nav-reminders-tab",
+ },
+ person: {
+ newButton: "person-new-button",
+ formNameInput: "person-form-name-input",
+ formSummaryInput: "person-form-summary-input",
+ formSubmit: "person-form-submit",
+ listItem: "person-list-item",
+ detailName: "person-detail-name",
+ detailSummary: "person-detail-summary",
+ actionsTrigger: "person-actions-trigger",
+ editButton: "person-edit-button",
+ deleteButton: "person-delete-button",
+ deleteConfirmButton: "person-delete-confirm-button",
+ restoreButton: "person-restore-button",
+ notesTab: "person-notes-tab",
+ remindersTab: "person-reminders-tab",
+ },
+ note: {
+ newButton: "note-new-button",
+ formContentInput: "note-form-content-input",
+ formSubmit: "note-form-submit",
+ listItem: "note-list-item",
+ content: "note-content",
+ editButton: "note-edit-button",
+ pinButton: "note-pin-button",
+ deleteButton: "note-delete-button",
+ restoreButton: "note-restore-button",
+ },
+ reminder: {
+ newButton: "reminder-new-button",
+ formTextInput: "reminder-form-text-input",
+ formDueInput: "reminder-form-due-input",
+ formSubmit: "reminder-form-submit",
+ listItem: "reminder-list-item",
+ text: "reminder-text",
+ doneToggle: "reminder-done-toggle",
+ editButton: "reminder-edit-button",
+ deleteButton: "reminder-delete-button",
+ restoreButton: "reminder-restore-button",
+ },
+} as const
diff --git a/src/shared/tools/define-tool.test.ts b/src/shared/tools/define-tool.test.ts
new file mode 100644
index 0000000..2e2fa13
--- /dev/null
+++ b/src/shared/tools/define-tool.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, test } from "vitest"
+import { z } from "zod"
+import {
+ defineTool,
+ operationSchema,
+ deletedSchema,
+ mutationSchema,
+ type Worker,
+} from "#shared/tools/define-tool"
+
+let stubWorker = undefined as unknown as Worker
+
+describe("defineTool — server tool", () => {
+ let buildEcho = () =>
+ defineTool({
+ description: "echo",
+ input: z.object({ value: z.string() }),
+ output: z.object({ echoed: z.string() }),
+ serverOp: async (_worker, input) => ({ echoed: input.value }),
+ })
+
+ test("returns a worker-bound factory with execute", () => {
+ let tool = buildEcho()(stubWorker)
+ expect(typeof tool.execute).toBe("function")
+ })
+
+ test("execute passes success through", async () => {
+ let tool = buildEcho()(stubWorker)
+ let out = await tool.execute!({ value: "hi" }, fakeOptions())
+ expect(out).toEqual({ echoed: "hi" })
+ })
+
+ test("execute wraps thrown errors as { error }", async () => {
+ let tool = defineTool({
+ description: "boom",
+ input: z.object({}),
+ output: z.object({ ok: z.boolean() }),
+ serverOp: async () => {
+ throw new Error("kaboom")
+ },
+ })(stubWorker)
+ let out = await tool.execute!({}, fakeOptions())
+ expect(out).toEqual({ error: "kaboom" })
+ })
+
+ test("output schema unions error branch with success", () => {
+ let tool = buildEcho()(stubWorker)
+ let schema = tool.outputSchema as unknown as z.ZodType
+ expect(schema.safeParse({ error: "nope" }).success).toBe(true)
+ })
+})
+
+describe("defineTool — client tool", () => {
+ test("omitting serverOp yields a tool without execute", () => {
+ let clientTool = defineTool({
+ description: "client-side",
+ input: z.object({ q: z.string() }),
+ output: z.object({ a: z.string() }),
+ })
+ expect(clientTool.execute).toBeUndefined()
+ })
+
+ test("cancellable adds the cancelled branch", () => {
+ let clientTool = defineTool({
+ description: "ask",
+ input: z.object({ q: z.string() }),
+ output: z.object({ answer: z.string() }),
+ cancellable: true,
+ })
+ let schema = clientTool.outputSchema as unknown as z.ZodType
+ expect(
+ schema.safeParse({ cancelled: true, reason: "user closed" }).success,
+ ).toBe(true)
+ })
+
+ test("non-cancellable rejects cancelled output", () => {
+ let clientTool = defineTool({
+ description: "no-cancel",
+ input: z.object({}),
+ output: z.object({ ok: z.boolean() }),
+ })
+ let schema = clientTool.outputSchema as unknown as z.ZodType
+ expect(schema.safeParse({ cancelled: true, reason: "x" }).success).toBe(
+ false,
+ )
+ })
+})
+
+describe("operationSchema / deletedSchema", () => {
+ let current = z.object({ id: z.string(), name: z.string() })
+
+ test("operationSchema accepts create with current", () => {
+ let parsed = operationSchema(current).safeParse({
+ operation: "create",
+ current: { id: "1", name: "Ada" },
+ })
+ expect(parsed.success).toBe(true)
+ })
+
+ test("operationSchema accepts update with current + previous", () => {
+ let parsed = operationSchema(current).safeParse({
+ operation: "update",
+ current: { id: "1", name: "Ada" },
+ previous: { id: "1", name: "Adelaide" },
+ })
+ expect(parsed.success).toBe(true)
+ })
+
+ test("deletedSchema accepts delete with previous only", () => {
+ let parsed = deletedSchema(current).safeParse({
+ operation: "delete",
+ previous: { id: "1", name: "Ada" },
+ })
+ expect(parsed.success).toBe(true)
+ })
+
+ test("mutationSchema accepts all three operations", () => {
+ let schema = mutationSchema(current)
+ expect(
+ schema.safeParse({
+ operation: "create",
+ current: { id: "1", name: "x" },
+ }).success,
+ ).toBe(true)
+ expect(
+ schema.safeParse({
+ operation: "update",
+ current: { id: "1", name: "x" },
+ previous: { id: "1", name: "y" },
+ }).success,
+ ).toBe(true)
+ expect(
+ schema.safeParse({
+ operation: "delete",
+ previous: { id: "1", name: "x" },
+ }).success,
+ ).toBe(true)
+ })
+})
+
+function fakeOptions() {
+ return {
+ toolCallId: "test",
+ messages: [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+}
diff --git a/src/shared/tools/define-tool.ts b/src/shared/tools/define-tool.ts
new file mode 100644
index 0000000..04f6191
--- /dev/null
+++ b/src/shared/tools/define-tool.ts
@@ -0,0 +1,141 @@
+/* eslint-disable no-redeclare */
+import { tool, type Tool } from "ai"
+import { z, type ZodType } from "zod"
+import type { Loaded } from "jazz-tools"
+import { UserAccount } from "#shared/schema/user"
+import { tryCatch } from "#shared/lib/trycatch"
+
+export {
+ defineTool,
+ createdSchema,
+ updatedSchema,
+ deletedSchema,
+ operationSchema,
+ mutationSchema,
+}
+
+export type { Created, Updated, Worker }
+
+type Worker = Loaded
+
+type Created = { operation: "create"; current: T }
+type Updated = { operation: "update"; current: T; previous: T }
+
+type Cancelled = { cancelled: true; reason: string }
+type ToolError = { error: string }
+
+type WrappedOutput = Cancellable extends true
+ ? TOut | ToolError | Cancelled
+ : TOut | ToolError
+
+function createdSchema(current: T) {
+ return z.object({
+ operation: z.literal("create"),
+ current,
+ })
+}
+
+function updatedSchema(current: T) {
+ return z.object({
+ operation: z.literal("update"),
+ current,
+ previous: current,
+ })
+}
+
+function operationSchema(current: T) {
+ return z.union([createdSchema(current), updatedSchema(current)])
+}
+
+function deletedSchema(current: T) {
+ return z.object({
+ operation: z.literal("delete"),
+ previous: current,
+ })
+}
+
+function mutationSchema(current: T) {
+ return z.union([operationSchema(current), deletedSchema(current)])
+}
+
+function defineTool<
+ TInput extends ZodType,
+ TOutput extends ZodType,
+ Cancellable extends boolean = false,
+>(args: {
+ description: string
+ input: TInput
+ output: TOutput
+ serverOp: (
+ worker: Worker,
+ input: z.infer,
+ ) => Promise>
+ cancellable?: Cancellable
+}): (
+ worker: Worker,
+) => Tool, WrappedOutput, Cancellable>>
+
+function defineTool<
+ TInput extends ZodType,
+ TOutput extends ZodType,
+ Cancellable extends boolean = false,
+>(args: {
+ description: string
+ input: TInput
+ output: TOutput
+ serverOp?: undefined
+ cancellable?: Cancellable
+}): Tool, WrappedOutput, Cancellable>>
+
+function defineTool(args: {
+ description: string
+ input: TInput
+ output: TOutput
+ serverOp?: (
+ worker: Worker,
+ input: z.infer,
+ ) => Promise>
+ cancellable?: boolean
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+}): any {
+ let outputSchema = wrapOutputSchema(args.output, args.cancellable ?? false)
+
+ if (!args.serverOp) {
+ return tool({
+ description: args.description,
+ inputSchema: args.input,
+ outputSchema,
+ })
+ }
+
+ let serverOp = args.serverOp
+ return (worker: Worker) =>
+ tool({
+ description: args.description,
+ inputSchema: args.input,
+ outputSchema,
+ execute: async (input: z.infer) => {
+ let res = await tryCatch(serverOp(worker, input))
+ if (!res.ok) return { error: errorMessage(res.error) }
+ return res.data
+ },
+ })
+}
+
+function wrapOutputSchema(success: ZodType, cancellable: boolean) {
+ let errorBranch = z.object({ error: z.string() })
+ if (cancellable) {
+ let cancelledBranch = z.object({
+ cancelled: z.literal(true),
+ reason: z.string(),
+ })
+ return z.union([errorBranch, cancelledBranch, success])
+ }
+ return z.union([errorBranch, success])
+}
+
+function errorMessage(error: unknown): string {
+ if (typeof error === "string") return error
+ if (error instanceof Error) return error.message
+ return String(error)
+}
diff --git a/src/shared/tools/note-create.test.ts b/src/shared/tools/note-create.test.ts
new file mode 100644
index 0000000..3cdbe7b
--- /dev/null
+++ b/src/shared/tools/note-create.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import { setActiveAccount } from "jazz-tools/testing"
+import type { co } from "jazz-tools"
+import { Note, UserAccount } from "#shared/schema/user"
+import { createNote } from "#shared/tools/note-create"
+import { createAccount, seedPerson } from "#shared/tools/test-helpers"
+
+describe("createNote", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("attaches a note to the person's notes list", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+
+ let result = await createNote(owner, {
+ personId: person.$jazz.id,
+ content: "first note",
+ })
+
+ expect(result.operation).toBe("create")
+ expect(result.current.personId).toBe(person.$jazz.id)
+ expect(result.current.content).toBe("first note")
+ expect(result.current.pinned).toBe(false)
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { notes: { $each: true } },
+ })
+ expect(loaded.notes.length).toBe(1)
+ expect(loaded.notes[0]?.content).toBe("first note")
+ expect(loaded.notes[0]?.$jazz.id).toBe(result.current.noteId)
+ })
+
+ test("content survives a reload via Note.load", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+
+ let result = await createNote(owner, {
+ personId: person.$jazz.id,
+ content: "persisted",
+ pinned: true,
+ title: "T",
+ })
+
+ let reloaded = await Note.load(result.current.noteId)
+ expect(reloaded?.$isLoaded).toBe(true)
+ if (!reloaded?.$isLoaded) return
+ expect(reloaded.content).toBe("persisted")
+ expect(reloaded.pinned).toBe(true)
+ expect(reloaded.title).toBe("T")
+ })
+
+ test("bumps the person's updatedAt", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let before = person.updatedAt.getTime()
+
+ await new Promise(r => setTimeout(r, 5))
+ await createNote(owner, {
+ personId: person.$jazz.id,
+ content: "x",
+ })
+
+ expect(person.updatedAt.getTime()).toBeGreaterThan(before)
+ })
+
+ test("throws when the person does not exist", async () => {
+ await expect(
+ createNote(owner, {
+ personId: "co_zNonExistent",
+ content: "x",
+ }),
+ ).rejects.toBeDefined()
+ })
+
+ describe("multi-user", () => {
+ let collaborator: co.loaded
+
+ beforeEach(async () => {
+ collaborator = await createAccount()
+ })
+
+ test("a writer collaborator can create a note on the shared person", async () => {
+ let person = await seedPerson(owner, {
+ name: "Shared Ada",
+ sharedWith: collaborator,
+ })
+ await owner.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(collaborator)
+
+ let result = await createNote(collaborator, {
+ personId: person.$jazz.id,
+ content: "from collaborator",
+ })
+
+ expect(result.current.content).toBe("from collaborator")
+
+ await collaborator.$jazz.waitForAllCoValuesSync()
+ setActiveAccount(owner)
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { notes: { $each: true } },
+ })
+ expect(loaded.notes.some(n => n?.content === "from collaborator")).toBe(
+ true,
+ )
+ })
+
+ test("a non-member cannot load the person, createNote rejects", async () => {
+ let person = await seedPerson(owner, { name: "Private Ada" })
+ await owner.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(collaborator)
+
+ await expect(
+ createNote(collaborator, {
+ personId: person.$jazz.id,
+ content: "should fail",
+ }),
+ ).rejects.toBeDefined()
+ })
+ })
+})
diff --git a/src/shared/tools/note-create.ts b/src/shared/tools/note-create.ts
index 11d912b..3f04aed 100644
--- a/src/shared/tools/note-create.ts
+++ b/src/shared/tools/note-create.ts
@@ -1,46 +1,72 @@
-import { tool } from "ai"
import { z } from "zod"
-import { Note, Person, UserAccount } from "#shared/schema/user"
-import { co, type Loaded } from "jazz-tools"
-import { tryCatch } from "#shared/lib/trycatch"
+import { co } from "jazz-tools"
import { createImage } from "jazz-tools/media"
+import { Note, Person } from "#shared/schema/user"
+import {
+ defineTool,
+ createdSchema,
+ type Created,
+ type Worker,
+} from "#shared/tools/define-tool"
-export { createAddNoteTool, createNote }
+export { createNote, createAddNoteTool, noteCurrent }
-export type { NoteData, NoteCreated }
+let noteCurrent = z.object({
+ noteId: z.string(),
+ personId: z.string(),
+ title: z.string().optional(),
+ content: z.string(),
+ pinned: z.boolean(),
+ imageCount: z.number().optional(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ deletedAt: z.string().optional(),
+})
+
+type NoteCurrent = z.infer
+
+let createNoteInput = z.object({
+ personId: z.string().describe("The person's ID"),
+ title: z.string().optional().describe("A short title for the note"),
+ content: z
+ .string()
+ .describe("The note content. Supports markdown formatting."),
+ pinned: z
+ .boolean()
+ .optional()
+ .describe("Whether to pin this note for prominent display"),
+})
+
+type CreateNoteInput = z.infer & {
+ imageFiles?: File[]
+}
async function createNote(
- data: Omit & {
- imageFiles?: File[]
- },
- options: {
- personId: string
- worker: Loaded
- },
-): Promise {
- let person = await Person.load(options.personId, {
+ worker: Worker,
+ input: CreateNoteInput,
+): Promise> {
+ let person = await Person.load(input.personId, {
resolve: { notes: { $each: true } },
- loadAs: options.worker,
+ loadAs: worker,
})
-
if (!person.$isLoaded) throw errors.PERSON_NOT_FOUND
let now = new Date()
let note = Note.create(
{
version: 1,
- title: data.title,
- content: data.content,
- pinned: data.pinned || false,
+ title: input.title,
+ content: input.content,
+ pinned: input.pinned ?? false,
createdAt: now,
updatedAt: now,
},
person.$jazz.owner,
)
- if (data.imageFiles && data.imageFiles.length > 0) {
+ if (input.imageFiles && input.imageFiles.length > 0) {
let imageList = co.list(co.image()).create([], person.$jazz.owner)
- for (let file of data.imageFiles.slice(0, 10)) {
+ for (let file of input.imageFiles.slice(0, 10)) {
let image = await createImage(file, {
owner: person.$jazz.owner,
maxSize: 2048,
@@ -59,56 +85,27 @@ async function createNote(
return {
operation: "create",
- noteID: note.$jazz.id,
- personID: options.personId,
- current: { ...note },
- _ref: note,
+ current: {
+ noteId: note.$jazz.id,
+ personId: input.personId,
+ title: note.title,
+ content: note.content,
+ pinned: note.pinned ?? false,
+ imageCount: note.imageCount,
+ createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt.toISOString(),
+ deletedAt: note.deletedAt?.toISOString(),
+ },
}
}
let errors = {
PERSON_NOT_FOUND: "person not found",
- NOTE_NOT_FOUND: "note not found",
} as const
-type NoteData = Parameters[0]
-
-type NoteCreated = {
- _ref: co.loaded
- operation: "create"
- noteID: string
- personID: string
- current: NoteData
-}
-
-function createAddNoteTool(worker: Loaded) {
- return tool({
- description: "Add a note to a person using their ID",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID"),
- title: z.string().describe("A short title for the note"),
- content: z
- .string()
- .describe("The note content. Supports markdown formatting."),
- pinned: z
- .boolean()
- .optional()
- .describe("Whether to pin this note for prominent display"),
- }),
- execute: async input => {
- let { personId, ...data } = input
- let res = await tryCatch(createNote(data, { personId, worker }))
- if (!res.ok) return { error: `${res.error}` }
- let result = res.data
- return {
- noteId: result.noteID,
- personId: result.personID,
- title: result.current.title || "",
- content: result.current.content,
- pinned: result.current.pinned || false,
- createdAt: result.current.createdAt.toISOString(),
- updatedAt: result.current.updatedAt.toISOString(),
- }
- },
- })
-}
+let createAddNoteTool = defineTool({
+ description: "Add a note to a person using their ID",
+ input: createNoteInput,
+ output: createdSchema(noteCurrent),
+ serverOp: createNote,
+})
diff --git a/src/shared/tools/note-read.test.ts b/src/shared/tools/note-read.test.ts
new file mode 100644
index 0000000..0e78262
--- /dev/null
+++ b/src/shared/tools/note-read.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import type { co } from "jazz-tools"
+import { UserAccount } from "#shared/schema/user"
+import { listNotes } from "#shared/tools/note-read"
+import { updateNote } from "#shared/tools/note-update"
+import { createAccount, seedNote, seedPerson } from "#shared/tools/test-helpers"
+
+describe("listNotes", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("returns notes across people, hides deleted by default", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ let grace = await seedPerson(owner, { name: "Grace" })
+ await seedNote(ada, { content: "ada-1" })
+ await seedNote(grace, { content: "grace-1" })
+ let gone = await seedNote(ada, { content: "gone" })
+ await updateNote(owner, {
+ personId: ada.$jazz.id,
+ noteId: gone.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let result = await listNotes(owner, { includeDeleted: false })
+ expect(result.notes.map(n => n.content).sort()).toEqual([
+ "ada-1",
+ "grace-1",
+ ])
+ expect(result.notes.find(n => n.content === "ada-1")?.person.name).toBe(
+ "Ada",
+ )
+ })
+
+ test("pinned notes appear first", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ await seedNote(ada, { content: "regular-1" })
+ await seedNote(ada, { content: "pinned-1", pinned: true })
+ await seedNote(ada, { content: "regular-2" })
+
+ let result = await listNotes(owner, { includeDeleted: false })
+ expect(result.notes[0]?.content).toBe("pinned-1")
+ })
+
+ test("search filters by content, title, person name", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ let grace = await seedPerson(owner, { name: "Grace" })
+ await seedNote(ada, { content: "needle here" })
+ await seedNote(ada, { content: "boring", title: "needle title" })
+ await seedNote(grace, { content: "plain" })
+
+ let byContent = await listNotes(owner, {
+ searchQuery: "needle here",
+ includeDeleted: false,
+ })
+ expect(byContent.filteredCount).toBe(1)
+
+ let byTitle = await listNotes(owner, {
+ searchQuery: "needle title",
+ includeDeleted: false,
+ })
+ expect(byTitle.filteredCount).toBe(1)
+
+ let byPerson = await listNotes(owner, {
+ searchQuery: "grace",
+ includeDeleted: false,
+ })
+ expect(byPerson.notes.every(n => n.person.name === "Grace")).toBe(true)
+ })
+
+ test("includeDeleted appends deleted notes at the end", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ await seedNote(ada, { content: "alive" })
+ let dead = await seedNote(ada, { content: "dead" })
+ await updateNote(owner, {
+ personId: ada.$jazz.id,
+ noteId: dead.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let result = await listNotes(owner, { includeDeleted: true })
+ expect(result.notes.map(n => n.content)).toEqual(["alive", "dead"])
+ expect(result.notes[1]?.deleted).toBe(true)
+ })
+})
diff --git a/src/shared/tools/note-read.ts b/src/shared/tools/note-read.ts
index 1eb79c6..6961b7d 100644
--- a/src/shared/tools/note-read.ts
+++ b/src/shared/tools/note-read.ts
@@ -1,16 +1,47 @@
-import { tool } from "ai"
import { z } from "zod"
-import { co, type Loaded } from "jazz-tools"
-import { UserAccount, Person, Note, isDeleted } from "#shared/schema/user"
-import { tryCatch } from "#shared/lib/trycatch"
+import { co } from "jazz-tools"
+import { Person, Note, isDeleted } from "#shared/schema/user"
+import { defineTool, type Worker } from "#shared/tools/define-tool"
-export { createListNotesTool }
-export type { ListNotesResult }
+export { listNotes, createListNotesTool }
+
+let noteSummary = z.object({
+ id: z.string(),
+ title: z.string(),
+ content: z.string(),
+ pinned: z.boolean(),
+ deleted: z.boolean(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ deletedAt: z.string().optional(),
+ person: z.object({
+ id: z.string(),
+ name: z.string(),
+ }),
+})
+
+let listNotesOutput = z.object({
+ operation: z.literal("list"),
+ notes: z.array(noteSummary),
+ totalCount: z.number(),
+ filteredCount: z.number(),
+ searchQuery: z.string().optional(),
+})
+
+let listNotesInput = z.object({
+ searchQuery: z
+ .string()
+ .optional()
+ .describe(
+ "Optional query to filter notes by content, title, or person name",
+ ),
+ includeDeleted: z.boolean().optional(),
+})
async function listNotes(
- options: { searchQuery?: string; includeDeleted?: boolean },
- worker: Loaded,
-): Promise {
+ worker: Worker,
+ input: z.infer,
+): Promise> {
let user = await worker.$jazz.ensureLoaded({
resolve: {
root: {
@@ -30,17 +61,15 @@ async function listNotes(
if (!person) continue
if (isDeleted(person)) continue
- // Check active notes
if (person.notes) {
for (let note of person.notes.values()) {
if (!note) continue
- if (!options.includeDeleted && isDeleted(note)) continue
+ if (!input.includeDeleted && isDeleted(note)) continue
notePairs.push({ note, person })
}
}
- // Check inactive notes if includeDeleted is true
- if (person.inactiveNotes && options.includeDeleted) {
+ if (person.inactiveNotes && input.includeDeleted) {
for (let note of person.inactiveNotes.values()) {
if (!note) continue
notePairs.push({ note, person })
@@ -49,8 +78,8 @@ async function listNotes(
}
let filteredPairs = notePairs
- if (options.searchQuery) {
- let searchLower = options.searchQuery.toLowerCase()
+ if (input.searchQuery) {
+ let searchLower = input.searchQuery.toLowerCase()
filteredPairs = notePairs.filter(({ note, person }) => {
let noteContent = note.content.toLowerCase()
let noteTitle = (note.title || "").toLowerCase()
@@ -95,75 +124,30 @@ async function listNotes(
return {
operation: "list",
- notes: orderedPairs.map(toNotePayload),
+ notes: orderedPairs.map(({ note, person }) => ({
+ id: note.$jazz.id,
+ title: note.title || "",
+ content: note.content,
+ pinned: note.pinned || false,
+ deleted: isDeleted(note),
+ deletedAt: note.deletedAt?.toISOString(),
+ createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt.toISOString(),
+ person: {
+ id: person.$jazz.id,
+ name: person.name,
+ },
+ })),
totalCount: notePairs.length,
filteredCount: filteredPairs.length,
- ...(options.searchQuery && { searchQuery: options.searchQuery }),
- }
-}
-
-function toNotePayload({
- note,
- person,
-}: {
- note: co.loaded
- person: co.loaded
-}): NoteSummary {
- return {
- id: note.$jazz.id,
- title: note.title || "",
- content: note.content,
- pinned: note.pinned || false,
- deleted: isDeleted(note),
- deletedAt: note.deletedAt?.toISOString(),
- createdAt: note.createdAt.toISOString(),
- updatedAt: note.updatedAt.toISOString(),
- person: {
- id: person.$jazz.id,
- name: person.name,
- },
+ ...(input.searchQuery && { searchQuery: input.searchQuery }),
}
}
-type NoteSummary = {
- id: string
- title: string
- content: string
- pinned: boolean
- deleted: boolean
- createdAt: string
- updatedAt: string
- deletedAt?: string
- person: {
- id: string
- name: string
- }
-}
-
-type ListNotesResult = {
- operation: "list"
- notes: Array
- totalCount: number
- filteredCount: number
- searchQuery?: string
-}
-
-function createListNotesTool(worker: Loaded) {
- return tool({
- description:
- "List notes across all people with optional search on note content, titles, and person names. Note content supports markdown formatting.",
- inputSchema: z.object({
- searchQuery: z
- .string()
- .optional()
- .describe(
- "Optional query to filter notes by content, title, or person name",
- ),
- }),
- execute: async input => {
- let result = await tryCatch(listNotes(input, worker))
- if (!result.ok) return { error: `${result.error}` }
- return result.data
- },
- })
-}
+let createListNotesTool = defineTool({
+ description:
+ "List notes across all people with optional search on note content, titles, and person names. Note content supports markdown formatting.",
+ input: listNotesInput,
+ output: listNotesOutput,
+ serverOp: listNotes,
+})
diff --git a/src/shared/tools/note-update.test.ts b/src/shared/tools/note-update.test.ts
new file mode 100644
index 0000000..c47771b
--- /dev/null
+++ b/src/shared/tools/note-update.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import { setActiveAccount } from "jazz-tools/testing"
+import type { co } from "jazz-tools"
+import { Note, UserAccount } from "#shared/schema/user"
+import { updateNote } from "#shared/tools/note-update"
+import { createAccount, seedNote, seedPerson } from "#shared/tools/test-helpers"
+
+describe("updateNote", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("edits content and returns previous + current", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let note = await seedNote(person, { content: "before" })
+
+ let result = await updateNote(owner, {
+ personId: person.$jazz.id,
+ noteId: note.$jazz.id,
+ content: "after",
+ })
+
+ expect(result.previous.content).toBe("before")
+ expect(result.current.content).toBe("after")
+
+ let reloaded = await Note.load(note.$jazz.id)
+ expect(reloaded?.$isLoaded ? reloaded.content : null).toBe("after")
+ })
+
+ test("toggle pin", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let note = await seedNote(person, { content: "x", pinned: false })
+
+ let result = await updateNote(owner, {
+ personId: person.$jazz.id,
+ noteId: note.$jazz.id,
+ pinned: true,
+ })
+ expect(result.previous.pinned).toBe(false)
+ expect(result.current.pinned).toBe(true)
+ })
+
+ test("soft delete moves note from notes to inactiveNotes", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let note = await seedNote(person, { content: "delete me" })
+
+ await updateNote(owner, {
+ personId: person.$jazz.id,
+ noteId: note.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { notes: { $each: true }, inactiveNotes: { $each: true } },
+ })
+ expect(loaded.notes.some(n => n?.$jazz.id === note.$jazz.id)).toBe(false)
+ expect(loaded.inactiveNotes?.some(n => n?.$jazz.id === note.$jazz.id)).toBe(
+ true,
+ )
+ })
+
+ test("restore moves note back from inactiveNotes", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let note = await seedNote(person, { content: "restore me" })
+ await updateNote(owner, {
+ personId: person.$jazz.id,
+ noteId: note.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ await updateNote(owner, {
+ personId: person.$jazz.id,
+ noteId: note.$jazz.id,
+ deletedAt: undefined,
+ })
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { notes: { $each: true }, inactiveNotes: { $each: true } },
+ })
+ expect(loaded.notes.some(n => n?.$jazz.id === note.$jazz.id)).toBe(true)
+ expect(loaded.inactiveNotes?.some(n => n?.$jazz.id === note.$jazz.id)).toBe(
+ false,
+ )
+ })
+
+ test("throws when note not found", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await expect(
+ updateNote(owner, {
+ personId: person.$jazz.id,
+ noteId: "co_zNope",
+ content: "x",
+ }),
+ ).rejects.toBeDefined()
+ })
+
+ describe("multi-user", () => {
+ test("collaborator edit visible to owner", async () => {
+ let collaborator = await createAccount()
+ let person = await seedPerson(owner, {
+ name: "Shared",
+ sharedWith: collaborator,
+ })
+ let note = await seedNote(person, { content: "original" })
+ await owner.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(collaborator)
+ await updateNote(collaborator, {
+ personId: person.$jazz.id,
+ noteId: note.$jazz.id,
+ content: "edited remotely",
+ })
+ await collaborator.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(owner)
+ let reloaded = await Note.load(note.$jazz.id)
+ expect(reloaded?.$isLoaded ? reloaded.content : null).toBe(
+ "edited remotely",
+ )
+ })
+ })
+})
diff --git a/src/shared/tools/note-update.ts b/src/shared/tools/note-update.ts
index 6236302..186ac9f 100644
--- a/src/shared/tools/note-update.ts
+++ b/src/shared/tools/note-update.ts
@@ -1,32 +1,52 @@
-import { tool } from "ai"
import { z } from "zod"
-import { Note, Person, UserAccount } from "#shared/schema/user"
-import { co, type Loaded } from "jazz-tools"
-import { tryCatch } from "#shared/lib/trycatch"
+import { co } from "jazz-tools"
import { createImage } from "jazz-tools/media"
-
-export { createEditNoteTool, createDeleteNoteTool, updateNote }
-
-export type { NoteData, NoteUpdated }
+import { Note, Person } from "#shared/schema/user"
+import {
+ defineTool,
+ updatedSchema,
+ type Updated,
+ type Worker,
+} from "#shared/tools/define-tool"
+import { noteCurrent } from "#shared/tools/note-create"
+
+export { updateNote, createEditNoteTool, createDeleteNoteTool }
+
+type NoteCurrent = z.infer
+
+let editNoteInput = z.object({
+ personId: z.string().describe("The person's ID who owns the note"),
+ noteId: z.string().describe("The note's ID"),
+ title: z.string().optional().describe("Updated title"),
+ content: z
+ .string()
+ .optional()
+ .describe("The updated note content. Supports markdown formatting."),
+ pinned: z
+ .boolean()
+ .optional()
+ .describe(
+ "Whether the note should be pinned. Pinned notes appear at the top of the note list.",
+ ),
+ createdAt: z
+ .string()
+ .optional()
+ .describe("Updated creation date (date string)"),
+})
+
+type UpdateNoteInput = z.infer & {
+ deletedAt?: Date | string | undefined
+ imageFiles?: File[]
+ removedImageIds?: string[]
+}
async function updateNote(
- updates: Partial<
- Pick & {
- deletedAt: Date | string | undefined
- }
- > & {
- imageFiles?: File[]
- removedImageIds?: string[]
- },
- options: {
- personId: string
- noteId: string
- worker: Loaded
- },
-): Promise {
- let person = await Person.load(options.personId, {
+ worker: Worker,
+ input: UpdateNoteInput,
+): Promise> {
+ let person = await Person.load(input.personId, {
resolve: { notes: true, inactiveNotes: true },
- loadAs: options.worker,
+ loadAs: worker,
})
if (!person.$isLoaded) throw errors.PERSON_NOT_FOUND
@@ -37,35 +57,26 @@ async function updateNote(
)
}
- let note = await Note.load(options.noteId, {
+ let note = await Note.load(input.noteId, {
resolve: { images: { $each: true } },
- loadAs: options.worker,
+ loadAs: worker,
})
if (!note.$isLoaded) throw errors.NOTE_NOT_FOUND
- let previous = {
- version: note.version,
- title: note.title,
- content: note.content,
- pinned: note.pinned,
- deletedAt: note.deletedAt,
- createdAt: note.createdAt,
- updatedAt: note.updatedAt,
- imageCount: note.imageCount,
- }
+ let previous: NoteCurrent = serializeNote(note, input.personId)
- if (updates.title !== undefined) {
- note.$jazz.set("title", updates.title)
+ if (input.title !== undefined) {
+ note.$jazz.set("title", input.title)
}
- if (updates.content !== undefined) {
- note.$jazz.set("content", updates.content)
+ if (input.content !== undefined) {
+ note.$jazz.set("content", input.content)
}
- if (updates.pinned !== undefined) {
- note.$jazz.set("pinned", updates.pinned)
+ if (input.pinned !== undefined) {
+ note.$jazz.set("pinned", input.pinned)
}
- if (updates.createdAt !== undefined) {
- let createdDate = new Date(updates.createdAt)
+ if (input.createdAt !== undefined) {
+ let createdDate = new Date(input.createdAt)
let now = new Date()
createdDate.setHours(
now.getHours(),
@@ -76,14 +87,11 @@ async function updateNote(
note.$jazz.set("createdAt", createdDate)
}
- if (
- updates.imageFiles !== undefined ||
- updates.removedImageIds !== undefined
- ) {
+ if (input.imageFiles !== undefined || input.removedImageIds !== undefined) {
let imageList = co.list(co.image()).create([])
if (note.images?.$isLoaded) {
- let removedIds = new Set(updates.removedImageIds ?? [])
+ let removedIds = new Set(input.removedImageIds ?? [])
for (let existingImage of note.images.values()) {
if (existingImage && !removedIds.has(existingImage.$jazz.id)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -92,9 +100,9 @@ async function updateNote(
}
}
- if (updates.imageFiles) {
+ if (input.imageFiles) {
let remainingSlots = 10 - imageList.length
- for (let file of updates.imageFiles.slice(0, remainingSlots)) {
+ for (let file of input.imageFiles.slice(0, remainingSlots)) {
let image = await createImage(file, {
owner: person.$jazz.owner,
maxSize: 2048,
@@ -115,12 +123,11 @@ async function updateNote(
}
}
- if ("deletedAt" in updates && updates.deletedAt === undefined) {
+ if ("deletedAt" in input && input.deletedAt === undefined) {
note.$jazz.delete("deletedAt")
- // Move to active
if (person.inactiveNotes?.$isLoaded) {
let inactiveIdx = Array.from(person.inactiveNotes.values()).findIndex(
- n => n?.$jazz.id === options.noteId,
+ n => n?.$jazz.id === input.noteId,
)
if (inactiveIdx !== -1) {
person.notes.$jazz.push(note)
@@ -129,12 +136,11 @@ async function updateNote(
}
}
- if (updates.deletedAt !== undefined) {
- note.$jazz.set("deletedAt", new Date(updates.deletedAt))
- // Move to inactive
+ if (input.deletedAt !== undefined) {
+ note.$jazz.set("deletedAt", new Date(input.deletedAt))
if (person.inactiveNotes?.$isLoaded) {
let activeIdx = Array.from(person.notes.values()).findIndex(
- n => n?.$jazz.id === options.noteId,
+ n => n?.$jazz.id === input.noteId,
)
if (activeIdx !== -1) {
person.inactiveNotes.$jazz.push(note)
@@ -148,20 +154,25 @@ async function updateNote(
return {
operation: "update",
- noteID: options.noteId,
- personID: options.personId,
- current: {
- version: note.version,
- title: note.title,
- content: note.content,
- pinned: note.pinned,
- deletedAt: note.deletedAt,
- createdAt: note.createdAt,
- updatedAt: note.updatedAt,
- imageCount: note.imageCount,
- },
+ current: serializeNote(note, input.personId),
previous,
- _ref: note,
+ }
+}
+
+function serializeNote(
+ note: co.loaded,
+ personId: string,
+): NoteCurrent {
+ return {
+ noteId: note.$jazz.id,
+ personId,
+ title: note.title,
+ content: note.content,
+ pinned: note.pinned ?? false,
+ imageCount: note.imageCount,
+ createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt.toISOString(),
+ deletedAt: note.deletedAt?.toISOString(),
}
}
@@ -170,98 +181,22 @@ let errors = {
NOTE_NOT_FOUND: "note not found",
} as const
-type NoteData = Parameters[0]
-
-type NoteUpdated = {
- _ref: co.loaded
- operation: "update"
- noteID: string
- personID: string
- current: NoteData
- previous: NoteData
-}
-
-function createEditNoteTool(worker: Loaded) {
- return tool({
- description: "Edit a note by ID",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID who owns the note"),
- noteId: z.string().describe("The note's ID"),
- title: z.string().optional().describe("Updated title"),
- content: z
- .string()
- .optional()
- .describe("The updated note content. Supports markdown formatting."),
- pinned: z
- .boolean()
- .optional()
- .describe(
- "Whether the note should be pinned. Pinned notes appear at the top of the note list.",
- ),
- createdAt: z
- .string()
- .optional()
- .describe("Updated creation date (date string)"),
- }),
- execute: async input => {
- let { personId, noteId, createdAt, ...otherUpdates } = input
- let updates = {
- ...otherUpdates,
- ...(createdAt !== undefined && { createdAt: new Date(createdAt) }),
- }
-
- let res = await tryCatch(
- updateNote(updates, { personId, noteId, worker }),
- )
- if (!res.ok) return { error: `${res.error}` }
- let result = res.data
- return {
- personId: result.personID,
- noteId: result.noteID,
- title: result.current.title,
- content: result.current.content,
- pinned: result.current.pinned,
- deletedAt: result.current.deletedAt?.toISOString(),
- createdAt: result.current.createdAt.toISOString(),
- updatedAt: result.current.updatedAt.toISOString(),
- previous: {
- title: result.previous.title,
- content: result.previous.content,
- pinned: result.previous.pinned,
- deletedAt: result.current.deletedAt?.toISOString(),
- createdAt: result.previous.createdAt.toISOString(),
- updatedAt: result.previous.updatedAt.toISOString(),
- },
- }
- },
- })
-}
-
-function createDeleteNoteTool(worker: Loaded) {
- return tool({
- description: "Delete a note by ID",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID who owns the note"),
- noteId: z.string().describe("The note's ID"),
- }),
- execute: async input => {
- let { personId, noteId } = input
-
- let res = await tryCatch(
- updateNote({ deletedAt: new Date() }, { personId, noteId, worker }),
- )
- if (!res.ok) return { error: `${res.error}` }
- let result = res.data
- return {
- personId: result.personID,
- noteId: result.noteID,
- title: result.current.title,
- content: result.current.content,
- pinned: result.current.pinned,
- deletedAt: result.current.deletedAt?.toISOString(),
- createdAt: result.current.createdAt.toISOString(),
- updatedAt: result.current.updatedAt.toISOString(),
- }
- },
- })
-}
+let createEditNoteTool = defineTool({
+ description: "Edit a note by ID",
+ input: editNoteInput,
+ output: updatedSchema(noteCurrent),
+ serverOp: updateNote,
+})
+
+let deleteNoteInput = z.object({
+ personId: z.string().describe("The person's ID who owns the note"),
+ noteId: z.string().describe("The note's ID"),
+})
+
+let createDeleteNoteTool = defineTool({
+ description: "Delete a note by ID",
+ input: deleteNoteInput,
+ output: updatedSchema(noteCurrent),
+ serverOp: (worker, input) =>
+ updateNote(worker, { ...input, deletedAt: new Date() }),
+})
diff --git a/src/shared/tools/person-create.test.ts b/src/shared/tools/person-create.test.ts
new file mode 100644
index 0000000..083ddd8
--- /dev/null
+++ b/src/shared/tools/person-create.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import type { co } from "jazz-tools"
+import { Person, UserAccount } from "#shared/schema/user"
+import { createPerson } from "#shared/tools/person-create"
+import { createAccount } from "#shared/tools/test-helpers"
+
+describe("createPerson", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("creates a person and appends to root.people", async () => {
+ let result = await createPerson(owner, {
+ name: "Ada Lovelace",
+ summary: "mathematician",
+ })
+
+ expect(result.operation).toBe("create")
+ expect(result.current.name).toBe("Ada Lovelace")
+ expect(result.current.summary).toBe("mathematician")
+
+ let { root } = await owner.$jazz.ensureLoaded({
+ resolve: { root: { people: { $each: true } } },
+ })
+ expect(root.people.length).toBe(1)
+ expect(root.people[0]?.$jazz.id).toBe(result.current.personId)
+ expect(root.people[0]?.name).toBe("Ada Lovelace")
+ })
+
+ test("created person reloads from its ID with content intact", async () => {
+ let result = await createPerson(owner, { name: "Grace" })
+
+ let reloaded = await Person.load(result.current.personId)
+ expect(reloaded?.$isLoaded).toBe(true)
+ if (!reloaded?.$isLoaded) return
+ expect(reloaded.name).toBe("Grace")
+ })
+
+ test("summary is optional", async () => {
+ let result = await createPerson(owner, { name: "Anon" })
+ expect(result.current.summary).toBeUndefined()
+ })
+})
diff --git a/src/shared/tools/person-create.ts b/src/shared/tools/person-create.ts
index a3c6ef2..c2be214 100644
--- a/src/shared/tools/person-create.ts
+++ b/src/shared/tools/person-create.ts
@@ -1,21 +1,46 @@
-import { tool, type InferUITool } from "ai"
import { z } from "zod"
-import { Person, Note, UserAccount, Reminder } from "#shared/schema/user"
-import { co, Group } from "jazz-tools"
+import { Group, co } from "jazz-tools"
import { createImage } from "jazz-tools/media"
-import { tryCatch } from "#shared/lib/trycatch"
+import { Person, Note, Reminder } from "#shared/schema/user"
+import {
+ defineTool,
+ createdSchema,
+ type Created,
+ type Worker,
+} from "#shared/tools/define-tool"
-export { createPersonTool, createPersonExecute, createPerson }
+export { createPerson, createPersonTool, personCurrent }
-export type { PersonData, PersonCreated }
+let personCurrent = z.object({
+ personId: z.string(),
+ name: z.string(),
+ summary: z.string().optional(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ deletedAt: z.string().optional(),
+})
+
+type PersonCurrent = z.infer
+
+let createPersonInput = z.object({
+ name: z.string().describe("The person's name"),
+ summary: z
+ .string()
+ .optional()
+ .describe(
+ "A compact summary displayed next to the person's name and avatar. Should include key details like relationship, profession, location, and personality traits. Example: 'sister in law, doctor, lives in switzerland with erik, high energy and positivity'",
+ ),
+})
+
+type CreatePersonInput = z.infer & {
+ avatarFile?: File | null
+}
async function createPerson(
- userId: string,
- data: Omit & {
- avatarFile?: File | null
- },
-): Promise {
- let account = await UserAccount.load(userId, {
+ worker: Worker,
+ input: CreatePersonInput,
+): Promise> {
+ let account = await worker.$jazz.ensureLoaded({
resolve: { root: { people: { $each: true } } },
})
if (!account.$isLoaded) throw errors.USER_ACCOUNT_NOT_FOUND
@@ -25,8 +50,8 @@ async function createPerson(
let person = Person.create(
{
version: 1,
- name: data.name,
- summary: data.summary,
+ name: input.name,
+ summary: input.summary,
notes: co.list(Note).create([], group),
reminders: co.list(Reminder).create([], group),
createdAt: now,
@@ -35,20 +60,20 @@ async function createPerson(
group,
)
- if (data.avatarFile) {
+ if (input.avatarFile) {
try {
- let avatar = await createImage(data.avatarFile, {
+ let avatar = await createImage(input.avatarFile, {
owner: group,
maxSize: 2048,
placeholder: "blur",
progressive: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- person.$jazz.set("avatar", avatar as any) // TODO: is this an error on the jazz side of things?
+ person.$jazz.set("avatar", avatar as any)
} catch (error) {
console.warn("Failed to create avatar:", error)
}
- } else if (data.avatarFile === null) {
+ } else if (input.avatarFile === null) {
person.$jazz.delete("avatar")
}
@@ -56,77 +81,23 @@ async function createPerson(
return {
operation: "create",
- personID: person.$jazz.id,
current: {
+ personId: person.$jazz.id,
name: person.name,
summary: person.summary,
- version: 1,
+ createdAt: person.createdAt.toISOString(),
+ updatedAt: person.updatedAt.toISOString(),
},
- _ref: person,
}
}
let errors = {
- PERSON_NOT_FOUND: "person not found",
USER_ACCOUNT_NOT_FOUND: "user account not found",
} as const
-type PersonData = {
- name: string
- summary?: string
- version: number
-}
-
-type PersonCreated = {
- _ref: co.loaded
- operation: "create"
- personID: string
- current: PersonData
-}
-
-let createPersonTool = tool({
+let createPersonTool = defineTool({
description: "Create a new person in the CRM",
- inputSchema: z.object({
- name: z.string().describe("The person's name"),
- summary: z
- .string()
- .optional()
- .describe(
- "A compact summary displayed next to the person's name and avatar. Should include key details like relationship, profession, location, and personality traits. Example: 'sister in law, doctor, lives in switzerland with erik, high energy and positivity'",
- ),
- }),
- outputSchema: z.union([
- z.object({
- error: z.string(),
- }),
- z.object({
- cancelled: z.literal(true),
- reason: z.string(),
- }),
- z.object({
- personId: z.string(),
- name: z.string(),
- summary: z.string().optional(),
- createdAt: z.string(),
- updatedAt: z.string(),
- }),
- ]),
+ input: createPersonInput,
+ output: createdSchema(personCurrent),
+ cancellable: true,
})
-
-type _CreatePersonTool = InferUITool
-
-async function createPersonExecute(
- userId: string,
- input: _CreatePersonTool["input"],
-): Promise<_CreatePersonTool["output"]> {
- let res = await tryCatch(createPerson(userId, input))
- if (!res.ok) return { error: String(res.error) }
- let { _ref, ...data } = res.data
- return {
- personId: data.personID,
- name: data.current.name,
- summary: data.current.summary,
- createdAt: _ref.createdAt.toISOString(),
- updatedAt: _ref.updatedAt.toISOString(),
- }
-}
diff --git a/src/shared/tools/person-read.test.ts b/src/shared/tools/person-read.test.ts
new file mode 100644
index 0000000..b963d13
--- /dev/null
+++ b/src/shared/tools/person-read.test.ts
@@ -0,0 +1,130 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import type { co } from "jazz-tools"
+import { UserAccount } from "#shared/schema/user"
+import { getPersonDetails, listPeople } from "#shared/tools/person-read"
+import { updatePerson } from "#shared/tools/person-update"
+import {
+ createAccount,
+ seedNote,
+ seedPerson,
+ seedReminder,
+} from "#shared/tools/test-helpers"
+
+describe("listPeople", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("returns active people, hides deleted by default", async () => {
+ await seedPerson(owner, { name: "Active" })
+ let deleted = await seedPerson(owner, { name: "Deleted" })
+ await updatePerson(owner, {
+ personId: deleted.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let result = await listPeople(owner, { includeDeleted: false })
+ expect(result.count).toBe(1)
+ expect(result.people[0]?.name).toBe("Active")
+ })
+
+ test("includeDeleted returns both", async () => {
+ await seedPerson(owner, { name: "Active" })
+ let deleted = await seedPerson(owner, { name: "Deleted" })
+ await updatePerson(owner, {
+ personId: deleted.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let result = await listPeople(owner, { includeDeleted: true })
+ expect(result.count).toBe(2)
+ })
+
+ test("search filters by name and summary", async () => {
+ await seedPerson(owner, { name: "Ada Lovelace" })
+ await seedPerson(owner, { name: "Grace Hopper", summary: "compilers" })
+ await seedPerson(owner, { name: "Random", summary: "ada is here too" })
+
+ let byName = await listPeople(owner, {
+ search: "grace",
+ includeDeleted: false,
+ })
+ expect(byName.people.map(p => p.name)).toContain("Grace Hopper")
+
+ let bySummary = await listPeople(owner, {
+ search: "compilers",
+ includeDeleted: false,
+ })
+ expect(bySummary.people.map(p => p.name)).toContain("Grace Hopper")
+ })
+})
+
+describe("getPersonDetails", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("returns the person with notes and reminders", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await seedNote(person, { content: "note one" })
+ await seedNote(person, { content: "note two" })
+ await seedReminder(person, { text: "ping", dueAtDate: "2099-01-01" })
+
+ let result = await getPersonDetails(owner, {
+ personId: person.$jazz.id,
+ includeDeletedNotes: false,
+ includeDeletedReminders: false,
+ })
+
+ expect(result.name).toBe("Ada")
+ expect(result.notes).toHaveLength(2)
+ expect(result.reminders).toHaveLength(1)
+ })
+
+ test("hides soft-deleted notes by default", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await seedNote(person, { content: "kept" })
+ await seedNote(person, { content: "gone", deletedAt: new Date() })
+
+ let result = await getPersonDetails(owner, {
+ personId: person.$jazz.id,
+ includeDeletedNotes: false,
+ includeDeletedReminders: false,
+ })
+ expect(result.notes.map(n => n.content)).toEqual(["kept"])
+ })
+
+ test("search filters notes and reminders", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await seedNote(person, { content: "needle in haystack" })
+ await seedNote(person, { content: "unrelated" })
+ await seedReminder(person, {
+ text: "needle ping",
+ dueAtDate: "2099-01-01",
+ })
+ await seedReminder(person, { text: "other ping", dueAtDate: "2099-01-01" })
+
+ let result = await getPersonDetails(owner, {
+ personId: person.$jazz.id,
+ search: "needle",
+ includeDeletedNotes: false,
+ includeDeletedReminders: false,
+ })
+ expect(result.notes.map(n => n.content)).toEqual(["needle in haystack"])
+ expect(result.reminders.map(r => r.text)).toEqual(["needle ping"])
+ })
+
+ test("throws when person not found", async () => {
+ await expect(
+ getPersonDetails(owner, {
+ personId: "co_zNope",
+ includeDeletedNotes: false,
+ includeDeletedReminders: false,
+ }),
+ ).rejects.toBeDefined()
+ })
+})
diff --git a/src/shared/tools/person-read.ts b/src/shared/tools/person-read.ts
index 9890467..4f88d1e 100644
--- a/src/shared/tools/person-read.ts
+++ b/src/shared/tools/person-read.ts
@@ -1,68 +1,79 @@
-import { tool } from "ai"
import { z } from "zod"
-import { co, type Loaded } from "jazz-tools"
-import { Person, UserAccount, isDeleted } from "#shared/schema/user"
-
-export { createListPeopleTool, createGetPersonDetailsTool }
-
-function createListPeopleTool(worker: Loaded) {
- return tool({
- description:
- "List people with their ID, name, and summary. Optionally search people by names and summaries.",
- inputSchema: z.object({
- search: z
- .string()
- .optional()
- .describe(
- "Optional search query to filter people by names and summaries",
- ),
- includeDeleted: z
- .boolean()
- .optional()
- .default(false)
- .describe("Include deleted people in results"),
- }),
- execute: async input => {
- let me = await worker.$jazz.ensureLoaded({
- resolve: {
- root: { people: { $each: true }, inactivePeople: { $each: true } },
- },
- })
- if (!me.root.people) {
- return { error: "No people data available" }
- }
-
- // Merge active and inactive people when includeDeleted is true
- let peopleToSearch = input.includeDeleted
- ? [...me.root.people, ...(me.root.inactivePeople || [])]
- : me.root.people
-
- let allPeople = peopleToSearch
- .filter(person => person != null)
- .filter(person => input.includeDeleted || !isDeleted(person))
-
- let people
- if (input.search) {
- people = searchPeople(allPeople, input.search).map(person => ({
- id: person.$jazz.id,
- name: person.name,
- summary: person.summary || null,
- deletedAt: person.deletedAt,
- }))
- } else {
- people = allPeople.map(person => ({
- id: person.$jazz.id,
- name: person.name,
- summary: person.summary || null,
- deletedAt: person.deletedAt,
- }))
- }
-
- return { people, count: people.length, searchQuery: input.search }
+import { co } from "jazz-tools"
+import { Person, isDeleted } from "#shared/schema/user"
+import { defineTool, type Worker } from "#shared/tools/define-tool"
+
+export {
+ listPeople,
+ getPersonDetails,
+ createListPeopleTool,
+ createGetPersonDetailsTool,
+}
+
+let personListItem = z.object({
+ id: z.string(),
+ name: z.string(),
+ summary: z.string().nullable(),
+ deletedAt: z.date().optional(),
+})
+
+let listPeopleInput = z.object({
+ search: z
+ .string()
+ .optional()
+ .describe("Optional search query to filter people by names and summaries"),
+ includeDeleted: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe("Include deleted people in results"),
+})
+
+let listPeopleOutput = z.object({
+ people: z.array(personListItem),
+ count: z.number(),
+ searchQuery: z.string().optional(),
+})
+
+async function listPeople(
+ worker: Worker,
+ input: z.infer,
+): Promise> {
+ let me = await worker.$jazz.ensureLoaded({
+ resolve: {
+ root: { people: { $each: true }, inactivePeople: { $each: true } },
},
})
+ if (!me.root.people) throw new Error("No people data available")
+
+ let peopleToSearch = input.includeDeleted
+ ? [...me.root.people, ...(me.root.inactivePeople || [])]
+ : me.root.people
+
+ let allPeople = peopleToSearch
+ .filter(person => person != null)
+ .filter(person => input.includeDeleted || !isDeleted(person))
+
+ let matched = input.search ? searchPeople(allPeople, input.search) : allPeople
+
+ let people = matched.map(person => ({
+ id: person.$jazz.id,
+ name: person.name,
+ summary: person.summary || null,
+ deletedAt: person.deletedAt,
+ }))
+
+ return { people, count: people.length, searchQuery: input.search }
}
+let createListPeopleTool = defineTool({
+ description:
+ "List people with their ID, name, and summary. Optionally search people by names and summaries.",
+ input: listPeopleInput,
+ output: listPeopleOutput,
+ serverOp: listPeople,
+})
+
function searchPeople(
people: Array>,
searchName: string,
@@ -80,92 +91,122 @@ function searchPeople(
.slice(0, 3)
}
-function createGetPersonDetailsTool(worker: Loaded) {
- return tool({
- description:
- "Get detailed information for a specific person by their ID. Returns the person's full data including all notes and reminders. Optionally filter notes and reminders using a search query.",
- inputSchema: z.object({
- personId: z
- .string()
- .describe("The unique ID of the person to retrieve details for"),
- search: z
- .string()
- .optional()
- .describe(
- "Optional search query to filter notes and reminders by content",
- ),
- includeDeletedNotes: z
- .boolean()
- .optional()
- .default(false)
- .describe("Include deleted notes in results"),
- includeDeletedReminders: z
- .boolean()
- .optional()
- .default(false)
- .describe("Include deleted reminders in results"),
+let getPersonDetailsInput = z.object({
+ personId: z
+ .string()
+ .describe("The unique ID of the person to retrieve details for"),
+ search: z
+ .string()
+ .optional()
+ .describe("Optional search query to filter notes and reminders by content"),
+ includeDeletedNotes: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe("Include deleted notes in results"),
+ includeDeletedReminders: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe("Include deleted reminders in results"),
+})
+
+let personDetailsOutput = z.object({
+ personId: z.string(),
+ name: z.string(),
+ summary: z.string().optional(),
+ deletedAt: z.string().optional(),
+ notes: z.array(
+ z.object({
+ id: z.string(),
+ title: z.string(),
+ content: z.string(),
+ pinned: z.boolean(),
+ deletedAt: z.string().optional(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
}),
- execute: async input => {
- let fullPerson = await Person.load(input.personId, {
- resolve: {
- reminders: { $each: true },
- notes: { $each: true },
- },
- loadAs: worker,
- })
-
- if (!fullPerson.$isLoaded) {
- return { error: `Person with ID "${input.personId}" not found` }
- }
-
- let filteredNotes =
- fullPerson.notes?.filter(n => {
- if (!n) return false
- if (!input.includeDeletedNotes && isDeleted(n)) return false
- return true
- }) || []
-
- let filteredReminders =
- fullPerson.reminders?.filter(r => {
- if (!r) return false
- if (!input.includeDeletedReminders && isDeleted(r)) return false
- if (!input.includeDeletedReminders && r.done) return false
- return true
- }) || []
-
- if (input.search) {
- let searchLower = input.search.toLowerCase()
- filteredNotes = filteredNotes.filter(note =>
- note.content.toLowerCase().includes(searchLower),
- )
- filteredReminders = filteredReminders.filter(reminder =>
- reminder.text.toLowerCase().includes(searchLower),
- )
- }
-
- return {
- personId: fullPerson.$jazz.id,
- name: fullPerson.name,
- summary: fullPerson.summary,
- deletedAt: fullPerson.deletedAt?.toISOString(),
- notes: filteredNotes.map(n => ({
- id: n.$jazz.id,
- title: n.title || "",
- content: n.content,
- pinned: n.pinned || false,
- deletedAt: n.deletedAt?.toISOString(),
- createdAt: n.createdAt.toISOString(),
- updatedAt: n.updatedAt.toISOString(),
- })),
- reminders: filteredReminders.map(r => ({
- id: r.$jazz.id,
- text: r.text,
- dueAtDate: r.dueAtDate,
- deletedAt: r.deletedAt?.toISOString(),
- createdAt: r.createdAt.toISOString(),
- updatedAt: r.updatedAt.toISOString(),
- })),
- }
+ ),
+ reminders: z.array(
+ z.object({
+ id: z.string(),
+ text: z.string(),
+ dueAtDate: z.string(),
+ deletedAt: z.string().optional(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ }),
+ ),
+})
+
+async function getPersonDetails(
+ worker: Worker,
+ input: z.infer,
+): Promise> {
+ let fullPerson = await Person.load(input.personId, {
+ resolve: {
+ reminders: { $each: true },
+ notes: { $each: true },
},
+ loadAs: worker,
})
+ if (!fullPerson.$isLoaded)
+ throw new Error(`Person with ID "${input.personId}" not found`)
+
+ let filteredNotes =
+ fullPerson.notes?.filter(n => {
+ if (!n) return false
+ if (!input.includeDeletedNotes && isDeleted(n)) return false
+ return true
+ }) || []
+
+ let filteredReminders =
+ fullPerson.reminders?.filter(r => {
+ if (!r) return false
+ if (!input.includeDeletedReminders && isDeleted(r)) return false
+ if (!input.includeDeletedReminders && r.done) return false
+ return true
+ }) || []
+
+ if (input.search) {
+ let searchLower = input.search.toLowerCase()
+ filteredNotes = filteredNotes.filter(note =>
+ note.content.toLowerCase().includes(searchLower),
+ )
+ filteredReminders = filteredReminders.filter(reminder =>
+ reminder.text.toLowerCase().includes(searchLower),
+ )
+ }
+
+ return {
+ personId: fullPerson.$jazz.id,
+ name: fullPerson.name,
+ summary: fullPerson.summary,
+ deletedAt: fullPerson.deletedAt?.toISOString(),
+ notes: filteredNotes.map(n => ({
+ id: n.$jazz.id,
+ title: n.title || "",
+ content: n.content,
+ pinned: n.pinned || false,
+ deletedAt: n.deletedAt?.toISOString(),
+ createdAt: n.createdAt.toISOString(),
+ updatedAt: n.updatedAt.toISOString(),
+ })),
+ reminders: filteredReminders.map(r => ({
+ id: r.$jazz.id,
+ text: r.text,
+ dueAtDate: r.dueAtDate,
+ deletedAt: r.deletedAt?.toISOString(),
+ createdAt: r.createdAt.toISOString(),
+ updatedAt: r.updatedAt.toISOString(),
+ })),
+ }
}
+
+let createGetPersonDetailsTool = defineTool({
+ description:
+ "Get detailed information for a specific person by their ID. Returns the person's full data including all notes and reminders. Optionally filter notes and reminders using a search query.",
+ input: getPersonDetailsInput,
+ output: personDetailsOutput,
+ serverOp: getPersonDetails,
+})
diff --git a/src/shared/tools/person-update.test.ts b/src/shared/tools/person-update.test.ts
new file mode 100644
index 0000000..93e1757
--- /dev/null
+++ b/src/shared/tools/person-update.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import { setActiveAccount } from "jazz-tools/testing"
+import type { co } from "jazz-tools"
+import { Person, UserAccount } from "#shared/schema/user"
+import { updatePerson } from "#shared/tools/person-update"
+import { createAccount, seedPerson } from "#shared/tools/test-helpers"
+
+describe("updatePerson", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("updates name and summary, returns previous + current", async () => {
+ let person = await seedPerson(owner, {
+ name: "Ada",
+ summary: "old summary",
+ })
+
+ let result = await updatePerson(owner, {
+ personId: person.$jazz.id,
+ name: "Ada L.",
+ summary: "new summary",
+ })
+
+ expect(result.operation).toBe("update")
+ expect(result.previous.name).toBe("Ada")
+ expect(result.previous.summary).toBe("old summary")
+ expect(result.current.name).toBe("Ada L.")
+ expect(result.current.summary).toBe("new summary")
+ })
+
+ test("partial update leaves untouched fields", async () => {
+ let person = await seedPerson(owner, {
+ name: "Ada",
+ summary: "keep this",
+ })
+
+ let result = await updatePerson(owner, {
+ personId: person.$jazz.id,
+ name: "Ada L.",
+ })
+
+ expect(result.current.summary).toBe("keep this")
+ })
+
+ test("soft delete moves person from active to inactivePeople", async () => {
+ let person = await seedPerson(owner, { name: "Bye" })
+
+ await updatePerson(owner, {
+ personId: person.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let { root } = await owner.$jazz.ensureLoaded({
+ resolve: {
+ root: { people: { $each: true }, inactivePeople: { $each: true } },
+ },
+ })
+ expect(root.people.some(p => p?.$jazz.id === person.$jazz.id)).toBe(false)
+ expect(
+ root.inactivePeople?.some(p => p?.$jazz.id === person.$jazz.id),
+ ).toBe(true)
+ })
+
+ test("restore moves back from inactivePeople to active", async () => {
+ let person = await seedPerson(owner, { name: "Back" })
+ await updatePerson(owner, {
+ personId: person.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ await updatePerson(owner, {
+ personId: person.$jazz.id,
+ deletedAt: undefined,
+ })
+
+ let { root } = await owner.$jazz.ensureLoaded({
+ resolve: {
+ root: { people: { $each: true }, inactivePeople: { $each: true } },
+ },
+ })
+ expect(root.people.some(p => p?.$jazz.id === person.$jazz.id)).toBe(true)
+ expect(
+ root.inactivePeople?.some(p => p?.$jazz.id === person.$jazz.id),
+ ).toBe(false)
+ })
+
+ test("throws when person not found", async () => {
+ await expect(
+ updatePerson(owner, { personId: "co_zNope", name: "x" }),
+ ).rejects.toBeDefined()
+ })
+
+ describe("multi-user", () => {
+ test("collaborator edit is visible to owner after sync", async () => {
+ let collaborator = await createAccount()
+ let person = await seedPerson(owner, {
+ name: "Shared",
+ sharedWith: collaborator,
+ })
+ await owner.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(collaborator)
+ await updatePerson(collaborator, {
+ personId: person.$jazz.id,
+ name: "Edited by other",
+ })
+ await collaborator.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(owner)
+ let reloaded = await Person.load(person.$jazz.id)
+ expect(reloaded?.$isLoaded ? reloaded.name : null).toBe("Edited by other")
+ })
+ })
+})
diff --git a/src/shared/tools/person-update.ts b/src/shared/tools/person-update.ts
index 4e0836b..3bd0625 100644
--- a/src/shared/tools/person-update.ts
+++ b/src/shared/tools/person-update.ts
@@ -1,24 +1,40 @@
-import { tool } from "ai"
import { z } from "zod"
-import { Person, UserAccount, UserAccountRoot } from "#shared/schema/user"
-import { co, type Loaded, type ResolveQuery } from "jazz-tools"
+import { co, type ResolveQuery } from "jazz-tools"
import { createImage } from "jazz-tools/media"
-
-export { createUpdatePersonTool, createDeletePersonTool, updatePerson }
-export type { PersonData, PersonUpdated }
+import { Person, UserAccountRoot } from "#shared/schema/user"
+import {
+ defineTool,
+ updatedSchema,
+ type Updated,
+ type Worker,
+} from "#shared/tools/define-tool"
+import { personCurrent } from "#shared/tools/person-create"
+
+export { updatePerson, createUpdatePersonTool, createDeletePersonTool }
+
+type PersonCurrent = z.infer
+
+let updatePersonInput = z.object({
+ personId: z.string().describe("The person's ID"),
+ name: z.string().optional().describe("The person's new name"),
+ summary: z
+ .string()
+ .optional()
+ .describe(
+ "A compact summary displayed next to the person's name and avatar. Should include key details like relationship, profession, location, and personality traits. Example: 'sister in law, doctor, lives in switzerland with erik, high energy and positivity'",
+ ),
+})
+
+type UpdatePersonInput = z.infer & {
+ deletedAt?: Date | undefined
+ avatarFile?: File | null
+}
async function updatePerson(
- personId: string,
- updates: Partial<
- Pick & {
- deletedAt: Date | undefined
- }
- > & {
- avatarFile?: File | null
- },
- worker: Loaded,
-): Promise {
- let person = await Person.load(personId, { loadAs: worker })
+ worker: Worker,
+ input: UpdatePersonInput,
+): Promise> {
+ let person = await Person.load(input.personId, { loadAs: worker })
if (!person.$isLoaded) throw errors.PERSON_NOT_FOUND
let { root } = await worker.$jazz.ensureLoaded({
@@ -29,25 +45,20 @@ async function updatePerson(
root.$jazz.set("inactivePeople", co.list(Person).create([]))
}
- let previous = {
- name: person.name,
- summary: person.summary,
- version: person.version,
- }
+ let previous: PersonCurrent = serializePerson(person)
- if (updates.name !== undefined) {
- person.$jazz.set("name", updates.name)
+ if (input.name !== undefined) {
+ person.$jazz.set("name", input.name)
}
- if (updates.summary !== undefined) {
- person.$jazz.set("summary", updates.summary)
+ if (input.summary !== undefined) {
+ person.$jazz.set("summary", input.summary)
}
- if ("deletedAt" in updates && updates.deletedAt === undefined) {
+ if ("deletedAt" in input && input.deletedAt === undefined) {
person.$jazz.delete("deletedAt")
- // Move from inactive to active
if (root.inactivePeople) {
let inactiveIdx = Array.from(root.inactivePeople.values()).findIndex(
- p => p?.$jazz.id === personId,
+ p => p?.$jazz.id === input.personId,
)
if (inactiveIdx !== -1) {
root.people.$jazz.push(person)
@@ -56,12 +67,11 @@ async function updatePerson(
}
}
- if (updates.deletedAt !== undefined) {
- person.$jazz.set("deletedAt", updates.deletedAt)
- // Move from active to inactive
+ if (input.deletedAt !== undefined) {
+ person.$jazz.set("deletedAt", input.deletedAt)
if (root.inactivePeople) {
let activeIdx = Array.from(root.people.values()).findIndex(
- p => p?.$jazz.id === personId,
+ p => p?.$jazz.id === input.personId,
)
if (activeIdx !== -1) {
root.inactivePeople.$jazz.push(person)
@@ -70,11 +80,11 @@ async function updatePerson(
}
}
- if (updates.avatarFile !== undefined) {
- if (updates.avatarFile === null) {
+ if (input.avatarFile !== undefined) {
+ if (input.avatarFile === null) {
person.$jazz.delete("avatar")
} else {
- let avatar = await createImage(updates.avatarFile, {
+ let avatar = await createImage(input.avatarFile, {
owner: person.$jazz.owner,
maxSize: 2048,
placeholder: "blur",
@@ -88,91 +98,20 @@ async function updatePerson(
return {
operation: "update",
- personID: personId,
- current: {
- name: person.name,
- summary: person.summary,
- version: person.version,
- },
+ current: serializePerson(person),
previous,
- _ref: person,
}
}
-function createUpdatePersonTool(worker: Loaded) {
- return tool({
- description:
- "Update a person's name and/or summary. Can also restore deleted people by updating their information.",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID"),
- name: z.string().optional().describe("The person's new name"),
- summary: z
- .string()
- .optional()
- .describe(
- "A compact summary displayed next to the person's name and avatar. Should include key details like relationship, profession, location, and personality traits. Example: 'sister in law, doctor, lives in switzerland with erik, high energy and positivity'",
- ),
- }),
- execute: async input => {
- let { personId, ...updates } = input
-
- try {
- let result = await updatePerson(personId, updates, worker)
- let { _ref, ...data } = result
-
- return {
- personId: data.personID,
- current: {
- name: data.current.name,
- summary: data.current.summary,
- deletedAt: _ref.deletedAt?.toISOString(),
- createdAt: _ref.createdAt.toISOString(),
- updatedAt: _ref.updatedAt.toISOString(),
- },
- previous: {
- name: data.previous.name,
- summary: data.previous.summary,
- deletedAt: _ref.deletedAt?.toISOString(),
- createdAt: _ref.createdAt.toISOString(),
- updatedAt: _ref.updatedAt.toISOString(),
- },
- }
- } catch (error) {
- return { error: `${error}` }
- }
- },
- })
-}
-
-function createDeletePersonTool(worker: Loaded) {
- return tool({
- description:
- "Delete a person from the CRM by marking them as deleted (soft delete). Use updatePerson to restore deleted people.",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID to delete"),
- }),
- execute: async input => {
- try {
- let result = await updatePerson(
- input.personId,
- { deletedAt: new Date() },
- worker,
- )
- let { _ref, ...data } = result
-
- return {
- personId: data.personID,
- name: data.current.name,
- summary: data.current.summary,
- deletedAt: _ref.deletedAt?.toISOString(),
- createdAt: _ref.createdAt.toISOString(),
- updatedAt: _ref.updatedAt.toISOString(),
- }
- } catch (error) {
- return { error: `${error}` }
- }
- },
- })
+function serializePerson(person: co.loaded): PersonCurrent {
+ return {
+ personId: person.$jazz.id,
+ name: person.name,
+ summary: person.summary,
+ createdAt: person.createdAt.toISOString(),
+ updatedAt: person.updatedAt.toISOString(),
+ deletedAt: person.deletedAt?.toISOString(),
+ }
}
let rootResolve = {
@@ -182,19 +121,25 @@ let rootResolve = {
let errors = {
PERSON_NOT_FOUND: "person not found",
- USER_ACCOUNT_NOT_FOUND: "user account not found",
} as const
-type PersonData = {
- name: string
- summary?: string
- version: number
-}
-
-type PersonUpdated = {
- _ref: co.loaded
- operation: "update"
- personID: string
- current: PersonData
- previous: PersonData
-}
+let createUpdatePersonTool = defineTool({
+ description:
+ "Update a person's name and/or summary. Can also restore deleted people by updating their information.",
+ input: updatePersonInput,
+ output: updatedSchema(personCurrent),
+ serverOp: updatePerson,
+})
+
+let deletePersonInput = z.object({
+ personId: z.string().describe("The person's ID to delete"),
+})
+
+let createDeletePersonTool = defineTool({
+ description:
+ "Delete a person from the CRM by marking them as deleted (soft delete). Use updatePerson to restore deleted people.",
+ input: deletePersonInput,
+ output: updatedSchema(personCurrent),
+ serverOp: (worker, input) =>
+ updatePerson(worker, { ...input, deletedAt: new Date() }),
+})
diff --git a/src/shared/tools/reminder-create.test.ts b/src/shared/tools/reminder-create.test.ts
new file mode 100644
index 0000000..a9df909
--- /dev/null
+++ b/src/shared/tools/reminder-create.test.ts
@@ -0,0 +1,90 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import { setActiveAccount } from "jazz-tools/testing"
+import type { co } from "jazz-tools"
+import { Reminder, UserAccount } from "#shared/schema/user"
+import { createReminder } from "#shared/tools/reminder-create"
+import { createAccount, seedPerson } from "#shared/tools/test-helpers"
+
+describe("createReminder", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("attaches a reminder with text + dueAtDate", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+
+ let result = await createReminder(owner, {
+ personId: person.$jazz.id,
+ text: "call back",
+ dueAtDate: "2099-01-01",
+ })
+
+ expect(result.operation).toBe("create")
+ expect(result.current.text).toBe("call back")
+ expect(result.current.dueAtDate).toBe("2099-01-01")
+ expect(result.current.done).toBe(false)
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { reminders: { $each: true } },
+ })
+ expect(
+ loaded.reminders.some(r => r?.$jazz.id === result.current.reminderId),
+ ).toBe(true)
+ })
+
+ test("persists repeat config", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+
+ let result = await createReminder(owner, {
+ personId: person.$jazz.id,
+ text: "weekly",
+ dueAtDate: "2099-01-01",
+ repeat: { interval: 1, unit: "week" },
+ })
+
+ let reloaded = await Reminder.load(result.current.reminderId)
+ expect(reloaded?.$isLoaded).toBe(true)
+ if (!reloaded?.$isLoaded) return
+ expect(reloaded.repeat?.interval).toBe(1)
+ expect(reloaded.repeat?.unit).toBe("week")
+ })
+
+ test("throws when person not found", async () => {
+ await expect(
+ createReminder(owner, {
+ personId: "co_zNope",
+ text: "x",
+ dueAtDate: "2099-01-01",
+ }),
+ ).rejects.toBeDefined()
+ })
+
+ describe("multi-user", () => {
+ test("writer collaborator can create on shared person", async () => {
+ let collaborator = await createAccount()
+ let person = await seedPerson(owner, {
+ name: "Shared",
+ sharedWith: collaborator,
+ })
+ await owner.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(collaborator)
+ let result = await createReminder(collaborator, {
+ personId: person.$jazz.id,
+ text: "from other",
+ dueAtDate: "2099-01-01",
+ })
+ await collaborator.$jazz.waitForAllCoValuesSync()
+
+ setActiveAccount(owner)
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { reminders: { $each: true } },
+ })
+ expect(
+ loaded.reminders.some(r => r?.$jazz.id === result.current.reminderId),
+ ).toBe(true)
+ })
+ })
+})
diff --git a/src/shared/tools/reminder-create.ts b/src/shared/tools/reminder-create.ts
index dc29055..f264acb 100644
--- a/src/shared/tools/reminder-create.ts
+++ b/src/shared/tools/reminder-create.ts
@@ -1,44 +1,59 @@
-import { tool } from "ai"
import { z } from "zod"
-import { Person, Reminder, UserAccount } from "#shared/schema/user"
-import { co, type Loaded } from "jazz-tools"
-import { tryCatch } from "#shared/lib/trycatch"
+import { Person, Reminder } from "#shared/schema/user"
+import {
+ defineTool,
+ createdSchema,
+ type Created,
+ type Worker,
+} from "#shared/tools/define-tool"
-export { createAddReminderTool, createReminder }
+export { createReminder, createAddReminderTool, reminderCurrent }
-export type { ReminderData, ReminderCreated }
+let repeatSchema = z.object({
+ interval: z.number().min(1),
+ unit: z.enum(["day", "week", "month", "year"]),
+})
+
+let reminderCurrent = z.object({
+ reminderId: z.string(),
+ personId: z.string(),
+ text: z.string(),
+ dueAtDate: z.string(),
+ repeat: repeatSchema.optional(),
+ done: z.boolean(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ deletedAt: z.string().optional(),
+})
+
+type ReminderCurrent = z.infer
+
+let createReminderInput = z.object({
+ personId: z.string().describe("The person's ID"),
+ text: z.string().describe("The reminder text"),
+ dueAtDate: z
+ .string()
+ .describe("Due date as a date string (e.g., '2025-07-18')"),
+ repeat: repeatSchema.optional().describe("Optional repeat configuration"),
+})
async function createReminder(
- data: {
- text: string
- dueAtDate: string
- repeat?: {
- interval: number
- unit: "day" | "week" | "month" | "year"
- }
- },
- options: {
- personId: string
- worker: Loaded
- },
-): Promise {
- let person = await Person.load(options.personId, {
+ worker: Worker,
+ input: z.infer,
+): Promise> {
+ let person = await Person.load(input.personId, {
resolve: { reminders: { $each: true } },
- loadAs: options.worker,
+ loadAs: worker,
})
-
- if (!person.$isLoaded) {
- throw errors.PERSON_NOT_FOUND
- }
+ if (!person.$isLoaded) throw errors.PERSON_NOT_FOUND
let now = new Date()
-
let reminder = Reminder.create(
{
version: 1,
- text: data.text,
- dueAtDate: data.dueAtDate,
- repeat: data.repeat,
+ text: input.text,
+ dueAtDate: input.dueAtDate,
+ repeat: input.repeat,
done: false,
createdAt: now,
updatedAt: now,
@@ -51,62 +66,26 @@ async function createReminder(
return {
operation: "create",
- reminderID: reminder.$jazz.id,
- personID: options.personId,
- current: { ...reminder },
- _ref: reminder,
+ current: {
+ reminderId: reminder.$jazz.id,
+ personId: input.personId,
+ text: reminder.text,
+ dueAtDate: reminder.dueAtDate,
+ repeat: reminder.repeat,
+ done: reminder.done,
+ createdAt: reminder.createdAt.toISOString(),
+ updatedAt: reminder.updatedAt.toISOString(),
+ },
}
}
let errors = {
PERSON_NOT_FOUND: "person not found",
- REMINDER_NOT_FOUND: "reminder not found",
- REMINDER_ALREADY_DONE: "cannot set reminder to done. is already done.",
} as const
-type ReminderData = Parameters[0]
-
-type ReminderCreated = {
- _ref: co.loaded
- operation: "create"
- reminderID: string
- personID: string
- current: ReminderData
-}
-
-function createAddReminderTool(worker: Loaded) {
- return tool({
- description: "Add a reminder for a person using their ID",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID"),
- text: z.string().describe("The reminder text"),
- dueAtDate: z
- .string()
- .describe("Due date as a date string (e.g., '2025-07-18')"),
- repeat: z
- .object({
- interval: z.number().min(1),
- unit: z.enum(["day", "week", "month", "year"]),
- })
- .optional()
- .describe("Optional repeat configuration"),
- }),
- execute: async input => {
- let { personId, ...data } = input
-
- let res = await tryCatch(createReminder(data, { personId, worker }))
- if (!res.ok) return { error: `${res.error}` }
- let result = res.data
- return {
- personId: result.personID,
- reminderId: result.reminderID,
- text: result.current.text,
- dueAtDate: result.current.dueAtDate,
- repeat: result.current.repeat,
- done: result.current.done,
- createdAt: result.current.createdAt.toISOString(),
- updatedAt: result.current.updatedAt.toISOString(),
- }
- },
- })
-}
+let createAddReminderTool = defineTool({
+ description: "Add a reminder for a person using their ID",
+ input: createReminderInput,
+ output: createdSchema(reminderCurrent),
+ serverOp: createReminder,
+})
diff --git a/src/shared/tools/reminder-read.test.ts b/src/shared/tools/reminder-read.test.ts
new file mode 100644
index 0000000..632c8d7
--- /dev/null
+++ b/src/shared/tools/reminder-read.test.ts
@@ -0,0 +1,114 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import type { co } from "jazz-tools"
+import { UserAccount } from "#shared/schema/user"
+import { listReminders } from "#shared/tools/reminder-read"
+import { updateReminder } from "#shared/tools/reminder-update"
+import {
+ createAccount,
+ seedPerson,
+ seedReminder,
+} from "#shared/tools/test-helpers"
+
+describe("listReminders", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("returns reminders across people, hides done and deleted by default", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ let grace = await seedPerson(owner, { name: "Grace" })
+ await seedReminder(ada, { text: "ada-1", dueAtDate: "2099-01-01" })
+ await seedReminder(grace, { text: "grace-1", dueAtDate: "2099-01-01" })
+ let done = await seedReminder(ada, {
+ text: "done-one",
+ dueAtDate: "2099-01-01",
+ })
+ await updateReminder(owner, {
+ personId: ada.$jazz.id,
+ reminderId: done.$jazz.id,
+ done: true,
+ })
+
+ let result = await listReminders(owner, {
+ includeDone: false,
+ includeDeleted: false,
+ })
+ expect(result.reminders.map(r => r.text).sort()).toEqual([
+ "ada-1",
+ "grace-1",
+ ])
+ })
+
+ test("dueOnly filters out reminders due in the future", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ await seedReminder(ada, { text: "past", dueAtDate: "2000-01-01" })
+ await seedReminder(ada, { text: "future", dueAtDate: "2099-01-01" })
+
+ let result = await listReminders(owner, {
+ dueOnly: true,
+ includeDone: false,
+ includeDeleted: false,
+ })
+ expect(result.reminders.map(r => r.text)).toEqual(["past"])
+ })
+
+ test("sorted by due date ascending", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ await seedReminder(ada, { text: "later", dueAtDate: "2099-12-31" })
+ await seedReminder(ada, { text: "earlier", dueAtDate: "2099-01-01" })
+ await seedReminder(ada, { text: "middle", dueAtDate: "2099-06-15" })
+
+ let result = await listReminders(owner, {
+ includeDone: false,
+ includeDeleted: false,
+ })
+ expect(result.reminders.map(r => r.text)).toEqual([
+ "earlier",
+ "middle",
+ "later",
+ ])
+ })
+
+ test("search filters by reminder text and person name", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ let grace = await seedPerson(owner, { name: "Grace" })
+ await seedReminder(ada, {
+ text: "needle ping",
+ dueAtDate: "2099-01-01",
+ })
+ await seedReminder(ada, { text: "boring", dueAtDate: "2099-01-01" })
+ await seedReminder(grace, { text: "plain", dueAtDate: "2099-01-01" })
+
+ let byText = await listReminders(owner, {
+ searchQuery: "needle",
+ includeDone: false,
+ includeDeleted: false,
+ })
+ expect(byText.reminders.map(r => r.text)).toEqual(["needle ping"])
+
+ let byPerson = await listReminders(owner, {
+ searchQuery: "grace",
+ includeDone: false,
+ includeDeleted: false,
+ })
+ expect(byPerson.reminders.every(r => r.person.name === "Grace")).toBe(true)
+ })
+
+ test("includeDone returns completed reminders", async () => {
+ let ada = await seedPerson(owner, { name: "Ada" })
+ let r = await seedReminder(ada, { text: "x", dueAtDate: "2099-01-01" })
+ await updateReminder(owner, {
+ personId: ada.$jazz.id,
+ reminderId: r.$jazz.id,
+ done: true,
+ })
+
+ let result = await listReminders(owner, {
+ includeDone: true,
+ includeDeleted: false,
+ })
+ expect(result.reminders.some(rem => rem.done)).toBe(true)
+ })
+})
diff --git a/src/shared/tools/reminder-read.ts b/src/shared/tools/reminder-read.ts
index 12d9541..bd9e6f9 100644
--- a/src/shared/tools/reminder-read.ts
+++ b/src/shared/tools/reminder-read.ts
@@ -1,98 +1,138 @@
-import { tool } from "ai"
import { z } from "zod"
-import { UserAccount, Person, Reminder, isDeleted } from "#shared/schema/user"
-import { tryCatch } from "#shared/lib/trycatch"
-import type { co, ResolveQuery, Loaded } from "jazz-tools"
+import type { co } from "jazz-tools"
+import { isDeleted, Person, Reminder } from "#shared/schema/user"
+import { defineTool, type Worker } from "#shared/tools/define-tool"
-export { createListRemindersTool }
+export { listReminders, createListRemindersTool }
-let query = {
- root: {
- people: {
- $each: {
- avatar: true,
- reminders: { $each: true },
- inactiveReminders: { $each: true },
+let repeatSchema = z.object({
+ interval: z.number().min(1),
+ unit: z.enum(["day", "week", "month", "year"]),
+})
+
+let reminderSummary = z.object({
+ id: z.string(),
+ text: z.string(),
+ dueAtDate: z.string(),
+ repeat: repeatSchema.optional(),
+ done: z.boolean(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ person: z.object({
+ id: z.string(),
+ name: z.string(),
+ }),
+})
+
+let listRemindersInput = z.object({
+ searchQuery: z
+ .string()
+ .optional()
+ .describe(
+ "Optional search query to filter reminders by text or person name",
+ ),
+ dueOnly: z
+ .boolean()
+ .optional()
+ .describe("If true, only show reminders that are due now or overdue"),
+ includeDeleted: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe("Include deleted reminders in results"),
+ includeDone: z
+ .boolean()
+ .optional()
+ .default(false)
+ .describe("Include completed reminders in results"),
+})
+
+let listRemindersOutput = z.object({
+ operation: z.literal("list"),
+ reminders: z.array(reminderSummary),
+ totalCount: z.number(),
+ filteredCount: z.number(),
+ searchQuery: z.string().optional(),
+ dueOnly: z.boolean().optional(),
+})
+
+async function listReminders(
+ worker: Worker,
+ input: z.infer,
+): Promise> {
+ let user = await worker.$jazz.ensureLoaded({
+ resolve: {
+ root: {
+ people: {
+ $each: {
+ avatar: true,
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ },
},
},
- },
-} as const satisfies ResolveQuery
-
-async function listReminders(options: {
- worker: Loaded
- searchQuery?: string
- dueOnly?: boolean
- includeDeleted?: boolean
- includeDone?: boolean
-}): Promise {
- let worker = await options.worker.$jazz.ensureLoaded({ resolve: query })
- if (!worker.$isLoaded) throw errors.USER_NOT_FOUND
+ })
+ if (!user.$isLoaded) throw new Error("user not found")
- let allReminders: Array<{
+ let pairs: Array<{
reminder: co.loaded
person: co.loaded
}> = []
- for (let person of worker.root.people.values()) {
- if (isDeleted(person)) continue
-
- // Check active reminders
+ for (let person of user.root.people.values()) {
+ if (!person || isDeleted(person)) continue
if (person.reminders) {
for (let reminder of person.reminders.values()) {
- if (!options.includeDone && reminder.done) continue
- if (!options.includeDeleted && isDeleted(reminder)) continue
-
- allReminders.push({ reminder, person })
+ if (!reminder) continue
+ if (!input.includeDone && reminder.done) continue
+ if (!input.includeDeleted && isDeleted(reminder)) continue
+ pairs.push({ reminder, person })
}
}
-
- // Check inactive reminders if includeDone or includeDeleted is true
if (
person.inactiveReminders &&
- (options.includeDone || options.includeDeleted)
+ (input.includeDone || input.includeDeleted)
) {
for (let reminder of person.inactiveReminders.values()) {
- if (!options.includeDone && reminder.done) continue
- if (!options.includeDeleted && isDeleted(reminder)) continue
-
- allReminders.push({ reminder, person })
+ if (!reminder) continue
+ if (!input.includeDone && reminder.done) continue
+ if (!input.includeDeleted && isDeleted(reminder)) continue
+ pairs.push({ reminder, person })
}
}
}
- allReminders.sort(
+ pairs.sort(
(a, b) =>
new Date(a.reminder.dueAtDate).getTime() -
new Date(b.reminder.dueAtDate).getTime(),
)
- // Filter by due date if dueOnly is true
- let filteredReminders = allReminders
- if (options.dueOnly) {
+ let filtered = pairs
+ if (input.dueOnly) {
let now = new Date()
- filteredReminders = allReminders.filter(({ reminder }) => {
- let dueDate = new Date(reminder.dueAtDate)
- return dueDate <= now
- })
+ filtered = pairs.filter(
+ ({ reminder }) => new Date(reminder.dueAtDate) <= now,
+ )
}
- if (options.searchQuery) {
- let searchLower = options.searchQuery.toLowerCase()
- filteredReminders = filteredReminders.filter(
+ if (input.searchQuery) {
+ let q = input.searchQuery.toLowerCase()
+ filtered = filtered.filter(
({ reminder, person }) =>
- reminder.text.toLowerCase().includes(searchLower) ||
- person.name.toLowerCase().includes(searchLower),
+ reminder.text.toLowerCase().includes(q) ||
+ person.name.toLowerCase().includes(q),
)
}
return {
- operation: "list",
- reminders: filteredReminders.map(({ reminder, person }) => ({
+ operation: "list" as const,
+ reminders: filtered.map(({ reminder, person }) => ({
id: reminder.$jazz.id,
text: reminder.text,
dueAtDate: reminder.dueAtDate,
repeat: reminder.repeat,
done: reminder.done,
- deletedAt: reminder.deletedAt,
createdAt: reminder.createdAt.toISOString(),
updatedAt: reminder.updatedAt.toISOString(),
person: {
@@ -100,68 +140,17 @@ async function listReminders(options: {
name: person.name,
},
})),
- totalCount: allReminders.length,
- filteredCount: filteredReminders.length,
- searchQuery: options.searchQuery,
- dueOnly: options.dueOnly,
+ totalCount: pairs.length,
+ filteredCount: filtered.length,
+ searchQuery: input.searchQuery,
+ dueOnly: input.dueOnly,
}
}
-let errors = {
- USER_NOT_FOUND: "user not found",
-} as const
-
-type ListRemindersResult = {
- operation: "list"
- reminders: Array<{
- id: string
- text: string
- dueAtDate?: string
- repeat?: { interval: number; unit: "day" | "week" | "month" | "year" }
- done: boolean
- createdAt: string
- updatedAt: string
- person: {
- id: string
- name: string
- }
- }>
- totalCount: number
- filteredCount: number
- searchQuery?: string
- dueOnly?: boolean
-}
-
-function createListRemindersTool(worker: Loaded) {
- return tool({
- description:
- "List all reminders across all people with optional search and due date filtering. By default, only shows undone and undeleted reminders.",
- inputSchema: z.object({
- searchQuery: z
- .string()
- .optional()
- .describe(
- "Optional search query to filter reminders by text or person name",
- ),
- dueOnly: z
- .boolean()
- .optional()
- .describe("If true, only show reminders that are due now or overdue"),
- includeDeleted: z
- .boolean()
- .optional()
- .default(false)
- .describe("Include deleted reminders in results"),
- includeDone: z
- .boolean()
- .optional()
- .default(false)
- .describe("Include completed reminders in results"),
- }),
- execute: async input => {
- let res = await tryCatch(listReminders({ worker, ...input }))
- if (!res.ok) return { error: `${res.error}` }
- return res.data
- },
- })
-}
+let createListRemindersTool = defineTool({
+ description:
+ "List all reminders across all people with optional search and due date filtering. By default, only shows undone and undeleted reminders.",
+ input: listRemindersInput,
+ output: listRemindersOutput,
+ serverOp: listReminders,
+})
diff --git a/src/shared/tools/reminder-update.test.ts b/src/shared/tools/reminder-update.test.ts
new file mode 100644
index 0000000..332dd8f
--- /dev/null
+++ b/src/shared/tools/reminder-update.test.ts
@@ -0,0 +1,191 @@
+import { beforeEach, describe, expect, test } from "vitest"
+import type { co } from "jazz-tools"
+import { Reminder, UserAccount } from "#shared/schema/user"
+import { updateReminder } from "#shared/tools/reminder-update"
+import {
+ createAccount,
+ seedPerson,
+ seedReminder,
+} from "#shared/tools/test-helpers"
+
+describe("updateReminder", () => {
+ let owner: co.loaded
+
+ beforeEach(async () => {
+ owner = await createAccount({ isCurrentActive: true })
+ })
+
+ test("edits text and dueAtDate, returns previous + current", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "old",
+ dueAtDate: "2099-01-01",
+ })
+
+ let result = await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ text: "new",
+ dueAtDate: "2099-02-01",
+ })
+
+ expect(result.previous.text).toBe("old")
+ expect(result.previous.dueAtDate).toBe("2099-01-01")
+ expect(result.current.text).toBe("new")
+ expect(result.current.dueAtDate).toBe("2099-02-01")
+ })
+
+ test("mark done (non-repeating) moves to inactiveReminders", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "do it",
+ dueAtDate: "2099-01-01",
+ })
+
+ await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ done: true,
+ })
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: {
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ })
+ expect(loaded.reminders.some(r => r?.$jazz.id === reminder.$jazz.id)).toBe(
+ false,
+ )
+ expect(
+ loaded.inactiveReminders?.some(r => r?.$jazz.id === reminder.$jazz.id),
+ ).toBe(true)
+ })
+
+ test("mark done (repeating) reschedules to next occurrence and stays active", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "weekly call",
+ dueAtDate: "2099-01-01",
+ repeat: { interval: 1, unit: "week" },
+ })
+
+ let result = await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ done: true,
+ })
+
+ expect(result.current.done).toBe(false)
+ expect(result.current.dueAtDate).toBe("2099-01-08")
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { reminders: { $each: true } },
+ })
+ expect(loaded.reminders.some(r => r?.$jazz.id === reminder.$jazz.id)).toBe(
+ true,
+ )
+ })
+
+ test("undone restores from inactive to active", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "x",
+ dueAtDate: "2099-01-01",
+ })
+ await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ done: true,
+ })
+
+ await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ done: false,
+ })
+
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: {
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ })
+ expect(loaded.reminders.some(r => r?.$jazz.id === reminder.$jazz.id)).toBe(
+ true,
+ )
+ })
+
+ test("soft delete moves to inactive, restore moves back", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "x",
+ dueAtDate: "2099-01-01",
+ })
+
+ await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ deletedAt: new Date(),
+ })
+
+ let afterDelete = await person.$jazz.ensureLoaded({
+ resolve: {
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ })
+ expect(
+ afterDelete.inactiveReminders?.some(
+ r => r?.$jazz.id === reminder.$jazz.id,
+ ),
+ ).toBe(true)
+
+ await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ deletedAt: undefined,
+ })
+
+ let afterRestore = await person.$jazz.ensureLoaded({
+ resolve: {
+ reminders: { $each: true },
+ inactiveReminders: { $each: true },
+ },
+ })
+ expect(
+ afterRestore.reminders.some(r => r?.$jazz.id === reminder.$jazz.id),
+ ).toBe(true)
+ })
+
+ test("throws when reminder not found", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ await expect(
+ updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: "co_zNope",
+ text: "x",
+ }),
+ ).rejects.toBeDefined()
+ })
+
+ test("repeat unit month advances by one month", async () => {
+ let person = await seedPerson(owner, { name: "Ada" })
+ let reminder = await seedReminder(person, {
+ text: "monthly",
+ dueAtDate: "2099-01-15",
+ repeat: { interval: 1, unit: "month" },
+ })
+
+ let result = await updateReminder(owner, {
+ personId: person.$jazz.id,
+ reminderId: reminder.$jazz.id,
+ done: true,
+ })
+
+ expect(result.current.dueAtDate).toBe("2099-02-15")
+
+ let reloaded = await Reminder.load(reminder.$jazz.id)
+ expect(reloaded?.$isLoaded ? reloaded.dueAtDate : null).toBe("2099-02-15")
+ })
+})
diff --git a/src/shared/tools/reminder-update.ts b/src/shared/tools/reminder-update.ts
index cecb7b5..8477bae 100644
--- a/src/shared/tools/reminder-update.ts
+++ b/src/shared/tools/reminder-update.ts
@@ -1,35 +1,47 @@
-import { format, parse } from "date-fns"
-import { tool } from "ai"
+import { format, parse, addDays, addWeeks, addMonths, addYears } from "date-fns"
import { z } from "zod"
-import { Person, Reminder, UserAccount } from "#shared/schema/user"
-import { addDays, addWeeks, addMonths, addYears } from "date-fns"
-import { co, type Loaded } from "jazz-tools"
-import { tryCatch } from "#shared/lib/trycatch"
-
-export { createUpdateReminderTool, createRemoveReminderTool, updateReminder }
-
-export type { ReminderData, ReminderUpdated }
+import { co } from "jazz-tools"
+import { Person, Reminder } from "#shared/schema/user"
+import {
+ defineTool,
+ updatedSchema,
+ type Updated,
+ type Worker,
+} from "#shared/tools/define-tool"
+import { reminderCurrent } from "#shared/tools/reminder-create"
+
+export { updateReminder, createUpdateReminderTool, createRemoveReminderTool }
+
+type ReminderCurrent = z.infer
+
+let repeatSchema = z.object({
+ interval: z.number().min(1),
+ unit: z.enum(["day", "week", "month", "year"]),
+})
+
+let updateReminderInput = z.object({
+ personId: z.string().describe("The person's ID who owns the reminder"),
+ reminderId: z.string().describe("The reminder's ID"),
+ text: z.string().optional().describe("The new reminder text"),
+ dueAtDate: z
+ .string()
+ .optional()
+ .describe("New due date as a date string (e.g., '2025-07-18')"),
+ repeat: repeatSchema.optional().describe("New repeat configuration"),
+ done: z.boolean().optional().describe("Mark reminder as done/undone"),
+})
+
+type UpdateReminderInput = z.infer & {
+ deletedAt?: Date | undefined
+}
async function updateReminder(
- updates: {
- text?: string
- dueAtDate?: string
- repeat?: {
- interval: number
- unit: "day" | "week" | "month" | "year"
- }
- done?: boolean
- deletedAt?: Date | undefined
- },
- options: {
- worker: Loaded
- personId: string
- reminderId: string
- },
-): Promise {
- let person = await Person.load(options.personId, {
+ worker: Worker,
+ input: UpdateReminderInput,
+): Promise> {
+ let person = await Person.load(input.personId, {
resolve: { reminders: true, inactiveReminders: true },
- loadAs: options.worker,
+ loadAs: worker,
})
if (!person.$isLoaded) throw errors.PERSON_NOT_FOUND
@@ -40,32 +52,29 @@ async function updateReminder(
)
}
- let reminder = await Reminder.load(options.reminderId, {
- loadAs: options.worker,
- })
+ let reminder = await Reminder.load(input.reminderId, { loadAs: worker })
if (!reminder.$isLoaded) throw errors.REMINDER_NOT_FOUND
- let previous = { ...reminder }
+ let previous: ReminderCurrent = serializeReminder(reminder, input.personId)
- if (updates.text !== undefined) {
- reminder.$jazz.set("text", updates.text)
+ if (input.text !== undefined) {
+ reminder.$jazz.set("text", input.text)
}
- if (updates.dueAtDate !== undefined) {
- reminder.$jazz.set("dueAtDate", updates.dueAtDate)
+ if (input.dueAtDate !== undefined) {
+ reminder.$jazz.set("dueAtDate", input.dueAtDate)
}
- if (updates.repeat !== undefined) {
- reminder.$jazz.set("repeat", updates.repeat)
+ if (input.repeat !== undefined) {
+ reminder.$jazz.set("repeat", input.repeat)
}
- if ("repeat" in updates && updates.repeat === undefined) {
+ if ("repeat" in input && input.repeat === undefined) {
reminder.$jazz.delete("repeat")
}
- if (updates.done === true && !reminder.done && !reminder.repeat) {
- reminder.$jazz.set("done", updates.done)
- // Move to inactive
+ if (input.done === true && !reminder.done && !reminder.repeat) {
+ reminder.$jazz.set("done", input.done)
if (person.inactiveReminders?.$isLoaded) {
let activeIdx = Array.from(person.reminders.values()).findIndex(
- r => r?.$jazz.id === options.reminderId,
+ r => r?.$jazz.id === input.reminderId,
)
if (activeIdx !== -1) {
person.inactiveReminders.$jazz.push(reminder)
@@ -73,17 +82,16 @@ async function updateReminder(
}
}
}
- if (updates.done === true && !reminder.done && reminder.repeat) {
+ if (input.done === true && !reminder.done && reminder.repeat) {
let { nextDueAtDate } = calculateNextDueDate(reminder)
reminder.$jazz.set("dueAtDate", nextDueAtDate)
reminder.$jazz.set("done", false)
}
- if (updates.done === false && reminder.done) {
- reminder.$jazz.set("done", updates.done)
+ if (input.done === false && reminder.done) {
+ reminder.$jazz.set("done", input.done)
if (!reminder.deletedAt && person.inactiveReminders?.$isLoaded) {
- // Move to active
let inactiveIdx = Array.from(person.inactiveReminders.values()).findIndex(
- r => r?.$jazz.id === options.reminderId,
+ r => r?.$jazz.id === input.reminderId,
)
if (inactiveIdx !== -1) {
person.reminders.$jazz.push(reminder)
@@ -92,12 +100,11 @@ async function updateReminder(
}
}
- if ("deletedAt" in updates && updates.deletedAt === undefined) {
+ if ("deletedAt" in input && input.deletedAt === undefined) {
reminder.$jazz.delete("deletedAt")
if (!reminder.done && person.inactiveReminders?.$isLoaded) {
- // Move to active
let inactiveIdx = Array.from(person.inactiveReminders.values()).findIndex(
- r => r?.$jazz.id === options.reminderId,
+ r => r?.$jazz.id === input.reminderId,
)
if (inactiveIdx !== -1) {
person.reminders.$jazz.push(reminder)
@@ -106,12 +113,11 @@ async function updateReminder(
}
}
- if (updates.deletedAt !== undefined) {
- reminder.$jazz.set("deletedAt", updates.deletedAt)
- // Move to inactive
+ if (input.deletedAt !== undefined) {
+ reminder.$jazz.set("deletedAt", input.deletedAt)
if (person.inactiveReminders?.$isLoaded) {
let activeIdx = Array.from(person.reminders.values()).findIndex(
- r => r?.$jazz.id === options.reminderId,
+ r => r?.$jazz.id === input.reminderId,
)
if (activeIdx !== -1) {
person.inactiveReminders.$jazz.push(reminder)
@@ -125,11 +131,25 @@ async function updateReminder(
return {
operation: "update",
- reminderID: options.reminderId,
- personID: options.personId,
- current: { ...reminder },
+ current: serializeReminder(reminder, input.personId),
previous,
- _ref: reminder,
+ }
+}
+
+function serializeReminder(
+ reminder: co.loaded,
+ personId: string,
+): ReminderCurrent {
+ return {
+ reminderId: reminder.$jazz.id,
+ personId,
+ text: reminder.text,
+ dueAtDate: reminder.dueAtDate,
+ repeat: reminder.repeat,
+ done: reminder.done,
+ createdAt: reminder.createdAt.toISOString(),
+ updatedAt: reminder.updatedAt.toISOString(),
+ deletedAt: reminder.deletedAt?.toISOString(),
}
}
@@ -156,7 +176,7 @@ function calculateNextDueDate(reminder: co.loaded): {
nextDueDate = addYears(currentDueDate, interval)
break
default:
- nextDueDate = addDays(currentDueDate, 1)
+ nextDueDate = addDays(currentDueDate, interval)
}
return { nextDueAtDate: format(nextDueDate, "yyyy-MM-dd") }
@@ -165,99 +185,25 @@ function calculateNextDueDate(reminder: co.loaded): {
let errors = {
PERSON_NOT_FOUND: "person not found",
REMINDER_NOT_FOUND: "reminder not found",
- REMINDER_ALREADY_DONE: "cannot set reminder to done. is already done.",
} as const
-type ReminderData = Parameters[0]
-
-type ReminderUpdated = {
- _ref: co.loaded
- operation: "update"
- reminderID: string
- personID: string
- current: ReminderData
- previous: ReminderData
-}
-
-function createUpdateReminderTool(worker: Loaded) {
- return tool({
- description:
- "Update a reminder's text, due date, repeat settings, or done status",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID who owns the reminder"),
- reminderId: z.string().describe("The reminder's ID"),
- text: z.string().optional().describe("The new reminder text"),
- dueAtDate: z
- .string()
- .optional()
- .describe("New due date as a date string (e.g., '2025-07-18')"),
- repeat: z
- .object({
- interval: z.number().min(1),
- unit: z.enum(["day", "week", "month", "year"]),
- })
- .optional()
- .describe("New repeat configuration"),
- done: z.boolean().optional().describe("Mark reminder as done/undone"),
- }),
- execute: async input => {
- let { personId, reminderId, ...updates } = input
- let res = await tryCatch(
- updateReminder(updates, { worker, personId, reminderId }),
- )
- if (!res.ok) return { error: `${res.error}` }
- let result = res.data
- return {
- personId: result.personID,
- reminderId: result.reminderID,
- text: result.current.text,
- dueAtDate: result.current.dueAtDate,
- repeat: result.current.repeat,
- done: result.current.done,
- createdAt: result.current.createdAt.toISOString(),
- updatedAt: result.current.updatedAt.toISOString(),
- previous: {
- text: result.previous.text,
- dueAtDate: result.previous.dueAtDate,
- repeat: result.previous.repeat,
- done: result.previous.done,
- createdAt: result.previous.createdAt.toISOString(),
- updatedAt: result.previous.updatedAt.toISOString(),
- },
- }
- },
- })
-}
-
-function createRemoveReminderTool(worker: Loaded) {
- return tool({
- description: "Remove a reminder from a person",
- inputSchema: z.object({
- personId: z.string().describe("The person's ID who owns the reminder"),
- reminderId: z.string().describe("The reminder's ID to remove"),
- }),
- execute: async input => {
- let { personId, reminderId } = input
-
- let res = await tryCatch(
- updateReminder(
- { deletedAt: new Date() },
- { worker, personId, reminderId },
- ),
- )
- if (!res.ok) return { error: `${res.error}` }
- let result = res.data
- return {
- personId: result.personID,
- reminderId: result.reminderID,
- text: result.current.text,
- dueAtDate: result.current.dueAtDate,
- repeat: result.current.repeat,
- done: result.current.done,
- deletedAt: result.current.deletedAt?.toISOString(),
- createdAt: result.current.createdAt.toISOString(),
- updatedAt: result.current.updatedAt.toISOString(),
- }
- },
- })
-}
+let createUpdateReminderTool = defineTool({
+ description:
+ "Update a reminder's text, due date, repeat settings, or done status",
+ input: updateReminderInput,
+ output: updatedSchema(reminderCurrent),
+ serverOp: updateReminder,
+})
+
+let removeReminderInput = z.object({
+ personId: z.string().describe("The person's ID who owns the reminder"),
+ reminderId: z.string().describe("The reminder's ID to remove"),
+})
+
+let createRemoveReminderTool = defineTool({
+ description: "Remove a reminder from a person",
+ input: removeReminderInput,
+ output: updatedSchema(reminderCurrent),
+ serverOp: (worker, input) =>
+ updateReminder(worker, { ...input, deletedAt: new Date() }),
+})
diff --git a/src/shared/tools/test-helpers.ts b/src/shared/tools/test-helpers.ts
new file mode 100644
index 0000000..d855107
--- /dev/null
+++ b/src/shared/tools/test-helpers.ts
@@ -0,0 +1,99 @@
+import { Group, co } from "jazz-tools"
+import { createJazzTestAccount } from "jazz-tools/testing"
+import { Note, Person, Reminder, UserAccount } from "#shared/schema/user"
+
+export { createAccount, seedPerson, seedNote, seedReminder }
+
+async function createAccount(opts: { isCurrentActive?: boolean } = {}) {
+ return await createJazzTestAccount({
+ isCurrentActiveAccount: opts.isCurrentActive ?? false,
+ AccountSchema: UserAccount,
+ })
+}
+
+async function seedPerson(
+ account: co.loaded,
+ args: {
+ name: string
+ summary?: string
+ sharedWith?: co.loaded
+ },
+) {
+ let { root } = await account.$jazz.ensureLoaded({
+ resolve: { root: { people: true } },
+ })
+
+ let group = Group.create()
+ if (args.sharedWith) {
+ group.addMember(args.sharedWith, "writer")
+ }
+
+ let now = new Date()
+ let person = Person.create(
+ {
+ version: 1,
+ name: args.name,
+ summary: args.summary,
+ notes: co.list(Note).create([], group),
+ reminders: co.list(Reminder).create([], group),
+ createdAt: now,
+ updatedAt: now,
+ },
+ group,
+ )
+ root.people.$jazz.push(person)
+ return person
+}
+
+async function seedNote(
+ person: co.loaded,
+ args: { content: string; pinned?: boolean; title?: string; deletedAt?: Date },
+) {
+ let loaded = await person.$jazz.ensureLoaded({ resolve: { notes: true } })
+ let now = new Date()
+ let note = Note.create(
+ {
+ version: 1,
+ title: args.title,
+ content: args.content,
+ pinned: args.pinned ?? false,
+ deletedAt: args.deletedAt,
+ createdAt: now,
+ updatedAt: now,
+ },
+ loaded.$jazz.owner,
+ )
+ loaded.notes.$jazz.push(note)
+ return note
+}
+
+async function seedReminder(
+ person: co.loaded,
+ args: {
+ text: string
+ dueAtDate: string
+ done?: boolean
+ deletedAt?: Date
+ repeat?: { interval: number; unit: "day" | "week" | "month" | "year" }
+ },
+) {
+ let loaded = await person.$jazz.ensureLoaded({
+ resolve: { reminders: true },
+ })
+ let now = new Date()
+ let reminder = Reminder.create(
+ {
+ version: 1,
+ text: args.text,
+ dueAtDate: args.dueAtDate,
+ done: args.done ?? false,
+ repeat: args.repeat,
+ deletedAt: args.deletedAt,
+ createdAt: now,
+ updatedAt: now,
+ },
+ loaded.$jazz.owner,
+ )
+ loaded.reminders.$jazz.push(reminder)
+ return reminder
+}
diff --git a/src/shared/tools/user-question.ts b/src/shared/tools/user-question.ts
index c7d7305..c6f247e 100644
--- a/src/shared/tools/user-question.ts
+++ b/src/shared/tools/user-question.ts
@@ -1,53 +1,45 @@
-import { tool, type InferUITool } from "ai"
import { z } from "zod"
+import { defineTool } from "#shared/tools/define-tool"
export { userQuestionTool, userQuestionExecute }
-let userQuestionTool = tool({
+let userQuestionInput = z.object({
+ question: z.string().describe("The question to ask the user"),
+ options: z
+ .array(
+ z.object({
+ value: z
+ .string()
+ .describe("The value to return when this option is selected"),
+ label: z.string().describe("The human-readable label for this option"),
+ }),
+ )
+ .optional()
+ .describe(
+ "Multiple choice options. If not provided, presents a yes/no question.",
+ ),
+})
+
+let userQuestionOutput = z.object({
+ question: z.string(),
+ answer: z.union([z.boolean(), z.string()]),
+ answerLabel: z.string().optional(),
+ answeredAt: z.string(),
+})
+
+let userQuestionTool = defineTool({
description:
"Ask the user a question and wait for their response. Can be a yes/no question or multiple choice.",
- inputSchema: z.object({
- question: z.string().describe("The question to ask the user"),
- options: z
- .array(
- z.object({
- value: z
- .string()
- .describe("The value to return when this option is selected"),
- label: z
- .string()
- .describe("The human-readable label for this option"),
- }),
- )
- .optional()
- .describe(
- "Multiple choice options. If not provided, presents a yes/no question.",
- ),
- }),
- outputSchema: z.union([
- z.object({
- error: z.string(),
- }),
- z.object({
- cancelled: z.literal(true),
- reason: z.string(),
- }),
- z.object({
- question: z.string(),
- answer: z.union([z.boolean(), z.string()]),
- answerLabel: z.string().optional(),
- answeredAt: z.string(),
- }),
- ]),
+ input: userQuestionInput,
+ output: userQuestionOutput,
+ cancellable: true,
})
-type _UserQuestionTool = InferUITool
-
async function userQuestionExecute(
- input: _UserQuestionTool["input"],
+ input: z.infer,
answer: boolean | string,
answerLabel?: string,
-): Promise<_UserQuestionTool["output"]> {
+): Promise> {
return {
question: input.question,
answer,
diff --git a/src/shared/ui/markdown-editor.tsx b/src/shared/ui/markdown-editor.tsx
index 182826a..3a53608 100644
--- a/src/shared/ui/markdown-editor.tsx
+++ b/src/shared/ui/markdown-editor.tsx
@@ -33,6 +33,7 @@ function MarkdownEditor({
className,
onKeyDown,
id,
+ "data-testid": dataTestId,
}: MarkdownEditorProps) {
let [showPreview, setShowPreview] = useState(false)
let textareaRef = useRef(null)
@@ -181,6 +182,7 @@ function MarkdownEditor({
rows={rows}
maxHeight={400}
id={id}
+ data-testid={dataTestId}
className={cn(
"max-h-[80dvh] overflow-y-auto [&::-webkit-resizer]:hidden",
className,
@@ -516,6 +518,7 @@ type MarkdownEditorProps = {
className?: string
onKeyDown?: (e: React.KeyboardEvent) => void
id?: string
+ "data-testid"?: string
}
type MarkdownFormatType = "bold" | "italic" | "link" | "heading" | "list"