Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Tilly — Domain Glossary

Vocabulary for navigating the codebase. Add terms here when a new concept earns a module of its own.

## Domain

- **Person** — central entity. Has `name`, optional `summary` (free-text, may contain hashtags = lists), `avatar`, and child collections of notes and reminders. Soft-deletable via `deletedAt`. (`src/shared/schema/user.ts`)
- **Note** — free-text entry attached to a Person. Optional title, content, images, `pinned`. Soft-deletable. Lives in `Person.notes` (active) or `Person.inactiveNotes` (kept for restore).
- **Reminder** — dated task attached to a Person. `text`, `dueAtDate`, optional `repeat` (interval + day/week/month/year), `done`. Soft-deletable. Same active/inactive split as notes.
- **List** — a hashtag in a Person's `summary`. Lists are not modeled as a separate CoValue; they are derived by parsing hashtags from `summary`. Adding a person to a list = inserting `#tag` into their summary. (Currently scattered across `people/lib`; see ADR-0001's deepening goals.)
- **Sharing** — a Person can be shared via an invite link. Sharing migrates the Person and its child notes/reminders into a new Group so the recipient has access. (`src/app/features/people/lib/person-sharing.ts`)
- **Assistant** — chat-driven AI that mutates the user's data via tools. Conversation state is persisted as `Assistant.stringifiedMessages` on the user account.

## Architecture

- **Module** — anything with an interface and an implementation: a function, a file, a feature folder.
- **Operation** — a mutation result returned by a core op. Shape: `{ operation: "create" | "update", current, previous? }`. The `current`/`previous` snapshots use the same shape as the AI tool's success output, so action-layer undo and chat-side undo read the same fields.
- **Deleted** — distinct mutation result for hard deletes. Shape: `{ operation: "delete", previous }`. No `current`, since the entity no longer exists.
- **Core op** — a function that performs a mutation against the Jazz schema and returns `Operation<T>` or `Deleted<T>`. Lives in `src/shared/tools/<entity>-<verb>.ts`. Takes a `worker: Loaded<UserAccount>` and reloads its dependencies fresh. Called directly by both the AI tool adapter and the UI action layer.
- **Tool** — an AI SDK tool, i.e. a description + zod input/output schemas the model uses to interact with the app. Defined per-entity via the `defineTool` primitive (`src/shared/tools/define-tool.ts`).
- **Action handler** — UI-side function (`src/app/features/<feature>/lib/*-actions.ts`) that calls a core op and adds toast feedback + undo.
- **Adapter** — concrete thing satisfying an interface at a seam. The `defineTool` primitive is the adapter at the AI SDK ↔ core op seam.
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions e2e/note-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect, type Page } from "@playwright/test"
import { testIds } from "../src/shared/lib/test-ids"

export { createNote, listNotes, editNote, deleteNote, restoreNote, togglePin }

async function createNote(
page: Page,
args: { personId: string; content: string },
) {
await page.goto(`/app/people/${args.personId}?tab=notes`)
await page.getByTestId(testIds.note.newButton).click()
await page.getByTestId(testIds.note.formContentInput).fill(args.content)
await page.getByTestId(testIds.note.formSubmit).click()

await expect
.poll(() => page.getByTestId(testIds.note.formSubmit).isVisible())
.toBe(false)

await expect
.poll(async () => await page.getByTestId(testIds.note.listItem).count(), {
timeout: 10_000,
})
.toBeGreaterThan(0)

let items = await listNotes(page, { personId: args.personId })
let created = items.items[0]
if (!created) throw new Error("Note not visible after create")

return { ok: true as const, id: created.id, content: args.content }
}

async function listNotes(page: Page, args: { personId: string }) {
if (!page.url().includes(`/app/people/${args.personId}`)) {
await page.goto(`/app/people/${args.personId}?tab=notes`)
}
let items = await page.getByTestId(testIds.note.listItem).evaluateAll(rows =>
rows.map(row => ({
id: row.getAttribute("data-note-id") ?? "",
status: row.getAttribute("data-note-status") ?? "",
})),
)
return { ok: true as const, count: items.length, items }
}

async function editNote(
page: Page,
args: { personId: string; noteId: string; content: string },
) {
let row = page.locator(
`[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`,
)
await row.scrollIntoViewIfNeeded()
await row.click()
await row.getByTestId(testIds.note.editButton).click()
await page.getByTestId(testIds.note.formContentInput).fill(args.content)
await page.getByTestId(testIds.note.formSubmit).click()

await expect
.poll(() => page.getByTestId(testIds.note.formSubmit).isVisible())
.toBe(false)

return { ok: true as const, id: args.noteId, content: args.content }
}

async function togglePin(
page: Page,
args: { personId: string; noteId: string },
) {
let row = page.locator(
`[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`,
)
await row.scrollIntoViewIfNeeded()
await row.click()
await row.getByTestId(testIds.note.pinButton).click()
return { ok: true as const, id: args.noteId }
}

async function deleteNote(
page: Page,
args: { personId: string; noteId: string },
) {
let row = page.locator(
`[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`,
)
await row.scrollIntoViewIfNeeded()
await row.click()
await row.getByTestId(testIds.note.deleteButton).click()
return { ok: true as const, id: args.noteId }
}

async function restoreNote(
page: Page,
args: { personId: string; noteId: string },
) {
let row = page.locator(
`[data-testid="${testIds.note.listItem}"][data-note-id="${args.noteId}"]`,
)
await row.scrollIntoViewIfNeeded()
await row.click()
await row.getByTestId(testIds.note.restoreButton).click()
return { ok: true as const, id: args.noteId }
}
49 changes: 49 additions & 0 deletions e2e/note.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test"
import { bootApp, skipTourIfShown, gotoPeople } from "./setup-helpers"
import { createPerson } from "./person-helpers"
import {
createNote,
deleteNote,
editNote,
listNotes,
togglePin,
} from "./note-helpers"

test("note CRUD on a person: create → edit → pin → delete", async ({
page,
}) => {
await bootApp(page)
await skipTourIfShown(page)
await gotoPeople(page)

let suffix = Date.now().toString(36)
let person = await createPerson(page, { name: `Note Owner ${suffix}` })

let note = await createNote(page, {
personId: person.id,
content: "first note content",
})
expect(note.id).toMatch(/^co_/)

let edited = await editNote(page, {
personId: person.id,
noteId: note.id,
content: "edited content",
})
expect(edited.content).toBe("edited content")

await togglePin(page, { personId: person.id, noteId: note.id })

let beforeDelete = await listNotes(page, { personId: person.id })
expect(
beforeDelete.items.some(n => n.id === note.id && n.status === "active"),
).toBe(true)

await deleteNote(page, { personId: person.id, noteId: note.id })

let afterDelete = await listNotes(page, { personId: person.id })
let stillActive = afterDelete.items.find(
n => n.id === note.id && n.status === "active",
)
expect(stillActive).toBeUndefined()
})
105 changes: 105 additions & 0 deletions e2e/person-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect, type Page } from "@playwright/test"
import { testIds } from "../src/shared/lib/test-ids"

export {
createPerson,
listPeople,
openPerson,
updatePerson,
deletePerson,
restorePerson,
}

async function createPerson(
page: Page,
args: { name: string; summary?: string },
) {
await page.getByTestId(testIds.person.newButton).first().click()
await page.getByTestId(testIds.person.formNameInput).fill(args.name)
if (args.summary !== undefined) {
await page.getByTestId(testIds.person.formSummaryInput).fill(args.summary)
}
await page.getByTestId(testIds.person.formSubmit).click()

await expect.poll(() => page.url()).toMatch(/\/people\/co_[^/]+/)

let id = parsePersonIdFromUrl(page.url())
if (!id) throw new Error(`Could not parse person id from ${page.url()}`)

return { ok: true as const, id, name: args.name, summary: args.summary }
}

async function listPeople(page: Page) {
let items = await page
.getByTestId(testIds.person.listItem)
.evaluateAll(rows => {
return rows.map(row => ({
id: row.getAttribute("data-person-id") ?? "",
status: row.getAttribute("data-person-status") ?? "",
}))
})

return { ok: true as const, count: items.length, items }
}

async function openPerson(page: Page, args: { personId: string }) {
await page.goto(`/app/people/${args.personId}`)
await expect(page.getByTestId(testIds.person.detailName)).toBeVisible()

let name = await page.getByTestId(testIds.person.detailName).innerText()
return { ok: true as const, id: args.personId, name }
}

async function updatePerson(
page: Page,
args: { personId: string; name?: string; summary?: string },
) {
await page.goto(`/app/people/${args.personId}`)
await page.getByTestId(testIds.person.actionsTrigger).click()
await page.getByTestId(testIds.person.editButton).click()

if (args.name !== undefined) {
await page.getByTestId(testIds.person.formNameInput).fill(args.name)
}
if (args.summary !== undefined) {
await page.getByTestId(testIds.person.formSummaryInput).fill(args.summary)
}
await page.getByTestId(testIds.person.formSubmit).click()

await expect
.poll(() => page.getByTestId(testIds.person.formSubmit).isVisible())
.toBe(false)

return { ok: true as const, id: args.personId }
}

async function deletePerson(page: Page, args: { personId: string }) {
await page.goto(`/app/people/${args.personId}`)
await page.getByTestId(testIds.person.actionsTrigger).click()
await page.getByTestId(testIds.person.deleteButton).click()
await page.getByTestId(testIds.person.deleteConfirmButton).click()

return { ok: true as const, id: args.personId }
}

async function restorePerson(page: Page, args: { personId: string }) {
await page.goto("/app/people")
let row = page.locator(
`[data-testid="${testIds.person.listItem}"][data-person-id="${args.personId}"]`,
)
let visible = await row.isVisible({ timeout: 2_000 }).catch(() => false)
if (!visible) {
throw new Error(
`Deleted person ${args.personId} not visible — is the deleted-filter active?`,
)
}
await row.click()
await page.getByTestId(testIds.person.restoreButton).click()

return { ok: true as const, id: args.personId }
}

function parsePersonIdFromUrl(url: string) {
let match = url.match(/\/people\/(co_[^/?#]+)/)
return match?.[1] ?? null
}
47 changes: 47 additions & 0 deletions e2e/person.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect, test } from "@playwright/test"
import { bootApp, skipTourIfShown, gotoPeople } from "./setup-helpers"
import {
createPerson,
deletePerson,
listPeople,
openPerson,
updatePerson,
} from "./person-helpers"

test("person CRUD: create → read → update → delete → restore", async ({
page,
}) => {
await bootApp(page)
await skipTourIfShown(page)
await gotoPeople(page)

let suffix = Date.now().toString(36)
let created = await createPerson(page, {
name: `E2E Person ${suffix}`,
summary: "test subject",
})
expect(created.id).toMatch(/^co_/)

await gotoPeople(page)
let listed = await listPeople(page)
expect(
listed.items.some(p => p.id === created.id && p.status === "active"),
).toBe(true)

let read = await openPerson(page, { personId: created.id })
expect(read.name).toBe(`E2E Person ${suffix}`)

let newName = `E2E Person ${suffix} updated`
await updatePerson(page, { personId: created.id, name: newName })

let reread = await openPerson(page, { personId: created.id })
expect(reread.name).toBe(newName)

await deletePerson(page, { personId: created.id })
await gotoPeople(page)
let afterDelete = await listPeople(page)
let foundActive = afterDelete.items.find(
p => p.id === created.id && p.status === "active",
)
expect(foundActive).toBeUndefined()
})
Loading
Loading