From e5c7666a2dfde1b11849f5acf05f28b1e49d172c Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Tue, 17 Feb 2026 13:23:25 -0600 Subject: [PATCH] share modes partially implemented --- src/store/index.ts | 191 ++++++++++++++++++++++++++++--------- src/views/SettingsView.tsx | 116 +++++++++++++++++----- 2 files changed, 240 insertions(+), 67 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 131de7e..a86bb81 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -128,6 +128,7 @@ export interface TagDefinition { color?: string; theme?: "light" | "dark"; isUser?: boolean; // New flag to identify user-based tags + isBucket?: boolean; // New flag to identify bucket-based tags } export interface FilterTag { @@ -158,6 +159,7 @@ export interface ShareRule { targetUserId: string; type: 'tag' | 'all'; tagName?: string; + share_mode?: 'ADD_USER' | 'SEND_TASK'; // Default to ADD_USER if undefined } export interface Bucket { @@ -404,7 +406,8 @@ const mapRecordToShareRule = (r: any): ShareRule => { ownerId: r.ownerId, targetUserId: r.targetUserId, type: r.type as 'tag' | 'all', - tagName: r.tagName + tagName: r.tagName, + share_mode: r.share_mode || 'ADD_USER' }; }; @@ -707,15 +710,17 @@ export const initStore = async () => { name: r.name, value: r.value, color: r.color, - theme: r.theme + theme: r.theme, + isUser: r.isUser || false, + isBucket: r.isBucket || false })); setStore("tagDefinitions", reconcile(loadedTags)); } catch (tagErr) { console.warn("Failed to load tags:", tagErr); } - // 1.8 Sync User Tags & Share Rules - await syncUserTagsAndRules(); + // 1.8 Sync System Tags & Share Rules + await syncSystemTagsAndRules(); // 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL const currentUserId = pb.authStore.model?.id; @@ -955,6 +960,7 @@ export const createBucket = async (name: string, color: string, description?: st color, description }); + await syncSystemTagsAndRules(); toast.success("Bucket created"); } catch (err) { console.error("Failed to create bucket:", err); @@ -965,6 +971,9 @@ export const createBucket = async (name: string, color: string, description?: st export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string }) => { try { await pb.collection(BUCKETS_COLLECTION).update(id, data); + if (data.name) { + await syncSystemTagsAndRules(); + } toast.success("Bucket updated"); } catch (err) { console.error("Failed to update bucket:", err); @@ -1070,12 +1079,50 @@ export const copyTask = async (id: string) => { toast.success("Task duplicated"); }; +const checkForHandoffs = (task: Task, updates: Partial): Partial => { + // Only check if tags are being modified + if (!updates.tags) return updates; + + const currentUserId = pb.authStore.model?.id; + if (!currentUserId || task.ownerId !== currentUserId) return updates; + + // Check for any tag that triggers a SEND_TASK rule + for (const tag of updates.tags) { + const rule = store.shareRules.find(r => + r.ownerId === currentUserId && + r.type === 'tag' && + r.tagName === tag && + r.share_mode === 'SEND_TASK' + ); + + if (rule) { + console.log(`Handoff triggered by tag "${tag}" -> Transferring to ${rule.targetUserId}`); + return { + ...updates, + ownerId: rule.targetUserId, + // Also clear sharedWith for the specific target user if they were already shared + sharedWith: (task.sharedWith || []).filter(s => s.userId !== rule.targetUserId) + }; + } + } + + return updates; +}; + export const updateTask = async (id: string, updates: Partial) => { if (!pb.authStore.isValid) return; + // 0. Check for Hand-off Logic (Tag-based ownership transfer) + const currentTask = store.tasks.find(t => t.id === id); + let finalUpdates = updates; + + if (currentTask) { + finalUpdates = checkForHandoffs(currentTask, updates); + } + // Optimistic update with timestamp to prevent stale realtime events const optimisticUpdates = { - ...updates, + ...finalUpdates, updated: new Date().toISOString() }; setStore("tasks", (t) => t.id === id, optimisticUpdates); @@ -1083,15 +1130,21 @@ export const updateTask = async (id: string, updates: Partial) => { try { // Map Task fields to PB fields if needed (dates are ISO strings, so should matches) // Check for specific fields being updated - const pbUpdates: any = { ...updates }; - if (updates.deletedAt !== undefined) { + const pbUpdates: any = { ...finalUpdates }; + + if (finalUpdates.ownerId) { + pbUpdates.user = finalUpdates.ownerId; // Map ownerId to 'user' field in PB + delete pbUpdates.ownerId; + } + + if (finalUpdates.deletedAt !== undefined) { // PB expects a date string or null for date fields usually, // but if we defined deletedAt as a 'date' field in PB. // If we defined it as a number in PB? User didn't specify. // Assuming it's a 'Date' field in PB based on previous conversation // "deletedAt | Date | No" - if (updates.deletedAt) { - pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString(); + if (finalUpdates.deletedAt) { + pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString(); } else { pbUpdates.deletedAt = null; // restore } @@ -1223,7 +1276,8 @@ export const upsertTagDefinition = async (name: string, value: number, color?: s name: record.name, value: record.value, color: record.color, - theme: record.theme as "light" | "dark" + theme: record.theme as "light" | "dark", + isBucket: record.isBucket || false }; setStore("tagDefinitions", (prev) => [...prev, newDef]); } @@ -1257,28 +1311,28 @@ export const removeTagDefinition = async (name: string) => { -export const syncUserTagsAndRules = async () => { +export const syncSystemTagsAndRules = async () => { if (!pb.authStore.isValid) return; const currentUserId = pb.authStore.model?.id; try { - // 1. Fetch only verified users - const usersPromise = pb.collection('users').getFullList({ - filter: 'verified = true', - fields: 'id,name,email,verified', - requestKey: null - }); + // 1. Fetch data + const [users, pbRules, tagDefinitions] = await Promise.all([ + pb.collection('users').getFullList({ + filter: 'verified = true', + fields: 'id,name,email,verified', + requestKey: null + }), + pb.collection(SHARE_RULES_COLLECTION).getFullList({ + filter: `ownerId = "${currentUserId}"`, + requestKey: null + }), + pb.collection(TAGS_COLLECTION).getFullList({ + filter: `user = "${currentUserId}"`, + requestKey: null + }) + ]); - // 2. Fetch existing rules DIRECTLY from PB to avoid store race conditions - const rulesPromise = pb.collection(SHARE_RULES_COLLECTION).getFullList({ - filter: `ownerId = "${currentUserId}"`, - requestKey: null - }); - - const [users, pbRules] = await Promise.all([usersPromise, rulesPromise]); - - // 2a. Map PB rules to check for duplicates - const existingTags = store.tagDefinitions; const normalizedRules = pbRules.map(r => ({ id: r.id, targetUserId: r.targetUserId, @@ -1286,21 +1340,55 @@ export const syncUserTagsAndRules = async () => { tagName: r.tagName })); + // --- Sync Bucket Tags --- + for (const bucket of store.buckets) { + const tagExists = tagDefinitions.find(t => t.name === bucket.name); + if (!tagExists) { + try { + const record = await pb.collection(TAGS_COLLECTION).create({ + user: currentUserId, + name: bucket.name, + value: 5, + color: bucket.color || "#64748b", + theme: "dark", + isBucket: true + }); + setStore("tagDefinitions", prev => [...prev, { + id: record.id, + name: record.name, + value: record.value, + color: record.color, + theme: record.theme, + isBucket: true + }]); + } catch (e) { + console.error(`Failed to create bucket tag ${bucket.name}`, 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); + } + } + } + + // --- Sync User Tags --- for (const user of users) { const userName = user.name || user.email; if (!userName) continue; - // --- Tag Sync --- - const tagExists = existingTags.find(t => t.name === userName); + const tagExists = tagDefinitions.find(t => t.name === userName); if (!tagExists) { - // ... create tag (logic unchanged) ... try { const record = await pb.collection(TAGS_COLLECTION).create({ user: currentUserId, name: userName, value: 5, color: "#64748b", - theme: "dark" + theme: "dark", + isUser: true }); setStore("tagDefinitions", prev => [...prev, { id: record.id, @@ -1313,15 +1401,17 @@ export const syncUserTagsAndRules = async () => { } catch (e) { // console.error(`Failed to create user tag ${userName}`, e); } - } else { - if (!tagExists.isUser) { + } else if (!tagExists.isUser) { + try { + await pb.collection(TAGS_COLLECTION).update(tagExists.id, { isUser: true }); setStore("tagDefinitions", d => d.id === tagExists.id, { isUser: true }); + } catch (e) { + console.error(`Failed to flag tag ${userName} as user`, e); } } - // --- Share Rule Sync --- + // --- User Share Rule Sync --- if (user.id !== currentUserId) { - // Find ALL rules matching this criteria const matchingRules = normalizedRules.filter(r => r.targetUserId === user.id && r.type === 'tag' && @@ -1329,16 +1419,12 @@ export const syncUserTagsAndRules = async () => { ); if (matchingRules.length === 0) { - console.log(`Creating auto-share rule for ${userName}`); - await addShareRule('tag', user.id, userName); + await addShareRule('tag', user.id, userName, 'ADD_USER'); } else if (matchingRules.length > 1) { - // DUPLICATE DETECTED: Keep first, delete others - console.warn(`Duplicate share rules found for ${userName}. Cleaning up...`); const [, ...remove] = matchingRules; for (const rule of remove) { try { await pb.collection(SHARE_RULES_COLLECTION).delete(rule.id); - // Update local store immediately to reflect deletion setStore("shareRules", list => list.filter(r => r.id !== rule.id)); } catch (e) { console.error("Failed to delete duplicate rule:", e); @@ -1349,7 +1435,7 @@ export const syncUserTagsAndRules = async () => { } } catch (err) { - console.error("Failed to sync user tags/rules:", err); + console.error("Failed to sync system tags/rules:", err); } }; @@ -1589,7 +1675,8 @@ export const loadShareRules = async () => { ownerId: r.ownerId, targetUserId: r.targetUserId, type: r.type as 'tag' | 'all', - tagName: r.tagName + tagName: r.tagName, + share_mode: r.share_mode || 'ADD_USER' })); setStore("shareRules", reconcile(rules)); @@ -1598,7 +1685,7 @@ export const loadShareRules = async () => { } }; -export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string) => { +export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => { if (!pb.authStore.isValid) return; try { @@ -1606,7 +1693,8 @@ export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, ta ownerId: pb.authStore.model?.id, targetUserId, type, - tagName: type === 'tag' ? tagName : null + tagName: type === 'tag' ? tagName : null, + share_mode: share_mode || 'ADD_USER' }); // Realtime subscription will handle adding to store toast.success(`Share rule created`); @@ -1616,6 +1704,21 @@ export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, ta } }; +export const updateShareRule = async (ruleId: string, updates: Partial) => { + if (!pb.authStore.isValid) return; + + try { + await pb.collection(SHARE_RULES_COLLECTION).update(ruleId, updates); + setStore("shareRules", (rules) => + rules.map(r => r.id === ruleId ? { ...r, ...updates } : r) + ); + toast.success("Share rule updated"); + } catch (err) { + console.error("Failed to update share rule:", err); + toast.error("Failed to update share rule."); + } +}; + export const removeShareRule = async (ruleId: string) => { if (!pb.authStore.isValid) return; diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index aef15c5..ccad2c8 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -1,6 +1,6 @@ import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js"; import { ThemeToggle } from "../components/ThemeToggle"; -import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store"; +import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store"; import { useTheme } from "@/components/ThemeProvider"; import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, Upload, Box, Edit2, Archive } from "lucide-solid"; import { TagPicker } from "@/components/TagPicker"; @@ -26,6 +26,7 @@ export const SettingsView: Component = () => { const [expandedTemplateId, setExpandedTemplateId] = createSignal(null); const [shareUserId, setShareUserId] = createSignal(""); const [shareType, setShareType] = createSignal<'all' | 'tag'>('all'); + const [shareMode, setShareMode] = createSignal<'ADD_USER' | 'SEND_TASK'>('ADD_USER'); const [shareTag, setShareTag] = createSignal(""); const [allUsers, setAllUsers] = createSignal>([]); const [usersLoading, setUsersLoading] = createSignal(false); @@ -128,10 +129,47 @@ export const SettingsView: Component = () => { + {/* Share Mode Selection */} +
+ + +
+