From 3826a2cbe28e98790b4f036610e2656f86ed3bb5 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Fri, 27 Feb 2026 10:22:28 -0600 Subject: [PATCH] tag prefixing via @ and # --- src/components/NotesSidebar.tsx | 2 +- src/components/TagPicker.tsx | 6 +-- src/components/TaskCard.tsx | 5 ++- src/components/TaskDetail.tsx | 11 ++--- src/store/index.ts | 78 +++++++++------------------------ src/views/NotepadView.tsx | 9 ++-- 6 files changed, 39 insertions(+), 72 deletions(-) diff --git a/src/components/NotesSidebar.tsx b/src/components/NotesSidebar.tsx index 69e9e9d..6e79bc0 100644 --- a/src/components/NotesSidebar.tsx +++ b/src/components/NotesSidebar.tsx @@ -81,7 +81,7 @@ export const NotesSidebar: Component<{ {(t) => ( - #{t} + {t} )} diff --git a/src/components/TagPicker.tsx b/src/components/TagPicker.tsx index 4a82546..917ecee 100644 --- a/src/components/TagPicker.tsx +++ b/src/components/TagPicker.tsx @@ -24,8 +24,8 @@ export const TagPicker: Component = (props) => { const allTagNames = () => { const definedTags = store.tagDefinitions.map(d => d.name); - const bucketTags = store.buckets.map(b => b.name); - const noteTags = store.notes.map(n => n.title); + const bucketTags = store.buckets.map(b => `@${b.name}`); + const noteTags = store.notes.map(n => `#${n.title}`); return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort(); }; @@ -52,7 +52,7 @@ export const TagPicker: Component = (props) => { if (!name) return; // update global definition - const bucket = store.buckets.find(b => b.name === name); + const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name); const color = bucket ? bucket.color : undefined; upsertTagDefinition(name, valueScore(), color, resolvedTheme()); diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index 018b5d3..8669566 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -33,8 +33,9 @@ export const TaskCard: Component<{ task: Task }> = (props) => { if (typeof ctx === 'object' && 'bucketId' in ctx) { // We are in a bucket view. Hide the tag that matches this bucket's name. - const bucketName = (ctx as { name: string }).name.toLowerCase(); - return tags.filter(t => t.toLowerCase() !== bucketName); + // Buckets are now tagged with '@' + name. + const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`; + return tags.filter(t => t.toLowerCase() !== bucketTagName); } return tags; diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index a932e42..e93f753 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -101,10 +101,10 @@ export const TaskDetail: Component = (props) => { // If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags') if (typeof ctx === 'object' && 'bucketId' in ctx) { - const bucketName = (ctx as { name: string }).name; + const bucketTagName = `@${(ctx as { name: string }).name}`; // Check case-insensitive existence - if (!newTags.some(t => t.toLowerCase() === bucketName.toLowerCase())) { - newTags.push(bucketName); + if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) { + newTags.push(bucketTagName); } } @@ -129,8 +129,9 @@ export const TaskDetail: Component = (props) => { if (typeof ctx === 'object' && 'bucketId' in ctx) { // We are in a bucket view. Hide the tag that matches this bucket's name. - const bucketName = (ctx as { name: string }).name.toLowerCase(); - return tags.filter(t => t.toLowerCase() !== bucketName); + // Buckets are now tagged with '@' + name. + const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`; + return tags.filter(t => t.toLowerCase() !== bucketTagName); } return tags; diff --git a/src/store/index.ts b/src/store/index.ts index 77184a4..3fe2be2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -260,28 +260,16 @@ export const matchesFilter = (task: Task) => { task.tags?.includes(r.tagName || ""); }); - // Bucket visibility logic for "My Bucket": - // Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users." - // AND "In settings a user should be able to see and edit all buckets... They should be able to add any bucket to their workspaces list." - // When in 'mine', do we show bucket tasks? - // Typically 'My Bucket' is just MY stuff + stuff shared explicitly with me. - // Buckets seem to be separate "Contexts" in the sidebar. - // However, if the user wants them mixed in, they didn't explicitly say "mix into my main list", - // they said "add that bucket to their workspaces list". This usually implies a separate view. - // BUT, if I create a task and tag it "Shop", I expect to see it. - - // If I own the task, I see it. - // If it's shared with me, I see it. - + // Bucket tasks are now tagged with '@' + name. if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false; } else if (typeof ctx === 'object' && 'bucketId' in ctx) { // Bucket View const bucketProxy = store.buckets.find(b => b.id === (ctx as { bucketId: string }).bucketId); if (!bucketProxy) return false; - // Show tasks that have the bucket name as a tag - // The requirement: "if any user adds a tag that is the name of a bucket to a task that task is then made public and moved to the bucket." - if (!task.tags?.includes(bucketProxy.name)) return false; + // Show tasks that have the bucket name (prefixed with @) as a tag + const bucketTagName = `@${bucketProxy.name}`; + if (!task.tags?.includes(bucketTagName)) return false; } else { // Context is Oversight for specific user // Show ONLY tasks owned by that user @@ -545,13 +533,9 @@ const isTaskInSubscribedBucket = (task: any): boolean => { if (taskTags.length === 0) return false; // Check if any of the task's tags match a bucket we are subscribed to - // We need to look up bucket names from our subscribed bucket IDs - // This is slightly expensive if we iterate. - // Optimization: Pre-calculate subscribed bucket names? - // For now, simple find. return store.subscribedBuckets.some(subId => { const bucket = store.buckets.find(b => b.id === subId); - return bucket && taskTags.includes(bucket.name); + return bucket && taskTags.includes(`@${bucket.name}`); }); }; @@ -1315,17 +1299,18 @@ const checkForHandoffs = (task: Task, updates: Partial): Partial => // Check for any tag that triggers a SEND_TASK rule for (const tag of updates.tags) { + // Handle @ prefix in rule matching const rule = store.shareRules.find(r => r.ownerId === currentUserId && r.type === 'tag' && - r.tagName === tag && + (r.tagName === tag || `@${r.tagName}` === tag) && r.share_mode === 'SEND_TASK' ); if (rule) { console.log(`Handoff triggered by tag "${tag}" -> Transferring to ${rule.targetUserId}`); - // Check if it's a bucket tag (target is self, but tag is a bucket) + // Check if it's a bucket tag const isBucketRule = rule.targetUserId === currentUserId && store.tagDefinitions.find(t => t.name === tag)?.isBucket; if (isBucketRule) { @@ -1633,12 +1618,13 @@ export const syncSystemTagsAndRules = async () => { // --- Sync Bucket Tags --- for (const bucket of store.buckets) { - const tagExists = tagDefinitions.find(t => t.name === bucket.name); + const bucketTagName = `@${bucket.name}`; + const tagExists = tagDefinitions.find(t => t.name === bucketTagName); if (!tagExists) { try { const record = await pb.collection(TAGS_COLLECTION).create({ user: currentUserId, - name: bucket.name, + name: bucketTagName, value: 5, color: bucket.color || "#64748b", theme: "dark", @@ -1653,23 +1639,22 @@ export const syncSystemTagsAndRules = async () => { isBucket: true }]); } catch (e) { - console.error(`Failed to create bucket tag ${bucket.name}`, e); + console.error(`Failed to create bucket tag ${bucketTagName}`, e); } } else if (!tagExists.isBucket) { try { await pb.collection(TAGS_COLLECTION).update(tagExists.id, { isBucket: true }); setStore("tagDefinitions", d => d.id === tagExists.id, { isBucket: true }); } catch (e) { - console.error(`Failed to flag tag ${bucket.name} as bucket`, e); + console.error(`Failed to flag tag ${bucketTagName} as bucket`, e); } } // --- Sync Bucket System Rule --- - // Ensure a ShareRule exists for this bucket so it appears in "System Rules" const bucketRuleExists = normalizedRules.some(r => r.ownerId === currentUserId && r.targetUserId === currentUserId && r.type === 'tag' && - r.tagName === bucket.name + (r.tagName === bucket.name || r.tagName === bucketTagName) ); if (!bucketRuleExists) { @@ -1678,19 +1663,20 @@ export const syncSystemTagsAndRules = async () => { ownerId: currentUserId, targetUserId: currentUserId, type: 'tag', - tagName: bucket.name, + tagName: bucketTagName, share_mode: 'SEND_TASK' }); } catch (e) { - console.error(`Failed to ensure system rule for bucket ${bucket.name}`, e); + console.error(`Failed to ensure system rule for bucket ${bucketTagName}`, e); } } } // --- Sync User Tags --- for (const user of users) { - const userName = user.name || user.email; - if (!userName) continue; + const originalName = user.name || user.email; + if (!originalName) continue; + const userName = `@${originalName}`; const tagExists = tagDefinitions.find(t => t.name === userName); if (!tagExists) { @@ -1723,34 +1709,13 @@ export const syncSystemTagsAndRules = async () => { } } - // --- User Share Rule Sync --- - // OPTIMIZATION: We no longer create individual share rules for every user-pair. - // Instead, we rely on the "Global Rule" (Self-Rule) created above for the user's own name. - // When User A tags a task "Bob", the Global Rule for Bob (Owner=Bob, Target=Bob, Tag=Bob) - // will pick it up via the updated `shouldTaskBeVisible` logic. - - // If we want to support "Collab" (Edit access), we might still need specific rules or specific shares, - // but for "View" access (Handoff), the Global Rule is sufficient. - - // We do NOT delete existing rules here to avoid data loss during transition, - // unless strictly requested. The user said "I want to optimize share rules... make them ownerless". - // So we stop creating new ones. - - /* - if (user.id !== currentUserId) { - // Legacy loop removed to prevent exponential growth - } - */ - // --- Ensure Self-Rule for Current User (Global Subscription) --- - // This is the critical piece for "Global Sharing". - // We must ensure the user has a rule: Owner=Me, Target=Me, Tag=Me. if (user.id === currentUserId) { const selfRuleExists = normalizedRules.some(r => r.ownerId === currentUserId && r.targetUserId === currentUserId && r.type === 'tag' && - r.tagName === userName + (r.tagName === originalName || r.tagName === userName) ); if (!selfRuleExists) { @@ -1760,10 +1725,9 @@ export const syncSystemTagsAndRules = async () => { targetUserId: currentUserId, type: 'tag', tagName: userName, - share_mode: 'ADD_USER' // Mode doesn't strictly matter for self-rule visibility, but ADD_USER is safe default + share_mode: 'ADD_USER' }); - // Update local store immediately to ensure loadAllHistory sees it const newRule: ShareRule = { id: record.id, ownerId: currentUserId, diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index 2a8a3ff..2b05ea8 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -53,7 +53,8 @@ export const NotepadView: Component<{ await handleUpdateNote(id, { title: newTitle }); // 2. Rename the TagDefinition (preserves color) and propagate to all tagged tasks - await renameTagDefinition(oldTitle, newTitle); + // Use '#' prefix for note tags + await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`); }; const handleDeleteNote = async (id: string) => { try { @@ -86,11 +87,12 @@ export const NotepadView: Component<{ const linkedTasks = createMemo(() => { const note = activeNote(); if (!note) return []; + const noteTagName = `#${note.title}`; return store.tasks.filter(t => { // Explicit relation if (note.tasks && note.tasks.includes(t.id)) return true; - // Tag relation (case-insensitive title match) - if (t.tags && t.tags.some(tag => tag.toLowerCase() === note.title.toLowerCase())) return true; + // Tag relation (case-insensitive title match with # prefix) + if (t.tags && t.tags.some(tag => tag.toLowerCase() === noteTagName.toLowerCase())) return true; return false; }); }); @@ -206,7 +208,6 @@ export const NotepadView: Component<{ {(tag) => ( - {tag}