share mode toggle added. Partial note task linkage change (stable)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import { store, upsertTagDefinition, isCurrentUserContextTag } from "@/store";
|
||||
import { store, upsertTagDefinition, isCurrentUserContextTag, setContextPolicy } from "@/store";
|
||||
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";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TagPickerProps {
|
||||
selectedTags: string[];
|
||||
@@ -17,6 +18,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [selectedTagName, setSelectedTagName] = createSignal("");
|
||||
const [valueScore, setValueScore] = createSignal(5);
|
||||
const [shareMode, setShareMode] = createSignal<"handoff" | "collaborative">("handoff");
|
||||
|
||||
// Filter tags for the "Name" part
|
||||
const localTagNames = () => {
|
||||
@@ -40,6 +42,19 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
return allTagNames();
|
||||
};
|
||||
|
||||
const selectedShareContext = () => {
|
||||
const name = selectedTagName().trim();
|
||||
if (!name.startsWith("@")) return null;
|
||||
|
||||
const normalized = normalizeContextKey(name);
|
||||
return store.contexts.find(context =>
|
||||
!context.deletedAt && (
|
||||
context.key === normalized ||
|
||||
normalizeContextKey(context.displayName) === normalized
|
||||
)
|
||||
) || null;
|
||||
};
|
||||
|
||||
// When a tag name is selected, update the value score to match its current definition
|
||||
createEffect(() => {
|
||||
const name = selectedTagName().trim();
|
||||
@@ -51,10 +66,23 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
createEffect(() => {
|
||||
const context = selectedShareContext();
|
||||
if (context) {
|
||||
setShareMode(context.policy);
|
||||
return;
|
||||
}
|
||||
if (selectedTagName().trim().startsWith("@")) {
|
||||
setShareMode("handoff");
|
||||
}
|
||||
});
|
||||
|
||||
const handleApply = async () => {
|
||||
const name = selectedTagName().trim();
|
||||
if (!name) return;
|
||||
|
||||
const context = selectedShareContext();
|
||||
|
||||
if (name.startsWith("@")) {
|
||||
const normalized = normalizeContextKey(name);
|
||||
const exists = store.contexts.some(context =>
|
||||
@@ -67,6 +95,11 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
toast.error("Create the shared context first before tagging tasks with it.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (context && context.policy !== shareMode()) {
|
||||
const updated = await setContextPolicy(context.id, shareMode());
|
||||
if (!updated) return;
|
||||
}
|
||||
}
|
||||
|
||||
if (name.startsWith("#")) {
|
||||
@@ -134,6 +167,40 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedTagName().trim().startsWith("@") && (
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">Share Mode</label>
|
||||
<div class="bg-background border border-border p-1 rounded-xl flex items-center shadow-sm relative min-w-[160px] h-10">
|
||||
<div
|
||||
class={cn(
|
||||
"absolute top-1 bottom-1 w-[calc(50%-4px)] bg-primary rounded-lg shadow-sm transition-transform duration-200 ease-out z-0",
|
||||
shareMode() === "handoff" ? "translate-x-0" : "translate-x-[calc(100%+0px)]"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
shareMode() === "handoff" ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setShareMode("handoff")}
|
||||
>
|
||||
Handoff
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
"flex-1 h-8 text-[0.7rem] font-bold uppercase tracking-widest z-10 transition-colors duration-200 flex items-center justify-center rounded-lg",
|
||||
shareMode() === "collaborative" ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setShareMode("collaborative")}
|
||||
>
|
||||
Collaborate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button size="sm" class="w-full mt-1" onClick={handleApply}>
|
||||
<Plus size={14} class="mr-2" />
|
||||
{store.tagDefinitions.find(d => d.name === selectedTagName().trim()) ? "Update & Add" : "Create & Add"}
|
||||
|
||||
+90
-4
@@ -286,6 +286,7 @@ interface ScopedTaskgridPrefs {
|
||||
collapsedUntilUpdatedTasks?: Record<string, string>;
|
||||
migratedStructuredTagsV2?: boolean;
|
||||
migratedPersonalContextRefsV1?: boolean;
|
||||
lastCompletedTaskCleanupDate?: string; // YYYY-MM-DD, per user/environment
|
||||
}
|
||||
|
||||
const PREF_ENVIRONMENTS_KEY = "environments";
|
||||
@@ -432,6 +433,7 @@ interface TaskStore {
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
noteFilter: Filter;
|
||||
lastCompletedTaskCleanupDate: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
@@ -472,6 +474,7 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: [],
|
||||
lastCompletedTaskCleanupDate: "",
|
||||
noteFilter: {
|
||||
query: "",
|
||||
tags: [],
|
||||
@@ -1714,7 +1717,7 @@ const syncUserContexts = async () => {
|
||||
key: buildUserContextKey(user.id),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "collaborative",
|
||||
policy: "handoff",
|
||||
targetUserId: user.id
|
||||
}, { requestKey: null }).catch(() => null);
|
||||
if (created) {
|
||||
@@ -1953,6 +1956,7 @@ export const initStore = async () => {
|
||||
heartbeatInterval = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.)
|
||||
runDailyCleanupIfNeeded(); // Once-per-day completed task cleanup
|
||||
}, 60000);
|
||||
|
||||
try {
|
||||
@@ -2329,12 +2333,32 @@ export const deleteBucket = async (id: string) => {
|
||||
};
|
||||
|
||||
export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
|
||||
const success = await setContextPolicy(contextId, policy);
|
||||
if (success) {
|
||||
toast.success("User context updated");
|
||||
}
|
||||
};
|
||||
|
||||
export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => {
|
||||
const previousContext = store.contexts.find(context => context.id === contextId);
|
||||
const previousPolicy = previousContext?.policy;
|
||||
|
||||
if (previousContext && previousPolicy !== policy) {
|
||||
setStore("contexts", context => context.id === contextId, "policy", policy);
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null });
|
||||
toast.success("User context updated");
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Failed to update user context:", err);
|
||||
if (previousContext && previousPolicy) {
|
||||
setStore("contexts", context => context.id === contextId, "policy", previousPolicy);
|
||||
setStore("shareRules", store.contexts.map(mapContextToShareRule));
|
||||
}
|
||||
toast.error("Failed to update user context");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3037,6 +3061,67 @@ export const loadTaskContent = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadTasksLinkedToNote = async (note: Pick<Note, "id" | "key" | "title" | "tasks">) => {
|
||||
if (!pb.authStore.isValid || !note?.id) return;
|
||||
|
||||
const noteTag = buildNoteTag(note.key || note.title);
|
||||
const escapedNoteTag = noteTag.replace(/"/g, '\\"');
|
||||
const escapedNoteIdNeedle = `\\"noteId\\":\\"${note.id}\\"`;
|
||||
const escapedNoteKeyNeedle = note.key ? `\\"key\\":\\"${note.key.replace(/"/g, '\\"')}\\"` : "";
|
||||
const linkedTaskIds = [...new Set((note.tasks || []).filter(Boolean))];
|
||||
const idFilter = linkedTaskIds.length > 0
|
||||
? ` || (${linkedTaskIds.map(taskId => `id = "${taskId}"`).join(" || ")})`
|
||||
: "";
|
||||
const noteRefsFilter = escapedNoteKeyNeedle
|
||||
? `(noteRefs ~ "${escapedNoteIdNeedle}" || noteRefs ~ "${escapedNoteKeyNeedle}")`
|
||||
: `(noteRefs ~ "${escapedNoteIdNeedle}")`;
|
||||
|
||||
try {
|
||||
const idFetchResults = await Promise.allSettled(
|
||||
linkedTaskIds.map(taskId =>
|
||||
pb.collection(TASGRID_COLLECTION).getOne(taskId, {
|
||||
fields: getTaskListFields(),
|
||||
requestKey: null
|
||||
})
|
||||
)
|
||||
);
|
||||
const idRecords = idFetchResults
|
||||
.filter((result): result is PromiseFulfilledResult<any> => result.status === "fulfilled")
|
||||
.map(result => result.value);
|
||||
|
||||
const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `((tags ~ "${escapedNoteTag}") || ${noteRefsFilter}${idFilter}) && !tags ~ "__template__"`,
|
||||
sort: "-updated",
|
||||
fields: getTaskListFields(),
|
||||
requestKey: `note-linked-${note.id}`
|
||||
}).catch(() => []);
|
||||
|
||||
const recordMap = new Map<string, any>();
|
||||
[...idRecords, ...listRecords].forEach(record => {
|
||||
if (!record?.id) return;
|
||||
if (record.tags?.includes("__template__")) return;
|
||||
recordMap.set(record.id, record);
|
||||
});
|
||||
|
||||
if (recordMap.size === 0) return;
|
||||
|
||||
setStore("tasks", (currentTasks) => {
|
||||
const taskMap = new Map(currentTasks.map(task => [task.id, task]));
|
||||
recordMap.forEach((record: any) => {
|
||||
const mapped = mapRecordToTask(record);
|
||||
const existing = taskMap.get(mapped.id);
|
||||
if (existing?.content) {
|
||||
mapped.content = existing.content;
|
||||
}
|
||||
taskMap.set(mapped.id, mapped);
|
||||
});
|
||||
return Array.from(taskMap.values());
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load note-linked tasks:", err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// -- Templates --
|
||||
|
||||
@@ -3059,7 +3144,8 @@ export const syncPreferences = async () => {
|
||||
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
|
||||
noteFilter: store.noteFilter,
|
||||
subscribedBuckets: store.subscribedBuckets,
|
||||
supervisorUserIds: store.personalContextSupervisorIds
|
||||
supervisorUserIds: store.personalContextSupervisorIds,
|
||||
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
@@ -3230,7 +3316,7 @@ export const loadShareRules = async () => {
|
||||
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') => {
|
||||
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'SEND_TASK') => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
if (type !== 'tag' || !tagName?.startsWith("@")) {
|
||||
toast.error("Only @tag contexts are supported now.");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js";
|
||||
import { store, renameTagDefinition, updateNote, updateTask, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag, loadNoteContent, requestImmediateNotesMetadataLoad } from "@/store";
|
||||
import { store, renameTagDefinition, updateNote, updateTask, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag, loadNoteContent, requestImmediateNotesMetadataLoad, loadTasksLinkedToNote } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { type Note } from "@/store";
|
||||
import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, MoreHorizontal, Type, FileText, Search, Star, ChevronRight } from "lucide-solid";
|
||||
@@ -80,6 +80,14 @@ export const NotepadView: Component<{
|
||||
void loadNoteContent(id);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const note = activeNote();
|
||||
if (!note) return;
|
||||
const linkageSignature = `${note.id}|${note.key || note.title}|${(note.tasks || []).join(",")}|${note.updated}`;
|
||||
void linkageSignature;
|
||||
void loadTasksLinkedToNote(note);
|
||||
});
|
||||
|
||||
// Derived states
|
||||
const activeNote = createMemo(() => {
|
||||
const id = props.selectedNoteId?.trim();
|
||||
|
||||
Reference in New Issue
Block a user