diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 29ffe2d1..9f5e3bf8 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -57,16 +57,42 @@ function parseLabelIds(v: unknown): number[] | undefined { return out; } +type ChecklistItem = { id: string; text: string; done: boolean }; + interface NoteInput { title?: string; content?: string; color?: string; + kind?: "text" | "checklist"; + items?: ChecklistItem[] | null; isPinned?: boolean; isArchived?: boolean; labelIds?: number[]; folderId?: number | null; } +function parseKind(v: unknown): "text" | "checklist" | undefined { + return v === "text" || v === "checklist" ? v : undefined; +} + +function parseItems(v: unknown): ChecklistItem[] | null | undefined { + if (v === null) return null; + if (!Array.isArray(v)) return undefined; + if (v.length > 200) return undefined; + const out: ChecklistItem[] = []; + for (const it of v) { + if (!it || typeof it !== "object") return undefined; + const obj = it as Record; + const id = typeof obj.id === "string" && obj.id.length > 0 && obj.id.length <= 64 + ? obj.id : null; + const text = typeof obj.text === "string" ? obj.text.slice(0, 500) : null; + const done = typeof obj.done === "boolean" ? obj.done : false; + if (id === null || text === null) return undefined; + out.push({ id, text, done }); + } + return out; +} + function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } { if (v === null) return { ok: true, value: null }; const n = Number(v); @@ -107,6 +133,26 @@ function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInp if (c === undefined) return { ok: false, error: "Invalid color" }; data.color = c; } else if (isCreate) data.color = "default"; + if (body.kind !== undefined) { + const k = parseKind(body.kind); + if (!k) return { ok: false, error: "Invalid kind" }; + data.kind = k; + } else if (isCreate) data.kind = "text"; + if (body.items !== undefined) { + const its = parseItems(body.items); + if (its === undefined) return { ok: false, error: "Invalid items" }; + data.items = its; + } + // Normalize items to match kind whenever kind is being set explicitly so + // the column is never out of sync (e.g. checklist-mode without items, or + // text-mode that still carries stale items from the previous shape). + if (data.kind !== undefined) { + if (data.kind === "checklist") { + data.items = data.items ?? []; + } else { + data.items = null; + } + } if (body.isPinned !== undefined) { if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" }; data.isPinned = body.isPinned; @@ -214,6 +260,8 @@ async function loadRecipientsForNote(noteId: number) { title: r.title, content: r.content, color: r.color, + kind: r.kind, + items: r.items, recipient: userMap.get(r.recipientUserId) ?? null, })); } @@ -283,6 +331,8 @@ router.post("/notes", requireAuth, async (req, res): Promise => { title: data.title ?? "", content: data.content ?? "", color: data.color ?? "default", + kind: data.kind ?? "text", + items: data.items ?? null, isPinned: data.isPinned ?? false, isArchived: data.isArchived ?? false, folderId: folderId ?? null, @@ -318,6 +368,17 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { res.status(400).json({ error: "Invalid folderId" }); return; } + // If the caller patched `items` without specifying `kind`, normalize + // against the note's current kind so a text note can never end up + // carrying checklist items (and vice versa). parseNoteInput handles the + // case where `kind` is provided; this covers the items-only PATCH. + if (data.kind === undefined && data.items !== undefined) { + if (existing.kind === "checklist") { + data.items = data.items ?? []; + } else { + data.items = null; + } + } if (Object.keys(data).length > 0) { await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id)); } @@ -411,6 +472,8 @@ router.get("/notes/received", requireAuth, async (req, res): Promise => { title: r.title, content: r.content, color: r.color, + kind: r.kind, + items: r.items, createdAt: r.sentAt, updatedAt: r.readAt ?? r.sentAt, sender: senderMap.get(r.senderUserId) ?? null, @@ -510,11 +573,16 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp const contentFallback = snapshotRow?.content ?? note?.content ?? ""; const colorFallback = snapshotRow?.color ?? note?.color ?? "default"; + const kindFallback = snapshotRow?.kind ?? note?.kind ?? "text"; + const itemsFallback = snapshotRow?.items ?? note?.items ?? null; + res.json({ id: id.id, title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback, content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback, color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback, + kind: useSnapshot ? recipientRow!.kind : note?.kind ?? kindFallback, + items: useSnapshot ? recipientRow!.items : note?.items ?? itemsFallback, createdAt: useSnapshot ? recipientRow!.sentAt : note?.createdAt ?? snapshotRow?.sentAt ?? null, @@ -595,6 +663,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise => { title: note.title, content: note.content, color: note.color, + kind: note.kind, + items: note.items, })), ) .onConflictDoNothing({ @@ -625,6 +695,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise => { title: r.title, content: r.content, color: r.color, + kind: r.kind, + items: r.items, sentAt: r.sentAt, senderUserId: userId, sender: sender ?? null, diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 617a3660..c61a8cde 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 8baede19..9a55460b 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -1,5 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +export type NoteKind = "text" | "checklist"; + +export interface ChecklistItem { + id: string; + text: string; + done: boolean; +} + export interface Note { id: number; userId: number; @@ -7,6 +15,8 @@ export interface Note { title: string; content: string; color: string; + kind: NoteKind; + items: ChecklistItem[] | null; isPinned: boolean; isArchived: boolean; createdAt: string; @@ -49,6 +59,11 @@ export interface SentNoteRecipient { sentAt: string; readAt: string | null; archivedAt: string | null; + title?: string; + content?: string; + color?: string; + kind?: NoteKind; + items?: ChecklistItem[] | null; recipient: UserSummary | null; } @@ -62,6 +77,8 @@ export interface ReceivedNote { title: string; content: string; color: string; + kind: NoteKind; + items: ChecklistItem[] | null; createdAt: string; updatedAt: string; sender: UserSummary | null; @@ -87,6 +104,8 @@ export interface NoteThread { title: string; content: string; color: string; + kind: NoteKind; + items: ChecklistItem[] | null; createdAt: string; updatedAt: string; senderUserId: number; diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index d4540e17..0c6c3df5 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1138,7 +1138,15 @@ "recipientsCount_other": "{{count}} مستلمين", "repliesCount_one": "رد واحد", "repliesCount_other": "{{count}} ردود", - "openThread": "فتح" + "openThread": "فتح", + "checklist": { + "toggle": "قائمة مهام", + "addItem": "إضافة عنصر", + "itemPlaceholder": "عنصر القائمة", + "emptyHint": "لا توجد عناصر بعد — أضف عنصراً أدناه.", + "removeItem": "حذف العنصر", + "progress": "{{done}} / {{total}}" + } }, "executiveMeetings": { "title": "قائمة الاجتماعات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index d565c6be..cb8afec9 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1039,7 +1039,15 @@ }, "conversationWith": "Conversation with {{name}}", "ownerReplies": "Your replies", - "noConversationsYet": "No conversations yet" + "noConversationsYet": "No conversations yet", + "checklist": { + "toggle": "To-do list", + "addItem": "Add item", + "itemPlaceholder": "List item", + "emptyHint": "No items yet — add one below.", + "removeItem": "Remove item", + "progress": "{{done}} / {{total}}" + } }, "executiveMeetings": { "title": "Meetings list", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index c9e46a50..6cfb9eb5 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -19,6 +19,7 @@ import { Inbox, Mail, MessageCircle, + ListTodo, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -47,7 +48,9 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { + type ChecklistItem, type Note, + type NoteKind, type NoteLabel, type NoteThread, type ReceivedNote, @@ -642,10 +645,24 @@ function NoteCard({ {note.title} )} - {note.content && ( -
- {note.content} -
+ {note.kind === "checklist" ? ( + { + const next = (note.items ?? []).map((it) => + it.id === itemId ? { ...it, done } : it, + ); + update.mutate({ id: note.id, kind: "checklist", items: next }); + }} + /> + ) : ( + note.content && ( +
+ {note.content} +
+ ) )} ))} @@ -822,10 +847,18 @@ function SentList({ {n.title} )} - {n.content && ( -
- {n.content} -
+ {n.kind === "checklist" ? ( + + ) : ( + n.content && ( +
+ {n.content} +
+ ) )}
{t("notes.to", "To:")}{" "} @@ -1191,10 +1224,17 @@ function ThreadDialog({ {t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
- {thread.content && ( -
- {thread.content} -
+ {thread.kind === "checklist" ? ( + + ) : ( + thread.content && ( +
+ {thread.content} +
+ ) )} {(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
@@ -1413,6 +1453,199 @@ function GroupedReplies({ ); } +function newItemId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`; +} + +function ChecklistEditor({ + items, + onChange, + testIdPrefix, +}: { + items: ChecklistItem[]; + onChange: (next: ChecklistItem[]) => void; + testIdPrefix: string; +}) { + const { t } = useTranslation(); + const inputRefs = useRef>(new Map()); + const focusItemIdRef = useRef(null); + + // After append (Enter or Add), shift focus to the freshly inserted row. + useEffect(() => { + const id = focusItemIdRef.current; + if (!id) return; + const el = inputRefs.current.get(id); + if (el) { + el.focus(); + focusItemIdRef.current = null; + } + }, [items]); + + const setItem = (id: string, patch: Partial) => { + onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it))); + }; + const removeItem = (id: string) => { + onChange(items.filter((it) => it.id !== id)); + }; + const appendItem = () => { + const item: ChecklistItem = { id: newItemId(), text: "", done: false }; + focusItemIdRef.current = item.id; + onChange([...items, item]); + }; + + return ( +
+ {items.length === 0 && ( +
+ {t("notes.checklist.emptyHint", "No items yet — add one below.")} +
+ )} + {items.map((it) => ( +
+ setItem(it.id, { done: e.target.checked })} + className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500" + aria-label={t("notes.checklist.itemPlaceholder", "List item")} + data-testid={`${testIdPrefix}-check-${it.id}`} + /> + { + if (el) inputRefs.current.set(it.id, el); + else inputRefs.current.delete(it.id); + }} + value={it.text} + onChange={(e) => setItem(it.id, { text: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + appendItem(); + } else if ( + e.key === "Backspace" && + it.text.length === 0 && + items.length > 1 + ) { + e.preventDefault(); + removeItem(it.id); + } + }} + placeholder={t("notes.checklist.itemPlaceholder", "List item")} + className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${ + it.done ? "line-through text-muted-foreground" : "" + }`} + data-testid={`${testIdPrefix}-text-${it.id}`} + /> + +
+ ))} + +
+ ); +} + +function ChecklistView({ + items, + onToggle, + testIdPrefix, + className = "", +}: { + items: ChecklistItem[]; + onToggle?: (id: string, done: boolean) => void; + testIdPrefix: string; + className?: string; +}) { + if (items.length === 0) return null; + return ( +
{ + // Prevent the parent card's click handler (which opens edit) when + // toggling a checkbox is the intended action. + if (onToggle) e.stopPropagation(); + }} + > + {items.map((it) => ( + + ))} +
+ ); +} + +function KindToggle({ + kind, + onChange, + testId, +}: { + kind: NoteKind; + onChange: (k: NoteKind) => void; + testId?: string; +}) { + const { t } = useTranslation(); + const next: NoteKind = kind === "text" ? "checklist" : "text"; + return ( + + ); +} + function ColorPicker({ value, onChange, @@ -1531,6 +1764,8 @@ function Composer({ labels }: { labels: NoteLabel[] }) { const [content, setContent] = useState(""); const [color, setColor] = useState("default"); const [labelIds, setLabelIds] = useState([]); + const [kind, setKind] = useState("text"); + const [items, setItems] = useState([]); const create = useCreateNote(); const ref = useRef(null); @@ -1557,23 +1792,41 @@ function Composer({ labels }: { labels: NoteLabel[] }) { document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, title, content, color, labelIds]); + }, [open, title, content, color, labelIds, kind, items]); const reset = () => { setTitle(""); setContent(""); setColor("default"); setLabelIds([]); + setKind("text"); + setItems([]); setOpen(false); }; const save = () => { - if (!title.trim() && !content.trim()) { + const cleanItems = + kind === "checklist" + ? items + .map((it) => ({ ...it, text: it.text.trim() })) + .filter((it) => it.text.length > 0) + : []; + const isEmpty = + !title.trim() && + (kind === "checklist" ? cleanItems.length === 0 : !content.trim()); + if (isEmpty) { reset(); return; } create.mutate( - { title: title.trim(), content: content.trim(), color, labelIds }, + { + title: title.trim(), + content: kind === "checklist" ? "" : content.trim(), + color, + labelIds, + kind, + items: kind === "checklist" ? cleanItems : null, + }, { onSuccess: reset }, ); }; @@ -1596,15 +1849,30 @@ function Composer({ labels }: { labels: NoteLabel[] }) { className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 font-semibold" data-testid="notes-composer-title" /> -