diff --git a/.env.devdata b/.env.devdata new file mode 100644 index 0000000..2054d62 --- /dev/null +++ b/.env.devdata @@ -0,0 +1 @@ +VITE_TASGRID_DATA_MODE=dev diff --git a/package.json b/package.json index b3850f2..155cf66 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,11 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 4001", + "dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata", "build": "tsc -b && vite build", + "build:devdata": "tsc -b && vite build --mode devdata", "serve": "vite preview --host 0.0.0.0 --port 4000", + "serve:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata", "---PM2 COMMANDS---": "------------------", "status": "pm2 status", "logs": "pm2 logs tasgrid", diff --git a/src/components/EmbedLayout.tsx b/src/components/EmbedLayout.tsx index 60a75d6..714ecdf 100644 --- a/src/components/EmbedLayout.tsx +++ b/src/components/EmbedLayout.tsx @@ -7,7 +7,12 @@ interface EmbedLayoutProps { export const EmbedLayout: Component = (props) => { return (
-
+
{props.children}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 731f390..b9e659e 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -196,10 +196,15 @@ export const Layout: Component = (props) => { {/* Main Content Area */} -
+
void; isMobile?: boolean; }> = (props) => { - const currentUserId = pb.authStore.model?.id; const [open, setOpen] = createSignal(false); // Sync with parent if needed @@ -136,37 +135,37 @@ export const ContextSwitcher: Component<{ } }); - const [oversightUsers] = createResource( - () => [...new Set(store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId))], - async (ownerIds) => { - if (ownerIds.length === 0) return []; - try { - // Fetch user names for these IDs in a single query - const filter = ownerIds.map(id => `id="${id}"`).join(' || '); - const users = await pb.collection('users').getFullList({ - filter, - fields: 'id,name,email', - requestKey: null // Disable auto-cancellation - }); - return users.map(u => ({ id: u.id, name: u.name || u.email })); - } catch (e: any) { - if (e.isAbort) return []; // Ignore explicit aborts - console.error("Failed to load oversight users", e); - return []; - } - } - ); - const ctx = currentTaskContext; // Accessor + const personalContext = () => + store.contexts.find(context => + !context.deletedAt && + context.kind === "user" && + context.targetUserId === pb.authStore.model?.id + ) || null; + const isPersonalContext = () => { + const current = ctx(); + const personal = personalContext(); + return current === 'mine' || ( + typeof current === "object" && + "bucketId" in current && + !!personal && + current.bucketId === personal.id + ); + }; const label = () => { const c = ctx(); - return c === 'mine' ? "My Bucket" : c.name; + return isPersonalContext() ? "My Bucket" : (c === 'mine' ? "My Bucket" : c.name); }; const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => { if (type === 'mine') { - setCurrentTaskContext('mine'); + const personal = personalContext(); + if (personal) { + setCurrentTaskContext({ bucketId: personal.id, name: "My Bucket", isPersonal: true }); + } else { + setCurrentTaskContext('mine'); + } } else if (type === 'bucket') { setCurrentTaskContext({ bucketId: id, name }); } else { @@ -182,7 +181,7 @@ export const ContextSwitcher: Component<{ } }; - const hasContexts = () => (oversightUsers() && oversightUsers()!.length > 0) || store.subscribedBuckets.length > 0; + const hasContexts = () => !!personalContext() || store.subscribedBuckets.length > 0 || store.supervisedContexts.length > 0; return ( @@ -197,9 +196,9 @@ export const ContextSwitcher: Component<{
-
@@ -209,8 +208,8 @@ export const ContextSwitcher: Component<{ }>
- - {ctx() !== 'mine' && ( + + {!isPersonalContext() && (
)}
@@ -223,7 +222,7 @@ export const ContextSwitcher: Component<{ onClick={() => handleSwitch('mine', '', 'mine')} class={cn( "w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors", - ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground" + isPersonalContext() ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground" )} >
@@ -260,31 +259,32 @@ export const ContextSwitcher: Component<{ - 0}> + 0}>
- Shared User Buckets - {oversightUsers()?.length} + Supervised Contexts + {store.supervisedContexts.length}
- - {(user) => ( + + {(supervised) => ( )} +
diff --git a/src/components/QuickEntryForm.tsx b/src/components/QuickEntryForm.tsx index 868bd76..968c5c3 100644 --- a/src/components/QuickEntryForm.tsx +++ b/src/components/QuickEntryForm.tsx @@ -224,7 +224,7 @@ export const QuickEntryForm: Component = (props) => { } } else if (typeof ctx === 'object') { if ('bucketId' in ctx) { - const bucketName = ctx.name; + const bucketName = store.contexts.find(context => context.id === ctx.bucketId)?.displayName || ctx.name; if (bucketName) { const tag = `@${bucketName}`; if (!finalTags.includes(tag)) finalTags.push(tag); diff --git a/src/components/TagPicker.tsx b/src/components/TagPicker.tsx index 5e0139b..316aec4 100644 --- a/src/components/TagPicker.tsx +++ b/src/components/TagPicker.tsx @@ -4,6 +4,8 @@ import { useTheme } from "./ThemeProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Tag, Plus } from "lucide-solid"; +import { toast } from "solid-sonner"; +import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey } from "@/lib/tags"; interface TagPickerProps { selectedTags: string[]; @@ -24,11 +26,11 @@ export const TagPicker: Component = (props) => { const allTagNames = () => { const definedTags = store.tagDefinitions.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name); - const bucketTags = store.buckets.map(b => `@${b.name}`); + const contextTags = store.contexts.filter(c => !c.deletedAt).map(c => buildShareTag(c.displayName)); const noteTags = store.notes .filter(n => !(n.tags || []).some(t => t.startsWith("child-of-"))) - .map(n => `#${n.title}`); - return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort(); + .map(n => buildNoteTag(n.key || n.title)); + return [...new Set([...definedTags, ...contextTags, ...noteTags])].sort(); }; const suggestedTags = () => { @@ -53,10 +55,28 @@ export const TagPicker: Component = (props) => { const name = selectedTagName().trim(); if (!name) return; + if (name.startsWith("@")) { + const exists = store.contexts.some(context => context.key === normalizeContextKey(name)); + if (!exists) { + toast.error("Create the shared context first before tagging tasks with it."); + return; + } + } + + if (name.startsWith("#")) { + const exists = store.notes.some(note => note.key === normalizeNoteKey(name)); + if (!exists) { + toast.error("Create the canonical note first before linking it with a #tag."); + return; + } + } + // update global definition const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name); const color = bucket ? bucket.color : undefined; - upsertTagDefinition(name, valueScore(), color, resolvedTheme()); + if (!name.startsWith("@") && !name.startsWith("#")) { + upsertTagDefinition(name, valueScore(), color, resolvedTheme()); + } // Toggle in current task selection if not already there const currentSelected = props.selectedTags; diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index d6871eb..28b21a3 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -8,6 +8,7 @@ import { getThemeAdjustedColor } from "@/lib/colors"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { getStatusOptionsForTask } from "@/lib/constants"; import { getRecurrenceProgress } from "@/lib/recurrence"; +import { buildShareTag } from "@/lib/tags"; import { StatusCircle } from "./StatusCircle"; export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => { @@ -33,9 +34,8 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) const ctx = currentTaskContext(); if (typeof ctx === 'object' && 'bucketId' in ctx) { - // We are in a bucket view. Hide the tag that matches this bucket's name. - // Buckets are now tagged with '@' + name. - const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`; + const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); + const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase(); return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__")); } @@ -139,23 +139,13 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) P{props.task.priority}
- {/* Shared Indicator - shows for individual shares OR tag-based ShareRules */} + {/* Shared Indicator - driven by @tag share contexts */} {(() => { - const hasIndividualShares = props.task.sharedWith && props.task.sharedWith.length > 0; - // Check if any tag-based ShareRules match this task's tags (exclude 'all' type) - const hasTagRuleShares = store.shareRules.some(rule => - rule.type === 'tag' && - rule.tagName && - props.task.tags?.includes(rule.tagName) - ); - const isShared = hasIndividualShares || hasTagRuleShares; + if (props.task.shareRefs.length === 0) return null; - if (!isShared) return null; - - const shareCount = (props.task.sharedWith?.length || 0); - const tooltip = hasIndividualShares - ? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}` - : 'Shared via tag rule'; + const tooltip = props.task.shareRefs.length === 1 + ? 'Shared via 1 @context' + : `Shared via ${props.task.shareRefs.length} @contexts`; return (
diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index bb3af70..66aa565 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -1,9 +1,9 @@ import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js"; import { Sheet, SheetContent } from "@/components/ui/sheet"; -import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store"; +import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, FileText } from "lucide-solid"; +import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Star, FileText } from "lucide-solid"; import { StatusCircle } from "./StatusCircle"; import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store"; import { Button } from "./ui/button"; @@ -16,8 +16,8 @@ import { toast } from "solid-sonner"; import { lazy, Suspense } from "solid-js"; import { useTheme } from "./ThemeProvider"; import { getThemeAdjustedColor } from "@/lib/colors"; -import { pb } from "@/lib/pocketbase"; import { getRecurrenceProgress } from "@/lib/recurrence"; +import { buildShareTag } from "@/lib/tags"; import { TextBubbleMenu } from "@/components/TextBubbleMenu"; import { TableBubbleMenu } from "@/components/TableBubbleMenu"; @@ -174,7 +174,8 @@ 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 bucketTagName = `@${(ctx as { name: string }).name}`; + const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); + const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name); // Check case-insensitive existence if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) { newTags.push(bucketTagName); @@ -201,9 +202,8 @@ export const TaskDetail: Component = (props) => { const ctx = currentTaskContext(); if (typeof ctx === 'object' && 'bucketId' in ctx) { - // We are in a bucket view. Hide the tag that matches this bucket's name. - // Buckets are now tagged with '@' + name. - const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`; + const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); + const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase(); return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__")); } @@ -682,11 +682,32 @@ export const TaskDetail: Component = (props) => { {/* Sharing Section */}
Sharing
- +
+

+ Sharing is driven by the task's @tags. Add or remove + @people and @buckets in the tag list above. +

+ 0} + fallback={

No shared contexts on this task.

} + > +
+ + {(ref) => { + const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key); + const label = `@${context?.displayName || ref.key}`; + const policy = context?.policy || (ref.kind === "bucket" ? "handoff" : "collaborative"); + return ( + + {label} + {policy} + + ); + }} + +
+
+
@@ -757,139 +778,3 @@ const MetadataItem: Component<{ ); }; -// User Share Picker Component -const UserSharePicker: Component<{ - taskId: string; - sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>; - tags: string[]; -}> = (props) => { - const [users, setUsers] = createSignal>([]); - const [selectedUserId, setSelectedUserId] = createSignal(""); - const [loading, setLoading] = createSignal(true); - - // Get matching ShareRules for this task - const matchingRules = () => { - const currentUserId = pb.authStore.model?.id; - return store.shareRules.filter(rule => { - if (rule.ownerId !== currentUserId) return false; - if (rule.type === 'all') return true; - if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true; - return false; - }); - }; - - const getUserName = (userId: string) => { - return users().find(u => u.id === userId)?.name || userId; - }; - - // Fetch users on mount - createEffect(async () => { - try { - const records = await pb.collection('users').getFullList({ - fields: 'id,name' - }); - setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id }))); - } catch (err) { - console.error("Failed to load users:", err); - } finally { - setLoading(false); - } - }); - - - const availableUsers = () => { - const sharedUserIds = props.sharedWith.map(s => s.userId); - const currentUserId = pb.authStore.model?.id; - return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId); - }; - - return ( - <> -
- - value={users().find(u => u.id === selectedUserId()) || null} - onChange={(user) => setSelectedUserId(user?.id || "")} - options={availableUsers()} - optionValue="id" - optionTextValue="name" - placeholder={loading() ? "Loading..." : "Select user..."} - itemComponent={(itemProps: any) => ( - {itemProps.item.rawValue.name} - )} - > - - > - {(state) => state.selectedOption()?.name || "Select user..."} - - - - - -
- - {(share) => ( -
- {getUserName(share.userId)} -
- - -
-
- )} -
- - {/* ShareRule-based sharing */} - {matchingRules().length > 0 && ( -
-
Automatic Rule Shares
- - {(rule) => ( -
-
- {rule.type === 'tag' ? : } -
-
- - {getUserName(rule.targetUserId)} - - - {rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`} - -
-
- )} -
-
- )} - - ); -}; diff --git a/src/lib/app-config.ts b/src/lib/app-config.ts new file mode 100644 index 0000000..839f31d --- /dev/null +++ b/src/lib/app-config.ts @@ -0,0 +1,11 @@ +const rawMode = (import.meta.env.VITE_TASGRID_DATA_MODE || "prod").toLowerCase(); + +export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod"; +export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev"; + +export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro"; +export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth"; +export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid"; +export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod"; + +export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 824d574..d929870 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,3 +1,5 @@ +import { withDataSuffix } from "@/lib/app-config"; + export const PRIORITY_OPTIONS = [ { value: "10", label: "10 - Necessary" }, { value: "9", label: "9" }, @@ -36,11 +38,12 @@ export const URGENCY_HOURS: Record = { 1: 4320 }; -export const TASGRID_COLLECTION = 'TasGrid'; -export const TAGS_COLLECTION = 'TasGrid_Tags'; -export const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules'; -export const BUCKETS_COLLECTION = 'buckets'; -export const NOTES_COLLECTION = 'TasGrid_Notes'; +export const TASGRID_COLLECTION = withDataSuffix('TasGrid'); +export const CONTEXTS_COLLECTION = withDataSuffix('TasGrid_Contexts'); +export const NOTES_COLLECTION = withDataSuffix('TasGrid_Notes'); +export const TAGS_COLLECTION = CONTEXTS_COLLECTION; +export const SHARE_RULES_COLLECTION = CONTEXTS_COLLECTION; +export const BUCKETS_COLLECTION = CONTEXTS_COLLECTION; export const SIZE_OPTIONS = [ { value: "10", label: "10 - Two weeks" }, diff --git a/src/lib/mention-suggestion.tsx b/src/lib/mention-suggestion.tsx index bea5272..89b3abd 100644 --- a/src/lib/mention-suggestion.tsx +++ b/src/lib/mention-suggestion.tsx @@ -2,14 +2,16 @@ import { store } from "@/store"; import { render } from "solid-js/web"; import tippy, { type Instance as TippyInstance } from "tippy.js"; import { MentionList } from "@/components/MentionList"; +import { buildShareTag } from "@/lib/tags"; export const userSuggestion = { items: ({ query }: { query: string }) => { - return store.tagDefinitions - .filter(tag => tag.isUser && tag.name.toLowerCase().substring(1).startsWith(query.toLowerCase())) - .map(tag => ({ - id: tag.name, // Use the tag name as ID for sharing logic - label: tag.name.substring(1), + return store.contexts + .filter(context => context.kind === "user" && !context.deletedAt) + .filter(context => context.displayName.toLowerCase().startsWith(query.toLowerCase())) + .map(context => ({ + id: buildShareTag(context.displayName), + label: context.displayName, type: 'user' as const })); }, @@ -90,10 +92,10 @@ export const noteSuggestion = { items: ({ query }: { query: string }) => { return store.notes .filter(note => !(note.tags || []).some(t => t.startsWith("child-of-"))) - .filter(note => note.title.toLowerCase().includes(query.toLowerCase())) + .filter(note => (note.key || note.title).toLowerCase().includes(query.toLowerCase())) .map(note => ({ id: note.id, - label: note.title, + label: note.key || note.title, sublabel: note.content?.substring(0, 50).replace(/<[^>]*>?/gm, ''), type: 'note' as const })); diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 1134f6d..6eb4d52 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -1,6 +1,6 @@ -import PocketBase from 'pocketbase'; +import PocketBase, { LocalAuthStore } from 'pocketbase'; +import { AUTH_STORE_KEY, POCKETBASE_URL } from "@/lib/app-config"; +import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants"; -export const pb = new PocketBase('https://pocketbase.ccllc.pro'); - -export const TASGRID_COLLECTION = 'TasGrid'; -export const TAGS_COLLECTION = 'TasGrid_Tags'; +export const pb = new PocketBase(POCKETBASE_URL, new LocalAuthStore(AUTH_STORE_KEY)); +export { TASGRID_COLLECTION, CONTEXTS_COLLECTION, NOTES_COLLECTION }; diff --git a/src/lib/tags.ts b/src/lib/tags.ts new file mode 100644 index 0000000..528575f --- /dev/null +++ b/src/lib/tags.ts @@ -0,0 +1,41 @@ +export type ParsedTag = + | { kind: "share"; raw: string; key: string; display: string } + | { kind: "note"; raw: string; key: string; display: string } + | { kind: "label"; raw: string; key: string; display: string }; + +const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " "); + +export const normalizeKey = (value: string) => + normalizeWhitespace(value) + .toLowerCase() + .replace(/^[@#]+/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +export const normalizeContextKey = (value: string) => normalizeKey(value); +export const normalizeNoteKey = (value: string) => normalizeKey(value); + +export const parseTag = (rawTag: string): ParsedTag => { + const raw = normalizeWhitespace(rawTag); + const prefix = raw[0]; + const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw; + const key = normalizeKey(display); + + if (prefix === "@") { + return { kind: "share", raw, key, display }; + } + + if (prefix === "#") { + return { kind: "note", raw, key, display }; + } + + return { kind: "label", raw, key, display }; +}; + +export const parseTags = (rawTags: string[] | undefined | null) => + (rawTags || []) + .map(parseTag) + .filter(tag => !!tag.display); + +export const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`; +export const buildNoteTag = (keyOrTitle: string) => `#${normalizeWhitespace(keyOrTitle)}`; diff --git a/src/store/index.ts b/src/store/index.ts index f41aacb..39f1fac 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,7 +2,9 @@ import { createStore, reconcile } from "solid-js/store"; import { createSignal, createEffect, createRoot } from "solid-js"; import { pb } from "@/lib/pocketbase"; import { toast } from "solid-sonner"; -import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, NOTES_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants"; +import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants"; +import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config"; +import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags"; // Helper to get the current user's personalized favorite tag export const getFavoriteTag = () => { @@ -10,16 +12,16 @@ export const getFavoriteTag = () => { return userId ? `__${userId}_favorite__` : "__favorite__"; }; -const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,user,size,recurrence,sharedWith,deletedAt'; +const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,labelTags,shareRefs,noteRefs,createdBy,user,size,recurrence,deletedAt'; const getStorageKey = () => { const userId = pb.authStore.model?.id; - return userId ? `tasgrid_data_${userId}` : null; + return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null; }; export const [now, setNow] = createSignal(Date.now()); -// Context: 'mine' = My Bucket (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View -type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string }; +// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket. +type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean }; export const [activeTaskId, setActiveTaskId] = createSignal(null); export const [activeNoteId, setActiveNoteId] = createSignal(null); export const [currentTaskContext, setCurrentTaskContext] = createSignal('mine'); @@ -35,7 +37,11 @@ export interface Task { status: number; // 0-10 completed: boolean; // Deprecated, kept for compat/computed from status = 10 tags: string[]; + labelTags: string[]; + shareRefs: TaskShareRef[]; + noteRefs: NoteRef[]; ownerId: string | null; // Add ownerId to interface + createdBy: string | null; content?: string; // HTML content from Tiptap deletedAt?: number | null; // Timestamp of soft delete or null if restored created: string; // ISO string from PB @@ -47,7 +53,7 @@ export interface Task { lastUncompleted?: string; // ISO date of last automatic reset }; size?: number; // 0-10, task complexity/size - sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with + sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; attachments?: string[]; // Array of filenames from PocketBase } @@ -139,13 +145,14 @@ export interface TagDefinition { value: number; color?: string; theme?: "light" | "dark"; - isUser?: boolean; // New flag to identify user-based tags - isBucket?: boolean; // New flag to identify bucket-based tags + isUser?: boolean; + isBucket?: boolean; } export interface Note { id: string; title: string; + key: string; content?: string; tags: string[]; isPrivate: boolean; @@ -185,7 +192,14 @@ export const startContextPolling = () => { } const ctx = currentTaskContext(); - if (typeof ctx === 'object' && 'userId' in ctx) { + const personalOrUserId = + typeof ctx === 'object' && 'userId' in ctx + ? ctx.userId + : typeof ctx === 'object' && 'bucketId' in ctx + ? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId + : null; + + if (personalOrUserId) { contextSyncInterval = window.setInterval(async () => { if (!pb.authStore.isValid) return; @@ -193,7 +207,7 @@ export const startContextPolling = () => { // Fetch only recent updates (last 30s) to minimize payload const timeStr = new Date(Date.now() - 30000).toISOString(); const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `user = "${ctx.userId}" && updated >= "${timeStr}"`, + filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`, sort: '-updated', fields: LIST_FIELDS, requestKey: 'context-poll' @@ -245,6 +259,81 @@ export interface FilterTemplate { filter: Filter; } +interface ScopedTaskgridPrefs { + pWeight?: number; + uWeight?: number; + matrixScaleDays?: number; + tagDefinitions?: TagDefinition[]; + filter?: Filter; + filterTemplates?: FilterTemplate[]; + noteFilterTemplates?: FilterTemplate[]; + quickloadTasks?: string[]; + noteFilter?: Filter; + subscribedBuckets?: string[]; + supervisorUserIds?: string[]; + migratedStructuredTagsV2?: boolean; + migratedPersonalContextRefsV1?: boolean; +} + +const PREF_ENVIRONMENTS_KEY = "environments"; + +const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => { + const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE]; + return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs; +}; + +const readScopedPrefsForMode = (rawPrefs: any, mode: string): ScopedTaskgridPrefs => { + const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[mode]; + return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs; +}; + +const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({ + ...(rawPrefs || {}), + [PREF_ENVIRONMENTS_KEY]: { + ...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}), + [TASGRID_DATA_MODE]: scopedPrefs + } +}); + +const unwrapRelationId = (value: unknown): string | null => { + if (typeof value === "string") { + return value || null; + } + + if (Array.isArray(value)) { + const first = value[0]; + return typeof first === "string" ? first : null; + } + + return null; +}; + +const relationFilter = (field: string, id: string | undefined | null) => + id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false"; + +export interface ShareContext { + id: string; + key: string; + displayName: string; + kind: 'user' | 'bucket'; + policy: 'collaborative' | 'handoff'; + targetUserId?: string | null; + color?: string; + description?: string; + deletedAt?: number | null; + deletedBy?: string | null; +} + +export interface TaskShareRef { + contextId: string; + key: string; + kind: 'user' | 'bucket'; +} + +export interface NoteRef { + noteId: string; + key: string; +} export interface ShareRule { id: string; @@ -252,7 +341,14 @@ export interface ShareRule { targetUserId: string; type: 'tag' | 'all'; tagName?: string; - share_mode?: 'ADD_USER' | 'SEND_TASK'; // Default to ADD_USER if undefined + share_mode?: 'ADD_USER' | 'SEND_TASK'; +} + +export interface SupervisedContextAccess { + ownerUserId: string; + ownerName: string; + contextId: string; + contextName: string; } export interface Bucket { @@ -260,6 +356,9 @@ export interface Bucket { name: string; color: string; description?: string; + policy?: 'collaborative' | 'handoff'; + key?: string; + deletedAt?: number | null; } interface TaskStore { @@ -273,9 +372,12 @@ interface TaskStore { templates: TaskTemplate[]; filter: Filter; shareRules: ShareRule[]; + contexts: ShareContext[]; + supervisedContexts: SupervisedContextAccess[]; filterTemplates: FilterTemplate[]; buckets: Bucket[]; subscribedBuckets: string[]; // IDs of buckets I'm subscribed to + personalContextSupervisorIds: string[]; notes: Note[]; isNotepadMode: boolean; quickloadTasks: string[]; @@ -305,9 +407,12 @@ export const [store, setStore] = createStore({ jobDivision: [] }, shareRules: [], + contexts: [], + supervisedContexts: [], filterTemplates: [], buckets: [], subscribedBuckets: [], + personalContextSupervisorIds: [], notes: [], isNotepadMode: false, quickloadTasks: [], @@ -326,55 +431,182 @@ export const [store, setStore] = createStore({ } }); +const DEFAULT_LABEL_THEME: "light" | "dark" = "dark"; + +const mapContextToBucket = (context: ShareContext): Bucket => ({ + id: context.id, + name: context.displayName, + color: context.color || "#64748b", + description: context.description, + policy: context.policy, + key: context.key, + deletedAt: context.deletedAt ?? null +}); + +const deriveBuckets = (contexts: ShareContext[]) => + contexts + .filter(context => context.kind === "bucket" && !context.deletedAt) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + .map(mapContextToBucket); + +const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] => + contexts + .filter(context => !context.deletedAt) + .map(context => ({ + id: context.id, + name: buildShareTag(context.displayName), + value: 5, + color: context.color, + theme: DEFAULT_LABEL_THEME, + isUser: context.kind === "user", + isBucket: context.kind === "bucket" + })); + +const getContextByRef = (ref: TaskShareRef) => + store.contexts.find(context => context.id === ref.contextId) || + store.contexts.find(context => context.key === ref.key && context.kind === ref.kind); + +const getNoteByRef = (ref: NoteRef) => + store.notes.find(note => note.id === ref.noteId) || + store.notes.find(note => note.key === ref.key); + +const buildTaskTags = ( + labelTags: string[], + shareRefs: TaskShareRef[], + noteRefs: NoteRef[], + favoriteTag?: string +) => { + const tags = [ + ...labelTags, + ...shareRefs.map(ref => { + const context = getContextByRef(ref); + return buildShareTag(context?.displayName || ref.key); + }), + ...noteRefs.map(ref => { + const note = getNoteByRef(ref); + return buildNoteTag(note?.key || ref.key); + }) + ]; + + if (favoriteTag && !tags.includes(favoriteTag)) { + tags.push(favoriteTag); + } + + return [...new Set(tags.filter(Boolean))]; +}; + +const withActiveContextTags = (rawTags: string[]) => { + const nextTags = [...rawTags]; + const ctx = currentTaskContext(); + + if (ctx === "mine") { + const personalContext = getPersonalContext(); + if (personalContext) { + const personalTag = buildShareTag(personalContext.displayName); + if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) { + nextTags.push(personalTag); + } + } + } else if (typeof ctx === "object") { + if ("bucketId" in ctx && ctx.name) { + const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); + const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name); + if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) { + nextTags.push(bucketTag); + } + } else if ("userId" in ctx && ctx.name) { + const userTag = buildShareTag(ctx.name); + if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) { + nextTags.push(userTag); + } + } + } + + if (store.isNotepadMode) { + const noteId = activeNoteId(); + const note = noteId ? store.notes.find(existing => existing.id === noteId) : null; + if (note) { + const noteTag = buildNoteTag(note.key || note.title); + if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) { + nextTags.push(noteTag); + } + } + } + + return [...new Set(nextTags)]; +}; + +const setContexts = (contexts: ShareContext[]) => { + setStore("contexts", reconcile(contexts)); + setStore("buckets", reconcile(deriveBuckets(contexts))); + + const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@")); + const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)]; + setStore("tagDefinitions", reconcile(mergedDefs)); +}; + +const getPersonalContext = (userId = pb.authStore.model?.id) => + userId + ? store.contexts.find(context => + !context.deletedAt && + context.kind === "user" && + context.targetUserId === userId + ) || null + : null; + export const matchesFilter = (task: Task) => { const currentUserId = pb.authStore.model?.id; const ctx = currentTaskContext(); + const personalContext = getPersonalContext(currentUserId); + const rawBucketRefs = task.shareRefs.filter(ref => ref.kind === "bucket"); + const activeShareContexts = task.shareRefs + .map(ref => getContextByRef(ref)) + .filter((context): context is ShareContext => !!context && !context.deletedAt); + const collaborativeUserShare = activeShareContexts.some(context => + context.kind === "user" && + context.policy === "collaborative" && + context.targetUserId === currentUserId + ); + const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket"); + const hasHandoffBucket = rawBucketRefs.some(ref => { + const context = getContextByRef(ref); + return !context || context.policy === "handoff"; + }); + const inPinnedBucket = rawBucketRefs.some(ref => + store.subscribedBuckets.includes(ref.contextId) || + store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key) + ); + const inAnyBucket = rawBucketRefs.length > 0; + const isPersonalView = ctx === "mine" || ( + typeof ctx === "object" && + "bucketId" in ctx && + !!personalContext && + ctx.bucketId === personalContext.id + ); + const inPersonalContext = !!personalContext && task.shareRefs.some(ref => + ref.kind === "user" && + (ref.contextId === personalContext.id || ref.key === personalContext.key) + ); // 0. Context Filter - if (ctx === 'mine') { + if (isPersonalView) { + if (hasHandoffBucket) return false; const isOwner = task.ownerId === currentUserId; - const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId); - - // Tag-based share rules targeting me - const isTagRuleShared = store.shareRules.some(r => { - if (r.targetUserId !== currentUserId) return false; - - // Global Rule / Self-Rule: Match purely on tag, ignore owner check - // logic: If I have a rule for tag "T" assigned to myself, show ANY task with tag "T" - if (r.ownerId === r.targetUserId && r.type === 'tag') { - const tagName = r.tagName || ""; - - // EXCLUDE Buckets from the main "Mine" view - // The user wants to see "Direct Shares" (User Tags), not "Bucket Subscriptions" here. - if (isBucketTag(tagName)) return false; - - return task.tags?.some(t => t.toLowerCase() === tagName.toLowerCase()); - } - - // Standard Rule: Match owner AND tag - return r.ownerId === task.ownerId && - r.type === 'tag' && - task.tags?.includes(r.tagName || ""); - }); - - // Bucket tasks are now tagged with '@' + name. - if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false; + if (!isOwner && !collaborativeUserShare && !inPersonalContext) 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 (prefixed with @) as a tag - const bucketTagName = `@${bucketProxy.name}`; - if (!task.tags?.includes(bucketTagName)) return false; + const activeBucketContext = store.contexts.find(context => context.id === (ctx as { bucketId: string }).bucketId); + const activeBucketKey = activeBucketContext?.key || normalizeContextKey((ctx as { name: string }).name); + const inCurrentBucket = rawBucketRefs.some(ref => + ref.contextId === (ctx as { bucketId: string }).bucketId || + ref.key === activeBucketKey + ) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId); + if (!inCurrentBucket) return false; } else { - // Context is Oversight for specific user - // Show ONLY tasks owned by that user - // Note: We might want to EXCLUDE bucket tasks from oversight if they are purely bucket tasks, - // but typically oversight means "everything that user owns". if (task.ownerId !== (ctx as { userId: string }).userId) return false; } + if (isPersonalView && inAnyBucket && !inPinnedBucket && task.ownerId !== currentUserId) return false; + // Hide templates from all regular views if (task.tags?.includes("__template__")) return false; @@ -557,9 +789,36 @@ export const getCombinedScore = (task: Task): number => { let heartbeatInterval: number | undefined; const mapRecordToTask = (r: any): Task => { - // Check if it's a template? The caller handles filtering templates usually, - // but here we just map fields. - const tags = r.tags || []; + const legacyTags: string[] = r.tags || []; + const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__"); + const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs) + ? r.shareRefs + : parsedTags + .filter(tag => tag.kind === "share") + .map(tag => { + const context = store.contexts.find(existing => existing.key === tag.key); + return { + contextId: context?.id || tag.key, + key: tag.key, + kind: context?.kind || "bucket" + }; + }); + const noteRefs: NoteRef[] = Array.isArray(r.noteRefs) + ? r.noteRefs + : parsedTags + .filter(tag => tag.kind === "note") + .map(tag => { + const note = store.notes.find(existing => existing.key === tag.key); + return { + noteId: note?.id || tag.key, + key: tag.key + }; + }); + const labelTags: string[] = Array.isArray(r.labelTags) + ? r.labelTags + : parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); + const favoriteTag = legacyTags.find((tag: string) => tag.endsWith("_favorite__")); + const tags = buildTaskTags(labelTags, shareRefs, noteRefs, favoriteTag); return { id: r.id, title: r.title, @@ -569,14 +828,18 @@ const mapRecordToTask = (r: any): Task => { status: r.status ?? (r.completed ? 10 : 0), completed: r.status === 10 || r.completed, tags: tags, - ownerId: r.user || null, + labelTags, + shareRefs, + noteRefs, + ownerId: unwrapRelationId(r.user), + createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user), content: r.content, deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined, created: r.created, updated: r.updated, recurrence: r.recurrence, size: (r.size === null || r.size === undefined) ? undefined : r.size, - sharedWith: r.sharedWith, + sharedWith: [], attachments: r.attachments || [] }; }; @@ -687,25 +950,37 @@ export const cleanupTaskAttachments = async (taskId: string) => { } }; -const mapRecordToShareRule = (r: any): ShareRule => { - return { - id: r.id, - ownerId: r.ownerId, - targetUserId: r.targetUserId, - type: r.type as 'tag' | 'all', - tagName: r.tagName, - share_mode: r.share_mode || 'ADD_USER' - }; -}; +const mapContextToShareRule = (context: ShareContext): ShareRule => ({ + id: context.id, + ownerId: pb.authStore.model?.id || "", + targetUserId: context.targetUserId || pb.authStore.model?.id || "", + type: 'tag', + tagName: buildShareTag(context.displayName), + share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER" +}); + +const mapRecordToContext = (r: any): ShareContext => ({ + id: r.id, + key: r.key || normalizeContextKey(r.displayName || r.name || ""), + displayName: r.displayName || r.name || r.key, + kind: (r.kind || "bucket") as "user" | "bucket", + policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff", + targetUserId: unwrapRelationId(r.targetUserId), + color: r.color, + description: r.description, + deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null, + deletedBy: unwrapRelationId(r.deletedBy) +}); const mapRecordToNote = (r: any): Note => { return { id: r.id, title: r.title, + key: r.key || normalizeNoteKey(r.title), content: r.content, - tags: r.tags || [], + tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)], isPrivate: r.isPrivate || false, - user: r.user, + user: unwrapRelationId(r.user) || "", tasks: r.tasks || [], created: r.created, updated: r.updated, @@ -739,72 +1014,21 @@ export const updateStoreWithNote = (note: Note) => { // Helper to check if a task should be visible based on ownership, shares, or rules const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { - // Own task - if (task.user === currentUserId) return true; + const mappedTask = "shareRefs" in task ? task as Task : mapRecordToTask(task); + if (mappedTask.ownerId === currentUserId) return true; + if (store.supervisedContexts.some(context => context.ownerUserId === mappedTask.ownerId)) return true; - // Individually shared - const sharedWith = task.sharedWith || []; - if (sharedWith.some((s: any) => s.userId === currentUserId)) return true; + if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true; - // ShareRule match - const matchingRule = store.shareRules.find(rule => { - if (rule.targetUserId !== currentUserId) return false; + const contexts = mappedTask.shareRefs + .map(ref => getContextByRef(ref)) + .filter((context): context is ShareContext => !!context && !context.deletedAt); - // Global Rule Logic: If owner == target, it's a "Global Subscription" to that tag. - // We ignore the rule.ownerId check (which would match the task.user) - // and instead match purely on the tag. - if (rule.ownerId === rule.targetUserId && rule.type === 'tag') { - if (isBucketTag(rule.tagName || "")) return false; - const tags: string[] = task.tags || []; - return tags.some(t => t.toLowerCase() === (rule.tagName || "").toLowerCase()); - } - - // Standard Rule Logic: Match specific owner - if (task.user !== rule.ownerId) return false; - if (rule.type === 'all') return true; - if (rule.type === 'tag') { - const tags = task.tags || []; - return tags.includes(rule.tagName); - } - return false; - }); - - // Bucket check - if (isTaskInSubscribedBucket(task)) return true; - - return !!matchingRule; -}; - -// Check if task is visible because it belongs to a subscribed bucket -const isTaskInSubscribedBucket = (task: any): boolean => { - const taskTags = task.tags || []; - if (taskTags.length === 0) return false; - - // Check if any of the task's tags match a bucket we are subscribed to - return store.subscribedBuckets.some(subId => { - const bucket = store.buckets.find(b => b.id === subId); - return bucket && taskTags.includes(`@${bucket.name}`); - }); -}; - -// Helper: detect bucket tags robustly (case-insensitive, with or without '@') -const isBucketTag = (tagName: string): boolean => { - const raw = (tagName || "").trim().toLowerCase(); - if (!raw) return false; - const normalized = raw.startsWith("@") ? raw.slice(1) : raw; - - const bucketNameMatch = store.buckets.some(b => { - const bName = (b.name || "").trim().toLowerCase(); - return normalized === bName || raw === `@${bName}`; - }); - if (bucketNameMatch) return true; - - const bucketTagDefMatch = store.tagDefinitions.some(d => { - if (!d.isBucket) return false; - const dName = (d.name || "").trim().toLowerCase(); - return raw === dName || normalized === dName || raw === `@${dName}`; - }); - return bucketTagDefMatch; + return contexts.some(context => + context.kind === "user" && + context.policy === "collaborative" && + context.targetUserId === currentUserId + ); }; export const subscribeToRealtime = async () => { @@ -841,7 +1065,7 @@ export const subscribeToRealtime = async () => { const exists = store.tasks.find(t => t.id === e.record.id); if (!exists) { const currentUserId = pb.authStore.model?.id; - const isOwnTask = e.record.user === currentUserId; + const isOwnTask = unwrapRelationId(e.record.user) === currentUserId; // Only add if it's own task or should be visible via sharing if (isOwnTask || (currentUserId && shouldTaskBeVisible(e.record, currentUserId))) { @@ -871,7 +1095,7 @@ export const subscribeToRealtime = async () => { const currentUserId = pb.authStore.model?.id; const existingTask = store.tasks.find(t => t.id === e.record.id); - const isOwnTask = e.record.user === currentUserId; + const isOwnTask = unwrapRelationId(e.record.user) === currentUserId; const incomingUpdated = new Date(e.record.updated).getTime(); if (existingTask) { @@ -915,7 +1139,7 @@ export const subscribeToRealtime = async () => { const currentUserId = pb.authStore.model?.id; // Only process if public or owned by current user - const isVisible = !e.record.isPrivate || e.record.user === currentUserId; + const isVisible = !e.record.isPrivate || unwrapRelationId(e.record.user) === currentUserId; if (e.action === 'create' || e.action === 'update') { if (isVisible) { @@ -936,91 +1160,22 @@ export const subscribeToRealtime = async () => { } }); - // Subscribe to Buckets - // We need to know if buckets are added/removed/renamed - await pb.collection(BUCKETS_COLLECTION).unsubscribe('*'); - pb.collection(BUCKETS_COLLECTION).subscribe('*', async (e) => { - if (e.action === 'create' || e.action === 'update') { - const bucket = { - id: e.record.id, - name: e.record.name, - color: e.record.color, - description: e.record.description - }; - - setStore("buckets", (prev) => { - const exists = prev.find(b => b.id === bucket.id); - if (exists) return prev.map(b => b.id === bucket.id ? bucket : b); - return [...prev, bucket]; - }); - } - + await pb.collection(CONTEXTS_COLLECTION).unsubscribe('*'); + pb.collection(CONTEXTS_COLLECTION).subscribe('*', (e) => { if (e.action === 'delete') { - setStore("buckets", (prev) => prev.filter(b => b.id !== e.record.id)); - // Also remove from subscribed if present? - // The user record might still have it, but we can filter display. - } - }); - - // Subscribe to Share Rules - await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*'); - pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => { - const currentUserId = pb.authStore.model?.id; - - if (e.action === 'create') { - const newRule = mapRecordToShareRule(e.record); - - // Add to shareRules store - setStore("shareRules", (rules) => { - if (rules.some(r => r.id === newRule.id)) return rules; - return [...rules, newRule]; - }); - - // If I'm the target, fetch matching tasks from the owner - if (newRule.targetUserId === currentUserId) { - try { - let filter = `user = "${newRule.ownerId}"`; - if (newRule.type === 'tag' && newRule.tagName) { - filter += ` && tags ~ "${newRule.tagName}"`; - } - - const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter, - sort: '-created', - requestKey: null - }); - - records.forEach((r: any) => { - if (!r.tags?.includes("__template__") && !store.tasks.find(t => t.id === r.id)) { - const newTask = mapRecordToTask(r); - setStore("tasks", t => [newTask, ...t]); - } - }); - } catch (err) { - console.warn("Failed to fetch tasks for new share rule:", err); - } - } + const remaining = store.contexts.filter(context => context.id !== e.record.id); + setContexts(remaining); + setStore("shareRules", remaining.map(mapContextToShareRule)); + setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || ""))); + return; } - if (e.action === 'delete') { - const deletedRuleId = e.record.id; - const deletedRule = store.shareRules.find(r => r.id === deletedRuleId); - - // Remove from shareRules store - setStore("shareRules", (rules) => rules.filter(r => r.id !== deletedRuleId)); - - // If I was the target, remove tasks that are no longer visible - if (deletedRule && deletedRule.targetUserId === currentUserId) { - setStore("tasks", tasks => - tasks.filter(task => shouldTaskBeVisible(task, currentUserId!)) - ); - } - } - - if (e.action === 'update') { - const updatedRule = mapRecordToShareRule(e.record); - setStore("shareRules", (rules) => rules.map(r => r.id === updatedRule.id ? updatedRule : r)); - } + const context = mapRecordToContext(e.record); + const nextContexts = store.contexts.some(existing => existing.id === context.id) + ? store.contexts.map(existing => existing.id === context.id ? context : existing) + : [...store.contexts, context]; + setContexts(nextContexts); + setStore("shareRules", nextContexts.map(mapContextToShareRule)); }); }; @@ -1030,121 +1185,200 @@ const runLegacyTagMigration = async () => { try { const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null }); - const prefs = userRec.Taskgrid_pref || {}; + const rawPrefs = userRec.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); - if (prefs.migratedLegacyTagsV1) { + if (prefs.migratedStructuredTagsV2) { return; // Already migrated } - console.log("[MIGRATION] Starting legacy tag migration..."); + console.log("[MIGRATION] Starting structured tag migration..."); - // Fetch users and buckets - const [allUsers, allBuckets] = await Promise.all([ - pb.collection('users').getFullList({ - filter: 'verified = true', - fields: 'name,email', - requestKey: null - }), - pb.collection(BUCKETS_COLLECTION).getFullList({ - fields: 'name', - requestKey: null - }) - ]); + await syncUserContexts(); - const validNames = new Set([ - ...allBuckets.map(b => b.name.toLowerCase()), - ...allUsers.map(u => (u.name || u.email)?.toLowerCase()).filter(Boolean) - ]); - - // Fetch all Tasks for this user const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `user = "${currentUserId}"`, requestKey: null }); - - let tasksUpdated = 0; - for (const t of tasks) { - if (!t.tags || t.tags.length === 0) continue; - let changed = false; - const newTags = t.tags.map((tag: string) => { - if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) { - changed = true; - return `@${tag}`; - } - return tag; - }); - - if (changed) { - await pb.collection(TASGRID_COLLECTION).update(t.id, { tags: newTags }, { requestKey: null }); - tasksUpdated++; - } + for (const taskRecord of tasks) { + if (taskRecord.tags?.includes("__template__")) continue; + const task = mapRecordToTask(taskRecord); + await pb.collection(TASGRID_COLLECTION).update(task.id, { + tags: task.tags, + labelTags: task.labelTags, + shareRefs: task.shareRefs, + noteRefs: task.noteRefs, + createdBy: task.createdBy || task.ownerId || currentUserId + }, { requestKey: null }); } - // Fetch all Notes for this user const notes = await pb.collection(NOTES_COLLECTION).getFullList({ - filter: `user = "${currentUserId}"`, requestKey: null }); - - let notesUpdated = 0; - for (const n of notes) { - if (!n.tags || n.tags.length === 0) continue; - let changed = false; - const newTags = n.tags.map((tag: string) => { - if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) { - changed = true; - return `@${tag}`; - } - return tag; - }); - - if (changed) { - await pb.collection(NOTES_COLLECTION).update(n.id, { tags: newTags }, { requestKey: null }); - notesUpdated++; - } + for (const noteRecord of notes) { + const note = mapRecordToNote(noteRecord); + await pb.collection(NOTES_COLLECTION).update(note.id, { + key: note.key, + tags: note.tags + }, { requestKey: null }); } - prefs.migratedLegacyTagsV1 = true; - await pb.collection('users').update(currentUserId, { Taskgrid_pref: prefs }, { requestKey: null }); - - if (pb.authStore.model) { - pb.authStore.model.Taskgrid_pref = prefs; - } - - console.log(`[MIGRATION] Completed. Updated ${tasksUpdated} tasks and ${notesUpdated} notes.`); - - // Update local state directly so it reflects immediately - setStore("tasks", (tasks) => tasks.map(t => { - let localChanged = false; - const nt = t.tags?.map(tag => { - if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) { - localChanged = true; - return `@${tag}`; - } - return tag; - }); - if (localChanged) return { ...t, tags: nt }; - return t; - })); - - setStore("notes", (items) => items.map(n => { - let localChanged = false; - const nt = n.tags?.map(tag => { - if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) { - localChanged = true; - return `@${tag}`; - } - return tag; - }); - if (localChanged) return { ...n, tags: nt }; - return n; - })); - + const nextPrefs = { ...prefs, migratedStructuredTagsV2: true }; + await pb.collection('users').update(currentUserId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs) + }, { requestKey: null }); } catch (err) { console.error("[MIGRATION] Failed:", err); } }; +const syncUserContexts = async () => { + const allUsers = await pb.collection('users').getFullList({ + filter: 'verified = true', + fields: 'id,name,email', + requestKey: null + }); + const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + requestKey: null + }).catch(() => []); + const contextByKey = new Map(existingContexts.map((context: any) => [context.key || normalizeContextKey(context.displayName || context.name), context])); + let createdAny = false; + + for (const user of allUsers) { + const displayName = user.name || user.email; + const key = normalizeContextKey(displayName); + if (!displayName || contextByKey.has(key)) continue; + const created = await pb.collection(CONTEXTS_COLLECTION).create({ + key, + displayName, + kind: "user", + policy: "collaborative", + targetUserId: user.id + }, { requestKey: null }).catch(() => null); + if (created) { + contextByKey.set(key, created); + createdAny = true; + } + } + + if (createdAny || store.contexts.length === 0) { + const nextContexts = Array.from(contextByKey.values()).map(mapRecordToContext); + setContexts(nextContexts); + setStore("shareRules", nextContexts.map(mapContextToShareRule)); + } +}; + +const syncSupervisedContexts = async () => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId) { + setStore("supervisedContexts", []); + return; + } + + try { + const users = await pb.collection('users').getFullList({ + fields: 'id,name,email,Taskgrid_pref', + requestKey: null + }); + + const nextSupervisedContexts = users + .filter((user: any) => user.id !== currentUserId) + .filter((user: any) => { + const prefs = readScopedPrefsForMode(user.Taskgrid_pref || {}, TASGRID_DATA_MODE); + return (prefs.supervisorUserIds || []).includes(currentUserId); + }) + .map((user: any) => { + const ownerName = user.name || user.email || user.id; + const personalContext = store.contexts.find(context => + !context.deletedAt && + context.kind === "user" && + context.targetUserId === user.id + ); + + return personalContext ? { + ownerUserId: user.id, + ownerName, + contextId: personalContext.id, + contextName: personalContext.displayName + } : null; + }) + .filter((value): value is SupervisedContextAccess => !!value) + .sort((a, b) => a.ownerName.localeCompare(b.ownerName)); + + setStore("supervisedContexts", nextSupervisedContexts); + } catch (err) { + console.error("Failed to load supervised contexts:", err); + } +}; + +const runPersonalContextMigration = async () => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId) return; + + try { + const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null }); + const rawPrefs = userRec.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + + if (prefs.migratedPersonalContextRefsV1) { + return; + } + + const contexts = store.contexts.length > 0 + ? store.contexts + : (await pb.collection(CONTEXTS_COLLECTION).getFullList({ requestKey: null })).map(mapRecordToContext); + const ownerContextByUserId = new Map( + contexts + .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) + .map(context => [context.targetUserId as string, context]) + ); + + const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ + fields: LIST_FIELDS, + requestKey: null + }); + + for (const taskRecord of taskRecords) { + if (taskRecord.tags?.includes("__template__")) continue; + + const task = mapRecordToTask(taskRecord); + if (!task.ownerId) continue; + + const ownerContext = ownerContextByUserId.get(task.ownerId); + if (!ownerContext) continue; + + const alreadyHasOwnerContext = task.shareRefs.some(ref => + ref.kind === "user" && + (ref.contextId === ownerContext.id || ref.key === ownerContext.key) + ); + if (alreadyHasOwnerContext) continue; + + const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); + const nextShareRefs = [ + { + contextId: ownerContext.id, + key: ownerContext.key, + kind: "user" as const + }, + ...task.shareRefs + ]; + + await pb.collection(TASGRID_COLLECTION).update(task.id, { + shareRefs: nextShareRefs, + tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, favoriteTag) + }, { requestKey: null }); + } + + await pb.collection('users').update(currentUserId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...prefs, + migratedPersonalContextRefsV1: true + }) + }, { requestKey: null }); + } catch (err) { + console.error("[MIGRATION] Failed personal context backfill:", err); + } +}; + export const initStore = async () => { if (!pb.authStore.isValid || store.isInitializing) return; @@ -1156,7 +1390,7 @@ export const initStore = async () => { // Goal: < 100ms First Paint. Doesn't wait for any other network requests. if (currentUserId) { try { - const prefs = pb.authStore.model?.Taskgrid_pref || {}; + const prefs = readScopedPrefs(pb.authStore.model?.Taskgrid_pref || {}); const cachedQuickload = prefs.quickloadTasks || []; let focusRecords; @@ -1172,7 +1406,7 @@ export const initStore = async () => { focusRecords = { items: res }; } else { focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, { - filter: `user = "${currentUserId}" && completed = false && deletedAt = ""`, + filter: `${relationFilter("user", currentUserId)} && completed = false && deletedAt = ""`, sort: '-priority,dueDate', requestKey: null }); @@ -1215,14 +1449,17 @@ export const initStore = async () => { const userId = pb.authStore.model?.id; if (userId) { const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const prefs = user.Taskgrid_pref || {}; + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); setStore({ pWeight: prefs.pWeight || 1.0, uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, prefId: userId, - subscribedBuckets: user.subscribedBuckets || [], + subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || [], + personalContextSupervisorIds: prefs.supervisorUserIds || [], + tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")), + filterTemplates: prefs.filterTemplates || [], quickloadTasks: prefs.quickloadTasks || [], noteFilter: prefs.noteFilter || { query: "", @@ -1233,32 +1470,45 @@ export const initStore = async () => { urgencyMax: 10, editedToday: false, ownedByMe: false, - starred: false - } + starred: false, + jobStatus: [], + jobDivision: [] + }, + filter: prefs.filter || store.filter }); } } catch (prefErr) { console.warn("Failed to load preferences:", prefErr); } - // 1.2 Fetch Buckets + // 1.2 Fetch Contexts try { - const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name', requestKey: null }); - setStore("buckets", buckets.map((b: any) => ({ - id: b.id, - name: b.name, - color: b.color, - description: b.description - }))); - } catch (bucketErr) { - console.warn("Failed to load buckets (collection might not exist yet):", bucketErr); + const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + sort: 'displayName,key', + requestKey: null + }); + const contexts = contextRecords.map(mapRecordToContext); + setContexts(contexts); + setStore("shareRules", contexts.map(mapContextToShareRule)); + await syncUserContexts(); + await syncSupervisedContexts(); + const personalContext = store.contexts.find(context => + !context.deletedAt && + context.kind === "user" && + context.targetUserId === pb.authStore.model?.id + ); + if (personalContext && currentTaskContext() === "mine") { + setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true }); + } + } catch (contextErr) { + console.warn("Failed to load contexts (collection might not exist yet):", contextErr); } // 1.3 Fetch Notes try { const currentUserId = pb.authStore.model?.id; const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({ - filter: `isPrivate = false || user = "${currentUserId}"`, + filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`, sort: '-created', requestKey: null }); @@ -1267,29 +1517,10 @@ export const initStore = async () => { console.warn("Failed to load notes (collection might not exist yet):", notesErr); } - // 1.5. Fetch Tag Definitions - try { - const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({ - filter: `user = "${pb.authStore.model?.id}"`, - requestKey: null - }); - const loadedTags: TagDefinition[] = tagRecords.map(r => ({ - id: r.id, - name: r.name, - value: r.value, - color: r.color, - 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 Fetch Share Rules (CRITICAL for filter construction) - // We need these loaded BEFORE we construct the focus filter - await loadShareRules(); + setStore("tagDefinitions", defs => { + const plainDefs = defs.filter(def => !def.name.startsWith("@")); + return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)]; + }); const currentUserId = pb.authStore.model?.id; if (!currentUserId) return; @@ -1301,98 +1532,47 @@ export const initStore = async () => { // STAGE 2 & 3: Background Sync const backgroundSync = async () => { - - const bucketNames = store.subscribedBuckets - .map(id => store.buckets.find(b => b.id === id)?.name) + const subscribedBucketKeys = store.subscribedBuckets + .map(id => store.contexts.find(context => context.id === id)?.key) .filter(Boolean); - - // Sync System Tags & Share Rules FIRST to ensure Self-Rules exist for query construction - await syncSystemTagsAndRules(); - - // Add rules from shareRules store (which was just synced) - const incomingRules = store.shareRules.filter(r => r.targetUserId === currentUserId); + const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); + const collaborativeKeys = store.contexts + .filter(context => + !context.deletedAt && + context.kind === "user" && + context.policy === "collaborative" && + context.targetUserId === currentUserId + ) + .map(context => buildShareTag(context.displayName)); // 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability - // We must replicate the complex sharing logic here const incompletePromises = [ - // Own Incomplete pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `user = "${currentUserId}" && completed = false`, + filter: `${relationFilter("user", currentUserId)} && completed = false`, sort: '-created', fields: LIST_FIELDS, requestKey: null }), - // Shared With Individual Incomplete - pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `sharedWith ~ "${currentUserId}" && completed = false`, + (subscribedBucketKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `(${subscribedBucketKeys.map(key => `tags ~ "@${key}"`).join(' || ')}) && completed = false`, sort: '-created', fields: LIST_FIELDS, requestKey: null - }).catch(() => []), - // Bucket Incomplete - (bucketNames.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `(${bucketNames.map(name => `tags ~ "${name}"`).join(' || ')}) && completed = false`, + }).catch(() => []) : Promise.resolve([]), + (collaborativeKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `(${collaborativeKeys.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`, + sort: '-created', + fields: LIST_FIELDS, + requestKey: null + }).catch(() => []) : Promise.resolve([]), + (supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => []) : Promise.resolve([]) ]; - // Share Rules Incomplete - if (incomingRules.length > 0) { - // Separate "Global Rules" (Self-Rules) from "Specific User Rules" - const globalTagRules: string[] = []; - const specificUserRules = new Map(); - - incomingRules.forEach((rule: any) => { - if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) { - globalTagRules.push(rule.tagName); - } else { - const existing = specificUserRules.get(rule.ownerId) || []; - existing.push({ type: rule.type, tagName: rule.tagName }); - specificUserRules.set(rule.ownerId, existing); - } - }); - - // 1. Fetch Global Rule Tasks (from ANY user) - // 1. Fetch Global Rule Tasks (from ANY user) - if (globalTagRules.length > 0) { - // We want tasks that have ANY of these tags, AND are not completed - // filter: (tags ~ "A" || tags ~ "B") && completed = false - const tagFilter = globalTagRules.map(t => `tags ~ "${t}"`).join(' || '); - incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `(${tagFilter}) && completed = false`, - sort: '-created', - fields: LIST_FIELDS, - requestKey: null - }).catch(() => [])); - } - - // 2. Fetch Specific User Rule Tasks (from specific owner) - Array.from(specificUserRules.entries()).forEach(([ownerId, rules]) => { - const hasAllRule = rules.some(r => r.type === 'all'); - const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean); - - let filter = `user = "${ownerId}" && completed = false`; - - if (hasAllRule) { - // Get everything from this user - } else if (tagRules.length > 0) { - // Get specific tags from this user - filter += ` && (${tagRules.map(t => `tags ~ "${t}"`).join(' || ')})`; - } else { - return; // No valid rules for this user - } - - incompletePromises.push(pb.collection(TASGRID_COLLECTION).getFullList({ - filter, - sort: '-created', - fields: LIST_FIELDS, - requestKey: null - }).catch(() => [])); - }); - } - const stage2Results = await Promise.all(incompletePromises); const allIncomplete = stage2Results.flat(); @@ -1450,7 +1630,7 @@ export const initStore = async () => { if (remainingSlots > 0) { try { const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, { - filter: `user = "${currentUserId}" && completed = true`, + filter: `${relationFilter("user", currentUserId)} && completed = true`, sort: '-updated', fields: LIST_FIELDS, requestKey: null @@ -1491,11 +1671,12 @@ export const initStore = async () => { // Run one-time migration for legacy tags await runLegacyTagMigration(); + await runPersonalContextMigration(); // Separate Templates load (less critical) try { const templates = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `user = "${currentUserId}" && tags ~ "__template__"`, + filter: `${relationFilter("user", currentUserId)} && tags ~ "__template__"`, requestKey: null }); const loadedTemplates = templates.map(r => { @@ -1548,16 +1729,22 @@ export const toggleBucketSubscription = async (bucketId: string) => { const userId = pb.authStore.model?.id; if (userId) { try { + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const scopedPrefs = readScopedPrefs(rawPrefs); await pb.collection('users').update(userId, { - subscribedBuckets: newSubscribed + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...scopedPrefs, + subscribedBuckets: newSubscribed + }) }); // If we just subscribed, we should fetch the tasks for this bucket immediately if (!isSubscribed) { - const bucketName = store.buckets.find(b => b.id === bucketId)?.name; - if (bucketName) { + const bucket = store.buckets.find(b => b.id === bucketId); + if (bucket) { const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `tags ~ "${bucketName}"`, + filter: `tags ~ "${buildShareTag(bucket.name)}"`, sort: '-created', fields: LIST_FIELDS }); @@ -1585,12 +1772,15 @@ export const toggleBucketSubscription = async (bucketId: string) => { export const createBucket = async (name: string, color: string, description?: string) => { try { - await pb.collection(BUCKETS_COLLECTION).create({ - name, + const key = normalizeContextKey(name); + await pb.collection(CONTEXTS_COLLECTION).create({ + key, + displayName: name, + kind: "bucket", + policy: "handoff", color, description }); - await syncSystemTagsAndRules(); toast.success("Bucket created"); } catch (err) { console.error("Failed to create bucket:", err); @@ -1598,12 +1788,12 @@ export const createBucket = async (name: string, color: string, description?: st } }; -export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string }) => { +export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => { try { - await pb.collection(BUCKETS_COLLECTION).update(id, data); - if (data.name) { - await syncSystemTagsAndRules(); - } + const payload: any = { ...data }; + if (data.name) payload.key = normalizeContextKey(data.name); + if (data.name) payload.displayName = data.name; + await pb.collection(CONTEXTS_COLLECTION).update(id, payload); toast.success("Bucket updated"); } catch (err) { console.error("Failed to update bucket:", err); @@ -1613,14 +1803,51 @@ export const updateBucket = async (id: string, data: { name?: string, color?: st export const deleteBucket = async (id: string) => { try { - await pb.collection(BUCKETS_COLLECTION).delete(id); - toast.success("Bucket deleted"); + await pb.collection(CONTEXTS_COLLECTION).update(id, { + deletedAt: new Date().toISOString(), + deletedBy: pb.authStore.model?.id || null + }); + toast.success("Bucket moved to trash"); } catch (err) { console.error("Failed to delete bucket:", err); toast.error("Failed to delete bucket"); } }; +export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => { + try { + await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null }); + toast.success("User context updated"); + } catch (err) { + console.error("Failed to update user context:", err); + toast.error("Failed to update user context"); + } +}; + +export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => { + const userId = pb.authStore.model?.id; + if (!userId) return; + + const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))]; + setStore("personalContextSupervisorIds", uniqueSupervisorIds); + + try { + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const scopedPrefs = readScopedPrefs(rawPrefs); + await pb.collection('users').update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...scopedPrefs, + supervisorUserIds: uniqueSupervisorIds + }) + }, { requestKey: null }); + toast.success("Personal supervision updated"); + } catch (err) { + console.error("Failed to update personal supervision:", err); + toast.error("Failed to update personal supervision"); + } +}; + export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => { if (!pb.authStore.isValid) return; @@ -1632,9 +1859,30 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string const startDate = new Date().toISOString(); const priority = options?.priority ?? 5; - const tags = options?.tags ?? ["work"]; + const tags = withActiveContextTags(options?.tags ?? ["work"]); const content = options?.content ?? ""; const size = options?.size ?? 3; + const parsedTags = parseTags(tags); + const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); + const shareRefs = parsedTags + .filter(tag => tag.kind === "share") + .map(tag => { + const context = store.contexts.find(existing => existing.key === tag.key); + return { + contextId: context?.id || tag.key, + key: tag.key, + kind: context?.kind || "bucket" + } satisfies TaskShareRef; + }); + const noteRefs = parsedTags + .filter(tag => tag.kind === "note") + .map(tag => { + const note = store.notes.find(existing => existing.key === tag.key); + return { + noteId: note?.id || tag.key, + key: tag.key + } satisfies NoteRef; + }); const tempId = "temp-" + Date.now(); const initialTask: Task = { @@ -1645,8 +1893,12 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string priority, status: 0, completed: false, - tags, + tags: buildTaskTags(labelTags, shareRefs, noteRefs), + labelTags, + shareRefs, + noteRefs, ownerId: pb.authStore.model?.id || "", + createdBy: pb.authStore.model?.id || "", content, size, created: new Date().toISOString(), @@ -1664,27 +1916,19 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string try { const record = await pb.collection(TASGRID_COLLECTION).create({ user: newTask.ownerId, // Use the (potentially updated) ownerId + createdBy: newTask.createdBy, title, startDate, dueDate, priority, status: 0, completed: false, - tags, + tags: newTask.tags, + labelTags: newTask.labelTags, + shareRefs: newTask.shareRefs, + noteRefs: newTask.noteRefs, content, - size, - // If checking for handoffs added specific sharedWith, we might need to handle that, - // but currently checkForHandoffs modifies ownerId/sharedWith. - // PB API expects 'sharedWith' if we want to set it, but our types might be complex? - // Wait, checkForHandoffs returns Partial. - // If it sets ownerId to someone else, we rely on 'user' field in create. - // If it sets sharedWith (e.g. for bucket rule clearing), we need to send that too? - // Standard create doesn't support 'sharedWith' directly in this code block usually? - // Let's check mapRecordToTask. sharedWith comes from record expansion usually. - // But if we are creating, we might need to set standard relation fields if they exist. - // However, the original code didn't set sharedWith. - // The handoff logic usually just changes OWNER. - // If it changes owner, 'user' field covers it. + size }); // Replace temp task with real record (merging server fields like id, created, updated) @@ -1701,7 +1945,8 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string id: record.id, created: record.created, updated: record.updated, - ownerId: record.user // Add missing ownerId from record + ownerId: unwrapRelationId(record.user), + createdBy: unwrapRelationId(record.createdBy) || unwrapRelationId(record.user) } : task); }); } catch (err) { @@ -1729,42 +1974,17 @@ export const copyTask = async (id: string) => { }; 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) { - // Handle @ prefix in rule matching - const rule = store.shareRules.find(r => - r.ownerId === pb.authStore.model?.id && - r.type === '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 - const isBucketRule = rule.targetUserId === currentUserId && store.tagDefinitions.find(t => t.name === tag)?.isBucket; - - if (isBucketRule) { - return { - ...updates, - ownerId: null, // Unassign from current user (null is better for PB relations) - // Remove current user from sharedWith to ensure it disappears from their view - sharedWith: (task.sharedWith || []).filter(s => s.userId !== currentUserId) - }; - } - + const refs = updates.shareRefs || task.shareRefs; + for (const ref of refs) { + const context = getContextByRef(ref); + if (!context || context.policy !== "handoff") continue; + if (context.kind === "user" && context.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) + ownerId: context.targetUserId }; } } @@ -1798,6 +2018,39 @@ export const updateTask = async (id: string, updates: Partial) => { } if (currentTask) { + if (finalUpdates.tags) { + const parsedTags = parseTags(finalUpdates.tags); + finalUpdates.labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); + finalUpdates.shareRefs = parsedTags + .filter(tag => tag.kind === "share") + .map(tag => { + const context = store.contexts.find(existing => existing.key === tag.key); + return { + contextId: context?.id || tag.key, + key: tag.key, + kind: context?.kind || "bucket" + } satisfies TaskShareRef; + }); + finalUpdates.noteRefs = parsedTags + .filter(tag => tag.kind === "note") + .map(tag => { + const note = store.notes.find(existing => existing.key === tag.key); + return { + noteId: note?.id || tag.key, + key: tag.key + } satisfies NoteRef; + }); + } + + if (finalUpdates.labelTags || finalUpdates.shareRefs || finalUpdates.noteRefs) { + const favoriteTag = currentTask.tags.find(tag => tag.endsWith("_favorite__")); + finalUpdates.tags = buildTaskTags( + finalUpdates.labelTags || currentTask.labelTags, + finalUpdates.shareRefs || currentTask.shareRefs, + finalUpdates.noteRefs || currentTask.noteRefs, + favoriteTag + ); + } finalUpdates = checkForHandoffs(currentTask, finalUpdates); } @@ -1817,6 +2070,9 @@ export const updateTask = async (id: string, updates: Partial) => { pbUpdates.user = finalUpdates.ownerId || null; // Map ownerId to 'user' field in PB delete pbUpdates.ownerId; } + if (finalUpdates.createdBy !== undefined) { + pbUpdates.createdBy = finalUpdates.createdBy; + } if ("deletedAt" in finalUpdates) { // PB expects a date string or null for date fields. @@ -1875,8 +2131,6 @@ export const updateTask = async (id: string, updates: Partial) => { export const updateTaskField = (id: string, field: keyof Task, value: any) => { setStore("tasks", (t) => t.id === id, field, value); - // Debounce this? - // For now, fire and forget update updateTask(id, { [field]: value }); }; @@ -1928,17 +2182,16 @@ export const deleteTaskPermanently = async (id: string) => { export const upsertTagDefinition = async (name: string, value: number, color?: string, theme?: "light" | "dark") => { if (!pb.authStore.isValid) return; + if (name.startsWith("@")) { + toast.error("Shared @tags are managed from contexts."); + return; + } + const existing = store.tagDefinitions.find(d => d.name === name); const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light"); try { if (existing) { - const updateData: any = { value }; - if (color !== undefined) updateData.color = color; - if (theme !== undefined) updateData.theme = theme; - - await pb.collection(TAGS_COLLECTION).update(existing.id, updateData, { requestKey: null }); - setStore("tagDefinitions", (d) => d.id === existing.id, { value, color: color !== undefined ? color : existing.color, @@ -1970,24 +2223,24 @@ export const upsertTagDefinition = async (name: string, value: number, color?: s return; } - const record = await pb.collection(TAGS_COLLECTION).create({ - user: pb.authStore.model?.id, + const record = { + id: `pref-${normalizeContextKey(name) || Date.now().toString()}`, name, value, color: color || "#6366f1", // Default indigo theme: finalTheme - }, { requestKey: null }); + }; const newDef: TagDefinition = { id: record.id, name: record.name, value: record.value, color: record.color, - theme: record.theme as "light" | "dark", - isBucket: record.isBucket || false + theme: record.theme as "light" | "dark" }; setStore("tagDefinitions", (prev) => [...prev, newDef]); } + await syncPreferences(); } catch (err) { console.error("Failed to upsert tag:", err); toast.error("Failed to save tag definition."); @@ -2000,7 +2253,6 @@ export const removeTagDefinition = async (name: string) => { if (!def) return; try { - await pb.collection(TAGS_COLLECTION).delete(def.id); setStore("tagDefinitions", (prev) => prev.filter(d => d.id !== def.id)); const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name)); @@ -2009,6 +2261,7 @@ export const removeTagDefinition = async (name: string) => { updateTask(task.id, { tags: nextTags }); } + await syncPreferences(); toast.success(`Tag "${name}" deleted`); } catch (err) { console.error("Failed to delete tag:", err); @@ -2022,6 +2275,7 @@ export const updateNote = async (id: string, updates: Partial) => { // Optimistic update const optimisticUpdates = { ...updates, + key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined), updated: new Date().toISOString() }; setStore("notes", (n) => n.id === id, optimisticUpdates); @@ -2037,6 +2291,10 @@ export const updateNote = async (id: string, updates: Partial) => { } } + if (!pbUpdates.key && updates.title) { + pbUpdates.key = normalizeNoteKey(updates.title); + } + await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null }); } catch (err) { console.error("Note update failed", err); @@ -2067,204 +2325,13 @@ export const deleteNotePermanently = async (id: string) => { } }; - - export const syncSystemTagsAndRules = async () => { if (!pb.authStore.isValid) return; - const currentUserId = pb.authStore.model?.id; - - try { - // 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 - }) - ]); - - const normalizedRules = pbRules.map(r => ({ - id: r.id, - ownerId: r.ownerId, - targetUserId: r.targetUserId, - type: r.type, - tagName: r.tagName - })); - - // Fetch task content on demand - // We'll call this when a task is opened - - // --- Sync Bucket Tags --- - for (const bucket of store.buckets) { - const bucketTagName = `@${bucket.name}`; - const tagExistsExact = tagDefinitions.find(t => t.name.toLowerCase() === bucketTagName.toLowerCase()); - if (!tagExistsExact) { - try { - const record = await pb.collection(TAGS_COLLECTION).create({ - user: currentUserId, - name: bucketTagName, - value: 5, - color: bucket.color || "#64748b", - theme: "dark", - isBucket: true - }, { requestKey: null }); - 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 ${bucketTagName}`, e); - } - } else if (!tagExistsExact.isBucket) { - try { - await pb.collection(TAGS_COLLECTION).update(tagExistsExact.id, { isBucket: true }, { requestKey: null }); - setStore("tagDefinitions", d => d.id === tagExistsExact.id, { isBucket: true }); - } catch (e) { - console.error(`Failed to flag tag ${bucketTagName} as bucket`, e); - } - } - // --- Sync Bucket System Rule --- - const bucketRuleExact = normalizedRules.find(r => - r.ownerId === currentUserId && - r.targetUserId === currentUserId && - r.type === 'tag' && - r.tagName === bucketTagName - ); - const bucketRuleLegacy = normalizedRules.find(r => - r.ownerId === currentUserId && - r.targetUserId === currentUserId && - r.type === 'tag' && - r.tagName === bucket.name - ); - - if (bucketRuleLegacy && !bucketRuleExact) { - // Rename legacy rule - try { - await pb.collection(SHARE_RULES_COLLECTION).update(bucketRuleLegacy.id, { tagName: bucketTagName }, { requestKey: null }); - setStore("shareRules", r => r.id === bucketRuleLegacy.id, { tagName: bucketTagName }); - console.log(`Renamed legacy bucket rule: ${bucket.name} -> ${bucketTagName}`); - } catch (e) { - console.error(`Failed to rename legacy bucket rule ${bucket.name}`, e); - } - } else if (!bucketRuleExact) { - try { - await pb.collection(SHARE_RULES_COLLECTION).create({ - ownerId: currentUserId, - targetUserId: currentUserId, - type: 'tag', - tagName: bucketTagName, - share_mode: 'SEND_TASK' - }, { requestKey: null }); - } catch (e) { - console.error(`Failed to ensure system rule for bucket ${bucketTagName}`, e); - } - } - } - - // --- Sync User Tags --- - for (const user of users) { - const originalName = user.name || user.email; - if (!originalName) continue; - const userName = `@${originalName}`; - - const tagExistsExact = tagDefinitions.find(t => t.name.toLowerCase() === userName.toLowerCase()); - if (!tagExistsExact) { - try { - const record = await pb.collection(TAGS_COLLECTION).create({ - user: currentUserId, - name: userName, - value: 5, - color: "#64748b", - theme: "dark", - isUser: true - }, { requestKey: null }); - setStore("tagDefinitions", prev => [...prev, { - id: record.id, - name: record.name, - value: record.value, - color: record.color, - theme: record.theme, - isUser: true - }]); - } catch (e) { - // console.error(`Failed to create user tag ${userName}`, e); - } - } else if (!tagExistsExact.isUser) { - try { - await pb.collection(TAGS_COLLECTION).update(tagExistsExact.id, { isUser: true }, { requestKey: null }); - setStore("tagDefinitions", d => d.id === tagExistsExact.id, { isUser: true }); - } catch (e) { - console.error(`Failed to flag tag ${userName} as user`, e); - } - } - - // --- Ensure Self-Rule for Current User (Global Subscription) --- - if (user.id === currentUserId) { - const selfRuleExact = normalizedRules.find(r => - r.ownerId === currentUserId && - r.targetUserId === currentUserId && - r.type === 'tag' && - r.tagName === userName - ); - const selfRuleLegacy = normalizedRules.find(r => - r.ownerId === currentUserId && - r.targetUserId === currentUserId && - r.type === 'tag' && - r.tagName === originalName - ); - - if (selfRuleLegacy && !selfRuleExact) { - // Rename legacy self-rule - try { - await pb.collection(SHARE_RULES_COLLECTION).update(selfRuleLegacy.id, { tagName: userName }, { requestKey: null }); - setStore("shareRules", r => r.id === selfRuleLegacy.id, { tagName: userName }); - console.log(`Renamed legacy self-rule: ${originalName} -> ${userName}`); - } catch (e) { - console.error(`Failed to rename legacy self-rule ${originalName}`, e); - } - } else if (!selfRuleExact) { - try { - const record = await pb.collection(SHARE_RULES_COLLECTION).create({ - ownerId: currentUserId, - targetUserId: currentUserId, - type: 'tag', - tagName: userName, - share_mode: 'ADD_USER' - }, { requestKey: null }); - - const newRule: ShareRule = { - id: record.id, - ownerId: currentUserId, - targetUserId: currentUserId, - type: 'tag', - tagName: userName, - share_mode: 'ADD_USER' - }; - setStore("shareRules", prev => [...prev, newRule]); - - console.log(`Created self-rule for global sharing: ${userName}`); - } catch (e) { - console.error(`Failed to create self-rule for ${userName}`, e); - } - } - } - } - - } catch (err) { - console.error("Failed to sync system tags/rules:", err); - } + setStore("shareRules", store.contexts.map(mapContextToShareRule)); + setStore("tagDefinitions", defs => { + const plainDefs = defs.filter(def => !def.name.startsWith("@")); + return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)]; + }); }; export const renameTagDefinition = async (oldName: string, newName: string) => { @@ -2285,7 +2352,6 @@ export const renameTagDefinition = async (oldName: string, newName: string) => { } try { - await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName }, { requestKey: null }); setStore("tagDefinitions", (d) => d.id === def.id, { name: finalName }); const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName)); @@ -2295,17 +2361,7 @@ export const renameTagDefinition = async (oldName: string, newName: string) => { updateTask(task.id, { tags: uniqueTags }); } - // Sync Share Rules - const rulesWithTag = store.shareRules.filter(r => r.tagName === oldName); - for (const rule of rulesWithTag) { - try { - await pb.collection(SHARE_RULES_COLLECTION).update(rule.id, { tagName: finalName }, { requestKey: null }); - setStore("shareRules", (r) => r.id === rule.id, { tagName: finalName }); - console.log(`Synced ShareRule ${rule.id}: ${oldName} -> ${finalName}`); - } catch (ruleErr) { - console.error(`Failed to sync ShareRule ${rule.id} during tag rename:`, ruleErr); - } - } + await syncPreferences(); } catch (err) { console.error("Failed to rename tag:", err); toast.error("Failed to rename tag."); @@ -2323,64 +2379,48 @@ export const loadAllHistory = async () => { toast.promise( (async () => { // Fetch ALL tasks for user (including completed) - // We use a simplified query for the user's own history + shared items - // Note: This matches the components of the background sync but without the 'completed=false' restriction - const bucketNames = store.subscribedBuckets - .map(id => store.buckets.find(b => b.id === id)?.name) + const bucketKeys = store.subscribedBuckets + .map(id => store.contexts.find(context => context.id === id)?.key) .filter(Boolean); + const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); + const collaborativeTags = store.contexts + .filter(context => + !context.deletedAt && + context.kind === "user" && + context.policy === "collaborative" && + context.targetUserId === userId + ) + .map(context => buildShareTag(context.displayName)); const promises = [ - pb.collection(TASGRID_COLLECTION).getFullList({ filter: `user = "${userId}"`, sort: '-created', fields: LIST_FIELDS, requestKey: null }), - pb.collection(TASGRID_COLLECTION).getFullList({ filter: `sharedWith ~ "${userId}"`, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => []) + pb.collection(TASGRID_COLLECTION).getFullList({ filter: relationFilter("user", userId), sort: '-created', fields: LIST_FIELDS, requestKey: null }) ]; - if (bucketNames.length > 0) { + if (bucketKeys.length > 0) { promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ - filter: bucketNames.map(name => `tags ~ "${name}"`).join(' || '), + filter: bucketKeys.map(key => `tags ~ "@${key}"`).join(' || '), sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => [])); } + if (collaborativeTags.length > 0) { + promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ + filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '), + sort: '-created', + fields: LIST_FIELDS, + requestKey: null + }).catch(() => [])); + } - - // Add Share Rules - const incomingRules = store.shareRules.filter(r => r.targetUserId === userId); - console.log("[DEBUG] loadAllHistory - Incoming Rules for User:", userId, incomingRules); - - if (incomingRules.length > 0) { - const rulesToFetch: string[] = []; - - incomingRules.forEach((rule: any) => { - // Global Rule (Self-Targeted) - if (rule.ownerId === rule.targetUserId && rule.type === 'tag' && rule.tagName) { - console.log("[DEBUG] Found Self-Rule:", rule.tagName); - rulesToFetch.push(`tags ~ "${rule.tagName}"`); - } else { - // Standard Rule - let f = `user = "${rule.ownerId}"`; - if (rule.type === 'tag' && rule.tagName) f += ` && tags ~ "${rule.tagName}"`; - rulesToFetch.push(f); - } - }); - - // Optimization: Group Global Rules? - // For now, simpler to just push them all. - // But if we have many global rules (e.g. buckets), we might want to group them. - // The Global Rules usually behave like buckets. - // Let's just push them as individual queries for safety. - - rulesToFetch.forEach(filter => { - console.log("[DEBUG] Fetching with filter:", filter); - promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, requestKey: null }).then(res => { - console.log(`[DEBUG] Filter "${filter}" returned ${res.length} items`); - return res; - }).catch(err => { - console.error(`[DEBUG] Filter "${filter}" FAILED:`, err); - return []; - })); - }); + if (supervisedUserIds.length > 0) { + promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ + filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '), + sort: '-created', + fields: LIST_FIELDS, + requestKey: null + }).catch(() => [])); } const results = await Promise.all(promises); @@ -2431,17 +2471,26 @@ export const syncPreferences = async () => { if (!userId) return; try { + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, - tagDefinitions: store.tagDefinitions, + tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")), + filter: store.filter, + filterTemplates: store.filterTemplates, quickloadTasks: store.quickloadTasks, - noteFilter: store.noteFilter + noteFilter: store.noteFilter, + subscribedBuckets: store.subscribedBuckets, + supervisorUserIds: store.personalContextSupervisorIds }; await pb.collection('users').update(userId, { - Taskgrid_pref: prefs + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...readScopedPrefs(rawPrefs), + ...prefs + }) }, { requestKey: null }); } catch (e: any) { if (e.isAbort) return; @@ -2548,75 +2597,19 @@ export const setFilter = (update: Partial) => { // -- Sharing Functions -- -export const shareTask = async (taskId: string, userId: string, access: 'view' | 'edit' = 'view') => { +export const shareTask = async (_taskId: string, _userId: string, _access: 'view' | 'edit' = 'view') => { if (!pb.authStore.isValid) return; - - const task = store.tasks.find(t => t.id === taskId); - if (!task) return; - - const existingShares = task.sharedWith || []; - if (existingShares.some(s => s.userId === userId)) { - toast.info(`Task is already shared with this user`); - return; - } - - const newShares = [...existingShares, { userId, access }]; - - // Update task with new sharedWith - await updateTask(taskId, { sharedWith: newShares }); - toast.success(`Task shared successfully`); + toast.info("Direct task sharing has been replaced by @user tags."); }; -export const revokeShare = async (taskId: string, userId: string) => { +export const revokeShare = async (_taskId: string, _userId: string) => { if (!pb.authStore.isValid) return; - - const task = store.tasks.find(t => t.id === taskId); - if (!task) return; - - const existingShares = task.sharedWith || []; - const newShares = existingShares.filter(s => s.userId !== userId); - - await updateTask(taskId, { sharedWith: newShares }); - toast.success(`Share revoked`); + toast.info("Remove the matching @user tag from the task instead."); }; -export const splitTask = async (taskId: string, userId: string) => { +export const splitTask = async (_taskId: string, _userId: string) => { if (!pb.authStore.isValid) return; - - const task = store.tasks.find(t => t.id === taskId); - if (!task) return; - - try { - // Create a duplicate task owned by the target user - const duplicateData = { - user: userId, - title: task.title, - content: task.content, - priority: task.priority, - size: task.size, - tags: task.tags, - startDate: task.startDate, - dueDate: task.dueDate, - status: task.status, - completed: task.completed, - deletedAt: null, - sharedWith: [], // Start fresh with no shares - recurrence: task.recurrence - }; - - await pb.collection(TASGRID_COLLECTION).create(duplicateData, { requestKey: null }); - - // Remove the user from the original task's sharedWith - const existingShares = task.sharedWith || []; - const newShares = existingShares.filter(s => s.userId !== userId); - await updateTask(taskId, { sharedWith: newShares }); - - toast.success(`Task split - user now has their own copy`); - } catch (err: any) { - console.error("Failed to split task:", err); - console.error("Error details:", err.response?.data || err.data || err.message); - toast.error("Failed to split task: " + (err.response?.data?.message || err.message || "Unknown error")); - } + toast.info("Task split is no longer supported. Duplicate the task and retag it."); }; // -- Share Rules (for tag-based and all-tasks sharing) -- @@ -2625,62 +2618,31 @@ export const splitTask = async (taskId: string, userId: string) => { export const loadShareRules = async () => { if (!pb.authStore.isValid) return; - - try { - const currentUserId = pb.authStore.model?.id; - const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({ - filter: `ownerId = "${currentUserId}" || targetUserId = "${currentUserId}"`, - requestKey: null - }); - - const rules: ShareRule[] = records.map(r => ({ - id: r.id, - ownerId: r.ownerId, - targetUserId: r.targetUserId, - type: r.type as 'tag' | 'all', - tagName: r.tagName, - share_mode: r.share_mode || 'ADD_USER' - })); - - setStore("shareRules", reconcile(rules)); - } catch (err) { - console.error("Failed to load share rules:", err); - } + setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule))); }; export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => { if (!pb.authStore.isValid) return; - - // Check for duplicates - // We check against the local store to avoid network round-trip delay issues - const currentUserId = pb.authStore.model?.id; - const exists = store.shareRules.some(r => - r.ownerId === currentUserId && - r.targetUserId === targetUserId && - r.type === type && - (type === 'all' || r.tagName === tagName) - // We don't strictly check share_mode because having two rules for same tag/person with different modes is confusing. - // Usually we update the existing one if mode changes. - ); - - if (exists) { - // toast.info("Share rule already exists."); - return; // Silent return or update? + if (type !== 'tag' || !tagName?.startsWith("@")) { + toast.error("Only @tag contexts are supported now."); + return; } + const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName); + if (existing) return; + try { - await pb.collection(SHARE_RULES_COLLECTION).create({ - ownerId: pb.authStore.model?.id, - targetUserId, - type, - tagName: type === 'tag' ? tagName : null, - share_mode: share_mode || 'ADD_USER' + await pb.collection(CONTEXTS_COLLECTION).create({ + key: normalizeContextKey(tagName), + displayName: tagName.replace(/^@/, ""), + kind: "user", + policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative", + targetUserId }, { requestKey: null }); - // Realtime subscription will handle adding to store - toast.success(`Share rule created`); + toast.success(`Shared context created`); } catch (err) { - console.error("Failed to create share rule:", err); - toast.error("Failed to create share rule. Make sure the collection exists in PocketBase."); + console.error("Failed to create shared context:", err); + toast.error("Failed to create shared context."); } }; @@ -2688,11 +2650,12 @@ export const updateShareRule = async (ruleId: string, updates: Partial - rules.map(r => r.id === ruleId ? { ...r, ...updates } : r) - ); - toast.success("Share rule updated"); + const context = store.contexts.find(existing => existing.id === ruleId); + if (!context) return; + await pb.collection(CONTEXTS_COLLECTION).update(ruleId, { + policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative" + }, { requestKey: null }); + toast.success("Context updated"); } catch (err) { console.error("Failed to update share rule:", err); toast.error("Failed to update share rule."); @@ -2703,52 +2666,34 @@ export const removeShareRule = async (ruleId: string) => { if (!pb.authStore.isValid) return; try { - await pb.collection(SHARE_RULES_COLLECTION).delete(ruleId); - setStore("shareRules", (rules) => rules.filter(r => r.id !== ruleId)); - toast.success("Share rule removed"); + await pb.collection(CONTEXTS_COLLECTION).delete(ruleId); + toast.success("Context removed"); } catch (err) { console.error("Failed to delete share rule:", err); toast.error("Failed to delete share rule."); } }; -// -- Filter Templates -- -const FILTER_TEMPLATES_COLLECTION = 'TasGrid_FilterTemplates'; - export const loadFilterTemplates = async () => { if (!pb.authStore.isValid) return; - try { - const records = await pb.collection(FILTER_TEMPLATES_COLLECTION).getFullList({ - filter: `user = "${pb.authStore.model?.id}"`, - requestKey: null - }); - const templates: FilterTemplate[] = records.map(r => ({ - id: r.id, - name: r.name, - filter: r.filter as Filter - })); - setStore("filterTemplates", templates); - } catch (err) { - console.error("Failed to load filter templates:", err); - } + const userId = pb.authStore.model?.id; + if (!userId) return; + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); + setStore("filterTemplates", prefs.filterTemplates || []); }; export const saveFilterTemplate = async (name: string) => { if (!pb.authStore.isValid) return; try { - const record = await pb.collection(FILTER_TEMPLATES_COLLECTION).create({ - user: pb.authStore.model?.id, + const newTemplate: FilterTemplate = { + id: `filter-${Date.now()}`, name, filter: store.filter - }, { requestKey: null }); - - const newTemplate: FilterTemplate = { - id: record.id, - name: record.name, - filter: record.filter as Filter }; setStore("filterTemplates", prev => [...prev, newTemplate]); + await syncPreferences(); toast.success(`Filter template "${name}" saved`); } catch (err) { console.error("Failed to save filter template:", err); @@ -2767,8 +2712,8 @@ export const applyFilterTemplate = (id: string) => { export const removeFilterTemplate = async (id: string) => { if (!pb.authStore.isValid) return; try { - await pb.collection(FILTER_TEMPLATES_COLLECTION).delete(id); setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); + await syncPreferences(); toast.success("Filter template deleted"); } catch (err) { console.error("Failed to delete filter template:", err); @@ -2797,42 +2742,37 @@ export const setNoteFilter = (update: Partial) => { syncPreferences(); }; -const NOTE_FILTER_TEMPLATES_COLLECTION = 'TasGrid_NoteFilterTemplates'; - export const loadNoteFilterTemplates = async () => { if (!pb.authStore.isValid) return; - try { - const records = await pb.collection(NOTE_FILTER_TEMPLATES_COLLECTION).getFullList({ - filter: `user = "${pb.authStore.model?.id}"`, - requestKey: null - }); - const templates: FilterTemplate[] = records.map(r => ({ - id: r.id, - name: r.name, - filter: r.filter as Filter - })); - setStore("filterTemplates", templates); - } catch (err) { - console.error("Failed to load note filter templates:", err); - } + const userId = pb.authStore.model?.id; + if (!userId) return; + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); + setStore("filterTemplates", prefs.noteFilterTemplates || []); }; export const saveNoteFilterTemplate = async (name: string) => { if (!pb.authStore.isValid) return; try { - const record = await pb.collection(NOTE_FILTER_TEMPLATES_COLLECTION).create({ - user: pb.authStore.model?.id, + const newTemplate: FilterTemplate = { + id: `note-filter-${Date.now()}`, name, filter: store.noteFilter - }, { requestKey: null }); - - const newTemplate: FilterTemplate = { - id: record.id, - name: record.name, - filter: record.filter as Filter }; setStore("filterTemplates", prev => [...prev, newTemplate]); + const userId = pb.authStore.model?.id; + if (userId) { + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + await pb.collection('users').update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...prefs, + noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate] + }) + }, { requestKey: null }); + } toast.success(`Note filter template "${name}" saved`); } catch (err) { console.error("Failed to save note filter template:", err); @@ -2851,8 +2791,19 @@ export const applyNoteFilterTemplate = (id: string) => { export const removeNoteFilterTemplate = async (id: string) => { if (!pb.authStore.isValid) return; try { - await pb.collection(NOTE_FILTER_TEMPLATES_COLLECTION).delete(id); setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); + const userId = pb.authStore.model?.id; + if (userId) { + const user = await pb.collection('users').getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + await pb.collection('users').update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...prefs, + noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id) + }) + }, { requestKey: null }); + } toast.success("Note filter template deleted"); } catch (err) { console.error("Failed to delete note filter template:", err); diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index 9d621e3..8c6da05 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -739,7 +739,13 @@ export const NotepadView: Component<{
{/* Sticky Footer Actions (Match TaskDetail style) */} -
+
{/* "..." popover menu containing Public/Private toggle and Delete */} diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index f5cc073..66081ca 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, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store"; +import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors } from "@/store"; import { useTheme } from "@/components/ThemeProvider"; import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid"; import { TagPicker } from "@/components/TagPicker"; @@ -25,13 +25,12 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props const [isBucketsOpen, setIsBucketsOpen] = createSignal(false); const [expandedTemplateId, setExpandedTemplateId] = createSignal(null); const [shareUserId, setShareUserId] = createSignal(""); - const [shareType, setShareType] = createSignal<'all' | 'tag'>('all'); + const [shareType, setShareType] = createSignal<'all' | 'tag'>('tag'); const [shareMode, setShareMode] = createSignal<'ADD_USER' | 'SEND_TASK'>('ADD_USER'); const [shareTag, setShareTag] = createSignal(""); const [allUsers, setAllUsers] = createSignal>([]); const [usersLoading, setUsersLoading] = createSignal(false); - // Fetch users when sharing section opens createEffect(() => { if (isSharingOpen() && allUsers().length === 0 && !usersLoading()) { setUsersLoading(true); @@ -53,6 +52,11 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props return allUsers().filter(u => u.id !== currentUserId); }; + const userContexts = () => + store.contexts + .filter(context => !context.deletedAt && context.kind === "user") + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + return (
@@ -113,9 +117,9 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props

- Task Sharing + Tag Sharing

-

Share all tasks or tasks with specific tags with others.

+

Share through tags only: `@people` collaborate, `@buckets` route work, and `#notes` stay link-only.

{isSharingOpen() ? : } @@ -124,6 +128,112 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
+
+

User Tag Policies

+

+ Control how each @user tag behaves. Collaborative tags keep work shared. Handoff tags route work into that user's queue. +

+
+ + No user contexts found yet. +
+ }> + {(context) => ( +
+
+

@{context.displayName}

+

+ {context.targetUserId === pb.authStore.model?.id ? "Your personal context" : `User: ${getUserName(context.targetUserId || "")}`} +

+
+
+ + +
+
+ )} + +
+
+ +
+

Personal Context Supervision

+

+ Allow specific supervisors to switch into your whole personal context, the same way they switch into pinned bucket contexts. +

+ + No other users available. +
+ }> + {(user) => { + const hasAccess = () => store.personalContextSupervisorIds.includes(user.id); + return ( +
+
+

{user.name}

+

+ {hasAccess() ? "Can switch into your personal context" : "No supervision access"} +

+
+ +
+ ); + }} + +
+ + 0}> +
+

+
+ Personal Contexts Shared With You +

+ + {(context) => ( +
+
+

{context.ownerName}

+

+ Available in the context switcher +

+
+
+ )} +
+
+
+ + {/* Add new share rule */}
@@ -131,10 +241,11 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props variant={shareType() === 'all' ? 'default' : 'outline'} size="sm" class="h-8 text-xs gap-1" - onClick={() => setShareType('all')} + disabled + title="All-task sharing is no longer supported" > - All Tasks + Legacy Off

{shareMode() === 'ADD_USER' - ? "User will be added to the task's shared list." - : "Task owner will change to the recipient immediately when tagged."} + ? "Collaborative contexts add access through the selected @tag." + : "Handoff contexts transfer ownership when that @tag is applied."}

@@ -378,6 +489,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
+
@@ -392,9 +504,9 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props

- Shared Global Buckets + Shared Buckets

-

Manage public task buckets and your subscriptions.

+

Each bucket is a real `@context` with a policy, metadata, soft delete, and personal pinning.

{isBucketsOpen() ? : } @@ -405,7 +517,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
{/* Create Bucket */}
-

Create New Bucket

+

Create New Bucket Context

{ @@ -455,7 +567,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
- No buckets found. + No bucket contexts found.
}> {(bucket) => { @@ -497,10 +609,21 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props />

{bucket.description || "No description"}

+

@{bucket.name}

+