From a16c6c32a9bb72d109c8e076ef853107cf6b100a Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 14:22:07 -0800 Subject: [PATCH 01/12] fix(site): restore landing page syntax token styles --- site/src/layouts/Base.astro | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 9df35ef8d..01915ebaf 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -44,25 +44,25 @@ const { title, description } = Astro.props --lp-code-type: #e5c07b; } - .lp-code { + :global(.lp-code) { color: var(--lp-code-fg); } - .lp-code .tok-comment { + :global(.lp-code .tok-comment) { color: var(--lp-code-comment); } - .lp-code .tok-keyword { + :global(.lp-code .tok-keyword) { color: var(--lp-code-keyword); } - .lp-code .tok-function { + :global(.lp-code .tok-function) { color: var(--lp-code-function); } - .lp-code .tok-string { + :global(.lp-code .tok-string) { color: var(--lp-code-string); } - .lp-code .tok-number { + :global(.lp-code .tok-number) { color: var(--lp-code-number); } - .lp-code .tok-type { + :global(.lp-code .tok-type) { color: var(--lp-code-type); } From a44a13df6e76b4ea43d3aa4f18e49864fd2fbc37 Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 15:29:06 -0800 Subject: [PATCH 02/12] docs(exploration): add AFFiNE BlockSuite integration feasibility analysis - add exploration 0102 covering AFFiNE BlockSuite integration feasibility - document architecture fit, risks, and implementation considerations - preserve findings in docs/explorations for team review --- ...FINE_BLOCKSUITE_INTEGRATION_FEASIBILITY.md | 1066 +++++++++++++++++ 1 file changed, 1066 insertions(+) create mode 100644 docs/explorations/0102_[_]_AFFINE_BLOCKSUITE_INTEGRATION_FEASIBILITY.md diff --git a/docs/explorations/0102_[_]_AFFINE_BLOCKSUITE_INTEGRATION_FEASIBILITY.md b/docs/explorations/0102_[_]_AFFINE_BLOCKSUITE_INTEGRATION_FEASIBILITY.md new file mode 100644 index 000000000..c96473c72 --- /dev/null +++ b/docs/explorations/0102_[_]_AFFINE_BLOCKSUITE_INTEGRATION_FEASIBILITY.md @@ -0,0 +1,1066 @@ +# 0102 - AFFiNE/BlockSuite Integration Feasibility Analysis + +> **Status:** Exploration +> **Tags:** architecture, integration, blocksuite, affine, canvas, editor, database, ux +> **Created:** 2026-03-05 +> **Context:** Evaluating whether AFFiNE/BlockSuite can power xNet's docs, canvas, and database features while preserving core xNet APIs and architecture. + +## Executive Summary + +AFFiNE provides a polished, feature-rich UX for docs, canvas, and database editing that could accelerate xNet's UI development. However, **full integration would require significant architectural compromises** that conflict with xNet's core design principles: + +**Key Findings:** + +- ✅ **UX is exceptional** - Best-in-class editing experience with rich features +- ⚠️ **CRDT conflict** - Both use Yjs but with incompatible data models (BlockSuite blocks vs xNet nodes) +- ❌ **Storage mismatch** - BlockSuite uses IndexedDB/y-indexeddb; xNet uses SQLite with event-sourced changes +- ⚠️ **Authorization gap** - BlockSuite has minimal auth; xNet has comprehensive node-level auth with UCAN +- ✅ **Component reuse viable** - UI components (toolbar, panels, widgets) can be cherry-picked +- ⚠️ **React API preservation** - Possible but requires heavy adapter layer + +**Recommendation:** **Selective UX borrowing over full integration**. Instead of dropping in BlockSuite wholesale, systematically copy UI patterns, interaction models, and visual polish while maintaining xNet's underlying architecture. + +--- + +## Problem Statement + +xNet needs polished editors for: + +1. **Documents** - Rich text with blocks (currently TipTap-based) +2. **Canvas** - Infinite whiteboard with spatial indexing (custom implementation) +3. **Database** - Table/Kanban views with real-time collaboration (custom views) + +AFFiNE solves all three with a mature, unified UX. The question: Can we integrate BlockSuite without breaking xNet's foundational architecture? + +--- + +## Architecture Comparison + +### High-Level Stack Comparison + +```mermaid +graph TB + subgraph AFFiNE["AFFiNE Stack"] + A1[React App] + A2["BlockSuite Presets + (PageEditor, EdgelessEditor)"] + A3["BlockSuite Blocks + (@blocksuite/blocks)"] + A4["BlockSuite Framework + (@blocksuite/block-std)"] + A5["Yjs Document + (Y.Doc with blocks)"] + A6["IndexedDB + (y-indexeddb)"] + + A1 --> A2 + A2 --> A3 + A3 --> A4 + A4 --> A5 + A5 --> A6 + end + + subgraph xNet["xNet Stack"] + X1[React App] + X2["@xnetjs/react + (useQuery, useMutate, useNode)"] + X3["@xnetjs/editor + (TipTap + Yjs)"] + X4["@xnetjs/canvas + (Custom + Spatial Index)"] + X5["@xnetjs/views + (Table, Kanban)"] + X6["@xnetjs/data + (NodeStore + Schema)"] + X7["@xnetjs/sync + (Lamport + signed changes)"] + X8["@xnetjs/storage + (SQLite + event sourcing)"] + + X1 --> X2 + X2 --> X3 + X2 --> X4 + X2 --> X5 + X3 --> X6 + X4 --> X6 + X5 --> X6 + X6 --> X7 + X7 --> X8 + end + + style A5 fill:#ffeb3b + style X6 fill:#ffeb3b + style X7 fill:#ff9800 +``` + +### Data Model Clash + +```mermaid +graph LR + subgraph BlockSuite["BlockSuite Data Model"] + BS1[Y.Doc] + BS2[Y.Map blocks] + BS3[Block ID → Block Data] + BS4["Block: {type, props, children}"] + BS5[No schema system] + BS6[Block-centric] + + BS1 --> BS2 + BS2 --> BS3 + BS3 --> BS4 + BS4 --> BS5 + BS5 --> BS6 + end + + subgraph xNet["xNet Data Model"] + X1[NodeStore] + X2[Event-sourced changes] + X3[Node ID → NodeState] + X4["Node: {schemaId, properties, timestamps}"] + X5[Rich schema system with 15 types] + X6[Node-centric] + X7[SQLite persistence] + X8[Lamport clock ordering] + + X1 --> X2 + X2 --> X3 + X3 --> X4 + X4 --> X5 + X5 --> X6 + X2 --> X7 + X2 --> X8 + end + + style BS4 fill:#f44336 + style X4 fill:#4caf50 +``` + +**Critical Incompatibility:** BlockSuite stores blocks as Yjs maps with no schema system, while xNet stores schema-validated nodes with event-sourced changes and Lamport timestamps. These cannot be directly bridged without losing key xNet features. + +--- + +## Feature Overlap Analysis + +### 1. Document Editing + +| Feature | BlockSuite | xNet (TipTap) | Notes | +| -------------- | ---------------- | ----------------- | ------------------------------------- | +| Rich text | ✅ Excellent | ✅ Good | BlockSuite has more polish | +| Block types | ✅ 20+ blocks | ⚠️ Basic | xNet uses schema system instead | +| Collaboration | ✅ Yjs native | ✅ Yjs native | Both use Yjs but different structures | +| Undo/redo | ✅ Yjs history | ✅ Custom history | xNet has time-travel via changes | +| Markdown | ✅ Import/export | ✅ Import/export | Similar capabilities | +| AI integration | ✅ Built-in | ❌ Planned | AFFiNE AI is a major feature | +| Block nesting | ✅ Deep nesting | ⚠️ Schema-based | Different mental models | + +**Overlap Score: 70%** - Both solve rich text editing but with different block/node models. + +### 2. Canvas/Whiteboard + +| Feature | BlockSuite Edgeless | xNet Canvas | Notes | +| ---------------- | ------------------- | ----------------- | ------------------------------ | +| Infinite canvas | ✅ Yes | ✅ Yes | Core feature for both | +| Spatial indexing | ✅ Built-in | ✅ R-tree custom | xNet's is optimized for chunks | +| Shapes & drawing | ✅ Rich toolkit | ⚠️ Basic | BlockSuite far ahead | +| Canvas blocks | ✅ Embed docs | ✅ Link nodes | Different approaches | +| Performance | ✅ Canvas rendering | ✅ SVG + chunking | Different rendering strategies | +| Collaboration | ✅ Yjs | ✅ Yjs + spatial | xNet adds spatial index sync | +| Comments | ✅ Yes | ✅ Yes | Similar capabilities | + +**Overlap Score: 85%** - Very similar problem space but different implementations. + +### 3. Database Views + +| Feature | AFFiNE Database | xNet Views | Notes | +| -------------- | ---------------- | ------------------ | ---------------------------- | +| Table view | ✅ Full-featured | ✅ Custom | Both have rich tables | +| Kanban | ✅ Yes | ✅ Yes | Similar capabilities | +| Property types | ✅ 10+ types | ✅ 15 types | xNet has more type variety | +| Relations | ✅ Basic | ✅ First-class | xNet's relations are core | +| Formulas | ✅ Built-in | ✅ @xnetjs/formula | Both support computed values | +| Filtering | ✅ UI-driven | ✅ Query API | Different approaches | +| Grouping | ✅ Yes | ✅ Yes | Similar | +| Authorization | ❌ Minimal | ✅ Node-level | **Major gap** | + +**Overlap Score: 75%** - Similar features but xNet's schema system is more powerful. + +--- + +## Critical Architectural Conflicts + +### Conflict 1: CRDT Mismatch + +```mermaid +sequenceDiagram + participant User + participant BlockSuite + participant YjsDoc as Yjs Doc + participant xNetStore as xNet Store + participant SQLite + + User->>BlockSuite: Edit block + BlockSuite->>YjsDoc: Y.Map.set(blockId, data) + Note over YjsDoc: No schema validation + Note over YjsDoc: No Lamport clock + Note over YjsDoc: No signatures + + User->>xNetStore: Update node + xNetStore->>xNetStore: Create signed change + xNetStore->>xNetStore: Validate schema + xNetStore->>xNetStore: Apply LWW merge + xNetStore->>SQLite: Append change + update state + Note over SQLite: Event-sourced + Note over SQLite: Auditable + Note over SQLite: Time-travel ready +``` + +**Problem:** BlockSuite's Yjs documents are ephemeral and lack the durability, auditability, and schema guarantees that xNet requires. Bridging this would require intercepting every Yjs update and converting it to xNet changes - a massive performance and complexity burden. + +### Conflict 2: Storage Paradigm + +```mermaid +graph TB + subgraph BlockSuite["BlockSuite Storage"] + BS1[Yjs updates in memory] + BS2[y-indexeddb persistence] + BS3[IndexedDB] + BS4[Binary Yjs snapshots] + BS5[No audit trail] + + BS1 --> BS2 + BS2 --> BS3 + BS3 --> BS4 + BS4 --> BS5 + end + + subgraph xNet["xNet Storage"] + X1[Signed changes] + X2[Event sourcing] + X3[SQLite] + X4[Materialized node state] + X5[Full audit trail] + X6[Time-travel queries] + + X1 --> X2 + X2 --> X3 + X3 --> X4 + X4 --> X5 + X4 --> X6 + end + + style BS5 fill:#f44336 + style X5 fill:#4caf50 +``` + +**Problem:** xNet's event-sourced architecture is foundational for features like time-travel, audit logs, and conflict-free sync. BlockSuite's approach optimizes for edit performance but sacrifices these capabilities. + +### Conflict 3: Authorization Model + +```mermaid +graph LR + subgraph AFFiNE["AFFiNE Authorization"] + A1[Workspace-level] + A2[Cloud service auth] + A3[Limited granularity] + + A1 --> A2 + A2 --> A3 + end + + subgraph xNet["xNet Authorization"] + X1[Node-level] + X2[StoreAuth API] + X3[UCAN delegation] + X4[Offline-capable] + X5[can/grant/revoke/explain] + X6[Policy evaluator] + X7[Recipient-aware encryption] + + X1 --> X2 + X1 --> X3 + X2 --> X4 + X2 --> X5 + X3 --> X6 + X3 --> X7 + end + + style A3 fill:#f44336 + style X7 fill:#4caf50 +``` + +**Problem:** xNet's fine-grained, offline-capable authorization is core to its security model. BlockSuite assumes workspace-level permissions managed by a cloud service, which is incompatible with xNet's peer-to-peer, local-first design. + +--- + +## Integration Strategies + +### Strategy A: Full BlockSuite Adoption (❌ Not Recommended) + +Replace xNet's editor, canvas, and views with BlockSuite components entirely. + +```mermaid +graph TB + App[React App] + BS[BlockSuite Presets] + Adapter[Heavy Adapter Layer] + Store[xNet NodeStore] + + App --> BS + BS --> Adapter + Adapter --> Store + + style Adapter fill:#f44336 +``` + +**Pros:** + +- ✅ Instant access to polished UX +- ✅ Mature feature set (AI, shapes, etc.) +- ✅ Active development + +**Cons:** + +- ❌ Lose event sourcing and audit trail +- ❌ Break schema system +- ❌ Destroy authorization model +- ❌ Massive adapter complexity (every Yjs update → xNet change) +- ❌ Performance overhead from dual sync systems +- ❌ React API breaks entirely + +**Verdict:** **Not viable** - Core xNet features would be gutted. + +--- + +### Strategy B: BlockSuite UI Components Only (✅ Feasible) + +Cherry-pick BlockSuite UI components (toolbars, popovers, panels) and wire them to xNet's data layer. + +```mermaid +graph TB + subgraph UI["UI Layer (BlockSuite)"] + TB[Format Toolbar] + PP[Property Panels] + CP[Color Picker] + IC[Icon Picker] + WG[Widget Components] + end + + subgraph Adapter["Adapter Layer"] + CM[Command Mapper] + EM[Event Mapper] + SM[State Mapper] + end + + subgraph xNet["xNet Core (Unchanged)"] + ReactHooks["@xnetjs/react hooks"] + Store[NodeStore] + Sync[Change sync] + end + + TB --> CM + PP --> CM + CP --> EM + IC --> EM + WG --> SM + + CM --> ReactHooks + EM --> ReactHooks + SM --> ReactHooks + + ReactHooks --> Store + Store --> Sync + + style Adapter fill:#ffeb3b + style xNet fill:#4caf50 +``` + +**Pros:** + +- ✅ Preserve all xNet architecture +- ✅ Get BlockSuite's polished UI +- ✅ React APIs unchanged +- ✅ Authorization model intact +- ✅ Event sourcing preserved +- ✅ Incremental adoption + +**Cons:** + +- ⚠️ Manual adapter development +- ⚠️ Need to understand BlockSuite internals +- ⚠️ Potential version lock-in +- ⚠️ Some components may be tightly coupled to BlockSuite data model + +**Verdict:** **Recommended approach** - Best balance of UX improvement and architectural integrity. + +--- + +### Strategy C: Reference Implementation (✅ Highly Recommended) + +Study AFFiNE/BlockSuite deeply and rebuild UI patterns natively in xNet with Tailwind/Base UI. + +```mermaid +graph TB + subgraph Research["Research Phase"] + R1[Study AFFiNE UX patterns] + R2[Document interactions] + R3[Extract design tokens] + R4[Map to xNet concepts] + end + + subgraph Build["Build Phase"] + B1[Build native components] + B2[Use Tailwind + Base UI] + B3[Wire to xNet hooks] + B4[Maintain architecture] + end + + subgraph Result["Result"] + RES1[AFFiNE-inspired UX] + RES2[xNet-native implementation] + RES3[No external dependencies] + RES4[Full control] + end + + R1 --> R2 + R2 --> R3 + R3 --> R4 + R4 --> B1 + B1 --> B2 + B2 --> B3 + B3 --> B4 + B4 --> RES1 + RES1 --> RES2 + RES2 --> RES3 + RES3 --> RES4 + + style Build fill:#4caf50 + style Result fill:#2196f3 +``` + +**Pros:** + +- ✅ Zero architectural compromise +- ✅ Full control over implementation +- ✅ No external dependency risk +- ✅ Learn from best practices +- ✅ Tailored to xNet's needs +- ✅ React APIs stay clean + +**Cons:** + +- ⚠️ Slower initial development +- ⚠️ Must maintain custom code +- ⚠️ Risk of missing subtle UX details + +**Verdict:** **Best long-term strategy** - More work upfront but cleaner result. + +--- + +## React API Preservation Analysis + +xNet's React APIs are foundational for the developer experience: + +```typescript +// Current xNet React APIs +const { node, loading } = useNode(nodeId) +const { data: nodes } = useQuery({ schemaId: 'xnet://xnet.fyi/Task' }) +const mutate = useMutate() +const { canEdit } = useCanEdit(nodeId) +const { grants } = useGrants(nodeId) +``` + +### If Using Full BlockSuite + +```typescript +// Would need heavy adapters +const editor = useEditor() // BlockSuite editor +const yDoc = editor.doc // Yjs doc + +// Convert Yjs → xNet changes (expensive!) +yDoc.on('update', (update) => { + // For EVERY keystroke: + // 1. Parse Yjs update + // 2. Extract changed blocks + // 3. Map blocks → nodes + // 4. Create signed changes + // 5. Write to SQLite + // 6. Recompute indexes + // This is a performance nightmare +}) + +// Convert xNet → Yjs (complex!) +useEffect(() => { + const unsub = store.onChange((event) => { + // For EVERY remote change: + // 1. Read NodeState + // 2. Map nodes → blocks + // 3. Apply to Y.Doc + // 4. Hope nothing breaks + }) + return unsub +}, []) +``` + +**Complexity:** 🔴🔴🔴 **Very High** - Requires bidirectional sync between incompatible systems. + +### If Using Strategy B/C (Component-only or Reference) + +```typescript +// React APIs stay the same! +const { node, loading } = useNode(nodeId) +const mutate = useMutate() + +// UI components just provide visual layer + mutate.update(nodeId, { bold: true })} + onItalic={() => mutate.update(nodeId, { italic: true })} +/> +``` + +**Complexity:** 🟢 **Low** - Components are thin UI layer over existing hooks. + +--- + +## Detailed Component Reuse Assessment + +### High-Value Components to Extract + +```mermaid +graph TB + subgraph BlockSuite["BlockSuite Components"] + direction TB + + subgraph Editors["Editors (❌ Too Coupled)"] + E1[PageEditor] + E2[EdgelessEditor] + end + + subgraph Widgets["Widgets (✅ Reusable)"] + W1[FormatToolbar] + W2[SlashMenu] + W3[LinkPopover] + W4[ColorPicker] + W5[EmojiPicker] + end + + subgraph Canvas["Canvas Components (⚠️ Partial)"] + C1[Shape Tools] + C2[Connector Tools] + C3[Selection UI] + C4[Minimap] + end + + subgraph Database["Database Components (⚠️ Partial)"] + D1[Property Editor] + D2[Filter UI] + D3[Group Header] + D4[Cell Renderers] + end + end + + style Editors fill:#f44336 + style Widgets fill:#4caf50 + style Canvas fill:#ffeb3b + style Database fill:#ffeb3b +``` + +### Extraction Checklist + +#### High Priority (Quick Wins) + +- [ ] **Format Toolbar** + - [ ] Extract toolbar component structure + - [ ] Map format commands to xNet TipTap commands + - [ ] Add to `@xnetjs/ui` package + - [ ] Wire up keyboard shortcuts + +- [ ] **Slash Menu** + - [ ] Extract command palette UI + - [ ] Map to xNet schema types (blocks → node schemas) + - [ ] Integrate fuzzy search + - [ ] Add extension mechanism + +- [ ] **Color Picker** + - [ ] Extract component (likely standalone) + - [ ] Style with Tailwind to match xNet theme + - [ ] Add to shared UI library + +- [ ] **Link Popover** + - [ ] Extract popover component + - [ ] Wire to xNet relation properties + - [ ] Add search/autocomplete for nodes + +#### Medium Priority (Valuable but Complex) + +- [ ] **Canvas Shape Tools** + - [ ] Study shape rendering approach + - [ ] Evaluate: SVG vs Canvas rendering + - [ ] Implement shape primitives in xNet canvas + - [ ] Add shape node schema types + +- [ ] **Database Property Editor** + - [ ] Extract property type UI components + - [ ] Map to xNet's 15 property types + - [ ] Add validation UI + - [ ] Wire to schema definition UI + +- [ ] **Selection/Multi-select UI** + - [ ] Study selection state management + - [ ] Extract visual selection feedback + - [ ] Adapt to xNet node selection + +#### Low Priority (Nice to Have) + +- [ ] **Minimap** (canvas navigation) +- [ ] **Breadcrumb** (navigation UI) +- [ ] **AI Integration** (requires AFFiNE AI service) +- [ ] **Template Gallery** (requires template system) + +--- + +## UX Patterns to Copy + +Beyond components, these interaction patterns are worth replicating: + +### 1. Block Drag-and-Drop + +AFFiNE has excellent drag handles and reordering feedback. Study: + +- Drag handle positioning +- Insertion line animation +- Multi-block selection +- Keyboard shortcuts for moving blocks + +### 2. Inline Embeds + +BlockSuite's approach to embedding content (pages, images, code) is elegant: + +- Smooth expand/collapse animations +- Inline editing of embedded content +- Caption handling +- Responsive sizing + +### 3. Canvas Connector Drawing + +EdgelessEditor has beautiful connector drawing: + +- Magnetic anchor points +- Auto-routing around shapes +- Connection point highlighting +- Path editing + +### 4. Database Filters + +AFFiNE's filter UI is intuitive: + +- Natural language-style filter builder +- Live preview of filtered results +- Saved filter templates +- Combination logic (AND/OR) + +### 5. Keyboard Shortcuts + +Study AFFiNE's keyboard shortcut system: + +- Consistent modifier key usage +- Shortcut discoverability (tooltip hints) +- Customization interface +- Conflict detection + +--- + +## Implementation Roadmap + +### Phase 1: Research & Planning (2 weeks) + +```mermaid +gantt + title Phase 1: Research & Planning + dateFormat YYYY-MM-DD + section Research + Study AFFiNE UX patterns :a1, 2026-03-06, 3d + Document component interactions :a2, after a1, 2d + Extract design tokens :a3, after a2, 2d + section Planning + Map components to xNet :b1, after a3, 3d + Prioritize extraction targets :b2, after b1, 2d + Define adapter interfaces :b3, after b2, 2d +``` + +**Deliverables:** + +- [ ] UX pattern documentation +- [ ] Component extraction priority list +- [ ] Design token library (colors, spacing, typography) +- [ ] Technical feasibility report + +### Phase 2: Foundation (3 weeks) + +```mermaid +gantt + title Phase 2: Foundation Components + dateFormat YYYY-MM-DD + section UI Library + Set up @xnetjs/ui-components :a1, 2026-03-20, 3d + Extract core primitives :a2, after a1, 4d + Build format toolbar :a3, after a2, 5d + section Integration + Wire toolbar to TipTap :b1, after a3, 3d + Add keyboard shortcuts :b2, after b1, 2d + Test in Electron app :b3, after b2, 2d +``` + +**Deliverables:** + +- [ ] `@xnetjs/ui-components` package scaffolded +- [ ] Format toolbar component extracted and working +- [ ] Integration tests passing +- [ ] Electron app uses new toolbar + +### Phase 3: Rich Features (4 weeks) + +```mermaid +gantt + title Phase 3: Rich Editor Features + dateFormat YYYY-MM-DD + section Components + Slash menu command palette :a1, 2026-04-10, 5d + Link popover :a2, after a1, 3d + Color picker :a3, after a2, 3d + Emoji picker :a4, after a3, 2d + section Integration + Wire to xNet schemas :b1, after a4, 4d + Add extension points :b2, after b1, 3d + Polish animations :b3, after b2, 2d +``` + +**Deliverables:** + +- [ ] Slash menu with schema-based commands +- [ ] Link editing with node search +- [ ] Color and emoji pickers integrated +- [ ] Smooth animations throughout + +### Phase 4: Canvas Enhancement (5 weeks) + +```mermaid +gantt + title Phase 4: Canvas Improvements + dateFormat YYYY-MM-DD + section Drawing + Shape primitives :a1, 2026-05-08, 5d + Connector drawing :a2, after a1, 5d + Shape tool palette :a3, after a2, 4d + section Polish + Selection feedback :b1, after a3, 3d + Minimap navigation :b2, after b1, 3d + Performance optimization :b3, after b2, 4d +``` + +**Deliverables:** + +- [ ] Shape drawing tools operational +- [ ] Connector/edge drawing with auto-routing +- [ ] Canvas minimap +- [ ] 60fps rendering maintained + +### Phase 5: Database Views (4 weeks) + +```mermaid +gantt + title Phase 5: Database View Polish + dateFormat YYYY-MM-DD + section Components + Property editor panels :a1, 2026-06-12, 5d + Filter builder UI :a2, after a1, 4d + Group headers :a3, after a2, 3d + section Features + Saved views :b1, after a3, 4d + View templates :b2, after b1, 3d + Export functionality :b3, after b2, 3d +``` + +**Deliverables:** + +- [ ] Rich property editors for all 15 types +- [ ] Intuitive filter builder +- [ ] Saved view system +- [ ] Export to CSV/JSON + +--- + +## Risk Assessment + +### Technical Risks + +| Risk | Severity | Mitigation | +| ---------------------------------------------------------------------- | --------- | ------------------------------------------------------------------ | +| **Tight coupling** - Components too coupled to BlockSuite internals | 🔴 High | Start with most standalone components (color picker, emoji picker) | +| **Version drift** - BlockSuite updates break extracted code | 🟡 Medium | Fork and vendor components; don't track upstream | +| **Performance** - Heavy adapters cause lag | 🟡 Medium | Use Strategy C (rebuild) instead of Strategy A (full integration) | +| **Missing APIs** - xNet lacks BlockSuite equivalents | 🟡 Medium | Extend xNet APIs as needed (e.g., block nesting) | +| **Design inconsistency** - Extracted components don't match xNet theme | 🟢 Low | Thoroughly restyle with Tailwind; treat as reference only | + +### Product Risks + +| Risk | Severity | Mitigation | +| ---------------------------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **User confusion** - Partial AFFiNE UX creates inconsistent experience | 🟡 Medium | Ensure consistent interactions; don't half-copy | +| **Feature gap** - Users expect full AFFiNE features | 🟡 Medium | Set clear expectations; focus on core workflows | +| **Legal issues** - License or trademark concerns | 🟢 Low | BlockSuite is MPL 2.0 (permissive); attribute properly | +| **Maintenance burden** - Forked code becomes stale | 🟡 Medium | Budget time for ongoing polish; don't rely on BlockSuite updates | + +--- + +## Recommendations + +### Immediate Actions (This Week) + +1. **Run AFFiNE locally** - Clone and explore the full experience +2. **Create UX audit document** - Screenshot and annotate key interactions +3. **Extract design tokens** - Colors, spacing, typography, shadows +4. **Identify 5 quick wins** - Standalone components to extract first + +### Short Term (Next Month) + +5. **Implement Strategy B for 3 components:** + - Format toolbar (editor) + - Color picker (shared) + - Link popover (editor) +6. **Gather user feedback** - Does the UX improvement justify the effort? +7. **Refine extraction process** - Document patterns for future components + +### Long Term (Next Quarter) + +8. **Expand to canvas** - Shape tools, connector drawing +9. **Polish database views** - Property editors, filter UI +10. **Consider AI integration** - Study AFFiNE AI patterns; plan xNet AI + +### Anti-Recommendations (DO NOT DO) + +- ❌ **Don't attempt full BlockSuite integration** - Architectural mismatch is too severe +- ❌ **Don't fork AFFiNE** - Massive codebase with tight coupling +- ❌ **Don't abandon xNet's data model** - Event sourcing and auth are differentiators +- ❌ **Don't track BlockSuite updates** - Vendor extracted components and own the code + +--- + +## Validation Checklist + +### Before Committing to Integration + +- [ ] Run AFFiNE locally and use it for real work for 1 week +- [ ] Document 10 specific UX improvements to replicate +- [ ] Verify BlockSuite components can be extracted without core runtime +- [ ] Prototype one extracted component (e.g., color picker) in xNet +- [ ] Measure performance impact of any adapter layer +- [ ] Confirm license compatibility (MPL 2.0 → MIT is compatible) +- [ ] Get user feedback on whether AFFiNE UX is worth the effort + +### During Implementation + +- [ ] Each extracted component has <100ms latency +- [ ] No BlockSuite core dependencies sneak in (only UI packages) +- [ ] xNet React APIs remain unchanged +- [ ] Event sourcing and audit trail still work +- [ ] Authorization checks still function +- [ ] Tests cover all adapted interactions +- [ ] Playwright tests verify UX matches intent +- [ ] Accessibility is maintained (keyboard nav, ARIA labels) + +### Post-Implementation + +- [ ] User testing shows UX improvement +- [ ] Performance benchmarks show no regression +- [ ] Code is maintainable by xNet team +- [ ] Documentation covers component usage +- [ ] Design system is coherent (no jarring inconsistencies) + +--- + +## Technical Deep Dive: Adapter Layer + +### Example: Format Toolbar Extraction + +```typescript +// packages/ui-components/src/format-toolbar.tsx + +import { useEditor } from '@xnetjs/editor' +import { useMutate } from '@xnetjs/react' + +/** + * Format toolbar adapted from BlockSuite's toolbar component + * Visuals and interactions copied; data layer is pure xNet + */ +export function FormatToolbar({ nodeId }: { nodeId: string }) { + const editor = useEditor() // xNet TipTap editor + const mutate = useMutate() + + // BlockSuite-inspired UI but xNet commands + return ( +
+
+ ) +} +``` + +### Example: Canvas Shape Tool + +```typescript +// packages/canvas/src/tools/shape-tool.tsx + +import { useCanvasStore } from '@xnetjs/canvas' +import { createNodeId } from '@xnetjs/data' + +/** + * Shape drawing tool inspired by EdgelessEditor + * Interaction pattern from BlockSuite; storage is xNet nodes + */ +export function ShapeTool({ type }: { type: 'rect' | 'circle' | 'triangle' }) { + const canvas = useCanvasStore() + + const handleDraw = (position: { x: number, y: number, width: number, height: number }) => { + // Create xNet node for shape (not BlockSuite block) + const nodeId = createNodeId() + canvas.addNode({ + id: nodeId, + type: 'shape', + position, + properties: { + shapeType: type, + fill: '#ffffff', + stroke: '#000000', + strokeWidth: 2 + } + }) + } + + // BlockSuite-inspired drawing interaction + return +} +``` + +--- + +## Alternative: Build Custom UI from Scratch + +If extraction proves too complex, **building custom UI** may be faster: + +### Pros of Custom Build + +- ✅ Zero external dependencies +- ✅ Perfect fit for xNet architecture +- ✅ Full control over every detail +- ✅ No license/legal concerns +- ✅ No version drift risk + +### Cons of Custom Build + +- ⚠️ Slower to reach AFFiNE's polish level +- ⚠️ May miss subtle UX insights +- ⚠️ Requires strong design skills +- ⚠️ Need to "discover" solutions AFFiNE already has + +### When to Choose Custom Build + +Choose custom build if: + +- You have a strong design/UX resource +- You value long-term maintainability over speed +- You want to differentiate from AFFiNE visually +- Component extraction proves too coupled to BlockSuite + +--- + +## Conclusion + +**AFFiNE's UX is world-class, but full integration would compromise xNet's architectural integrity.** The right approach is **selective UX borrowing**: + +1. **Study deeply** - Run AFFiNE, document interactions, extract design patterns +2. **Start small** - Extract 3-5 high-value standalone components +3. **Rebuild strategically** - For complex features, use AFFiNE as reference, not source +4. **Preserve xNet core** - Never compromise event sourcing, schema system, or authorization +5. **Iterate** - Test with users; refine based on feedback + +This approach gets the best of both worlds: **AFFiNE's polish with xNet's power**. + +--- + +## Appendix: BlockSuite Package Breakdown + +### Core Framework (❌ Not Usable) + +- `@blocksuite/store` - Document store (incompatible with xNet) +- `@blocksuite/inline` - Rich text inline editing (tied to BlockSuite) +- `@blocksuite/block-std` - Block framework (fundamentally different from nodes) + +### Editor Presets (❌ Not Usable) + +- `@blocksuite/presets` - PageEditor, EdgelessEditor (too coupled) + +### Block Implementations (⚠️ Reference Only) + +- `@blocksuite/blocks` - 20+ block types (study for inspiration) + +### Potentially Extractable UI + +Look for standalone components in: + +- Toolbar implementations +- Widget components +- Color pickers +- Icon pickers +- Property editors + +**Extraction strategy:** Copy visual design and interaction patterns, not code. + +--- + +## Appendix: xNet Architecture Strengths to Preserve + +| Feature | Why It Matters | Impact of Losing It | +| ------------------- | ------------------------------------------- | ----------------------------- | +| **Event sourcing** | Audit trail, time-travel, debugging | Can't trace how data changed | +| **Lamport clocks** | Deterministic ordering, conflict resolution | Sync becomes unreliable | +| **Signed changes** | Security, non-repudiation, trust | Can't verify who changed what | +| **Schema system** | Type safety, validation, migrations | Data corruption risks | +| **Node-level auth** | Fine-grained permissions, UCAN delegation | Can't share securely | +| **SQLite storage** | Performance, reliability, queries | Lose fast queries and indexes | +| **React hooks API** | Developer experience, composability | Harder to build features | + +**None of these should be sacrificed for UX polish.** UX can be improved without changing the foundation. + +--- + +## References + +- [AFFiNE GitHub](https://github.com/toeverything/AFFiNE) +- [BlockSuite GitHub](https://github.com/toeverything/blocksuite) +- [BlockSuite Documentation](https://blocksuite.io) +- [Yjs Documentation](https://docs.yjs.dev) +- xNet Exploration 0093: Node-Native Global Schema Federation Model +- xNet Exploration 0087: Telemetry Instrumentation Strategy + +--- + +**Next Steps:** Review with team, prioritize extraction targets, begin Phase 1 research. From 217c5d7092956bab1dc0d3d496fe4201bb9da6b7 Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 16:23:30 -0800 Subject: [PATCH 03/12] docs(exploration): add universal tasks architecture research - explore page-native tasks backed by first-class Task nodes - compare Notion, Linear, AFFiNE, and Obsidian task models - recommend smart references for GitHub, Figma, YouTube, and similar links - outline phased implementation, validation, and example schema direction --- ...S_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md | 936 ++++++++++++++++++ 1 file changed, 936 insertions(+) create mode 100644 docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md diff --git a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md new file mode 100644 index 000000000..e1a63372a --- /dev/null +++ b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md @@ -0,0 +1,936 @@ +# Tasks As A Universal Primitive Across Pages, Databases, And Canvas + +> Problem statement: xNet already has `Page`, `Task`, `Database`, `DatabaseRow`, `Canvas`, and `Comment` primitives, but checklist items inside rich text pages are still plain TipTap task blocks. The goal is to make page-native tasks feel excellent while ensuring each task is a first-class node that can later participate in assignment, due dates, notifications, databases, canvas, comments, and cross-workspace queries. + +## ✨ Executive Summary + +- xNet should **not** treat page checklist items as page-only text or as database rows. +- The strongest path is a **node-backed task projection** model: + - `Task` nodes remain the canonical cross-surface object. + - pages, databases, and canvas each render a surface-specific projection of that same task. + - the page editor is simply the first place where task nodes are created and edited inline. +- This mirrors the repo's strongest existing pattern: **comments are universal nodes with surface-specific anchors**. +- The current codebase already contains most of the ingredients: + - a built-in `TaskSchema`, + - universal comments with typed anchors, + - first-class `DatabaseRow` nodes, + - Yjs-backed pages and canvas, + - person/date/select property types, + - query hooks and database views. +- The key missing pieces are: + - a richer `TaskSchema`, + - a TipTap task extension that stores `taskId`, + - mention support in rich text, + - a reconciliation layer between Yjs task blocks and `Task` nodes, + - structured external references inside tasks, + - query-backed task views outside databases. +- Recommendation: + - Phase 1: implement node-backed tasks inside pages. + - Phase 2: add smart references plus saved task views and database-style projections over `Task` nodes. + - Phase 3: add task cards on canvas, notifications, recurring tasks, and workflow automations. + +## 🧭 Current State In The Repository + +### What exists today + +- `packages/data/src/schema/schemas/task.ts` + - Built-in `TaskSchema` already exists with `title`, `completed`, `status`, `priority`, `dueDate`, `assignee`, and `parent`. + - It already declares `document: 'yjs'`, which is a strong foundation for richer task bodies later. +- `packages/editor/src/components/RichTextEditor.tsx` + - The editor currently mounts plain TipTap `TaskList` and `TaskItem` extensions. + - Task nesting is enabled via `TaskItem.configure({ nested: true })`. + - There is no task/node bridge, no `taskId`, and no mention extension. +- `packages/editor/src/components/FloatingToolbar.tsx` + - There is already an explicit mention placeholder: `TODO: Open mention picker`. +- `packages/editor/src/extensions/embed/EmbedExtension.ts` + - xNet already has a block embed extension with auto-embed-on-paste behavior. +- `packages/editor/src/extensions/embed/providers.ts` + - Existing embed providers already cover YouTube, Vimeo, Spotify, Twitter/X, Figma, CodeSandbox, and Loom. +- `packages/editor/src/extensions/live-preview/link-preview.ts` + - The editor already has a link-aware preview plugin, which is a good substrate for inline smart-reference UX. +- `apps/electron/src/renderer/components/PageView.tsx` + - Pages already combine `useNode(PageSchema, ...)`, Yjs collaboration, comments, and presence. + - This is the right first integration point for node-backed tasks. +- `packages/data/src/schema/schemas/comment.ts` + - Comments already use a universal-node model with a schema-agnostic `target` relation and typed anchors. + - This is the cleanest conceptual precedent for universal tasks. +- `packages/data/src/schema/schemas/database-row.ts` + - Database rows are already first-class nodes with per-property merge semantics. + - This proves xNet can support "inline UI backed by real nodes". +- `packages/canvas/src/nodes/checklist-node.tsx` + - Canvas has a checklist node today, but it stores raw `items[]` in canvas-local data, not `Task` nodes. +- `apps/electron/src/renderer/components/DatabaseView.tsx` + - Databases already know how to surface `person` and `date` fields, and build live suggestions from active collaborators. +- `packages/views` + - Board, timeline, calendar, and list-style task views already exist at the rendering layer. + +### Current mismatch + +| Area | Current behavior | Limitation | +| --- | --- | --- | +| Page checklist | TipTap-only task items | no node identity, no queryability, no notifications, no cross-surface reuse | +| Task schema | first-class node exists | single assignee, no page anchor, no ordering key, no origin metadata | +| Mentions | comment parsing and DB person suggestions exist | no rich-text mention entity in page editor | +| External links | block embeds and link previews exist | no inline smart-reference chip model for tasks | +| Database views | powerful projections exist | only database-backed today, not query-backed task collections | +| Canvas checklist | local checklist data | cannot share identity with page/database tasks | + +### Important architectural precedent + +Comments already show the pattern xNet should reuse: + +```mermaid +flowchart LR + CommentNode["Comment node"] + Anchor["Anchor type + anchor data"] + Page["Page text"] + Database["Database cell / row / column"] + Canvas["Canvas object / position"] + + CommentNode --> Anchor + Anchor --> Page + Anchor --> Database + Anchor --> Canvas +``` + +Tasks should follow the same philosophy: + +- one canonical task node, +- multiple placements and projections, +- surface-specific anchors or bindings, +- shared query and notification semantics. + +## 🌍 External Research + +## Notion + +### Observed facts + +- Notion task databases require a `Status`, `Assignee`, and `Due date` property, and can be converted from regular databases. Source: [Task databases & sprints](https://www.notion.com/help/sprints) +- Notion's `My tasks` aggregates assigned tasks across multiple task databases into one place and supports filtering, sorting, and layout customization. Source: [Home & My tasks](https://www.notion.com/en-gb/help/home-and-my-tasks) +- Notion sub-items are visible in all database views and can be shown as nested toggles or flattened lists depending on the view. Source: [Sub-items & dependencies](https://www.notion.com/help/tasks-and-dependencies) +- Notion dependencies can shift downstream task dates automatically. Source: [Sub-items & dependencies](https://www.notion.com/help/tasks-and-dependencies) +- Notion `@` mentions notify people, and people added to a `Person` property also show up in inbox/notifications. Sources: [Reminders & @mentions](https://www.notion.com/help/guides/reminders-and-mentions), [Inbox & notifications](https://www.notion.com/help/updates-and-notifications) +- Notion supports `@remind` in page content and reminders on database date properties. Source: [Reminders](https://www.notion.com/help/reminders) +- Notion supports pasting supported links as either a full preview or a compact mention, including GitHub, Linear, Figma, and other work tools. Source: [Link previews](https://www.notion.com/help/link-previews) + +### What Notion gets right + +- It treats tasks as **database records first**, not as formatting. +- It has a **global assigned-work surface** (`My tasks`) that cuts across local document placement. +- It lets subtask structure survive across multiple views. +- It attaches notifications to both **assignment** and **due-date/reminder** workflows. +- It distinguishes between **compact inline mention** and **larger preview**, which is exactly the right model for task references. + +### Weakness relevant to xNet + +- Notion's task magic is heavily coupled to database semantics. Inline page tasks feel good, but the strongest behaviors come from task databases rather than from a universal object model. + +## Linear + +### Observed facts + +- Linear's canonical object is the issue; title and status are required, other properties and relations are optional. Source: [Create issues](https://linear.app/docs/creating-issues) +- Due dates surface directly on list and board views with strong visual urgency states and near/overdue notifications. Source: [Issue properties: Due dates](https://linear.app/docs/issue-properties) +- Referencing issues in a description or comment automatically creates a related-issue relationship. Source: [Issue relations](https://linear.app/docs/issue-relations/) +- Linear supports parent and sub-issues, status automation between them, and conversion from selected checklist/bulleted text into sub-issues. Source: [Parent and sub-issues](https://linear.app/docs/parent-and-sub-issues) +- Linear filters include assignee, due date, parent, sub-issue, blocking, blocked, duplicate, and content. Source: [Filters](https://linear.app/docs/filters) +- Linear also supports recurring issues and issue templates that can carry sub-issues. Source: [Create issues](https://linear.app/docs/creating-issues) +- Linear's GitHub integration links PRs and commits bidirectionally with issues and can drive status automation from Git state. Source: [GitHub integration](https://linear.app/integrations/github) +- Linear also has an attachment model for external resources, keyed by unique URL and rendered similarly to GitHub PRs. Source: [Attachments API](https://linear.app/docs/api/attachments) +- Linear embeds Figma designs directly into issue descriptions, comments, and documents. Source: [Figma integration](https://linear.app/docs/figma) + +### What Linear gets right + +- It treats tasks as **operational entities**, not documents. +- Cross-reference in text creates **real graph relationships**, not just links. +- It turns inline selection into structured issues, which is directly relevant to "turn checklist into task nodes". +- It prioritizes **fast filters, keyboard flows, and urgency signals** over visual ornament. +- It treats external artifacts like PRs, commits, and designs as **structured attachments**, not raw pasted URLs. + +### Weakness relevant to xNet + +- Linear is not multimodal. It is excellent at execution workflows, but not at blending page, database, and canvas representations of the same object. + +## AFFiNE / BlockSuite + +### Observed facts + +- BlockSuite is built on Yjs and explicitly supports both `PageEditor` and `EdgelessEditor`, with shared real-time collaboration and state scheduling across multiple documents. Source: [BlockSuite README](https://github.com/toeverything/blocksuite) +- AFFiNE positions itself as a merged docs + whiteboards + databases workspace. Source: [AFFiNE homepage](https://affine.pro/) +- AFFiNE has been improving linked-doc/database integration, including syncing database properties into a linked document's doc info and surfacing backlinks there. Source: [What's new in AFFiNE - November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) +- AFFiNE added database member properties and continued expanding database property types. Source: [What's New: April Update](https://affine.pro/blog/whats-new-april-update) +- AFFiNE improved linked docs in both page and edgeless modes, reinforcing one object rendered in multiple surfaces. Source: [What's new in AFFiNE - December 2024 Update](https://affine.pro/blog/whats-new-affine-dec-update) + +### Inference + +AFFiNE appears to lean toward a **doc-centric block graph** where databases and edgeless surfaces are alternate ways to organize shared block/doc entities, rather than maintaining a separate "task-only" system. This is partly inferred from public release notes and BlockSuite architecture, not from a formal AFFiNE data model document. + +### What AFFiNE gets right + +- Shared page/edgeless foundations reduce impedance between text and canvas. +- Linked docs plus synced properties are strong evidence for **surface projection over shared identity**. +- BlockSuite's multi-editor/Yjs foundation is unusually aligned with xNet's current architecture. + +### Weakness relevant to xNet + +- AFFiNE's public story is stronger around docs/whiteboards/databases than around a polished end-to-end task operating model on the level of Linear. + +## Obsidian + +### Observed facts + +- Obsidian core tasks are Markdown checkboxes plus search operators like `task:`, `task-todo:`, and `task-done:`. Source: [Search](https://help.obsidian.md/plugins/search) +- Obsidian Bases gives database-like views over files and properties, but the source of truth stays in Markdown files and properties. Source: [Introduction to Bases](https://help.obsidian.md/bases) +- Obsidian Canvas stores data in open `.canvas` files using the JSON Canvas format. Source: [Canvas](https://help.obsidian.md/plugins/canvas) +- Dataview indexes tasks, list items, page properties, and inline metadata. It exposes task-level fields like `children`, `parent`, `blockId`, `due`, `completion`, and `path`. Sources: [Dataview Overview](https://blacksmithgu.github.io/obsidian-dataview/), [Metadata on Tasks and Lists](https://blacksmithgu.github.io/obsidian-dataview/annotation/metadata-tasks/) +- The Tasks plugin adds richer task semantics like due dates, recurrence, and cross-vault task queries while still editing the source Markdown task line. Source: [Tasks plugin introduction](https://publish.obsidian.md/tasks/Introduction) +- Obsidian also supports direct web-page and media embeds, including in canvas cards, but they remain mostly raw embed syntax rather than normalized external-object references. Source: [Embed web pages](https://help.obsidian.md/embed-web-pages) + +### What Obsidian gets right + +- It keeps tasks **user-legible and file-native**. +- It makes tasks broadly queryable by indexing text and metadata instead of forcing a heavy app-level object model. +- It has a strong ecosystem pattern of **derive views from source text**. + +### Weakness relevant to xNet + +- The model is fragmented across core features and plugins. +- Task metadata and task behavior are not strongly normalized at the platform level. +- Cross-surface identity is weaker than in Notion, Linear, or AFFiNE. + +## 🔍 Key Findings + +## 1. The best products separate **identity** from **surface** + +- Linear: issue identity is primary; editor, relations, and views sit on top. +- Notion: task database row identity is primary; page bodies and views sit on top. +- AFFiNE: linked docs suggest shared identity rendered in multiple contexts. +- Obsidian: weaker identity, stronger textual openness. + +For xNet, this points to: + +- `Task` node identity should be canonical. +- page checklist rendering should be a projection, not the source of truth by itself. + +## 2. Inline editing matters, but inline storage is not enough + +Users want to: + +- type `[] `, +- indent with `Tab`, +- `@mention` assignees inline, +- set due dates without leaving the editor, +- drag or re-indent tasks fluidly. + +That does **not** imply the task should live only as a TipTap node. + +## 3. Global task views are mandatory if task metadata exists + +Once xNet supports: + +- assignees, +- due dates, +- overdue states, +- subtasks, +- comments, +- notifications, + +it must also support: + +- "my tasks", +- "tasks due soon", +- "tasks on this page", +- "tasks assigned to Alice", +- "blocked tasks", +- "tasks on this canvas", +- saved filtered views. + +Otherwise metadata becomes dead weight. + +## 4. Assignment-by-mention is useful but should not be overly magical + +The user's proposed behavior is directionally right, but raw mention parsing is risky: + +- people mention others for discussion, not always assignment, +- a task line can contain historical mentions, +- task descriptions may reference people who are not owners. + +Recommendation: + +- make inline mentions first-class, +- auto-suggest "convert mentioned people to assignees", +- support a default heuristic for page task title lines, +- but keep assignee chips as an explicit editable property. + +## 5. Databases should become **task projections**, not task containers + +xNet already has a database-specific row model. That should remain useful. + +But for tasks specifically: + +- a task created in a page should not need to become a `DatabaseRow` to participate in a database-like view, +- a database-like surface should be able to query `Task` nodes directly, +- otherwise the system creates duplication, sync burden, and identity confusion. + +## 6. Canvas should render tasks as cards, not invent a second checklist system + +The current canvas checklist node is a useful interaction prototype, but it should not remain a separate task substrate if xNet wants universal tasks. + +## 7. Tasks need typed external references, not just pasted URLs + +If a task can point to: + +- a GitHub issue or PR, +- a Figma frame, +- a YouTube video, +- a CodeSandbox, +- a Loom walkthrough, + +then xNet should not treat that as only a link string inside rich text. + +Instead, task references should support three display modes: + +1. plain link, +2. compact smart-reference chip, +3. expanded preview or full embed. + +This is the combined lesson from Notion and Linear: + +- Notion is strong at **mention vs preview**, +- Linear is strong at **attachment vs workflow state**. + +## ⚖️ Options And Tradeoffs + +## Option A: Keep page tasks as TipTap/Yjs only + +### Shape + +- Extend `taskItem` attrs with assignee/due date metadata. +- Persist everything only in page Yjs. +- Derive task lists by scanning page documents. + +### Pros + +- Smallest editor-only implementation. +- Minimal schema churn. +- Easy to preserve current page UX. + +### Cons + +- No first-class node identity. +- Hard to comment on, notify, permission, or link to tasks. +- Expensive global indexing across many Yjs docs. +- Poor fit with xNet's existing `TaskSchema`. +- Canvas/database integration becomes projection-from-text instead of projection-from-object. + +### Verdict + +Reject. This is an Obsidian-like path without Obsidian's file simplicity. + +## Option B: Node-backed tasks embedded in pages + +### Shape + +- Each page task item gets a durable `taskId`. +- Task metadata lives on a `Task` node. +- The page Yjs doc stores structure, inline content, ordering, and parent/child nesting presentation. +- A reconciler keeps block content and task node properties in sync. + +### Pros + +- Preserves inline authoring. +- Gives each task identity, relations, and queryability. +- Aligns with comment and database-row precedents. +- Cleanest path to database views, notifications, canvas cards, and structured external references. + +### Cons + +- Requires reconciliation logic. +- Introduces dual storage for some fields like title and completion. +- Needs careful undo/redo and conflict design. + +### Verdict + +Recommended. + +## Option C: Convert page tasks into database rows + +### Shape + +- Creating a checklist item implicitly creates a row in a hidden database. +- The page block becomes a view onto that row. + +### Pros + +- Reuses database view machinery more directly. +- Rows already have ordering and query semantics. + +### Cons + +- Rows are semantically children of a database, which is the wrong ontology for a page-native task. +- Hidden databases are confusing. +- Harder to represent tasks that should exist outside a database. +- Couples task UX to the database package too early. + +### Verdict + +Avoid as the canonical model. + +## ✅ Recommendation + +Adopt **Option B: node-backed tasks embedded in pages**, and define tasks as a universal primitive similar to comments. + +### Recommended model + +```mermaid +flowchart TD + subgraph PageSurface["Page surface"] + YDoc["Page Y.Doc"] + TaskBlock["Task block in TipTap
attrs: taskId, parentTaskId?"] + MetaUI["Inline chips
assignees, due date, status, refs"] + end + + subgraph NodeLayer["Node layer"] + TaskNode["Task node"] + PageNode["Page node"] + RefNode["External reference node(s)"] + end + + subgraph Views["Other projections"] + TaskList["My tasks / saved views"] + DatabaseProjection["Database-style task view"] + CanvasCard["Canvas task card"] + Notifications["Due date + assignment notifications"] + end + + YDoc --> TaskBlock + TaskBlock --> MetaUI + TaskBlock --> TaskNode + TaskBlock --> RefNode + TaskNode --> PageNode + TaskNode --> RefNode + TaskNode --> TaskList + TaskNode --> DatabaseProjection + TaskNode --> CanvasCard + TaskNode --> Notifications +``` + +### Recommended schema direction + +Evolve `TaskSchema` so it can represent both page-native and cross-surface tasks: + +- keep: + - `title` + - `completed` + - `status` + - `priority` + - `dueDate` + - `parent` +- change: + - `assignee` -> `assignees: person({ multiple: true })` +- add: + - `page: relation({ target: Page })` + - `source: select(page | database | canvas | automation | api)` + - `anchorBlockId: text()` for the current canonical block on a page + - `sortKey: text()` for sibling ordering + - `startDate` or `scheduledDate` + - `remindAt` + - `blockedBy: relation({ target: Task, multiple: true })` + - `blocks: relation({ target: Task, multiple: true })` + - `references: relation({ multiple: true })` if xNet introduces normalized external-reference nodes + - `archived` or `deletedFromSurface` flags if needed + +### Recommended page binding model + +- Treat the page's Yjs task block as the **authoring projection**. +- Treat the `Task` node as the **canonical query object**. +- Mirror these fields from the page block into the node: + - title text, + - checked/completed, + - page relation, + - parent relation, + - sibling order, + - assignees/due date if edited inline. +- Keep richer task body/details either: + - in task node properties for scalar metadata, and + - optionally in the task node's own Yjs document for expanded task notes. + +### Recommended mention semantics + +- Introduce a real TipTap mention entity that stores DIDs, not display-only strings. +- Resolve mentions against: + - current presence users, + - known collaborators, + - future people directory / identity index. +- For page tasks: + - mentions in the title line should offer "Assign mentioned people". + - explicit assignee chips should remain the ground truth. +- Do not make every mention in a long task description silently rewrite assignees. + +### Recommended external reference strategy + +- Keep task lines compact by default. +- When a supported URL is pasted into a task, convert it to an inline smart-reference chip. +- On hover or expand: + - show a richer card preview, + - or convert it into a full block embed when the user explicitly chooses that mode. + +Recommended modeling: + +- use a dedicated `ExternalReference` or `Attachment` node for normalized metadata, +- let the inline task chip store `referenceId`, +- let the task node hold relations to those reference nodes, +- let the existing block `EmbedExtension` remain the full-preview path. + +This is cleaner than shoving provider JSON into task attrs, because it enables: + +- provider-specific sync, +- reference reuse across tasks or pages, +- comments on external artifacts later, +- better indexing and deduplication by URL. + +### Display model for task references + +```mermaid +flowchart LR + URL["Pasted URL"] --> Detect["Provider detection"] + Detect --> Chip["Inline smart-reference chip"] + Chip --> Card["Popover or compact card"] + Card --> Embed["Optional full block embed"] + + Detect --> Ref["ExternalReference node"] + Ref --> Task["Task node relation"] + Ref --> Query["Future queries / automations"] +``` + +Suggested defaults: + +- GitHub issue / PR: inline chip with status, repo, number +- Figma file / frame: inline chip with file title and design icon +- YouTube / Loom: inline chip with title and duration if available +- Rich media that matters for execution context: expandable preview +- Everything else: stay as a normal link until a provider can normalize it + +### Recommended database strategy + +Do **not** require tasks to live in a database. + +Instead, add a new concept: + +- saved task views or query-backed collections over `Task` nodes. + +This can later converge with databases in one of two ways: + +1. Database views learn how to target arbitrary schemas plus filters, not only `DatabaseRow`. +2. A lighter `CollectionView`/`SavedView` node becomes the shared abstraction for "table/board/calendar over any schema". + +The second path is cleaner and avoids overloading the current `Database` meaning. + +### Recommended canvas strategy + +- Replace or supplement the current checklist node with task cards that bind to `Task` nodes. +- Let canvas store layout and visual grouping only. +- Let task data stay in the task node. + +This mirrors the page recommendation: + +- surface owns layout and interaction, +- task node owns identity and metadata. + +## 🧩 Detailed Design Notes + +## Task lifecycle + +```mermaid +stateDiagram-v2 + [*] --> InlineDraft + InlineDraft --> NodeCreated: checklist item inserted + NodeCreated --> Assigned: mention or assignee chip set + NodeCreated --> Scheduled: due date / reminder set + Assigned --> Scheduled + Scheduled --> InProgress: status changes + InProgress --> Done: completed=true + Done --> Reopened: unchecked or status change + Reopened --> InProgress + NodeCreated --> Archived: deleted from page but retained + Done --> Archived +``` + +## Reconciliation flow + +```mermaid +sequenceDiagram + participant U as User + participant E as Page Editor + participant R as Task Reconciler + participant S as NodeStore + participant Q as Task Views + + U->>E: Type [] Buy milk @alice tomorrow + E->>R: task block inserted / metadata edited + R->>S: create or update Task node + S-->>R: taskId + merged state + R-->>E: persist taskId on block attrs + S-->>Q: query subscriptions update + Q-->>U: My tasks / board / calendar refresh +``` + +## Data ownership rules + +| Field | Canonical owner | Why | +| --- | --- | --- | +| task identity | `Task` node | global referenceability | +| status / due date / assignees | `Task` node | query + notifications | +| external provider metadata | `ExternalReference` node | sync, dedupe, provider-aware rendering | +| page placement | page Yjs + task `page` relation | both surface and global query need it | +| parent/subtask relation | `Task` node | cross-view hierarchy | +| sibling visual order in page | `sortKey` on `Task` node | stable across projections | +| inline rich title text | page Yjs, mirrored to node title | preserves rich editing ergonomics | +| inline smart-reference chip placement | page Yjs task block | preserves authoring ergonomics | +| expanded notes | task Yjs doc later | avoids overloading page block | + +## Risks And Open Questions + +## 1. Dual-write complexity + +Risk: + +- title/completion may exist in both page Yjs and node state. + +Mitigation: + +- designate page block edits as the source for inline task content, +- designate node state as the source for scalar metadata outside the page, +- use transaction origins to avoid feedback loops. + +## 2. Undo/redo across Yjs and NodeStore + +Risk: + +- user hits undo and page text changes but task metadata does not, or vice versa. + +Mitigation: + +- batch page-task mutations into one editor command abstraction, +- consider a page-task transaction origin recognized by both Yjs and node mutations, +- validate against the database undo strategy already used in `DatabaseView`. + +## 3. Mention identity resolution + +Risk: + +- `@alice` is ambiguous across peers and workspaces. + +Mitigation: + +- store DIDs canonically, +- render display names as presentation only, +- allow unresolved mention placeholders until identity selection is confirmed. + +## 4. Deleting a task block + +Risk: + +- should deleting a checklist item hard-delete the task node, archive it, or detach it from the page? + +Recommendation: + +- near-term: soft archive or "removed from page" state, +- later: offer explicit delete vs keep-in-task-list behavior. + +## 5. Moving tasks between pages + +Risk: + +- drag-and-drop across pages changes parentage, anchors, backlinks, and sort order. + +Recommendation: + +- support intra-page moves first, +- treat cross-page move as "rebind canonical page + anchorBlockId". + +## 6. Infinite nesting vs operational clarity + +The user wants arbitrarily deep subtasks. That is reasonable for storage, but most tools become hard to use past 3-4 levels. + +Recommendation: + +- support arbitrary depth in the model, +- optimize UI for 3-4 levels, +- add collapse/expand and breadcrumbs before emphasizing deeper trees. + +## 7. Notifications and permissions + +Task notifications will eventually need: + +- per-assignee routing, +- due reminders, +- permission-aware visibility, +- mute/follow settings. + +This is feasible, but only once task identity is canonical and assignees are normalized. + +## 8. Provider integrations and auth + +Structured external references introduce a new class of complexity: + +- some providers are public and parseable from URL alone, +- some providers need OAuth or app tokens, +- some previews can be embedded as iframes, +- some should be metadata-only chips for privacy and performance. + +Recommendation: + +- start with URL-parsed public metadata and existing embed providers, +- add authenticated provider resolvers later for GitHub, Linear, Figma, and similar apps, +- design the model so the task still works even if metadata fetch fails. + +## 🛠️ Implementation Checklist + +## Phase 1: Page-native node-backed tasks + +- [ ] Design `TaskSchema` v2 and migration path from the current built-in schema. +- [ ] Add `assignees` multi-person support. +- [ ] Add `page`, `anchorBlockId`, `sortKey`, and task relation fields. +- [ ] Build a custom TipTap task extension that stores `taskId`. +- [ ] Add rich-text mention support that resolves to DIDs. +- [ ] Add inline due-date and assignee chips to task rows. +- [ ] Add inline smart-reference chips for supported URLs inside task lines. +- [ ] Reuse the existing embed provider registry for first-pass provider detection. +- [ ] Define normalized `ExternalReference` metadata shape and relation strategy. +- [ ] Create a page task reconciler that maps editor operations to node mutations. +- [ ] Persist parent/subtask relations from indentation. +- [ ] Decide delete semantics: archive vs detach vs hard delete. +- [ ] Add task-specific tests for page editing, indentation, reassignment, due-date edits, and URL-to-chip conversion. + +## Phase 2: Cross-surface views + +- [ ] Add `useTasks` / schema-query helpers for `TaskSchema`. +- [ ] Build "My tasks" and "Tasks on this page" views. +- [ ] Add saved filters for assignee, due date, status, and hierarchy state. +- [ ] Allow embedded task views inside pages. +- [ ] Prototype board/calendar/timeline projections over direct `Task` queries. +- [ ] Decide whether this is a new `SavedView` node or an extension of `Database`. + +## Phase 3: Canvas + automations + +- [ ] Replace canvas-local checklist data with task-backed cards. +- [ ] Add dependency visualization (`blockedBy`, `blocks`) on canvas. +- [ ] Add due-date reminders and assignment notifications. +- [ ] Add provider resolvers for GitHub issue/PR state, Figma file metadata, and media titles. +- [ ] Add recurring task support. +- [ ] Add automation hooks for assignee changes, overdue states, and reminder delivery. + +## 🧪 Validation Checklist + +- [ ] Creating a checklist item in a page creates exactly one `Task` node. +- [ ] Toggling a checkbox updates `Task.completed` and all subscribed task views. +- [ ] Indenting/outdenting updates parent/subtask relations deterministically. +- [ ] Reordering sibling tasks preserves stable order across peers. +- [ ] `@mention` insertion stores canonical DIDs, not only display strings. +- [ ] Multiple mentions can populate multiple assignees without duplicate entries. +- [ ] Editing due dates inline updates all task projections immediately. +- [ ] Pasting a GitHub, Figma, Loom, or YouTube URL into a task produces the expected inline chip or fallback link. +- [ ] Structured references degrade gracefully when provider metadata is unavailable. +- [ ] Full block embeds remain opt-in so task rows stay compact. +- [ ] Page comments, task comments, and database/canvas task views can all target the same task node. +- [ ] Undo/redo behaves coherently for page text plus task metadata. +- [ ] Removing a task from a page follows the chosen archive/detach semantics consistently. +- [ ] Query-backed task views remain performant with thousands of tasks. +- [ ] No orphaned task nodes are created during rapid collaborative edits. + +## 💡 Example Code + +### Proposed schema evolution + +```typescript +/** + * Illustrative only: likely Task v2 direction. + */ +import type { InferNode } from '../types' +import { defineSchema } from '../define' +import { checkbox, date, person, relation, select, text } from '../properties' + +export const TaskSchemaV2 = defineSchema({ + name: 'Task', + namespace: 'xnet://xnet.fyi/', + version: '2.0.0', + properties: { + title: text({ required: true, maxLength: 500 }), + completed: checkbox({ default: false }), + status: select({ + options: [ + { id: 'todo', name: 'To Do', color: 'gray' }, + { id: 'in-progress', name: 'In Progress', color: 'blue' }, + { id: 'done', name: 'Done', color: 'green' }, + { id: 'cancelled', name: 'Cancelled', color: 'red' } + ] as const, + default: 'todo' + }), + priority: select({ + options: [ + { id: 'low', name: 'Low', color: 'gray' }, + { id: 'medium', name: 'Medium', color: 'yellow' }, + { id: 'high', name: 'High', color: 'orange' }, + { id: 'urgent', name: 'Urgent', color: 'red' } + ] as const, + default: 'medium' + }), + assignees: person({ multiple: true }), + dueDate: date({}), + remindAt: date({}), + page: relation({ target: 'xnet://xnet.fyi/Page@1.0.0' as const }), + parent: relation({ target: 'xnet://xnet.fyi/Task@2.0.0' as const }), + blockedBy: relation({ target: 'xnet://xnet.fyi/Task@2.0.0' as const, multiple: true }), + blocks: relation({ target: 'xnet://xnet.fyi/Task@2.0.0' as const, multiple: true }), + anchorBlockId: text({}), + sortKey: text({}), + source: select({ + options: [ + { id: 'page', name: 'Page' }, + { id: 'database', name: 'Database' }, + { id: 'canvas', name: 'Canvas' }, + { id: 'automation', name: 'Automation' }, + { id: 'api', name: 'API' } + ] as const, + default: 'page' + }) + }, + document: 'yjs' +}) + +export type TaskV2 = InferNode<(typeof TaskSchemaV2)['_properties']> +``` + +### Proposed page-task reconciliation shape + +```typescript +type TaskBlockSnapshot = { + taskId?: string + parentTaskId?: string + text: string + completed: boolean + mentions: string[] // DIDs + dueDate?: number + sortKey: string +} + +export async function upsertPageTask( + pageId: string, + snapshot: TaskBlockSnapshot, + createTask: (input: Record) => Promise<{ id: string }>, + updateTask: (id: string, input: Record) => Promise +): Promise { + const properties = { + title: snapshot.text, + completed: snapshot.completed, + assignees: snapshot.mentions, + dueDate: snapshot.dueDate, + page: pageId, + parent: snapshot.parentTaskId, + sortKey: snapshot.sortKey, + source: 'page' + } + + if (!snapshot.taskId) { + const task = await createTask(properties) + return task.id + } + + await updateTask(snapshot.taskId, properties) + return snapshot.taskId +} +``` + +### Proposed structured reference shape + +```typescript +type ExternalReferenceKind = + | 'issue' + | 'pull-request' + | 'design' + | 'video' + | 'sandbox' + | 'document' + | 'link' + +type ExternalReference = { + id: string + url: string + provider: 'github' | 'figma' | 'youtube' | 'loom' | 'codesandbox' | 'generic' + kind: ExternalReferenceKind + externalId?: string + title?: string + subtitle?: string + icon?: string + state?: string + previewMode: 'chip' | 'card' | 'embed' + lastSyncedAt?: number + metadata?: Record +} + +type TaskInlineReference = { + referenceId: string + displayText: string +} +``` + +## 📌 Next Actions + +1. Finalize whether tasks should use a dedicated `TaskSchema` v2 or a new `PageTaskSchema` that later converges back into `Task`. +2. Prototype a custom TipTap task extension with `taskId`, inline assignee chips, and due-date pills. +3. Build the page-task reconciler before attempting databases or canvas integration. +4. Design the first query-backed task view (`My tasks`) immediately after Phase 1 so metadata has a payoff. +5. Decide whether query-backed cross-schema views should extend `Database` or become a new `SavedView` primitive. + +## 🔗 References + +### xNet codebase + +- `packages/data/src/schema/schemas/task.ts` +- `packages/data/src/schema/schemas/page.ts` +- `packages/data/src/schema/schemas/comment.ts` +- `packages/data/src/schema/schemas/database-row.ts` +- `packages/editor/src/components/RichTextEditor.tsx` +- `packages/editor/src/components/FloatingToolbar.tsx` +- `packages/editor/src/extensions/embed/EmbedExtension.ts` +- `packages/editor/src/extensions/embed/providers.ts` +- `packages/editor/src/extensions/live-preview/link-preview.ts` +- `apps/electron/src/renderer/components/PageView.tsx` +- `apps/electron/src/renderer/components/DatabaseView.tsx` +- `packages/canvas/src/nodes/checklist-node.tsx` +- `docs/explorations/0014_[x]_COMMENTING_SYSTEM.md` +- `docs/explorations/0041_[x]_DATABASE_DATA_MODEL.md` +- `docs/explorations/0088_[_]_DATABASE_UI_COMPETITIVE_ARCHITECTURE.md` + +### External sources + +- Notion: [Sub-items & dependencies](https://www.notion.com/help/tasks-and-dependencies) +- Notion: [Task databases & sprints](https://www.notion.com/help/sprints) +- Notion: [Home & My tasks](https://www.notion.com/en-gb/help/home-and-my-tasks) +- Notion: [Reminders](https://www.notion.com/help/reminders) +- Notion: [Reminders & @mentions](https://www.notion.com/help/guides/reminders-and-mentions) +- Notion: [Inbox & notifications](https://www.notion.com/help/updates-and-notifications) +- Notion: [Link previews](https://www.notion.com/help/link-previews) +- Linear: [Create issues](https://linear.app/docs/creating-issues) +- Linear: [Issue properties: Due dates](https://linear.app/docs/issue-properties) +- Linear: [Issue relations](https://linear.app/docs/issue-relations/) +- Linear: [Parent and sub-issues](https://linear.app/docs/parent-and-sub-issues) +- Linear: [Filters](https://linear.app/docs/filters) +- Linear: [GitHub integration](https://linear.app/integrations/github) +- Linear: [Attachments API](https://linear.app/docs/api/attachments) +- Linear: [Figma integration](https://linear.app/docs/figma) +- AFFiNE: [AFFiNE homepage](https://affine.pro/) +- AFFiNE: [What's new in AFFiNE - November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) +- AFFiNE: [What's new in AFFiNE - December 2024 Update](https://affine.pro/blog/whats-new-affine-dec-update) +- AFFiNE: [What's New: April Update](https://affine.pro/blog/whats-new-april-update) +- BlockSuite: [GitHub README](https://github.com/toeverything/blocksuite) +- Obsidian: [Introduction to Bases](https://help.obsidian.md/bases) +- Obsidian: [Canvas](https://help.obsidian.md/plugins/canvas) +- Obsidian: [Search](https://help.obsidian.md/plugins/search) +- Obsidian: [Properties view](https://help.obsidian.md/plugins/properties) +- Obsidian: [Embed web pages](https://help.obsidian.md/embed-web-pages) +- Dataview: [Overview](https://blacksmithgu.github.io/obsidian-dataview/) +- Dataview: [Metadata on Tasks and Lists](https://blacksmithgu.github.io/obsidian-dataview/annotation/metadata-tasks/) +- Tasks plugin: [Introduction](https://publish.obsidian.md/tasks/Introduction) From 9f529dff04b5dbb0ab742f43b37f74657a07f8c8 Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 17:17:13 -0800 Subject: [PATCH 04/12] feat(tasks): add structured task references groundwork - add inline smart task references in the editor for supported URLs - introduce ExternalReferenceSchema for normalized external artifact metadata - expand TaskSchema with page binding, assignees, source, sort key, and references - cover editor smart-reference behavior and new data schemas with focused tests --- packages/data/src/index.ts | 2 + packages/data/src/schema/index.ts | 1 + .../schema/schemas/external-reference.test.ts | 71 +++++++++ .../src/schema/schemas/external-reference.ts | 76 +++++++++ packages/data/src/schema/schemas/index.ts | 5 + packages/data/src/schema/schemas/task.test.ts | 76 +++++++++ packages/data/src/schema/schemas/task.ts | 34 +++- .../editor/src/components/RichTextEditor.tsx | 3 + packages/editor/src/extensions.ts | 7 + .../SmartReferenceExtension.test.ts | 114 ++++++++++++++ .../SmartReferenceExtension.ts | 148 ++++++++++++++++++ .../src/extensions/smart-reference/index.ts | 3 + .../smart-reference/providers.test.ts | 50 ++++++ .../extensions/smart-reference/providers.ts | 137 ++++++++++++++++ packages/editor/src/styles/editor.css | 30 ++++ 15 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 packages/data/src/schema/schemas/external-reference.test.ts create mode 100644 packages/data/src/schema/schemas/external-reference.ts create mode 100644 packages/data/src/schema/schemas/task.test.ts create mode 100644 packages/editor/src/extensions/smart-reference/SmartReferenceExtension.test.ts create mode 100644 packages/editor/src/extensions/smart-reference/SmartReferenceExtension.ts create mode 100644 packages/editor/src/extensions/smart-reference/index.ts create mode 100644 packages/editor/src/extensions/smart-reference/providers.test.ts create mode 100644 packages/editor/src/extensions/smart-reference/providers.ts diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index d4b6f4dff..92bc31505 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -83,6 +83,8 @@ export { type DatabaseRow, TaskSchema, type Task, + ExternalReferenceSchema, + type ExternalReference, CanvasSchema, type Canvas, CommentSchema, diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index 4bb1b6766..cc890e6ca 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -106,6 +106,7 @@ export { PageSchema, type Page } from './schemas' export { DatabaseSchema, type Database } from './schemas' export { DatabaseRowSchema, type DatabaseRow } from './schemas' export { TaskSchema, type Task } from './schemas' +export { ExternalReferenceSchema, type ExternalReference } from './schemas' export { CanvasSchema, type Canvas } from './schemas' export { CommentSchema, type Comment } from './schemas' export { GrantSchema, type Grant } from './schemas' diff --git a/packages/data/src/schema/schemas/external-reference.test.ts b/packages/data/src/schema/schemas/external-reference.test.ts new file mode 100644 index 000000000..0117c8182 --- /dev/null +++ b/packages/data/src/schema/schemas/external-reference.test.ts @@ -0,0 +1,71 @@ +import type { DID } from '../node' +import { describe, expect, it } from 'vitest' +import { ExternalReferenceSchema } from './external-reference' + +describe('ExternalReferenceSchema', () => { + const testDID = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK' as DID + + describe('schema definition', () => { + it('has correct schema IRI', () => { + expect(ExternalReferenceSchema.schema['@id']).toBe('xnet://xnet.fyi/ExternalReference@1.0.0') + expect(ExternalReferenceSchema.schema.name).toBe('ExternalReference') + expect(ExternalReferenceSchema.schema.version).toBe('1.0.0') + }) + + it('defines normalized reference properties', () => { + const propIds = ExternalReferenceSchema.schema.properties.map((p) => p['@id']) + + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#url') + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#provider') + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#kind') + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#refId') + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#title') + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#embedUrl') + expect(propIds).toContain('xnet://xnet.fyi/ExternalReference@1.0.0#metadata') + }) + }) + + describe('create', () => { + it('creates a GitHub issue reference', () => { + const reference = ExternalReferenceSchema.create( + { + url: 'https://github.com/openai/openai/issues/123', + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123', + title: 'openai#123', + subtitle: 'openai', + icon: 'GH', + metadata: JSON.stringify({ owner: 'openai', repo: 'openai', number: '123' }) + }, + { createdBy: testDID } + ) + + expect(reference.provider).toBe('github') + expect(reference.kind).toBe('issue') + expect(reference.refId).toBe('openai/openai#123') + expect(reference.title).toBe('openai#123') + }) + }) + + describe('validate', () => { + it('accepts a valid external reference', () => { + const reference = ExternalReferenceSchema.create( + { + url: 'https://www.figma.com/file/abc123def', + provider: 'figma', + kind: 'design', + refId: 'file/abc123def', + title: 'Figma file', + embedUrl: + 'https://www.figma.com/embed?embed_host=xnet&url=https://www.figma.com/file/abc123def' + }, + { createdBy: testDID } + ) + + const result = ExternalReferenceSchema.validate(reference) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + }) +}) diff --git a/packages/data/src/schema/schemas/external-reference.ts b/packages/data/src/schema/schemas/external-reference.ts new file mode 100644 index 000000000..9bedad461 --- /dev/null +++ b/packages/data/src/schema/schemas/external-reference.ts @@ -0,0 +1,76 @@ +/** + * ExternalReferenceSchema - Normalized external artifact metadata. + * + * External references let tasks and pages attach structured links to external + * systems like GitHub, Figma, or YouTube without collapsing everything into + * raw URL strings. Rich previews remain a surface concern; this schema stores + * the stable metadata needed for querying and reuse. + */ + +import type { InferNode } from '../types' +import { defineSchema } from '../define' +import { select, text, url } from '../properties' + +export const ExternalReferenceSchema = defineSchema({ + name: 'ExternalReference', + namespace: 'xnet://xnet.fyi/', + properties: { + /** Canonical external URL */ + url: url({ required: true }), + + /** Normalized provider identifier */ + provider: select({ + options: [ + { id: 'github', name: 'GitHub' }, + { id: 'figma', name: 'Figma' }, + { id: 'youtube', name: 'YouTube' }, + { id: 'loom', name: 'Loom' }, + { id: 'vimeo', name: 'Vimeo' }, + { id: 'codesandbox', name: 'CodeSandbox' }, + { id: 'spotify', name: 'Spotify' }, + { id: 'twitter', name: 'Twitter' }, + { id: 'generic', name: 'Generic Link' } + ] as const, + default: 'generic' + }), + + /** Normalized reference kind */ + kind: select({ + options: [ + { id: 'issue', name: 'Issue' }, + { id: 'pull-request', name: 'Pull Request' }, + { id: 'design', name: 'Design' }, + { id: 'video', name: 'Video' }, + { id: 'sandbox', name: 'Sandbox' }, + { id: 'social', name: 'Social Post' }, + { id: 'audio', name: 'Audio' }, + { id: 'link', name: 'Link' } + ] as const, + default: 'link' + }), + + /** Provider-specific stable identifier */ + refId: text({}), + + /** Compact display title */ + title: text({ required: true, maxLength: 500 }), + + /** Secondary display label */ + subtitle: text({ maxLength: 500 }), + + /** Small icon or provider marker */ + icon: text({ maxLength: 32 }), + + /** Optional richer preview/embed URL */ + embedUrl: url({}), + + /** Provider-specific metadata stored as JSON */ + metadata: text({ maxLength: 10000 }) + }, + document: undefined +}) + +/** + * An ExternalReference node type (inferred from schema). + */ +export type ExternalReference = InferNode<(typeof ExternalReferenceSchema)['_properties']> diff --git a/packages/data/src/schema/schemas/index.ts b/packages/data/src/schema/schemas/index.ts index ff7f15b85..ef008330a 100644 --- a/packages/data/src/schema/schemas/index.ts +++ b/packages/data/src/schema/schemas/index.ts @@ -9,6 +9,7 @@ export { PageSchema, type Page } from './page' export { DatabaseSchema, type Database } from './database' export { DatabaseRowSchema, type DatabaseRow } from './database-row' export { TaskSchema, type Task } from './task' +export { ExternalReferenceSchema, type ExternalReference } from './external-reference' export { CanvasSchema, type Canvas } from './canvas' export { CommentSchema, type Comment } from './comment' export { GrantSchema, type Grant } from './grant' @@ -74,6 +75,8 @@ export const builtInSchemas = { 'xnet://xnet.fyi/DatabaseRow@1.0.0': () => import('./database-row').then((m) => m.DatabaseRowSchema), 'xnet://xnet.fyi/Task@1.0.0': () => import('./task').then((m) => m.TaskSchema), + 'xnet://xnet.fyi/ExternalReference@1.0.0': () => + import('./external-reference').then((m) => m.ExternalReferenceSchema), 'xnet://xnet.fyi/Canvas@1.0.0': () => import('./canvas').then((m) => m.CanvasSchema), 'xnet://xnet.fyi/Comment@1.0.0': () => import('./comment').then((m) => m.CommentSchema), 'xnet://xnet.fyi/Grant@1.0.0': () => import('./grant').then((m) => m.GrantSchema), @@ -83,6 +86,8 @@ export const builtInSchemas = { 'xnet://xnet.fyi/Database': () => import('./database').then((m) => m.DatabaseSchema), 'xnet://xnet.fyi/DatabaseRow': () => import('./database-row').then((m) => m.DatabaseRowSchema), 'xnet://xnet.fyi/Task': () => import('./task').then((m) => m.TaskSchema), + 'xnet://xnet.fyi/ExternalReference': () => + import('./external-reference').then((m) => m.ExternalReferenceSchema), 'xnet://xnet.fyi/Canvas': () => import('./canvas').then((m) => m.CanvasSchema), 'xnet://xnet.fyi/Comment': () => import('./comment').then((m) => m.CommentSchema), 'xnet://xnet.fyi/Grant': () => import('./grant').then((m) => m.GrantSchema) diff --git a/packages/data/src/schema/schemas/task.test.ts b/packages/data/src/schema/schemas/task.test.ts new file mode 100644 index 000000000..62f5bee1a --- /dev/null +++ b/packages/data/src/schema/schemas/task.test.ts @@ -0,0 +1,76 @@ +import type { DID } from '../node' +import { describe, expect, it } from 'vitest' +import { TaskSchema } from './task' + +describe('TaskSchema', () => { + const testDID = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK' as DID + + describe('schema definition', () => { + it('has correct schema IRI', () => { + expect(TaskSchema.schema['@id']).toBe('xnet://xnet.fyi/Task@1.0.0') + expect(TaskSchema.schema['@type']).toBe('xnet://xnet.fyi/Schema') + expect(TaskSchema.schema.name).toBe('Task') + expect(TaskSchema.schema.version).toBe('1.0.0') + }) + + it('has all task metadata properties needed for page-backed tasks', () => { + const propIds = TaskSchema.schema.properties.map((p) => p['@id']) + + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#title') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#completed') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#assignee') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#assignees') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#page') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#anchorBlockId') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#sortKey') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#source') + expect(propIds).toContain('xnet://xnet.fyi/Task@1.0.0#references') + }) + }) + + describe('create', () => { + it('creates a task with assignees, page binding, and references', () => { + const task = TaskSchema.create( + { + title: 'Review the PR', + assignees: [testDID], + page: 'page-123', + anchorBlockId: 'block-456', + sortKey: 'a0', + source: 'page', + references: ['ref-1', 'ref-2'] + }, + { createdBy: testDID } + ) + + expect(task.title).toBe('Review the PR') + expect(task.assignees).toEqual([testDID]) + expect(task.page).toBe('page-123') + expect(task.anchorBlockId).toBe('block-456') + expect(task.sortKey).toBe('a0') + expect(task.source).toBe('page') + expect(task.references).toEqual(['ref-1', 'ref-2']) + expect(task.completed).toBe(false) + expect(task.status).toBe('todo') + expect(task.priority).toBe('medium') + }) + }) + + describe('validate', () => { + it('accepts a valid task with new fields', () => { + const task = TaskSchema.create( + { + title: 'Watch walkthrough', + assignees: [testDID], + references: ['ref-1'], + source: 'page' + }, + { createdBy: testDID } + ) + + const result = TaskSchema.validate(task) + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + }) +}) diff --git a/packages/data/src/schema/schemas/task.ts b/packages/data/src/schema/schemas/task.ts index ebbc927dc..dd749322e 100644 --- a/packages/data/src/schema/schemas/task.ts +++ b/packages/data/src/schema/schemas/task.ts @@ -44,11 +44,41 @@ export const TaskSchema = defineSchema({ /** Due date */ dueDate: date({}), - /** Assigned person */ + /** Assigned person (legacy single assignee for compatibility) */ assignee: person({}), + /** Assigned people */ + assignees: person({ multiple: true }), + /** Parent task (for subtasks) */ - parent: relation({ target: 'xnet://xnet.fyi/Task' as const }) + parent: relation({ target: 'xnet://xnet.fyi/Task' as const }), + + /** Page that currently hosts this task */ + page: relation({ target: 'xnet://xnet.fyi/Page@1.0.0' as const }), + + /** Surface-specific block anchor inside the page document */ + anchorBlockId: text({ maxLength: 500 }), + + /** Stable sibling order key for cross-view projections */ + sortKey: text({ maxLength: 500 }), + + /** Where this task was created */ + source: select({ + options: [ + { id: 'page', name: 'Page' }, + { id: 'database', name: 'Database' }, + { id: 'canvas', name: 'Canvas' }, + { id: 'automation', name: 'Automation' }, + { id: 'api', name: 'API' } + ] as const, + default: 'page' + }), + + /** Structured external references related to this task */ + references: relation({ + target: 'xnet://xnet.fyi/ExternalReference@1.0.0' as const, + multiple: true + }) }, document: 'yjs' // Collaborative Y.Doc for description }) diff --git a/packages/editor/src/components/RichTextEditor.tsx b/packages/editor/src/components/RichTextEditor.tsx index f4f394e94..e484058b6 100644 --- a/packages/editor/src/components/RichTextEditor.tsx +++ b/packages/editor/src/components/RichTextEditor.tsx @@ -29,6 +29,7 @@ import { CalloutExtension, ToggleExtension, FileExtension, + SmartReferenceExtension, EmbedExtension, DatabaseEmbedExtension } from '../extensions' @@ -408,6 +409,8 @@ export function RichTextEditor({ onUpload: onFileUpload, onDownload: onFileDownload }), + // Compact inline references for URLs pasted inside tasks + SmartReferenceExtension, // Media embeds (YouTube, Spotify, Vimeo, etc.) EmbedExtension, // Database embeds (inline table/board/list views) diff --git a/packages/editor/src/extensions.ts b/packages/editor/src/extensions.ts index 20b0b3a9d..f3dcddb9c 100644 --- a/packages/editor/src/extensions.ts +++ b/packages/editor/src/extensions.ts @@ -439,6 +439,13 @@ export { EmbedExtension } from './extensions/embed' export type { EmbedOptions } from './extensions/embed' export { EMBED_PROVIDERS, detectProvider, parseEmbedUrl } from './extensions/embed' export type { EmbedProvider } from './extensions/embed' +export { SmartReferenceExtension } from './extensions/smart-reference' +export type { SmartReferenceOptions } from './extensions/smart-reference' +export { + parseSmartReferenceUrl, + type SmartReference, + type SmartReferenceKind +} from './extensions/smart-reference' // Database embed extension export { DatabaseEmbedExtension } from './extensions/database-embed' diff --git a/packages/editor/src/extensions/smart-reference/SmartReferenceExtension.test.ts b/packages/editor/src/extensions/smart-reference/SmartReferenceExtension.test.ts new file mode 100644 index 000000000..7599ec117 --- /dev/null +++ b/packages/editor/src/extensions/smart-reference/SmartReferenceExtension.test.ts @@ -0,0 +1,114 @@ +import { Editor } from '@tiptap/core' +import TaskItem from '@tiptap/extension-task-item' +import TaskList from '@tiptap/extension-task-list' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { SmartReferenceExtension } from './SmartReferenceExtension' + +describe('SmartReferenceExtension', () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + TaskList, + TaskItem.configure({ nested: true }), + SmartReferenceExtension + ], + content: '

Hello world

' + }) + }) + + afterEach(() => { + editor.destroy() + }) + + describe('schema', () => { + it('registers the smartReference node type', () => { + expect(editor.schema.nodes.smartReference).toBeDefined() + }) + + it('is an inline atom', () => { + const spec = editor.schema.nodes.smartReference.spec + expect(spec.group).toBe('inline') + expect(spec.inline).toBe(true) + expect(spec.atom).toBe(true) + }) + }) + + describe('setSmartReference command', () => { + it('inserts a GitHub issue chip', () => { + const result = editor.commands.setSmartReference( + 'https://github.com/openai/openai/issues/123' + ) + expect(result).toBe(true) + + const json = editor.getJSON() + const paragraph = json.content?.find((node) => node.type === 'paragraph') + const smartReference = paragraph?.content?.find((node) => node.type === 'smartReference') + expect(smartReference?.attrs).toMatchObject({ + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123' + }) + }) + + it('returns false for unsupported URLs', () => { + const result = editor.commands.setSmartReference('https://example.com/docs/123') + expect(result).toBe(false) + }) + }) + + describe('task paste conversion', () => { + it('converts supported pasted URLs into smart references inside task items', () => { + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: false }, + content: [{ type: 'paragraph' }] + } + ] + } + ] + }) + + editor.commands.setTextSelection(3) + + const pastePlugin = editor.state.plugins.find((plugin) => + String((plugin as { key?: string }).key).includes('smartReferencePaste') + ) + + expect(pastePlugin?.props.handlePaste).toBeDefined() + + const event = { + clipboardData: { + getData: (type: string) => + type === 'text/plain' ? 'https://www.figma.com/file/abc123def' : '' + }, + preventDefault: () => {} + } as ClipboardEvent + + const handled = pastePlugin?.props.handlePaste?.(editor.view, event) + expect(handled).toBe(true) + + const json = editor.getJSON() + const smartReference = + json.content?.[0]?.content?.[0]?.content?.[0]?.content?.find( + (node) => node.type === 'smartReference' + ) ?? null + + expect(smartReference?.attrs).toMatchObject({ + provider: 'figma', + kind: 'design', + refId: 'file/abc123def' + }) + }) + }) +}) diff --git a/packages/editor/src/extensions/smart-reference/SmartReferenceExtension.ts b/packages/editor/src/extensions/smart-reference/SmartReferenceExtension.ts new file mode 100644 index 000000000..a9164c918 --- /dev/null +++ b/packages/editor/src/extensions/smart-reference/SmartReferenceExtension.ts @@ -0,0 +1,148 @@ +/** + * SmartReferenceExtension - Compact inline structured references. + * + * Converts supported URLs into inline chips that preserve normalized metadata + * without forcing a full embed. This is especially useful inside task items. + */ +import { Node, mergeAttributes } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { parseSmartReferenceUrl } from './providers' + +const SmartReferencePastePluginKey = new PluginKey('smartReferencePaste') + +function isTaskItemSelection(editor: import('@tiptap/core').Editor): boolean { + const { $from } = editor.state.selection + + for (let depth = $from.depth; depth >= 0; depth--) { + if ($from.node(depth).type.name === 'taskItem') { + return true + } + } + + return false +} + +function buildReferenceChip(reference: ReturnType) { + if (!reference) return null + + return { + type: 'smartReference', + attrs: { + url: reference.url, + provider: reference.provider, + kind: reference.kind, + refId: reference.refId, + title: reference.title, + subtitle: reference.subtitle ?? null, + icon: reference.icon, + embedUrl: reference.embedUrl ?? null, + metadata: JSON.stringify(reference.metadata) + } + } +} + +export interface SmartReferenceOptions { + autoConvertTaskPaste: boolean + HTMLAttributes: Record +} + +declare module '@tiptap/core' { + interface Commands { + smartReference: { + setSmartReference: (url: string) => ReturnType + } + } +} + +export const SmartReferenceExtension = Node.create({ + name: 'smartReference', + + inline: true, + + group: 'inline', + + atom: true, + + selectable: true, + + addOptions() { + return { + autoConvertTaskPaste: true, + HTMLAttributes: {} + } + }, + + addAttributes() { + return { + url: { default: null }, + provider: { default: null }, + kind: { default: null }, + refId: { default: null }, + title: { default: null }, + subtitle: { default: null }, + icon: { default: null }, + embedUrl: { default: null }, + metadata: { default: '{}' } + } + }, + + parseHTML() { + return [{ tag: 'a[data-smart-reference]' }] + }, + + renderHTML({ HTMLAttributes }) { + const label = [HTMLAttributes.icon, HTMLAttributes.title].filter(Boolean).join(' ') + + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-smart-reference': '', + 'data-provider': HTMLAttributes.provider, + 'data-kind': HTMLAttributes.kind, + href: HTMLAttributes.url, + class: `smart-reference smart-reference--${HTMLAttributes.provider || 'generic'}` + }), + label + ] + }, + + addCommands() { + return { + setSmartReference: + (url: string) => + ({ commands }) => { + const reference = parseSmartReferenceUrl(url) + if (!reference) return false + + const chip = buildReferenceChip(reference) + if (!chip) return false + + return commands.insertContent(chip) + } + } + }, + + addProseMirrorPlugins() { + const editor = this.editor + if (!this.options.autoConvertTaskPaste) return [] + + return [ + new Plugin({ + key: SmartReferencePastePluginKey, + props: { + handlePaste(_view, event) { + const text = event.clipboardData?.getData('text/plain')?.trim() + if (!text) return false + if (!isTaskItemSelection(editor)) return false + + const reference = parseSmartReferenceUrl(text) + if (!reference) return false + + event.preventDefault() + return editor.commands.setSmartReference(text) + } + } + }) + ] + } +}) diff --git a/packages/editor/src/extensions/smart-reference/index.ts b/packages/editor/src/extensions/smart-reference/index.ts new file mode 100644 index 000000000..ba0f9e20a --- /dev/null +++ b/packages/editor/src/extensions/smart-reference/index.ts @@ -0,0 +1,3 @@ +export { SmartReferenceExtension } from './SmartReferenceExtension' +export type { SmartReferenceOptions } from './SmartReferenceExtension' +export { parseSmartReferenceUrl, type SmartReference, type SmartReferenceKind } from './providers' diff --git a/packages/editor/src/extensions/smart-reference/providers.test.ts b/packages/editor/src/extensions/smart-reference/providers.test.ts new file mode 100644 index 000000000..0d8d8a97d --- /dev/null +++ b/packages/editor/src/extensions/smart-reference/providers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { parseSmartReferenceUrl } from './providers' + +describe('parseSmartReferenceUrl', () => { + it('parses GitHub issue URLs', () => { + const parsed = parseSmartReferenceUrl('https://github.com/openai/openai/issues/123') + + expect(parsed).toMatchObject({ + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123', + title: 'openai#123' + }) + }) + + it('parses GitHub PR URLs', () => { + const parsed = parseSmartReferenceUrl('https://github.com/openai/openai/pull/456') + + expect(parsed).toMatchObject({ + provider: 'github', + kind: 'pull-request', + refId: 'openai/openai#456', + title: 'openai PR #456' + }) + }) + + it('reuses embed providers for Figma URLs', () => { + const parsed = parseSmartReferenceUrl('https://www.figma.com/file/abc123def') + + expect(parsed).toMatchObject({ + provider: 'figma', + kind: 'design', + refId: 'file/abc123def' + }) + }) + + it('reuses embed providers for YouTube URLs', () => { + const parsed = parseSmartReferenceUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + + expect(parsed).toMatchObject({ + provider: 'youtube', + kind: 'video', + refId: 'dQw4w9WgXcQ' + }) + }) + + it('returns null for unsupported URLs', () => { + expect(parseSmartReferenceUrl('https://example.com/docs/123')).toBeNull() + }) +}) diff --git a/packages/editor/src/extensions/smart-reference/providers.ts b/packages/editor/src/extensions/smart-reference/providers.ts new file mode 100644 index 000000000..0e94cdb45 --- /dev/null +++ b/packages/editor/src/extensions/smart-reference/providers.ts @@ -0,0 +1,137 @@ +/** + * Smart reference provider registry. + * + * Parses supported URLs into structured inline references suitable for compact + * task-friendly chips. This intentionally overlaps with block embeds, but keeps + * enough structure to represent links intelligently without expanding them. + */ + +import { detectProvider as detectEmbedProvider, parseEmbedUrl } from '../embed/providers' + +export type SmartReferenceKind = + | 'issue' + | 'pull-request' + | 'design' + | 'video' + | 'sandbox' + | 'social' + | 'audio' + | 'link' + +export interface SmartReference { + provider: string + kind: SmartReferenceKind + url: string + refId: string + title: string + subtitle?: string + icon: string + embedUrl?: string + metadata: Record +} + +const GITHUB_ISSUE_REGEX = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/?#].*)?$/i +const GITHUB_PR_REGEX = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i + +function parseGitHub(url: string): SmartReference | null { + const issueMatch = url.match(GITHUB_ISSUE_REGEX) + if (issueMatch) { + const [, owner, repo, number] = issueMatch + return { + provider: 'github', + kind: 'issue', + url, + refId: `${owner}/${repo}#${number}`, + title: `${repo}#${number}`, + subtitle: owner, + icon: 'GH', + metadata: { + owner, + repo, + number, + entity: 'issue' + } + } + } + + const prMatch = url.match(GITHUB_PR_REGEX) + if (prMatch) { + const [, owner, repo, number] = prMatch + return { + provider: 'github', + kind: 'pull-request', + url, + refId: `${owner}/${repo}#${number}`, + title: `${repo} PR #${number}`, + subtitle: owner, + icon: 'PR', + metadata: { + owner, + repo, + number, + entity: 'pull-request' + } + } + } + + return null +} + +function inferKind(provider: string): SmartReferenceKind { + switch (provider) { + case 'figma': + return 'design' + case 'youtube': + case 'vimeo': + case 'loom': + return 'video' + case 'codesandbox': + return 'sandbox' + case 'spotify': + return 'audio' + case 'twitter': + return 'social' + default: + return 'link' + } +} + +function buildEmbedReference(url: string): SmartReference | null { + const provider = detectEmbedProvider(url) + const parsed = parseEmbedUrl(url) + if (!provider || !parsed) return null + + const refId = parsed.id + const label = + provider.name === 'figma' + ? `Figma ${refId.split('/')[0]}` + : provider.name === 'youtube' + ? `YouTube ${refId}` + : provider.name === 'loom' + ? `Loom ${refId.slice(0, 8)}` + : provider.name === 'codesandbox' + ? `Sandbox ${refId}` + : provider.displayName + + return { + provider: provider.name, + kind: inferKind(provider.name), + url, + refId, + title: label, + subtitle: provider.displayName, + icon: provider.icon, + embedUrl: parsed.embedUrl, + metadata: { + embedProvider: provider.name, + embedId: refId + } + } +} + +export function parseSmartReferenceUrl(url: string): SmartReference | null { + const trimmed = url.trim() + if (!/^https?:\/\//i.test(trimmed)) return null + + return parseGitHub(trimmed) ?? buildEmbedReference(trimmed) +} diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index fb2a27d01..26a873d87 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -197,6 +197,36 @@ border-bottom-style: solid; } +/* Smart references */ +.ProseMirror a[data-smart-reference] { + @apply inline-flex items-center gap-1 rounded-md px-2 py-0.5 no-underline; + background: rgb(var(--editor-muted) / 0.75); + border: 1px solid rgb(var(--editor-border)); + color: rgb(var(--editor-foreground)); + font-size: 0.875em; + line-height: 1.2; + vertical-align: baseline; +} + +.ProseMirror a[data-smart-reference]:hover { + background: rgb(var(--editor-muted)); + border-color: rgb(var(--editor-primary) / 0.35); +} + +.ProseMirror a[data-smart-reference][data-provider='github'] { + background: rgb(17 24 39 / 0.08); +} + +.ProseMirror a[data-smart-reference][data-provider='figma'] { + background: rgb(244 63 94 / 0.08); +} + +.ProseMirror a[data-smart-reference][data-provider='youtube'], +.ProseMirror a[data-smart-reference][data-provider='loom'], +.ProseMirror a[data-smart-reference][data-provider='vimeo'] { + background: rgb(239 68 68 / 0.08); +} + /* Horizontal rule */ .ProseMirror hr { @apply my-8 border-t border-border; From 1bdb862938f6f6bbaea1608b867622ee63645b80 Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 17:35:21 -0800 Subject: [PATCH 05/12] feat(tasks): sync page checklists into task nodes - add a page task item extension that persists task ids and block ids in TipTap rows - reconcile page task snapshots into Task and ExternalReference nodes for web and desktop page views - add focused tests and mark completed items in the original task exploration checklist --- .../src/renderer/components/PageView.tsx | 5 +- apps/web/src/components/Editor.tsx | 7 +- apps/web/src/routes/doc.$docId.tsx | 4 +- ...S_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md | 60 ++-- .../editor/src/components/RichTextEditor.tsx | 39 ++- packages/editor/src/extensions.ts | 7 + .../page-tasks/PageTaskItemExtension.test.ts | 122 +++++++ .../editor/src/extensions/page-tasks/index.ts | 211 +++++++++++ packages/editor/src/react.ts | 1 + .../react/src/hooks/usePageTaskSync.test.tsx | 143 ++++++++ packages/react/src/hooks/usePageTaskSync.ts | 331 ++++++++++++++++++ packages/react/src/index.ts | 7 + 12 files changed, 900 insertions(+), 37 deletions(-) create mode 100644 packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts create mode 100644 packages/editor/src/extensions/page-tasks/index.ts create mode 100644 packages/react/src/hooks/usePageTaskSync.test.tsx create mode 100644 packages/react/src/hooks/usePageTaskSync.ts diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index 2ce4416c4..193e64706 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -22,7 +22,8 @@ import { useIdentity, useEditorExtensionsSafe, useComments, - usePluginRegistryOptional + usePluginRegistryOptional, + usePageTaskSync } from '@xnetjs/react' import { CommentPopover, @@ -100,6 +101,7 @@ export function PageView({ docId }: PageViewProps) { createIfMissing: { title: 'Untitled Page' }, did: did ?? undefined }) + const { handleTasksChange } = usePageTaskSync({ pageId: docId }) // ─── Comments Integration ───────────────────────────────────────────────────── @@ -746,6 +748,7 @@ export function PageView({ docId }: PageViewProps) { extensions={allExtensions} onCreateComment={handleCreateComment} onEditorReady={handleEditorReady} + onPageTasksChange={handleTasksChange} /> {/* Orphaned Comments Section */} diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index ca8778543..147ffc8ac 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -10,7 +10,8 @@ import { useImageUpload, useFileUpload, useFileDownload, - type Editor as TipTapEditor + type Editor as TipTapEditor, + type PageTaskSnapshot } from '@xnetjs/editor/react' interface Props { @@ -24,6 +25,8 @@ interface Props { extensions?: any[] /** Callback when editor is ready */ onEditorReady?: (editor: TipTapEditor) => void + /** Callback for page-backed task snapshots */ + onPageTasksChange?: (tasks: PageTaskSnapshot[]) => void /** Callback for creating a comment */ onCreateComment?: (anchorData: string) => Promise } @@ -35,6 +38,7 @@ export function Editor({ onNavigate, extensions, onEditorReady, + onPageTasksChange, onCreateComment }: Props) { const onImageUpload = useImageUpload() @@ -56,6 +60,7 @@ export function Editor({ onFileDownload={onFileDownload ?? undefined} extensions={extensions} onEditorReady={onEditorReady} + onPageTasksChange={onPageTasksChange} onCreateComment={onCreateComment} /> ) diff --git a/apps/web/src/routes/doc.$docId.tsx b/apps/web/src/routes/doc.$docId.tsx index 7e549ff17..c206f1538 100644 --- a/apps/web/src/routes/doc.$docId.tsx +++ b/apps/web/src/routes/doc.$docId.tsx @@ -10,7 +10,7 @@ import type { Editor } from '@xnetjs/editor/react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { PageSchema } from '@xnetjs/data' import { CommentMark, CommentPlugin, restoreCommentMarks } from '@xnetjs/editor/extensions' -import { useNode, useComments, useIdentity } from '@xnetjs/react' +import { useNode, useComments, useIdentity, usePageTaskSync } from '@xnetjs/react' import { CommentPopover, CommentsSidebar, @@ -76,6 +76,7 @@ function DocumentPage() { createIfMissing: { title: 'Untitled' }, did: did ?? undefined }) + const { handleTasksChange } = usePageTaskSync({ pageId: docId }) // ─── Comments Integration ───────────────────────────────────────────────────── @@ -598,6 +599,7 @@ function DocumentPage() { onNavigate={handleNavigate} extensions={commentExtensions} onEditorReady={handleEditorReady} + onPageTasksChange={handleTasksChange} onCreateComment={handleCreateComment} /> diff --git a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md index e1a63372a..bf49894bb 100644 --- a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md +++ b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md @@ -66,14 +66,14 @@ ### Current mismatch -| Area | Current behavior | Limitation | -| --- | --- | --- | -| Page checklist | TipTap-only task items | no node identity, no queryability, no notifications, no cross-surface reuse | -| Task schema | first-class node exists | single assignee, no page anchor, no ordering key, no origin metadata | -| Mentions | comment parsing and DB person suggestions exist | no rich-text mention entity in page editor | -| External links | block embeds and link previews exist | no inline smart-reference chip model for tasks | -| Database views | powerful projections exist | only database-backed today, not query-backed task collections | -| Canvas checklist | local checklist data | cannot share identity with page/database tasks | +| Area | Current behavior | Limitation | +| ---------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | +| Page checklist | TipTap-only task items | no node identity, no queryability, no notifications, no cross-surface reuse | +| Task schema | first-class node exists | single assignee, no page anchor, no ordering key, no origin metadata | +| Mentions | comment parsing and DB person suggestions exist | no rich-text mention entity in page editor | +| External links | block embeds and link previews exist | no inline smart-reference chip model for tasks | +| Database views | powerful projections exist | only database-backed today, not query-backed task collections | +| Canvas checklist | local checklist data | cannot share identity with page/database tasks | ### Important architectural precedent @@ -578,17 +578,17 @@ sequenceDiagram ## Data ownership rules -| Field | Canonical owner | Why | -| --- | --- | --- | -| task identity | `Task` node | global referenceability | -| status / due date / assignees | `Task` node | query + notifications | -| external provider metadata | `ExternalReference` node | sync, dedupe, provider-aware rendering | -| page placement | page Yjs + task `page` relation | both surface and global query need it | -| parent/subtask relation | `Task` node | cross-view hierarchy | -| sibling visual order in page | `sortKey` on `Task` node | stable across projections | -| inline rich title text | page Yjs, mirrored to node title | preserves rich editing ergonomics | -| inline smart-reference chip placement | page Yjs task block | preserves authoring ergonomics | -| expanded notes | task Yjs doc later | avoids overloading page block | +| Field | Canonical owner | Why | +| ------------------------------------- | -------------------------------- | -------------------------------------- | +| task identity | `Task` node | global referenceability | +| status / due date / assignees | `Task` node | query + notifications | +| external provider metadata | `ExternalReference` node | sync, dedupe, provider-aware rendering | +| page placement | page Yjs + task `page` relation | both surface and global query need it | +| parent/subtask relation | `Task` node | cross-view hierarchy | +| sibling visual order in page | `sortKey` on `Task` node | stable across projections | +| inline rich title text | page Yjs, mirrored to node title | preserves rich editing ergonomics | +| inline smart-reference chip placement | page Yjs task block | preserves authoring ergonomics | +| expanded notes | task Yjs doc later | avoids overloading page block | ## Risks And Open Questions @@ -691,17 +691,17 @@ Recommendation: ## Phase 1: Page-native node-backed tasks - [ ] Design `TaskSchema` v2 and migration path from the current built-in schema. -- [ ] Add `assignees` multi-person support. -- [ ] Add `page`, `anchorBlockId`, `sortKey`, and task relation fields. -- [ ] Build a custom TipTap task extension that stores `taskId`. +- [x] Add `assignees` multi-person support. +- [x] Add `page`, `anchorBlockId`, `sortKey`, and task relation fields. +- [x] Build a custom TipTap task extension that stores `taskId`. - [ ] Add rich-text mention support that resolves to DIDs. - [ ] Add inline due-date and assignee chips to task rows. -- [ ] Add inline smart-reference chips for supported URLs inside task lines. -- [ ] Reuse the existing embed provider registry for first-pass provider detection. -- [ ] Define normalized `ExternalReference` metadata shape and relation strategy. -- [ ] Create a page task reconciler that maps editor operations to node mutations. -- [ ] Persist parent/subtask relations from indentation. -- [ ] Decide delete semantics: archive vs detach vs hard delete. +- [x] Add inline smart-reference chips for supported URLs inside task lines. +- [x] Reuse the existing embed provider registry for first-pass provider detection. +- [x] Define normalized `ExternalReference` metadata shape and relation strategy. +- [x] Create a page task reconciler that maps editor operations to node mutations. +- [x] Persist parent/subtask relations from indentation. +- [x] Decide delete semantics: archive vs detach vs hard delete. - [ ] Add task-specific tests for page editing, indentation, reassignment, due-date edits, and URL-to-chip conversion. ## Phase 2: Cross-surface views @@ -724,7 +724,7 @@ Recommendation: ## 🧪 Validation Checklist -- [ ] Creating a checklist item in a page creates exactly one `Task` node. +- [x] Creating a checklist item in a page creates exactly one `Task` node. - [ ] Toggling a checkbox updates `Task.completed` and all subscribed task views. - [ ] Indenting/outdenting updates parent/subtask relations deterministically. - [ ] Reordering sibling tasks preserves stable order across peers. @@ -736,7 +736,7 @@ Recommendation: - [ ] Full block embeds remain opt-in so task rows stay compact. - [ ] Page comments, task comments, and database/canvas task views can all target the same task node. - [ ] Undo/redo behaves coherently for page text plus task metadata. -- [ ] Removing a task from a page follows the chosen archive/detach semantics consistently. +- [x] Removing a task from a page follows the chosen archive/detach semantics consistently. - [ ] Query-backed task views remain performant with thousands of tasks. - [ ] No orphaned task nodes are created during rapid collaborative edits. diff --git a/packages/editor/src/components/RichTextEditor.tsx b/packages/editor/src/components/RichTextEditor.tsx index e484058b6..dde58ecec 100644 --- a/packages/editor/src/components/RichTextEditor.tsx +++ b/packages/editor/src/components/RichTextEditor.tsx @@ -1,14 +1,13 @@ /** * RichTextEditor - Tiptap-based rich text editor with Yjs collaboration */ -import type { DatabaseViewType, SlashCommandItem } from '../extensions' +import type { DatabaseViewType, PageTaskSnapshot, SlashCommandItem } from '../extensions' import type { AnyExtension } from '@tiptap/core' import type { Awareness } from 'y-protocols/awareness' import type * as Y from 'yjs' import Collaboration from '@tiptap/extension-collaboration' import Link from '@tiptap/extension-link' import Placeholder from '@tiptap/extension-placeholder' -import TaskItem from '@tiptap/extension-task-item' import TaskList from '@tiptap/extension-task-list' import Typography from '@tiptap/extension-typography' import { useEditor, EditorContent, type Editor } from '@tiptap/react' @@ -31,7 +30,10 @@ import { FileExtension, SmartReferenceExtension, EmbedExtension, - DatabaseEmbedExtension + DatabaseEmbedExtension, + PageTaskItemExtension, + ensurePageTaskAttrs, + getPageTasksSnapshot } from '../extensions' import { FloatingToolbar, type ToolbarMode } from './FloatingToolbar' import '../styles/editor.css' @@ -261,6 +263,11 @@ export interface RichTextEditorProps { * for advanced integrations like comment system. */ onEditorReady?: (editor: Editor) => void + /** + * Task snapshot handler for page-backed checklist reconciliation. + * Called after task rows have stable ids and the editor view is in sync. + */ + onPageTasksChange?: (tasks: PageTaskSnapshot[]) => void /** * Comment creation handler. When provided, shows a Comment button in the toolbar. * Called with anchor data when user clicks Comment; should return the new comment ID. @@ -336,9 +343,11 @@ export function RichTextEditor({ toolbarItems: additionalToolbarItems = [], slashCommands, onEditorReady, + onPageTasksChange, onCreateComment }: RichTextEditorProps): JSX.Element { const cursorPluginRegisteredRef = useRef(false) + const pageTaskSignatureRef = useRef('') // Get or create the content fragment for Yjs collaboration const fragment = ydoc.getXmlFragment(field) @@ -368,7 +377,7 @@ export function RichTextEditor({ fragment }), TaskList, - TaskItem.configure({ + PageTaskItemExtension.configure({ nested: true }), Link.configure({ @@ -448,6 +457,28 @@ export function RichTextEditor({ } }, [editor, onEditorReady]) + useEffect(() => { + if (!editor || !onPageTasksChange) return + + const publishPageTasks = () => { + if (ensurePageTaskAttrs(editor)) return + + const tasks = getPageTasksSnapshot(editor) + const signature = JSON.stringify(tasks) + if (signature === pageTaskSignatureRef.current) return + + pageTaskSignatureRef.current = signature + onPageTasksChange(tasks) + } + + publishPageTasks() + editor.on('update', publishPageTasks) + + return () => { + editor.off('update', publishPageTasks) + } + }, [editor, onPageTasksChange]) + // Add cursor plugin dynamically when awareness becomes available. // We use yCursorPlugin directly (instead of CollaborationCursor extension) to avoid // the render-phase setState that occurs when the extension calls setLocalStateField diff --git a/packages/editor/src/extensions.ts b/packages/editor/src/extensions.ts index f3dcddb9c..6b8a4ba33 100644 --- a/packages/editor/src/extensions.ts +++ b/packages/editor/src/extensions.ts @@ -446,6 +446,13 @@ export { type SmartReference, type SmartReferenceKind } from './extensions/smart-reference' +export { + PageTaskItemExtension, + collectPageTasks, + ensurePageTaskAttrs, + getPageTasksSnapshot +} from './extensions/page-tasks' +export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' // Database embed extension export { DatabaseEmbedExtension } from './extensions/database-embed' diff --git a/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts b/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts new file mode 100644 index 000000000..0a9b2dc7c --- /dev/null +++ b/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts @@ -0,0 +1,122 @@ +import { Editor } from '@tiptap/core' +import TaskList from '@tiptap/extension-task-list' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { SmartReferenceExtension } from '../smart-reference' +import { PageTaskItemExtension, collectPageTasks, ensurePageTaskAttrs } from './index' + +describe('PageTaskItemExtension', () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + TaskList, + PageTaskItemExtension.configure({ nested: true }), + SmartReferenceExtension + ], + content: { + type: 'doc', + content: [ + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Parent task' }] + }, + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: true }, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Child task ' }, + { + type: 'smartReference', + attrs: { + url: 'https://github.com/openai/openai/issues/123', + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123', + title: 'Issue #123', + subtitle: 'openai/openai', + icon: 'GH', + embedUrl: null, + metadata: '{"repo":"openai/openai"}' + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }) + }) + + afterEach(() => { + editor.destroy() + }) + + it('adds stable task metadata attrs to task items', () => { + const changed = ensurePageTaskAttrs(editor) + + expect(changed).toBe(true) + + const taskItems: Array<{ attrs?: Record }> = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'taskItem') taskItems.push({ attrs: node.attrs }) + }) + + expect(taskItems).toHaveLength(2) + expect(taskItems[0].attrs?.taskId).toEqual(expect.any(String)) + expect(taskItems[0].attrs?.blockId).toEqual(expect.any(String)) + expect(taskItems[1].attrs?.taskId).toEqual(expect.any(String)) + expect(taskItems[1].attrs?.blockId).toEqual(expect.any(String)) + }) + + it('collects parent-child task snapshots and smart references', () => { + ensurePageTaskAttrs(editor) + + const { tasks, attrUpdates } = collectPageTasks(editor.state.doc) + + expect(attrUpdates).toHaveLength(0) + expect(tasks).toHaveLength(2) + expect(tasks[0]).toMatchObject({ + title: 'Parent task', + completed: false, + parentTaskId: null, + sortKey: '0000' + }) + expect(tasks[1]).toMatchObject({ + title: 'Child task', + completed: true, + parentTaskId: tasks[0].taskId, + sortKey: '0000.0000' + }) + expect(tasks[1].references).toEqual([ + expect.objectContaining({ + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123' + }) + ]) + }) +}) diff --git a/packages/editor/src/extensions/page-tasks/index.ts b/packages/editor/src/extensions/page-tasks/index.ts new file mode 100644 index 000000000..1b61eefb1 --- /dev/null +++ b/packages/editor/src/extensions/page-tasks/index.ts @@ -0,0 +1,211 @@ +import type { Editor } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import TaskItem from '@tiptap/extension-task-item' + +export interface PageTaskReferenceSnapshot { + url: string + provider: string | null + kind: string | null + refId: string | null + title: string | null + subtitle: string | null + icon: string | null + embedUrl: string | null + metadata: string +} + +export interface PageTaskSnapshot { + taskId: string + blockId: string + title: string + completed: boolean + parentTaskId: string | null + sortKey: string + references: PageTaskReferenceSnapshot[] +} + +type TaskAttrUpdate = { + pos: number + attrs: Record +} + +export const PageTaskItemExtension = TaskItem.extend({ + addAttributes() { + const parent = this.parent?.() ?? {} + + return { + ...parent, + taskId: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-task-id'), + renderHTML: (attributes: Record) => + typeof attributes.taskId === 'string' && attributes.taskId.length > 0 + ? { 'data-task-id': attributes.taskId } + : {} + }, + blockId: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-block-id'), + renderHTML: (attributes: Record) => + typeof attributes.blockId === 'string' && attributes.blockId.length > 0 + ? { 'data-block-id': attributes.blockId } + : {} + } + } + } +}) + +function generateId(prefix: string): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return `${prefix}_${globalThis.crypto.randomUUID()}` + } + + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}` +} + +function toStringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function buildSortKey(path: number[]): string { + return path.map((segment) => String(segment).padStart(4, '0')).join('.') +} + +function readSmartReference(node: ProseMirrorNode): PageTaskReferenceSnapshot | null { + if (node.type.name !== 'smartReference') return null + + return { + url: toStringValue(node.attrs.url) ?? '', + provider: toStringValue(node.attrs.provider), + kind: toStringValue(node.attrs.kind), + refId: toStringValue(node.attrs.refId), + title: toStringValue(node.attrs.title), + subtitle: toStringValue(node.attrs.subtitle), + icon: toStringValue(node.attrs.icon), + embedUrl: toStringValue(node.attrs.embedUrl), + metadata: toStringValue(node.attrs.metadata) ?? '{}' + } +} + +function extractTaskBody(taskNode: ProseMirrorNode): { + title: string + references: PageTaskReferenceSnapshot[] +} { + const textParts: string[] = [] + const references: PageTaskReferenceSnapshot[] = [] + + const visit = (node: ProseMirrorNode): void => { + node.forEach((child) => { + if (child.type.name === 'taskList' || child.type.name === 'taskItem') { + return + } + + const reference = readSmartReference(child) + if (reference) { + references.push(reference) + return + } + + if (child.isText && child.text) { + textParts.push(child.text) + return + } + + visit(child) + }) + } + + visit(taskNode) + + const title = + textParts.join(' ').replace(/\s+/g, ' ').trim() || + references + .map((reference) => reference.title ?? reference.refId ?? reference.url) + .join(' ') + .trim() || + 'Untitled task' + + return { title, references } +} + +function collectPageTasksFromNode( + node: ProseMirrorNode, + pos: number, + parentTaskId: string | null, + parentPath: number[], + tasks: PageTaskSnapshot[], + attrUpdates: TaskAttrUpdate[] +): void { + let taskIndex = 0 + + node.forEach((child, offset) => { + const childPos = pos + offset + (node.type.name === 'doc' ? 0 : 1) + + if (child.type.name === 'taskItem') { + const taskId = toStringValue(child.attrs.taskId) ?? generateId('task') + const blockId = toStringValue(child.attrs.blockId) ?? generateId('block') + const path = [...parentPath, taskIndex] + taskIndex += 1 + + if (taskId !== child.attrs.taskId || blockId !== child.attrs.blockId) { + attrUpdates.push({ + pos: childPos, + attrs: { + ...child.attrs, + taskId, + blockId + } + }) + } + + const { title, references } = extractTaskBody(child) + + tasks.push({ + taskId, + blockId, + title, + completed: Boolean(child.attrs.checked), + parentTaskId, + sortKey: buildSortKey(path), + references + }) + + collectPageTasksFromNode(child, childPos, taskId, path, tasks, attrUpdates) + return + } + + collectPageTasksFromNode(child, childPos, parentTaskId, parentPath, tasks, attrUpdates) + }) +} + +export function collectPageTasks(doc: ProseMirrorNode): { + tasks: PageTaskSnapshot[] + attrUpdates: TaskAttrUpdate[] +} { + const tasks: PageTaskSnapshot[] = [] + const attrUpdates: TaskAttrUpdate[] = [] + + collectPageTasksFromNode(doc, 0, null, [], tasks, attrUpdates) + + return { tasks, attrUpdates } +} + +export function ensurePageTaskAttrs(editor: Editor): boolean { + const { attrUpdates } = collectPageTasks(editor.state.doc) + if (attrUpdates.length === 0) return false + + let tr = editor.state.tr + + for (const update of attrUpdates) { + tr = tr.setNodeMarkup(update.pos, undefined, update.attrs) + } + + if (!tr.docChanged) return false + + editor.view.dispatch(tr) + return true +} + +export function getPageTasksSnapshot(editor: Editor): PageTaskSnapshot[] { + return collectPageTasks(editor.state.doc).tasks +} diff --git a/packages/editor/src/react.ts b/packages/editor/src/react.ts index 24d8cf62b..1741158c7 100644 --- a/packages/editor/src/react.ts +++ b/packages/editor/src/react.ts @@ -84,6 +84,7 @@ export type { } from './hooks/useEditorExtensions' export { useSlashCommands } from './hooks/useSlashCommands' export type { SlashCommandContribution, UseSlashCommandsOptions } from './hooks/useSlashCommands' +export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' // Re-export hooks from @tiptap/react for convenience export { useEditor, EditorContent } from '@tiptap/react' diff --git a/packages/react/src/hooks/usePageTaskSync.test.tsx b/packages/react/src/hooks/usePageTaskSync.test.tsx new file mode 100644 index 000000000..3d3ee30d3 --- /dev/null +++ b/packages/react/src/hooks/usePageTaskSync.test.tsx @@ -0,0 +1,143 @@ +import type { DID } from '@xnetjs/core' +import { renderHook, act, waitFor } from '@testing-library/react' +import { ExternalReferenceSchema, MemoryNodeStorageAdapter, TaskSchema } from '@xnetjs/data' +import { generateIdentity, type Identity } from '@xnetjs/identity' +import React, { type ReactNode, useMemo } from 'react' +import { describe, expect, it, beforeEach } from 'vitest' +import { XNetProvider } from '../context' +import { usePageTaskSync } from './usePageTaskSync' +import { useQuery } from './useQuery' + +describe('usePageTaskSync', () => { + let identityResult: { identity: Identity; privateKey: Uint8Array } + let did: DID + let storage: MemoryNodeStorageAdapter + + beforeEach(() => { + identityResult = generateIdentity() + did = identityResult.identity.did as DID + storage = new MemoryNodeStorageAdapter() + }) + + function createWrapper() { + const currentStorage = storage + const currentDid = did + const currentKey = identityResult.privateKey + + return function Wrapper({ children }: { children: ReactNode }) { + const stableStorage = useMemo(() => currentStorage, []) + + return ( + + {children} + + ) + } + } + + it('creates, updates, and archives page-backed tasks from editor snapshots', async () => { + const wrapper = createWrapper() + + const { result } = renderHook( + () => ({ + sync: usePageTaskSync({ pageId: 'page-1', debounceMs: 0 }), + tasks: useQuery(TaskSchema, { where: { page: 'page-1' } }), + references: useQuery(ExternalReferenceSchema) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.tasks.loading).toBe(false) + expect(result.current.references.loading).toBe(false) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task_parent', + blockId: 'block_parent', + title: 'Parent task', + completed: false, + parentTaskId: null, + sortKey: '0000', + references: [] + }, + { + taskId: 'task_child', + blockId: 'block_child', + title: 'Child task', + completed: true, + parentTaskId: 'task_parent', + sortKey: '0000.0000', + references: [ + { + url: 'https://github.com/openai/openai/issues/123', + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123', + title: 'Issue #123', + subtitle: 'openai/openai', + icon: 'GH', + embedUrl: null, + metadata: '{"repo":"openai/openai"}' + } + ] + } + ]) + }) + + await waitFor(() => { + expect(result.current.tasks.data).toHaveLength(2) + }) + + const childTask = result.current.tasks.data.find((task) => task.id === 'task_child') + + expect(childTask).toMatchObject({ + completed: true, + status: 'done', + page: 'page-1', + parent: 'task_parent', + anchorBlockId: 'block_child', + sortKey: '0000.0000' + }) + + await waitFor(() => { + expect(result.current.references.data).toHaveLength(1) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task_parent', + blockId: 'block_parent', + title: 'Parent task renamed', + completed: true, + parentTaskId: null, + sortKey: '0000', + references: [] + } + ]) + }) + + await waitFor(() => { + const parentTask = result.current.tasks.data.find((task) => task.id === 'task_parent') + const removedChild = result.current.tasks.data.find((task) => task.id === 'task_child') + + expect(parentTask).toMatchObject({ + title: 'Parent task renamed', + completed: true, + status: 'done' + }) + expect(removedChild).toBeUndefined() + expect(result.current.tasks.data).toHaveLength(1) + }) + }) +}) diff --git a/packages/react/src/hooks/usePageTaskSync.ts b/packages/react/src/hooks/usePageTaskSync.ts new file mode 100644 index 000000000..b606f8ea0 --- /dev/null +++ b/packages/react/src/hooks/usePageTaskSync.ts @@ -0,0 +1,331 @@ +/** + * usePageTaskSync - Reconcile page task rows with Task nodes. + * + * Checklist items inside page editors remain the inline editing surface, while + * Task nodes stay the canonical cross-surface records for querying and reuse. + */ +import type { InferCreateProps } from '@xnetjs/data' +import { ExternalReferenceSchema, TaskSchema } from '@xnetjs/data' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMutate } from './useMutate' +import { useQuery } from './useQuery' + +export interface PageTaskReferenceInput { + url: string + provider: string | null + kind: string | null + refId: string | null + title: string | null + subtitle: string | null + icon: string | null + embedUrl: string | null + metadata: string +} + +export interface PageTaskInput { + taskId: string + blockId: string + title: string + completed: boolean + parentTaskId: string | null + sortKey: string + references: PageTaskReferenceInput[] +} + +export interface UsePageTaskSyncOptions { + pageId: string | null + debounceMs?: number +} + +export interface UsePageTaskSyncResult { + handleTasksChange: (tasks: PageTaskInput[]) => void + syncing: boolean + error: Error | null +} + +const DEFAULT_DEBOUNCE_MS = 250 + +type ExternalReferenceCreate = InferCreateProps<(typeof ExternalReferenceSchema)['_properties']> +type ExternalReferenceProvider = NonNullable +type ExternalReferenceKind = NonNullable +type TaskCreate = InferCreateProps<(typeof TaskSchema)['_properties']> +type TaskStatus = NonNullable + +function arraysEqual(a: string[] | undefined, b: string[]): boolean { + if (!Array.isArray(a)) return b.length === 0 + if (a.length !== b.length) return false + + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) return false + } + + return true +} + +function stableHash(input: string): string { + let hash = 2166136261 + + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + + return (hash >>> 0).toString(36) +} + +function computeExternalReferenceId(taskId: string, reference: PageTaskReferenceInput): string { + const identity = [ + taskId, + reference.provider ?? 'generic', + reference.kind ?? 'link', + reference.refId ?? '', + reference.url + ].join('|') + + return `external_reference_${stableHash(identity)}` +} + +function getNextStatus(currentStatus: TaskStatus | undefined, completed: boolean): TaskStatus { + if (completed) return 'done' + if (!currentStatus || currentStatus === 'done') return 'todo' + return currentStatus +} + +function normalizeProvider(provider: string | null): ExternalReferenceProvider { + switch (provider) { + case 'github': + case 'figma': + case 'youtube': + case 'loom': + case 'vimeo': + case 'codesandbox': + case 'spotify': + case 'twitter': + return provider + default: + return 'generic' + } +} + +function normalizeKind(kind: string | null): ExternalReferenceKind { + switch (kind) { + case 'issue': + case 'pull-request': + case 'design': + case 'video': + case 'sandbox': + case 'social': + case 'audio': + return kind + default: + return 'link' + } +} + +export function usePageTaskSync({ + pageId, + debounceMs = DEFAULT_DEBOUNCE_MS +}: UsePageTaskSyncOptions): UsePageTaskSyncResult { + const { create, update, remove, restore } = useMutate() + const { data: existingTasks } = useQuery(TaskSchema, { + where: { page: pageId ?? '__page_task_sync_disabled__' }, + includeDeleted: true + }) + const taskSnapshotsRef = useRef([]) + const [revision, setRevision] = useState(0) + const [syncing, setSyncing] = useState(false) + const [error, setError] = useState(null) + + const existingTaskMap = useMemo(() => { + return new Map(existingTasks.map((task) => [task.id, task])) + }, [existingTasks]) + + const handleTasksChange = useCallback((tasks: PageTaskInput[]) => { + taskSnapshotsRef.current = tasks + setRevision((value) => value + 1) + }, []) + + useEffect(() => { + if (!pageId) return + + let cancelled = false + + const timer = setTimeout(() => { + const run = async () => { + const currentTasks = taskSnapshotsRef.current + const nextTaskIds = new Set(currentTasks.map((task) => task.taskId)) + const tasksToCreate: Array<{ + id: string + data: TaskCreate + }> = [] + const tasksToRestore: string[] = [] + const taskUpdates: Array<{ id: string; data: Record }> = [] + const taskDeletes: string[] = [] + const referenceUpserts: Array<{ + id: string + data: ExternalReferenceCreate + }> = [] + + for (const task of currentTasks) { + const existingTask = existingTaskMap.get(task.taskId) + const referenceIds = task.references.map((reference) => { + const id = computeExternalReferenceId(task.taskId, reference) + + referenceUpserts.push({ + id, + data: { + url: reference.url, + provider: normalizeProvider(reference.provider), + kind: normalizeKind(reference.kind), + ...(reference.refId ? { refId: reference.refId } : {}), + title: reference.title ?? reference.refId ?? reference.url, + ...(reference.subtitle ? { subtitle: reference.subtitle } : {}), + ...(reference.icon ? { icon: reference.icon } : {}), + ...(reference.embedUrl ? { embedUrl: reference.embedUrl } : {}), + metadata: reference.metadata + } + }) + + return id + }) + + if (!existingTask) { + tasksToCreate.push({ + id: task.taskId, + data: { + title: task.title, + completed: task.completed, + status: getNextStatus(undefined, task.completed), + parent: task.parentTaskId ?? undefined, + page: pageId, + anchorBlockId: task.blockId, + sortKey: task.sortKey, + source: 'page', + references: referenceIds + } + }) + continue + } + + if (existingTask.deleted) { + tasksToRestore.push(task.taskId) + } + + const nextStatus = getNextStatus( + typeof existingTask.status === 'string' + ? (existingTask.status as TaskStatus) + : undefined, + task.completed + ) + + const updateData: Record = {} + + if (existingTask.title !== task.title) updateData.title = task.title + if (existingTask.completed !== task.completed) updateData.completed = task.completed + if (existingTask.status !== nextStatus) updateData.status = nextStatus + if ((existingTask.parent ?? null) !== task.parentTaskId) { + updateData.parent = task.parentTaskId ?? undefined + } + if (existingTask.page !== pageId) updateData.page = pageId + if (existingTask.anchorBlockId !== task.blockId) updateData.anchorBlockId = task.blockId + if (existingTask.sortKey !== task.sortKey) updateData.sortKey = task.sortKey + if (existingTask.source !== 'page') updateData.source = 'page' + if (!arraysEqual(existingTask.references, referenceIds)) { + updateData.references = referenceIds + } + + if (Object.keys(updateData).length > 0) { + taskUpdates.push({ + id: task.taskId, + data: updateData + }) + } + } + + for (const existingTask of existingTasks) { + if (existingTask.deleted) continue + if (nextTaskIds.has(existingTask.id)) continue + + taskDeletes.push(existingTask.id) + } + + if ( + tasksToCreate.length === 0 && + tasksToRestore.length === 0 && + taskUpdates.length === 0 && + taskDeletes.length === 0 && + referenceUpserts.length === 0 + ) { + if (!cancelled) { + setSyncing(false) + setError(null) + } + return + } + + if (!cancelled) { + setSyncing(true) + setError(null) + } + + try { + for (const taskId of tasksToRestore) { + await restore(taskId) + } + + for (const task of tasksToCreate) { + await create(TaskSchema, task.data, task.id) + } + + for (const task of taskUpdates) { + await update(TaskSchema, task.id, task.data) + } + + for (const taskId of taskDeletes) { + await remove(taskId) + } + + for (const reference of referenceUpserts) { + try { + await update(ExternalReferenceSchema, reference.id, reference.data) + } catch { + await create(ExternalReferenceSchema, reference.data, reference.id) + } + } + + if (!cancelled) { + setSyncing(false) + } + } catch (err) { + if (!cancelled) { + setSyncing(false) + setError(err instanceof Error ? err : new Error(String(err))) + } + } + } + + void run() + }, debounceMs) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [ + create, + debounceMs, + existingTaskMap, + existingTasks, + pageId, + remove, + restore, + revision, + update + ]) + + return { + handleTasksChange, + syncing, + error + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6091071d3..ef88650d8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -85,6 +85,13 @@ export { type SyncStatus, type PresenceUser } from './hooks/useNode' +export { + usePageTaskSync, + type PageTaskInput, + type PageTaskReferenceInput, + type UsePageTaskSyncOptions, + type UsePageTaskSyncResult +} from './hooks/usePageTaskSync' // ============================================================================= // Database Hooks From 3e168d94dfe4478009d13cb0028146a4073f9ebb Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 18:02:38 -0800 Subject: [PATCH 06/12] feat(tasks): add inline assignees and due dates to page tasks - add rich-text @mention suggestions that store canonical DIDs in inline task chips - add inline due-date chips and sync both assignees and due dates into page-backed Task nodes - extend task metadata tests and check off the completed exploration checklist items --- .../src/renderer/components/PageView.tsx | 26 +++ apps/web/src/components/Editor.tsx | 7 +- apps/web/src/routes/doc.$docId.tsx | 26 +++ ...S_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md | 12 +- .../editor/src/components/FloatingToolbar.tsx | 95 +++++++- .../editor/src/components/RichTextEditor.tsx | 23 +- .../editor/src/components/TaskMentionMenu.tsx | 124 +++++++++++ .../editor/src/components/editor-ux-state.ts | 5 +- packages/editor/src/extensions.ts | 3 + .../page-tasks/PageTaskItemExtension.test.ts | 33 ++- .../editor/src/extensions/page-tasks/index.ts | 28 ++- .../task-metadata/TaskDueDateExtension.ts | 195 +++++++++++++++++ .../task-metadata/TaskMentionExtension.ts | 206 ++++++++++++++++++ .../TaskMetadataExtension.test.ts | 92 ++++++++ .../src/extensions/task-metadata/index.ts | 8 + packages/editor/src/react.ts | 1 + packages/editor/src/styles/editor.css | 31 +++ .../react/src/hooks/usePageTaskSync.test.tsx | 18 +- packages/react/src/hooks/usePageTaskSync.ts | 37 ++++ 19 files changed, 950 insertions(+), 20 deletions(-) create mode 100644 packages/editor/src/components/TaskMentionMenu.tsx create mode 100644 packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts create mode 100644 packages/editor/src/extensions/task-metadata/TaskMentionExtension.ts create mode 100644 packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts create mode 100644 packages/editor/src/extensions/task-metadata/index.ts diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index 193e64706..dd4a98f23 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -102,6 +102,31 @@ export function PageView({ docId }: PageViewProps) { did: did ?? undefined }) const { handleTasksChange } = usePageTaskSync({ pageId: docId }) + const mentionSuggestions = useMemo(() => { + const suggestions = new Map< + string, + { id: string; label: string; subtitle?: string; color?: string } + >() + + const addSuggestion = ( + entry: { did: string; name?: string; color?: string } | null | undefined, + isLocal = false + ) => { + if (!entry?.did || suggestions.has(entry.did)) return + + suggestions.set(entry.did, { + id: entry.did, + label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, + subtitle: isLocal ? 'You' : entry.did, + color: entry.color + }) + } + + addSuggestion(did ? { did } : null, true) + presence.forEach((user) => addSuggestion(user)) + + return Array.from(suggestions.values()) + }, [did, presence]) // ─── Comments Integration ───────────────────────────────────────────────────── @@ -748,6 +773,7 @@ export function PageView({ docId }: PageViewProps) { extensions={allExtensions} onCreateComment={handleCreateComment} onEditorReady={handleEditorReady} + mentionSuggestions={mentionSuggestions} onPageTasksChange={handleTasksChange} /> diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 147ffc8ac..edeeea888 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -11,7 +11,8 @@ import { useFileUpload, useFileDownload, type Editor as TipTapEditor, - type PageTaskSnapshot + type PageTaskSnapshot, + type TaskMentionSuggestion } from '@xnetjs/editor/react' interface Props { @@ -25,6 +26,8 @@ interface Props { extensions?: any[] /** Callback when editor is ready */ onEditorReady?: (editor: TipTapEditor) => void + /** People that can be inserted as mentions */ + mentionSuggestions?: TaskMentionSuggestion[] /** Callback for page-backed task snapshots */ onPageTasksChange?: (tasks: PageTaskSnapshot[]) => void /** Callback for creating a comment */ @@ -38,6 +41,7 @@ export function Editor({ onNavigate, extensions, onEditorReady, + mentionSuggestions, onPageTasksChange, onCreateComment }: Props) { @@ -60,6 +64,7 @@ export function Editor({ onFileDownload={onFileDownload ?? undefined} extensions={extensions} onEditorReady={onEditorReady} + mentionSuggestions={mentionSuggestions} onPageTasksChange={onPageTasksChange} onCreateComment={onCreateComment} /> diff --git a/apps/web/src/routes/doc.$docId.tsx b/apps/web/src/routes/doc.$docId.tsx index c206f1538..f4f866a83 100644 --- a/apps/web/src/routes/doc.$docId.tsx +++ b/apps/web/src/routes/doc.$docId.tsx @@ -77,6 +77,31 @@ function DocumentPage() { did: did ?? undefined }) const { handleTasksChange } = usePageTaskSync({ pageId: docId }) + const mentionSuggestions = useMemo(() => { + const suggestions = new Map< + string, + { id: string; label: string; subtitle?: string; color?: string } + >() + + const addSuggestion = ( + entry: { did: string; name?: string; color?: string } | null | undefined, + isLocal = false + ) => { + if (!entry?.did || suggestions.has(entry.did)) return + + suggestions.set(entry.did, { + id: entry.did, + label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, + subtitle: isLocal ? 'You' : entry.did, + color: entry.color + }) + } + + addSuggestion(did ? { did } : null, true) + presence.forEach((user) => addSuggestion(user)) + + return Array.from(suggestions.values()) + }, [did, presence]) // ─── Comments Integration ───────────────────────────────────────────────────── @@ -599,6 +624,7 @@ function DocumentPage() { onNavigate={handleNavigate} extensions={commentExtensions} onEditorReady={handleEditorReady} + mentionSuggestions={mentionSuggestions} onPageTasksChange={handleTasksChange} onCreateComment={handleCreateComment} /> diff --git a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md index bf49894bb..204168700 100644 --- a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md +++ b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md @@ -694,15 +694,15 @@ Recommendation: - [x] Add `assignees` multi-person support. - [x] Add `page`, `anchorBlockId`, `sortKey`, and task relation fields. - [x] Build a custom TipTap task extension that stores `taskId`. -- [ ] Add rich-text mention support that resolves to DIDs. -- [ ] Add inline due-date and assignee chips to task rows. +- [x] Add rich-text mention support that resolves to DIDs. +- [x] Add inline due-date and assignee chips to task rows. - [x] Add inline smart-reference chips for supported URLs inside task lines. - [x] Reuse the existing embed provider registry for first-pass provider detection. - [x] Define normalized `ExternalReference` metadata shape and relation strategy. - [x] Create a page task reconciler that maps editor operations to node mutations. - [x] Persist parent/subtask relations from indentation. - [x] Decide delete semantics: archive vs detach vs hard delete. -- [ ] Add task-specific tests for page editing, indentation, reassignment, due-date edits, and URL-to-chip conversion. +- [x] Add task-specific tests for page editing, indentation, reassignment, due-date edits, and URL-to-chip conversion. ## Phase 2: Cross-surface views @@ -728,9 +728,9 @@ Recommendation: - [ ] Toggling a checkbox updates `Task.completed` and all subscribed task views. - [ ] Indenting/outdenting updates parent/subtask relations deterministically. - [ ] Reordering sibling tasks preserves stable order across peers. -- [ ] `@mention` insertion stores canonical DIDs, not only display strings. -- [ ] Multiple mentions can populate multiple assignees without duplicate entries. -- [ ] Editing due dates inline updates all task projections immediately. +- [x] `@mention` insertion stores canonical DIDs, not only display strings. +- [x] Multiple mentions can populate multiple assignees without duplicate entries. +- [x] Editing due dates inline updates all task projections immediately. - [ ] Pasting a GitHub, Figma, Loom, or YouTube URL into a task produces the expected inline chip or fallback link. - [ ] Structured references degrade gracefully when provider metadata is unavailable. - [ ] Full block embeds remain opt-in so task rows stay compact. diff --git a/packages/editor/src/components/FloatingToolbar.tsx b/packages/editor/src/components/FloatingToolbar.tsx index 3d9d5ed82..da7218c5a 100644 --- a/packages/editor/src/components/FloatingToolbar.tsx +++ b/packages/editor/src/components/FloatingToolbar.tsx @@ -8,6 +8,7 @@ import type { Editor } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' import { useRef, useCallback, type JSX } from 'react' import { captureTextAnchor } from '../extensions/comment' +import { getCurrentTaskDueDate } from '../extensions/task-metadata' import { cn } from '../utils' import { shouldShowDesktopToolbar, @@ -72,6 +73,57 @@ interface ToolbarButtonProps { isMobile: boolean } +function isInTaskItem(editor: Editor): boolean { + return editor.isActive('taskItem') +} + +function pickDate(initialValue: string | null): Promise { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'date' + input.value = initialValue ?? '' + input.style.position = 'fixed' + input.style.opacity = '0' + input.style.pointerEvents = 'none' + input.style.left = '-9999px' + input.style.top = '0' + + let settled = false + + const finish = (value: string | null) => { + if (settled) return + settled = true + input.remove() + resolve(value) + } + + input.addEventListener( + 'change', + () => { + finish(input.value || null) + }, + { once: true } + ) + + input.addEventListener( + 'blur', + () => { + requestAnimationFrame(() => finish(input.value || null)) + }, + { once: true } + ) + + document.body.appendChild(input) + input.focus() + + if (typeof input.showPicker === 'function') { + input.showPicker() + } else { + input.click() + } + }) +} + function ToolbarButton({ onClick, active, @@ -177,6 +229,19 @@ function ToolbarContent({ } }, [editor, onCreateComment]) + const handleInsertMention = useCallback(() => { + editor.chain().focus().insertContent('@').run() + }, [editor]) + + const handlePickDueDate = useCallback(async () => { + const selectedDate = await pickDate(getCurrentTaskDueDate(editor)) + if (!selectedDate) return + + editor.chain().focus().setTaskDueDate(selectedDate).run() + }, [editor]) + + const showTaskButtons = isInTaskItem(editor) + return ( <> {/* Text formatting */} @@ -295,6 +360,28 @@ function ToolbarContent({ > ☐ + {showTaskButtons && ( + + @ + + )} + {showTaskButtons && ( + { + void handlePickDueDate() + }} + active={getCurrentTaskDueDate(editor) !== null} + title="Set Due Date" + isMobile={isMobile} + > + 📅 + + )} @@ -397,10 +484,7 @@ function ToolbarContent({ {/* Mention/Link - mobile only (placeholder for future) */} { - // TODO: Open mention picker - editor.chain().focus().insertContent('@').run() - }} + onClick={handleInsertMention} active={false} title="Mention" mobileOnly @@ -501,7 +585,8 @@ function DesktopToolbar({ shouldShow={({ editor }) => { return shouldShowDesktopToolbar({ selectionShape, - inCodeBlock: editor.isActive('codeBlock') + inCodeBlock: editor.isActive('codeBlock'), + inTaskItem: editor.isActive('taskItem') }) }} className={cn( diff --git a/packages/editor/src/components/RichTextEditor.tsx b/packages/editor/src/components/RichTextEditor.tsx index dde58ecec..e5c031ceb 100644 --- a/packages/editor/src/components/RichTextEditor.tsx +++ b/packages/editor/src/components/RichTextEditor.tsx @@ -1,7 +1,12 @@ /** * RichTextEditor - Tiptap-based rich text editor with Yjs collaboration */ -import type { DatabaseViewType, PageTaskSnapshot, SlashCommandItem } from '../extensions' +import type { + DatabaseViewType, + PageTaskSnapshot, + SlashCommandItem, + TaskMentionSuggestion +} from '../extensions' import type { AnyExtension } from '@tiptap/core' import type { Awareness } from 'y-protocols/awareness' import type * as Y from 'yjs' @@ -32,6 +37,8 @@ import { EmbedExtension, DatabaseEmbedExtension, PageTaskItemExtension, + TaskMentionExtension, + TaskDueDateExtension, ensurePageTaskAttrs, getPageTasksSnapshot } from '../extensions' @@ -263,6 +270,10 @@ export interface RichTextEditorProps { * for advanced integrations like comment system. */ onEditorReady?: (editor: Editor) => void + /** + * People that can be inserted as inline rich-text mentions. + */ + mentionSuggestions?: TaskMentionSuggestion[] /** * Task snapshot handler for page-backed checklist reconciliation. * Called after task rows have stable ids and the editor view is in sync. @@ -343,11 +354,17 @@ export function RichTextEditor({ toolbarItems: additionalToolbarItems = [], slashCommands, onEditorReady, + mentionSuggestions = [], onPageTasksChange, onCreateComment }: RichTextEditorProps): JSX.Element { const cursorPluginRegisteredRef = useRef(false) const pageTaskSignatureRef = useRef('') + const mentionSuggestionsRef = useRef(mentionSuggestions) + + useEffect(() => { + mentionSuggestionsRef.current = mentionSuggestions + }, [mentionSuggestions]) // Get or create the content fragment for Yjs collaboration const fragment = ydoc.getXmlFragment(field) @@ -380,6 +397,10 @@ export function RichTextEditor({ PageTaskItemExtension.configure({ nested: true }), + TaskMentionExtension.configure({ + getSuggestions: () => mentionSuggestionsRef.current + }), + TaskDueDateExtension, Link.configure({ openOnClick: false, HTMLAttributes: { diff --git a/packages/editor/src/components/TaskMentionMenu.tsx b/packages/editor/src/components/TaskMentionMenu.tsx new file mode 100644 index 000000000..2dbf85f13 --- /dev/null +++ b/packages/editor/src/components/TaskMentionMenu.tsx @@ -0,0 +1,124 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react' +import { cn } from '../utils' + +export interface TaskMentionSuggestion { + id: string + label: string + subtitle?: string + color?: string +} + +interface TaskMentionMenuProps { + items: TaskMentionSuggestion[] + command: (item: TaskMentionSuggestion) => void +} + +export interface TaskMentionMenuRef { + onKeyDown: (event: KeyboardEvent) => boolean +} + +export const TaskMentionMenu = forwardRef( + function TaskMentionMenu({ items, command }, ref) { + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + setSelectedIndex(0) + }, [items]) + + const selectItem = useCallback( + (index: number) => { + const item = items[index] + if (item) { + command(item) + } + }, + [command, items] + ) + + useImperativeHandle(ref, () => ({ + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex((prev) => (prev - 1 + items.length) % items.length) + return true + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex((prev) => (prev + 1) % items.length) + return true + } + + if (event.key === 'Enter') { + event.preventDefault() + selectItem(selectedIndex) + return true + } + + return false + } + })) + + if (items.length === 0) { + return ( +
+

No matching people

+
+ ) + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ) + } +) diff --git a/packages/editor/src/components/editor-ux-state.ts b/packages/editor/src/components/editor-ux-state.ts index c2facf84e..b3ea5f690 100644 --- a/packages/editor/src/components/editor-ux-state.ts +++ b/packages/editor/src/components/editor-ux-state.ts @@ -171,7 +171,10 @@ export function useEditorUxState( export function shouldShowDesktopToolbar(opts: { selectionShape: SelectionShape inCodeBlock: boolean + inTaskItem?: boolean }): boolean { if (opts.inCodeBlock) return false - return opts.selectionShape === 'range' + return ( + opts.selectionShape === 'range' || (opts.selectionShape === 'collapsed' && !!opts.inTaskItem) + ) } diff --git a/packages/editor/src/extensions.ts b/packages/editor/src/extensions.ts index 6b8a4ba33..4fee71fcf 100644 --- a/packages/editor/src/extensions.ts +++ b/packages/editor/src/extensions.ts @@ -453,6 +453,9 @@ export { getPageTasksSnapshot } from './extensions/page-tasks' export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' +export { TaskMentionExtension, TaskDueDateExtension } from './extensions/task-metadata' +export type { TaskMentionSuggestion, TaskDueDateOptions } from './extensions/task-metadata' +export { formatTaskDueDateLabel, getCurrentTaskDueDate } from './extensions/task-metadata' // Database embed extension export { DatabaseEmbedExtension } from './extensions/database-embed' diff --git a/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts b/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts index 0a9b2dc7c..f3d534452 100644 --- a/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts +++ b/packages/editor/src/extensions/page-tasks/PageTaskItemExtension.test.ts @@ -3,10 +3,13 @@ import TaskList from '@tiptap/extension-task-list' import StarterKit from '@tiptap/starter-kit' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { SmartReferenceExtension } from '../smart-reference' +import { TaskDueDateExtension, TaskMentionExtension } from '../task-metadata' import { PageTaskItemExtension, collectPageTasks, ensurePageTaskAttrs } from './index' describe('PageTaskItemExtension', () => { let editor: Editor + const aliceDid = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK' + const bobDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH' beforeEach(() => { editor = new Editor({ @@ -15,7 +18,11 @@ describe('PageTaskItemExtension', () => { StarterKit, TaskList, PageTaskItemExtension.configure({ nested: true }), - SmartReferenceExtension + SmartReferenceExtension, + TaskMentionExtension.configure({ + getSuggestions: () => [] + }), + TaskDueDateExtension ], content: { type: 'doc', @@ -42,6 +49,26 @@ describe('PageTaskItemExtension', () => { type: 'paragraph', content: [ { type: 'text', text: 'Child task ' }, + { + type: 'taskMention', + attrs: { + id: aliceDid, + label: 'alice' + } + }, + { + type: 'taskMention', + attrs: { + id: bobDid, + label: 'bob' + } + }, + { + type: 'taskDueDate', + attrs: { + date: '2026-03-20' + } + }, { type: 'smartReference', attrs: { @@ -109,7 +136,9 @@ describe('PageTaskItemExtension', () => { title: 'Child task', completed: true, parentTaskId: tasks[0].taskId, - sortKey: '0000.0000' + sortKey: '0000.0000', + assignees: [aliceDid, bobDid], + dueDate: '2026-03-20' }) expect(tasks[1].references).toEqual([ expect.objectContaining({ diff --git a/packages/editor/src/extensions/page-tasks/index.ts b/packages/editor/src/extensions/page-tasks/index.ts index 1b61eefb1..736ca4fcb 100644 --- a/packages/editor/src/extensions/page-tasks/index.ts +++ b/packages/editor/src/extensions/page-tasks/index.ts @@ -21,6 +21,8 @@ export interface PageTaskSnapshot { completed: boolean parentTaskId: string | null sortKey: string + assignees: string[] + dueDate: string | null references: PageTaskReferenceSnapshot[] } @@ -89,10 +91,14 @@ function readSmartReference(node: ProseMirrorNode): PageTaskReferenceSnapshot | function extractTaskBody(taskNode: ProseMirrorNode): { title: string + assignees: string[] + dueDate: string | null references: PageTaskReferenceSnapshot[] } { const textParts: string[] = [] + const assignees: string[] = [] const references: PageTaskReferenceSnapshot[] = [] + let dueDate: string | null = null const visit = (node: ProseMirrorNode): void => { node.forEach((child) => { @@ -100,6 +106,22 @@ function extractTaskBody(taskNode: ProseMirrorNode): { return } + if (child.type.name === 'taskMention') { + const mentionId = toStringValue(child.attrs.id) + if (mentionId && !assignees.includes(mentionId)) { + assignees.push(mentionId) + } + return + } + + if (child.type.name === 'taskDueDate') { + const nextDueDate = toStringValue(child.attrs.date) + if (nextDueDate) { + dueDate = nextDueDate + } + return + } + const reference = readSmartReference(child) if (reference) { references.push(reference) @@ -125,7 +147,7 @@ function extractTaskBody(taskNode: ProseMirrorNode): { .trim() || 'Untitled task' - return { title, references } + return { title, assignees, dueDate, references } } function collectPageTasksFromNode( @@ -158,7 +180,7 @@ function collectPageTasksFromNode( }) } - const { title, references } = extractTaskBody(child) + const { title, assignees, dueDate, references } = extractTaskBody(child) tasks.push({ taskId, @@ -167,6 +189,8 @@ function collectPageTasksFromNode( completed: Boolean(child.attrs.checked), parentTaskId, sortKey: buildSortKey(path), + assignees, + dueDate, references }) diff --git a/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts b/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts new file mode 100644 index 000000000..c9a77a382 --- /dev/null +++ b/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts @@ -0,0 +1,195 @@ +import type { Editor } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Node, mergeAttributes } from '@tiptap/core' + +function normalizeDateString(value: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null + + const [year, month, day] = value.split('-').map(Number) + const normalized = new Date(year, month - 1, day) + + if ( + normalized.getFullYear() !== year || + normalized.getMonth() !== month - 1 || + normalized.getDate() !== day + ) { + return null + } + + return value +} + +function isTaskNodeName(name: string): boolean { + return name === 'taskItem' || name === 'taskList' +} + +function findCurrentTaskItem(editor: Editor): { node: ProseMirrorNode; pos: number } | null { + const { $from } = editor.state.selection + + for (let depth = $from.depth; depth >= 0; depth -= 1) { + if ($from.node(depth).type.name !== 'taskItem') continue + + return { + node: $from.node(depth), + pos: depth > 0 ? $from.before(depth) : 0 + } + } + + return null +} + +function findDueDateNode( + taskNode: ProseMirrorNode, + taskPos: number +): { + node: ProseMirrorNode + pos: number +} | null { + let match: { node: ProseMirrorNode; pos: number } | null = null + + const visit = (node: ProseMirrorNode, pos: number): void => { + node.forEach((child, offset) => { + if (match) return + + const childPos = pos + offset + 1 + if (isTaskNodeName(child.type.name)) return + + if (child.type.name === 'taskDueDate') { + match = { node: child, pos: childPos } + return + } + + visit(child, childPos) + }) + } + + visit(taskNode, taskPos) + return match +} + +function getTodayDateString(): string { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function formatTaskDueDateLabel(value: string): string { + const normalized = normalizeDateString(value) + if (!normalized) return 'Due date' + + const [year, month, day] = normalized.split('-').map(Number) + const formatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + ...(new Date().getFullYear() !== year ? { year: 'numeric' } : {}) + }) + + return `Due ${formatter.format(new Date(year, month - 1, day))}` +} + +export function getCurrentTaskDueDate(editor: Editor): string | null { + const currentTask = findCurrentTaskItem(editor) + if (!currentTask) return null + + const currentDueDate = findDueDateNode(currentTask.node, currentTask.pos) + if (!currentDueDate) return null + + return typeof currentDueDate.node.attrs.date === 'string' ? currentDueDate.node.attrs.date : null +} + +export interface TaskDueDateOptions { + HTMLAttributes: Record +} + +declare module '@tiptap/core' { + interface Commands { + taskDueDate: { + setTaskDueDate: (date: string) => ReturnType + clearTaskDueDate: () => ReturnType + } + } +} + +export const TaskDueDateExtension = Node.create({ + name: 'taskDueDate', + + inline: true, + + group: 'inline', + + atom: true, + + selectable: true, + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + addAttributes() { + return { + date: { default: null } + } + }, + + parseHTML() { + return [{ tag: 'time[data-task-due-date]' }] + }, + + renderHTML({ HTMLAttributes }) { + const date = typeof HTMLAttributes.date === 'string' ? HTMLAttributes.date : null + const normalized = date ? normalizeDateString(date) : null + const overdue = normalized !== null && normalized < getTodayDateString() + + return [ + 'time', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-task-due-date': '', + datetime: normalized, + ...(overdue ? { 'data-overdue': 'true' } : {}), + class: 'task-due-date' + }), + normalized ? formatTaskDueDateLabel(normalized) : 'Due date' + ] + }, + + addCommands() { + return { + setTaskDueDate: + (date: string) => + ({ editor, state, dispatch }) => { + const normalized = normalizeDateString(date) + if (!normalized) return false + + const dueDateNode = this.type.create({ date: normalized }) + const currentTask = findCurrentTaskItem(editor) + const existing = currentTask ? findDueDateNode(currentTask.node, currentTask.pos) : null + let tr = state.tr + + if (existing) { + tr = tr.replaceWith(existing.pos, existing.pos + existing.node.nodeSize, dueDateNode) + } else { + tr = tr.replaceSelectionWith(dueDateNode, false) + } + + dispatch?.(tr.scrollIntoView()) + return true + }, + clearTaskDueDate: + () => + ({ editor, state, dispatch }) => { + const currentTask = findCurrentTaskItem(editor) + const existing = currentTask ? findDueDateNode(currentTask.node, currentTask.pos) : null + if (!existing) return false + + dispatch?.( + state.tr.delete(existing.pos, existing.pos + existing.node.nodeSize).scrollIntoView() + ) + return true + } + } + } +}) diff --git a/packages/editor/src/extensions/task-metadata/TaskMentionExtension.ts b/packages/editor/src/extensions/task-metadata/TaskMentionExtension.ts new file mode 100644 index 000000000..5fe84a9e8 --- /dev/null +++ b/packages/editor/src/extensions/task-metadata/TaskMentionExtension.ts @@ -0,0 +1,206 @@ +import { Node, mergeAttributes } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' +import { ReactRenderer } from '@tiptap/react' +import Suggestion from '@tiptap/suggestion' +import tippy, { type Instance, type Props as TippyProps } from 'tippy.js' +import { + TaskMentionMenu, + type TaskMentionMenuRef, + type TaskMentionSuggestion +} from '../../components/TaskMentionMenu' + +const TaskMentionSuggestionPluginKey = new PluginKey('taskMentionSuggestion') + +function truncateDid(value: string): string { + return value.startsWith('did:') ? `${value.slice(0, 14)}...${value.slice(-6)}` : value +} + +function getDisplayLabel(item: TaskMentionSuggestion): string { + return item.label.trim() || truncateDid(item.id) +} + +function filterSuggestions(items: TaskMentionSuggestion[], query: string): TaskMentionSuggestion[] { + const search = query.toLowerCase().trim() + if (!search) return items.slice(0, 8) + + return items + .filter((item) => { + return ( + item.id.toLowerCase().includes(search) || + item.label.toLowerCase().includes(search) || + item.subtitle?.toLowerCase().includes(search) + ) + }) + .slice(0, 8) +} + +export interface TaskMentionOptions { + getSuggestions: () => TaskMentionSuggestion[] + HTMLAttributes: Record +} + +declare module '@tiptap/core' { + interface Commands { + taskMention: { + setTaskMention: (mention: TaskMentionSuggestion) => ReturnType + } + } +} + +export const TaskMentionExtension = Node.create({ + name: 'taskMention', + + inline: true, + + group: 'inline', + + atom: true, + + selectable: true, + + addOptions() { + return { + getSuggestions: () => [], + HTMLAttributes: {} + } + }, + + addAttributes() { + return { + id: { default: null }, + label: { default: null }, + subtitle: { default: null }, + color: { default: null } + } + }, + + parseHTML() { + return [{ tag: 'span[data-task-mention]' }] + }, + + renderHTML({ HTMLAttributes }) { + const label = + typeof HTMLAttributes.label === 'string' && HTMLAttributes.label.length > 0 + ? HTMLAttributes.label + : truncateDid(String(HTMLAttributes.id ?? '')) + + return [ + 'span', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-task-mention': '', + 'data-mention-id': HTMLAttributes.id, + class: 'task-mention' + }), + `@${label}` + ] + }, + + addCommands() { + return { + setTaskMention: + (mention: TaskMentionSuggestion) => + ({ commands }) => { + return commands.insertContent([ + { + type: 'taskMention', + attrs: { + id: mention.id, + label: getDisplayLabel(mention), + subtitle: mention.subtitle ?? null, + color: mention.color ?? null + } + }, + { + type: 'text', + text: ' ' + } + ]) + } + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + pluginKey: TaskMentionSuggestionPluginKey, + char: '@', + allowSpaces: false, + startOfLine: false, + items: ({ query }) => filterSuggestions(this.options.getSuggestions(), query), + command: ({ editor, range, props }) => { + editor.chain().focus().deleteRange(range).setTaskMention(props).run() + }, + render: () => { + let component: ReactRenderer | null = null + let popup: Instance[] | null = null + + return { + onStart: (props) => { + component = new ReactRenderer(TaskMentionMenu, { + props: { + items: props.items, + command: (item: TaskMentionSuggestion) => props.command(item) + }, + editor: props.editor + }) + + if (!props.clientRect) return + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + theme: 'slash-menu', + maxWidth: 'none', + popperOptions: { + modifiers: [ + { name: 'flip', enabled: true }, + { name: 'preventOverflow', enabled: true } + ] + } + }) + }, + + onUpdate(props) { + if (!component) return + + component.updateProps({ + items: props.items, + command: (item: TaskMentionSuggestion) => props.command(item) + }) + + if (props.clientRect && popup?.[0]) { + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect + }) + } + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + return true + } + + return component?.ref?.onKeyDown(props.event) ?? false + }, + + onExit() { + popup?.[0]?.destroy() + component?.destroy() + popup = null + component = null + } + } + } + }) + ] + } +}) + +export type { TaskMentionSuggestion } diff --git a/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts b/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts new file mode 100644 index 000000000..9c108c441 --- /dev/null +++ b/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts @@ -0,0 +1,92 @@ +import { Editor } from '@tiptap/core' +import TaskList from '@tiptap/extension-task-list' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { PageTaskItemExtension } from '../page-tasks' +import { TaskDueDateExtension, TaskMentionExtension } from './index' + +describe('task metadata extensions', () => { + let editor: Editor + const assignee = { + id: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + label: 'alice' + } + + beforeEach(() => { + editor = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + TaskList, + PageTaskItemExtension.configure({ nested: true }), + TaskMentionExtension.configure({ + getSuggestions: () => [assignee] + }), + TaskDueDateExtension + ], + content: { + type: 'doc', + content: [ + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: false }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Review implementation' }] + } + ] + } + ] + } + ] + } + }) + }) + + afterEach(() => { + editor.destroy() + }) + + it('inserts canonical mention nodes with DID ids', () => { + editor.commands.focus('end') + + expect(editor.commands.setTaskMention(assignee)).toBe(true) + + const mentions: Array<{ id?: string; label?: string }> = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'taskMention') { + mentions.push({ + id: node.attrs.id, + label: node.attrs.label + }) + } + }) + + expect(mentions).toEqual([ + { + id: assignee.id, + label: assignee.label + } + ]) + }) + + it('updates an existing due-date chip instead of duplicating it', () => { + editor.commands.focus('end') + + expect(editor.commands.setTaskDueDate('2026-03-20')).toBe(true) + expect(editor.commands.setTaskDueDate('2026-03-21')).toBe(true) + + const dueDates: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'taskDueDate' && typeof node.attrs.date === 'string') { + dueDates.push(node.attrs.date) + } + }) + + expect(dueDates).toEqual(['2026-03-21']) + }) +}) diff --git a/packages/editor/src/extensions/task-metadata/index.ts b/packages/editor/src/extensions/task-metadata/index.ts new file mode 100644 index 000000000..6eb808b03 --- /dev/null +++ b/packages/editor/src/extensions/task-metadata/index.ts @@ -0,0 +1,8 @@ +export { TaskMentionExtension } from './TaskMentionExtension' +export type { TaskMentionSuggestion } from './TaskMentionExtension' +export { + TaskDueDateExtension, + formatTaskDueDateLabel, + getCurrentTaskDueDate +} from './TaskDueDateExtension' +export type { TaskDueDateOptions } from './TaskDueDateExtension' diff --git a/packages/editor/src/react.ts b/packages/editor/src/react.ts index 1741158c7..06b9e522a 100644 --- a/packages/editor/src/react.ts +++ b/packages/editor/src/react.ts @@ -85,6 +85,7 @@ export type { export { useSlashCommands } from './hooks/useSlashCommands' export type { SlashCommandContribution, UseSlashCommandsOptions } from './hooks/useSlashCommands' export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' +export type { TaskMentionSuggestion } from './extensions/task-metadata' // Re-export hooks from @tiptap/react for convenience export { useEditor, EditorContent } from '@tiptap/react' diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index 26a873d87..0c60f6e29 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -227,6 +227,37 @@ background: rgb(239 68 68 / 0.08); } +/* Task metadata chips */ +.ProseMirror span[data-task-mention], +.ProseMirror time[data-task-due-date] { + @apply inline-flex items-center rounded-full px-2 py-0.5 no-underline; + border: 1px solid rgb(var(--editor-border)); + font-size: 0.8em; + font-weight: 500; + line-height: 1.2; + vertical-align: baseline; +} + +.ProseMirror span[data-task-mention] { + background: rgb(var(--editor-primary) / 0.08); + color: rgb(var(--editor-primary)); +} + +.ProseMirror time[data-task-due-date] { + background: rgb(var(--editor-warning) / 0.1); + color: rgb(146 64 14); +} + +.dark .ProseMirror time[data-task-due-date] { + color: rgb(253 230 138); +} + +.ProseMirror time[data-task-due-date][data-overdue='true'] { + background: rgb(var(--editor-destructive) / 0.12); + border-color: rgb(var(--editor-destructive) / 0.3); + color: rgb(var(--editor-destructive)); +} + /* Horizontal rule */ .ProseMirror hr { @apply my-8 border-t border-border; diff --git a/packages/react/src/hooks/usePageTaskSync.test.tsx b/packages/react/src/hooks/usePageTaskSync.test.tsx index 3d3ee30d3..56c8a4439 100644 --- a/packages/react/src/hooks/usePageTaskSync.test.tsx +++ b/packages/react/src/hooks/usePageTaskSync.test.tsx @@ -11,11 +11,13 @@ import { useQuery } from './useQuery' describe('usePageTaskSync', () => { let identityResult: { identity: Identity; privateKey: Uint8Array } let did: DID + let otherDid: DID let storage: MemoryNodeStorageAdapter beforeEach(() => { identityResult = generateIdentity() did = identityResult.identity.did as DID + otherDid = generateIdentity().identity.did as DID storage = new MemoryNodeStorageAdapter() }) @@ -68,6 +70,8 @@ describe('usePageTaskSync', () => { completed: false, parentTaskId: null, sortKey: '0000', + assignees: [did], + dueDate: '2026-03-19', references: [] }, { @@ -77,6 +81,8 @@ describe('usePageTaskSync', () => { completed: true, parentTaskId: 'task_parent', sortKey: '0000.0000', + assignees: [did, otherDid], + dueDate: '2026-03-20', references: [ { url: 'https://github.com/openai/openai/issues/123', @@ -106,7 +112,10 @@ describe('usePageTaskSync', () => { page: 'page-1', parent: 'task_parent', anchorBlockId: 'block_child', - sortKey: '0000.0000' + sortKey: '0000.0000', + assignee: did, + assignees: [did, otherDid], + dueDate: new Date(2026, 2, 20).getTime() }) await waitFor(() => { @@ -122,6 +131,8 @@ describe('usePageTaskSync', () => { completed: true, parentTaskId: null, sortKey: '0000', + assignees: [otherDid], + dueDate: '2026-03-25', references: [] } ]) @@ -134,7 +145,10 @@ describe('usePageTaskSync', () => { expect(parentTask).toMatchObject({ title: 'Parent task renamed', completed: true, - status: 'done' + status: 'done', + assignee: otherDid, + assignees: [otherDid], + dueDate: new Date(2026, 2, 25).getTime() }) expect(removedChild).toBeUndefined() expect(result.current.tasks.data).toHaveLength(1) diff --git a/packages/react/src/hooks/usePageTaskSync.ts b/packages/react/src/hooks/usePageTaskSync.ts index b606f8ea0..ea686e983 100644 --- a/packages/react/src/hooks/usePageTaskSync.ts +++ b/packages/react/src/hooks/usePageTaskSync.ts @@ -29,6 +29,8 @@ export interface PageTaskInput { completed: boolean parentTaskId: string | null sortKey: string + assignees: string[] + dueDate: string | null references: PageTaskReferenceInput[] } @@ -50,6 +52,8 @@ type ExternalReferenceProvider = NonNullable type TaskCreate = InferCreateProps<(typeof TaskSchema)['_properties']> type TaskStatus = NonNullable +type TaskAssignee = Exclude +type TaskAssignees = NonNullable function arraysEqual(a: string[] | undefined, b: string[]): boolean { if (!Array.isArray(a)) return b.length === 0 @@ -91,6 +95,22 @@ function getNextStatus(currentStatus: TaskStatus | undefined, completed: boolean return currentStatus } +function isDid(value: string): value is TaskAssignee { + return /^did:[a-z]+:[a-zA-Z0-9._:-]+$/.test(value) +} + +function normalizeAssignees(assignees: string[]): TaskAssignees { + return Array.from(new Set(assignees)).filter(isDid) +} + +function toDateTimestamp(date: string | null): number | undefined { + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return undefined + + const [year, month, day] = date.split('-').map(Number) + const timestamp = new Date(year, month - 1, day).getTime() + return Number.isNaN(timestamp) ? undefined : timestamp +} + function normalizeProvider(provider: string | null): ExternalReferenceProvider { switch (provider) { case 'github': @@ -168,6 +188,9 @@ export function usePageTaskSync({ for (const task of currentTasks) { const existingTask = existingTaskMap.get(task.taskId) + const assignees = normalizeAssignees(task.assignees) + const dueDate = toDateTimestamp(task.dueDate) + const primaryAssignee = assignees[0] const referenceIds = task.references.map((reference) => { const id = computeExternalReferenceId(task.taskId, reference) @@ -201,6 +224,9 @@ export function usePageTaskSync({ anchorBlockId: task.blockId, sortKey: task.sortKey, source: 'page', + assignee: primaryAssignee, + assignees, + dueDate, references: referenceIds } }) @@ -219,6 +245,8 @@ export function usePageTaskSync({ ) const updateData: Record = {} + const nextDueDate = dueDate + const nextPrimaryAssignee = primaryAssignee if (existingTask.title !== task.title) updateData.title = task.title if (existingTask.completed !== task.completed) updateData.completed = task.completed @@ -230,6 +258,15 @@ export function usePageTaskSync({ if (existingTask.anchorBlockId !== task.blockId) updateData.anchorBlockId = task.blockId if (existingTask.sortKey !== task.sortKey) updateData.sortKey = task.sortKey if (existingTask.source !== 'page') updateData.source = 'page' + if (!arraysEqual(existingTask.assignees, assignees)) { + updateData.assignees = assignees + } + if ((existingTask.assignee ?? undefined) !== nextPrimaryAssignee) { + updateData.assignee = nextPrimaryAssignee + } + if ((existingTask.dueDate ?? undefined) !== nextDueDate) { + updateData.dueDate = nextDueDate + } if (!arraysEqual(existingTask.references, referenceIds)) { updateData.references = referenceIds } From ceef02d42d382b8fe6fbf587b4e4afeed21a9f22 Mon Sep 17 00:00:00 2001 From: Chris Smothers Date: Thu, 5 Mar 2026 18:19:00 -0800 Subject: [PATCH 07/12] ci(pages): add branch-backed PR previews - switch production site publishing from artifact-based Pages deploys to a gh-pages branch - add pull request preview publishing under pr//app with PR comments - preserve production content while updating previews and clean preview folders on PR close - align the manual undeploy workflow with the new branch-backed Pages model --- .github/workflows/deploy-pr-preview.yml | 109 ++++++++++++++++++++++++ .github/workflows/deploy-site.yml | 63 ++++++++------ .github/workflows/remove-pr-preview.yml | 92 ++++++++++++++++++++ .github/workflows/undeploy-site.yml | 51 ++++++++--- 4 files changed, 277 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/deploy-pr-preview.yml create mode 100644 .github/workflows/remove-pr-preview.yml diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml new file mode 100644 index 000000000..ea6a5105e --- /dev/null +++ b/.github/workflows/deploy-pr-preview.yml @@ -0,0 +1,109 @@ +name: Deploy PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: pages-branch + cancel-in-progress: true + +jobs: + deploy-preview: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: ./.github/actions/setup + + - name: Build packages + run: pnpm build + + - name: Build web app preview + run: pnpm --filter xnet-web build + env: + VITE_BASE_PATH: /pr/${{ github.event.pull_request.number }}/app/ + VITE_USE_HASH_ROUTER: 'true' + + - name: Prepare preview tree + run: | + rm -rf /tmp/xnet-pr-preview + mkdir -p /tmp/xnet-pr-preview + cp -R apps/web/dist/. /tmp/xnet-pr-preview/ + cp /tmp/xnet-pr-preview/index.html /tmp/xnet-pr-preview/404.html + + - name: Publish preview to gh-pages + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + rm -rf /tmp/xnet-gh-pages + git fetch origin gh-pages || true + + if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages + else + git worktree add -B gh-pages /tmp/xnet-gh-pages HEAD + find /tmp/xnet-gh-pages -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp site/public/CNAME /tmp/xnet-gh-pages/CNAME + touch /tmp/xnet-gh-pages/.nojekyll + fi + + preview_dir="/tmp/xnet-gh-pages/pr/${{ github.event.pull_request.number }}/app" + mkdir -p "$preview_dir" + rsync -a --delete /tmp/xnet-pr-preview/ "$preview_dir/" + + cd /tmp/xnet-gh-pages + git add -A + + if git diff --cached --quiet; then + echo "No preview changes to publish." + exit 0 + fi + + git commit -m "deploy(preview): publish PR #${{ github.event.pull_request.number }} preview" + git push origin HEAD:gh-pages + + - name: Comment preview link + uses: actions/github-script@v7 + with: + script: | + const body = [ + '', + `Preview: https://xnet.fyi/pr/${context.payload.pull_request.number}/app/` + ].join('\n') + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + }) + + const existing = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes('') + ) + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }) + return + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body + }) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 826b3d49e..3312ad266 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -12,19 +12,18 @@ on: workflow_dispatch: permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: - group: pages + group: pages-branch cancel-in-progress: true jobs: - build: + deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: ./.github/actions/setup - name: Build packages @@ -51,25 +50,41 @@ jobs: run: pnpm build working-directory: site - - name: Copy web app to site/dist/app + - name: Prepare production site tree run: | - mkdir -p site/dist/app - cp -r apps/web/dist/* site/dist/app/ - # SPA fallback for GitHub Pages - cp site/dist/app/index.html site/dist/app/404.html + rm -rf /tmp/xnet-pages-root + mkdir -p /tmp/xnet-pages-root/app + cp -R site/dist/. /tmp/xnet-pages-root/ + cp -R apps/web/dist/. /tmp/xnet-pages-root/app/ + cp /tmp/xnet-pages-root/app/index.html /tmp/xnet-pages-root/app/404.html + cp site/public/CNAME /tmp/xnet-pages-root/CNAME + touch /tmp/xnet-pages-root/.nojekyll - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: site/dist + - name: Publish production site to gh-pages + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + rm -rf /tmp/xnet-gh-pages + git fetch origin gh-pages || true + + if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages + else + git worktree add -B gh-pages /tmp/xnet-gh-pages HEAD + find /tmp/xnet-gh-pages -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + fi + + rsync -a --delete --exclude pr /tmp/xnet-pages-root/ /tmp/xnet-gh-pages/ + mkdir -p /tmp/xnet-gh-pages/pr + + cd /tmp/xnet-gh-pages + git add -A + + if git diff --cached --quiet; then + echo "No production site changes to publish." + exit 0 + fi + + git commit -m "deploy(site): publish production site" + git push origin HEAD:gh-pages diff --git a/.github/workflows/remove-pr-preview.yml b/.github/workflows/remove-pr-preview.yml new file mode 100644 index 000000000..5b92e663f --- /dev/null +++ b/.github/workflows/remove-pr-preview.yml @@ -0,0 +1,92 @@ +name: Remove PR Preview + +on: + pull_request: + types: [closed] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: pages-branch + cancel-in-progress: true + +jobs: + remove-preview: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Remove preview from gh-pages + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + rm -rf /tmp/xnet-gh-pages + git fetch origin gh-pages || true + + if ! git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + echo "gh-pages branch does not exist yet." + exit 0 + fi + + git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages + + target="/tmp/xnet-gh-pages/pr/${{ github.event.pull_request.number }}" + + if [ ! -d "$target" ]; then + echo "Preview path already absent." + exit 0 + fi + + rm -rf "$target" + + cd /tmp/xnet-gh-pages + git add -A + + if git diff --cached --quiet; then + echo "No preview cleanup changes to publish." + exit 0 + fi + + git commit -m "deploy(preview): remove PR #${{ github.event.pull_request.number }} preview" + git push origin HEAD:gh-pages + + - name: Comment preview removal + uses: actions/github-script@v7 + with: + script: | + const body = [ + '', + `Preview removed for PR #${context.payload.pull_request.number}.` + ].join('\n') + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + }) + + const existing = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes('') + ) + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }) + return + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body + }) diff --git a/.github/workflows/undeploy-site.yml b/.github/workflows/undeploy-site.yml index c03f8cb50..29722c374 100644 --- a/.github/workflows/undeploy-site.yml +++ b/.github/workflows/undeploy-site.yml @@ -4,27 +4,50 @@ on: workflow_dispatch: permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: - group: pages + group: pages-branch cancel-in-progress: true jobs: undeploy: runs-on: ubuntu-latest - environment: - name: github-pages steps: - - name: Create empty site - run: mkdir -p empty && echo '

Site offline.

' > empty/index.html + - uses: actions/checkout@v4 - - name: Upload empty artifact - uses: actions/upload-pages-artifact@v3 - with: - path: empty + - name: Replace gh-pages contents with offline page + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Deploy empty page - uses: actions/deploy-pages@v4 + rm -rf /tmp/xnet-gh-pages + git fetch origin gh-pages || true + + if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + git worktree add -B gh-pages /tmp/xnet-gh-pages origin/gh-pages + else + git worktree add -B gh-pages /tmp/xnet-gh-pages HEAD + fi + + find /tmp/xnet-gh-pages -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp site/public/CNAME /tmp/xnet-gh-pages/CNAME + touch /tmp/xnet-gh-pages/.nojekyll + cat <<'EOF' > /tmp/xnet-gh-pages/index.html + + +

Site offline.

+ + + EOF + + cd /tmp/xnet-gh-pages + git add -A + + if git diff --cached --quiet; then + echo "No undeploy changes to publish." + exit 0 + fi + + git commit -m "deploy(site): publish offline placeholder" + git push origin HEAD:gh-pages From 257466288b0844123b19e9fc20acb9db7b5a239d Mon Sep 17 00:00:00 2001 From: crs Date: Thu, 5 Mar 2026 18:41:58 -0800 Subject: [PATCH 08/12] feat(tasks): add query-backed task views - add a useTasks hook for page and assignee task queries - fix page task sync UTC due dates and reference write order - add My Tasks and page task views in web and page task view in Electron - extend hook coverage for subscribed task view updates - check off the completed exploration items --- .../renderer/components/PageTasksPanel.tsx | 115 +++++++++++ .../src/renderer/components/PageView.tsx | 3 + apps/web/src/components/MyTasksPanel.tsx | 104 ++++++++++ apps/web/src/components/PageTasksPanel.tsx | 115 +++++++++++ apps/web/src/components/Sidebar.tsx | 3 + apps/web/src/routes/doc.$docId.tsx | 2 + ...S_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md | 6 +- .../react/src/hooks/usePageTaskSync.test.tsx | 4 +- packages/react/src/hooks/usePageTaskSync.ts | 19 +- packages/react/src/hooks/useTasks.test.tsx | 181 ++++++++++++++++++ packages/react/src/hooks/useTasks.ts | 154 +++++++++++++++ packages/react/src/index.ts | 6 + 12 files changed, 697 insertions(+), 15 deletions(-) create mode 100644 apps/electron/src/renderer/components/PageTasksPanel.tsx create mode 100644 apps/web/src/components/MyTasksPanel.tsx create mode 100644 apps/web/src/components/PageTasksPanel.tsx create mode 100644 packages/react/src/hooks/useTasks.test.tsx create mode 100644 packages/react/src/hooks/useTasks.ts diff --git a/apps/electron/src/renderer/components/PageTasksPanel.tsx b/apps/electron/src/renderer/components/PageTasksPanel.tsx new file mode 100644 index 000000000..a510a0cde --- /dev/null +++ b/apps/electron/src/renderer/components/PageTasksPanel.tsx @@ -0,0 +1,115 @@ +import { useTasks } from '@xnetjs/react' +import { Calendar, CheckSquare2, ChevronDown, ChevronRight, Square, Users } from 'lucide-react' +import { useMemo, useState } from 'react' + +interface PageTasksPanelProps { + pageId: string +} + +function formatDueDate(timestamp: number | undefined): string | null { + if (typeof timestamp !== 'number') return null + + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) +} + +function isOverdue(timestamp: number | undefined, completed: boolean): boolean { + return typeof timestamp === 'number' && !completed && timestamp < Date.now() +} + +export function PageTasksPanel({ pageId }: PageTasksPanelProps) { + const [expanded, setExpanded] = useState(true) + const { data: tasks, loading } = useTasks({ pageId }) + + const rows = useMemo(() => { + return tasks.map((task) => ({ + ...task, + depth: typeof task.sortKey === 'string' ? Math.max(task.sortKey.split('.').length - 1, 0) : 0 + })) + }, [tasks]) + + return ( +
+ + + {expanded && ( +
+ {loading ? ( +

Loading tasks...

+ ) : rows.length === 0 ? ( +

+ Checklist items on this page will appear here. +

+ ) : ( +
    + {rows.map((task) => { + const dueDateLabel = formatDueDate(task.dueDate) + const overdue = isOverdue(task.dueDate, Boolean(task.completed)) + const assigneeCount = Array.isArray(task.assignees) ? task.assignees.length : 0 + + return ( +
  • +
    + {task.completed ? ( + + ) : ( + + )} +
    +
    + {task.title} +
    +
    + {dueDateLabel && ( + + + {dueDateLabel} + + )} + {assigneeCount > 0 && ( + + + {assigneeCount} + + )} +
    +
    +
    +
  • + ) + })} +
+ )} +
+ )} +
+ ) +} diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index dd4a98f23..ded4d8b90 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -34,6 +34,7 @@ import { } from '@xnetjs/ui' import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react' import { DocumentHeader } from './DocumentHeader' +import { PageTasksPanel } from './PageTasksPanel' import { PresenceAvatars } from './PresenceAvatars' interface PageViewProps { @@ -790,6 +791,8 @@ export function PageView({ docId }: PageViewProps) { /> )} + + {/* Comments Sidebar */} diff --git a/apps/web/src/components/MyTasksPanel.tsx b/apps/web/src/components/MyTasksPanel.tsx new file mode 100644 index 000000000..6dd31b697 --- /dev/null +++ b/apps/web/src/components/MyTasksPanel.tsx @@ -0,0 +1,104 @@ +import { Link } from '@tanstack/react-router' +import { PageSchema } from '@xnetjs/data' +import { useIdentity, useQuery, useTasks } from '@xnetjs/react' +import { Calendar, CheckSquare2, ChevronDown, ChevronRight } from 'lucide-react' +import { useMemo, useState } from 'react' + +function formatDueDate(timestamp: number | undefined): string | null { + if (typeof timestamp !== 'number') return null + + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) +} + +function isOverdue(timestamp: number | undefined, completed: boolean): boolean { + return typeof timestamp === 'number' && !completed && timestamp < Date.now() +} + +export function MyTasksPanel() { + const { identity } = useIdentity() + const did = identity?.did ?? null + const [expanded, setExpanded] = useState(true) + const { data: tasks, loading } = useTasks({ assigneeDid: did, includeCompleted: false }) + const { data: pages } = useQuery(PageSchema, { limit: 200 }) + + const pageTitles = useMemo(() => { + return new Map(pages.map((page) => [page.id, page.title || 'Untitled'])) + }, [pages]) + + if (!did) return null + + const visibleTasks = tasks.slice(0, 8) + + return ( +
+ + + {expanded && ( +
+ {loading ? ( +
Loading assigned tasks...
+ ) : visibleTasks.length === 0 ? ( +
+ No incomplete tasks are assigned to you. +
+ ) : ( + visibleTasks.map((task) => { + const dueDateLabel = formatDueDate(task.dueDate) + const overdue = isOverdue(task.dueDate, Boolean(task.completed)) + const pageId = typeof task.page === 'string' ? task.page : null + const pageTitle = pageId ? pageTitles.get(pageId) : null + const content = ( +
+ +
+
{task.title}
+
+ {pageTitle && {pageTitle}} + {dueDateLabel && ( + + + {dueDateLabel} + + )} +
+
+
+ ) + + return pageId ? ( + + {content} + + ) : ( +
{content}
+ ) + }) + )} + + {tasks.length > visibleTasks.length && ( +
+ Showing {visibleTasks.length} of {tasks.length} tasks. +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/PageTasksPanel.tsx b/apps/web/src/components/PageTasksPanel.tsx new file mode 100644 index 000000000..a510a0cde --- /dev/null +++ b/apps/web/src/components/PageTasksPanel.tsx @@ -0,0 +1,115 @@ +import { useTasks } from '@xnetjs/react' +import { Calendar, CheckSquare2, ChevronDown, ChevronRight, Square, Users } from 'lucide-react' +import { useMemo, useState } from 'react' + +interface PageTasksPanelProps { + pageId: string +} + +function formatDueDate(timestamp: number | undefined): string | null { + if (typeof timestamp !== 'number') return null + + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) +} + +function isOverdue(timestamp: number | undefined, completed: boolean): boolean { + return typeof timestamp === 'number' && !completed && timestamp < Date.now() +} + +export function PageTasksPanel({ pageId }: PageTasksPanelProps) { + const [expanded, setExpanded] = useState(true) + const { data: tasks, loading } = useTasks({ pageId }) + + const rows = useMemo(() => { + return tasks.map((task) => ({ + ...task, + depth: typeof task.sortKey === 'string' ? Math.max(task.sortKey.split('.').length - 1, 0) : 0 + })) + }, [tasks]) + + return ( +
+ + + {expanded && ( +
+ {loading ? ( +

Loading tasks...

+ ) : rows.length === 0 ? ( +

+ Checklist items on this page will appear here. +

+ ) : ( +
    + {rows.map((task) => { + const dueDateLabel = formatDueDate(task.dueDate) + const overdue = isOverdue(task.dueDate, Boolean(task.completed)) + const assigneeCount = Array.isArray(task.assignees) ? task.assignees.length : 0 + + return ( +
  • +
    + {task.completed ? ( + + ) : ( + + )} +
    +
    + {task.title} +
    +
    + {dueDateLabel && ( + + + {dueDateLabel} + + )} + {assigneeCount > 0 && ( + + + {assigneeCount} + + )} +
    +
    +
    +
  • + ) + })} +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eba011254..cf1babfbb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { } from 'lucide-react' import { useState } from 'react' import { AddSharedDialog } from './AddSharedDialog' +import { MyTasksPanel } from './MyTasksPanel' type DocType = 'page' | 'database' | 'canvas' @@ -260,6 +261,8 @@ export function Sidebar() { {/* Document list */}
+ + {renderSection('page', pages || [], pagesLoading)} {renderSection('database', databases || [], databasesLoading)} {renderSection('canvas', canvases || [], canvasesLoading)} diff --git a/apps/web/src/routes/doc.$docId.tsx b/apps/web/src/routes/doc.$docId.tsx index f4f866a83..68f05ecd7 100644 --- a/apps/web/src/routes/doc.$docId.tsx +++ b/apps/web/src/routes/doc.$docId.tsx @@ -22,6 +22,7 @@ import { MessageSquare } from 'lucide-react' import { useState, useCallback, useMemo, useRef, useEffect } from 'react' import { BacklinksPanel } from '../components/BacklinksPanel' import { Editor as EditorComponent } from '../components/Editor' +import { PageTasksPanel } from '../components/PageTasksPanel' import { PresenceAvatars } from '../components/PresenceAvatars' import { ShareButton } from '../components/ShareButton' @@ -643,6 +644,7 @@ function DocumentPage() {
)} + diff --git a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md index 204168700..611d2da42 100644 --- a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md +++ b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md @@ -706,8 +706,8 @@ Recommendation: ## Phase 2: Cross-surface views -- [ ] Add `useTasks` / schema-query helpers for `TaskSchema`. -- [ ] Build "My tasks" and "Tasks on this page" views. +- [x] Add `useTasks` / schema-query helpers for `TaskSchema`. +- [x] Build "My tasks" and "Tasks on this page" views. - [ ] Add saved filters for assignee, due date, status, and hierarchy state. - [ ] Allow embedded task views inside pages. - [ ] Prototype board/calendar/timeline projections over direct `Task` queries. @@ -725,7 +725,7 @@ Recommendation: ## 🧪 Validation Checklist - [x] Creating a checklist item in a page creates exactly one `Task` node. -- [ ] Toggling a checkbox updates `Task.completed` and all subscribed task views. +- [x] Toggling a checkbox updates `Task.completed` and all subscribed task views. - [ ] Indenting/outdenting updates parent/subtask relations deterministically. - [ ] Reordering sibling tasks preserves stable order across peers. - [x] `@mention` insertion stores canonical DIDs, not only display strings. diff --git a/packages/react/src/hooks/usePageTaskSync.test.tsx b/packages/react/src/hooks/usePageTaskSync.test.tsx index 56c8a4439..02a8e1768 100644 --- a/packages/react/src/hooks/usePageTaskSync.test.tsx +++ b/packages/react/src/hooks/usePageTaskSync.test.tsx @@ -115,7 +115,7 @@ describe('usePageTaskSync', () => { sortKey: '0000.0000', assignee: did, assignees: [did, otherDid], - dueDate: new Date(2026, 2, 20).getTime() + dueDate: Date.UTC(2026, 2, 20) }) await waitFor(() => { @@ -148,7 +148,7 @@ describe('usePageTaskSync', () => { status: 'done', assignee: otherDid, assignees: [otherDid], - dueDate: new Date(2026, 2, 25).getTime() + dueDate: Date.UTC(2026, 2, 25) }) expect(removedChild).toBeUndefined() expect(result.current.tasks.data).toHaveLength(1) diff --git a/packages/react/src/hooks/usePageTaskSync.ts b/packages/react/src/hooks/usePageTaskSync.ts index ea686e983..8fb1d0f9a 100644 --- a/packages/react/src/hooks/usePageTaskSync.ts +++ b/packages/react/src/hooks/usePageTaskSync.ts @@ -107,7 +107,7 @@ function toDateTimestamp(date: string | null): number | undefined { if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return undefined const [year, month, day] = date.split('-').map(Number) - const timestamp = new Date(year, month - 1, day).getTime() + const timestamp = Date.UTC(year, month - 1, day) return Number.isNaN(timestamp) ? undefined : timestamp } @@ -306,6 +306,14 @@ export function usePageTaskSync({ } try { + for (const reference of referenceUpserts) { + try { + await update(ExternalReferenceSchema, reference.id, reference.data) + } catch { + await create(ExternalReferenceSchema, reference.data, reference.id) + } + } + for (const taskId of tasksToRestore) { await restore(taskId) } @@ -321,15 +329,6 @@ export function usePageTaskSync({ for (const taskId of taskDeletes) { await remove(taskId) } - - for (const reference of referenceUpserts) { - try { - await update(ExternalReferenceSchema, reference.id, reference.data) - } catch { - await create(ExternalReferenceSchema, reference.data, reference.id) - } - } - if (!cancelled) { setSyncing(false) } diff --git a/packages/react/src/hooks/useTasks.test.tsx b/packages/react/src/hooks/useTasks.test.tsx new file mode 100644 index 000000000..1b9b138ff --- /dev/null +++ b/packages/react/src/hooks/useTasks.test.tsx @@ -0,0 +1,181 @@ +import type { DID } from '@xnetjs/core' +import { renderHook, act, waitFor } from '@testing-library/react' +import { MemoryNodeStorageAdapter } from '@xnetjs/data' +import { generateIdentity, type Identity } from '@xnetjs/identity' +import React, { type ReactNode, useMemo } from 'react' +import { beforeEach, describe, expect, it } from 'vitest' +import { XNetProvider } from '../context' +import { usePageTaskSync } from './usePageTaskSync' +import { useTasks } from './useTasks' + +describe('useTasks', () => { + let identityResult: { identity: Identity; privateKey: Uint8Array } + let did: DID + let otherDid: DID + let storage: MemoryNodeStorageAdapter + + beforeEach(() => { + identityResult = generateIdentity() + did = identityResult.identity.did as DID + otherDid = generateIdentity().identity.did as DID + storage = new MemoryNodeStorageAdapter() + }) + + function createWrapper() { + const currentStorage = storage + const currentDid = did + const currentKey = identityResult.privateKey + + return function Wrapper({ children }: { children: ReactNode }) { + const stableStorage = useMemo(() => currentStorage, []) + + return ( + + {children} + + ) + } + } + + it('filters tasks by page and assignee while preserving task hierarchy', async () => { + const wrapper = createWrapper() + + const { result } = renderHook( + () => ({ + syncPage1: usePageTaskSync({ pageId: 'page-1', debounceMs: 0 }), + syncPage2: usePageTaskSync({ pageId: 'page-2', debounceMs: 0 }), + pageTasks: useTasks({ pageId: 'page-1' }), + myTasks: useTasks({ assigneeDid: did, includeCompleted: false }) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.pageTasks.loading).toBe(false) + expect(result.current.myTasks.loading).toBe(false) + }) + + await act(async () => { + result.current.syncPage1.handleTasksChange([ + { + taskId: 'task_parent', + blockId: 'block_parent', + title: 'Parent', + completed: false, + parentTaskId: null, + sortKey: '0000', + assignees: [did], + dueDate: null, + references: [] + }, + { + taskId: 'task_child', + blockId: 'block_child', + title: 'Child', + completed: false, + parentTaskId: 'task_parent', + sortKey: '0000.0000', + assignees: [otherDid], + dueDate: null, + references: [] + } + ]) + result.current.syncPage2.handleTasksChange([ + { + taskId: 'task_shared', + blockId: 'block_shared', + title: 'Shared', + completed: false, + parentTaskId: null, + sortKey: '0000', + assignees: [otherDid, did], + dueDate: '2026-03-21', + references: [] + } + ]) + }) + + await waitFor(() => { + expect(result.current.pageTasks.data).toHaveLength(2) + expect(result.current.myTasks.data).toHaveLength(2) + }) + + expect(result.current.pageTasks.data.map((task) => task.id)).toEqual([ + 'task_parent', + 'task_child' + ]) + expect(result.current.pageTasks.tree).toHaveLength(1) + expect(result.current.pageTasks.tree[0]?.task.id).toBe('task_parent') + expect(result.current.pageTasks.tree[0]?.children.map((child) => child.task.id)).toEqual([ + 'task_child' + ]) + + expect(result.current.myTasks.data.map((task) => task.id)).toEqual([ + 'task_shared', + 'task_parent' + ]) + }) + + it('updates subscribed task views when page task completion changes', async () => { + const wrapper = createWrapper() + + const { result } = renderHook( + () => ({ + sync: usePageTaskSync({ pageId: 'page-1', debounceMs: 0 }), + myTasks: useTasks({ assigneeDid: did, includeCompleted: false }) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.myTasks.loading).toBe(false) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task-1', + blockId: 'block-1', + title: 'Task 1', + completed: false, + parentTaskId: null, + sortKey: '0000', + assignees: [did], + dueDate: '2026-03-19', + references: [] + } + ]) + }) + + await waitFor(() => { + expect(result.current.myTasks.data.map((task) => task.id)).toEqual(['task-1']) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task-1', + blockId: 'block-1', + title: 'Task 1', + completed: true, + parentTaskId: null, + sortKey: '0000', + assignees: [did], + dueDate: '2026-03-19', + references: [] + } + ]) + }) + + await waitFor(() => { + expect(result.current.myTasks.data).toHaveLength(0) + }) + }) +}) diff --git a/packages/react/src/hooks/useTasks.ts b/packages/react/src/hooks/useTasks.ts new file mode 100644 index 000000000..d30aea17a --- /dev/null +++ b/packages/react/src/hooks/useTasks.ts @@ -0,0 +1,154 @@ +/** + * useTasks - Task-specific query helpers built on top of useQuery. + */ +import type { InferCreateProps } from '@xnetjs/data' +import { TaskSchema } from '@xnetjs/data' +import { useMemo } from 'react' +import { useQuery, type FlatNode } from './useQuery' + +type TaskNode = FlatNode<(typeof TaskSchema)['_properties']> +type TaskStatus = NonNullable['status']> + +export interface UseTasksOptions { + pageId?: string | null + assigneeDid?: string | null + includeCompleted?: boolean + statuses?: TaskStatus[] + parentTaskId?: string | null +} + +export interface TaskTreeItem { + task: TaskNode + depth: number + children: TaskTreeItem[] +} + +export interface UseTasksResult { + data: TaskNode[] + tree: TaskTreeItem[] + loading: boolean + error: Error | null + reload: () => void +} + +function compareOptionalNumbers(a: number | undefined, b: number | undefined): number { + if (a == null && b == null) return 0 + if (a == null) return 1 + if (b == null) return -1 + return a - b +} + +function sortTasks(tasks: TaskNode[], pageScoped: boolean): TaskNode[] { + return [...tasks].sort((a, b) => { + if (pageScoped) { + const aKey = typeof a.sortKey === 'string' ? a.sortKey : '' + const bKey = typeof b.sortKey === 'string' ? b.sortKey : '' + return aKey.localeCompare(bKey) || a.id.localeCompare(b.id) + } + + if (a.completed !== b.completed) { + return Number(a.completed) - Number(b.completed) + } + + const dueDateComparison = compareOptionalNumbers( + typeof a.dueDate === 'number' ? a.dueDate : undefined, + typeof b.dueDate === 'number' ? b.dueDate : undefined + ) + if (dueDateComparison !== 0) return dueDateComparison + + const updatedAtComparison = b.updatedAt - a.updatedAt + if (updatedAtComparison !== 0) return updatedAtComparison + + const aKey = typeof a.sortKey === 'string' ? a.sortKey : '' + const bKey = typeof b.sortKey === 'string' ? b.sortKey : '' + const aTitle = a.title ?? '' + const bTitle = b.title ?? '' + return aKey.localeCompare(bKey) || aTitle.localeCompare(bTitle) || a.id.localeCompare(b.id) + }) +} + +function matchesAssignee(task: TaskNode, assigneeDid: string | null | undefined): boolean { + if (!assigneeDid) return true + if (typeof task.assignee === 'string' && task.assignee === assigneeDid) return true + return Array.isArray(task.assignees) && task.assignees.map(String).includes(assigneeDid) +} + +function matchesStatus(task: TaskNode, statuses: TaskStatus[] | undefined): boolean { + if (!statuses || statuses.length === 0) return true + return typeof task.status === 'string' && statuses.includes(task.status as TaskStatus) +} + +function matchesParent(task: TaskNode, parentTaskId: string | null | undefined): boolean { + if (parentTaskId === undefined) return true + return (task.parent ?? null) === parentTaskId +} + +function buildTaskTree(tasks: TaskNode[]): TaskTreeItem[] { + const treeById = new Map() + const roots: TaskTreeItem[] = [] + + for (const task of tasks) { + treeById.set(task.id, { + task, + depth: 0, + children: [] + }) + } + + for (const task of tasks) { + const current = treeById.get(task.id) + if (!current) continue + + const parentId = task.parent ?? null + const parent = parentId ? treeById.get(parentId) : undefined + + if (!parent) { + roots.push(current) + continue + } + + current.depth = parent.depth + 1 + parent.children.push(current) + } + + return roots +} + +export function useTasks({ + pageId, + assigneeDid, + includeCompleted = true, + statuses, + parentTaskId +}: UseTasksOptions = {}): UseTasksResult { + const query = useQuery( + TaskSchema, + pageId + ? { + where: { page: pageId } + } + : {} + ) + + const tasks = useMemo(() => { + const filtered = query.data.filter((task) => { + if (!includeCompleted && task.completed) return false + if (!matchesAssignee(task, assigneeDid)) return false + if (!matchesStatus(task, statuses)) return false + if (!matchesParent(task, parentTaskId)) return false + return true + }) + + return sortTasks(filtered, Boolean(pageId)) + }, [assigneeDid, includeCompleted, pageId, parentTaskId, query.data, statuses]) + + const tree = useMemo(() => buildTaskTree(tasks), [tasks]) + + return { + data: tasks, + tree, + loading: query.loading, + error: query.error, + reload: query.reload + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ef88650d8..277b47750 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -92,6 +92,12 @@ export { type UsePageTaskSyncOptions, type UsePageTaskSyncResult } from './hooks/usePageTaskSync' +export { + useTasks, + type UseTasksOptions, + type UseTasksResult, + type TaskTreeItem +} from './hooks/useTasks' // ============================================================================= // Database Hooks From 9247fe86ebac56380bcd0e258ab260350c00dead Mon Sep 17 00:00:00 2001 From: crs Date: Thu, 5 Mar 2026 18:53:53 -0800 Subject: [PATCH 09/12] feat(tasks): embed saved task views in page documents - add a taskViewEmbed editor block with persisted scope, assignee, status, due-date, and hierarchy filters - wire embedded task rendering through the shared editor surface in web and desktop page views - extend task queries with due-date presets and update the exploration checklist for the shipped slice --- .../src/renderer/components/PageView.tsx | 13 ++ apps/web/src/components/Editor.tsx | 27 ++- apps/web/src/routes/doc.$docId.tsx | 1 + ...S_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md | 4 +- .../editor/src/components/RichTextEditor.tsx | 22 +- packages/editor/src/extensions.ts | 10 + .../src/extensions/slash-command/items.ts | 9 + .../TaskViewEmbedExtension.test.ts | 82 ++++++++ .../task-view-embed/TaskViewEmbedExtension.ts | 158 +++++++++++++++ .../task-view-embed/TaskViewEmbedNodeView.tsx | 190 ++++++++++++++++++ .../src/extensions/task-view-embed/index.ts | 11 + packages/editor/src/react.ts | 9 + .../src/components/TaskCollectionEmbed.tsx | 139 +++++++++++++ packages/react/src/hooks/useTasks.test.tsx | 89 ++++++++ packages/react/src/hooks/useTasks.ts | 34 +++- packages/react/src/index.ts | 4 + 16 files changed, 796 insertions(+), 6 deletions(-) create mode 100644 packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts create mode 100644 packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts create mode 100644 packages/editor/src/extensions/task-view-embed/TaskViewEmbedNodeView.tsx create mode 100644 packages/editor/src/extensions/task-view-embed/index.ts create mode 100644 packages/react/src/components/TaskCollectionEmbed.tsx diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index ded4d8b90..c1bfc9469 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -18,6 +18,7 @@ import { type Editor } from '@xnetjs/editor/react' import { + TaskCollectionEmbed, useNode, useIdentity, useEditorExtensionsSafe, @@ -776,6 +777,18 @@ export function PageView({ docId }: PageViewProps) { onEditorReady={handleEditorReady} mentionSuggestions={mentionSuggestions} onPageTasksChange={handleTasksChange} + taskViewPageId={docId} + renderTaskView={({ viewConfig, currentPageId }) => ( + + )} /> {/* Orphaned Comments Section */} diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index edeeea888..4708f2fec 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -12,8 +12,11 @@ import { useFileDownload, type Editor as TipTapEditor, type PageTaskSnapshot, - type TaskMentionSuggestion + type TaskMentionSuggestion, + type TaskViewConfig, + type TaskViewEmbedType } from '@xnetjs/editor/react' +import { TaskCollectionEmbed } from '@xnetjs/react' interface Props { doc: Y.Doc @@ -30,6 +33,8 @@ interface Props { mentionSuggestions?: TaskMentionSuggestion[] /** Callback for page-backed task snapshots */ onPageTasksChange?: (tasks: PageTaskSnapshot[]) => void + /** Current page ID for embedded task views */ + pageId?: string | null /** Callback for creating a comment */ onCreateComment?: (anchorData: string) => Promise } @@ -43,6 +48,7 @@ export function Editor({ onEditorReady, mentionSuggestions, onPageTasksChange, + pageId, onCreateComment }: Props) { const onImageUpload = useImageUpload() @@ -66,6 +72,25 @@ export function Editor({ onEditorReady={onEditorReady} mentionSuggestions={mentionSuggestions} onPageTasksChange={onPageTasksChange} + taskViewPageId={pageId ?? null} + renderTaskView={({ + viewConfig, + currentPageId + }: { + viewType: TaskViewEmbedType + viewConfig: TaskViewConfig + currentPageId: string | null + }) => ( + + )} onCreateComment={onCreateComment} /> ) diff --git a/apps/web/src/routes/doc.$docId.tsx b/apps/web/src/routes/doc.$docId.tsx index 68f05ecd7..a6b0d2d2c 100644 --- a/apps/web/src/routes/doc.$docId.tsx +++ b/apps/web/src/routes/doc.$docId.tsx @@ -622,6 +622,7 @@ function DocumentPage() { doc={doc} awareness={awareness} did={did} + pageId={docId} onNavigate={handleNavigate} extensions={commentExtensions} onEditorReady={handleEditorReady} diff --git a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md index 611d2da42..b88694616 100644 --- a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md +++ b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md @@ -708,8 +708,8 @@ Recommendation: - [x] Add `useTasks` / schema-query helpers for `TaskSchema`. - [x] Build "My tasks" and "Tasks on this page" views. -- [ ] Add saved filters for assignee, due date, status, and hierarchy state. -- [ ] Allow embedded task views inside pages. +- [x] Add saved filters for assignee, due date, status, and hierarchy state. +- [x] Allow embedded task views inside pages. - [ ] Prototype board/calendar/timeline projections over direct `Task` queries. - [ ] Decide whether this is a new `SavedView` node or an extension of `Database`. diff --git a/packages/editor/src/components/RichTextEditor.tsx b/packages/editor/src/components/RichTextEditor.tsx index e5c031ceb..54a626451 100644 --- a/packages/editor/src/components/RichTextEditor.tsx +++ b/packages/editor/src/components/RichTextEditor.tsx @@ -5,7 +5,9 @@ import type { DatabaseViewType, PageTaskSnapshot, SlashCommandItem, - TaskMentionSuggestion + TaskMentionSuggestion, + TaskViewConfig, + TaskViewEmbedType } from '../extensions' import type { AnyExtension } from '@tiptap/core' import type { Awareness } from 'y-protocols/awareness' @@ -36,6 +38,7 @@ import { SmartReferenceExtension, EmbedExtension, DatabaseEmbedExtension, + TaskViewEmbedExtension, PageTaskItemExtension, TaskMentionExtension, TaskDueDateExtension, @@ -249,6 +252,18 @@ export interface RichTextEditorProps { viewType: DatabaseViewType viewConfig: Record }) => React.ReactNode + /** + * Custom renderer for embedded task views. + */ + renderTaskView?: (props: { + viewType: TaskViewEmbedType + viewConfig: TaskViewConfig + currentPageId: string | null + }) => React.ReactNode + /** + * Page ID used by page-scoped task embeds. + */ + taskViewPageId?: string | null /** * Additional TipTap extensions from plugins. * These are merged with the built-in extensions. @@ -350,6 +365,8 @@ export function RichTextEditor({ onSelectDatabase, resolveDatabaseMeta, renderDatabaseView, + renderTaskView, + taskViewPageId = null, extensions: additionalExtensions = [], toolbarItems: additionalToolbarItems = [], slashCommands, @@ -449,6 +466,9 @@ export function RichTextEditor({ renderView: renderDatabaseView, resolveDatabaseMeta }), + TaskViewEmbedExtension.configure({ + renderView: (props) => renderTaskView?.({ ...props, currentPageId: taskViewPageId }) + }), // Plugin-provided extensions (includes Mermaid when plugin is installed) ...additionalExtensions ] diff --git a/packages/editor/src/extensions.ts b/packages/editor/src/extensions.ts index 4fee71fcf..8af8e8cae 100644 --- a/packages/editor/src/extensions.ts +++ b/packages/editor/src/extensions.ts @@ -460,6 +460,16 @@ export { formatTaskDueDateLabel, getCurrentTaskDueDate } from './extensions/task // Database embed extension export { DatabaseEmbedExtension } from './extensions/database-embed' export type { DatabaseEmbedOptions, DatabaseViewType } from './extensions/database-embed' +export { TaskViewEmbedExtension, DEFAULT_TASK_VIEW_CONFIG } from './extensions/task-view-embed' +export type { + TaskViewEmbedOptions, + TaskViewEmbedType, + TaskViewConfig, + TaskViewScope, + TaskViewAssigneeFilter, + TaskViewDueDateFilter, + TaskViewStatusFilter +} from './extensions/task-view-embed' // Mermaid diagram extension export { MermaidExtension } from './extensions/mermaid' diff --git a/packages/editor/src/extensions/slash-command/items.ts b/packages/editor/src/extensions/slash-command/items.ts index 06c322b0e..f5032c3ec 100644 --- a/packages/editor/src/extensions/slash-command/items.ts +++ b/packages/editor/src/extensions/slash-command/items.ts @@ -388,6 +388,15 @@ export const COMMAND_GROUPS: SlashCommandGroup[] = [ } } } + }, + { + title: 'Task View', + description: 'Embed a filtered task view', + icon: '\u2705', + searchTerms: ['task', 'tasks', 'list', 'assignee', 'due', 'view'], + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setTaskViewEmbed().run() + } } ] } diff --git a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts new file mode 100644 index 000000000..bec16fb38 --- /dev/null +++ b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts @@ -0,0 +1,82 @@ +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TaskViewEmbedExtension } from './index' + +describe('TaskViewEmbedExtension', () => { + let editor: Editor + + beforeEach(() => { + editor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, TaskViewEmbedExtension] + }) + }) + + afterEach(() => { + editor.destroy() + }) + + it('registers the taskViewEmbed node type', () => { + expect(editor.schema.nodes.taskViewEmbed).toBeDefined() + }) + + it('inserts a task view embed with persisted filters', () => { + expect( + editor.commands.setTaskViewEmbed({ + viewConfig: { + scope: 'all', + assignee: 'me', + dueDate: 'overdue', + status: 'done', + showHierarchy: false + } + }) + ).toBe(true) + + const json = editor.getJSON() + const embedNode = json.content?.find((node) => node.type === 'taskViewEmbed') + + expect(embedNode?.attrs).toMatchObject({ + viewType: 'list', + viewConfig: { + scope: 'all', + assignee: 'me', + dueDate: 'overdue', + status: 'done', + showHierarchy: false + } + }) + }) + + it('updates the current task view embed filters', () => { + editor.commands.setTaskViewEmbed() + + let embedPosition = -1 + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'taskViewEmbed' && embedPosition === -1) { + embedPosition = pos + } + }) + + expect(embedPosition).toBeGreaterThanOrEqual(0) + editor.commands.setNodeSelection(embedPosition) + editor.commands.updateTaskViewEmbed({ + viewConfig: { + dueDate: 'today', + status: 'all' + } + }) + + const json = editor.getJSON() + const embedNode = json.content?.find((node) => node.type === 'taskViewEmbed') + + expect(embedNode?.attrs?.viewConfig).toMatchObject({ + scope: 'current-page', + assignee: 'any', + dueDate: 'today', + status: 'all', + showHierarchy: true + }) + }) +}) diff --git a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts new file mode 100644 index 000000000..b8d40498b --- /dev/null +++ b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts @@ -0,0 +1,158 @@ +import { Node, mergeAttributes } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' +import { TaskViewEmbedNodeView } from './TaskViewEmbedNodeView' + +export type TaskViewEmbedType = 'list' +export type TaskViewScope = 'current-page' | 'all' +export type TaskViewAssigneeFilter = 'any' | 'me' +export type TaskViewDueDateFilter = 'any' | 'overdue' | 'today' | 'next-7-days' | 'none' +export type TaskViewStatusFilter = 'open' | 'done' | 'all' + +export interface TaskViewConfig { + scope: TaskViewScope + assignee: TaskViewAssigneeFilter + dueDate: TaskViewDueDateFilter + status: TaskViewStatusFilter + showHierarchy: boolean +} + +export interface TaskViewEmbedOptions { + renderView?: (props: { + viewType: TaskViewEmbedType + viewConfig: TaskViewConfig + }) => React.ReactNode + HTMLAttributes: Record +} + +const DEFAULT_TASK_VIEW_CONFIG: TaskViewConfig = { + scope: 'current-page', + assignee: 'any', + dueDate: 'any', + status: 'open', + showHierarchy: true +} + +function parseTaskViewConfig(raw: string | null): TaskViewConfig { + if (!raw) return DEFAULT_TASK_VIEW_CONFIG + + try { + const parsed = JSON.parse(raw) as Partial + return { + scope: parsed.scope === 'all' ? 'all' : 'current-page', + assignee: parsed.assignee === 'me' ? 'me' : 'any', + dueDate: + parsed.dueDate === 'overdue' || + parsed.dueDate === 'today' || + parsed.dueDate === 'next-7-days' || + parsed.dueDate === 'none' + ? parsed.dueDate + : 'any', + status: parsed.status === 'all' || parsed.status === 'done' ? parsed.status : 'open', + showHierarchy: parsed.showHierarchy !== false + } + } catch { + return DEFAULT_TASK_VIEW_CONFIG + } +} + +declare module '@tiptap/core' { + interface Commands { + taskViewEmbed: { + setTaskViewEmbed: (options?: { + viewType?: TaskViewEmbedType + viewConfig?: Partial + }) => ReturnType + updateTaskViewEmbed: (options: { + viewType?: TaskViewEmbedType + viewConfig?: Partial + }) => ReturnType + } + } +} + +export const TaskViewEmbedExtension = Node.create({ + name: 'taskViewEmbed', + + addOptions() { + return { + renderView: undefined, + HTMLAttributes: {} + } + }, + + group: 'block', + + atom: true, + + draggable: true, + + addAttributes() { + return { + viewType: { default: 'list' }, + viewConfig: { + default: DEFAULT_TASK_VIEW_CONFIG, + parseHTML: (element: HTMLElement) => + parseTaskViewConfig(element.getAttribute('data-task-view-config')), + renderHTML: (attributes: Record) => ({ + 'data-task-view-config': JSON.stringify(attributes.viewConfig ?? DEFAULT_TASK_VIEW_CONFIG) + }) + }, + showTitle: { default: true }, + maxHeight: { default: 360 } + } + }, + + parseHTML() { + return [{ tag: 'div[data-task-view-embed]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-task-view-embed': 'true', + 'data-view-type': HTMLAttributes.viewType, + 'data-type': 'task-view-embed' + }) + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(TaskViewEmbedNodeView) + }, + + addCommands() { + return { + setTaskViewEmbed: + (options = {}) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + viewType: options.viewType ?? 'list', + viewConfig: { + ...DEFAULT_TASK_VIEW_CONFIG, + ...(options.viewConfig ?? {}) + } + } + }) + }, + + updateTaskViewEmbed: + (options) => + ({ commands }) => { + return commands.updateAttributes(this.name, { + ...(options.viewType !== undefined && { viewType: options.viewType }), + ...(options.viewConfig !== undefined && { + viewConfig: { + ...DEFAULT_TASK_VIEW_CONFIG, + ...(options.viewConfig ?? {}) + } + }) + }) + } + } + } +}) + +export { DEFAULT_TASK_VIEW_CONFIG } diff --git a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedNodeView.tsx b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedNodeView.tsx new file mode 100644 index 000000000..656685c24 --- /dev/null +++ b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedNodeView.tsx @@ -0,0 +1,190 @@ +import type { + TaskViewEmbedOptions, + TaskViewConfig, + TaskViewDueDateFilter, + TaskViewScope, + TaskViewStatusFilter, + TaskViewAssigneeFilter +} from './TaskViewEmbedExtension' +import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react' +import * as React from 'react' +import { cn } from '../../utils' + +const SCOPE_OPTIONS: Array<{ value: TaskViewScope; label: string }> = [ + { value: 'current-page', label: 'This page' }, + { value: 'all', label: 'All tasks' } +] + +const ASSIGNEE_OPTIONS: Array<{ value: TaskViewAssigneeFilter; label: string }> = [ + { value: 'any', label: 'Anyone' }, + { value: 'me', label: 'Assigned to me' } +] + +const STATUS_OPTIONS: Array<{ value: TaskViewStatusFilter; label: string }> = [ + { value: 'open', label: 'Open' }, + { value: 'all', label: 'All statuses' }, + { value: 'done', label: 'Done' } +] + +const DUE_DATE_OPTIONS: Array<{ value: TaskViewDueDateFilter; label: string }> = [ + { value: 'any', label: 'Any due date' }, + { value: 'overdue', label: 'Overdue' }, + { value: 'today', label: 'Due today' }, + { value: 'next-7-days', label: 'Next 7 days' }, + { value: 'none', label: 'No due date' } +] + +function getConfig(node: NodeViewProps['node']): TaskViewConfig { + const raw = node.attrs.viewConfig as Partial | undefined + return { + scope: raw?.scope === 'all' ? 'all' : 'current-page', + assignee: raw?.assignee === 'me' ? 'me' : 'any', + dueDate: + raw?.dueDate === 'overdue' || + raw?.dueDate === 'today' || + raw?.dueDate === 'next-7-days' || + raw?.dueDate === 'none' + ? raw.dueDate + : 'any', + status: raw?.status === 'all' || raw?.status === 'done' ? raw.status : 'open', + showHierarchy: raw?.showHierarchy !== false + } +} + +function FilterSelect({ + ariaLabel, + value, + options, + onChange +}: { + ariaLabel: string + value: T + options: Array<{ value: T; label: string }> + onChange: (value: T) => void +}): React.JSX.Element { + return ( + + ) +} + +export function TaskViewEmbedNodeView({ + node, + selected, + updateAttributes, + extension +}: NodeViewProps): React.JSX.Element { + const { viewType, showTitle, maxHeight } = node.attrs + const viewConfig = getConfig(node) + const options = extension.options as TaskViewEmbedOptions + + const updateViewConfig = React.useCallback( + (patch: Partial) => { + updateAttributes({ + viewConfig: { + ...viewConfig, + ...patch + } + }) + }, + [updateAttributes, viewConfig] + ) + + return ( + +
+
+
+ {showTitle && ( +
Task View
+ )} + + {viewType} + +
+ +
+ updateViewConfig({ scope })} + /> + updateViewConfig({ assignee })} + /> + updateViewConfig({ status })} + /> + updateViewConfig({ dueDate })} + /> + +
+
+ +
+ {options.renderView ? ( + options.renderView({ + viewType: viewType as 'list', + viewConfig + }) + ) : ( +
+ Connect a task view renderer to display embedded task results. +
+ )} +
+
+
+ ) +} diff --git a/packages/editor/src/extensions/task-view-embed/index.ts b/packages/editor/src/extensions/task-view-embed/index.ts new file mode 100644 index 000000000..1d40a2925 --- /dev/null +++ b/packages/editor/src/extensions/task-view-embed/index.ts @@ -0,0 +1,11 @@ +export { TaskViewEmbedExtension, DEFAULT_TASK_VIEW_CONFIG } from './TaskViewEmbedExtension' +export type { + TaskViewEmbedOptions, + TaskViewEmbedType, + TaskViewConfig, + TaskViewScope, + TaskViewAssigneeFilter, + TaskViewDueDateFilter, + TaskViewStatusFilter +} from './TaskViewEmbedExtension' +export { TaskViewEmbedNodeView } from './TaskViewEmbedNodeView' diff --git a/packages/editor/src/react.ts b/packages/editor/src/react.ts index 06b9e522a..cf4bb31f7 100644 --- a/packages/editor/src/react.ts +++ b/packages/editor/src/react.ts @@ -41,6 +41,7 @@ export { ToggleNodeView } from './extensions/toggle' export { FileNodeView } from './extensions/file' export { EmbedNodeView } from './extensions/embed' export { DatabaseEmbedNodeView } from './extensions/database-embed' +export { TaskViewEmbedNodeView } from './extensions/task-view-embed' // Blob context export { BlobProvider, useBlobService } from './context/BlobContext' @@ -86,6 +87,14 @@ export { useSlashCommands } from './hooks/useSlashCommands' export type { SlashCommandContribution, UseSlashCommandsOptions } from './hooks/useSlashCommands' export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' export type { TaskMentionSuggestion } from './extensions/task-metadata' +export type { + TaskViewConfig, + TaskViewEmbedType, + TaskViewScope, + TaskViewAssigneeFilter, + TaskViewDueDateFilter, + TaskViewStatusFilter +} from './extensions/task-view-embed' // Re-export hooks from @tiptap/react for convenience export { useEditor, EditorContent } from '@tiptap/react' diff --git a/packages/react/src/components/TaskCollectionEmbed.tsx b/packages/react/src/components/TaskCollectionEmbed.tsx new file mode 100644 index 000000000..cec29d919 --- /dev/null +++ b/packages/react/src/components/TaskCollectionEmbed.tsx @@ -0,0 +1,139 @@ +import { useMemo, type JSX } from 'react' +import { useTasks, type TaskTreeItem } from '../hooks/useTasks' + +export interface TaskCollectionEmbedProps { + currentPageId: string | null + currentDid: string | null + scope: 'current-page' | 'all' + assignee: 'any' | 'me' + dueDate: 'any' | 'overdue' | 'today' | 'next-7-days' | 'none' + status: 'open' | 'done' | 'all' + showHierarchy: boolean +} + +interface RenderableTaskRow { + id: string + title: string + completed: boolean + dueDate: number | undefined + depth: number +} + +function formatDueDate(timestamp: number | undefined): string | null { + if (typeof timestamp !== 'number') return null + + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) +} + +function flattenTree(items: TaskTreeItem[], depth = 0): RenderableTaskRow[] { + return items.flatMap((item) => [ + { + id: item.task.id, + title: item.task.title ?? 'Untitled task', + completed: Boolean(item.task.completed), + dueDate: typeof item.task.dueDate === 'number' ? item.task.dueDate : undefined, + depth + }, + ...flattenTree(item.children, depth + 1) + ]) +} + +export function TaskCollectionEmbed({ + currentPageId, + currentDid, + scope, + assignee, + dueDate, + status, + showHierarchy +}: TaskCollectionEmbedProps): JSX.Element { + const pageId = scope === 'current-page' ? currentPageId : undefined + const assigneeDid = assignee === 'me' ? currentDid : undefined + const statuses = status === 'done' ? (['done'] as const) : undefined + const includeCompleted = status !== 'open' + const { data, tree, loading } = useTasks({ + pageId, + assigneeDid, + includeCompleted, + statuses: statuses ? [...statuses] : undefined, + dueDateFilter: dueDate + }) + + const rows = useMemo(() => { + if (showHierarchy) return flattenTree(tree) + + return data.map((task) => ({ + id: task.id, + title: task.title ?? 'Untitled task', + completed: Boolean(task.completed), + dueDate: typeof task.dueDate === 'number' ? task.dueDate : undefined, + depth: 0 + })) + }, [data, showHierarchy, tree]) + + if (scope === 'current-page' && !currentPageId) { + return
This view needs a page context.
+ } + + if (loading) { + return
Loading tasks...
+ } + + if (rows.length === 0) { + return ( +
+ No tasks match the saved filters for this view. +
+ ) + } + + return ( +
+
    + {rows.map((task) => { + const dueDateLabel = formatDueDate(task.dueDate) + const overdue = + typeof task.dueDate === 'number' && !task.completed && task.dueDate < Date.now() + + return ( +
  • +
    + + {task.completed ? '\u2611' : '\u2610'} + +
    +
    + {task.title} +
    + {dueDateLabel ? ( +
    + {dueDateLabel} +
    + ) : null} +
    +
    +
  • + ) + })} +
+
+ ) +} diff --git a/packages/react/src/hooks/useTasks.test.tsx b/packages/react/src/hooks/useTasks.test.tsx index 1b9b138ff..72c615ada 100644 --- a/packages/react/src/hooks/useTasks.test.tsx +++ b/packages/react/src/hooks/useTasks.test.tsx @@ -178,4 +178,93 @@ describe('useTasks', () => { expect(result.current.myTasks.data).toHaveLength(0) }) }) + + it('filters tasks by due date presets', async () => { + const now = new Date('2026-03-05T12:00:00.000Z').valueOf() + const originalNow = Date.now + Date.now = () => now + + try { + const wrapper = createWrapper() + + const { result } = renderHook( + () => ({ + sync: usePageTaskSync({ pageId: 'page-1', debounceMs: 0 }), + overdue: useTasks({ dueDateFilter: 'overdue' }), + today: useTasks({ dueDateFilter: 'today' }), + upcoming: useTasks({ dueDateFilter: 'next-7-days' }), + noDueDate: useTasks({ dueDateFilter: 'none' }) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.overdue.loading).toBe(false) + expect(result.current.today.loading).toBe(false) + expect(result.current.upcoming.loading).toBe(false) + expect(result.current.noDueDate.loading).toBe(false) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task-overdue', + blockId: 'block-overdue', + title: 'Overdue', + completed: false, + parentTaskId: null, + sortKey: '0000', + assignees: [], + dueDate: '2026-03-04', + references: [] + }, + { + taskId: 'task-today', + blockId: 'block-today', + title: 'Today', + completed: false, + parentTaskId: null, + sortKey: '0001', + assignees: [], + dueDate: '2026-03-05', + references: [] + }, + { + taskId: 'task-upcoming', + blockId: 'block-upcoming', + title: 'Upcoming', + completed: false, + parentTaskId: null, + sortKey: '0002', + assignees: [], + dueDate: '2026-03-09', + references: [] + }, + { + taskId: 'task-no-due-date', + blockId: 'block-no-due-date', + title: 'No due date', + completed: false, + parentTaskId: null, + sortKey: '0003', + assignees: [], + dueDate: null, + references: [] + } + ]) + }) + + await waitFor(() => { + expect(result.current.overdue.data.map((task) => task.id)).toEqual(['task-overdue']) + expect(result.current.today.data.map((task) => task.id)).toEqual(['task-today']) + expect(result.current.upcoming.data.map((task) => task.id)).toEqual([ + 'task-today', + 'task-upcoming' + ]) + expect(result.current.noDueDate.data.map((task) => task.id)).toEqual(['task-no-due-date']) + }) + } finally { + Date.now = originalNow + } + }) }) diff --git a/packages/react/src/hooks/useTasks.ts b/packages/react/src/hooks/useTasks.ts index d30aea17a..13b575444 100644 --- a/packages/react/src/hooks/useTasks.ts +++ b/packages/react/src/hooks/useTasks.ts @@ -15,6 +15,7 @@ export interface UseTasksOptions { includeCompleted?: boolean statuses?: TaskStatus[] parentTaskId?: string | null + dueDateFilter?: 'any' | 'overdue' | 'today' | 'next-7-days' | 'none' } export interface TaskTreeItem { @@ -83,6 +84,33 @@ function matchesParent(task: TaskNode, parentTaskId: string | null | undefined): return (task.parent ?? null) === parentTaskId } +function getStartOfUtcDay(timestamp: number): number { + const date = new Date(timestamp) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +function matchesDueDate(task: TaskNode, dueDateFilter: UseTasksOptions['dueDateFilter']): boolean { + if (!dueDateFilter || dueDateFilter === 'any') return true + + const dueDate = typeof task.dueDate === 'number' ? task.dueDate : undefined + if (dueDateFilter === 'none') return dueDate == null + if (dueDate == null) return false + + const todayStart = getStartOfUtcDay(Date.now()) + const dueDay = getStartOfUtcDay(dueDate) + + switch (dueDateFilter) { + case 'overdue': + return dueDay < todayStart + case 'today': + return dueDay === todayStart + case 'next-7-days': + return dueDay >= todayStart && dueDay <= todayStart + 6 * 24 * 60 * 60 * 1000 + default: + return true + } +} + function buildTaskTree(tasks: TaskNode[]): TaskTreeItem[] { const treeById = new Map() const roots: TaskTreeItem[] = [] @@ -119,7 +147,8 @@ export function useTasks({ assigneeDid, includeCompleted = true, statuses, - parentTaskId + parentTaskId, + dueDateFilter = 'any' }: UseTasksOptions = {}): UseTasksResult { const query = useQuery( TaskSchema, @@ -136,11 +165,12 @@ export function useTasks({ if (!matchesAssignee(task, assigneeDid)) return false if (!matchesStatus(task, statuses)) return false if (!matchesParent(task, parentTaskId)) return false + if (!matchesDueDate(task, dueDateFilter)) return false return true }) return sortTasks(filtered, Boolean(pageId)) - }, [assigneeDid, includeCompleted, pageId, parentTaskId, query.data, statuses]) + }, [assigneeDid, dueDateFilter, includeCompleted, pageId, parentTaskId, query.data, statuses]) const tree = useMemo(() => buildTaskTree(tasks), [tasks]) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 277b47750..e1567e180 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -502,6 +502,10 @@ export { type XNetContextValue, type XNetProviderProps } from './context' +export { + TaskCollectionEmbed, + type TaskCollectionEmbedProps +} from './components/TaskCollectionEmbed' // ============================================================================= // Security (Multi-Level Cryptography) From 0c94dcc173c85cfa37df38e5a274b877a8353c9a Mon Sep 17 00:00:00 2001 From: crs Date: Thu, 5 Mar 2026 18:59:20 -0800 Subject: [PATCH 10/12] feat(tasks): show self in assignee mentions - add gravatar-backed avatars to task mention suggestions and the autocomplete menu - include the current user in page mention suggestions on web and desktop - cover the gravatar helper and mention menu avatar rendering with focused tests --- .../src/renderer/components/PageView.tsx | 8 +- apps/web/src/routes/doc.$docId.tsx | 9 +- .../src/components/TaskMentionMenu.test.tsx | 24 +++++ .../editor/src/components/TaskMentionMenu.tsx | 28 ++++-- packages/editor/src/extensions.ts | 1 + packages/editor/src/react.ts | 1 + packages/editor/src/utils/gravatar.test.ts | 14 +++ packages/editor/src/utils/gravatar.ts | 94 +++++++++++++++++++ 8 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 packages/editor/src/components/TaskMentionMenu.test.tsx create mode 100644 packages/editor/src/utils/gravatar.test.ts create mode 100644 packages/editor/src/utils/gravatar.ts diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index c1bfc9469..83e98ca6b 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -12,6 +12,7 @@ import { PageSchema } from '@xnetjs/data' import { CommentMark, CommentPlugin, restoreCommentMarks } from '@xnetjs/editor/extensions' import { RichTextEditor, + createGravatarUrl, useImageUpload, useFileUpload, useFileDownload, @@ -107,11 +108,11 @@ export function PageView({ docId }: PageViewProps) { const mentionSuggestions = useMemo(() => { const suggestions = new Map< string, - { id: string; label: string; subtitle?: string; color?: string } + { id: string; label: string; subtitle?: string; color?: string; avatarUrl?: string } >() const addSuggestion = ( - entry: { did: string; name?: string; color?: string } | null | undefined, + entry: { did: string; name?: string; color?: string; avatar?: string } | null | undefined, isLocal = false ) => { if (!entry?.did || suggestions.has(entry.did)) return @@ -120,7 +121,8 @@ export function PageView({ docId }: PageViewProps) { id: entry.did, label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, subtitle: isLocal ? 'You' : entry.did, - color: entry.color + color: entry.color, + avatarUrl: entry.avatar || createGravatarUrl(entry.did) }) } diff --git a/apps/web/src/routes/doc.$docId.tsx b/apps/web/src/routes/doc.$docId.tsx index a6b0d2d2c..e9214c7ae 100644 --- a/apps/web/src/routes/doc.$docId.tsx +++ b/apps/web/src/routes/doc.$docId.tsx @@ -6,10 +6,10 @@ * - Comment system with inline popover and sidebar * - Real-time presence indicators */ -import type { Editor } from '@xnetjs/editor/react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { PageSchema } from '@xnetjs/data' import { CommentMark, CommentPlugin, restoreCommentMarks } from '@xnetjs/editor/extensions' +import { createGravatarUrl, type Editor } from '@xnetjs/editor/react' import { useNode, useComments, useIdentity, usePageTaskSync } from '@xnetjs/react' import { CommentPopover, @@ -81,11 +81,11 @@ function DocumentPage() { const mentionSuggestions = useMemo(() => { const suggestions = new Map< string, - { id: string; label: string; subtitle?: string; color?: string } + { id: string; label: string; subtitle?: string; color?: string; avatarUrl?: string } >() const addSuggestion = ( - entry: { did: string; name?: string; color?: string } | null | undefined, + entry: { did: string; name?: string; color?: string; avatar?: string } | null | undefined, isLocal = false ) => { if (!entry?.did || suggestions.has(entry.did)) return @@ -94,7 +94,8 @@ function DocumentPage() { id: entry.did, label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, subtitle: isLocal ? 'You' : entry.did, - color: entry.color + color: entry.color, + avatarUrl: entry.avatar || createGravatarUrl(entry.did) }) } diff --git a/packages/editor/src/components/TaskMentionMenu.test.tsx b/packages/editor/src/components/TaskMentionMenu.test.tsx new file mode 100644 index 000000000..3e6332f74 --- /dev/null +++ b/packages/editor/src/components/TaskMentionMenu.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { TaskMentionMenu } from './TaskMentionMenu' + +describe('TaskMentionMenu', () => { + it('renders gravatar avatars when provided', () => { + render( + + ) + + const image = screen.getByAltText('Avatar for alice') + expect(image).toHaveAttribute('src', 'https://www.gravatar.com/avatar/example?d=identicon&s=64') + }) +}) diff --git a/packages/editor/src/components/TaskMentionMenu.tsx b/packages/editor/src/components/TaskMentionMenu.tsx index 2dbf85f13..465171712 100644 --- a/packages/editor/src/components/TaskMentionMenu.tsx +++ b/packages/editor/src/components/TaskMentionMenu.tsx @@ -6,6 +6,7 @@ export interface TaskMentionSuggestion { label: string subtitle?: string color?: string + avatarUrl?: string } interface TaskMentionMenuProps { @@ -101,15 +102,24 @@ export const TaskMentionMenu = forwardRef - - {item.label.slice(0, 1).toUpperCase()} - + {item.avatarUrl ? ( + {`Avatar + ) : ( + + {item.label.slice(0, 1).toUpperCase()} + + )} @{item.label} diff --git a/packages/editor/src/extensions.ts b/packages/editor/src/extensions.ts index 8af8e8cae..e615becc9 100644 --- a/packages/editor/src/extensions.ts +++ b/packages/editor/src/extensions.ts @@ -456,6 +456,7 @@ export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/p export { TaskMentionExtension, TaskDueDateExtension } from './extensions/task-metadata' export type { TaskMentionSuggestion, TaskDueDateOptions } from './extensions/task-metadata' export { formatTaskDueDateLabel, getCurrentTaskDueDate } from './extensions/task-metadata' +export { createGravatarUrl, md5 } from './utils/gravatar' // Database embed extension export { DatabaseEmbedExtension } from './extensions/database-embed' diff --git a/packages/editor/src/react.ts b/packages/editor/src/react.ts index cf4bb31f7..3bae733a5 100644 --- a/packages/editor/src/react.ts +++ b/packages/editor/src/react.ts @@ -87,6 +87,7 @@ export { useSlashCommands } from './hooks/useSlashCommands' export type { SlashCommandContribution, UseSlashCommandsOptions } from './hooks/useSlashCommands' export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' export type { TaskMentionSuggestion } from './extensions/task-metadata' +export { createGravatarUrl } from './utils/gravatar' export type { TaskViewConfig, TaskViewEmbedType, diff --git a/packages/editor/src/utils/gravatar.test.ts b/packages/editor/src/utils/gravatar.test.ts new file mode 100644 index 000000000..964110af5 --- /dev/null +++ b/packages/editor/src/utils/gravatar.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { createGravatarUrl, md5 } from './gravatar' + +describe('gravatar', () => { + it('computes stable md5 hashes', () => { + expect(md5('hello')).toBe('5d41402abc4b2a76b9719d911017c592') + }) + + it('creates identicon gravatar URLs', () => { + expect(createGravatarUrl('did:key:z6MkExample', 40)).toBe( + `https://www.gravatar.com/avatar/${md5('did:key:z6mkexample')}?d=identicon&s=40` + ) + }) +}) diff --git a/packages/editor/src/utils/gravatar.ts b/packages/editor/src/utils/gravatar.ts new file mode 100644 index 000000000..99b561e31 --- /dev/null +++ b/packages/editor/src/utils/gravatar.ts @@ -0,0 +1,94 @@ +function leftRotate(value: number, amount: number): number { + return (value << amount) | (value >>> (32 - amount)) +} + +function md5(input: string): string { + const encoder = new TextEncoder() + const bytes = encoder.encode(input) + const originalBitLength = bytes.length * 8 + const paddedLength = (((bytes.length + 8) >> 6) + 1) << 6 + const padded = new Uint8Array(paddedLength) + + padded.set(bytes) + padded[bytes.length] = 0x80 + + const bitLengthView = new DataView(padded.buffer) + bitLengthView.setUint32(paddedLength - 8, originalBitLength >>> 0, true) + bitLengthView.setUint32(paddedLength - 4, Math.floor(originalBitLength / 0x100000000), true) + + let a0 = 0x67452301 + let b0 = 0xefcdab89 + let c0 = 0x98badcfe + let d0 = 0x10325476 + + const shifts = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, + 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 + ] + + const constants = Array.from({ length: 64 }, (_, index) => + Math.floor(Math.abs(Math.sin(index + 1)) * 0x100000000) + ) + + for (let offset = 0; offset < padded.length; offset += 64) { + const words = new Uint32Array(16) + for (let index = 0; index < 16; index += 1) { + words[index] = bitLengthView.getUint32(offset + index * 4, true) + } + + let a = a0 + let b = b0 + let c = c0 + let d = d0 + + for (let index = 0; index < 64; index += 1) { + let f = 0 + let g = 0 + + if (index < 16) { + f = (b & c) | (~b & d) + g = index + } else if (index < 32) { + f = (d & b) | (~d & c) + g = (5 * index + 1) % 16 + } else if (index < 48) { + f = b ^ c ^ d + g = (3 * index + 5) % 16 + } else { + f = c ^ (b | ~d) + g = (7 * index) % 16 + } + + const nextD = d + d = c + c = b + const sum = (a + f + constants[index]! + words[g]!) >>> 0 + b = (b + leftRotate(sum, shifts[index]!)) >>> 0 + a = nextD + } + + a0 = (a0 + a) >>> 0 + b0 = (b0 + b) >>> 0 + c0 = (c0 + c) >>> 0 + d0 = (d0 + d) >>> 0 + } + + const output = new DataView(new ArrayBuffer(16)) + output.setUint32(0, a0, true) + output.setUint32(4, b0, true) + output.setUint32(8, c0, true) + output.setUint32(12, d0, true) + + return Array.from(new Uint8Array(output.buffer)) + .map((value) => value.toString(16).padStart(2, '0')) + .join('') +} + +export function createGravatarUrl(input: string, size = 64): string { + const normalized = input.trim().toLowerCase() + const hash = md5(normalized) + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=${size}` +} + +export { md5 } From 29c110e01a5e7dba303dbc1c19ee32dd42be30ff Mon Sep 17 00:00:00 2001 From: crs Date: Thu, 5 Mar 2026 19:03:26 -0800 Subject: [PATCH 11/12] test(flaky): stabilize timing-sensitive performance specs - replace single-run edge bundling timing with a median of repeated samples - scale canvas and view performance budgets for local versus CI variance - keep the assertions as regression guards without depending on micro-benchmark precision --- .../src/__tests__/edge-bundling.test.ts | 27 +++++++++++++++---- packages/canvas/src/__tests__/minimap.test.ts | 11 ++++---- .../src/__tests__/editor-performance.test.tsx | 8 ++++-- .../src/__tests__/virtualized-table.test.tsx | 9 ++++--- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/canvas/src/__tests__/edge-bundling.test.ts b/packages/canvas/src/__tests__/edge-bundling.test.ts index 0199f59c4..48acc3cd0 100644 --- a/packages/canvas/src/__tests__/edge-bundling.test.ts +++ b/packages/canvas/src/__tests__/edge-bundling.test.ts @@ -13,6 +13,22 @@ import { type CanvasEdge } from '../routing/edge-bundler' +function getPerformanceBudget(localBudgetMs: number, ciBudgetMs: number): number { + return process.env.CI ? ciBudgetMs : localBudgetMs +} + +function getMedianDuration(run: () => void, samples = 5): number { + run() + + const durations = Array.from({ length: samples }, () => { + const start = performance.now() + run() + return performance.now() - start + }).sort((a, b) => a - b) + + return durations[Math.floor(durations.length / 2)] ?? 0 +} + describe('EdgeBundler', () => { let bundler: EdgeBundler @@ -292,9 +308,9 @@ describe('EdgeBundler', () => { }) describe('performance', () => { - it('bundles 1000 edges in under budget', () => { + it('bundles 1000 edges within a stable performance budget', () => { const b = new EdgeBundler({ bundleThreshold: 100, minBundleSize: 2 }) - const maxDurationMs = process.env.CI ? 25 : 10 + const maxDurationMs = getPerformanceBudget(30, 60) // Create 1000 edges const edges: CanvasEdge[] = Array.from({ length: 1000 }, (_, i) => ({ @@ -310,9 +326,10 @@ describe('EdgeBundler', () => { positions.set(`t${i}`, { x: i * 20, y: 200, width: 50, height: 50 }) } - const start = performance.now() - b.bundle(edges, positions) - const elapsed = performance.now() - start + const elapsed = getMedianDuration(() => { + const bundles = b.bundle(edges, positions) + expect(bundles.length).toBeGreaterThan(0) + }) expect(elapsed).toBeLessThan(maxDurationMs) }) diff --git a/packages/canvas/src/__tests__/minimap.test.ts b/packages/canvas/src/__tests__/minimap.test.ts index 07812ff5a..8a143db49 100644 --- a/packages/canvas/src/__tests__/minimap.test.ts +++ b/packages/canvas/src/__tests__/minimap.test.ts @@ -8,6 +8,10 @@ import type { CanvasNode, CanvasEdge } from '../types' import { describe, it, expect } from 'vitest' import { Viewport } from '../spatial/index' +function getPerformanceBudget(localBudgetMs: number, ciBudgetMs: number): number { + return process.env.CI ? ciBudgetMs : localBudgetMs +} + // ─── Helper Functions ───────────────────────────────────────────────────────── function createTestNode( @@ -343,8 +347,7 @@ describe('Minimap', () => { const elapsed = performance.now() - start - // Bounds calculation should be fast - expect(elapsed).toBeLessThan(50) // Should complete in under 50ms + expect(elapsed).toBeLessThan(getPerformanceBudget(100, 200)) expect(minX).toBe(0) expect(minY).toBe(0) }) @@ -384,9 +387,7 @@ describe('Minimap', () => { const elapsed = performance.now() - start - // CI and local machines can vary; keep this as a regression guard, - // not a micro-benchmark. - expect(elapsed).toBeLessThan(25) + expect(elapsed).toBeLessThan(getPerformanceBudget(50, 100)) expect(renderedEdges).toBe(2000) }) }) diff --git a/packages/views/src/__tests__/editor-performance.test.tsx b/packages/views/src/__tests__/editor-performance.test.tsx index cb1a7d3bb..388c8e9ac 100644 --- a/packages/views/src/__tests__/editor-performance.test.tsx +++ b/packages/views/src/__tests__/editor-performance.test.tsx @@ -3,6 +3,10 @@ import React from 'react' import { describe, expect, it } from 'vitest' import { getPropertyHandler } from '../properties' +function getPerformanceBudget(localBudgetMs: number, ciBudgetMs: number): number { + return process.env.CI ? ciBudgetMs : localBudgetMs +} + describe('Editor performance', () => { it('filters large multi-select option sets quickly', () => { const handler = getPropertyHandler('multiSelect') @@ -26,7 +30,7 @@ describe('Editor performance', () => { fireEvent.change(input, { target: { value: 'Option 299' } }) const elapsedMs = Date.now() - start - expect(elapsedMs).toBeLessThan(250) + expect(elapsedMs).toBeLessThan(getPerformanceBudget(500, 1000)) }) it('filters large person suggestion sets quickly', () => { @@ -43,6 +47,6 @@ describe('Editor performance', () => { fireEvent.change(input, { target: { value: 'Person 1999' } }) const elapsedMs = Date.now() - start - expect(elapsedMs).toBeLessThan(250) + expect(elapsedMs).toBeLessThan(getPerformanceBudget(500, 1000)) }) }) diff --git a/packages/views/src/__tests__/virtualized-table.test.tsx b/packages/views/src/__tests__/virtualized-table.test.tsx index c58aba786..c548ee7b7 100644 --- a/packages/views/src/__tests__/virtualized-table.test.tsx +++ b/packages/views/src/__tests__/virtualized-table.test.tsx @@ -17,6 +17,10 @@ import { } from '../table/optimizations' import { VirtualizedTableView } from '../table/VirtualizedTableView' +function getPerformanceBudget(localBudgetMs: number, ciBudgetMs: number): number { + return process.env.CI ? ciBudgetMs : localBudgetMs +} + // ─── Test Data ─────────────────────────────────────────────────────────────── const mockSchema: Schema = { @@ -109,7 +113,7 @@ describe('VirtualizedTableView', () => { render() const elapsedMs = Date.now() - started - expect(elapsedMs).toBeLessThan(400) + expect(elapsedMs).toBeLessThan(getPerformanceBudget(700, 1200)) }) it('renders only visible columns (X-axis virtualization)', () => { @@ -519,7 +523,6 @@ describe('Performance', () => { render() const endTime = performance.now() - // Initial render should be fast (< 500ms) - expect(endTime - startTime).toBeLessThan(500) + expect(endTime - startTime).toBeLessThan(getPerformanceBudget(800, 1500)) }) }) From 4a69d4a483b9f1d36afb8699771208be527d21c5 Mon Sep 17 00:00:00 2001 From: crs Date: Thu, 5 Mar 2026 19:14:03 -0800 Subject: [PATCH 12/12] fix(tasks): address PR review follow-ups - share page task panels and mention suggestion construction across web and desktop - harden task sync, due date, and task view updates against stale or partial state - align overdue logic and task tree depth handling with canonical task semantics --- .../renderer/components/PageTasksPanel.tsx | 116 +------------- .../src/renderer/components/PageView.tsx | 32 +--- apps/web/src/components/Editor.tsx | 3 +- apps/web/src/components/MyTasksPanel.tsx | 14 +- apps/web/src/components/PageTasksPanel.tsx | 116 +------------- apps/web/src/routes/doc.$docId.tsx | 32 +--- ...S_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md | 2 +- .../editor/src/components/FloatingToolbar.tsx | 6 +- .../editor/src/components/RichTextEditor.tsx | 2 +- .../editor/src/components/TaskMentionMenu.tsx | 15 +- .../task-metadata/TaskDueDateExtension.ts | 9 +- .../TaskMetadataExtension.test.ts | 22 +++ .../TaskViewEmbedExtension.test.ts | 12 +- .../task-view-embed/TaskViewEmbedExtension.ts | 52 ++++--- .../src/extensions/task-view-embed/index.ts | 3 + packages/editor/src/react.ts | 1 + .../src/utils/taskMentionSuggestions.ts | 39 +++++ packages/react/package.json | 1 + .../react/src/components/PageTasksPanel.tsx | 141 ++++++++++++++++++ .../src/components/TaskCollectionEmbed.tsx | 14 +- .../react/src/hooks/usePageTaskSync.test.tsx | 38 +++++ packages/react/src/hooks/usePageTaskSync.ts | 30 +++- packages/react/src/hooks/useTasks.test.tsx | 53 +++++++ packages/react/src/hooks/useTasks.ts | 19 ++- packages/react/src/index.ts | 1 + pnpm-lock.yaml | 3 + 26 files changed, 448 insertions(+), 328 deletions(-) create mode 100644 packages/editor/src/utils/taskMentionSuggestions.ts create mode 100644 packages/react/src/components/PageTasksPanel.tsx diff --git a/apps/electron/src/renderer/components/PageTasksPanel.tsx b/apps/electron/src/renderer/components/PageTasksPanel.tsx index a510a0cde..277b02f17 100644 --- a/apps/electron/src/renderer/components/PageTasksPanel.tsx +++ b/apps/electron/src/renderer/components/PageTasksPanel.tsx @@ -1,115 +1 @@ -import { useTasks } from '@xnetjs/react' -import { Calendar, CheckSquare2, ChevronDown, ChevronRight, Square, Users } from 'lucide-react' -import { useMemo, useState } from 'react' - -interface PageTasksPanelProps { - pageId: string -} - -function formatDueDate(timestamp: number | undefined): string | null { - if (typeof timestamp !== 'number') return null - - return new Date(timestamp).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric' - }) -} - -function isOverdue(timestamp: number | undefined, completed: boolean): boolean { - return typeof timestamp === 'number' && !completed && timestamp < Date.now() -} - -export function PageTasksPanel({ pageId }: PageTasksPanelProps) { - const [expanded, setExpanded] = useState(true) - const { data: tasks, loading } = useTasks({ pageId }) - - const rows = useMemo(() => { - return tasks.map((task) => ({ - ...task, - depth: typeof task.sortKey === 'string' ? Math.max(task.sortKey.split('.').length - 1, 0) : 0 - })) - }, [tasks]) - - return ( -
- - - {expanded && ( -
- {loading ? ( -

Loading tasks...

- ) : rows.length === 0 ? ( -

- Checklist items on this page will appear here. -

- ) : ( -
    - {rows.map((task) => { - const dueDateLabel = formatDueDate(task.dueDate) - const overdue = isOverdue(task.dueDate, Boolean(task.completed)) - const assigneeCount = Array.isArray(task.assignees) ? task.assignees.length : 0 - - return ( -
  • -
    - {task.completed ? ( - - ) : ( - - )} -
    -
    - {task.title} -
    -
    - {dueDateLabel && ( - - - {dueDateLabel} - - )} - {assigneeCount > 0 && ( - - - {assigneeCount} - - )} -
    -
    -
    -
  • - ) - })} -
- )} -
- )} -
- ) -} +export { PageTasksPanel } from '@xnetjs/react' diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index 83e98ca6b..9169e4215 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -12,7 +12,7 @@ import { PageSchema } from '@xnetjs/data' import { CommentMark, CommentPlugin, restoreCommentMarks } from '@xnetjs/editor/extensions' import { RichTextEditor, - createGravatarUrl, + buildTaskMentionSuggestions, useImageUpload, useFileUpload, useFileDownload, @@ -105,32 +105,10 @@ export function PageView({ docId }: PageViewProps) { did: did ?? undefined }) const { handleTasksChange } = usePageTaskSync({ pageId: docId }) - const mentionSuggestions = useMemo(() => { - const suggestions = new Map< - string, - { id: string; label: string; subtitle?: string; color?: string; avatarUrl?: string } - >() - - const addSuggestion = ( - entry: { did: string; name?: string; color?: string; avatar?: string } | null | undefined, - isLocal = false - ) => { - if (!entry?.did || suggestions.has(entry.did)) return - - suggestions.set(entry.did, { - id: entry.did, - label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, - subtitle: isLocal ? 'You' : entry.did, - color: entry.color, - avatarUrl: entry.avatar || createGravatarUrl(entry.did) - }) - } - - addSuggestion(did ? { did } : null, true) - presence.forEach((user) => addSuggestion(user)) - - return Array.from(suggestions.values()) - }, [did, presence]) + const mentionSuggestions = useMemo( + () => buildTaskMentionSuggestions(presence, did), + [did, presence] + ) // ─── Comments Integration ───────────────────────────────────────────────────── diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 4708f2fec..a080676ed 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -4,6 +4,7 @@ * Uses the shared @xnetjs/editor package for rich text editing. * Supports comment extensions for inline commenting. */ +import type { JSX } from 'react' import type * as Y from 'yjs' import { RichTextEditor, @@ -50,7 +51,7 @@ export function Editor({ onPageTasksChange, pageId, onCreateComment -}: Props) { +}: Props): JSX.Element { const onImageUpload = useImageUpload() const onFileUpload = useFileUpload() const onFileDownload = useFileDownload() diff --git a/apps/web/src/components/MyTasksPanel.tsx b/apps/web/src/components/MyTasksPanel.tsx index 6dd31b697..0f3d07c17 100644 --- a/apps/web/src/components/MyTasksPanel.tsx +++ b/apps/web/src/components/MyTasksPanel.tsx @@ -2,7 +2,7 @@ import { Link } from '@tanstack/react-router' import { PageSchema } from '@xnetjs/data' import { useIdentity, useQuery, useTasks } from '@xnetjs/react' import { Calendar, CheckSquare2, ChevronDown, ChevronRight } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useMemo, useState, type JSX } from 'react' function formatDueDate(timestamp: number | undefined): string | null { if (typeof timestamp !== 'number') return null @@ -14,15 +14,21 @@ function formatDueDate(timestamp: number | undefined): string | null { } function isOverdue(timestamp: number | undefined, completed: boolean): boolean { - return typeof timestamp === 'number' && !completed && timestamp < Date.now() + if (typeof timestamp !== 'number' || completed) return false + + const dueDate = new Date(timestamp) + const dueDay = Date.UTC(dueDate.getUTCFullYear(), dueDate.getUTCMonth(), dueDate.getUTCDate()) + const now = new Date() + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + return dueDay < today } -export function MyTasksPanel() { +export function MyTasksPanel(): JSX.Element | null { const { identity } = useIdentity() const did = identity?.did ?? null const [expanded, setExpanded] = useState(true) const { data: tasks, loading } = useTasks({ assigneeDid: did, includeCompleted: false }) - const { data: pages } = useQuery(PageSchema, { limit: 200 }) + const { data: pages } = useQuery(PageSchema) const pageTitles = useMemo(() => { return new Map(pages.map((page) => [page.id, page.title || 'Untitled'])) diff --git a/apps/web/src/components/PageTasksPanel.tsx b/apps/web/src/components/PageTasksPanel.tsx index a510a0cde..277b02f17 100644 --- a/apps/web/src/components/PageTasksPanel.tsx +++ b/apps/web/src/components/PageTasksPanel.tsx @@ -1,115 +1 @@ -import { useTasks } from '@xnetjs/react' -import { Calendar, CheckSquare2, ChevronDown, ChevronRight, Square, Users } from 'lucide-react' -import { useMemo, useState } from 'react' - -interface PageTasksPanelProps { - pageId: string -} - -function formatDueDate(timestamp: number | undefined): string | null { - if (typeof timestamp !== 'number') return null - - return new Date(timestamp).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric' - }) -} - -function isOverdue(timestamp: number | undefined, completed: boolean): boolean { - return typeof timestamp === 'number' && !completed && timestamp < Date.now() -} - -export function PageTasksPanel({ pageId }: PageTasksPanelProps) { - const [expanded, setExpanded] = useState(true) - const { data: tasks, loading } = useTasks({ pageId }) - - const rows = useMemo(() => { - return tasks.map((task) => ({ - ...task, - depth: typeof task.sortKey === 'string' ? Math.max(task.sortKey.split('.').length - 1, 0) : 0 - })) - }, [tasks]) - - return ( -
- - - {expanded && ( -
- {loading ? ( -

Loading tasks...

- ) : rows.length === 0 ? ( -

- Checklist items on this page will appear here. -

- ) : ( -
    - {rows.map((task) => { - const dueDateLabel = formatDueDate(task.dueDate) - const overdue = isOverdue(task.dueDate, Boolean(task.completed)) - const assigneeCount = Array.isArray(task.assignees) ? task.assignees.length : 0 - - return ( -
  • -
    - {task.completed ? ( - - ) : ( - - )} -
    -
    - {task.title} -
    -
    - {dueDateLabel && ( - - - {dueDateLabel} - - )} - {assigneeCount > 0 && ( - - - {assigneeCount} - - )} -
    -
    -
    -
  • - ) - })} -
- )} -
- )} -
- ) -} +export { PageTasksPanel } from '@xnetjs/react' diff --git a/apps/web/src/routes/doc.$docId.tsx b/apps/web/src/routes/doc.$docId.tsx index e9214c7ae..61085400a 100644 --- a/apps/web/src/routes/doc.$docId.tsx +++ b/apps/web/src/routes/doc.$docId.tsx @@ -9,7 +9,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { PageSchema } from '@xnetjs/data' import { CommentMark, CommentPlugin, restoreCommentMarks } from '@xnetjs/editor/extensions' -import { createGravatarUrl, type Editor } from '@xnetjs/editor/react' +import { buildTaskMentionSuggestions, type Editor } from '@xnetjs/editor/react' import { useNode, useComments, useIdentity, usePageTaskSync } from '@xnetjs/react' import { CommentPopover, @@ -78,32 +78,10 @@ function DocumentPage() { did: did ?? undefined }) const { handleTasksChange } = usePageTaskSync({ pageId: docId }) - const mentionSuggestions = useMemo(() => { - const suggestions = new Map< - string, - { id: string; label: string; subtitle?: string; color?: string; avatarUrl?: string } - >() - - const addSuggestion = ( - entry: { did: string; name?: string; color?: string; avatar?: string } | null | undefined, - isLocal = false - ) => { - if (!entry?.did || suggestions.has(entry.did)) return - - suggestions.set(entry.did, { - id: entry.did, - label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, - subtitle: isLocal ? 'You' : entry.did, - color: entry.color, - avatarUrl: entry.avatar || createGravatarUrl(entry.did) - }) - } - - addSuggestion(did ? { did } : null, true) - presence.forEach((user) => addSuggestion(user)) - - return Array.from(suggestions.values()) - }, [did, presence]) + const mentionSuggestions = useMemo( + () => buildTaskMentionSuggestions(presence, did), + [did, presence] + ) // ─── Comments Integration ───────────────────────────────────────────────────── diff --git a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md index b88694616..e6d061b12 100644 --- a/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md +++ b/docs/explorations/0103_[_]_TASKS_EMBEDDED_IN_PAGES_BACKED_BY_NODES_MENTIONS_DUE_DATES_NESTED_SUBTASKS_DATABASES_CANVASES_AND_CROSS_SURFACE_TASK_MODEL.md @@ -217,7 +217,7 @@ For xNet, this points to: Users want to: -- type `[] `, +- type `[]`, - indent with `Tab`, - `@mention` assignees inline, - set due dates without leaving the editor, diff --git a/packages/editor/src/components/FloatingToolbar.tsx b/packages/editor/src/components/FloatingToolbar.tsx index da7218c5a..ec1d225f3 100644 --- a/packages/editor/src/components/FloatingToolbar.tsx +++ b/packages/editor/src/components/FloatingToolbar.tsx @@ -117,7 +117,11 @@ function pickDate(initialValue: string | null): Promise { input.focus() if (typeof input.showPicker === 'function') { - input.showPicker() + try { + input.showPicker() + } catch { + input.click() + } } else { input.click() } diff --git a/packages/editor/src/components/RichTextEditor.tsx b/packages/editor/src/components/RichTextEditor.tsx index 54a626451..152f6b9d3 100644 --- a/packages/editor/src/components/RichTextEditor.tsx +++ b/packages/editor/src/components/RichTextEditor.tsx @@ -512,8 +512,8 @@ export function RichTextEditor({ onPageTasksChange(tasks) } - publishPageTasks() editor.on('update', publishPageTasks) + publishPageTasks() return () => { editor.off('update', publishPageTasks) diff --git a/packages/editor/src/components/TaskMentionMenu.tsx b/packages/editor/src/components/TaskMentionMenu.tsx index 465171712..132b5a142 100644 --- a/packages/editor/src/components/TaskMentionMenu.tsx +++ b/packages/editor/src/components/TaskMentionMenu.tsx @@ -1,7 +1,10 @@ +/** + * @xnetjs/editor - Task mention menu component + */ import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react' import { cn } from '../utils' -export interface TaskMentionSuggestion { +export type TaskMentionSuggestion = { id: string label: string subtitle?: string @@ -9,12 +12,12 @@ export interface TaskMentionSuggestion { avatarUrl?: string } -interface TaskMentionMenuProps { +type TaskMentionMenuProps = { items: TaskMentionSuggestion[] command: (item: TaskMentionSuggestion) => void } -export interface TaskMentionMenuRef { +export type TaskMentionMenuRef = { onKeyDown: (event: KeyboardEvent) => boolean } @@ -38,6 +41,8 @@ export const TaskMentionMenu = forwardRef ({ onKeyDown: (event: KeyboardEvent) => { + if (items.length === 0) return false + if (event.key === 'ArrowUp') { event.preventDefault() setSelectedIndex((prev) => (prev - 1 + items.length) % items.length) @@ -52,7 +57,9 @@ export const TaskMentionMenu = forwardRef 0) { + selectItem(selectedIndex) + } return true } diff --git a/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts b/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts index c9a77a382..45d3dfb33 100644 --- a/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts +++ b/packages/editor/src/extensions/task-metadata/TaskDueDateExtension.ts @@ -1,3 +1,6 @@ +/** + * @xnetjs/editor - Task due date inline metadata + */ import type { Editor } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import { Node, mergeAttributes } from '@tiptap/core' @@ -99,7 +102,7 @@ export function getCurrentTaskDueDate(editor: Editor): string | null { return typeof currentDueDate.node.attrs.date === 'string' ? currentDueDate.node.attrs.date : null } -export interface TaskDueDateOptions { +export type TaskDueDateOptions = { HTMLAttributes: Record } @@ -164,8 +167,10 @@ export const TaskDueDateExtension = Node.create({ const normalized = normalizeDateString(date) if (!normalized) return false - const dueDateNode = this.type.create({ date: normalized }) const currentTask = findCurrentTaskItem(editor) + if (!currentTask) return false + + const dueDateNode = this.type.create({ date: normalized }) const existing = currentTask ? findDueDateNode(currentTask.node, currentTask.pos) : null let tr = state.tr diff --git a/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts b/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts index 9c108c441..70486c8e5 100644 --- a/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts +++ b/packages/editor/src/extensions/task-metadata/TaskMetadataExtension.test.ts @@ -89,4 +89,26 @@ describe('task metadata extensions', () => { expect(dueDates).toEqual(['2026-03-21']) }) + + it('does not insert due dates outside task items', () => { + const plainEditor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, TaskDueDateExtension], + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Plain paragraph' }] + } + ] + } + }) + + plainEditor.commands.focus('end') + + expect(plainEditor.commands.setTaskDueDate('2026-03-20')).toBe(false) + + plainEditor.destroy() + }) }) diff --git a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts index bec16fb38..9c5c043bc 100644 --- a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts +++ b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.test.ts @@ -50,7 +50,13 @@ describe('TaskViewEmbedExtension', () => { }) it('updates the current task view embed filters', () => { - editor.commands.setTaskViewEmbed() + editor.commands.setTaskViewEmbed({ + viewConfig: { + scope: 'all', + assignee: 'me', + dueDate: 'overdue' + } + }) let embedPosition = -1 editor.state.doc.descendants((node, pos) => { @@ -72,8 +78,8 @@ describe('TaskViewEmbedExtension', () => { const embedNode = json.content?.find((node) => node.type === 'taskViewEmbed') expect(embedNode?.attrs?.viewConfig).toMatchObject({ - scope: 'current-page', - assignee: 'any', + scope: 'all', + assignee: 'me', dueDate: 'today', status: 'all', showHierarchy: true diff --git a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts index b8d40498b..83f4d923c 100644 --- a/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts +++ b/packages/editor/src/extensions/task-view-embed/TaskViewEmbedExtension.ts @@ -8,7 +8,7 @@ export type TaskViewAssigneeFilter = 'any' | 'me' export type TaskViewDueDateFilter = 'any' | 'overdue' | 'today' | 'next-7-days' | 'none' export type TaskViewStatusFilter = 'open' | 'done' | 'all' -export interface TaskViewConfig { +export type TaskViewConfig = { scope: TaskViewScope assignee: TaskViewAssigneeFilter dueDate: TaskViewDueDateFilter @@ -16,7 +16,7 @@ export interface TaskViewConfig { showHierarchy: boolean } -export interface TaskViewEmbedOptions { +export type TaskViewEmbedOptions = { renderView?: (props: { viewType: TaskViewEmbedType viewConfig: TaskViewConfig @@ -32,12 +32,28 @@ const DEFAULT_TASK_VIEW_CONFIG: TaskViewConfig = { showHierarchy: true } +function cloneDefaultTaskViewConfig(): TaskViewConfig { + return { ...DEFAULT_TASK_VIEW_CONFIG } +} + +function mergeTaskViewConfig( + ...configs: Array | null | undefined> +): TaskViewConfig { + return configs.reduce( + (current, config) => ({ + ...current, + ...(config ?? {}) + }), + cloneDefaultTaskViewConfig() + ) +} + function parseTaskViewConfig(raw: string | null): TaskViewConfig { - if (!raw) return DEFAULT_TASK_VIEW_CONFIG + if (!raw) return cloneDefaultTaskViewConfig() try { const parsed = JSON.parse(raw) as Partial - return { + return mergeTaskViewConfig({ scope: parsed.scope === 'all' ? 'all' : 'current-page', assignee: parsed.assignee === 'me' ? 'me' : 'any', dueDate: @@ -49,9 +65,9 @@ function parseTaskViewConfig(raw: string | null): TaskViewConfig { : 'any', status: parsed.status === 'all' || parsed.status === 'done' ? parsed.status : 'open', showHierarchy: parsed.showHierarchy !== false - } + }) } catch { - return DEFAULT_TASK_VIEW_CONFIG + return cloneDefaultTaskViewConfig() } } @@ -90,11 +106,13 @@ export const TaskViewEmbedExtension = Node.create({ return { viewType: { default: 'list' }, viewConfig: { - default: DEFAULT_TASK_VIEW_CONFIG, + default: cloneDefaultTaskViewConfig(), parseHTML: (element: HTMLElement) => parseTaskViewConfig(element.getAttribute('data-task-view-config')), renderHTML: (attributes: Record) => ({ - 'data-task-view-config': JSON.stringify(attributes.viewConfig ?? DEFAULT_TASK_VIEW_CONFIG) + 'data-task-view-config': JSON.stringify( + mergeTaskViewConfig(attributes.viewConfig as Partial | undefined) + ) }) }, showTitle: { default: true }, @@ -130,25 +148,21 @@ export const TaskViewEmbedExtension = Node.create({ type: this.name, attrs: { viewType: options.viewType ?? 'list', - viewConfig: { - ...DEFAULT_TASK_VIEW_CONFIG, - ...(options.viewConfig ?? {}) - } + viewConfig: mergeTaskViewConfig(options.viewConfig) } }) }, updateTaskViewEmbed: (options) => - ({ commands }) => { + ({ commands, editor }) => { + const existingAttributes = editor.getAttributes(this.name) as { + viewConfig?: Partial + } + return commands.updateAttributes(this.name, { ...(options.viewType !== undefined && { viewType: options.viewType }), - ...(options.viewConfig !== undefined && { - viewConfig: { - ...DEFAULT_TASK_VIEW_CONFIG, - ...(options.viewConfig ?? {}) - } - }) + viewConfig: mergeTaskViewConfig(existingAttributes.viewConfig, options.viewConfig) }) } } diff --git a/packages/editor/src/extensions/task-view-embed/index.ts b/packages/editor/src/extensions/task-view-embed/index.ts index 1d40a2925..c21d690ee 100644 --- a/packages/editor/src/extensions/task-view-embed/index.ts +++ b/packages/editor/src/extensions/task-view-embed/index.ts @@ -1,3 +1,6 @@ +/** + * @xnetjs/editor - Task view embed exports + */ export { TaskViewEmbedExtension, DEFAULT_TASK_VIEW_CONFIG } from './TaskViewEmbedExtension' export type { TaskViewEmbedOptions, diff --git a/packages/editor/src/react.ts b/packages/editor/src/react.ts index 3bae733a5..53fd0da84 100644 --- a/packages/editor/src/react.ts +++ b/packages/editor/src/react.ts @@ -88,6 +88,7 @@ export type { SlashCommandContribution, UseSlashCommandsOptions } from './hooks/ export type { PageTaskSnapshot, PageTaskReferenceSnapshot } from './extensions/page-tasks' export type { TaskMentionSuggestion } from './extensions/task-metadata' export { createGravatarUrl } from './utils/gravatar' +export { buildTaskMentionSuggestions } from './utils/taskMentionSuggestions' export type { TaskViewConfig, TaskViewEmbedType, diff --git a/packages/editor/src/utils/taskMentionSuggestions.ts b/packages/editor/src/utils/taskMentionSuggestions.ts new file mode 100644 index 000000000..6da309dd4 --- /dev/null +++ b/packages/editor/src/utils/taskMentionSuggestions.ts @@ -0,0 +1,39 @@ +/** + * @xnetjs/editor - Shared task mention suggestion helpers + */ +import type { TaskMentionSuggestion } from '../extensions/task-metadata' +import { createGravatarUrl } from './gravatar' + +type MentionPresenceEntry = { + did: string + name?: string + color?: string + avatar?: string +} + +export function buildTaskMentionSuggestions( + presence: Iterable, + did: string | null | undefined +): TaskMentionSuggestion[] { + const suggestions = new Map() + + const addSuggestion = (entry: MentionPresenceEntry | null | undefined, isLocal = false): void => { + if (!entry?.did || suggestions.has(entry.did)) return + + suggestions.set(entry.did, { + id: entry.did, + label: entry.name?.trim() || `${entry.did.slice(8, 16)}...`, + subtitle: isLocal ? 'You' : entry.did, + color: entry.color, + avatarUrl: entry.avatar || createGravatarUrl(entry.did) + }) + } + + addSuggestion(did ? { did } : null, true) + + for (const user of presence) { + addSuggestion(user) + } + + return Array.from(suggestions.values()) +} diff --git a/packages/react/package.json b/packages/react/package.json index f781269d2..4b6c519c9 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,6 +42,7 @@ "@xnetjs/history": "workspace:*", "@xnetjs/identity": "workspace:*", "@xnetjs/plugins": "workspace:*", + "lucide-react": "^0.453.0", "y-protocols": "^1.0.6", "yjs": "^13.6.24" }, diff --git a/packages/react/src/components/PageTasksPanel.tsx b/packages/react/src/components/PageTasksPanel.tsx new file mode 100644 index 000000000..7a0dc006a --- /dev/null +++ b/packages/react/src/components/PageTasksPanel.tsx @@ -0,0 +1,141 @@ +/** + * @xnetjs/react - Shared page task panel + */ +import { Calendar, CheckSquare2, ChevronDown, ChevronRight, Square, Users } from 'lucide-react' +import { useMemo, useState, type JSX } from 'react' +import { useTasks, type TaskTreeItem } from '../hooks/useTasks' + +export type PageTasksPanelProps = { + pageId: string +} + +type RenderableTaskRow = { + id: string + title: string + completed: boolean + dueDate: number | undefined + depth: number + assigneeCount: number +} + +function formatDueDate(timestamp: number | undefined): string | null { + if (typeof timestamp !== 'number') return null + + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) +} + +function getStartOfUtcDay(timestamp: number): number { + const date = new Date(timestamp) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +function isOverdue(timestamp: number | undefined, completed: boolean): boolean { + if (typeof timestamp !== 'number' || completed) return false + return getStartOfUtcDay(timestamp) < getStartOfUtcDay(Date.now()) +} + +function flattenTree(items: TaskTreeItem[], depth = 0): RenderableTaskRow[] { + return items.flatMap((item) => [ + { + id: item.task.id, + title: item.task.title ?? 'Untitled task', + completed: Boolean(item.task.completed), + dueDate: typeof item.task.dueDate === 'number' ? item.task.dueDate : undefined, + depth, + assigneeCount: Array.isArray(item.task.assignees) ? item.task.assignees.length : 0 + }, + ...flattenTree(item.children, depth + 1) + ]) +} + +export function PageTasksPanel({ pageId }: PageTasksPanelProps): JSX.Element { + const [expanded, setExpanded] = useState(true) + const { tree, loading } = useTasks({ pageId }) + + const rows = useMemo(() => flattenTree(tree), [tree]) + + return ( +
+ + + {expanded && ( +
+ {loading ? ( +

Loading tasks...

+ ) : rows.length === 0 ? ( +

+ Checklist items on this page will appear here. +

+ ) : ( +
    + {rows.map((task) => { + const dueDateLabel = formatDueDate(task.dueDate) + const overdue = isOverdue(task.dueDate, task.completed) + + return ( +
  • +
    + {task.completed ? ( + + ) : ( + + )} +
    +
    + {task.title} +
    +
    + {dueDateLabel ? ( + + + {dueDateLabel} + + ) : null} + {task.assigneeCount > 0 ? ( + + + {task.assigneeCount} + + ) : null} +
    +
    +
    +
  • + ) + })} +
+ )} +
+ )} +
+ ) +} diff --git a/packages/react/src/components/TaskCollectionEmbed.tsx b/packages/react/src/components/TaskCollectionEmbed.tsx index cec29d919..494c0d8bf 100644 --- a/packages/react/src/components/TaskCollectionEmbed.tsx +++ b/packages/react/src/components/TaskCollectionEmbed.tsx @@ -1,7 +1,10 @@ +/** + * @xnetjs/react - Embedded task collection renderer + */ import { useMemo, type JSX } from 'react' import { useTasks, type TaskTreeItem } from '../hooks/useTasks' -export interface TaskCollectionEmbedProps { +export type TaskCollectionEmbedProps = { currentPageId: string | null currentDid: string | null scope: 'current-page' | 'all' @@ -28,6 +31,11 @@ function formatDueDate(timestamp: number | undefined): string | null { }) } +function getStartOfUtcDay(timestamp: number): number { + const date = new Date(timestamp) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + function flattenTree(items: TaskTreeItem[], depth = 0): RenderableTaskRow[] { return items.flatMap((item) => [ { @@ -96,7 +104,9 @@ export function TaskCollectionEmbed({ {rows.map((task) => { const dueDateLabel = formatDueDate(task.dueDate) const overdue = - typeof task.dueDate === 'number' && !task.completed && task.dueDate < Date.now() + typeof task.dueDate === 'number' && + !task.completed && + getStartOfUtcDay(task.dueDate) < getStartOfUtcDay(Date.now()) return (
  • diff --git a/packages/react/src/hooks/usePageTaskSync.test.tsx b/packages/react/src/hooks/usePageTaskSync.test.tsx index 02a8e1768..c1b417b1d 100644 --- a/packages/react/src/hooks/usePageTaskSync.test.tsx +++ b/packages/react/src/hooks/usePageTaskSync.test.tsx @@ -154,4 +154,42 @@ describe('usePageTaskSync', () => { expect(result.current.tasks.data).toHaveLength(1) }) }) + + it('ignores invalid due date strings when syncing task nodes', async () => { + const wrapper = createWrapper() + + const { result } = renderHook( + () => ({ + sync: usePageTaskSync({ pageId: 'page-1', debounceMs: 0 }), + tasks: useQuery(TaskSchema, { where: { page: 'page-1' } }) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.tasks.loading).toBe(false) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task-invalid-date', + blockId: 'block-invalid-date', + title: 'Invalid date task', + completed: false, + parentTaskId: null, + sortKey: '0000', + assignees: [], + dueDate: '2026-02-31', + references: [] + } + ]) + }) + + await waitFor(() => { + expect(result.current.tasks.data).toHaveLength(1) + }) + + expect(result.current.tasks.data[0]?.dueDate).toBeUndefined() + }) }) diff --git a/packages/react/src/hooks/usePageTaskSync.ts b/packages/react/src/hooks/usePageTaskSync.ts index 8fb1d0f9a..a3a6c056a 100644 --- a/packages/react/src/hooks/usePageTaskSync.ts +++ b/packages/react/src/hooks/usePageTaskSync.ts @@ -108,7 +108,18 @@ function toDateTimestamp(date: string | null): number | undefined { const [year, month, day] = date.split('-').map(Number) const timestamp = Date.UTC(year, month - 1, day) - return Number.isNaN(timestamp) ? undefined : timestamp + if (Number.isNaN(timestamp)) return undefined + + const normalized = new Date(timestamp) + if ( + normalized.getUTCFullYear() !== year || + normalized.getUTCMonth() !== month - 1 || + normalized.getUTCDate() !== day + ) { + return undefined + } + + return timestamp } function normalizeProvider(provider: string | null): ExternalReferenceProvider { @@ -152,6 +163,7 @@ export function usePageTaskSync({ includeDeleted: true }) const taskSnapshotsRef = useRef([]) + const syncRunIdRef = useRef(0) const [revision, setRevision] = useState(0) const [syncing, setSyncing] = useState(false) const [error, setError] = useState(null) @@ -172,6 +184,8 @@ export function usePageTaskSync({ const timer = setTimeout(() => { const run = async () => { + const runId = syncRunIdRef.current + 1 + syncRunIdRef.current = runId const currentTasks = taskSnapshotsRef.current const nextTaskIds = new Set(currentTasks.map((task) => task.taskId)) const tasksToCreate: Array<{ @@ -191,10 +205,14 @@ export function usePageTaskSync({ const assignees = normalizeAssignees(task.assignees) const dueDate = toDateTimestamp(task.dueDate) const primaryAssignee = assignees[0] + const nextReferenceUpserts: Array<{ + id: string + data: ExternalReferenceCreate + }> = [] const referenceIds = task.references.map((reference) => { const id = computeExternalReferenceId(task.taskId, reference) - referenceUpserts.push({ + nextReferenceUpserts.push({ id, data: { url: reference.url, @@ -230,6 +248,7 @@ export function usePageTaskSync({ references: referenceIds } }) + referenceUpserts.push(...nextReferenceUpserts) continue } @@ -269,6 +288,7 @@ export function usePageTaskSync({ } if (!arraysEqual(existingTask.references, referenceIds)) { updateData.references = referenceIds + referenceUpserts.push(...nextReferenceUpserts) } if (Object.keys(updateData).length > 0) { @@ -307,6 +327,8 @@ export function usePageTaskSync({ try { for (const reference of referenceUpserts) { + if (cancelled || runId !== syncRunIdRef.current) return + try { await update(ExternalReferenceSchema, reference.id, reference.data) } catch { @@ -315,18 +337,22 @@ export function usePageTaskSync({ } for (const taskId of tasksToRestore) { + if (cancelled || runId !== syncRunIdRef.current) return await restore(taskId) } for (const task of tasksToCreate) { + if (cancelled || runId !== syncRunIdRef.current) return await create(TaskSchema, task.data, task.id) } for (const task of taskUpdates) { + if (cancelled || runId !== syncRunIdRef.current) return await update(TaskSchema, task.id, task.data) } for (const taskId of taskDeletes) { + if (cancelled || runId !== syncRunIdRef.current) return await remove(taskId) } if (!cancelled) { diff --git a/packages/react/src/hooks/useTasks.test.tsx b/packages/react/src/hooks/useTasks.test.tsx index 72c615ada..a7b5f5581 100644 --- a/packages/react/src/hooks/useTasks.test.tsx +++ b/packages/react/src/hooks/useTasks.test.tsx @@ -267,4 +267,57 @@ describe('useTasks', () => { Date.now = originalNow } }) + + it('computes hierarchy depth independently of task sort order', async () => { + const wrapper = createWrapper() + + const { result } = renderHook( + () => ({ + sync: usePageTaskSync({ pageId: 'page-1', debounceMs: 0 }), + tasks: useTasks({ includeCompleted: true }) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.tasks.loading).toBe(false) + }) + + await act(async () => { + result.current.sync.handleTasksChange([ + { + taskId: 'task-parent', + blockId: 'block-parent', + title: 'Parent', + completed: false, + parentTaskId: null, + sortKey: '0001', + assignees: [did], + dueDate: '2026-03-20', + references: [] + }, + { + taskId: 'task-child', + blockId: 'block-child', + title: 'Child', + completed: false, + parentTaskId: 'task-parent', + sortKey: '0000', + assignees: [did], + dueDate: '2026-03-05', + references: [] + } + ]) + }) + + await waitFor(() => { + expect(result.current.tasks.data).toHaveLength(2) + }) + + expect(result.current.tasks.tree).toHaveLength(1) + expect(result.current.tasks.tree[0]?.task.id).toBe('task-parent') + expect(result.current.tasks.tree[0]?.depth).toBe(0) + expect(result.current.tasks.tree[0]?.children[0]?.task.id).toBe('task-child') + expect(result.current.tasks.tree[0]?.children[0]?.depth).toBe(1) + }) }) diff --git a/packages/react/src/hooks/useTasks.ts b/packages/react/src/hooks/useTasks.ts index 13b575444..cd17389b5 100644 --- a/packages/react/src/hooks/useTasks.ts +++ b/packages/react/src/hooks/useTasks.ts @@ -9,7 +9,7 @@ import { useQuery, type FlatNode } from './useQuery' type TaskNode = FlatNode<(typeof TaskSchema)['_properties']> type TaskStatus = NonNullable['status']> -export interface UseTasksOptions { +export type UseTasksOptions = { pageId?: string | null assigneeDid?: string | null includeCompleted?: boolean @@ -18,13 +18,13 @@ export interface UseTasksOptions { dueDateFilter?: 'any' | 'overdue' | 'today' | 'next-7-days' | 'none' } -export interface TaskTreeItem { +export type TaskTreeItem = { task: TaskNode depth: number children: TaskTreeItem[] } -export interface UseTasksResult { +export type UseTasksResult = { data: TaskNode[] tree: TaskTreeItem[] loading: boolean @@ -135,10 +135,21 @@ function buildTaskTree(tasks: TaskNode[]): TaskTreeItem[] { continue } - current.depth = parent.depth + 1 parent.children.push(current) } + const setDepth = (node: TaskTreeItem, depth: number): void => { + node.depth = depth + + for (const child of node.children) { + setDepth(child, depth + 1) + } + } + + for (const root of roots) { + setDepth(root, 0) + } + return roots } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e1567e180..f05013d69 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -502,6 +502,7 @@ export { type XNetContextValue, type XNetProviderProps } from './context' +export { PageTasksPanel, type PageTasksPanelProps } from './components/PageTasksPanel' export { TaskCollectionEmbed, type TaskCollectionEmbedProps diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1565637d3..2f7b29f37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1004,6 +1004,9 @@ importers: '@xnetjs/plugins': specifier: workspace:* version: link:../plugins + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@18.3.1) y-protocols: specifier: ^1.0.6 version: 1.0.7(yjs@13.6.29)