diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 09e9b53..16331e7 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,5 +1,5 @@ import { type Component, For, Show } from "solid-js"; -import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users } from "lucide-solid"; +import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box } from "lucide-solid"; import { cn } from "@/lib/utils"; import { Button } from "./ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -162,11 +162,13 @@ export const ContextSwitcher: Component<{ return c === 'mine' ? "My Workspace" : c.name; }; - const handleSwitch = (userId: string, name: string) => { - if (userId === 'mine') { + const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => { + if (type === 'mine') { setCurrentTaskContext('mine'); + } else if (type === 'bucket') { + setCurrentTaskContext({ bucketId: id, name }); } else { - setCurrentTaskContext({ userId, name }); + setCurrentTaskContext({ userId: id, name }); } // Close popover @@ -214,7 +216,7 @@ export const ContextSwitcher: Component<{
My Workspace -
-
-
-
- Oversight - {oversightUsers()?.length} -
- - {(user) => ( - - )} - + 0}> +
+
+
+
+ Buckets + {store.subscribedBuckets.length} +
+ store.subscribedBuckets.includes(b.id))}> + {(bucket) => ( + + )} + + + + 0}> +
+
+
+
+ Oversight + {oversightUsers()?.length} +
+ + {(user) => ( + + )} + +
diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 83c0f8a..d7cbe25 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -36,6 +36,11 @@ 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 SIZE_OPTIONS = [ { value: "10", label: "10 - Multiple Days" }, { value: "9", label: "9 - More than a day" }, diff --git a/src/store/index.ts b/src/store/index.ts index e6ab007..131de7e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,8 +1,8 @@ import { createStore, reconcile } from "solid-js/store"; import { createSignal, createEffect, createRoot } from "solid-js"; -import { pb, TASGRID_COLLECTION, TAGS_COLLECTION } from "@/lib/pocketbase"; +import { pb } from "@/lib/pocketbase"; import { toast } from "solid-sonner"; -import { URGENCY_HOURS } from "@/lib/constants"; +import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, URGENCY_HOURS } from "@/lib/constants"; const getStorageKey = () => { const userId = pb.authStore.model?.id; @@ -10,9 +10,10 @@ const getStorageKey = () => { }; export const [now, setNow] = createSignal(Date.now()); -// Context: 'mine' = My Workspace (default), { userId: string } = Oversight View for that user +// Context: 'mine' = My Workspace (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 }; export const [activeTaskId, setActiveTaskId] = createSignal(null); -export const [currentTaskContext, setCurrentTaskContext] = createSignal<'mine' | { userId: string, name: string }>('mine'); +export const [currentTaskContext, setCurrentTaskContext] = createSignal('mine'); export type UrgencyLevel = number; @@ -159,6 +160,13 @@ export interface ShareRule { tagName?: string; } +export interface Bucket { + id: string; + name: string; + color: string; + description?: string; +} + interface TaskStore { tasks: Task[]; pWeight: number; @@ -170,6 +178,8 @@ interface TaskStore { filter: Filter; shareRules: ShareRule[]; filterTemplates: FilterTemplate[]; + buckets: Bucket[]; + subscribedBuckets: string[]; // IDs of buckets I'm subscribed to } // Initial empty state @@ -190,7 +200,9 @@ export const [store, setStore] = createStore({ editedToday: false }, shareRules: [], - filterTemplates: [] + filterTemplates: [], + buckets: [], + subscribedBuckets: [] }); export const matchesFilter = (task: Task) => { @@ -210,13 +222,34 @@ export const matchesFilter = (task: Task) => { task.tags?.includes(r.tagName || "") ); - // If context is "mine", we hide tasks that only match a "Share All" rule - // (i.e., not owner, not explicit, not tag rule) + // Bucket visibility logic for "My Workspace": + // Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users." + // AND "In settings a user should be able to see and edit all buckets... They should be able to add any bucket to their workspaces list." + // When in 'mine', do we show bucket tasks? + // Typically 'My Workspace' is just MY stuff + stuff shared explicitly with me. + // Buckets seem to be separate "Contexts" in the sidebar. + // However, if the user wants them mixed in, they didn't explicitly say "mix into my main list", + // they said "add that bucket to their workspaces list". This usually implies a separate view. + // BUT, if I create a task and tag it "Shop", I expect to see it. + + // If I own the task, I see it. + // If it's shared with me, I see it. + if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false; + } else if (typeof ctx === 'object' && 'bucketId' in ctx) { + // Bucket View + const bucketProxy = store.buckets.find(b => b.id === (ctx as { bucketId: string }).bucketId); + if (!bucketProxy) return false; + + // Show tasks that have the bucket name as a tag + // The requirement: "if any user adds a tag that is the name of a bucket to a task that task is then made public and moved to the bucket." + if (!task.tags?.includes(bucketProxy.name)) return false; } else { // Context is Oversight for specific user // Show ONLY tasks owned by that user - if (task.ownerId !== ctx.userId) return false; + // 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; } // Hide templates from all regular views @@ -396,9 +429,28 @@ const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { 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 + // We need to look up bucket names from our subscribed bucket IDs + // This is slightly expensive if we iterate. + // Optimization: Pre-calculate subscribed bucket names? + // For now, simple find. + return store.subscribedBuckets.some(subId => { + const bucket = store.buckets.find(b => b.id === subId); + return bucket && taskTags.includes(bucket.name); + }); +}; + export const subscribeToRealtime = async () => { // Unsubscribe first to avoid duplicates await pb.collection(TASGRID_COLLECTION).unsubscribe('*'); @@ -501,6 +553,32 @@ 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]; + }); + } + + 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) => { @@ -598,13 +676,27 @@ export const initStore = async () => { pWeight: prefs.pWeight || 1.0, uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, - prefId: userId + prefId: userId, + subscribedBuckets: user.subscribedBuckets || [] }); } } catch (prefErr) { console.warn("Failed to load preferences:", prefErr); } + // 1.2 Fetch Buckets + try { + const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name' }); + 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); + } + // 1.5. Fetch Tag Definitions try { const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({ @@ -650,11 +742,26 @@ export const initStore = async () => { }).catch(err => { console.warn("Failed to load share rules:", err); return []; }) : Promise.resolve([]); + // 1.9 Fetch Tasks for Subscribed Buckets + const subscribedBucketNames = store.subscribedBuckets + .map(id => store.buckets.find(b => b.id === id)?.name) + .filter(Boolean); + + const bucketTasksPromise = (subscribedBucketNames.length > 0) + ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: subscribedBucketNames.map(name => `tags ~ "${name}"`).join(' || '), + sort: '-created', + requestKey: null + }).catch(err => { console.warn("Failed to load bucket tasks:", err); return []; }) + : Promise.resolve([]); + + // Wait for all initial fetches - const [ownRecords, individuallySharedRecords, incomingRules] = await Promise.all([ + const [ownRecords, individuallySharedRecords, incomingRules, bucketRecords] = await Promise.all([ ownTasksPromise, individuallySharedPromise, - shareRulesPromise + shareRulesPromise, + bucketTasksPromise ]); const allTasks: Task[] = []; @@ -734,6 +841,19 @@ export const initStore = async () => { }); } + // Process Bucket Tasks + if (bucketRecords && bucketRecords.length > 0) { + bucketRecords.forEach((r: any) => { + if (!seenIds.has(r.id)) { + const tags = r.tags || []; + if (!tags.includes("__template__")) { + allTasks.push(mapRecordToTask(r)); + seenIds.add(r.id); + } + } + }); + } + // Set store ONCE with all tasks and rules setStore("tasks", reconcile(allTasks)); setStore("templates", loadedTemplates); @@ -777,6 +897,91 @@ export const initStore = async () => { // -- Actions -- +export const toggleBucketSubscription = async (bucketId: string) => { + const currentSubscribed = store.subscribedBuckets; + const isSubscribed = currentSubscribed.includes(bucketId); + + let newSubscribed = []; + if (isSubscribed) { + newSubscribed = currentSubscribed.filter(id => id !== bucketId); + } else { + newSubscribed = [...currentSubscribed, bucketId]; + } + + setStore("subscribedBuckets", newSubscribed); + + // Persist to user profile + const userId = pb.authStore.model?.id; + if (userId) { + try { + await pb.collection('users').update(userId, { + 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 records = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `tags ~ "${bucketName}"`, + sort: '-created' + }); + + const newTasks: Task[] = []; + records.forEach((r: any) => { + const exists = store.tasks.find(t => t.id === r.id); + if (!exists && !r.tags?.includes("__template__")) { + newTasks.push(mapRecordToTask(r)); + } + }); + + if (newTasks.length > 0) { + setStore("tasks", t => [...newTasks, ...t]); + } + } + } + } catch (err) { + console.error("Failed to update subscriptions:", err); + // Revert on error + setStore("subscribedBuckets", currentSubscribed); + } + } +}; + +export const createBucket = async (name: string, color: string, description?: string) => { + try { + await pb.collection(BUCKETS_COLLECTION).create({ + name, + color, + description + }); + toast.success("Bucket created"); + } catch (err) { + console.error("Failed to create bucket:", err); + toast.error("Failed to create bucket"); + } +}; + +export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string }) => { + try { + await pb.collection(BUCKETS_COLLECTION).update(id, data); + toast.success("Bucket updated"); + } catch (err) { + console.error("Failed to update bucket:", err); + toast.error("Failed to update bucket"); + } +}; + +export const deleteBucket = async (id: string) => { + try { + await pb.collection(BUCKETS_COLLECTION).delete(id); + toast.success("Bucket deleted"); + } catch (err) { + console.error("Failed to delete bucket:", err); + toast.error("Failed to delete bucket"); + } +}; + export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => { if (!pb.authStore.isValid) return; @@ -1368,7 +1573,7 @@ export const splitTask = async (taskId: string, userId: string) => { // -- Share Rules (for tag-based and all-tasks sharing) -- -const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules'; + export const loadShareRules = async () => { if (!pb.authStore.isValid) return; @@ -1508,3 +1713,5 @@ export const setupPersistence = () => { // Actually, index.tsx calls this. We can leave it empty or have it check auth. initStore(); }; + + diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index bef1768..85f1054 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -1,8 +1,8 @@ import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js"; import { ThemeToggle } from "../components/ThemeToggle"; -import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule } from "@/store"; +import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store"; import { useTheme } from "@/components/ThemeProvider"; -import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, Upload } from "lucide-solid"; +import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, Upload, Box, Edit2, Archive } from "lucide-solid"; import { TagPicker } from "@/components/TagPicker"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -22,6 +22,7 @@ export const SettingsView: Component = () => { const [isTrashOpen, setIsTrashOpen] = createSignal(false); const [isSharingOpen, setIsSharingOpen] = createSignal(false); const [isImportOpen, setIsImportOpen] = createSignal(false); + const [isBucketsOpen, setIsBucketsOpen] = createSignal(false); const [expandedTemplateId, setExpandedTemplateId] = createSignal(null); const [shareUserId, setShareUserId] = createSignal(""); const [shareType, setShareType] = createSignal<'all' | 'tag'>('all'); @@ -292,6 +293,165 @@ export const SettingsView: Component = () => {
+ + {/* Buckets Manager */} +
+
setIsBucketsOpen(!isBucketsOpen())} + > +
+

+ + Buckets (Global Workspaces) +

+

Manage public task buckets and your subscriptions.

+
+
+ {isBucketsOpen() ? : } +
+
+ + +
+ {/* Create Bucket */} +
+

Create New Bucket

+
{ + e.preventDefault(); + const form = e.currentTarget; + const nameInput = form.elements.namedItem('name') as HTMLInputElement; + const colorInput = form.elements.namedItem('color') as HTMLInputElement; + const descInput = form.elements.namedItem('desc') as HTMLInputElement; + + if (nameInput.value) { + createBucket(nameInput.value, colorInput.value || "#6366f1", descInput.value); + nameInput.value = ""; + descInput.value = ""; + form.reset(); + } + }} + > + + + + +
+
+ + {/* List Buckets */} +
+ + No buckets found. +
+ }> + {(bucket) => { + const isSubscribed = () => store.subscribedBuckets.includes(bucket.id); + const [isEditing, setIsEditing] = createSignal(false); + const [editName, setEditName] = createSignal(bucket.name); + + return ( +
+
+
+
+ +

{bucket.name}

+ +
+ }> + setEditName(e.currentTarget.value)} + onBlur={() => { + if (editName() !== bucket.name) updateBucket(bucket.id, { name: editName() }); + setIsEditing(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (editName() !== bucket.name) updateBucket(bucket.id, { name: editName() }); + setIsEditing(false); + } + }} + class="h-6 w-full max-w-[150px] rounded border border-input bg-transparent px-1 text-sm" + autofocus + /> + +

{bucket.description || "No description"}

+
+
+ +
+ + + +
+
+ ); + }} + +
+
+
+ + {/* Matrix Configuration */}