Compare commits
3 Commits
42531b37a5
...
f1200a2c78
| Author | SHA1 | Date | |
|---|---|---|---|
| f1200a2c78 | |||
| 15f518c58f | |||
| e5d579051d |
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "tasgrid",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "^3.18.0",
|
||||
@@ -297,6 +298,8 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"deploy": "git pull && bun install && bun run build && (pm2 restart tasgrid || bun run start-prod)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "^3.18.0",
|
||||
|
||||
+5
-1
@@ -30,12 +30,16 @@ const App: Component = () => {
|
||||
window.addEventListener('popstate', handleLocChange);
|
||||
window.addEventListener('hashchange', handleLocChange);
|
||||
|
||||
let isFirstRun = true;
|
||||
const removeListener = pb.authStore.onChange((token) => {
|
||||
console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token);
|
||||
const wasAuthenticated = isAuthenticated();
|
||||
setIsAuthenticated(!!token);
|
||||
if (token) {
|
||||
|
||||
if (token && (isFirstRun || !wasAuthenticated)) {
|
||||
initStore();
|
||||
}
|
||||
isFirstRun = false;
|
||||
}, true);
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/u
|
||||
import { getStatusOptionsForTask } from "@/lib/constants";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
|
||||
export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
||||
|
||||
@@ -58,8 +58,9 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"group relative flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-all duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
||||
props.task.completed && "opacity-60"
|
||||
"group relative flex items-center p-3 sm:p-4 bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
||||
props.task.completed && "opacity-60",
|
||||
props.isShaking && "animate-task-shake bg-accent/20"
|
||||
)}
|
||||
onClick={() => setActiveTaskId(props.task.id)}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ const Toaster = (props: ToasterProps) => {
|
||||
theme={theme() as ToasterProps["theme"]}
|
||||
class="toaster group"
|
||||
position="top-center"
|
||||
closeButton
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createSignal, createEffect, onCleanup, untrack } from "solid-js";
|
||||
import type { Task } from "@/store";
|
||||
|
||||
export function useDelayedSort(
|
||||
sourceTasks: () => Task[],
|
||||
sortFn: (a: Task, b: Task) => number,
|
||||
delayMs = 300
|
||||
) {
|
||||
const initialTasks = untrack(() => [...sourceTasks()].sort(sortFn));
|
||||
const [displayedTasks, setDisplayedTasks] = createSignal<Task[]>(initialTasks);
|
||||
const [shakingTaskIds, setShakingTaskIds] = createSignal<string[]>([]);
|
||||
|
||||
let timer: number | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
const sorted = [...sourceTasks()].sort(sortFn);
|
||||
const currentDisplayed = untrack(() => displayedTasks());
|
||||
|
||||
// Initial load (though now handled by initial state, kept for safety if it resets)
|
||||
if (currentDisplayed.length === 0 && sorted.length > 0) {
|
||||
setDisplayedTasks(sorted);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if anything fundamentally changed order
|
||||
const newIds = sorted.map(t => t.id).join(',');
|
||||
const oldIds = currentDisplayed.map(t => t.id).join(',');
|
||||
|
||||
if (newIds !== oldIds) {
|
||||
// Find which IDs have moved or changed their presence
|
||||
// To be safe, let's just shake all currently visible ones, or maybe just the ones moving.
|
||||
// A simple "shake everything" works perfectly for a brief warning: "Items are shifting!"
|
||||
// The user wanted "tasks an animation when they rearange where they shake"
|
||||
const changedSet = new Set<string>();
|
||||
sorted.forEach((t: Task, i: number) => {
|
||||
if (currentDisplayed[i]?.id !== t.id) {
|
||||
changedSet.add(t.id);
|
||||
if (currentDisplayed[i]) changedSet.add(currentDisplayed[i].id);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedSet.size > 0) {
|
||||
setShakingTaskIds(Array.from(changedSet));
|
||||
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
setShakingTaskIds([]);
|
||||
setDisplayedTasks(sorted);
|
||||
}, delayMs);
|
||||
} else {
|
||||
setDisplayedTasks(sorted);
|
||||
}
|
||||
} else {
|
||||
// Identical order, just data updates (e.g. title changes)
|
||||
// We want these to pass through instantly without rearranging
|
||||
setDisplayedTasks(sorted);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
|
||||
return { displayedTasks, shakingTaskIds };
|
||||
}
|
||||
@@ -55,6 +55,40 @@
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-task-shake: task-shake 0.3s ease-in-out;
|
||||
|
||||
@keyframes task-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-task-enter: task-enter 0.25s ease-out backwards;
|
||||
|
||||
@keyframes task-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
+85
-31
@@ -30,7 +30,7 @@ export interface Task {
|
||||
tags: string[];
|
||||
ownerId: string | null; // Add ownerId to interface
|
||||
content?: string; // HTML content from Tiptap
|
||||
deletedAt?: number; // Timestamp of soft delete
|
||||
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
||||
created: string; // ISO string from PB
|
||||
updated: string; // ISO string from PB
|
||||
recurrence?: {
|
||||
@@ -202,6 +202,7 @@ interface TaskStore {
|
||||
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
@@ -227,7 +228,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
buckets: [],
|
||||
subscribedBuckets: [],
|
||||
notes: [],
|
||||
isNotepadMode: false
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: []
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
@@ -336,6 +338,38 @@ createRoot(() => {
|
||||
localStorage.setItem(key, JSON.stringify(store));
|
||||
}
|
||||
});
|
||||
|
||||
let debouncedSync: number | undefined;
|
||||
createEffect(() => {
|
||||
if (store.isInitializing || !pb.authStore.model?.id) return;
|
||||
|
||||
// Calculate Top 20 Tasks
|
||||
const incompleteTasks = store.tasks.filter(t =>
|
||||
!t.completed &&
|
||||
t.ownerId === pb.authStore.model?.id &&
|
||||
!t.tags?.includes("__template__") &&
|
||||
!t.deletedAt
|
||||
);
|
||||
|
||||
const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) }));
|
||||
scoredTasks.sort((a, b) => b.score - a.score);
|
||||
|
||||
const top20Ids = scoredTasks.slice(0, 20).map(t => t.id);
|
||||
|
||||
// Compare with current
|
||||
const currentIds = store.quickloadTasks || [];
|
||||
const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]);
|
||||
|
||||
if (isDifferent) {
|
||||
setStore("quickloadTasks", top20Ids);
|
||||
|
||||
// Debounce save by 10s
|
||||
if (debouncedSync) window.clearTimeout(debouncedSync);
|
||||
debouncedSync = window.setTimeout(() => {
|
||||
syncPreferences();
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Calibration --
|
||||
@@ -762,6 +796,45 @@ export const initStore = async () => {
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
setStore("isInitializing", true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
// STAGE 1: Instant Focus from Local Auth Model (Top 20 + Fallback)
|
||||
// Goal: < 100ms First Paint. Doesn't wait for any other network requests.
|
||||
if (currentUserId) {
|
||||
try {
|
||||
const prefs = pb.authStore.model?.Taskgrid_pref || {};
|
||||
const cachedQuickload = prefs.quickloadTasks || [];
|
||||
|
||||
let focusRecords;
|
||||
if (cachedQuickload.length > 0) {
|
||||
const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(' || ');
|
||||
const res = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `(${idFilter}) && deletedAt = ""`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
// Sort manually to match quickload order
|
||||
res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id));
|
||||
focusRecords = { items: res };
|
||||
} else {
|
||||
focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `user = "${currentUserId}" && completed = false && deletedAt = ""`,
|
||||
sort: '-priority,dueDate',
|
||||
requestKey: null
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out templates client-side to ensure tasks with empty/null tags aren't excluded by DB
|
||||
const focusTasks = focusRecords.items
|
||||
.map(mapRecordToTask)
|
||||
.filter(t => !t.tags?.includes("__template__"));
|
||||
|
||||
setStore("tasks", focusTasks);
|
||||
} catch (focusErr) {
|
||||
console.warn("Stage 1 load failed:", focusErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Synchronous hydration from localStorage for "instant" first paint
|
||||
const key = getStorageKey();
|
||||
if (key) {
|
||||
@@ -795,7 +868,8 @@ export const initStore = async () => {
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
prefId: userId,
|
||||
subscribedBuckets: user.subscribedBuckets || []
|
||||
subscribedBuckets: user.subscribedBuckets || [],
|
||||
quickloadTasks: prefs.quickloadTasks || []
|
||||
});
|
||||
}
|
||||
} catch (prefErr) {
|
||||
@@ -859,24 +933,6 @@ export const initStore = async () => {
|
||||
|
||||
// --- WINDOWED SYNC STRATEGY ---
|
||||
|
||||
// STAGE 1: Instant Focus (Top 30 Incomplete - User Only)
|
||||
// Goal: < 100ms First Paint. Uses 'idx_user_focused'.
|
||||
try {
|
||||
const focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, {
|
||||
filter: `user = "${currentUserId}" && completed = false && deletedAt = ""`,
|
||||
sort: '-priority,dueDate',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
// Filter out templates client-side to ensure tasks with empty/null tags aren't excluded by DB
|
||||
const focusTasks = focusRecords.items
|
||||
.map(mapRecordToTask)
|
||||
.filter(t => !t.tags?.includes("__template__"));
|
||||
|
||||
setStore("tasks", focusTasks);
|
||||
} catch (focusErr) {
|
||||
console.warn("Stage 1 load failed:", focusErr);
|
||||
}
|
||||
|
||||
// STAGE 2 & 3: Background Sync
|
||||
const backgroundSync = async () => {
|
||||
@@ -1387,16 +1443,13 @@ export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||
delete pbUpdates.ownerId;
|
||||
}
|
||||
|
||||
if (finalUpdates.deletedAt !== undefined) {
|
||||
// PB expects a date string or null for date fields usually,
|
||||
// but if we defined deletedAt as a 'date' field in PB.
|
||||
// If we defined it as a number in PB? User didn't specify.
|
||||
// Assuming it's a 'Date' field in PB based on previous conversation
|
||||
// "deletedAt | Date | No"
|
||||
if ("deletedAt" in finalUpdates) {
|
||||
// PB expects a date string or null for date fields.
|
||||
// Using finalUpdates.deletedAt check to distinguish between soft-delete (timestamp) and restore (null/falsy)
|
||||
if (finalUpdates.deletedAt) {
|
||||
pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString();
|
||||
} else {
|
||||
pbUpdates.deletedAt = null; // restore
|
||||
pbUpdates.deletedAt = null; // restore task
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,8 +1531,8 @@ export const removeTask = (id: string) => {
|
||||
|
||||
export const restoreTask = (id: string) => {
|
||||
// updateTask handles both optimistic update and PB sync
|
||||
// Pass undefined for deletedAt, updateTask will convert to null for PB
|
||||
updateTask(id, { deletedAt: undefined });
|
||||
// Pass null for deletedAt to explicitly clear it in PB
|
||||
updateTask(id, { deletedAt: null });
|
||||
};
|
||||
|
||||
export const deleteTaskPermanently = async (id: string) => {
|
||||
@@ -1956,7 +2009,8 @@ export const syncPreferences = async () => {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions
|
||||
tagDefinitions: store.tagDefinitions,
|
||||
quickloadTasks: store.quickloadTasks
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
|
||||
+25
-12
@@ -1,15 +1,24 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const CriticalView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -19,13 +28,17 @@ export const CriticalView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">✨</span>
|
||||
|
||||
+30
-17
@@ -1,20 +1,29 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const DigInView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size descending (largest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeB - sizeA;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size descending (largest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeB - sizeA;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -24,13 +33,17 @@ export const DigInView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">⛏️</span>
|
||||
|
||||
+113
-65
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { store, renameTagDefinition } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { type Note } from "@/store";
|
||||
import { Trash2, Lock, Unlock, Search, Link, X } from "lucide-solid";
|
||||
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskEditor } from "@/components/TaskEditor";
|
||||
@@ -17,6 +17,7 @@ export const NotepadView: Component<{
|
||||
}> = (props) => {
|
||||
const [isLinking, setIsLinking] = createSignal(false);
|
||||
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
||||
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(true);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
@@ -173,15 +174,7 @@ export const NotepadView: Component<{
|
||||
return (
|
||||
<>
|
||||
{/* Editor Area */}
|
||||
<div class="flex-none md:flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative p-4 md:p-6 space-y-4 md:space-y-6 scroll-smooth">
|
||||
{/* Mobile Back Button */}
|
||||
<Show when={!props.hideNavigation}>
|
||||
<div class="md:hidden mt-2 md:mt-0 mb-2 md:mb-0">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" onClick={() => props.setSelectedNoteId(null)}>
|
||||
← Back to Notes
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative p-4 md:p-6 space-y-4 md:space-y-6 scroll-smooth">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<input
|
||||
@@ -224,46 +217,99 @@ export const NotepadView: Component<{
|
||||
</div>
|
||||
|
||||
{/* Editor Instance */}
|
||||
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm">
|
||||
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
||||
<TaskEditor
|
||||
content={note().content}
|
||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||
editable={canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close button (Mobile only) */}
|
||||
<Show when={!props.hideNavigation}>
|
||||
<div class="md:hidden flex justify-end pb-4 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 px-3 gap-2 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40"
|
||||
onClick={() => props.setSelectedNoteId(null)}
|
||||
>
|
||||
<X size={16} />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Linked Tasks Sidebar */}
|
||||
<div class="w-full md:w-80 lg:w-96 border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col h-auto md:h-full shrink-0">
|
||||
<div class="p-4 border-b border-border bg-card/50 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Link size={12} />
|
||||
Linked Tasks
|
||||
</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={() => setIsLinking(!isLinking())}
|
||||
title="Link Existing Task"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
||||
onClick={openQuickEntry}
|
||||
title="Create Task via Quick Entry"
|
||||
>
|
||||
+ Task
|
||||
</Button>
|
||||
<div class={cn(
|
||||
"border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col shrink-0 transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isLinkedTasksOpen() ? "w-full md:w-80 lg:w-96 h-auto md:h-full" : "w-full md:w-12 h-12 md:h-full cursor-pointer hover:bg-muted/10"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isLinkedTasksOpen()) {
|
||||
setIsLinkedTasksOpen(true);
|
||||
}
|
||||
}}>
|
||||
<div class={cn("p-4 border-b border-border bg-card/50 flex flex-col gap-3", !isLinkedTasksOpen() && "items-center justify-center p-0 h-full md:py-4")}>
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinkedTasksOpen(!isLinkedTasksOpen());
|
||||
}}
|
||||
>
|
||||
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
||||
<ChevronRight size={14} class={cn("text-muted-foreground transition-all duration-300 group-hover:text-foreground", !isLinkedTasksOpen() ? "rotate-90 md:rotate-0" : "rotate-90 md:rotate-180")} />
|
||||
<Show when={isLinkedTasksOpen()} fallback={
|
||||
<div class="hidden md:flex items-center justify-center -rotate-90 origin-center whitespace-nowrap mt-8 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||
<Link size={10} class="mr-2 rotate-90" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
}>
|
||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Link size={12} />
|
||||
Linked Tasks
|
||||
</h3>
|
||||
</Show>
|
||||
<Show when={!isLinkedTasksOpen()}>
|
||||
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Link size={12} class="mr-2" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinking(!isLinking());
|
||||
}}
|
||||
title="Link Existing Task"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openQuickEntry();
|
||||
}}
|
||||
title="Create Task via Quick Entry"
|
||||
>
|
||||
+ Task
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isLinking()}>
|
||||
<Show when={isLinkedTasksOpen() && isLinking()}>
|
||||
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
|
||||
<div class="relative">
|
||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
||||
@@ -292,34 +338,36 @@ export const NotepadView: Component<{
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<For each={linkedTasks()} fallback={
|
||||
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
|
||||
<Link size={24} class="opacity-20" />
|
||||
<p>No tasks linked yet.</p>
|
||||
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
|
||||
</div>
|
||||
}>
|
||||
{(task) => (
|
||||
<div class="relative group">
|
||||
<TaskCard task={task} />
|
||||
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
|
||||
<Show when={note().tasks && note().tasks.includes(task.id)}>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlinkTask(task.id);
|
||||
}}
|
||||
title="Unlink Task"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<For each={linkedTasks()} fallback={
|
||||
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
|
||||
<Link size={24} class="opacity-20" />
|
||||
<p>No tasks linked yet.</p>
|
||||
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}>
|
||||
{(task) => (
|
||||
<div class="relative group">
|
||||
<TaskCard task={task} />
|
||||
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
|
||||
<Show when={note().tasks && note().tasks.includes(task.id)}>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlinkTask(task.id);
|
||||
}}
|
||||
title="Unlink Task"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+25
-13
@@ -1,16 +1,24 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const PriorityView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Use combined score to account for priority, urgency, and tags
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -20,13 +28,17 @@ export const PriorityView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">🎯</span>
|
||||
|
||||
+30
-17
@@ -1,22 +1,31 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const ProgressView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
// 1. Completed tasks at the bottom
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
// 2. Sort by Progress (Status) Descending (Highest progress first)
|
||||
// Assuming status 0-9 are active states, 10 is completed (handled above)
|
||||
if (a.status !== b.status) return b.status - a.status;
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
// 1. Completed tasks at the bottom
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
|
||||
// 3. Then by Focus Score (Combined Score)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
// 2. Sort by Progress (Status) Descending (Highest progress first)
|
||||
// Assuming status 0-9 are active states, 10 is completed (handled above)
|
||||
if (a.status !== b.status) return b.status - a.status;
|
||||
|
||||
// 3. Then by Focus Score (Combined Score)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -26,13 +35,17 @@ export const ProgressView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">📈</span>
|
||||
|
||||
+30
-17
@@ -1,20 +1,29 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const SnowballView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size ascending (smallest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeA - sizeB;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
// Primary: Size ascending (smallest first)
|
||||
const sizeA = a.size ?? 5;
|
||||
const sizeB = b.size ?? 5;
|
||||
if (sizeA !== sizeB) return sizeA - sizeB;
|
||||
// Secondary: Combined score descending (higher focus first)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -24,13 +33,17 @@ export const SnowballView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">❄️</span>
|
||||
|
||||
+29
-16
@@ -1,19 +1,28 @@
|
||||
import { type Component, For, createMemo } from "solid-js";
|
||||
import { type Component, For, createMemo, onMount } from "solid-js";
|
||||
import { store, calculateUrgencyScore, getCombinedScore, matchesFilter } from "@/store";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { useDelayedSort } from "@/hooks/useDelayedSort";
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
|
||||
export const UrgencyView: Component = () => {
|
||||
const sortedTasks = createMemo(() => {
|
||||
return [...store.tasks]
|
||||
.filter(t => !t.deletedAt && matchesFilter(t))
|
||||
.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
if (uA !== uB) return uB - uA;
|
||||
// Tie-break with combined score (Priority + Tags)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
});
|
||||
const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
|
||||
|
||||
const { displayedTasks, shakingTaskIds } = useDelayedSort(
|
||||
sourceTasks,
|
||||
(a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const uA = calculateUrgencyScore(a.dueDate);
|
||||
const uB = calculateUrgencyScore(b.dueDate);
|
||||
if (uA !== uB) return uB - uA;
|
||||
// Tie-break with combined score (Priority + Tags)
|
||||
return getCombinedScore(b) - getCombinedScore(a);
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
let listRef: HTMLDivElement | undefined;
|
||||
onMount(() => {
|
||||
if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -23,13 +32,17 @@ export const UrgencyView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={sortedTasks()}>
|
||||
{(task) => <TaskCard task={task} />}
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{sortedTasks().length === 0 && (
|
||||
{displayedTasks().length === 0 && (
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<span class="text-2xl">⏳</span>
|
||||
|
||||
Reference in New Issue
Block a user