Optional description field on activities

A free-text body alongside title/tags/location/scheduled. Plain text
for now; markdown rendering is a deliberate non-goal (the user noted
it was nice-to-have but not essential).

Schema (additive, idempotent via ensureColumn):
  - activities.description TEXT NULL
  - For private rows the column stays NULL; the description lives
    inside the encrypted payload alongside title.

Wire/types:
  - PrivatePayload.description?: string  (in shared/crypto.ts)
  - ActivityPublic.description / ActivitySemi.description: string | null
  - CreateActivityRequest.description?: string | null

Server:
  - INSERT and UPDATE handlers now write description for semi/public
  - Private→semi/public transition: description column populated
  - Semi/public→private transition: description column wiped (now in
    the encrypted blob)
  - serialize() includes the column on public and semi rows
  - server/users.ts public-list endpoint surfaces it too

Frontend:
  - ActivityForm.svelte: textarea after the title field; round-trips
    through the existing private-encrypt / plaintext-PATCH paths
  - ActivityRow.svelte: renders the description as a `white-space:
    pre-wrap` <p> so line breaks survive without enabling markdown
  - Home.svelte: search now matches against the description text
    (decrypted client-side for private rows, just like the title)
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:08:55 +02:00
commit 3215917b7a
8 changed files with 48 additions and 6 deletions

View file

@ -29,6 +29,7 @@ interface ActivityRow {
ciphertext: Uint8Array | null;
nonce: Uint8Array | null;
title: string | null;
description: string | null;
scheduled_at: number | null;
loc_label: string | null;
loc_lat: number | null;
@ -128,6 +129,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
id: row.id,
visibility: 'semi',
title: row.title ?? '',
description: row.description,
tags,
loc_label: row.loc_label,
loc_lat: row.loc_lat,
@ -149,6 +151,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
owner_display: attrib.display,
owner_username: attrib.username,
title: row.title ?? '',
description: row.description,
tags,
loc_label: row.loc_label,
loc_lat: row.loc_lat,
@ -237,11 +240,13 @@ activitiesRoutes.post('/', requireAuth, async (c) => {
} else {
db.prepare(`
INSERT INTO activities
(id, owner_id, visibility, title, scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, owner_id, visibility, title, description, scheduled_at,
loc_label, loc_lat, loc_lng, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, userId, body.visibility,
body.title!.trim(),
body.description?.trim() || null,
body.scheduled_at ?? null,
body.loc_label ?? null,
body.loc_lat ?? null,
@ -282,6 +287,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
ciphertext = ?,
nonce = ?,
title = NULL,
description = NULL,
scheduled_at = NULL,
loc_label = NULL,
loc_lat = NULL,
@ -296,6 +302,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
UPDATE activities SET
visibility = ?,
title = ?,
description = ?,
scheduled_at = ?,
loc_label = ?,
loc_lat = ?,
@ -307,6 +314,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
`).run(
body.visibility,
body.title!.trim(),
body.description?.trim() || null,
body.scheduled_at ?? null,
body.loc_label ?? null,
body.loc_lat ?? null,

View file

@ -58,6 +58,10 @@ const SCHEMA_STATEMENTS: readonly string[] = [
ciphertext BLOB,
nonce BLOB,
title TEXT,
-- Optional free-text body. Plain text for now (markdown is a possible
-- future polish). For private rows this column stays NULL the
-- description lives inside the encrypted payload alongside the title.
description TEXT,
scheduled_at INTEGER,
loc_label TEXT,
loc_lat REAL,
@ -184,6 +188,7 @@ export function getDb(): Database {
// Feedback triage columns (added after the feedback feature shipped).
ensureColumn(db, 'feedback', 'done_at', 'INTEGER');
ensureColumn(db, 'feedback', 'done_by', 'TEXT');
ensureColumn(db, 'activities', 'description', 'TEXT');
ensureColumn(db, 'users', 'username', 'TEXT');
ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0');
// UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE

View file

@ -15,6 +15,7 @@ interface ActivityRow {
id: string;
owner_id: string;
title: string | null;
description: string | null;
scheduled_at: number | null;
loc_label: string | null;
loc_lat: number | null;
@ -41,8 +42,8 @@ usersRoutes.get('/:username/list', (c) => {
const rows = db
.prepare(`
SELECT id, owner_id, title, scheduled_at, loc_label, loc_lat, loc_lng,
created_at, updated_at
SELECT id, owner_id, title, description, scheduled_at, loc_label,
loc_lat, loc_lng, created_at, updated_at
FROM activities
WHERE owner_id = ? AND visibility = 'public'
ORDER BY created_at DESC
@ -64,6 +65,7 @@ usersRoutes.get('/:username/list', (c) => {
// Surfacing it on each row keeps ActivityRow's rendering uniform.
owner_username: username,
title: r.title ?? '',
description: r.description,
tags: tagsFor(r.id),
loc_label: r.loc_label,
loc_lat: r.loc_lat,